diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 0c00fed4e70..b57c13032ce 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -23,6 +23,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, + { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, @@ -50,6 +51,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.split`: split terminal (in focused terminal context by default) - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) +- `commandPalette.toggle`: open or close the global command palette - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 19c96bd5471..b473f77ca1b 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -61,6 +61,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts new file mode 100644 index 00000000000..4f291d5a480 --- /dev/null +++ b/apps/web/src/commandPaletteStore.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +interface CommandPaletteStore { + open: boolean; + setOpen: (open: boolean) => void; + toggleOpen: () => void; +} + +export const useCommandPaletteStore = create((set) => ({ + open: false, + setOpen: (open) => set({ open }), + toggleOpen: () => set((state) => ({ open: !state.open })), +})); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 401282f8f19..a65f5755f9a 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -30,11 +30,12 @@ import { page } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import { useComposerDraftStore, DraftId } from "../composerDraftStore"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, - type TerminalContextDraft, removeInlineTerminalContextPlaceholder, + type TerminalContextDraft, } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; import { __resetLocalApiForTests } from "../localApi"; @@ -57,7 +58,9 @@ vi.mock("../lib/gitStatusState", () => ({ })); const THREAD_ID = "thread-browser-test" as ThreadId; +const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; +const SECOND_PROJECT_ID = "project-2" as ProjectId; const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); const THREAD_KEY = scopedThreadKey(THREAD_REF); @@ -563,6 +566,18 @@ function createDraftOnlySnapshot(): OrchestrationReadModel { }; } +function createProjectlessSnapshot(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-projectless-target" as MessageId, + targetText: "projectless", + }); + return { + ...snapshot, + projects: [], + threads: [], + }; +} + function withProjectScripts( snapshot: OrchestrationReadModel, scripts: OrchestrationReadModel["projects"][number]["scripts"], @@ -654,6 +669,100 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithSecondaryProject(options?: { + includeSecondaryThread?: boolean; + includeArchivedSecondaryThread?: boolean; +}): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-secondary-project-target" as MessageId, + targetText: "secondary project", + }); + const includeSecondaryThread = options?.includeSecondaryThread ?? true; + const includeArchivedSecondaryThread = options?.includeArchivedSecondaryThread ?? true; + const secondaryThreads: OrchestrationReadModel["threads"] = includeSecondaryThread + ? [ + { + id: "thread-secondary-project" as ThreadId, + projectId: SECOND_PROJECT_ID, + title: "Release checklist", + modelSelection: { provider: "codex", model: "gpt-5" }, + interactionMode: "default", + runtimeMode: "full-access", + branch: "release/docs-portal", + worktreePath: null, + latestTurn: null, + createdAt: isoAt(30), + updatedAt: isoAt(31), + deletedAt: null, + messages: [], + activities: [], + proposedPlans: [], + checkpoints: [], + session: { + threadId: "thread-secondary-project" as ThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: isoAt(31), + }, + archivedAt: null, + }, + ] + : []; + const archivedSecondaryThreads: OrchestrationReadModel["threads"] = includeArchivedSecondaryThread + ? [ + { + id: ARCHIVED_SECONDARY_THREAD_ID, + projectId: SECOND_PROJECT_ID, + title: "Archived Docs Notes", + modelSelection: { provider: "codex", model: "gpt-5" }, + interactionMode: "default", + runtimeMode: "full-access", + branch: "release/docs-archive", + worktreePath: null, + latestTurn: null, + createdAt: isoAt(24), + updatedAt: isoAt(25), + deletedAt: null, + messages: [], + activities: [], + proposedPlans: [], + checkpoints: [], + session: { + threadId: ARCHIVED_SECONDARY_THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: isoAt(25), + }, + archivedAt: isoAt(26), + }, + ] + : []; + + return { + ...snapshot, + projects: [ + ...snapshot.projects, + { + id: SECOND_PROJECT_ID, + title: "Docs Portal", + workspaceRoot: "/repo/clients/docs-portal", + defaultModelSelection: { provider: "codex", model: "gpt-5" }, + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + threads: [...snapshot.threads, ...secondaryThreads, ...archivedSecondaryThreads], + }; +} + function createSnapshotWithPendingUserInput(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-pending-input-target" as MessageId, @@ -1242,6 +1351,16 @@ async function triggerChatNewShortcutUntilPath( throw new Error(`${errorMessage} Last path: ${pathname}`); } +async function openCommandPaletteFromTrigger(): Promise { + const trigger = page.getByTestId("command-palette-trigger"); + await expect.element(trigger).toBeInTheDocument(); + await trigger.click(); + await waitForElement( + () => document.querySelector('[data-testid="command-palette"]'), + "Command palette should have opened from the sidebar trigger.", + ); +} + async function waitForNewThreadShortcutLabel(): Promise { const newThreadButton = page.getByTestId("new-thread-button"); await expect.element(newThreadButton).toBeInTheDocument(); @@ -1252,6 +1371,13 @@ async function waitForNewThreadShortcutLabel(): Promise { await expect.element(page.getByText(shortcutLabel)).toBeInTheDocument(); } +async function waitForCommandPaletteShortcutLabel(): Promise { + await waitForElement( + () => document.querySelector('[data-testid="command-palette-trigger"] kbd'), + "Command palette shortcut label did not render.", + ); +} + async function waitForImagesToLoad(scope: ParentNode): Promise { const images = Array.from(scope.querySelectorAll("img")); if (images.length === 0) { @@ -1383,6 +1509,7 @@ async function mountChatView(options: { customWsRpcResolver = null; await screen.unmount(); host.remove(); + await waitForLayout(); }; return { @@ -1480,6 +1607,9 @@ describe("ChatView timeline estimator parity (full app)", () => { stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); + useCommandPaletteStore.setState({ + open: false, + }); useStore.setState({ activeEnvironmentId: null, environmentStateById: {}, @@ -3489,6 +3619,359 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("does not consume chat.new when there is no project context", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createProjectlessSnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "chat.new", + shortcut: { + key: "o", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + dispatchChatNewShortcut(); + await waitForLayout(); + + expect(mounted.router.state.location.pathname).toBe(serverThreadPath(THREAD_ID)); + expect(Object.keys(useComposerDraftStore.getState().draftThreadsByThreadKey)).toHaveLength(0); + } finally { + await mounted.cleanup(); + } + }); + + it("renders the configurable shortcut and runs a command from the sidebar trigger", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-shortcut-test" as MessageId, + targetText: "command palette shortcut test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await expect + .element(palette.getByText("New thread in Project", { exact: true })) + .toBeInTheDocument(); + await palette.getByText("New thread in Project", { exact: true }).click(); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID from the command palette.", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("filters command palette results as the user types", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-search-test" as MessageId, + targetText: "command palette search test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings"); + await expect.element(palette.getByText("Open settings", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("New thread in Project", { exact: true })) + .not.toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps project-context thread matches available when searching by project name", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs"); + await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("Release checklist", { exact: true })) + .toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("searches projects by path and opens the latest thread for that project", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + settings: { + ...nextFixture.serverConfig.settings, + defaultThreadEnvMode: "worktree", + }, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); + await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("/repo/clients/docs-portal", { exact: true })) + .toBeInTheDocument(); + await palette.getByText("Docs Portal", { exact: true }).click(); + + const nextPath = await waitForURL( + mounted.router, + (path) => path === serverThreadPath("thread-secondary-project" as ThreadId), + "Route should have changed to the latest thread for the selected project.", + ); + expect(nextPath).toBe(serverThreadPath("thread-secondary-project" as ThreadId)); + expect( + useComposerDraftStore + .getState() + .getDraftThread(threadRefFor("thread-secondary-project" as ThreadId)), + ).toBeNull(); + } finally { + await mounted.cleanup(); + } + }); + + it("creates a new thread from project search when no active project thread exists", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject({ includeSecondaryThread: false }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + settings: { + ...nextFixture.serverConfig.settings, + defaultThreadEnvMode: "worktree", + }, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); + await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("/repo/clients/docs-portal", { exact: true })) + .toBeInTheDocument(); + await palette.getByText("Docs Portal", { exact: true }).click(); + + const nextPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID from the project search result.", + ); + const nextDraftId = draftIdFromPath(nextPath); + const draftThread = useComposerDraftStore.getState().getDraftSession(nextDraftId); + expect(draftThread?.projectId).toBe(SECOND_PROJECT_ID); + expect(draftThread?.envMode).toBe("worktree"); + } finally { + await mounted.cleanup(); + } + }); + + it("filters archived threads out of command palette search results", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs-archive"); + await expect + .element(palette.getByText("Archived Docs Notes", { exact: true })) + .not.toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -3523,7 +4006,6 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const newThreadButton = page.getByTestId("new-thread-button"); await expect.element(newThreadButton).toBeInTheDocument(); - await waitForNewThreadShortcutLabel(); await waitForServerConfigToApply(); await newThreadButton.click(); @@ -3547,6 +4029,9 @@ describe("ChatView timeline estimator parity (full app)", () => { }, { timeout: 8_000, interval: 16 }, ); + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + await waitForLayout(); const freshThreadPath = await triggerChatNewShortcutUntilPath( mounted.router, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 25ed7639520..7a5a2875ccc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -89,6 +89,7 @@ import { } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; @@ -160,6 +161,7 @@ import { waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useComposerHandleContext } from "../composerHandleContext"; import { useServerAvailableEditors, useServerConfig, @@ -650,7 +652,8 @@ export default function ChatView(props: ChatViewProps) { const promptRef = useRef(""); const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); - const composerRef = useRef(null); + const localComposerRef = useRef(null); + const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -2321,7 +2324,9 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { - if (!activeThreadId || event.defaultPrevented) return; + if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { + return; + } const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts new file mode 100644 index 00000000000..a49dadc851f --- /dev/null +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from "vitest"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import type { Thread } from "../types"; +import { + buildThreadActionItems, + filterCommandPaletteGroups, + type CommandPaletteGroup, +} from "./CommandPalette.logic"; + +const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const PROJECT_ID = ProjectId.make("project-1"); + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.make("thread-1"), + environmentId: LOCAL_ENVIRONMENT_ID, + codexThreadId: null, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-01T00:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + ...overrides, + }; +} + +describe("buildThreadActionItems", () => { + it("orders threads by most recent activity and formats timestamps from updatedAt", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-25T12:00:00.000Z")); + + try { + const items = buildThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("thread-older"), + title: "Older thread", + updatedAt: "2026-03-24T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-newer"), + title: "Newer thread", + createdAt: "2026-03-20T00:00:00.000Z", + updatedAt: "2026-03-20T00:00:00.000Z", + }), + ], + projectTitleById: new Map([[PROJECT_ID, "Project"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_thread) => undefined, + }); + + expect(items.map((item) => item.value)).toEqual([ + "thread:thread-older", + "thread:thread-newer", + ]); + expect(items[0]?.timestamp).toBe("1d ago"); + expect(items[1]?.timestamp).toBe("5d ago"); + } finally { + vi.useRealTimers(); + } + }); + + it("ranks thread title matches ahead of contextual project-name matches", () => { + const threadItems = buildThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("thread-context-match"), + title: "Fix navbar spacing", + updatedAt: "2026-03-20T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-title-match"), + title: "Project kickoff notes", + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-19T00:00:00.000Z", + }), + ], + projectTitleById: new Map([[PROJECT_ID, "Project"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_thread) => undefined, + }); + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "project", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: threadItems, + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.value).toBe("threads-search"); + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "thread:thread-title-match", + "thread:thread-context-match", + ]); + }); + + it("preserves thread project-name matches when there is no stronger title match", () => { + const group: CommandPaletteGroup = { + value: "threads-search", + label: "Threads", + items: [ + { + kind: "action", + value: "thread:project-context-only", + searchTerms: ["Fix navbar spacing", "Project"], + title: "Fix navbar spacing", + description: "Project", + icon: null, + run: async () => undefined, + }, + ], + }; + + const groups = filterCommandPaletteGroups({ + activeGroups: [group], + query: "project", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [], + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]); + }); + + it("filters archived threads out of thread search items", () => { + const items = buildThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("thread-active"), + title: "Active thread", + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-19T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-archived"), + title: "Archived thread", + archivedAt: "2026-03-20T00:00:00.000Z", + updatedAt: "2026-03-20T00:00:00.000Z", + }), + ], + projectTitleById: new Map([[PROJECT_ID, "Project"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_thread) => undefined, + }); + + expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts new file mode 100644 index 00000000000..c29f1271087 --- /dev/null +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -0,0 +1,262 @@ +import { type KeybindingCommand } from "@t3tools/contracts"; +import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { type ReactNode } from "react"; +import { sortThreads } from "../lib/threadSort"; +import { formatRelativeTimeLabel } from "../timestampFormat"; +import { type Project, type SidebarThreadSummary, type Thread } from "../types"; + +export const RECENT_THREAD_LIMIT = 12; +export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; +export const ADDON_ICON_CLASS = "size-4"; + +export interface CommandPaletteItem { + readonly kind: "action" | "submenu"; + readonly value: string; + readonly searchTerms: ReadonlyArray; + readonly title: ReactNode; + readonly description?: string; + readonly timestamp?: string; + readonly icon: ReactNode; + readonly shortcutCommand?: KeybindingCommand; +} + +export interface CommandPaletteActionItem extends CommandPaletteItem { + readonly kind: "action"; + readonly keepOpen?: boolean; + readonly run: () => Promise; +} + +export interface CommandPaletteSubmenuItem extends CommandPaletteItem { + readonly kind: "submenu"; + readonly addonIcon: ReactNode; + readonly groups: ReadonlyArray; + readonly initialQuery?: string; +} + +export interface CommandPaletteGroup { + readonly value: string; + readonly label: string; + readonly items: ReadonlyArray; +} + +export interface CommandPaletteView { + readonly addonIcon: ReactNode; + readonly groups: ReadonlyArray; + readonly initialQuery?: string; +} + +export type CommandPaletteMode = "root" | "submenu"; + +export function normalizeSearchText(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +export function buildProjectActionItems(input: { + projects: ReadonlyArray; + valuePrefix: string; + icon: (project: Project) => ReactNode; + runProject: (project: Project) => Promise; +}): CommandPaletteActionItem[] { + return input.projects.map((project) => ({ + kind: "action", + value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, + searchTerms: [project.name, project.cwd], + title: project.name, + description: project.cwd, + icon: input.icon(project), + run: async () => { + await input.runProject(project); + }, + })); +} + +export function buildThreadActionItems(input: { + threads: ReadonlyArray< + Pick< + SidebarThreadSummary, + "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" + > & { + updatedAt?: string | undefined; + latestUserMessageAt?: string | null; + } + >; + activeThreadId?: Thread["id"]; + projectTitleById: ReadonlyMap; + sortOrder: SidebarThreadSortOrder; + icon: ReactNode; + runThread: (thread: Pick) => Promise; + limit?: number; +}): CommandPaletteActionItem[] { + const sortedThreads = sortThreads( + input.threads.filter((thread) => thread.archivedAt === null), + input.sortOrder, + ); + const visibleThreads = + input.limit === undefined ? sortedThreads : sortedThreads.slice(0, input.limit); + + return visibleThreads.map((thread) => { + const projectTitle = input.projectTitleById.get(thread.projectId); + const descriptionParts: string[] = []; + + if (projectTitle) { + descriptionParts.push(projectTitle); + } + if (thread.branch) { + descriptionParts.push(`#${thread.branch}`); + } + if (thread.id === input.activeThreadId) { + descriptionParts.push("Current thread"); + } + + return { + kind: "action", + value: `thread:${thread.id}`, + searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""], + title: thread.title, + description: descriptionParts.join(" · "), + timestamp: formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt), + icon: input.icon, + run: async () => { + await input.runThread(thread); + }, + }; + }); +} + +function rankSearchFieldMatch(field: string, normalizedQuery: string): number { + const normalizedField = normalizeSearchText(field); + if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) { + return Number.NEGATIVE_INFINITY; + } + if (normalizedField === normalizedQuery) { + return 3; + } + if (normalizedField.startsWith(normalizedQuery)) { + return 2; + } + return 1; +} + +function rankCommandPaletteItemMatch( + item: CommandPaletteActionItem | CommandPaletteSubmenuItem, + normalizedQuery: string, +): number { + const terms = item.searchTerms.filter((term) => term.length > 0); + if (terms.length === 0) { + return 0; + } + + for (const [index, field] of terms.entries()) { + const fieldRank = rankSearchFieldMatch(field, normalizedQuery); + if (fieldRank !== Number.NEGATIVE_INFINITY) { + return 1_000 - index * 100 + fieldRank; + } + } + + return 0; +} + +export function filterCommandPaletteGroups(input: { + activeGroups: ReadonlyArray; + query: string; + isInSubmenu: boolean; + projectSearchItems: ReadonlyArray; + threadSearchItems: ReadonlyArray; +}): CommandPaletteGroup[] { + const isActionsFilter = input.query.startsWith(">"); + const searchQuery = isActionsFilter ? input.query.slice(1) : input.query; + const normalizedQuery = normalizeSearchText(searchQuery); + + if (normalizedQuery.length === 0) { + if (isActionsFilter) { + return input.activeGroups.filter((group) => group.value === "actions"); + } + return [...input.activeGroups]; + } + + let baseGroups = [...input.activeGroups]; + if (isActionsFilter) { + baseGroups = baseGroups.filter((group) => group.value === "actions"); + } else if (!input.isInSubmenu) { + baseGroups = baseGroups.filter((group) => group.value !== "recent-threads"); + } + + const searchableGroups = [...baseGroups]; + if (!input.isInSubmenu && !isActionsFilter) { + if (input.projectSearchItems.length > 0) { + searchableGroups.push({ + value: "projects-search", + label: "Projects", + items: input.projectSearchItems, + }); + } + if (input.threadSearchItems.length > 0) { + searchableGroups.push({ + value: "threads-search", + label: "Threads", + items: input.threadSearchItems, + }); + } + } + + return searchableGroups.flatMap((group) => { + const items = group.items + .map((item, index) => { + const haystack = normalizeSearchText(item.searchTerms.join(" ")); + if (!haystack.includes(normalizedQuery)) { + return null; + } + + return { + item, + index, + rank: rankCommandPaletteItemMatch(item, normalizedQuery), + }; + }) + .filter( + (entry): entry is { item: (typeof group.items)[number]; index: number; rank: number } => + entry !== null, + ) + .toSorted((left, right) => right.rank - left.rank || left.index - right.index) + .map((entry) => entry.item); + + if (items.length === 0) { + return []; + } + + return [{ value: group.value, label: group.label, items }]; + }); +} + +export function getCommandPaletteMode(input: { + currentView: CommandPaletteView | null; +}): CommandPaletteMode { + return input.currentView ? "submenu" : "root"; +} + +export function buildRootGroups(input: { + actionItems: ReadonlyArray; + recentThreadItems: ReadonlyArray; +}): CommandPaletteGroup[] { + const groups: CommandPaletteGroup[] = []; + if (input.actionItems.length > 0) { + groups.push({ value: "actions", label: "Actions", items: input.actionItems }); + } + if (input.recentThreadItems.length > 0) { + groups.push({ + value: "recent-threads", + label: "Recent Threads", + items: input.recentThreadItems, + }); + } + return groups; +} + +export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string { + switch (mode) { + case "root": + return "Search commands, projects, and threads..."; + case "submenu": + return "Search..."; + } +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 00000000000..4028043f196 --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -0,0 +1,458 @@ +"use client"; + +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import type { ProjectId } from "@t3tools/contracts"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { + ArrowDownIcon, + ArrowLeftIcon, + ArrowUpIcon, + MessageSquareIcon, + SettingsIcon, + SquarePenIcon, +} from "lucide-react"; +import { + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useCommandPaletteStore } from "../commandPaletteStore"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useSettings } from "../hooks/useSettings"; +import { + startNewThreadInProjectFromContext, + startNewThreadFromContext, +} from "../lib/chatThreadActions"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { getLatestThreadForProject } from "../lib/threadSort"; +import { cn } from "../lib/utils"; +import { + selectProjectsAcrossEnvironments, + selectSidebarThreadsAcrossEnvironments, + useStore, +} from "../store"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { + ADDON_ICON_CLASS, + buildProjectActionItems, + buildRootGroups, + buildThreadActionItems, + type CommandPaletteActionItem, + type CommandPaletteSubmenuItem, + type CommandPaletteView, + filterCommandPaletteGroups, + getCommandPaletteInputPlaceholder, + getCommandPaletteMode, + ITEM_ICON_CLASS, + RECENT_THREAD_LIMIT, +} from "./CommandPalette.logic"; +import { CommandPaletteResults } from "./CommandPaletteResults"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { useServerKeybindings } from "../rpc/serverState"; +import { resolveShortcutCommand } from "../keybindings"; +import { + Command, + CommandDialog, + CommandDialogPopup, + CommandFooter, + CommandInput, + CommandPanel, +} from "./ui/command"; +import { Kbd, KbdGroup } from "./ui/kbd"; +import { toastManager } from "./ui/toast"; +import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; +import type { ChatComposerHandle } from "./chat/ChatComposer"; + +export function CommandPalette({ children }: { children: ReactNode }) { + const open = useCommandPaletteStore((store) => store.open); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + const toggleOpen = useCommandPaletteStore((store) => store.toggleOpen); + const keybindings = useServerKeybindings(); + const composerHandleRef = useRef(null); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; + const terminalOpen = useTerminalStateStore((state) => + routeThreadRef + ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + }, + }); + if (command !== "commandPalette.toggle") { + return; + } + event.preventDefault(); + event.stopPropagation(); + toggleOpen(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [keybindings, terminalOpen, toggleOpen]); + + return ( + + + {children} + + + + ); +} + +function CommandPaletteDialog() { + const open = useCommandPaletteStore((store) => store.open); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + + useEffect(() => { + return () => { + setOpen(false); + }; + }, [setOpen]); + + if (!open) { + return null; + } + + return ; +} + +function OpenCommandPaletteDialog() { + const navigate = useNavigate(); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + const composerHandleRef = useComposerHandleContext(); + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); + const isActionsOnly = deferredQuery.startsWith(">"); + const settings = useSettings(); + const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = + useHandleNewThread(); + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); + const keybindings = useServerKeybindings(); + const [viewStack, setViewStack] = useState([]); + const currentView = viewStack.at(-1) ?? null; + const paletteMode = getCommandPaletteMode({ currentView }); + + const projectTitleById = useMemo( + () => new Map(projects.map((project) => [project.id, project.name])), + [projects], + ); + + const activeThreadId = activeThread?.id; + const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + + const openProjectFromSearch = useMemo( + () => async (project: (typeof projects)[number]) => { + const latestThread = getLatestThreadForProject( + threads.filter((thread) => thread.environmentId === project.environmentId), + project.id, + settings.sidebarThreadSortOrder, + ); + if (latestThread) { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(latestThread.environmentId, latestThread.id), + ), + }); + return; + } + + await handleNewThread(scopeProjectRef(project.environmentId, project.id), { + envMode: settings.defaultThreadEnvMode, + }); + }, + [ + handleNewThread, + navigate, + settings.defaultThreadEnvMode, + settings.sidebarThreadSortOrder, + threads, + ], + ); + + const projectSearchItems = useMemo( + () => + buildProjectActionItems({ + projects, + valuePrefix: "project", + icon: (project) => ( + + ), + runProject: openProjectFromSearch, + }), + [openProjectFromSearch, projects], + ); + + const projectThreadItems = useMemo( + () => + buildProjectActionItems({ + projects, + valuePrefix: "new-thread-in", + icon: (project) => ( + + ), + runProject: async (project) => { + await startNewThreadInProjectFromContext( + { + activeDraftThread, + activeThread, + defaultProjectRef, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + }, + scopeProjectRef(project.environmentId, project.id), + ); + }, + }), + [ + activeDraftThread, + activeThread, + defaultProjectRef, + handleNewThread, + projects, + settings.defaultThreadEnvMode, + ], + ); + + const allThreadItems = useMemo( + () => + buildThreadActionItems({ + threads, + ...(activeThreadId ? { activeThreadId } : {}), + projectTitleById, + sortOrder: settings.sidebarThreadSortOrder, + icon: , + runThread: async (thread) => { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), + }); + }, + }), + [activeThreadId, navigate, projectTitleById, settings.sidebarThreadSortOrder, threads], + ); + const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); + + function pushView(item: CommandPaletteSubmenuItem): void { + setViewStack((previousViews) => [ + ...previousViews, + { + addonIcon: item.addonIcon, + groups: item.groups, + ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}), + }, + ]); + setQuery(item.initialQuery ?? ""); + } + + function popView(): void { + setViewStack((previousViews) => previousViews.slice(0, -1)); + setQuery(""); + } + + function handleQueryChange(nextQuery: string): void { + setQuery(nextQuery); + if (nextQuery === "" && currentView?.initialQuery) { + popView(); + } + } + + const actionItems: Array = []; + + if (projects.length > 0) { + const activeProjectTitle = currentProjectId + ? (projectTitleById.get(currentProjectId) ?? null) + : null; + + if (activeProjectTitle) { + actionItems.push({ + kind: "action", + value: "action:new-thread", + searchTerms: ["new thread", "chat", "create", "draft"], + title: ( + <> + New thread in {activeProjectTitle} + + ), + icon: , + shortcutCommand: "chat.new", + run: async () => { + await startNewThreadFromContext({ + activeDraftThread, + activeThread, + defaultProjectRef, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + }); + }, + }); + } + + actionItems.push({ + kind: "submenu", + value: "action:new-thread-in", + searchTerms: ["new thread", "project", "pick", "choose", "select"], + title: "New thread in...", + icon: , + addonIcon: , + groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + }); + } + + actionItems.push({ + kind: "action", + value: "action:settings", + searchTerms: ["settings", "preferences", "configuration", "keybindings"], + title: "Open settings", + icon: , + run: async () => { + await navigate({ to: "/settings" }); + }, + }); + + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); + const activeGroups = currentView ? currentView.groups : rootGroups; + + const displayedGroups = filterCommandPaletteGroups({ + activeGroups, + query: deferredQuery, + isInSubmenu: currentView !== null, + projectSearchItems: projectSearchItems, + threadSearchItems: allThreadItems, + }); + + const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); + const isSubmenu = paletteMode === "submenu"; + + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Backspace" && query === "" && isSubmenu) { + event.preventDefault(); + popView(); + } + } + + function executeItem(item: CommandPaletteActionItem | CommandPaletteSubmenuItem): void { + if (item.kind === "submenu") { + pushView(item); + return; + } + + if (!item.keepOpen) { + setOpen(false); + } + + void item.run().catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Unable to run command", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + } + + return ( + { + composerHandleRef?.current?.focusAtEnd(); + return false; + }} + > + + + + + ), + } + : {})} + onKeyDown={handleKeyDown} + /> + + + + +
+ + + + + + + + Navigate + + + Enter + Select + + {isSubmenu ? ( + + Backspace + Back + + ) : null} + + Esc + Close + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx new file mode 100644 index 00000000000..72700471bac --- /dev/null +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -0,0 +1,103 @@ +import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { ChevronRightIcon } from "lucide-react"; +import { shortcutLabelForCommand } from "../keybindings"; +import { + type CommandPaletteActionItem, + type CommandPaletteGroup, + type CommandPaletteSubmenuItem, +} from "./CommandPalette.logic"; +import { + CommandCollection, + CommandGroup, + CommandGroupLabel, + CommandItem, + CommandList, + CommandShortcut, +} from "./ui/command"; + +interface CommandPaletteResultsProps { + emptyStateMessage?: string; + groups: ReadonlyArray; + isActionsOnly: boolean; + keybindings: ResolvedKeybindingsConfig; + onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; +} + +export function CommandPaletteResults(props: CommandPaletteResultsProps) { + if (props.groups.length === 0) { + return ( +
+ {props.emptyStateMessage ?? + (props.isActionsOnly + ? "No matching actions." + : "No matching commands, projects, or threads.")} +
+ ); + } + + return ( + + {props.groups.map((group) => ( + + {group.label} + + {(item) => ( + + )} + + + ))} + + ); +} + +function CommandPaletteResultRow(props: { + item: CommandPaletteActionItem | CommandPaletteSubmenuItem; + keybindings: ResolvedKeybindingsConfig; + onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; +}) { + const shortcutLabel = props.item.shortcutCommand + ? shortcutLabelForCommand(props.keybindings, props.item.shortcutCommand) + : null; + + return ( + { + event.preventDefault(); + }} + onClick={() => { + props.onExecuteItem(props.item); + }} + > + {props.item.icon} + {props.item.description ? ( + + {props.item.title} + + {props.item.description} + + + ) : ( + + {props.item.title} + + )} + {props.item.timestamp ? ( + + {props.item.timestamp} + + ) : null} + {shortcutLabel ? {shortcutLabel} : null} + {props.item.kind === "submenu" ? ( + + ) : null} + + ); +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 282d7f9c4db..35534af9f38 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -17,7 +17,6 @@ import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - sortThreadsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; @@ -667,133 +666,6 @@ function makeThread(overrides: Partial = {}): Thread { }; } -describe("sortThreadsForSidebar", () => { - it("sorts threads by the latest user message in recency mode", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.make("thread-1"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:10:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "user", - text: "older", - createdAt: "2026-03-09T10:01:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:01:00.000Z", - }, - ], - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [ - { - id: "message-2" as never, - role: "user", - text: "newer", - createdAt: "2026-03-09T10:06:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:06:00.000Z", - }, - ], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-2"), - ThreadId.make("thread-1"), - ]); - }); - - it("falls back to thread timestamps when there is no user message", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.make("thread-1"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:01:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "assistant", - text: "assistant only", - createdAt: "2026-03-09T10:02:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:02:00.000Z", - }, - ], - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-2"), - ThreadId.make("thread-1"), - ]); - }); - - it("falls back to id ordering when threads have no sortable timestamps", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.make("thread-1"), - createdAt: "" as never, - updatedAt: undefined, - messages: [], - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "" as never, - updatedAt: undefined, - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-2"), - ThreadId.make("thread-1"), - ]); - }); - - it("can sort threads by createdAt when configured", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.make("thread-1"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:10:00.000Z", - }), - ], - "created_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ]); - }); -}); - describe("getFallbackThreadIdAfterDelete", () => { it("returns the top remaining thread in the deleted thread's project sidebar order", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ @@ -860,7 +732,6 @@ describe("getFallbackThreadIdAfterDelete", () => { expect(fallbackThreadId).toBe(ThreadId.make("thread-next")); }); }); - describe("sortProjectsForSidebar", () => { it("sorts projects by the most recent user message across their threads", () => { const projects = [ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ed151c6da89..8c742cbe9b9 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,11 @@ import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { + getThreadSortTimestamp, + sortThreads, + toSortableTimestamp, + type ThreadSortInput, +} from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; @@ -13,10 +19,6 @@ type SidebarProject = { createdAt?: string | undefined; updatedAt?: string | undefined; }; -type SidebarThreadSortInput = Pick & { - latestUserMessageAt?: string | null; - messages?: Pick[]; -}; export type ThreadTraversalDirection = "previous" | "next"; @@ -441,61 +443,8 @@ export function getVisibleThreadsForProject>(input: }; } -function toSortableTimestamp(iso: string | undefined): number | null { - if (!iso) return null; - const ms = Date.parse(iso); - return Number.isFinite(ms) ? ms : null; -} - -function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number { - if (thread.latestUserMessageAt) { - return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; - } - - let latestUserMessageTimestamp: number | null = null; - - for (const message of thread.messages ?? []) { - if (message.role !== "user") continue; - const messageTimestamp = toSortableTimestamp(message.createdAt); - if (messageTimestamp === null) continue; - latestUserMessageTimestamp = - latestUserMessageTimestamp === null - ? messageTimestamp - : Math.max(latestUserMessageTimestamp, messageTimestamp); - } - - if (latestUserMessageTimestamp !== null) { - return latestUserMessageTimestamp; - } - - return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY; -} - -function getThreadSortTimestamp( - thread: SidebarThreadSortInput, - sortOrder: SidebarThreadSortOrder | Exclude, -): number { - if (sortOrder === "created_at") { - return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY; - } - return getLatestUserMessageTimestamp(thread); -} - -export function sortThreadsForSidebar< - T extends Pick & SidebarThreadSortInput, ->(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { - return threads.toSorted((left, right) => { - const rightTimestamp = getThreadSortTimestamp(right, sortOrder); - const leftTimestamp = getThreadSortTimestamp(left, sortOrder); - const byTimestamp = - rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; - if (byTimestamp !== 0) return byTimestamp; - return right.id.localeCompare(left.id); - }); -} - export function getFallbackThreadIdAfterDelete< - T extends Pick & SidebarThreadSortInput, + T extends Pick & ThreadSortInput, >(input: { threads: readonly T[]; deletedThreadId: T["id"]; @@ -509,7 +458,7 @@ export function getFallbackThreadIdAfterDelete< } return ( - sortThreadsForSidebar( + sortThreads( threads.filter( (thread) => thread.projectId === deletedThread.projectId && @@ -520,10 +469,9 @@ export function getFallbackThreadIdAfterDelete< )[0]?.id ?? null ); } - export function getProjectSortTimestamp( project: SidebarProject, - projectThreads: readonly SidebarThreadSortInput[], + projectThreads: readonly ThreadSortInput[], sortOrder: Exclude, ): number { if (projectThreads.length > 0) { @@ -541,7 +489,7 @@ export function getProjectSortTimestamp( export function sortProjectsForSidebar< TProject extends SidebarProject, - TThread extends Pick & SidebarThreadSortInput, + TThread extends Pick & ThreadSortInput, >( projects: readonly TProject[], threads: readonly TThread[], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c5725c6d0d9..03ae9790174 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { FolderIcon, GitPullRequestIcon, PlusIcon, + SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -90,6 +91,7 @@ import { import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -130,12 +132,13 @@ import { orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - sortThreadsForSidebar, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; +import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; @@ -1139,7 +1142,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; - const visibleProjectThreads = sortThreadsForSidebar( + const visibleProjectThreads = sortThreads( projectThreads.filter((thread) => thread.archivedAt === null), threadSortOrder, ); @@ -2041,6 +2044,7 @@ interface SidebarProjectsContentProps { activeRouteProjectKey: string | null; routeThreadKey: string | null; newThreadShortcutLabel: string | null; + commandPaletteShortcutLabel: string | null; threadJumpLabelByKey: ReadonlyMap; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; @@ -2092,6 +2096,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( activeRouteProjectKey, routeThreadKey, newThreadShortcutLabel, + commandPaletteShortcutLabel, threadJumpLabelByKey, attachThreadListAutoAnimateRef, expandThreadListForProject, @@ -2138,6 +2143,29 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( return ( + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( @@ -2535,7 +2563,7 @@ export default function Sidebar() { scopeProjectRef(projectRef.environmentId, projectRef.projectId), ); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const latestThread = sortThreadsForSidebar( + const latestThread = sortThreads( (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), sidebarThreadSortOrder, )[0]; @@ -2549,7 +2577,7 @@ export default function Sidebar() { [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey], ); - const addProjectFromPath = useCallback( + const addProjectFromInput = useCallback( async (rawCwd: string) => { const cwd = rawCwd.trim(); if (!cwd || isAddingProject) return; @@ -2575,7 +2603,6 @@ export default function Sidebar() { } const projectId = newProjectId(); - const createdAt = new Date().toISOString(); const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; try { await api.orchestration.dispatchCommand({ @@ -2588,7 +2615,7 @@ export default function Sidebar() { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, - createdAt, + createdAt: new Date().toISOString(), }); if (activeEnvironmentId !== null) { await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { @@ -2624,7 +2651,7 @@ export default function Sidebar() { ); const handleAddProject = () => { - void addProjectFromPath(newCwd); + void addProjectFromInput(newCwd); }; const canAddProject = newCwd.trim().length > 0 && !isAddingProject; @@ -2640,7 +2667,7 @@ export default function Sidebar() { // Ignore picker failures and leave the current thread selection unchanged. } if (pickedPath) { - await addProjectFromPath(pickedPath); + await addProjectFromInput(pickedPath); } else if (!shouldBrowseForProjectImmediately) { addProjectInputRef.current?.focus(); } @@ -2771,7 +2798,7 @@ export default function Sidebar() { const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { - const projectThreads = sortThreadsForSidebar( + const projectThreads = sortThreads( (threadsByProjectKey.get(project.projectKey) ?? []).filter( (thread) => thread.archivedAt === null, ), @@ -3042,6 +3069,11 @@ export default function Sidebar() { desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; + const commandPaletteShortcutLabel = shortcutLabelForCommand( + keybindings, + "commandPalette.toggle", + newThreadShortcutLabelOptions, + ); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; @@ -3167,6 +3199,7 @@ export default function Sidebar() { activeRouteProjectKey={activeRouteProjectKey} routeThreadKey={routeThreadKey} newThreadShortcutLabel={newThreadShortcutLabel} + commandPaletteShortcutLabel={commandPaletteShortcutLabel} threadJumpLabelByKey={visibleThreadJumpLabelByKey} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index a2bc59c092f..0bd1c7a5737 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -30,7 +30,7 @@ function CommandDialogBackdrop({ className, ...props }: CommandDialogPrimitive.B return ( ) { +}: React.ComponentProps & { + wrapperClassName?: string | undefined; +}) { return ( -
+
; + +export const ComposerHandleContext = createContext(null); + +export function useComposerHandleContext(): ComposerHandleRef | null { + return useContext(ComposerHandleContext); +} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index eba0bd3b462..613f67d5487 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -101,6 +101,11 @@ const DEFAULT_BINDINGS = compile([ command: "diff.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: modShortcut("k"), + command: "commandPalette.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, @@ -249,6 +254,10 @@ describe("shortcutLabelForCommand", () => { it("returns effective labels for non-terminal commands", () => { assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), + "⌘K", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), "Ctrl+O", @@ -382,6 +391,23 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches commandPalette.toggle shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "commandPalette.toggle", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "commandPalette.toggle", + ); + }); + it("matches diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts new file mode 100644 index 00000000000..9f51264bab0 --- /dev/null +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -0,0 +1,107 @@ +import { scopeProjectRef } from "@t3tools/client-runtime"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vitest"; +import { + resolveThreadActionProjectRef, + startNewLocalThreadFromContext, + startNewThreadFromContext, + type ChatThreadActionContext, +} from "./chatThreadActions"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const PROJECT_ID = ProjectId.make("project-1"); +const FALLBACK_PROJECT_ID = ProjectId.make("project-2"); + +function createContext(overrides: Partial = {}): ChatThreadActionContext { + return { + activeDraftThread: null, + activeThread: undefined, + defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID), + defaultThreadEnvMode: "local", + handleNewThread: async () => {}, + ...overrides, + }; +} + +describe("chatThreadActions", () => { + it("prefers the active draft thread project when resolving thread actions", () => { + const projectRef = resolveThreadActionProjectRef( + createContext({ + activeDraftThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + envMode: "worktree", + }, + }), + ); + + expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); + }); + + it("falls back to the default project ref when there is no active thread context", () => { + const projectRef = resolveThreadActionProjectRef( + createContext({ + defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), + }), + ); + + expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); + }); + + it("starts a contextual new thread from the active draft thread", async () => { + const handleNewThread = vi.fn(async () => {}); + + const didStart = await startNewThreadFromContext( + createContext({ + activeDraftThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + envMode: "worktree", + }, + handleNewThread, + }), + ); + + expect(didStart).toBe(true); + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + envMode: "worktree", + }); + }); + + it("starts a local thread with the configured default env mode", async () => { + const handleNewThread = vi.fn(async () => {}); + + const didStart = await startNewLocalThreadFromContext( + createContext({ + defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), + defaultThreadEnvMode: "worktree", + handleNewThread, + }), + ); + + expect(didStart).toBe(true); + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + envMode: "worktree", + }); + }); + + it("does not start a thread when there is no project context", async () => { + const handleNewThread = vi.fn(async () => {}); + + const didStart = await startNewThreadFromContext( + createContext({ + defaultProjectRef: null, + handleNewThread, + }), + ); + + expect(didStart).toBe(false); + expect(handleNewThread).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts new file mode 100644 index 00000000000..39826e8af3d --- /dev/null +++ b/apps/web/src/lib/chatThreadActions.ts @@ -0,0 +1,98 @@ +import { scopeProjectRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts"; +import type { DraftThreadEnvMode } from "../composerDraftStore"; + +interface ThreadContextLike { + environmentId: EnvironmentId; + projectId: ProjectId; + branch: string | null; + worktreePath: string | null; +} + +interface DraftThreadContextLike extends ThreadContextLike { + envMode: DraftThreadEnvMode; +} + +interface NewThreadHandler { + ( + projectRef: ScopedProjectRef, + options?: { + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; + }, + ): Promise; +} + +type NewThreadOptions = NonNullable[1]>; + +export interface ChatThreadActionContext { + readonly activeDraftThread: DraftThreadContextLike | null; + readonly activeThread: ThreadContextLike | undefined; + readonly defaultProjectRef: ScopedProjectRef | null; + readonly defaultThreadEnvMode: DraftThreadEnvMode; + readonly handleNewThread: NewThreadHandler; +} + +export function resolveThreadActionProjectRef( + context: ChatThreadActionContext, +): ScopedProjectRef | null { + if (context.activeThread) { + return scopeProjectRef(context.activeThread.environmentId, context.activeThread.projectId); + } + if (context.activeDraftThread) { + return scopeProjectRef( + context.activeDraftThread.environmentId, + context.activeDraftThread.projectId, + ); + } + return context.defaultProjectRef; +} + +function buildContextualThreadOptions(context: ChatThreadActionContext): NewThreadOptions { + return { + branch: context.activeThread?.branch ?? context.activeDraftThread?.branch ?? null, + worktreePath: + context.activeThread?.worktreePath ?? context.activeDraftThread?.worktreePath ?? null, + envMode: + context.activeDraftThread?.envMode ?? + (context.activeThread?.worktreePath ? "worktree" : "local"), + }; +} + +function buildDefaultThreadOptions(context: ChatThreadActionContext): NewThreadOptions { + return { + envMode: context.defaultThreadEnvMode, + }; +} + +export async function startNewThreadInProjectFromContext( + context: ChatThreadActionContext, + projectRef: ScopedProjectRef, +): Promise { + await context.handleNewThread(projectRef, buildContextualThreadOptions(context)); +} + +export async function startNewThreadFromContext( + context: ChatThreadActionContext, +): Promise { + const projectRef = resolveThreadActionProjectRef(context); + if (!projectRef) { + return false; + } + + await startNewThreadInProjectFromContext(context, projectRef); + return true; +} + +export async function startNewLocalThreadFromContext( + context: ChatThreadActionContext, +): Promise { + const projectRef = resolveThreadActionProjectRef(context); + if (!projectRef) { + return false; + } + + await context.handleNewThread(projectRef, buildDefaultThreadOptions(context)); + return true; +} diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts new file mode 100644 index 00000000000..f80260a69da --- /dev/null +++ b/apps/web/src/lib/threadSort.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_RUNTIME_MODE, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import type { Thread } from "../types"; +import { getLatestThreadForProject, sortThreads } from "./threadSort"; + +const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const PROJECT_ID = ProjectId.make("project-1"); + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.make("thread-1"), + environmentId: LOCAL_ENVIRONMENT_ID, + codexThreadId: null, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-09T10:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-09T10:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + ...overrides, + }; +} + +describe("sortThreads", () => { + it("sorts threads by the latest user message in recency mode", () => { + const sorted = sortThreads( + [ + makeThread({ + id: ThreadId.make("thread-1"), + updatedAt: "2026-03-09T10:10:00.000Z", + messages: [ + { + id: "message-1" as never, + role: "user", + text: "older", + createdAt: "2026-03-09T10:01:00.000Z", + streaming: false, + completedAt: "2026-03-09T10:01:00.000Z", + }, + ], + }), + makeThread({ + id: ThreadId.make("thread-2"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + messages: [ + { + id: "message-2" as never, + role: "user", + text: "newer", + createdAt: "2026-03-09T10:06:00.000Z", + streaming: false, + completedAt: "2026-03-09T10:06:00.000Z", + }, + ], + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.make("thread-2"), + ThreadId.make("thread-1"), + ]); + }); + + it("falls back to thread timestamps when there is no user message", () => { + const sorted = sortThreads( + [ + makeThread({ + id: ThreadId.make("thread-1"), + updatedAt: "2026-03-09T10:01:00.000Z", + messages: [ + { + id: "message-1" as never, + role: "assistant", + text: "assistant only", + createdAt: "2026-03-09T10:02:00.000Z", + streaming: false, + completedAt: "2026-03-09T10:02:00.000Z", + }, + ], + }), + makeThread({ + id: ThreadId.make("thread-2"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + messages: [], + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.make("thread-2"), + ThreadId.make("thread-1"), + ]); + }); + + it("falls back to id ordering when threads have no sortable timestamps", () => { + const sorted = sortThreads( + [ + makeThread({ + id: ThreadId.make("thread-1"), + createdAt: "" as never, + updatedAt: undefined, + messages: [], + }), + makeThread({ + id: ThreadId.make("thread-2"), + createdAt: "" as never, + updatedAt: undefined, + messages: [], + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.make("thread-2"), + ThreadId.make("thread-1"), + ]); + }); + + it("can sort threads by createdAt when configured", () => { + const sorted = sortThreads( + [ + makeThread({ + id: ThreadId.make("thread-1"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-2"), + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:10:00.000Z", + }), + ], + "created_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.make("thread-1"), + ThreadId.make("thread-2"), + ]); + }); + + it("returns the latest active thread for a project", () => { + const latestThread = getLatestThreadForProject( + [ + makeThread({ + id: ThreadId.make("thread-1"), + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + archivedAt: null, + }), + makeThread({ + id: ThreadId.make("thread-2"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:10:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-3"), + createdAt: "2026-03-09T10:06:00.000Z", + updatedAt: "2026-03-09T10:06:00.000Z", + archivedAt: null, + }), + ], + PROJECT_ID, + "updated_at", + ); + + expect(latestThread?.id).toBe(ThreadId.make("thread-3")); + }); +}); diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts new file mode 100644 index 00000000000..837a7788d38 --- /dev/null +++ b/apps/web/src/lib/threadSort.ts @@ -0,0 +1,73 @@ +import type { ProjectId } from "@t3tools/contracts"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import type { Thread } from "../types"; + +export type ThreadSortInput = Pick & { + latestUserMessageAt?: string | null; + messages?: Pick[]; +}; + +export function toSortableTimestamp(iso: string | undefined): number | null { + if (!iso) return null; + const ms = Date.parse(iso); + return Number.isFinite(ms) ? ms : null; +} + +function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { + if (thread.latestUserMessageAt) { + return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; + } + + let latestUserMessageTimestamp: number | null = null; + + for (const message of thread.messages ?? []) { + if (message.role !== "user") continue; + const messageTimestamp = toSortableTimestamp(message.createdAt); + if (messageTimestamp === null) continue; + latestUserMessageTimestamp = + latestUserMessageTimestamp === null + ? messageTimestamp + : Math.max(latestUserMessageTimestamp, messageTimestamp); + } + + if (latestUserMessageTimestamp !== null) { + return latestUserMessageTimestamp; + } + + return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY; +} + +export function getThreadSortTimestamp( + thread: ThreadSortInput, + sortOrder: SidebarThreadSortOrder | Exclude, +): number { + if (sortOrder === "created_at") { + return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY; + } + return getLatestUserMessageTimestamp(thread); +} + +export function sortThreads & ThreadSortInput>( + threads: readonly T[], + sortOrder: SidebarThreadSortOrder, +): T[] { + return threads.toSorted((left, right) => { + const rightTimestamp = getThreadSortTimestamp(right, sortOrder); + const leftTimestamp = getThreadSortTimestamp(left, sortOrder); + const byTimestamp = + rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; + if (byTimestamp !== 0) return byTimestamp; + return right.id.localeCompare(left.id); + }); +} + +export function getLatestThreadForProject< + T extends Pick & ThreadSortInput, +>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { + return ( + sortThreads( + threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), + sortOrder, + )[0] ?? null + ); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ce8d0d5dc9e..8c5046af362 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -12,6 +12,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; +import { CommandPalette } from "../components/CommandPalette"; import { SlowRpcAckToastCoordinator, WebSocketConnectionCoordinator, @@ -93,9 +94,11 @@ function RootRouteView() { - - - + + + + + diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 51d6d3eee2d..b72e504fae9 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,12 +1,16 @@ -import { scopeProjectRef } from "@t3tools/client-runtime"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect } from "react"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import { ensurePrimaryEnvironmentReady, resolveInitialServerAuthGateState, } from "../environments/primary"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { + startNewLocalThreadFromContext, + startNewThreadFromContext, +} from "../lib/chatThreadActions"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; @@ -31,20 +35,6 @@ function ChatRouteGlobalShortcuts() { useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - - if (event.key === "Escape" && selectedThreadKeysSize > 0) { - event.preventDefault(); - clearSelection(); - return; - } - - const projectRef = activeThread - ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) - : activeDraftThread && routeThreadRef - ? scopeProjectRef(routeThreadRef.environmentId, activeDraftThread.projectId) - : defaultProjectRef; - if (!projectRef) return; - const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: isTerminalFocused(), @@ -52,13 +42,27 @@ function ChatRouteGlobalShortcuts() { }, }); + if (useCommandPaletteStore.getState().open) { + return; + } + + if (event.key === "Escape" && selectedThreadKeysSize > 0) { + event.preventDefault(); + clearSelection(); + return; + } + if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectRef, { - envMode: resolveSidebarNewThreadEnvMode({ + void startNewLocalThreadFromContext({ + activeDraftThread, + activeThread, + defaultProjectRef, + defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, }), + handleNewThread, }); return; } @@ -66,13 +70,15 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectRef, { - branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, - worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, - envMode: - activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), + void startNewThreadFromContext({ + activeDraftThread, + activeThread, + defaultProjectRef, + defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ + defaultEnvMode: appSettings.defaultThreadEnvMode, + }), + handleNewThread, }); - return; } }; @@ -87,7 +93,6 @@ function ChatRouteGlobalShortcuts() { handleNewThread, keybindings, defaultProjectRef, - routeThreadRef, selectedThreadKeysSize, terminalOpen, appSettings.defaultThreadEnvMode, diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index c3a7d9f00ed..092d5344f2b 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -41,6 +41,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedDiffToggle.command, "diff.toggle"); + const parsedCommandPalette = yield* decode(KeybindingRule, { + key: "mod+k", + command: "commandPalette.toggle", + }); + assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle"); + const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index b08fff8679a..72067eac8a8 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -33,6 +33,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "diff.toggle", + "commandPalette.toggle", "chat.new", "chat.newLocal", "editor.openFavorite",