From 65da51f385a17f7406ea422369d5c868461a58f4 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 15 Mar 2026 10:13:39 +0100 Subject: [PATCH 1/8] Add command palette project search and thread timestamps --- apps/web/src/components/ChatView.browser.tsx | 92 +++++++++++++++- apps/web/src/components/CommandPalette.tsx | 104 +++++++++++++++---- apps/web/src/relativeTime.test.ts | 16 +++ apps/web/src/relativeTime.ts | 39 +++++++ 4 files changed, 227 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/relativeTime.test.ts create mode 100644 apps/web/src/relativeTime.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 515833b5b40..129c0154c50 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -29,6 +29,7 @@ import { estimateTimelineMessageHeight } from "./timelineHeight"; const THREAD_ID = "thread-browser-test" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; +const SECOND_PROJECT_ID = "project-2" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -356,6 +357,30 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithSecondaryProject(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-secondary-project-target" as MessageId, + targetText: "secondary project", + }); + + return { + ...snapshot, + projects: [ + ...snapshot.projects, + { + id: SECOND_PROJECT_ID, + title: "Docs Portal", + workspaceRoot: "/repo/clients/docs-portal", + defaultModel: "gpt-5", + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { @@ -1242,7 +1267,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ); await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); - await page.getByPlaceholder("Search commands and threads...").fill("settings"); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings"); await expect.element(page.getByText("Open settings")).toBeInTheDocument(); await expect.element(page.getByText("New thread")).not.toBeInTheDocument(); } finally { @@ -1250,6 +1275,71 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("searches projects by path and opens a new thread using the default env mode", async () => { + localStorage.setItem( + "t3code:app-settings:v1", + JSON.stringify({ defaultThreadEnvMode: "worktree" }), + ); + + 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 { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); + await expect.element(page.getByText("Docs Portal")).toBeInTheDocument(); + await expect.element(page.getByText("/repo/clients/docs-portal")).toBeInTheDocument(); + await page.getByText("Docs Portal").click(); + + const nextPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path) && path !== `/${THREAD_ID}`, + "Route should have changed to a new draft thread UUID from the project search result.", + ); + const nextThreadId = nextPath.slice(1) as ThreadId; + const draftThread = useComposerDraftStore.getState().draftThreadsByThreadId[nextThreadId]; + expect(draftThread?.projectId).toBe(SECOND_PROJECT_ID); + expect(draftThread?.envMode).toBe("worktree"); + } finally { + await mounted.cleanup(); + } + }); + it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d4a9efb10b3..94608460e6d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -3,7 +3,7 @@ import { type KeybindingCommand } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { MessageSquareIcon, SettingsIcon, SquarePenIcon } from "lucide-react"; +import { FolderIcon, MessageSquareIcon, SettingsIcon, SquarePenIcon } from "lucide-react"; import { createContext, useCallback, @@ -22,6 +22,7 @@ import { import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; import { shortcutLabelForCommand } from "../keybindings"; +import { formatRelativeTime } from "../relativeTime"; import { useStore } from "../store"; import { Kbd, KbdGroup } from "./ui/kbd"; import { @@ -54,6 +55,7 @@ interface CommandPaletteItem { readonly label: string; readonly title: string; readonly description?: string; + readonly timestamp?: string; readonly icon: ReactNode; readonly shortcutCommand?: KeybindingCommand; readonly run: () => Promise; @@ -71,6 +73,50 @@ function iconClassName() { return "size-4 text-muted-foreground/80"; } +function resolveThreadLastActivityAt(thread: { + createdAt: string; + latestTurn: { + completedAt: string | null; + startedAt: string | null; + requestedAt: string; + } | null; +}): string { + return ( + thread.latestTurn?.completedAt ?? + thread.latestTurn?.startedAt ?? + thread.latestTurn?.requestedAt ?? + thread.createdAt + ); +} + +function compareThreadsByLastActivityDesc( + left: { + id: string; + createdAt: string; + latestTurn: { + completedAt: string | null; + startedAt: string | null; + requestedAt: string; + } | null; + }, + right: { + id: string; + createdAt: string; + latestTurn: { + completedAt: string | null; + startedAt: string | null; + requestedAt: string; + } | null; + }, +): number { + const byTimestamp = + Date.parse(resolveThreadLastActivityAt(right)) - Date.parse(resolveThreadLastActivityAt(left)); + if (!Number.isNaN(byTimestamp) && byTimestamp !== 0) { + return byTimestamp; + } + return right.id.localeCompare(left.id); +} + function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } @@ -191,26 +237,21 @@ function OpenCommandPaletteDialog() { }, }); + const projectItems = projects.map((project) => ({ + value: `project:${project.id}`, + label: `${project.name} ${project.cwd}`.trim(), + title: project.name, + description: project.cwd, + icon: , + run: async () => { + await handleNewThread(project.id, { + envMode: settings.defaultThreadEnvMode, + }); + }, + })); + const recentThreadItems = threads - .toSorted((left, right) => { - const rightTimestamp = Date.parse( - right.latestTurn?.completedAt ?? - right.latestTurn?.startedAt ?? - right.latestTurn?.requestedAt ?? - right.createdAt, - ); - const leftTimestamp = Date.parse( - left.latestTurn?.completedAt ?? - left.latestTurn?.startedAt ?? - left.latestTurn?.requestedAt ?? - left.createdAt, - ); - const byTimestamp = rightTimestamp - leftTimestamp; - if (byTimestamp !== 0) { - return byTimestamp; - } - return right.id.localeCompare(left.id); - }) + .toSorted(compareThreadsByLastActivityDesc) .slice(0, RECENT_THREAD_LIMIT) .map((thread) => { const projectTitle = projectTitleById.get(thread.projectId); @@ -225,6 +266,7 @@ function OpenCommandPaletteDialog() { label: `${thread.title} ${projectTitle ?? ""} ${thread.branch ?? ""}`.trim(), title: thread.title, description: descriptionParts.join(" · "), + timestamp: formatRelativeTime(resolveThreadLastActivityAt(thread)), icon: , run: async () => { await navigate({ @@ -250,6 +292,13 @@ function OpenCommandPaletteDialog() { items: recentThreadItems, }); } + if (projectItems.length > 0) { + nextGroups.push({ + value: "projects", + label: "Projects", + items: projectItems, + }); + } return nextGroups; }, [ activeDraftThread, @@ -302,7 +351,7 @@ function OpenCommandPaletteDialog() { data-testid="command-palette" > - + {filteredGroups.map((group) => ( @@ -335,6 +384,11 @@ function OpenCommandPaletteDialog() { ) : null} + {item.timestamp ? ( + + {item.timestamp} + + ) : null} {shortcutLabel ? {shortcutLabel} : null} ); @@ -343,10 +397,14 @@ function OpenCommandPaletteDialog() { ))} - No matching commands or threads. + + No matching commands, projects, or threads. + - Search actions and jump back into recent threads. + + Search actions, start a thread in any project, or jump back into recent threads. +
Enter diff --git a/apps/web/src/relativeTime.test.ts b/apps/web/src/relativeTime.test.ts new file mode 100644 index 00000000000..eecf1a51168 --- /dev/null +++ b/apps/web/src/relativeTime.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { formatRelativeTime } from "./relativeTime"; + +describe("formatRelativeTime", () => { + const nowMs = Date.parse("2026-03-15T12:00:00.000Z"); + + it("returns just now for times under a minute old", () => { + expect(formatRelativeTime("2026-03-15T11:59:45.000Z", nowMs)).toBe("just now"); + }); + + it("formats minutes, hours, and days ago", () => { + expect(formatRelativeTime("2026-03-15T11:55:00.000Z", nowMs)).toBe("5 minutes ago"); + expect(formatRelativeTime("2026-03-15T09:00:00.000Z", nowMs)).toBe("3 hours ago"); + expect(formatRelativeTime("2026-03-12T12:00:00.000Z", nowMs)).toBe("3 days ago"); + }); +}); diff --git a/apps/web/src/relativeTime.ts b/apps/web/src/relativeTime.ts new file mode 100644 index 00000000000..59f27bdf2d6 --- /dev/null +++ b/apps/web/src/relativeTime.ts @@ -0,0 +1,39 @@ +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const WEEK_MS = 7 * DAY_MS; +const MONTH_MS = 30 * DAY_MS; +const YEAR_MS = 365 * DAY_MS; + +function formatRelativeUnit(value: number, unit: Intl.RelativeTimeFormatUnit): string { + return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format(-value, unit); +} + +export function formatRelativeTime(isoDate: string, nowMs = Date.now()): string { + const targetMs = Date.parse(isoDate); + if (Number.isNaN(targetMs)) { + return ""; + } + + const diffMs = Math.max(0, nowMs - targetMs); + + if (diffMs < MINUTE_MS) { + return "just now"; + } + if (diffMs < HOUR_MS) { + return formatRelativeUnit(Math.floor(diffMs / MINUTE_MS), "minute"); + } + if (diffMs < DAY_MS) { + return formatRelativeUnit(Math.floor(diffMs / HOUR_MS), "hour"); + } + if (diffMs < WEEK_MS) { + return formatRelativeUnit(Math.floor(diffMs / DAY_MS), "day"); + } + if (diffMs < MONTH_MS) { + return formatRelativeUnit(Math.floor(diffMs / WEEK_MS), "week"); + } + if (diffMs < YEAR_MS) { + return formatRelativeUnit(Math.floor(diffMs / MONTH_MS), "month"); + } + return formatRelativeUnit(Math.floor(diffMs / YEAR_MS), "year"); +} From 031c165d44fbb1f42cc16c17a57ec3e67ee1d7be Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 15 Mar 2026 10:18:02 +0100 Subject: [PATCH 2/8] Refine command palette project ordering --- apps/web/src/components/ChatView.browser.tsx | 68 ++++++++++++++++++++ apps/web/src/components/CommandPalette.tsx | 30 ++++++--- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 129c0154c50..3b720dd8bc7 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -533,6 +533,23 @@ async function waitForElement( return element; } +async function waitForGroupLabels(): Promise { + let labels: string[] = []; + await vi.waitFor( + () => { + labels = Array.from( + document.querySelectorAll('[data-slot="command-group-label"]'), + ).map((label) => label.textContent?.trim() ?? ""); + expect(labels.length).toBeGreaterThan(0); + }, + { + timeout: 8_000, + interval: 16, + }, + ); + return labels; +} + async function waitForURL( router: ReturnType, predicate: (pathname: string) => boolean, @@ -1340,6 +1357,57 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("prioritizes projects over threads while searching", 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 { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("project"); + const labels = await waitForGroupLabels(); + expect(labels.indexOf("Projects")).toBeGreaterThanOrEqual(0); + expect(labels.indexOf("Recent Threads")).toBeGreaterThanOrEqual(0); + expect(labels.indexOf("Projects")).toBeLessThan(labels.indexOf("Recent Threads")); + } finally { + await mounted.cleanup(); + } + }); + it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 94608460e6d..949da12ec4e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -121,6 +121,19 @@ function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } +function groupPriorityForQuery(group: CommandPaletteGroup): number { + switch (group.value) { + case "actions": + return 0; + case "projects": + return 1; + case "recent-threads": + return 2; + default: + return 3; + } +} + export function useCommandPalette() { const context = useContext(CommandPaletteContext); if (!context) { @@ -285,13 +298,6 @@ function OpenCommandPaletteDialog() { items: actionItems, }); } - if (recentThreadItems.length > 0) { - nextGroups.push({ - value: "recent-threads", - label: "Recent Threads", - items: recentThreadItems, - }); - } if (projectItems.length > 0) { nextGroups.push({ value: "projects", @@ -299,6 +305,13 @@ function OpenCommandPaletteDialog() { items: projectItems, }); } + if (recentThreadItems.length > 0) { + nextGroups.push({ + value: "recent-threads", + label: "Recent Threads", + items: recentThreadItems, + }); + } return nextGroups; }, [ activeDraftThread, @@ -327,7 +340,8 @@ function OpenCommandPaletteDialog() { return haystack.includes(normalizedQuery); }), })) - .filter((group) => group.items.length > 0); + .filter((group) => group.items.length > 0) + .toSorted((left, right) => groupPriorityForQuery(left) - groupPriorityForQuery(right)); }, [allGroups, deferredQuery]); const executeItem = useCallback( From 5fdb8f609f292b54543ce6cc0fda702f0b5839bb Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 15 Mar 2026 10:28:28 +0100 Subject: [PATCH 3/8] Refactor shared thread timestamp helpers --- apps/web/src/components/CommandPalette.tsx | 79 ++++++---------------- apps/web/src/components/Sidebar.tsx | 11 +-- apps/web/src/relativeTime.ts | 6 +- apps/web/src/threadPresentation.test.ts | 76 +++++++++++++++++++++ apps/web/src/threadPresentation.ts | 26 +++++++ 5 files changed, 130 insertions(+), 68 deletions(-) create mode 100644 apps/web/src/threadPresentation.test.ts create mode 100644 apps/web/src/threadPresentation.ts diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 949da12ec4e..f43032f068b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -24,6 +24,10 @@ import { cn } from "../lib/utils"; import { shortcutLabelForCommand } from "../keybindings"; import { formatRelativeTime } from "../relativeTime"; import { useStore } from "../store"; +import { + compareThreadsByLastActivityDesc, + resolveThreadLastActivityAt, +} from "../threadPresentation"; import { Kbd, KbdGroup } from "./ui/kbd"; import { Command, @@ -68,70 +72,31 @@ interface CommandPaletteGroup { } const CommandPaletteContext = createContext(null); +const COMMAND_PALETTE_GROUP_ORDER = ["actions", "projects", "recent-threads"] as const; +const COMMAND_PALETTE_GROUP_PRIORITY = new Map( + COMMAND_PALETTE_GROUP_ORDER.map((value, index) => [value, index] as const), +); function iconClassName() { return "size-4 text-muted-foreground/80"; } -function resolveThreadLastActivityAt(thread: { - createdAt: string; - latestTurn: { - completedAt: string | null; - startedAt: string | null; - requestedAt: string; - } | null; -}): string { - return ( - thread.latestTurn?.completedAt ?? - thread.latestTurn?.startedAt ?? - thread.latestTurn?.requestedAt ?? - thread.createdAt - ); -} - -function compareThreadsByLastActivityDesc( - left: { - id: string; - createdAt: string; - latestTurn: { - completedAt: string | null; - startedAt: string | null; - requestedAt: string; - } | null; - }, - right: { - id: string; - createdAt: string; - latestTurn: { - completedAt: string | null; - startedAt: string | null; - requestedAt: string; - } | null; - }, -): number { - const byTimestamp = - Date.parse(resolveThreadLastActivityAt(right)) - Date.parse(resolveThreadLastActivityAt(left)); - if (!Number.isNaN(byTimestamp) && byTimestamp !== 0) { - return byTimestamp; - } - return right.id.localeCompare(left.id); -} - function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } -function groupPriorityForQuery(group: CommandPaletteGroup): number { - switch (group.value) { - case "actions": - return 0; - case "projects": - return 1; - case "recent-threads": - return 2; - default: - return 3; - } +function compareCommandPaletteGroups( + left: CommandPaletteGroup, + right: CommandPaletteGroup, +): number { + return ( + (COMMAND_PALETTE_GROUP_PRIORITY.get( + left.value as (typeof COMMAND_PALETTE_GROUP_ORDER)[number], + ) ?? COMMAND_PALETTE_GROUP_ORDER.length) - + (COMMAND_PALETTE_GROUP_PRIORITY.get( + right.value as (typeof COMMAND_PALETTE_GROUP_ORDER)[number], + ) ?? COMMAND_PALETTE_GROUP_ORDER.length) + ); } export function useCommandPalette() { @@ -312,7 +277,7 @@ function OpenCommandPaletteDialog() { items: recentThreadItems, }); } - return nextGroups; + return nextGroups.toSorted(compareCommandPaletteGroups); }, [ activeDraftThread, activeThread, @@ -341,7 +306,7 @@ function OpenCommandPaletteDialog() { }), })) .filter((group) => group.items.length > 0) - .toSorted((left, right) => groupPriorityForQuery(left) - groupPriorityForQuery(right)); + .toSorted(compareCommandPaletteGroups); }, [allGroups, deferredQuery]); const executeItem = useCallback( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 57c35a31ba7..dc93edb585a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -41,6 +41,7 @@ import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { formatRelativeTime } from "../relativeTime"; import { useStore } from "../store"; import { shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; @@ -97,16 +98,6 @@ import { CommandDialogTrigger } from "./ui/command"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; -function formatRelativeTime(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); - const minutes = Math.floor(diff / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -} - interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; diff --git a/apps/web/src/relativeTime.ts b/apps/web/src/relativeTime.ts index 59f27bdf2d6..7f88efe3e82 100644 --- a/apps/web/src/relativeTime.ts +++ b/apps/web/src/relativeTime.ts @@ -4,9 +4,13 @@ const DAY_MS = 24 * HOUR_MS; const WEEK_MS = 7 * DAY_MS; const MONTH_MS = 30 * DAY_MS; const YEAR_MS = 365 * DAY_MS; +let relativeTimeFormatter: Intl.RelativeTimeFormat | null = null; function formatRelativeUnit(value: number, unit: Intl.RelativeTimeFormatUnit): string { - return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format(-value, unit); + if (relativeTimeFormatter === null) { + relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + } + return relativeTimeFormatter.format(-value, unit); } export function formatRelativeTime(isoDate: string, nowMs = Date.now()): string { diff --git a/apps/web/src/threadPresentation.test.ts b/apps/web/src/threadPresentation.test.ts new file mode 100644 index 00000000000..a73f1b49731 --- /dev/null +++ b/apps/web/src/threadPresentation.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + compareThreadsByLastActivityDesc, + resolveThreadLastActivityAt, +} from "./threadPresentation"; + +function makeLatestTurn(overrides?: { + requestedAt?: string | null; + startedAt?: string | null; + completedAt?: string | null; +}) { + return { + turnId: "turn-1" as never, + state: "completed" as const, + assistantMessageId: null, + requestedAt: overrides?.requestedAt ?? "2026-03-09T10:00:00.000Z", + startedAt: overrides?.startedAt ?? "2026-03-09T10:01:00.000Z", + completedAt: overrides?.completedAt ?? "2026-03-09T10:05:00.000Z", + }; +} + +describe("resolveThreadLastActivityAt", () => { + it("prefers completedAt, then startedAt, then requestedAt, then createdAt", () => { + expect( + resolveThreadLastActivityAt({ + createdAt: "2026-03-09T09:00:00.000Z", + latestTurn: makeLatestTurn(), + }), + ).toBe("2026-03-09T10:05:00.000Z"); + + expect( + resolveThreadLastActivityAt({ + createdAt: "2026-03-09T09:00:00.000Z", + latestTurn: makeLatestTurn({ completedAt: null }), + }), + ).toBe("2026-03-09T10:01:00.000Z"); + + expect( + resolveThreadLastActivityAt({ + createdAt: "2026-03-09T09:00:00.000Z", + latestTurn: makeLatestTurn({ completedAt: null, startedAt: null }), + }), + ).toBe("2026-03-09T10:00:00.000Z"); + + expect( + resolveThreadLastActivityAt({ + createdAt: "2026-03-09T09:00:00.000Z", + latestTurn: null, + }), + ).toBe("2026-03-09T09:00:00.000Z"); + }); +}); + +describe("compareThreadsByLastActivityDesc", () => { + it("sorts newer thread activity before older thread activity", () => { + const sorted = [ + { + id: "thread-a" as never, + createdAt: "2026-03-09T09:00:00.000Z", + latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:01:00.000Z" }), + }, + { + id: "thread-c" as never, + createdAt: "2026-03-09T08:00:00.000Z", + latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:05:00.000Z" }), + }, + { + id: "thread-b" as never, + createdAt: "2026-03-09T10:05:00.000Z", + latestTurn: null, + }, + ].toSorted(compareThreadsByLastActivityDesc); + + expect(sorted.map((thread) => thread.id)).toEqual(["thread-c", "thread-b", "thread-a"]); + }); +}); diff --git a/apps/web/src/threadPresentation.ts b/apps/web/src/threadPresentation.ts new file mode 100644 index 00000000000..129beb23c26 --- /dev/null +++ b/apps/web/src/threadPresentation.ts @@ -0,0 +1,26 @@ +import type { Thread } from "./types"; + +export type ThreadActivityLike = Pick; + +export function resolveThreadLastActivityAt( + thread: Pick, +): string { + return ( + thread.latestTurn?.completedAt ?? + thread.latestTurn?.startedAt ?? + thread.latestTurn?.requestedAt ?? + thread.createdAt + ); +} + +export function compareThreadsByLastActivityDesc( + left: ThreadActivityLike, + right: ThreadActivityLike, +): number { + const byTimestamp = + Date.parse(resolveThreadLastActivityAt(right)) - Date.parse(resolveThreadLastActivityAt(left)); + if (!Number.isNaN(byTimestamp) && byTimestamp !== 0) { + return byTimestamp; + } + return right.id.localeCompare(left.id); +} From ac63dd84add7f96de9e765a25576bf5003088420 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 15 Mar 2026 10:30:40 +0100 Subject: [PATCH 4/8] Add short shared relative timestamp format --- apps/web/src/components/Sidebar.tsx | 6 +++++- apps/web/src/relativeTime.test.ts | 6 ++++++ apps/web/src/relativeTime.ts | 27 ++++++++++++++++++++------- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dc93edb585a..5cc62efc5a4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1572,7 +1572,11 @@ export default function Sidebar() { : "text-muted-foreground/40" }`} > - {formatRelativeTime(thread.createdAt)} + {formatRelativeTime( + thread.createdAt, + Date.now(), + "short", + )}
diff --git a/apps/web/src/relativeTime.test.ts b/apps/web/src/relativeTime.test.ts index eecf1a51168..dd076ab1968 100644 --- a/apps/web/src/relativeTime.test.ts +++ b/apps/web/src/relativeTime.test.ts @@ -13,4 +13,10 @@ describe("formatRelativeTime", () => { expect(formatRelativeTime("2026-03-15T09:00:00.000Z", nowMs)).toBe("3 hours ago"); expect(formatRelativeTime("2026-03-12T12:00:00.000Z", nowMs)).toBe("3 days ago"); }); + + it("supports compact m/h/d formatting", () => { + expect(formatRelativeTime("2026-03-15T11:55:00.000Z", nowMs, "short")).toBe("5m ago"); + expect(formatRelativeTime("2026-03-15T09:00:00.000Z", nowMs, "short")).toBe("3h ago"); + expect(formatRelativeTime("2026-03-12T12:00:00.000Z", nowMs, "short")).toBe("3d ago"); + }); }); diff --git a/apps/web/src/relativeTime.ts b/apps/web/src/relativeTime.ts index 7f88efe3e82..c0c6ae2dbdc 100644 --- a/apps/web/src/relativeTime.ts +++ b/apps/web/src/relativeTime.ts @@ -5,6 +5,7 @@ const WEEK_MS = 7 * DAY_MS; const MONTH_MS = 30 * DAY_MS; const YEAR_MS = 365 * DAY_MS; let relativeTimeFormatter: Intl.RelativeTimeFormat | null = null; +export type RelativeTimeStyle = "long" | "short"; function formatRelativeUnit(value: number, unit: Intl.RelativeTimeFormatUnit): string { if (relativeTimeFormatter === null) { @@ -13,31 +14,43 @@ function formatRelativeUnit(value: number, unit: Intl.RelativeTimeFormatUnit): s return relativeTimeFormatter.format(-value, unit); } -export function formatRelativeTime(isoDate: string, nowMs = Date.now()): string { +function formatShortRelativeUnit(value: number, suffix: string): string { + return `${value}${suffix} ago`; +} + +export function formatRelativeTime( + isoDate: string, + nowMs = Date.now(), + style: RelativeTimeStyle = "long", +): string { const targetMs = Date.parse(isoDate); if (Number.isNaN(targetMs)) { return ""; } const diffMs = Math.max(0, nowMs - targetMs); + const formatUnit = (value: number, unit: Intl.RelativeTimeFormatUnit, shortSuffix: string) => + style === "short" + ? formatShortRelativeUnit(value, shortSuffix) + : formatRelativeUnit(value, unit); if (diffMs < MINUTE_MS) { return "just now"; } if (diffMs < HOUR_MS) { - return formatRelativeUnit(Math.floor(diffMs / MINUTE_MS), "minute"); + return formatUnit(Math.floor(diffMs / MINUTE_MS), "minute", "m"); } if (diffMs < DAY_MS) { - return formatRelativeUnit(Math.floor(diffMs / HOUR_MS), "hour"); + return formatUnit(Math.floor(diffMs / HOUR_MS), "hour", "h"); } if (diffMs < WEEK_MS) { - return formatRelativeUnit(Math.floor(diffMs / DAY_MS), "day"); + return formatUnit(Math.floor(diffMs / DAY_MS), "day", "d"); } if (diffMs < MONTH_MS) { - return formatRelativeUnit(Math.floor(diffMs / WEEK_MS), "week"); + return formatUnit(Math.floor(diffMs / WEEK_MS), "week", "w"); } if (diffMs < YEAR_MS) { - return formatRelativeUnit(Math.floor(diffMs / MONTH_MS), "month"); + return formatUnit(Math.floor(diffMs / MONTH_MS), "month", "mo"); } - return formatRelativeUnit(Math.floor(diffMs / YEAR_MS), "year"); + return formatUnit(Math.floor(diffMs / YEAR_MS), "year", "y"); } From fba3a1f2ff008895e2d942a08a1c579c1ab87b50 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 15 Mar 2026 10:34:08 +0100 Subject: [PATCH 5/8] Simplify command palette thread timestamp logic --- apps/web/src/components/CommandPalette.tsx | 19 ++++-- apps/web/src/threadPresentation.test.ts | 76 ---------------------- apps/web/src/threadPresentation.ts | 26 -------- 3 files changed, 13 insertions(+), 108 deletions(-) delete mode 100644 apps/web/src/threadPresentation.test.ts delete mode 100644 apps/web/src/threadPresentation.ts diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f43032f068b..f971d55ba4f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -24,10 +24,6 @@ import { cn } from "../lib/utils"; import { shortcutLabelForCommand } from "../keybindings"; import { formatRelativeTime } from "../relativeTime"; import { useStore } from "../store"; -import { - compareThreadsByLastActivityDesc, - resolveThreadLastActivityAt, -} from "../threadPresentation"; import { Kbd, KbdGroup } from "./ui/kbd"; import { Command, @@ -81,6 +77,17 @@ function iconClassName() { return "size-4 text-muted-foreground/80"; } +function compareThreadsByCreatedAtDesc( + left: { id: string; createdAt: string }, + right: { id: string; createdAt: string }, +): number { + const byTimestamp = Date.parse(right.createdAt) - Date.parse(left.createdAt); + if (!Number.isNaN(byTimestamp) && byTimestamp !== 0) { + return byTimestamp; + } + return right.id.localeCompare(left.id); +} + function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } @@ -229,7 +236,7 @@ function OpenCommandPaletteDialog() { })); const recentThreadItems = threads - .toSorted(compareThreadsByLastActivityDesc) + .toSorted(compareThreadsByCreatedAtDesc) .slice(0, RECENT_THREAD_LIMIT) .map((thread) => { const projectTitle = projectTitleById.get(thread.projectId); @@ -244,7 +251,7 @@ function OpenCommandPaletteDialog() { label: `${thread.title} ${projectTitle ?? ""} ${thread.branch ?? ""}`.trim(), title: thread.title, description: descriptionParts.join(" · "), - timestamp: formatRelativeTime(resolveThreadLastActivityAt(thread)), + timestamp: formatRelativeTime(thread.createdAt), icon: , run: async () => { await navigate({ diff --git a/apps/web/src/threadPresentation.test.ts b/apps/web/src/threadPresentation.test.ts deleted file mode 100644 index a73f1b49731..00000000000 --- a/apps/web/src/threadPresentation.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - compareThreadsByLastActivityDesc, - resolveThreadLastActivityAt, -} from "./threadPresentation"; - -function makeLatestTurn(overrides?: { - requestedAt?: string | null; - startedAt?: string | null; - completedAt?: string | null; -}) { - return { - turnId: "turn-1" as never, - state: "completed" as const, - assistantMessageId: null, - requestedAt: overrides?.requestedAt ?? "2026-03-09T10:00:00.000Z", - startedAt: overrides?.startedAt ?? "2026-03-09T10:01:00.000Z", - completedAt: overrides?.completedAt ?? "2026-03-09T10:05:00.000Z", - }; -} - -describe("resolveThreadLastActivityAt", () => { - it("prefers completedAt, then startedAt, then requestedAt, then createdAt", () => { - expect( - resolveThreadLastActivityAt({ - createdAt: "2026-03-09T09:00:00.000Z", - latestTurn: makeLatestTurn(), - }), - ).toBe("2026-03-09T10:05:00.000Z"); - - expect( - resolveThreadLastActivityAt({ - createdAt: "2026-03-09T09:00:00.000Z", - latestTurn: makeLatestTurn({ completedAt: null }), - }), - ).toBe("2026-03-09T10:01:00.000Z"); - - expect( - resolveThreadLastActivityAt({ - createdAt: "2026-03-09T09:00:00.000Z", - latestTurn: makeLatestTurn({ completedAt: null, startedAt: null }), - }), - ).toBe("2026-03-09T10:00:00.000Z"); - - expect( - resolveThreadLastActivityAt({ - createdAt: "2026-03-09T09:00:00.000Z", - latestTurn: null, - }), - ).toBe("2026-03-09T09:00:00.000Z"); - }); -}); - -describe("compareThreadsByLastActivityDesc", () => { - it("sorts newer thread activity before older thread activity", () => { - const sorted = [ - { - id: "thread-a" as never, - createdAt: "2026-03-09T09:00:00.000Z", - latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:01:00.000Z" }), - }, - { - id: "thread-c" as never, - createdAt: "2026-03-09T08:00:00.000Z", - latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:05:00.000Z" }), - }, - { - id: "thread-b" as never, - createdAt: "2026-03-09T10:05:00.000Z", - latestTurn: null, - }, - ].toSorted(compareThreadsByLastActivityDesc); - - expect(sorted.map((thread) => thread.id)).toEqual(["thread-c", "thread-b", "thread-a"]); - }); -}); diff --git a/apps/web/src/threadPresentation.ts b/apps/web/src/threadPresentation.ts deleted file mode 100644 index 129beb23c26..00000000000 --- a/apps/web/src/threadPresentation.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Thread } from "./types"; - -export type ThreadActivityLike = Pick; - -export function resolveThreadLastActivityAt( - thread: Pick, -): string { - return ( - thread.latestTurn?.completedAt ?? - thread.latestTurn?.startedAt ?? - thread.latestTurn?.requestedAt ?? - thread.createdAt - ); -} - -export function compareThreadsByLastActivityDesc( - left: ThreadActivityLike, - right: ThreadActivityLike, -): number { - const byTimestamp = - Date.parse(resolveThreadLastActivityAt(right)) - Date.parse(resolveThreadLastActivityAt(left)); - if (!Number.isNaN(byTimestamp) && byTimestamp !== 0) { - return byTimestamp; - } - return right.id.localeCompare(left.id); -} From 51dd5e6a6709d0bb7860ec307af20132855c0c07 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 15 Mar 2026 10:39:39 +0100 Subject: [PATCH 6/8] Simplify command palette group ordering --- apps/web/src/components/CommandPalette.tsx | 23 ++-------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f971d55ba4f..7c46d7d06ab 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -68,10 +68,6 @@ interface CommandPaletteGroup { } const CommandPaletteContext = createContext(null); -const COMMAND_PALETTE_GROUP_ORDER = ["actions", "projects", "recent-threads"] as const; -const COMMAND_PALETTE_GROUP_PRIORITY = new Map( - COMMAND_PALETTE_GROUP_ORDER.map((value, index) => [value, index] as const), -); function iconClassName() { return "size-4 text-muted-foreground/80"; @@ -92,20 +88,6 @@ function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } -function compareCommandPaletteGroups( - left: CommandPaletteGroup, - right: CommandPaletteGroup, -): number { - return ( - (COMMAND_PALETTE_GROUP_PRIORITY.get( - left.value as (typeof COMMAND_PALETTE_GROUP_ORDER)[number], - ) ?? COMMAND_PALETTE_GROUP_ORDER.length) - - (COMMAND_PALETTE_GROUP_PRIORITY.get( - right.value as (typeof COMMAND_PALETTE_GROUP_ORDER)[number], - ) ?? COMMAND_PALETTE_GROUP_ORDER.length) - ); -} - export function useCommandPalette() { const context = useContext(CommandPaletteContext); if (!context) { @@ -284,7 +266,7 @@ function OpenCommandPaletteDialog() { items: recentThreadItems, }); } - return nextGroups.toSorted(compareCommandPaletteGroups); + return nextGroups; }, [ activeDraftThread, activeThread, @@ -312,8 +294,7 @@ function OpenCommandPaletteDialog() { return haystack.includes(normalizedQuery); }), })) - .filter((group) => group.items.length > 0) - .toSorted(compareCommandPaletteGroups); + .filter((group) => group.items.length > 0); }, [allGroups, deferredQuery]); const executeItem = useCallback( From ee86330403b287bf6a27be0c3fac52623c72110d Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 15 Mar 2026 10:42:42 +0100 Subject: [PATCH 7/8] Remove stale command palette ordering test --- apps/web/src/components/ChatView.browser.tsx | 68 -------------------- 1 file changed, 68 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 3b720dd8bc7..129c0154c50 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -533,23 +533,6 @@ async function waitForElement( return element; } -async function waitForGroupLabels(): Promise { - let labels: string[] = []; - await vi.waitFor( - () => { - labels = Array.from( - document.querySelectorAll('[data-slot="command-group-label"]'), - ).map((label) => label.textContent?.trim() ?? ""); - expect(labels.length).toBeGreaterThan(0); - }, - { - timeout: 8_000, - interval: 16, - }, - ); - return labels; -} - async function waitForURL( router: ReturnType, predicate: (pathname: string) => boolean, @@ -1357,57 +1340,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("prioritizes projects over threads while searching", 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 { - const useMetaForMod = isMacPlatform(navigator.platform); - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "k", - metaKey: useMetaForMod, - ctrlKey: !useMetaForMod, - bubbles: true, - cancelable: true, - }), - ); - - await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); - await page.getByPlaceholder("Search commands, projects, and threads...").fill("project"); - const labels = await waitForGroupLabels(); - expect(labels.indexOf("Projects")).toBeGreaterThanOrEqual(0); - expect(labels.indexOf("Recent Threads")).toBeGreaterThanOrEqual(0); - expect(labels.indexOf("Projects")).toBeLessThan(labels.indexOf("Recent Threads")); - } finally { - await mounted.cleanup(); - } - }); - it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, From fda7d1f5adcdc12ee93aa407f49a640b7a006196 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 15 Mar 2026 10:46:24 +0100 Subject: [PATCH 8/8] Tighten command palette action search matching --- apps/web/src/components/ChatView.browser.tsx | 52 ++++++++++++++++++++ apps/web/src/components/CommandPalette.tsx | 9 +++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 129c0154c50..a984499a6b6 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1275,6 +1275,58 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("does not match thread actions from contextual project names", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-project-query-test" as MessageId, + targetText: "command palette project query 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 { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("project"); + await expect.element(page.getByText("Project")).toBeInTheDocument(); + await expect.element(page.getByText("New thread")).not.toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + it("searches projects by path and opens a new thread using the default env mode", async () => { localStorage.setItem( "t3code:app-settings:v1", diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 7c46d7d06ab..ae0980a80ed 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -55,6 +55,7 @@ interface CommandPaletteItem { readonly label: string; readonly title: string; readonly description?: string; + readonly searchText?: string; readonly timestamp?: string; readonly icon: ReactNode; readonly shortcutCommand?: KeybindingCommand; @@ -160,6 +161,7 @@ function OpenCommandPaletteDialog() { description: activeProjectTitle ? `Create a draft thread in ${activeProjectTitle}` : "Create a new draft thread", + searchText: "new thread chat create draft", icon: , shortcutCommand: "chat.new", run: async () => { @@ -179,6 +181,7 @@ function OpenCommandPaletteDialog() { description: activeProjectTitle ? `Create a fresh ${settings.defaultThreadEnvMode} thread in ${activeProjectTitle}` : "Create a fresh thread using the default environment", + searchText: "new local thread chat create fresh default environment", icon: , shortcutCommand: "chat.newLocal", run: async () => { @@ -289,7 +292,11 @@ function OpenCommandPaletteDialog() { ...group, items: group.items.filter((item) => { const haystack = normalizeSearchText( - [item.label, item.title, item.description ?? ""].join(" "), + [ + item.title, + item.searchText ?? item.label, + item.searchText ? "" : (item.description ?? ""), + ].join(" "), ); return haystack.includes(normalizedQuery); }),