Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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