From 113363f53f7a4e6f4e485e1cd68734929acf9045 Mon Sep 17 00:00:00 2001 From: Matt Stone Date: Mon, 19 Oct 2026 13:39:02 +1100 Subject: [PATCH 1/2] feat(web): command palette runs the current thread's menu actions The thread right-click menu (snooze, wake, settle, pin, rename, archive, delete and the rest) had no command palette entry. The palette now builds its entries from the same menu items and dispatch as the thread menu, so the two stay in step. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/CommandPalette.tsx | 126 ++++++ apps/web/src/components/chat/ChatHeader.tsx | 2 + .../components/chat/threadRenameActionBus.ts | 10 + apps/web/src/hooks/useThreadActionMenu.ts | 404 +++++++++--------- scripts/effect-budget.json | 2 +- 5 files changed, 352 insertions(+), 192 deletions(-) create mode 100644 apps/web/src/components/chat/threadRenameActionBus.ts diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 7951655a2daf5..840c983b4734b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -39,6 +39,7 @@ import { AuthSourceControlWriteScope, AuthFilesystemReadScope, type DesktopWslState, + type ContextMenuItem, type EnvironmentId, type FilesystemBrowseResult, SourceControlProviderKind, @@ -65,8 +66,25 @@ import { SquarePenIcon, TextSearchIcon, UserPlusIcon, + ArchiveIcon, + CircleCheckIcon, + ClockIcon, + CopyIcon, + FolderTreeIcon, + GitBranchIcon, + HashIcon, + MailOpenIcon, + MessageSquarePlusIcon, + PencilIcon, + PinIcon, + PinOffIcon, + RefreshCwIcon, + TimerIcon, + TrashIcon, + type LucideIcon, } from "lucide-react"; import { requestThreadFindOpen } from "./chat/threadFindActionBus"; +import { requestThreadRenameStart } from "./chat/threadRenameActionBus"; import { useDeferredValue, useEffect, @@ -84,6 +102,8 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useThreadActionMenu } from "../hooks/useThreadActionMenu"; +import type { ThreadActionMenuId } from "./threadActionMenu.logic"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { @@ -240,6 +260,97 @@ import { threadRecentUse } from "@t3tools/shared/paletteSearch"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +const THREAD_ACTION_ICONS: Record = { + archive: ArchiveIcon, + "circle-check": CircleCheckIcon, + clock: ClockIcon, + copy: CopyIcon, + folder: FolderIcon, + "folder-tree": FolderTreeIcon, + "git-branch": GitBranchIcon, + hash: HashIcon, + "mail-open": MailOpenIcon, + "message-square-plus": MessageSquarePlusIcon, + pencil: PencilIcon, + pin: PinIcon, + "pin-off": PinOffIcon, + "refresh-cw": RefreshCwIcon, + settings: SettingsIcon, + timer: TimerIcon, + trash: TrashIcon, +}; + +// The context menu opens on a thread, so its terse labels need the target named once they stand alone in the palette. +const THREAD_ACTION_PALETTE_TITLES: Partial> = { + snooze: "Snooze thread", + "mark-unread": "Mark thread unread", + copy: "Copy thread details", + delete: "Delete thread", +}; + +function buildThreadMenuPaletteItems( + items: ReadonlyArray>, + runAction: (action: ThreadActionMenuId) => Promise, +): Array { + return items.flatMap((item) => { + // The palette already lists project settings and guest sharing for the current thread. + if (item.id === "project-settings" || item.id === "share-guest") return []; + const title = THREAD_ACTION_PALETTE_TITLES[item.id] ?? item.label; + const Icon = item.icon ? THREAD_ACTION_ICONS[item.icon] : undefined; + const icon = Icon ? : null; + const searchTerms = [title, item.label, "thread"]; + if (!item.children) { + return [ + { + kind: "action", + value: `thread-action:${item.id}`, + searchTerms, + title, + icon, + ...(item.disabled ? { disabled: true } : {}), + run: () => runAction(item.id), + }, + ]; + } + return [ + { + kind: "submenu", + value: `thread-action:${item.id}`, + searchTerms, + title, + icon, + addonIcon: Icon ? : null, + ...(item.disabled ? { disabled: true } : {}), + groups: [ + { + value: `thread-action:${item.id}`, + label: title, + items: item.children.map((child) => { + const ChildIcon = (child.icon ? THREAD_ACTION_ICONS[child.icon] : undefined) ?? Icon; + return { + kind: "action", + value: `thread-action:${child.id}`, + searchTerms: [child.label], + title: child.label, + icon: ChildIcon ? : null, + ...(child.disabled ? { disabled: true } : {}), + ...(child.checked + ? { + titleTrailingContent: ( + Current + ), + } + : {}), + run: () => runAction(child.id), + }; + }), + }, + ], + }, + ]; + }); +} + export function CommandPalette({ children }: { children: ReactNode }) { const navigate = useNavigate(); const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); @@ -728,6 +839,15 @@ function OpenCommandPaletteDialog(props: { }; const { projectCwdById, projectByKey, projectTitleById } = useProjectLookups(); + const activeThreadActionMenu = useThreadActionMenu({ + threadRef: activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null, + projectCwd: activeThread + ? (projectByKey.get(`${activeThread.environmentId}:${activeThread.projectId}`) + ?.workspaceRoot ?? null) + : null, + onStartRename: requestThreadRenameStart, + canShareWithGuest: canShareActiveThreadWithGuest, + }); const activeThreadId = activeThread?.id; const supportsThreadFind = @@ -1415,6 +1535,12 @@ function OpenCommandPaletteDialog(props: { if (refreshed._tag === "Failure") throw squashAtomCommandFailure(refreshed); }, }); + const { items: threadMenuItems, snoozePresets } = activeThreadActionMenu.buildMenu(thread); + actionItems.push( + ...buildThreadMenuPaletteItems(threadMenuItems, (action) => + activeThreadActionMenu.runAction(action, snoozePresets), + ), + ); } actionItems.push({ diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 32028c06af66f..f2b5187d809af 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -19,6 +19,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import { isTrailingDoubleClick } from "../Sidebar.logic"; +import { subscribeThreadRenameStart } from "./threadRenameActionBus"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; @@ -195,6 +196,7 @@ export const ChatHeader = memo(function ChatHeader({ onStartRename: startRename, canShareWithGuest, }); + useEffect(() => subscribeThreadRenameStart(startRename), [startRename]); const titleButtonRef = useRef(null); const titleMenuTimerRef = useRef(null); const cancelPendingTitleMenu = () => { diff --git a/apps/web/src/components/chat/threadRenameActionBus.ts b/apps/web/src/components/chat/threadRenameActionBus.ts new file mode 100644 index 0000000000000..689df21c95271 --- /dev/null +++ b/apps/web/src/components/chat/threadRenameActionBus.ts @@ -0,0 +1,10 @@ +const THREAD_RENAME_START_EVENT = "t3:thread-rename-start"; + +export function requestThreadRenameStart(): void { + window.dispatchEvent(new Event(THREAD_RENAME_START_EVENT)); +} + +export function subscribeThreadRenameStart(listener: () => void): () => void { + window.addEventListener(THREAD_RENAME_START_EVENT, listener); + return () => window.removeEventListener(THREAD_RENAME_START_EVENT, listener); +} diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 0804fc007e1a6..5d41f9fa0219d 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -1,13 +1,18 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { requestCustomSnooze } from "../components/CustomSnoozeDialog"; import { openGuestShareDialog } from "../components/chat/guestShareDialogState"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { type AtomCommandResult, isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { + canSnooze, + effectiveSnoozed, + type SnoozePreset, +} from "@t3tools/client-runtime/state/thread-settled"; import { AuthOrchestrationOperateScope, type ScopedThreadRef, @@ -113,213 +118,230 @@ export function useThreadActionMenu(input: { onError: (error) => failureToast("Failed to copy thread ID", error), }); - const openMenu = (position: { x: number; y: number }) => { + const buildMenu = (thread: EnvironmentThreadShell) => { + const now = new Date(); + const supports = { + settlement: readEnvironmentSupportsSettlement(thread.environmentId), + autoSettleOptOut: readEnvironmentSupportsAutoSettleOptOut(thread.environmentId), + snooze: readEnvironmentSupportsSnooze(thread.environmentId), + pinning: readEnvironmentSupportsPinning(thread.environmentId), + titleRegeneration: readEnvironmentSupportsTitleRegeneration(thread.environmentId), + }; + const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const items = buildThreadActionMenuItems({ + canOperate: readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope), + branch: thread.branch ?? null, + projectFilter: null, + isPinned: thread.pinnedAt != null, + isSettled: supports.settlement && thread.settledOverride === "settled", + autoSettleEnabled: thread.autoSettleDisabledAt == null, + isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), + canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), + isRegeneratingTitle: thread.titleRegeneration != null, + isRunning: !threadRuntimeCanArchive(thread.runtime), + supports, + snoozePresets, + canShareWithGuest, + }); + return { items, snoozePresets }; + }; + + // Snooze choices resolve against the presets the menu showed, not a later recomputation. + const runAction = async ( + action: ThreadActionMenuId, + snoozePresets: ReadonlyArray, + ) => { if (threadRef === null) return; - void (async () => { - const api = readLocalApi(); - if (!api) return; - const thread = readThreadShell(threadRef); - if (!thread) return; - const now = new Date(); - const supports = { - settlement: readEnvironmentSupportsSettlement(threadRef.environmentId), - autoSettleOptOut: readEnvironmentSupportsAutoSettleOptOut(threadRef.environmentId), - snooze: readEnvironmentSupportsSnooze(threadRef.environmentId), - pinning: readEnvironmentSupportsPinning(threadRef.environmentId), - titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), - }; - const isRegeneratingTitle = thread.titleRegeneration != null; - const snoozePresets = resolveSnoozePresets(now, timestampFormat); - const items = buildThreadActionMenuItems({ - canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), - branch: thread.branch ?? null, - projectFilter: null, - isPinned: thread.pinnedAt != null, - isSettled: supports.settlement && thread.settledOverride === "settled", - autoSettleEnabled: thread.autoSettleDisabledAt == null, - isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), - canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), - isRegeneratingTitle, - isRunning: !threadRuntimeCanArchive(thread.runtime), - supports, - snoozePresets, - canShareWithGuest, - }); - const clicked = await settlePromise(() => api.contextMenu.show(items, position)); - if (clicked._tag === "Failure" || clicked.value === null) return; - const action: ThreadActionMenuId = clicked.value; - if ( - threadActionRequiresOperate(action) && - !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) - ) { - failureToast( - "Thread action unavailable", - new Error("This connection cannot change threads."), + const api = readLocalApi(); + if (!api) return; + const thread = readThreadShell(threadRef); + if (!thread) return; + const isRegeneratingTitle = thread.titleRegeneration != null; + if ( + threadActionRequiresOperate(action) && + !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) + ) { + failureToast( + "Thread action unavailable", + new Error("This connection cannot change threads."), + ); + return; + } + if (action.startsWith("snooze:")) { + const preset = + action === "snooze:custom" + ? await requestCustomSnooze() + : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); + if (!preset) return; + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); + } + return; + } + const reportFailure = async ( + title: string, + run: () => Promise>, + ) => { + const result = await run(); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast(title, squashAtomCommandFailure(result)); + } + }; + switch (action) { + case "project-settings": { + const project = projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.projectId, ); + if (!project) return; + const projectKey = + logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey }, + }); return; } - if (action.startsWith("snooze:")) { - const preset = - action === "snooze:custom" - ? await requestCustomSnooze() - : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); - if (!preset) return; - const result = await snoozeThread(threadRef, preset.snoozedUntil); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); + case "new-thread-on-branch": { + const result = await settlePromise(() => + handleNewThread(scopeProjectRef(threadRef.environmentId, thread.projectId), { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }), + ); + if (result._tag === "Failure") { + failureToast("Could not create thread", squashAtomCommandFailure(result)); } return; } - const reportFailure = async ( - title: string, - run: () => Promise>, - ) => { - const result = await run(); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - failureToast(title, squashAtomCommandFailure(result)); - } - }; - switch (action) { - case "project-settings": { - const project = projects.find( - (candidate) => - candidate.environmentId === thread.environmentId && candidate.id === thread.projectId, - ); - if (!project) return; - const projectKey = - logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? - deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); - void router.navigate({ - to: "/projects/$projectKey", - params: { projectKey }, - }); - return; - } - case "new-thread-on-branch": { - const result = await settlePromise(() => - handleNewThread(scopeProjectRef(threadRef.environmentId, thread.projectId), { - branch: thread.branch, - worktreePath: thread.worktreePath, - envMode: thread.worktreePath ? "worktree" : "local", - startFromOrigin: false, + case "settle": + await reportFailure("Failed to settle thread", () => settleThread(threadRef)); + return; + case "unsettle": + await reportFailure("Failed to un-settle thread", () => unsettleThread(threadRef)); + return; + case "unsnooze": + await reportFailure("Failed to wake thread", () => unsnoozeThread(threadRef)); + return; + case "pin": + await reportFailure("Failed to pin thread", () => pinThread(threadRef)); + return; + case "unpin": { + await reportFailure("Failed to unpin thread", () => confirmAndUnpinThread(threadRef)); + return; + } + case "auto-settle:enabled": + case "auto-settle:disabled": + await reportFailure("Failed to update auto-settle", () => + setThreadAutoSettle(threadRef, action === "auto-settle:enabled"), + ); + return; + case "rename": + onStartRename(); + return; + case "regenerate-title": + if (isRegeneratingTitle) return; + await reportFailure("Failed to regenerate thread title", () => + updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, regenerateTitle: true }, + }), + ); + return; + case "mark-unread": + markThreadUnread(threadRef); + return; + case "share-guest": + openGuestShareDialog(threadRef); + return; + case "copy-path": { + const workspacePath = thread.worktreePath ?? projectCwd; + if (!workspacePath) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", }), ); - if (result._tag === "Failure") { - failureToast("Could not create thread", squashAtomCommandFailure(result)); - } return; } - case "settle": - await reportFailure("Failed to settle thread", () => settleThread(threadRef)); - return; - case "unsettle": - await reportFailure("Failed to un-settle thread", () => unsettleThread(threadRef)); - return; - case "unsnooze": - await reportFailure("Failed to wake thread", () => unsnoozeThread(threadRef)); - return; - case "pin": - await reportFailure("Failed to pin thread", () => pinThread(threadRef)); - return; - case "unpin": { - await reportFailure("Failed to unpin thread", () => confirmAndUnpinThread(threadRef)); - return; + copyPathToClipboard(workspacePath, { path: workspacePath }); + return; + } + case "copy-branch": + if (thread.branch) { + copyBranchToClipboard(thread.branch, { branch: thread.branch }); } - case "auto-settle:enabled": - case "auto-settle:disabled": - await reportFailure("Failed to update auto-settle", () => - setThreadAutoSettle(threadRef, action === "auto-settle:enabled"), + return; + case "copy-thread-id": + copyThreadIdToClipboard(thread.id, { threadId: thread.id }); + return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), ); - return; - case "rename": - onStartRename(); - return; - case "regenerate-title": - if (isRegeneratingTitle) return; - await reportFailure("Failed to regenerate thread title", () => - updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, regenerateTitle: true }, - }), + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast( + didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread", + squashAtomCommandFailure(result), ); - return; - case "mark-unread": - markThreadUnread(threadRef); - return; - case "share-guest": - openGuestShareDialog(threadRef); - return; - case "copy-path": { - const workspacePath = thread.worktreePath ?? projectCwd; - if (!workspacePath) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Path unavailable", - description: "This thread does not have a workspace path to copy.", - }), - ); - return; - } - copyPathToClipboard(workspacePath, { path: workspacePath }); - return; } - case "copy-branch": - if (thread.branch) { - copyBranchToClipboard(thread.branch, { branch: thread.branch }); - } - return; - case "copy-thread-id": - copyThreadIdToClipboard(thread.id, { threadId: thread.id }); - return; - case "archive": { - if (confirmThreadArchive) { - const confirmed = await settlePromise(() => - api.dialogs.confirm(`Archive thread "${thread.title}"?`), - ); - if (confirmed._tag === "Failure" || !confirmed.value) return; - } - let didArchive = false; - const result = await archiveThread(threadRef, { - onArchived: () => { - didArchive = true; - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - failureToast( - didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread", - squashAtomCommandFailure(result), - ); - } - return; + return; + } + case "delete": { + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + { variant: "destructive" }, + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; } - case "delete": { - if (confirmThreadDelete) { - const confirmed = await settlePromise(() => - api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - { variant: "destructive" }, - ), - ); - if (confirmed._tag === "Failure" || !confirmed.value) return; - } - const deleted = await deleteThread(threadRef); - if ( - deleted._tag === "Failure" && - !isAtomCommandInterrupted(deleted) && - // A missing thread means cleanup failed after deletion; deleteThread already toasted that failure. - readThreadShell(threadRef) !== null - ) { - failureToast("Failed to delete thread", squashAtomCommandFailure(deleted)); - } - return; + const deleted = await deleteThread(threadRef); + if ( + deleted._tag === "Failure" && + !isAtomCommandInterrupted(deleted) && + // A missing thread means cleanup failed after deletion; deleteThread already toasted that failure. + readThreadShell(threadRef) !== null + ) { + failureToast("Failed to delete thread", squashAtomCommandFailure(deleted)); } - default: - return; + return; } + default: + return; + } + }; + + const openMenu = (position: { x: number; y: number }) => { + if (threadRef === null) return; + void (async () => { + const api = readLocalApi(); + if (!api) return; + const thread = readThreadShell(threadRef); + if (!thread) return; + const { items, snoozePresets } = buildMenu(thread); + const clicked = await settlePromise(() => api.contextMenu.show(items, position)); + if (clicked._tag === "Failure" || clicked.value === null) return; + await runAction(clicked.value, snoozePresets); })(); }; @@ -327,5 +349,5 @@ export function useThreadActionMenu(input: { void readLocalApi()?.contextMenu.close(); }; - return { openMenu, closeMenu }; + return { openMenu, closeMenu, buildMenu, runAction }; } diff --git a/scripts/effect-budget.json b/scripts/effect-budget.json index 44c55fb275201..ceac5ec906bd6 100644 --- a/scripts/effect-budget.json +++ b/scripts/effect-budget.json @@ -226,7 +226,7 @@ "apps/web/src/components/chat/ChangedFilesTree.tsx": 1, "apps/web/src/components/chat/ChatCanvas.tsx": 1, "apps/web/src/components/chat/ChatComposer.tsx": 26, - "apps/web/src/components/chat/ChatHeader.tsx": 2, + "apps/web/src/components/chat/ChatHeader.tsx": 3, "apps/web/src/components/chat/ChatMarkdownMedia.tsx": 1, "apps/web/src/components/chat/ChatOverlayHosts.tsx": 1, "apps/web/src/components/chat/CommandOutputPanel.tsx": 1, From a2b8ec1d59ea3d8d53fc8b3bdc88a36329a2d2d3 Mon Sep 17 00:00:00 2001 From: Matt Stone Date: Mon, 19 Oct 2026 14:13:01 +1100 Subject: [PATCH 2/2] fix(web): palette rename restores a maximized panel Renaming from the palette while the right panel was maximized opened the title input inside the collapsed chat column, out of sight. The rename request now restores the chat column first. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/ChatHeader.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index f2b5187d809af..f63a175a567bb 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -24,6 +24,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; import { readLocalApi } from "~/localApi"; +import { useRightPanelStore } from "~/rightPanelStore"; import { threadEnvironment } from "../../state/threads"; import { serverEnvironment } from "../../state/server"; import { canShareWithGuestAtom } from "./guestShareAvailability"; @@ -196,7 +197,15 @@ export const ChatHeader = memo(function ChatHeader({ onStartRename: startRename, canShareWithGuest, }); - useEffect(() => subscribeThreadRenameStart(startRename), [startRename]); + useEffect( + () => + subscribeThreadRenameStart(() => { + // A maximized right panel collapses the chat column, which would hide the title input. + useRightPanelStore.getState().setMaximized(activeThreadRef, false); + startRename(); + }), + [activeThreadRef, startRename], + ); const titleButtonRef = useRef(null); const titleMenuTimerRef = useRef(null); const cancelPendingTitleMenu = () => {