Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6035,6 +6035,8 @@ function ChatViewContent(props: ChatViewProps) {
activeThreadId={activeThread.id}
{...(routeKind === "draft" && draftId ? { draftId } : {})}
activeThreadTitle={activeThread.title}
isServerThread={isServerThread}
changeRequestState={activeThreadPr?.state ?? null}
activeProjectName={activeProject?.title}
activeProjectCwd={activeProject?.workspaceRoot ?? null}
openInCwd={gitCwd}
Expand Down
72 changes: 16 additions & 56 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import {
import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";
import type { SidebarThreadSummary } from "../types";
import { cn } from "~/lib/utils";
import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
import {
buildBulkTitleRegenerationContextMenuItem,
formatWorkingDurationLabel,
Expand Down Expand Up @@ -2539,62 +2540,21 @@ export default function SidebarV2() {
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
const clicked = await settlePromise(() =>
api.contextMenu.show(
[
...(thread.branch
? [
{
id: "new-thread-on-branch",
label: `New thread on ${thread.branch}`,
},
]
: []),
...(supportsPinning
? [
isPinned
? { id: "unpin", label: "Unpin thread" }
: { id: "pin", label: "Pin thread" },
]
: []),
// Both lifecycle actions stay available on pinned threads:
// settling clears the pin ("done" beats "keep on top"), and
// snoozing hides the card until wake with the pin intact.
...(supportsSettlement
? [
isSettled
? { id: "unsettle", label: "Un-settle thread" }
: { id: "settle", label: "Settle thread" },
]
: []),
...(supportsSnooze
? [
isSnoozed
? { id: "unsnooze", label: "Wake thread" }
: {
id: "snooze",
label: "Snooze",
disabled: !canSnooze(thread, { now: new Date().toISOString() }),
children: snoozePresets.map((preset) => ({
id: `snooze:${preset.id}`,
label: `${preset.label} (${preset.whenLabel})`,
})),
},
]
: []),
{ id: "rename", label: "Rename thread" },
...(supportsTitleRegeneration
? [
{
id: "regenerate-title",
label: isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
disabled: isRegeneratingTitle,
},
]
: []),
{ id: "mark-unread", label: "Mark unread" },
{ id: "copy-path", label: "Copy path", icon: "copy" },
...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []),
{ id: "delete", label: "Delete", destructive: true, icon: "trash" },
],
buildThreadActionMenuItems({
branch: thread.branch ?? null,
isPinned,
isSettled,
isSnoozed,
canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),
isRegeneratingTitle,
supports: {
settlement: supportsSettlement,
snooze: supportsSnooze,
pinning: supportsPinning,
titleRegeneration: supportsTitleRegeneration,
},
snoozePresets,
}),
position,
),
);
Expand Down
23 changes: 22 additions & 1 deletion apps/web/src/components/chat/ChatHeader.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { shouldShowOpenInPicker } from "./ChatHeader";
import { resolveRenameCommit, shouldShowOpenInPicker } from "./ChatHeader";

describe("shouldShowOpenInPicker", () => {
const primaryEnvironmentId = EnvironmentId.make("environment-primary");
Expand Down Expand Up @@ -46,3 +46,24 @@ describe("shouldShowOpenInPicker", () => {
).toBe(false);
});
});

describe("resolveRenameCommit", () => {
it("commits a trimmed changed title", () => {
expect(resolveRenameCommit({ title: " New title ", originalTitle: "Old" })).toEqual({
action: "commit",
title: "New title",
});
});

it("rejects empty and whitespace-only titles", () => {
expect(resolveRenameCommit({ title: " ", originalTitle: "Old" })).toEqual({
action: "reject-empty",
});
});

it("no-ops when the trimmed title is unchanged", () => {
expect(resolveRenameCommit({ title: " Old ", originalTitle: "Old" })).toEqual({
action: "noop",
});
});
});
186 changes: 173 additions & 13 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,35 @@ import {
type ThreadId,
} from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { memo } from "react";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import type { ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled";
import { ChevronDownIcon } from "lucide-react";
import {
memo,
useCallback,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from "react";
import GitActionsControl from "../GitActionsControl";
import { type DraftId } from "~/composerDraftStore";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { toastManager } from "../ui/toast";
import ProjectScriptsControl, {
type NewProjectScriptInput,
type ProjectScriptActionResult,
} from "../ProjectScriptsControl";
import { OpenInPicker } from "./OpenInPicker";
import { usePrimaryEnvironmentId } from "../../state/environments";
import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";
import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { ProjectFavicon } from "../ProjectFavicon";
import { cn } from "~/lib/utils";

Expand All @@ -25,6 +43,10 @@ interface ChatHeaderProps {
activeThreadId: ThreadId;
draftId?: DraftId;
activeThreadTitle: string;
/** Drafts have no server thread yet, so the title carries no action menu. */
isServerThread: boolean;
/** PR state feeding the settled classification, resolved by ChatView. */
changeRequestState: ChangeRequestStateLike | null;
activeProjectName: string | undefined;
activeProjectCwd: string | null;
openInCwd: string | null;
Expand All @@ -44,6 +66,20 @@ interface ChatHeaderProps {
onDeleteProjectScript: (scriptId: string) => Promise<ProjectScriptActionResult>;
}

/**
* Rename commit rule shared with the sidebar's inline rename: trim, reject
* empty (the caller toasts), and skip the mutation when nothing changed.
*/
export function resolveRenameCommit(input: {
readonly title: string;
readonly originalTitle: string;
}): { action: "commit"; title: string } | { action: "reject-empty" } | { action: "noop" } {
const trimmed = input.title.trim();
if (trimmed.length === 0) return { action: "reject-empty" };
if (trimmed === input.originalTitle) return { action: "noop" };
return { action: "commit", title: trimmed };
}

export function shouldShowOpenInPicker(input: {
readonly activeProjectName: string | undefined;
readonly activeThreadEnvironmentId: EnvironmentId;
Expand All @@ -61,6 +97,8 @@ export const ChatHeader = memo(function ChatHeader({
activeThreadId,
draftId,
activeThreadTitle,
isServerThread,
changeRequestState,
activeProjectName,
activeProjectCwd,
openInCwd,
Expand All @@ -86,8 +124,91 @@ export const ChatHeader = memo(function ChatHeader({
activeThreadEnvironmentId,
primaryEnvironmentId,
});
const activeThreadRef = useMemo(
() => scopeThreadRef(activeThreadEnvironmentId, activeThreadId),
[activeThreadEnvironmentId, activeThreadId],
);
const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
reportFailure: false,
});
// Inline rename, keyed by thread: navigating away drops an in-progress
// rename instead of committing stale text. Cleared on thread change (not
// just hidden) so returning to the thread doesn't revive the old draft.
const [renaming, setRenaming] = useState<{ threadId: ThreadId; title: string } | null>(null);
if (renaming !== null && renaming.threadId !== activeThreadId) {
setRenaming(null);
}
const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const renameCommittedRef = useRef(false);
const startRename = useCallback(() => {
renameCommittedRef.current = false;
setRenaming({ threadId: activeThreadId, title: activeThreadTitle });
}, [activeThreadId, activeThreadTitle]);
const commitRename = useCallback(
(title: string) => {
setRenaming(null);
const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle });
if (resolution.action === "reject-empty") {
toastManager.add({ type: "warning", title: "Thread title cannot be empty" });
return;
}
if (resolution.action === "noop") return;
void updateThreadMetadata({
environmentId: activeThreadEnvironmentId,
input: { threadId: activeThreadId, title: resolution.title },
}).then((result) => {
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add({
type: "error",
title: "Failed to rename thread",
description: error instanceof Error ? error.message : "An error occurred.",
});
}
});
},
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
[activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata],
);
const { openMenu } = useThreadActionMenu({
threadRef: isServerThread ? activeThreadRef : null,
projectCwd: activeProjectCwd,
changeRequestState,
onStartRename: startRename,
});
const titleButtonRef = useRef<HTMLButtonElement | null>(null);
const openMenuFromTitle = useCallback(() => {
const rect = titleButtonRef.current?.getBoundingClientRect();
if (!rect) return;
openMenu({ x: rect.left, y: rect.bottom + 4 });
}, [openMenu]);
const handleHeaderContextMenu = useCallback(
(event: ReactMouseEvent) => {
if (!isServerThread || renamingTitle !== null) return;
// The right-side controls (git, scripts, open-in) keep their own
// behavior; only the breadcrumb area opens the thread menu.
if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) return;
event.preventDefault();
openMenu({ x: event.clientX, y: event.clientY });
},
[isServerThread, openMenu, renamingTitle],
);
const handleRenameKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
renameCommittedRef.current = true;
commitRename(event.currentTarget.value);
} else if (event.key === "Escape") {
renameCommittedRef.current = true;
setRenaming(null);
}
},
[commitRename],
);
return (
<div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
<div
className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"
onContextMenu={handleHeaderContextMenu}
>
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
{/* The project always leads the header: knowing which project a
thread lives in is priority zero, and the thread title alone
Expand Down Expand Up @@ -119,19 +240,58 @@ export const ChatHeader = memo(function ChatHeader({
</span>
</span>
) : null}
<Tooltip>
<TooltipTrigger
render={
<h2
aria-label={activeThreadTitle}
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"
>
{renamingTitle !== null ? (
<input
autoFocus
aria-label="Thread title"
className="min-w-0 flex-1 rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-ring/50 focus:ring-ring"
defaultValue={renamingTitle}
onBlur={(event) => {
if (renameCommittedRef.current) return;
commitRename(event.currentTarget.value);
}}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={handleRenameKeyDown}
/>
) : isServerThread ? (
<Tooltip>
<TooltipTrigger
render={
<button
ref={titleButtonRef}
type="button"
aria-label={`Thread actions for ${activeThreadTitle}`}
aria-haspopup="menu"
onClick={openMenuFromTitle}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
className="group/thread-title inline-flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
{activeThreadTitle}
</h2>
}
/>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
<ChevronDownIcon
aria-hidden
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/thread-title:opacity-100 group-focus-visible/thread-title:opacity-100"
/>
</TooltipTrigger>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger
render={
<h2
aria-label={activeThreadTitle}
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"
>
{activeThreadTitle}
</h2>
}
/>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
)}
</div>
<div
data-chat-header-actions
Expand Down
Loading
Loading