From 837f6b871a88db0986c98a5a5ebc6bd80b4f2546 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:17:07 +0530 Subject: [PATCH 01/17] feat(web): double-click chat header title to rename thread (#7817) --- apps/web/src/components/chat/ChatHeader.tsx | 70 +++++++++++++++++++-- apps/web/src/hooks/useThreadActionMenu.ts | 6 +- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index d032b16a186b..dbba327489ac 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -15,6 +15,7 @@ import { ChevronDownIcon } from "lucide-react"; import { memo, useCallback, + useEffect, useMemo, useRef, useState, @@ -22,6 +23,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import GitActionsControl from "../GitActionsControl"; +import { isTrailingDoubleClick } from "../Sidebar.logic"; import { type DraftId } from "~/composerDraftStore"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; @@ -88,6 +90,14 @@ export function resolveRenameCommit(input: { return { action: "commit", title: trimmed }; } +// How long a click on the thread title waits before opening the action menu, +// so a double-click-to-rename can cancel it first. Only the native desktop +// menu needs this: it swallows input while open, so the wait must cover the +// OS double-click interval. The browser fallback menu keeps seeing DOM +// events (the second click dismisses it and dblclick still fires), so it +// opens immediately. +const TITLE_MENU_OPEN_DELAY_MS = 500; + export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; @@ -188,28 +198,77 @@ export const ChatHeader = memo(function ChatHeader({ }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], ); - const { openMenu } = useThreadActionMenu({ + const { openMenu, closeMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, changeRequest, onStartRename: startRename, }); const titleButtonRef = useRef(null); - const openMenuFromTitle = useCallback(() => { + const titleMenuTimerRef = useRef(null); + const cancelPendingTitleMenu = useCallback(() => { + if (titleMenuTimerRef.current === null) return; + clearTimeout(titleMenuTimerRef.current); + titleMenuTimerRef.current = null; + }, []); + // Drop a pending menu-open when the thread changes or the header unmounts, + // so it can never fire for a thread the user already left. + useEffect( + () => () => { + cancelPendingTitleMenu(); + }, + [activeThreadId, cancelPendingTitleMenu], + ); + const openTitleMenuNow = useCallback(() => { + cancelPendingTitleMenu(); const rect = titleButtonRef.current?.getBoundingClientRect(); if (!rect) return; openMenu({ x: rect.left, y: rect.bottom + 4 }); - }, [openMenu]); + }, [cancelPendingTitleMenu, openMenu]); + const openMenuFromTitle = useCallback( + (event: ReactMouseEvent) => { + // The trailing click of a double-click belongs to rename, not the menu. + if (isTrailingDoubleClick(event.detail)) return; + // Keyboard activation and the explicit chevron affordance can never be + // the first half of a double-click, so they open without waiting. + const clickedChevron = + (event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null; + if (event.detail === 0 || clickedChevron || window.desktopBridge === undefined) { + openTitleMenuNow(); + return; + } + // Stay pending long enough for dblclick to cancel the open before the + // native menu appears and swallows the second click. + cancelPendingTitleMenu(); + titleMenuTimerRef.current = window.setTimeout(() => { + titleMenuTimerRef.current = null; + openTitleMenuNow(); + }, TITLE_MENU_OPEN_DELAY_MS); + }, + [cancelPendingTitleMenu, openTitleMenuNow], + ); + const handleTitleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + // The chevron is the explicit menu affordance; only the title text renames. + if ((event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null) return; + cancelPendingTitleMenu(); + closeMenu(); + startRename(); + }, + [cancelPendingTitleMenu, closeMenu, startRename], + ); 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; + cancelPendingTitleMenu(); event.preventDefault(); openMenu({ x: event.clientX, y: event.clientY }); }, - [isServerThread, openMenu, renamingTitle], + [cancelPendingTitleMenu, isServerThread, openMenu, renamingTitle], ); const handleRenameKeyDown = useCallback( (event: ReactKeyboardEvent) => { @@ -285,6 +344,8 @@ export const ChatHeader = memo(function ChatHeader({ aria-label={`Thread actions for ${activeThreadTitle}`} aria-haspopup="menu" onClick={openMenuFromTitle} + onDoubleClick={handleTitleDoubleClick} + onBlur={cancelPendingTitleMenu} className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" /> } @@ -292,6 +353,7 @@ export const ChatHeader = memo(function ChatHeader({

{activeThreadTitle}

diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 91f31e4df1c3..4024fb6b7b7b 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -335,5 +335,9 @@ export function useThreadActionMenu(input: { ], ); - return { openMenu }; + const closeMenu = useCallback(() => { + void readLocalApi()?.contextMenu.close(); + }, []); + + return { openMenu, closeMenu }; } From 292c6dd8c242d2b5e2b727295dd17951e55c2d3d Mon Sep 17 00:00:00 2001 From: Rakshith Bhat <88523594+RakshithBhat03@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:31:33 +0530 Subject: [PATCH 02/17] fix(web): model picker no longer shows a double border (#7772) Co-authored-by: GPT-5.6 --- apps/web/src/components/chat/ModelPickerContent.tsx | 2 +- apps/web/src/components/chat/ProviderModelPicker.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 7ffb2bf077da..8729b1bf8f00 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -599,7 +599,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
{/* Sidebar */} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index bd374a0fd6f5..a74a77f01353 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -186,8 +186,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { Date: Sat, 22 Aug 2026 02:31:47 +0530 Subject: [PATCH 03/17] fix(web): give sidebar un-settle button a tooltip (#7796) --- apps/web/src/components/Sidebar.tsx | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a88bc9ce4bb7..af7cdc8a94a2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1298,17 +1298,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - + + + } + > + + + Un-settle thread + ) : ( + ) : null; return ( diff --git a/apps/web/src/components/settings/ProjectFaviconPickerDialog.test.tsx b/apps/web/src/components/settings/ProjectFaviconPickerDialog.test.tsx new file mode 100644 index 000000000000..1cb090d9dc5c --- /dev/null +++ b/apps/web/src/components/settings/ProjectFaviconPickerDialog.test.tsx @@ -0,0 +1,125 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { ReactElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { visitElements } from "../../test/reactElementTree"; +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useMemo: reactHookHarness.useMemo, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => ({}), +})); + +vi.mock("~/state/server", () => ({ + primaryServerKeybindingsAtom: Symbol("keybindings"), +})); + +vi.mock("~/hooks/useTheme", () => ({ + useTheme: () => ({ resolvedTheme: "dark" }), +})); + +vi.mock("../files/projectFilesQueryState", () => ({ + useProjectFilePickerQuery: () => ({ + entries: [], + error: null, + isPending: false, + matchedQuery: "", + }), +})); + +vi.mock("../ui/toast", () => ({ + toastManager: { add: vi.fn() }, +})); + +import { toastManager } from "../ui/toast"; +import { + canPickExternalProjectFavicon, + ProjectFaviconPickerDialog, +} from "./ProjectFaviconPickerDialog"; + +describe("ProjectFaviconPickerDialog", () => { + beforeEach(() => { + hooks.reset(); + vi.stubGlobal("navigator", { platform: "MacIntel" }); + }); + + it("selects an image from the native file picker", async () => { + const onOpenChange = vi.fn(); + const onPickExternal = vi.fn().mockResolvedValue("/Users/me/Pictures/icon.png"); + const onSelect = vi.fn(); + hooks.beginRender(); + const picker = ProjectFaviconPickerDialog({ + cwd: "/Users/me/project", + environmentId: EnvironmentId.make("local"), + onOpenChange, + onPickExternal, + onSelect, + open: true, + projectName: "Project", + } as Parameters[0] & { + readonly onPickExternal: () => Promise; + }) as ReactElement>; + + const button = visitElements(picker, (element) => element.props.children === "Open in Finder"); + expect(button).not.toBeNull(); + + (button?.props.onClick as (() => void) | undefined)?.(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onPickExternal).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onSelect).toHaveBeenCalledWith("/Users/me/Pictures/icon.png"); + }); + + it("hides the native picker for WSL project paths", () => { + expect(canPickExternalProjectFavicon("/home/me/project", "Win32")).toBe(false); + expect(canPickExternalProjectFavicon("C:\\Users\\me\\project", "Win32")).toBe(true); + }); + + it("keeps the dialog open when the native picker fails", async () => { + const onOpenChange = vi.fn(); + const onSelect = vi.fn(); + const props = { + cwd: "/Users/me/project", + environmentId: EnvironmentId.make("local"), + onOpenChange, + onPickExternal: vi.fn().mockRejectedValue(new Error("picker failed")), + onSelect, + open: true, + projectName: "Project", + } as Parameters[0] & { + readonly onPickExternal: () => Promise; + }; + + hooks.beginRender(); + const picker = ProjectFaviconPickerDialog(props) as ReactElement>; + const button = visitElements(picker, (element) => element.props.children === "Open in Finder"); + + (button?.props.onClick as (() => void) | undefined)?.(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + expect(toastManager.add).toHaveBeenCalledWith({ + type: "error", + title: "Could not open image picker", + description: "picker failed", + }); + }); +}); diff --git a/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx b/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx index eee3880692b0..aa4d291851c7 100644 --- a/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx +++ b/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx @@ -1,9 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; import { useMemo, useState } from "react"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { useTheme } from "~/hooks/useTheme"; +import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils"; import { CommandPaletteContent } from "../CommandPaletteContent"; import type { CommandPaletteActionItem } from "../CommandPalette.logic"; import { CommandPaletteResults } from "../CommandPaletteResults"; @@ -13,24 +15,30 @@ import { PROJECT_FILE_PICKER_RESULT_LIMIT, } from "../files/ProjectFilePicker.logic"; import { useProjectFilePickerQuery } from "../files/projectFilesQueryState"; -import { CommandDialog, CommandDialogPopup } from "../ui/command"; +import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "../ui/command"; +import { toastManager } from "../ui/toast"; function emptyMessage(query: string, error: string | null, isPending: boolean): string { if (error) return error; if (isPending) return query.trim() ? "Searching project files…" : "Indexing project files…"; return query.trim() ? "No matching image files." : "No image files found."; } +export function canPickExternalProjectFavicon(cwd: string, platform: string): boolean { + return !isWindowsPlatform(platform) || isWindowsAbsolutePath(cwd); +} export function ProjectFaviconPickerDialog(props: { readonly cwd: string; readonly environmentId: EnvironmentId; readonly onOpenChange: (open: boolean) => void; + readonly onPickExternal?: () => Promise; readonly onSelect: (path: string) => void; readonly open: boolean; readonly projectName: string; }) { const [query, setQuery] = useState(""); const [highlightedItemValue, setHighlightedItemValue] = useState(null); + const [isPickingExternal, setIsPickingExternal] = useState(false); const result = useProjectFilePickerQuery( props.environmentId, props.cwd, @@ -40,6 +48,10 @@ export function ProjectFaviconPickerDialog(props: { ); const { resolvedTheme } = useTheme(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const pickExternal = props.onPickExternal; + const fileManagerName = getLocalFileManagerName( + typeof navigator === "undefined" ? "" : navigator.platform, + ); const items = useMemo( () => getProjectFilePickerMatches(result.entries, result.matchedQuery).map((match) => ({ @@ -67,6 +79,33 @@ export function ProjectFaviconPickerDialog(props: { autoHighlight="always" escapeLabel="Close" footerActionLabel="Select icon" + footerTrailing={ + pickExternal ? ( + { + setIsPickingExternal(true); + void pickExternal() + .then((path) => { + if (!path) return; + props.onOpenChange(false); + props.onSelect(path); + }) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not open image picker", + description: + error instanceof Error ? error.message : "An error occurred.", + }); + }) + .finally(() => setIsPickingExternal(false)); + }} + > + {`Open in ${fileManagerName}`} + + ) : null + } inputProps={{ placeholder: "Search image files…" }} mode="none" onItemHighlighted={(value) => { diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 0fb0415a34bb..6768d2dc61ef 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -108,7 +108,10 @@ import { SettingsRow, SettingsSection, } from "./settingsLayout"; -import { ProjectFaviconPickerDialog } from "./ProjectFaviconPickerDialog"; +import { + canPickExternalProjectFavicon, + ProjectFaviconPickerDialog, +} from "./ProjectFaviconPickerDialog"; export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", @@ -286,6 +289,7 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const navigate = useNavigate(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); const settings = usePrimarySettings(); const updateClientSettings = useUpdateClientSettings(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -319,6 +323,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { (member) => member.environmentId === group.environmentId && member.id === group.id, ) ?? group.memberProjects[0]!; const faviconPath = representative.faviconPath ?? null; + const pickProjectFavicon = + typeof window !== "undefined" && + group.memberProjects.every( + (member) => + member.environmentId === primaryEnvironmentId && + canPickExternalProjectFavicon(member.workspaceRoot, navigator.platform), + ) + ? window.desktopBridge?.pickProjectFavicon + : undefined; const threadCountByMember = useMemo(() => { const counts = new Map(); @@ -1166,6 +1179,9 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { cwd={representative.workspaceRoot} environmentId={representative.environmentId} onOpenChange={setFaviconPickerOpen} + {...(pickProjectFavicon + ? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) } + : {})} onSelect={(path) => void setFaviconPath(path)} open={faviconPickerOpen} projectName={group.displayName} diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 06ce8d0b2d55..c1952e9c6f36 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -16,6 +16,7 @@ import { AutocompleteSeparator, } from "~/components/ui/autocomplete"; import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles"; +import { Button } from "~/components/ui/button"; const CommandDialog = CommandDialogPrimitive.Root; @@ -225,6 +226,20 @@ function CommandFooter({ className, ...props }: React.ComponentProps<"div">) { ); } +function CommandFooterAction({ + className, + ...props +}: Omit, "size" | "variant">) { + return ( +