authorgravatar for crazywolf132@gmail.com0x1f99d <crazywolf132@gmail.com> 2026-04-12 04:02:06+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-11 11:02:06-07:00
log934037cb66d6c4874e1a47c374569c110e71d669
treec80525cbfdbb56344e644be8a85ee0c417f32cfe
parent5467d11980e2b41e4cf5c8d1c5fe972532da3a74
signature Signed by PGP key B5690EEEBB952194

feat(web): add extensible command palette (#1103)

Co-authored-by: Hugo Blom <6117705+huxcrux@users.noreply.github.com> Co-authored-by: eggfriedrice <eggfriedricew.g.o@gmail.com> Co-authored-by: Jono Kemball <jono-kemball@idexx.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

24 files changed, 2102 insertions(+), 236 deletions(-)

KEYBINDINGS.md+2
......@@ -23,6 +23,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`](
2323 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
2424 { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },
2525 { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" },
26 { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" },
2627 { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" },
2728 { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" },
2829 { "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
5051- `terminal.split`: split terminal (in focused terminal context by default)
5152- `terminal.new`: create new terminal (in focused terminal context by default)
5253- `terminal.close`: close/kill the focused terminal (in focused terminal context by default)
54- `commandPalette.toggle`: open or close the global command palette
5355- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
5456- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`))
5557- `editor.openFavorite`: open current project/worktree in the last-used editor
apps/server/src/keybindings.ts+1
......@@ -61,6 +61,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
6161 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
6262 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
6363 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
64 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
6465 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
6566 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
6667 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
apps/web/src/commandPaletteStore.ts created+13
......@@ -0,0 +1,13 @@
1import { create } from "zustand";
2
3interface CommandPaletteStore {
4 open: boolean;
5 setOpen: (open: boolean) => void;
6 toggleOpen: () => void;
7}
8
9export const useCommandPaletteStore = create<CommandPaletteStore>((set) => ({
10 open: false,
11 setOpen: (open) => set({ open }),
12 toggleOpen: () => set((state) => ({ open: !state.open })),
13}));
apps/web/src/components/ChatView.browser.tsx+487-2
......@@ -30,11 +30,12 @@ import { page } from "vitest/browser";
3030import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3131import { render } from "vitest-browser-react";
3232
33import { useCommandPaletteStore } from "../commandPaletteStore";
3334import { useComposerDraftStore, DraftId } from "../composerDraftStore";
3435import {
3536 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
36 type TerminalContextDraft,
3737 removeInlineTerminalContextPlaceholder,
38 type TerminalContextDraft,
3839} from "../lib/terminalContext";
3940import { isMacPlatform } from "../lib/utils";
4041import { __resetLocalApiForTests } from "../localApi";
......@@ -57,7 +58,9 @@ vi.mock("../lib/gitStatusState", () => ({
5758}));
5859
5960const THREAD_ID = "thread-browser-test" as ThreadId;
61const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as ThreadId;
6062const PROJECT_ID = "project-1" as ProjectId;
63const SECOND_PROJECT_ID = "project-2" as ProjectId;
6164const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
6265const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID);
6366const THREAD_KEY = scopedThreadKey(THREAD_REF);
......@@ -563,6 +566,18 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {
563566 };
564567}
565568
569function createProjectlessSnapshot(): OrchestrationReadModel {
570 const snapshot = createSnapshotForTargetUser({
571 targetMessageId: "msg-user-projectless-target" as MessageId,
572 targetText: "projectless",
573 });
574 return {
575 ...snapshot,
576 projects: [],
577 threads: [],
578 };
579}
580
566581function withProjectScripts(
567582 snapshot: OrchestrationReadModel,
568583 scripts: OrchestrationReadModel["projects"][number]["scripts"],
......@@ -654,6 +669,100 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
654669 };
655670}
656671
672function createSnapshotWithSecondaryProject(options?: {
673 includeSecondaryThread?: boolean;
674 includeArchivedSecondaryThread?: boolean;
675}): OrchestrationReadModel {
676 const snapshot = createSnapshotForTargetUser({
677 targetMessageId: "msg-user-secondary-project-target" as MessageId,
678 targetText: "secondary project",
679 });
680 const includeSecondaryThread = options?.includeSecondaryThread ?? true;
681 const includeArchivedSecondaryThread = options?.includeArchivedSecondaryThread ?? true;
682 const secondaryThreads: OrchestrationReadModel["threads"] = includeSecondaryThread
683 ? [
684 {
685 id: "thread-secondary-project" as ThreadId,
686 projectId: SECOND_PROJECT_ID,
687 title: "Release checklist",
688 modelSelection: { provider: "codex", model: "gpt-5" },
689 interactionMode: "default",
690 runtimeMode: "full-access",
691 branch: "release/docs-portal",
692 worktreePath: null,
693 latestTurn: null,
694 createdAt: isoAt(30),
695 updatedAt: isoAt(31),
696 deletedAt: null,
697 messages: [],
698 activities: [],
699 proposedPlans: [],
700 checkpoints: [],
701 session: {
702 threadId: "thread-secondary-project" as ThreadId,
703 status: "ready",
704 providerName: "codex",
705 runtimeMode: "full-access",
706 activeTurnId: null,
707 lastError: null,
708 updatedAt: isoAt(31),
709 },
710 archivedAt: null,
711 },
712 ]
713 : [];
714 const archivedSecondaryThreads: OrchestrationReadModel["threads"] = includeArchivedSecondaryThread
715 ? [
716 {
717 id: ARCHIVED_SECONDARY_THREAD_ID,
718 projectId: SECOND_PROJECT_ID,
719 title: "Archived Docs Notes",
720 modelSelection: { provider: "codex", model: "gpt-5" },
721 interactionMode: "default",
722 runtimeMode: "full-access",
723 branch: "release/docs-archive",
724 worktreePath: null,
725 latestTurn: null,
726 createdAt: isoAt(24),
727 updatedAt: isoAt(25),
728 deletedAt: null,
729 messages: [],
730 activities: [],
731 proposedPlans: [],
732 checkpoints: [],
733 session: {
734 threadId: ARCHIVED_SECONDARY_THREAD_ID,
735 status: "ready",
736 providerName: "codex",
737 runtimeMode: "full-access",
738 activeTurnId: null,
739 lastError: null,
740 updatedAt: isoAt(25),
741 },
742 archivedAt: isoAt(26),
743 },
744 ]
745 : [];
746
747 return {
748 ...snapshot,
749 projects: [
750 ...snapshot.projects,
751 {
752 id: SECOND_PROJECT_ID,
753 title: "Docs Portal",
754 workspaceRoot: "/repo/clients/docs-portal",
755 defaultModelSelection: { provider: "codex", model: "gpt-5" },
756 scripts: [],
757 createdAt: NOW_ISO,
758 updatedAt: NOW_ISO,
759 deletedAt: null,
760 },
761 ],
762 threads: [...snapshot.threads, ...secondaryThreads, ...archivedSecondaryThreads],
763 };
764}
765
657766function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
658767 const snapshot = createSnapshotForTargetUser({
659768 targetMessageId: "msg-user-pending-input-target" as MessageId,
......@@ -1242,6 +1351,16 @@ async function triggerChatNewShortcutUntilPath(
12421351 throw new Error(`${errorMessage} Last path: ${pathname}`);
12431352}
12441353
1354async function openCommandPaletteFromTrigger(): Promise<void> {
1355 const trigger = page.getByTestId("command-palette-trigger");
1356 await expect.element(trigger).toBeInTheDocument();
1357 await trigger.click();
1358 await waitForElement(
1359 () => document.querySelector('[data-testid="command-palette"]'),
1360 "Command palette should have opened from the sidebar trigger.",
1361 );
1362}
1363
12451364async function waitForNewThreadShortcutLabel(): Promise<void> {
12461365 const newThreadButton = page.getByTestId("new-thread-button");
12471366 await expect.element(newThreadButton).toBeInTheDocument();
......@@ -1252,6 +1371,13 @@ async function waitForNewThreadShortcutLabel(): Promise<void> {
12521371 await expect.element(page.getByText(shortcutLabel)).toBeInTheDocument();
12531372}
12541373
1374async function waitForCommandPaletteShortcutLabel(): Promise<void> {
1375 await waitForElement(
1376 () => document.querySelector('[data-testid="command-palette-trigger"] kbd'),
1377 "Command palette shortcut label did not render.",
1378 );
1379}
1380
12551381async function waitForImagesToLoad(scope: ParentNode): Promise<void> {
12561382 const images = Array.from(scope.querySelectorAll("img"));
12571383 if (images.length === 0) {
......@@ -1383,6 +1509,7 @@ async function mountChatView(options: {
13831509 customWsRpcResolver = null;
13841510 await screen.unmount();
13851511 host.remove();
1512 await waitForLayout();
13861513 };
13871514
13881515 return {
......@@ -1480,6 +1607,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
14801607 stickyModelSelectionByProvider: {},
14811608 stickyActiveProvider: null,
14821609 });
1610 useCommandPaletteStore.setState({
1611 open: false,
1612 });
14831613 useStore.setState({
14841614 activeEnvironmentId: null,
14851615 environmentStateById: {},
......@@ -3489,6 +3619,359 @@ describe("ChatView timeline estimator parity (full app)", () => {
34893619 await mounted.cleanup();
34903620 }
34913621 });
3622
3623 it("does not consume chat.new when there is no project context", async () => {
3624 const mounted = await mountChatView({
3625 viewport: DEFAULT_VIEWPORT,
3626 snapshot: createProjectlessSnapshot(),
3627 configureFixture: (nextFixture) => {
3628 nextFixture.serverConfig = {
3629 ...nextFixture.serverConfig,
3630 keybindings: [
3631 {
3632 command: "chat.new",
3633 shortcut: {
3634 key: "o",
3635 metaKey: false,
3636 ctrlKey: false,
3637 shiftKey: true,
3638 altKey: false,
3639 modKey: true,
3640 },
3641 whenAst: {
3642 type: "not",
3643 node: { type: "identifier", name: "terminalFocus" },
3644 },
3645 },
3646 ],
3647 };
3648 },
3649 });
3650
3651 try {
3652 await waitForServerConfigToApply();
3653 dispatchChatNewShortcut();
3654 await waitForLayout();
3655
3656 expect(mounted.router.state.location.pathname).toBe(serverThreadPath(THREAD_ID));
3657 expect(Object.keys(useComposerDraftStore.getState().draftThreadsByThreadKey)).toHaveLength(0);
3658 } finally {
3659 await mounted.cleanup();
3660 }
3661 });
3662
3663 it("renders the configurable shortcut and runs a command from the sidebar trigger", async () => {
3664 const mounted = await mountChatView({
3665 viewport: DEFAULT_VIEWPORT,
3666 snapshot: createSnapshotForTargetUser({
3667 targetMessageId: "msg-user-command-palette-shortcut-test" as MessageId,
3668 targetText: "command palette shortcut test",
3669 }),
3670 configureFixture: (nextFixture) => {
3671 nextFixture.serverConfig = {
3672 ...nextFixture.serverConfig,
3673 keybindings: [
3674 {
3675 command: "commandPalette.toggle",
3676 shortcut: {
3677 key: "k",
3678 metaKey: false,
3679 ctrlKey: false,
3680 shiftKey: false,
3681 altKey: false,
3682 modKey: true,
3683 },
3684 whenAst: {
3685 type: "not",
3686 node: { type: "identifier", name: "terminalFocus" },
3687 },
3688 },
3689 ],
3690 };
3691 },
3692 });
3693
3694 try {
3695 await waitForServerConfigToApply();
3696 await waitForCommandPaletteShortcutLabel();
3697 const palette = page.getByTestId("command-palette");
3698 await openCommandPaletteFromTrigger();
3699
3700 await expect.element(palette).toBeInTheDocument();
3701 await expect
3702 .element(palette.getByText("New thread in Project", { exact: true }))
3703 .toBeInTheDocument();
3704 await palette.getByText("New thread in Project", { exact: true }).click();
3705
3706 await waitForURL(
3707 mounted.router,
3708 (path) => UUID_ROUTE_RE.test(path),
3709 "Route should have changed to a new draft thread UUID from the command palette.",
3710 );
3711 } finally {
3712 await mounted.cleanup();
3713 }
3714 });
3715
3716 it("filters command palette results as the user types", async () => {
3717 const mounted = await mountChatView({
3718 viewport: DEFAULT_VIEWPORT,
3719 snapshot: createSnapshotForTargetUser({
3720 targetMessageId: "msg-user-command-palette-search-test" as MessageId,
3721 targetText: "command palette search test",
3722 }),
3723 configureFixture: (nextFixture) => {
3724 nextFixture.serverConfig = {
3725 ...nextFixture.serverConfig,
3726 keybindings: [
3727 {
3728 command: "commandPalette.toggle",
3729 shortcut: {
3730 key: "k",
3731 metaKey: false,
3732 ctrlKey: false,
3733 shiftKey: false,
3734 altKey: false,
3735 modKey: true,
3736 },
3737 whenAst: {
3738 type: "not",
3739 node: { type: "identifier", name: "terminalFocus" },
3740 },
3741 },
3742 ],
3743 };
3744 },
3745 });
3746
3747 try {
3748 await waitForServerConfigToApply();
3749 await waitForCommandPaletteShortcutLabel();
3750 const palette = page.getByTestId("command-palette");
3751 await openCommandPaletteFromTrigger();
3752
3753 await expect.element(palette).toBeInTheDocument();
3754 await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings");
3755 await expect.element(palette.getByText("Open settings", { exact: true })).toBeInTheDocument();
3756 await expect
3757 .element(palette.getByText("New thread in Project", { exact: true }))
3758 .not.toBeInTheDocument();
3759 } finally {
3760 await mounted.cleanup();
3761 }
3762 });
3763
3764 it("keeps project-context thread matches available when searching by project name", async () => {
3765 const mounted = await mountChatView({
3766 viewport: DEFAULT_VIEWPORT,
3767 snapshot: createSnapshotWithSecondaryProject(),
3768 configureFixture: (nextFixture) => {
3769 nextFixture.serverConfig = {
3770 ...nextFixture.serverConfig,
3771 keybindings: [
3772 {
3773 command: "commandPalette.toggle",
3774 shortcut: {
3775 key: "k",
3776 metaKey: false,
3777 ctrlKey: false,
3778 shiftKey: false,
3779 altKey: false,
3780 modKey: true,
3781 },
3782 whenAst: {
3783 type: "not",
3784 node: { type: "identifier", name: "terminalFocus" },
3785 },
3786 },
3787 ],
3788 };
3789 },
3790 });
3791
3792 try {
3793 await waitForServerConfigToApply();
3794 await waitForCommandPaletteShortcutLabel();
3795 const palette = page.getByTestId("command-palette");
3796 await openCommandPaletteFromTrigger();
3797
3798 await expect.element(palette).toBeInTheDocument();
3799 await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs");
3800 await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument();
3801 await expect
3802 .element(palette.getByText("Release checklist", { exact: true }))
3803 .toBeInTheDocument();
3804 } finally {
3805 await mounted.cleanup();
3806 }
3807 });
3808
3809 it("searches projects by path and opens the latest thread for that project", async () => {
3810 const mounted = await mountChatView({
3811 viewport: DEFAULT_VIEWPORT,
3812 snapshot: createSnapshotWithSecondaryProject(),
3813 configureFixture: (nextFixture) => {
3814 nextFixture.serverConfig = {
3815 ...nextFixture.serverConfig,
3816 settings: {
3817 ...nextFixture.serverConfig.settings,
3818 defaultThreadEnvMode: "worktree",
3819 },
3820 keybindings: [
3821 {
3822 command: "commandPalette.toggle",
3823 shortcut: {
3824 key: "k",
3825 metaKey: false,
3826 ctrlKey: false,
3827 shiftKey: false,
3828 altKey: false,
3829 modKey: true,
3830 },
3831 whenAst: {
3832 type: "not",
3833 node: { type: "identifier", name: "terminalFocus" },
3834 },
3835 },
3836 ],
3837 };
3838 },
3839 });
3840
3841 try {
3842 await waitForServerConfigToApply();
3843 await waitForCommandPaletteShortcutLabel();
3844 const palette = page.getByTestId("command-palette");
3845 await openCommandPaletteFromTrigger();
3846
3847 await expect.element(palette).toBeInTheDocument();
3848 await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs");
3849 await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument();
3850 await expect
3851 .element(palette.getByText("/repo/clients/docs-portal", { exact: true }))
3852 .toBeInTheDocument();
3853 await palette.getByText("Docs Portal", { exact: true }).click();
3854
3855 const nextPath = await waitForURL(
3856 mounted.router,
3857 (path) => path === serverThreadPath("thread-secondary-project" as ThreadId),
3858 "Route should have changed to the latest thread for the selected project.",
3859 );
3860 expect(nextPath).toBe(serverThreadPath("thread-secondary-project" as ThreadId));
3861 expect(
3862 useComposerDraftStore
3863 .getState()
3864 .getDraftThread(threadRefFor("thread-secondary-project" as ThreadId)),
3865 ).toBeNull();
3866 } finally {
3867 await mounted.cleanup();
3868 }
3869 });
3870
3871 it("creates a new thread from project search when no active project thread exists", async () => {
3872 const mounted = await mountChatView({
3873 viewport: DEFAULT_VIEWPORT,
3874 snapshot: createSnapshotWithSecondaryProject({ includeSecondaryThread: false }),
3875 configureFixture: (nextFixture) => {
3876 nextFixture.serverConfig = {
3877 ...nextFixture.serverConfig,
3878 settings: {
3879 ...nextFixture.serverConfig.settings,
3880 defaultThreadEnvMode: "worktree",
3881 },
3882 keybindings: [
3883 {
3884 command: "commandPalette.toggle",
3885 shortcut: {
3886 key: "k",
3887 metaKey: false,
3888 ctrlKey: false,
3889 shiftKey: false,
3890 altKey: false,
3891 modKey: true,
3892 },
3893 whenAst: {
3894 type: "not",
3895 node: { type: "identifier", name: "terminalFocus" },
3896 },
3897 },
3898 ],
3899 };
3900 },
3901 });
3902
3903 try {
3904 await waitForServerConfigToApply();
3905 await waitForCommandPaletteShortcutLabel();
3906 const palette = page.getByTestId("command-palette");
3907 await openCommandPaletteFromTrigger();
3908
3909 await expect.element(palette).toBeInTheDocument();
3910 await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs");
3911 await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument();
3912 await expect
3913 .element(palette.getByText("/repo/clients/docs-portal", { exact: true }))
3914 .toBeInTheDocument();
3915 await palette.getByText("Docs Portal", { exact: true }).click();
3916
3917 const nextPath = await waitForURL(
3918 mounted.router,
3919 (path) => UUID_ROUTE_RE.test(path),
3920 "Route should have changed to a new draft thread UUID from the project search result.",
3921 );
3922 const nextDraftId = draftIdFromPath(nextPath);
3923 const draftThread = useComposerDraftStore.getState().getDraftSession(nextDraftId);
3924 expect(draftThread?.projectId).toBe(SECOND_PROJECT_ID);
3925 expect(draftThread?.envMode).toBe("worktree");
3926 } finally {
3927 await mounted.cleanup();
3928 }
3929 });
3930
3931 it("filters archived threads out of command palette search results", async () => {
3932 const mounted = await mountChatView({
3933 viewport: DEFAULT_VIEWPORT,
3934 snapshot: createSnapshotWithSecondaryProject(),
3935 configureFixture: (nextFixture) => {
3936 nextFixture.serverConfig = {
3937 ...nextFixture.serverConfig,
3938 keybindings: [
3939 {
3940 command: "commandPalette.toggle",
3941 shortcut: {
3942 key: "k",
3943 metaKey: false,
3944 ctrlKey: false,
3945 shiftKey: false,
3946 altKey: false,
3947 modKey: true,
3948 },
3949 whenAst: {
3950 type: "not",
3951 node: { type: "identifier", name: "terminalFocus" },
3952 },
3953 },
3954 ],
3955 };
3956 },
3957 });
3958
3959 try {
3960 await waitForServerConfigToApply();
3961 await waitForCommandPaletteShortcutLabel();
3962 const palette = page.getByTestId("command-palette");
3963 await openCommandPaletteFromTrigger();
3964
3965 await expect.element(palette).toBeInTheDocument();
3966 await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs-archive");
3967 await expect
3968 .element(palette.getByText("Archived Docs Notes", { exact: true }))
3969 .not.toBeInTheDocument();
3970 } finally {
3971 await mounted.cleanup();
3972 }
3973 });
3974
34923975 it("creates a fresh draft after the previous draft thread is promoted", async () => {
34933976 const mounted = await mountChatView({
34943977 viewport: DEFAULT_VIEWPORT,
......@@ -3523,7 +4006,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
35234006 try {
35244007 const newThreadButton = page.getByTestId("new-thread-button");
35254008 await expect.element(newThreadButton).toBeInTheDocument();
3526 await waitForNewThreadShortcutLabel();
35274009 await waitForServerConfigToApply();
35284010 await newThreadButton.click();
35294011
......@@ -3547,6 +4029,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
35474029 },
35484030 { timeout: 8_000, interval: 16 },
35494031 );
4032 const composerEditor = await waitForComposerEditor();
4033 composerEditor.focus();
4034 await waitForLayout();
35504035
35514036 const freshThreadPath = await triggerChatNewShortcutUntilPath(
35524037 mounted.router,
apps/web/src/components/ChatView.tsx+7-2
......@@ -89,6 +89,7 @@ import {
8989} from "../types";
9090import { useTheme } from "../hooks/useTheme";
9191import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
92import { useCommandPaletteStore } from "../commandPaletteStore";
9293import { BranchToolbar } from "./BranchToolbar";
9394import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
9495import PlanSidebar from "./PlanSidebar";
......@@ -160,6 +161,7 @@ import {
160161 waitForStartedServerThread,
161162} from "./ChatView.logic";
162163import { useLocalStorage } from "~/hooks/useLocalStorage";
164import { useComposerHandleContext } from "../composerHandleContext";
163165import {
164166 useServerAvailableEditors,
165167 useServerConfig,
......@@ -650,7 +652,8 @@ export default function ChatView(props: ChatViewProps) {
650652 const promptRef = useRef("");
651653 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
652654 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
653 const composerRef = useRef<ChatComposerHandle>(null);
655 const localComposerRef = useRef<ChatComposerHandle | null>(null);
656 const composerRef = useComposerHandleContext() ?? localComposerRef;
654657 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
655658 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
656659 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
......@@ -2321,7 +2324,9 @@ export default function ChatView(props: ChatViewProps) {
23212324
23222325 useEffect(() => {
23232326 const handler = (event: globalThis.KeyboardEvent) => {
2324 if (!activeThreadId || event.defaultPrevented) return;
2327 if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) {
2328 return;
2329 }
23252330 const shortcutContext = {
23262331 terminalFocus: isTerminalFocused(),
23272332 terminalOpen: Boolean(terminalState.terminalOpen),
apps/web/src/components/CommandPalette.logic.test.ts created+166
......@@ -0,0 +1,166 @@
1import { describe, expect, it, vi } from "vitest";
2import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
3import type { Thread } from "../types";
4import {
5 buildThreadActionItems,
6 filterCommandPaletteGroups,
7 type CommandPaletteGroup,
8} from "./CommandPalette.logic";
9
10const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
11const PROJECT_ID = ProjectId.make("project-1");
12
13function makeThread(overrides: Partial<Thread> = {}): Thread {
14 return {
15 id: ThreadId.make("thread-1"),
16 environmentId: LOCAL_ENVIRONMENT_ID,
17 codexThreadId: null,
18 projectId: PROJECT_ID,
19 title: "Thread",
20 modelSelection: { provider: "codex", model: "gpt-5" },
21 runtimeMode: "full-access",
22 interactionMode: "default",
23 session: null,
24 messages: [],
25 proposedPlans: [],
26 error: null,
27 createdAt: "2026-03-01T00:00:00.000Z",
28 archivedAt: null,
29 updatedAt: "2026-03-01T00:00:00.000Z",
30 latestTurn: null,
31 branch: null,
32 worktreePath: null,
33 turnDiffSummaries: [],
34 activities: [],
35 ...overrides,
36 };
37}
38
39describe("buildThreadActionItems", () => {
40 it("orders threads by most recent activity and formats timestamps from updatedAt", () => {
41 vi.useFakeTimers();
42 vi.setSystemTime(new Date("2026-03-25T12:00:00.000Z"));
43
44 try {
45 const items = buildThreadActionItems({
46 threads: [
47 makeThread({
48 id: ThreadId.make("thread-older"),
49 title: "Older thread",
50 updatedAt: "2026-03-24T12:00:00.000Z",
51 }),
52 makeThread({
53 id: ThreadId.make("thread-newer"),
54 title: "Newer thread",
55 createdAt: "2026-03-20T00:00:00.000Z",
56 updatedAt: "2026-03-20T00:00:00.000Z",
57 }),
58 ],
59 projectTitleById: new Map([[PROJECT_ID, "Project"]]),
60 sortOrder: "updated_at",
61 icon: null,
62 runThread: async (_thread) => undefined,
63 });
64
65 expect(items.map((item) => item.value)).toEqual([
66 "thread:thread-older",
67 "thread:thread-newer",
68 ]);
69 expect(items[0]?.timestamp).toBe("1d ago");
70 expect(items[1]?.timestamp).toBe("5d ago");
71 } finally {
72 vi.useRealTimers();
73 }
74 });
75
76 it("ranks thread title matches ahead of contextual project-name matches", () => {
77 const threadItems = buildThreadActionItems({
78 threads: [
79 makeThread({
80 id: ThreadId.make("thread-context-match"),
81 title: "Fix navbar spacing",
82 updatedAt: "2026-03-20T00:00:00.000Z",
83 }),
84 makeThread({
85 id: ThreadId.make("thread-title-match"),
86 title: "Project kickoff notes",
87 createdAt: "2026-03-02T00:00:00.000Z",
88 updatedAt: "2026-03-19T00:00:00.000Z",
89 }),
90 ],
91 projectTitleById: new Map([[PROJECT_ID, "Project"]]),
92 sortOrder: "updated_at",
93 icon: null,
94 runThread: async (_thread) => undefined,
95 });
96
97 const groups = filterCommandPaletteGroups({
98 activeGroups: [],
99 query: "project",
100 isInSubmenu: false,
101 projectSearchItems: [],
102 threadSearchItems: threadItems,
103 });
104
105 expect(groups).toHaveLength(1);
106 expect(groups[0]?.value).toBe("threads-search");
107 expect(groups[0]?.items.map((item) => item.value)).toEqual([
108 "thread:thread-title-match",
109 "thread:thread-context-match",
110 ]);
111 });
112
113 it("preserves thread project-name matches when there is no stronger title match", () => {
114 const group: CommandPaletteGroup = {
115 value: "threads-search",
116 label: "Threads",
117 items: [
118 {
119 kind: "action",
120 value: "thread:project-context-only",
121 searchTerms: ["Fix navbar spacing", "Project"],
122 title: "Fix navbar spacing",
123 description: "Project",
124 icon: null,
125 run: async () => undefined,
126 },
127 ],
128 };
129
130 const groups = filterCommandPaletteGroups({
131 activeGroups: [group],
132 query: "project",
133 isInSubmenu: false,
134 projectSearchItems: [],
135 threadSearchItems: [],
136 });
137
138 expect(groups).toHaveLength(1);
139 expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]);
140 });
141
142 it("filters archived threads out of thread search items", () => {
143 const items = buildThreadActionItems({
144 threads: [
145 makeThread({
146 id: ThreadId.make("thread-active"),
147 title: "Active thread",
148 createdAt: "2026-03-02T00:00:00.000Z",
149 updatedAt: "2026-03-19T00:00:00.000Z",
150 }),
151 makeThread({
152 id: ThreadId.make("thread-archived"),
153 title: "Archived thread",
154 archivedAt: "2026-03-20T00:00:00.000Z",
155 updatedAt: "2026-03-20T00:00:00.000Z",
156 }),
157 ],
158 projectTitleById: new Map([[PROJECT_ID, "Project"]]),
159 sortOrder: "updated_at",
160 icon: null,
161 runThread: async (_thread) => undefined,
162 });
163
164 expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]);
165 });
166});
apps/web/src/components/CommandPalette.logic.ts created+262
......@@ -0,0 +1,262 @@
1import { type KeybindingCommand } from "@t3tools/contracts";
2import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import { type ReactNode } from "react";
4import { sortThreads } from "../lib/threadSort";
5import { formatRelativeTimeLabel } from "../timestampFormat";
6import { type Project, type SidebarThreadSummary, type Thread } from "../types";
7
8export const RECENT_THREAD_LIMIT = 12;
9export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";
10export const ADDON_ICON_CLASS = "size-4";
11
12export interface CommandPaletteItem {
13 readonly kind: "action" | "submenu";
14 readonly value: string;
15 readonly searchTerms: ReadonlyArray<string>;
16 readonly title: ReactNode;
17 readonly description?: string;
18 readonly timestamp?: string;
19 readonly icon: ReactNode;
20 readonly shortcutCommand?: KeybindingCommand;
21}
22
23export interface CommandPaletteActionItem extends CommandPaletteItem {
24 readonly kind: "action";
25 readonly keepOpen?: boolean;
26 readonly run: () => Promise<void>;
27}
28
29export interface CommandPaletteSubmenuItem extends CommandPaletteItem {
30 readonly kind: "submenu";
31 readonly addonIcon: ReactNode;
32 readonly groups: ReadonlyArray<CommandPaletteGroup>;
33 readonly initialQuery?: string;
34}
35
36export interface CommandPaletteGroup {
37 readonly value: string;
38 readonly label: string;
39 readonly items: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>;
40}
41
42export interface CommandPaletteView {
43 readonly addonIcon: ReactNode;
44 readonly groups: ReadonlyArray<CommandPaletteGroup>;
45 readonly initialQuery?: string;
46}
47
48export type CommandPaletteMode = "root" | "submenu";
49
50export function normalizeSearchText(value: string): string {
51 return value.trim().toLowerCase().replace(/\s+/g, " ");
52}
53
54export function buildProjectActionItems(input: {
55 projects: ReadonlyArray<Project>;
56 valuePrefix: string;
57 icon: (project: Project) => ReactNode;
58 runProject: (project: Project) => Promise<void>;
59}): CommandPaletteActionItem[] {
60 return input.projects.map((project) => ({
61 kind: "action",
62 value: `${input.valuePrefix}:${project.environmentId}:${project.id}`,
63 searchTerms: [project.name, project.cwd],
64 title: project.name,
65 description: project.cwd,
66 icon: input.icon(project),
67 run: async () => {
68 await input.runProject(project);
69 },
70 }));
71}
72
73export function buildThreadActionItems(input: {
74 threads: ReadonlyArray<
75 Pick<
76 SidebarThreadSummary,
77 "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title"
78 > & {
79 updatedAt?: string | undefined;
80 latestUserMessageAt?: string | null;
81 }
82 >;
83 activeThreadId?: Thread["id"];
84 projectTitleById: ReadonlyMap<Project["id"], string>;
85 sortOrder: SidebarThreadSortOrder;
86 icon: ReactNode;
87 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
88 limit?: number;
89}): CommandPaletteActionItem[] {
90 const sortedThreads = sortThreads(
91 input.threads.filter((thread) => thread.archivedAt === null),
92 input.sortOrder,
93 );
94 const visibleThreads =
95 input.limit === undefined ? sortedThreads : sortedThreads.slice(0, input.limit);
96
97 return visibleThreads.map((thread) => {
98 const projectTitle = input.projectTitleById.get(thread.projectId);
99 const descriptionParts: string[] = [];
100
101 if (projectTitle) {
102 descriptionParts.push(projectTitle);
103 }
104 if (thread.branch) {
105 descriptionParts.push(`#${thread.branch}`);
106 }
107 if (thread.id === input.activeThreadId) {
108 descriptionParts.push("Current thread");
109 }
110
111 return {
112 kind: "action",
113 value: `thread:${thread.id}`,
114 searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""],
115 title: thread.title,
116 description: descriptionParts.join(" · "),
117 timestamp: formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt),
118 icon: input.icon,
119 run: async () => {
120 await input.runThread(thread);
121 },
122 };
123 });
124}
125
126function rankSearchFieldMatch(field: string, normalizedQuery: string): number {
127 const normalizedField = normalizeSearchText(field);
128 if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) {
129 return Number.NEGATIVE_INFINITY;
130 }
131 if (normalizedField === normalizedQuery) {
132 return 3;
133 }
134 if (normalizedField.startsWith(normalizedQuery)) {
135 return 2;
136 }
137 return 1;
138}
139
140function rankCommandPaletteItemMatch(
141 item: CommandPaletteActionItem | CommandPaletteSubmenuItem,
142 normalizedQuery: string,
143): number {
144 const terms = item.searchTerms.filter((term) => term.length > 0);
145 if (terms.length === 0) {
146 return 0;
147 }
148
149 for (const [index, field] of terms.entries()) {
150 const fieldRank = rankSearchFieldMatch(field, normalizedQuery);
151 if (fieldRank !== Number.NEGATIVE_INFINITY) {
152 return 1_000 - index * 100 + fieldRank;
153 }
154 }
155
156 return 0;
157}
158
159export function filterCommandPaletteGroups(input: {
160 activeGroups: ReadonlyArray<CommandPaletteGroup>;
161 query: string;
162 isInSubmenu: boolean;
163 projectSearchItems: ReadonlyArray<CommandPaletteActionItem>;
164 threadSearchItems: ReadonlyArray<CommandPaletteActionItem>;
165}): CommandPaletteGroup[] {
166 const isActionsFilter = input.query.startsWith(">");
167 const searchQuery = isActionsFilter ? input.query.slice(1) : input.query;
168 const normalizedQuery = normalizeSearchText(searchQuery);
169
170 if (normalizedQuery.length === 0) {
171 if (isActionsFilter) {
172 return input.activeGroups.filter((group) => group.value === "actions");
173 }
174 return [...input.activeGroups];
175 }
176
177 let baseGroups = [...input.activeGroups];
178 if (isActionsFilter) {
179 baseGroups = baseGroups.filter((group) => group.value === "actions");
180 } else if (!input.isInSubmenu) {
181 baseGroups = baseGroups.filter((group) => group.value !== "recent-threads");
182 }
183
184 const searchableGroups = [...baseGroups];
185 if (!input.isInSubmenu && !isActionsFilter) {
186 if (input.projectSearchItems.length > 0) {
187 searchableGroups.push({
188 value: "projects-search",
189 label: "Projects",
190 items: input.projectSearchItems,
191 });
192 }
193 if (input.threadSearchItems.length > 0) {
194 searchableGroups.push({
195 value: "threads-search",
196 label: "Threads",
197 items: input.threadSearchItems,
198 });
199 }
200 }
201
202 return searchableGroups.flatMap((group) => {
203 const items = group.items
204 .map((item, index) => {
205 const haystack = normalizeSearchText(item.searchTerms.join(" "));
206 if (!haystack.includes(normalizedQuery)) {
207 return null;
208 }
209
210 return {
211 item,
212 index,
213 rank: rankCommandPaletteItemMatch(item, normalizedQuery),
214 };
215 })
216 .filter(
217 (entry): entry is { item: (typeof group.items)[number]; index: number; rank: number } =>
218 entry !== null,
219 )
220 .toSorted((left, right) => right.rank - left.rank || left.index - right.index)
221 .map((entry) => entry.item);
222
223 if (items.length === 0) {
224 return [];
225 }
226
227 return [{ value: group.value, label: group.label, items }];
228 });
229}
230
231export function getCommandPaletteMode(input: {
232 currentView: CommandPaletteView | null;
233}): CommandPaletteMode {
234 return input.currentView ? "submenu" : "root";
235}
236
237export function buildRootGroups(input: {
238 actionItems: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>;
239 recentThreadItems: ReadonlyArray<CommandPaletteActionItem>;
240}): CommandPaletteGroup[] {
241 const groups: CommandPaletteGroup[] = [];
242 if (input.actionItems.length > 0) {
243 groups.push({ value: "actions", label: "Actions", items: input.actionItems });
244 }
245 if (input.recentThreadItems.length > 0) {
246 groups.push({
247 value: "recent-threads",
248 label: "Recent Threads",
249 items: input.recentThreadItems,
250 });
251 }
252 return groups;
253}
254
255export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string {
256 switch (mode) {
257 case "root":
258 return "Search commands, projects, and threads...";
259 case "submenu":
260 return "Search...";
261 }
262}
apps/web/src/components/CommandPalette.tsx created+458
......@@ -0,0 +1,458 @@
1"use client";
2
3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
4import type { ProjectId } from "@t3tools/contracts";
5import { useNavigate, useParams } from "@tanstack/react-router";
6import {
7 ArrowDownIcon,
8 ArrowLeftIcon,
9 ArrowUpIcon,
10 MessageSquareIcon,
11 SettingsIcon,
12 SquarePenIcon,
13} from "lucide-react";
14import {
15 useDeferredValue,
16 useEffect,
17 useMemo,
18 useRef,
19 useState,
20 type KeyboardEvent,
21 type ReactNode,
22} from "react";
23import { useShallow } from "zustand/react/shallow";
24import { useCommandPaletteStore } from "../commandPaletteStore";
25import { useHandleNewThread } from "../hooks/useHandleNewThread";
26import { useSettings } from "../hooks/useSettings";
27import {
28 startNewThreadInProjectFromContext,
29 startNewThreadFromContext,
30} from "../lib/chatThreadActions";
31import { isTerminalFocused } from "../lib/terminalFocus";
32import { getLatestThreadForProject } from "../lib/threadSort";
33import { cn } from "../lib/utils";
34import {
35 selectProjectsAcrossEnvironments,
36 selectSidebarThreadsAcrossEnvironments,
37 useStore,
38} from "../store";
39import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
40import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
41import {
42 ADDON_ICON_CLASS,
43 buildProjectActionItems,
44 buildRootGroups,
45 buildThreadActionItems,
46 type CommandPaletteActionItem,
47 type CommandPaletteSubmenuItem,
48 type CommandPaletteView,
49 filterCommandPaletteGroups,
50 getCommandPaletteInputPlaceholder,
51 getCommandPaletteMode,
52 ITEM_ICON_CLASS,
53 RECENT_THREAD_LIMIT,
54} from "./CommandPalette.logic";
55import { CommandPaletteResults } from "./CommandPaletteResults";
56import { ProjectFavicon } from "./ProjectFavicon";
57import { useServerKeybindings } from "../rpc/serverState";
58import { resolveShortcutCommand } from "../keybindings";
59import {
60 Command,
61 CommandDialog,
62 CommandDialogPopup,
63 CommandFooter,
64 CommandInput,
65 CommandPanel,
66} from "./ui/command";
67import { Kbd, KbdGroup } from "./ui/kbd";
68import { toastManager } from "./ui/toast";
69import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
70import type { ChatComposerHandle } from "./chat/ChatComposer";
71
72export function CommandPalette({ children }: { children: ReactNode }) {
73 const open = useCommandPaletteStore((store) => store.open);
74 const setOpen = useCommandPaletteStore((store) => store.setOpen);
75 const toggleOpen = useCommandPaletteStore((store) => store.toggleOpen);
76 const keybindings = useServerKeybindings();
77 const composerHandleRef = useRef<ChatComposerHandle | null>(null);
78 const routeTarget = useParams({
79 strict: false,
80 select: (params) => resolveThreadRouteTarget(params),
81 });
82 const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;
83 const terminalOpen = useTerminalStateStore((state) =>
84 routeThreadRef
85 ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen
86 : false,
87 );
88
89 useEffect(() => {
90 const onKeyDown = (event: globalThis.KeyboardEvent) => {
91 if (event.defaultPrevented) return;
92 const command = resolveShortcutCommand(event, keybindings, {
93 context: {
94 terminalFocus: isTerminalFocused(),
95 terminalOpen,
96 },
97 });
98 if (command !== "commandPalette.toggle") {
99 return;
100 }
101 event.preventDefault();
102 event.stopPropagation();
103 toggleOpen();
104 };
105 window.addEventListener("keydown", onKeyDown);
106 return () => window.removeEventListener("keydown", onKeyDown);
107 }, [keybindings, terminalOpen, toggleOpen]);
108
109 return (
110 <ComposerHandleContext.Provider value={composerHandleRef}>
111 <CommandDialog open={open} onOpenChange={setOpen}>
112 {children}
113 <CommandPaletteDialog />
114 </CommandDialog>
115 </ComposerHandleContext.Provider>
116 );
117}
118
119function CommandPaletteDialog() {
120 const open = useCommandPaletteStore((store) => store.open);
121 const setOpen = useCommandPaletteStore((store) => store.setOpen);
122
123 useEffect(() => {
124 return () => {
125 setOpen(false);
126 };
127 }, [setOpen]);
128
129 if (!open) {
130 return null;
131 }
132
133 return <OpenCommandPaletteDialog />;
134}
135
136function OpenCommandPaletteDialog() {
137 const navigate = useNavigate();
138 const setOpen = useCommandPaletteStore((store) => store.setOpen);
139 const composerHandleRef = useComposerHandleContext();
140 const [query, setQuery] = useState("");
141 const deferredQuery = useDeferredValue(query);
142 const isActionsOnly = deferredQuery.startsWith(">");
143 const settings = useSettings();
144 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
145 useHandleNewThread();
146 const projects = useStore(useShallow(selectProjectsAcrossEnvironments));
147 const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments));
148 const keybindings = useServerKeybindings();
149 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
150 const currentView = viewStack.at(-1) ?? null;
151 const paletteMode = getCommandPaletteMode({ currentView });
152
153 const projectTitleById = useMemo(
154 () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.name])),
155 [projects],
156 );
157
158 const activeThreadId = activeThread?.id;
159 const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null;
160
161 const openProjectFromSearch = useMemo(
162 () => async (project: (typeof projects)[number]) => {
163 const latestThread = getLatestThreadForProject(
164 threads.filter((thread) => thread.environmentId === project.environmentId),
165 project.id,
166 settings.sidebarThreadSortOrder,
167 );
168 if (latestThread) {
169 await navigate({
170 to: "/$environmentId/$threadId",
171 params: buildThreadRouteParams(
172 scopeThreadRef(latestThread.environmentId, latestThread.id),
173 ),
174 });
175 return;
176 }
177
178 await handleNewThread(scopeProjectRef(project.environmentId, project.id), {
179 envMode: settings.defaultThreadEnvMode,
180 });
181 },
182 [
183 handleNewThread,
184 navigate,
185 settings.defaultThreadEnvMode,
186 settings.sidebarThreadSortOrder,
187 threads,
188 ],
189 );
190
191 const projectSearchItems = useMemo(
192 () =>
193 buildProjectActionItems({
194 projects,
195 valuePrefix: "project",
196 icon: (project) => (
197 <ProjectFavicon
198 environmentId={project.environmentId}
199 cwd={project.cwd}
200 className={ITEM_ICON_CLASS}
201 />
202 ),
203 runProject: openProjectFromSearch,
204 }),
205 [openProjectFromSearch, projects],
206 );
207
208 const projectThreadItems = useMemo(
209 () =>
210 buildProjectActionItems({
211 projects,
212 valuePrefix: "new-thread-in",
213 icon: (project) => (
214 <ProjectFavicon
215 environmentId={project.environmentId}
216 cwd={project.cwd}
217 className={ITEM_ICON_CLASS}
218 />
219 ),
220 runProject: async (project) => {
221 await startNewThreadInProjectFromContext(
222 {
223 activeDraftThread,
224 activeThread,
225 defaultProjectRef,
226 defaultThreadEnvMode: settings.defaultThreadEnvMode,
227 handleNewThread,
228 },
229 scopeProjectRef(project.environmentId, project.id),
230 );
231 },
232 }),
233 [
234 activeDraftThread,
235 activeThread,
236 defaultProjectRef,
237 handleNewThread,
238 projects,
239 settings.defaultThreadEnvMode,
240 ],
241 );
242
243 const allThreadItems = useMemo(
244 () =>
245 buildThreadActionItems({
246 threads,
247 ...(activeThreadId ? { activeThreadId } : {}),
248 projectTitleById,
249 sortOrder: settings.sidebarThreadSortOrder,
250 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
251 runThread: async (thread) => {
252 await navigate({
253 to: "/$environmentId/$threadId",
254 params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)),
255 });
256 },
257 }),
258 [activeThreadId, navigate, projectTitleById, settings.sidebarThreadSortOrder, threads],
259 );
260 const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);
261
262 function pushView(item: CommandPaletteSubmenuItem): void {
263 setViewStack((previousViews) => [
264 ...previousViews,
265 {
266 addonIcon: item.addonIcon,
267 groups: item.groups,
268 ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}),
269 },
270 ]);
271 setQuery(item.initialQuery ?? "");
272 }
273
274 function popView(): void {
275 setViewStack((previousViews) => previousViews.slice(0, -1));
276 setQuery("");
277 }
278
279 function handleQueryChange(nextQuery: string): void {
280 setQuery(nextQuery);
281 if (nextQuery === "" && currentView?.initialQuery) {
282 popView();
283 }
284 }
285
286 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];
287
288 if (projects.length > 0) {
289 const activeProjectTitle = currentProjectId
290 ? (projectTitleById.get(currentProjectId) ?? null)
291 : null;
292
293 if (activeProjectTitle) {
294 actionItems.push({
295 kind: "action",
296 value: "action:new-thread",
297 searchTerms: ["new thread", "chat", "create", "draft"],
298 title: (
299 <>
300 New thread in <span className="font-semibold">{activeProjectTitle}</span>
301 </>
302 ),
303 icon: <SquarePenIcon className={ITEM_ICON_CLASS} />,
304 shortcutCommand: "chat.new",
305 run: async () => {
306 await startNewThreadFromContext({
307 activeDraftThread,
308 activeThread,
309 defaultProjectRef,
310 defaultThreadEnvMode: settings.defaultThreadEnvMode,
311 handleNewThread,
312 });
313 },
314 });
315 }
316
317 actionItems.push({
318 kind: "submenu",
319 value: "action:new-thread-in",
320 searchTerms: ["new thread", "project", "pick", "choose", "select"],
321 title: "New thread in...",
322 icon: <SquarePenIcon className={ITEM_ICON_CLASS} />,
323 addonIcon: <SquarePenIcon className={ADDON_ICON_CLASS} />,
324 groups: [{ value: "projects", label: "Projects", items: projectThreadItems }],
325 });
326 }
327
328 actionItems.push({
329 kind: "action",
330 value: "action:settings",
331 searchTerms: ["settings", "preferences", "configuration", "keybindings"],
332 title: "Open settings",
333 icon: <SettingsIcon className={ITEM_ICON_CLASS} />,
334 run: async () => {
335 await navigate({ to: "/settings" });
336 },
337 });
338
339 const rootGroups = buildRootGroups({ actionItems, recentThreadItems });
340 const activeGroups = currentView ? currentView.groups : rootGroups;
341
342 const displayedGroups = filterCommandPaletteGroups({
343 activeGroups,
344 query: deferredQuery,
345 isInSubmenu: currentView !== null,
346 projectSearchItems: projectSearchItems,
347 threadSearchItems: allThreadItems,
348 });
349
350 const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode);
351 const isSubmenu = paletteMode === "submenu";
352
353 function handleKeyDown(event: KeyboardEvent<HTMLInputElement>): void {
354 if (event.key === "Backspace" && query === "" && isSubmenu) {
355 event.preventDefault();
356 popView();
357 }
358 }
359
360 function executeItem(item: CommandPaletteActionItem | CommandPaletteSubmenuItem): void {
361 if (item.kind === "submenu") {
362 pushView(item);
363 return;
364 }
365
366 if (!item.keepOpen) {
367 setOpen(false);
368 }
369
370 void item.run().catch((error: unknown) => {
371 toastManager.add({
372 type: "error",
373 title: "Unable to run command",
374 description: error instanceof Error ? error.message : "An unexpected error occurred.",
375 });
376 });
377 }
378
379 return (
380 <CommandDialogPopup
381 aria-label="Command palette"
382 className="overflow-hidden p-0"
383 data-testid="command-palette"
384 finalFocus={() => {
385 composerHandleRef?.current?.focusAtEnd();
386 return false;
387 }}
388 >
389 <Command
390 key={viewStack.length}
391 aria-label="Command palette"
392 autoHighlight="always"
393 mode="none"
394 onValueChange={handleQueryChange}
395 value={query}
396 >
397 <CommandInput
398 placeholder={inputPlaceholder}
399 wrapperClassName={
400 isSubmenu
401 ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto [&_[data-slot=autocomplete-start-addon]]:cursor-pointer"
402 : undefined
403 }
404 {...(isSubmenu
405 ? {
406 startAddon: (
407 <button
408 type="button"
409 className="flex cursor-pointer items-center"
410 aria-label="Back"
411 onClick={popView}
412 >
413 <ArrowLeftIcon />
414 </button>
415 ),
416 }
417 : {})}
418 onKeyDown={handleKeyDown}
419 />
420 <CommandPanel className="max-h-[min(28rem,70vh)]">
421 <CommandPaletteResults
422 groups={displayedGroups}
423 isActionsOnly={isActionsOnly}
424 keybindings={keybindings}
425 onExecuteItem={executeItem}
426 />
427 </CommandPanel>
428 <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start">
429 <div className="flex items-center gap-3">
430 <KbdGroup className="items-center gap-1.5">
431 <Kbd>
432 <ArrowUpIcon />
433 </Kbd>
434 <Kbd>
435 <ArrowDownIcon />
436 </Kbd>
437 <span className={cn("text-muted-foreground/80")}>Navigate</span>
438 </KbdGroup>
439 <KbdGroup className="items-center gap-1.5">
440 <Kbd>Enter</Kbd>
441 <span className={cn("text-muted-foreground/80")}>Select</span>
442 </KbdGroup>
443 {isSubmenu ? (
444 <KbdGroup className="items-center gap-1.5">
445 <Kbd>Backspace</Kbd>
446 <span className={cn("text-muted-foreground/80")}>Back</span>
447 </KbdGroup>
448 ) : null}
449 <KbdGroup className="items-center gap-1.5">
450 <Kbd>Esc</Kbd>
451 <span className={cn("text-muted-foreground/80")}>Close</span>
452 </KbdGroup>
453 </div>
454 </CommandFooter>
455 </Command>
456 </CommandDialogPopup>
457 );
458}
apps/web/src/components/CommandPaletteResults.tsx created+103
......@@ -0,0 +1,103 @@
1import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { ChevronRightIcon } from "lucide-react";
3import { shortcutLabelForCommand } from "../keybindings";
4import {
5 type CommandPaletteActionItem,
6 type CommandPaletteGroup,
7 type CommandPaletteSubmenuItem,
8} from "./CommandPalette.logic";
9import {
10 CommandCollection,
11 CommandGroup,
12 CommandGroupLabel,
13 CommandItem,
14 CommandList,
15 CommandShortcut,
16} from "./ui/command";
17
18interface CommandPaletteResultsProps {
19 emptyStateMessage?: string;
20 groups: ReadonlyArray<CommandPaletteGroup>;
21 isActionsOnly: boolean;
22 keybindings: ResolvedKeybindingsConfig;
23 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;
24}
25
26export function CommandPaletteResults(props: CommandPaletteResultsProps) {
27 if (props.groups.length === 0) {
28 return (
29 <div className="py-10 text-center text-sm text-muted-foreground">
30 {props.emptyStateMessage ??
31 (props.isActionsOnly
32 ? "No matching actions."
33 : "No matching commands, projects, or threads.")}
34 </div>
35 );
36 }
37
38 return (
39 <CommandList>
40 {props.groups.map((group) => (
41 <CommandGroup items={group.items} key={group.value}>
42 <CommandGroupLabel>{group.label}</CommandGroupLabel>
43 <CommandCollection>
44 {(item) => (
45 <CommandPaletteResultRow
46 item={item}
47 key={item.value}
48 keybindings={props.keybindings}
49 onExecuteItem={props.onExecuteItem}
50 />
51 )}
52 </CommandCollection>
53 </CommandGroup>
54 ))}
55 </CommandList>
56 );
57}
58
59function CommandPaletteResultRow(props: {
60 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
61 keybindings: ResolvedKeybindingsConfig;
62 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;
63}) {
64 const shortcutLabel = props.item.shortcutCommand
65 ? shortcutLabelForCommand(props.keybindings, props.item.shortcutCommand)
66 : null;
67
68 return (
69 <CommandItem
70 value={props.item.value}
71 className="cursor-pointer gap-2"
72 onMouseDown={(event) => {
73 event.preventDefault();
74 }}
75 onClick={() => {
76 props.onExecuteItem(props.item);
77 }}
78 >
79 {props.item.icon}
80 {props.item.description ? (
81 <span className="flex min-w-0 flex-1 flex-col">
82 <span className="truncate text-sm text-foreground">{props.item.title}</span>
83 <span className="truncate text-muted-foreground/70 text-xs">
84 {props.item.description}
85 </span>
86 </span>
87 ) : (
88 <span className="flex min-w-0 items-center gap-1.5 truncate text-sm text-foreground">
89 <span className="truncate">{props.item.title}</span>
90 </span>
91 )}
92 {props.item.timestamp ? (
93 <span className="min-w-12 shrink-0 text-right text-[10px] tabular-nums text-muted-foreground/70">
94 {props.item.timestamp}
95 </span>
96 ) : null}
97 {shortcutLabel ? <CommandShortcut>{shortcutLabel}</CommandShortcut> : null}
98 {props.item.kind === "submenu" ? (
99 <ChevronRightIcon className="ml-auto size-4 shrink-0 text-muted-foreground/50" />
100 ) : null}
101 </CommandItem>
102 );
103}
apps/web/src/components/Sidebar.logic.test.ts-129
......@@ -17,7 +17,6 @@ import {
1717 resolveThreadStatusPill,
1818 shouldClearThreadSelectionOnMouseDown,
1919 sortProjectsForSidebar,
20 sortThreadsForSidebar,
2120 THREAD_JUMP_HINT_SHOW_DELAY_MS,
2221} from "./Sidebar.logic";
2322import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts";
......@@ -667,133 +666,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
667666 };
668667}
669668
670describe("sortThreadsForSidebar", () => {
671 it("sorts threads by the latest user message in recency mode", () => {
672 const sorted = sortThreadsForSidebar(
673 [
674 makeThread({
675 id: ThreadId.make("thread-1"),
676 createdAt: "2026-03-09T10:00:00.000Z",
677 updatedAt: "2026-03-09T10:10:00.000Z",
678 messages: [
679 {
680 id: "message-1" as never,
681 role: "user",
682 text: "older",
683 createdAt: "2026-03-09T10:01:00.000Z",
684 streaming: false,
685 completedAt: "2026-03-09T10:01:00.000Z",
686 },
687 ],
688 }),
689 makeThread({
690 id: ThreadId.make("thread-2"),
691 createdAt: "2026-03-09T10:05:00.000Z",
692 updatedAt: "2026-03-09T10:05:00.000Z",
693 messages: [
694 {
695 id: "message-2" as never,
696 role: "user",
697 text: "newer",
698 createdAt: "2026-03-09T10:06:00.000Z",
699 streaming: false,
700 completedAt: "2026-03-09T10:06:00.000Z",
701 },
702 ],
703 }),
704 ],
705 "updated_at",
706 );
707
708 expect(sorted.map((thread) => thread.id)).toEqual([
709 ThreadId.make("thread-2"),
710 ThreadId.make("thread-1"),
711 ]);
712 });
713
714 it("falls back to thread timestamps when there is no user message", () => {
715 const sorted = sortThreadsForSidebar(
716 [
717 makeThread({
718 id: ThreadId.make("thread-1"),
719 createdAt: "2026-03-09T10:00:00.000Z",
720 updatedAt: "2026-03-09T10:01:00.000Z",
721 messages: [
722 {
723 id: "message-1" as never,
724 role: "assistant",
725 text: "assistant only",
726 createdAt: "2026-03-09T10:02:00.000Z",
727 streaming: false,
728 completedAt: "2026-03-09T10:02:00.000Z",
729 },
730 ],
731 }),
732 makeThread({
733 id: ThreadId.make("thread-2"),
734 createdAt: "2026-03-09T10:05:00.000Z",
735 updatedAt: "2026-03-09T10:05:00.000Z",
736 messages: [],
737 }),
738 ],
739 "updated_at",
740 );
741
742 expect(sorted.map((thread) => thread.id)).toEqual([
743 ThreadId.make("thread-2"),
744 ThreadId.make("thread-1"),
745 ]);
746 });
747
748 it("falls back to id ordering when threads have no sortable timestamps", () => {
749 const sorted = sortThreadsForSidebar(
750 [
751 makeThread({
752 id: ThreadId.make("thread-1"),
753 createdAt: "" as never,
754 updatedAt: undefined,
755 messages: [],
756 }),
757 makeThread({
758 id: ThreadId.make("thread-2"),
759 createdAt: "" as never,
760 updatedAt: undefined,
761 messages: [],
762 }),
763 ],
764 "updated_at",
765 );
766
767 expect(sorted.map((thread) => thread.id)).toEqual([
768 ThreadId.make("thread-2"),
769 ThreadId.make("thread-1"),
770 ]);
771 });
772
773 it("can sort threads by createdAt when configured", () => {
774 const sorted = sortThreadsForSidebar(
775 [
776 makeThread({
777 id: ThreadId.make("thread-1"),
778 createdAt: "2026-03-09T10:05:00.000Z",
779 updatedAt: "2026-03-09T10:05:00.000Z",
780 }),
781 makeThread({
782 id: ThreadId.make("thread-2"),
783 createdAt: "2026-03-09T10:00:00.000Z",
784 updatedAt: "2026-03-09T10:10:00.000Z",
785 }),
786 ],
787 "created_at",
788 );
789
790 expect(sorted.map((thread) => thread.id)).toEqual([
791 ThreadId.make("thread-1"),
792 ThreadId.make("thread-2"),
793 ]);
794 });
795});
796
797669describe("getFallbackThreadIdAfterDelete", () => {
798670 it("returns the top remaining thread in the deleted thread's project sidebar order", () => {
799671 const fallbackThreadId = getFallbackThreadIdAfterDelete({
......@@ -860,7 +732,6 @@ describe("getFallbackThreadIdAfterDelete", () => {
860732 expect(fallbackThreadId).toBe(ThreadId.make("thread-next"));
861733 });
862734});
863
864735describe("sortProjectsForSidebar", () => {
865736 it("sorts projects by the most recent user message across their threads", () => {
866737 const projects = [
apps/web/src/components/Sidebar.logic.ts+10-62
......@@ -1,5 +1,11 @@
11import * as React from "react";
22import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import {
4 getThreadSortTimestamp,
5 sortThreads,
6 toSortableTimestamp,
7 type ThreadSortInput,
8} from "../lib/threadSort";
39import type { SidebarThreadSummary, Thread } from "../types";
410import { cn } from "../lib/utils";
511import { isLatestTurnSettled } from "../session-logic";
......@@ -13,10 +19,6 @@ type SidebarProject = {
1319 createdAt?: string | undefined;
1420 updatedAt?: string | undefined;
1521};
16type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & {
17 latestUserMessageAt?: string | null;
18 messages?: Pick<Thread["messages"][number], "createdAt" | "role">[];
19};
2022
2123export type ThreadTraversalDirection = "previous" | "next";
2224
......@@ -441,61 +443,8 @@ export function getVisibleThreadsForProject<T extends Pick<Thread, "id">>(input:
441443 };
442444}
443445
444function toSortableTimestamp(iso: string | undefined): number | null {
445 if (!iso) return null;
446 const ms = Date.parse(iso);
447 return Number.isFinite(ms) ? ms : null;
448}
449
450function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number {
451 if (thread.latestUserMessageAt) {
452 return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;
453 }
454
455 let latestUserMessageTimestamp: number | null = null;
456
457 for (const message of thread.messages ?? []) {
458 if (message.role !== "user") continue;
459 const messageTimestamp = toSortableTimestamp(message.createdAt);
460 if (messageTimestamp === null) continue;
461 latestUserMessageTimestamp =
462 latestUserMessageTimestamp === null
463 ? messageTimestamp
464 : Math.max(latestUserMessageTimestamp, messageTimestamp);
465 }
466
467 if (latestUserMessageTimestamp !== null) {
468 return latestUserMessageTimestamp;
469 }
470
471 return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY;
472}
473
474function getThreadSortTimestamp(
475 thread: SidebarThreadSortInput,
476 sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,
477): number {
478 if (sortOrder === "created_at") {
479 return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY;
480 }
481 return getLatestUserMessageTimestamp(thread);
482}
483
484export function sortThreadsForSidebar<
485 T extends Pick<Thread, "id" | "createdAt" | "updatedAt"> & SidebarThreadSortInput,
486>(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] {
487 return threads.toSorted((left, right) => {
488 const rightTimestamp = getThreadSortTimestamp(right, sortOrder);
489 const leftTimestamp = getThreadSortTimestamp(left, sortOrder);
490 const byTimestamp =
491 rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
492 if (byTimestamp !== 0) return byTimestamp;
493 return right.id.localeCompare(left.id);
494 });
495}
496
497446export function getFallbackThreadIdAfterDelete<
498 T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt"> & SidebarThreadSortInput,
447 T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt"> & ThreadSortInput,
499448>(input: {
500449 threads: readonly T[];
501450 deletedThreadId: T["id"];
......@@ -509,7 +458,7 @@ export function getFallbackThreadIdAfterDelete<
509458 }
510459
511460 return (
512 sortThreadsForSidebar(
461 sortThreads(
513462 threads.filter(
514463 (thread) =>
515464 thread.projectId === deletedThread.projectId &&
......@@ -520,10 +469,9 @@ export function getFallbackThreadIdAfterDelete<
520469 )[0]?.id ?? null
521470 );
522471}
523
524472export function getProjectSortTimestamp(
525473 project: SidebarProject,
526 projectThreads: readonly SidebarThreadSortInput[],
474 projectThreads: readonly ThreadSortInput[],
527475 sortOrder: Exclude<SidebarProjectSortOrder, "manual">,
528476): number {
529477 if (projectThreads.length > 0) {
......@@ -541,7 +489,7 @@ export function getProjectSortTimestamp(
541489
542490export function sortProjectsForSidebar<
543491 TProject extends SidebarProject,
544 TThread extends Pick<Thread, "projectId" | "createdAt" | "updatedAt"> & SidebarThreadSortInput,
492 TThread extends Pick<Thread, "projectId" | "createdAt" | "updatedAt"> & ThreadSortInput,
545493>(
546494 projects: readonly TProject[],
547495 threads: readonly TThread[],
apps/web/src/components/Sidebar.tsx+42-9
......@@ -6,6 +6,7 @@ import {
66 FolderIcon,
77 GitPullRequestIcon,
88 PlusIcon,
9 SearchIcon,
910 SettingsIcon,
1011 SquarePenIcon,
1112 TerminalIcon,
......@@ -90,6 +91,7 @@ import {
9091import { toastManager } from "./ui/toast";
9192import { formatRelativeTimeLabel } from "../timestampFormat";
9293import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
94import { Kbd } from "./ui/kbd";
9395import {
9496 getArm64IntelBuildWarningDescription,
9597 getDesktopUpdateActionError,
......@@ -130,12 +132,13 @@ import {
130132 orderItemsByPreferredIds,
131133 shouldClearThreadSelectionOnMouseDown,
132134 sortProjectsForSidebar,
133 sortThreadsForSidebar,
134135 useThreadJumpHintVisibility,
135136 ThreadStatusPill,
136137} from "./Sidebar.logic";
138import { sortThreads } from "../lib/threadSort";
137139import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
138140import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
141import { CommandDialogTrigger } from "./ui/command";
139142import { readEnvironmentApi } from "../environmentApi";
140143import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
141144import { useServerKeybindings } from "../rpc/serverState";
......@@ -1139,7 +1142,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
11391142 },
11401143 });
11411144 };
1142 const visibleProjectThreads = sortThreadsForSidebar(
1145 const visibleProjectThreads = sortThreads(
11431146 projectThreads.filter((thread) => thread.archivedAt === null),
11441147 threadSortOrder,
11451148 );
......@@ -2041,6 +2044,7 @@ interface SidebarProjectsContentProps {
20412044 activeRouteProjectKey: string | null;
20422045 routeThreadKey: string | null;
20432046 newThreadShortcutLabel: string | null;
2047 commandPaletteShortcutLabel: string | null;
20442048 threadJumpLabelByKey: ReadonlyMap<string, string>;
20452049 attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void;
20462050 expandThreadListForProject: (projectKey: string) => void;
......@@ -2092,6 +2096,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
20922096 activeRouteProjectKey,
20932097 routeThreadKey,
20942098 newThreadShortcutLabel,
2099 commandPaletteShortcutLabel,
20952100 threadJumpLabelByKey,
20962101 attachThreadListAutoAnimateRef,
20972102 expandThreadListForProject,
......@@ -2138,6 +2143,29 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
21382143
21392144 return (
21402145 <SidebarContent className="gap-0">
2146 <SidebarGroup className="px-2 pt-2 pb-1">
2147 <SidebarMenu>
2148 <SidebarMenuItem>
2149 <CommandDialogTrigger
2150 render={
2151 <SidebarMenuButton
2152 size="sm"
2153 className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground focus-visible:ring-0"
2154 data-testid="command-palette-trigger"
2155 />
2156 }
2157 >
2158 <SearchIcon className="size-3.5" />
2159 <span className="flex-1 truncate text-left text-xs">Search</span>
2160 {commandPaletteShortcutLabel ? (
2161 <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">
2162 {commandPaletteShortcutLabel}
2163 </Kbd>
2164 ) : null}
2165 </CommandDialogTrigger>
2166 </SidebarMenuItem>
2167 </SidebarMenu>
2168 </SidebarGroup>
21412169 {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
21422170 <SidebarGroup className="px-2 pt-2 pb-0">
21432171 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
......@@ -2535,7 +2563,7 @@ export default function Sidebar() {
25352563 scopeProjectRef(projectRef.environmentId, projectRef.projectId),
25362564 );
25372565 const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey;
2538 const latestThread = sortThreadsForSidebar(
2566 const latestThread = sortThreads(
25392567 (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null),
25402568 sidebarThreadSortOrder,
25412569 )[0];
......@@ -2549,7 +2577,7 @@ export default function Sidebar() {
25492577 [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey],
25502578 );
25512579
2552 const addProjectFromPath = useCallback(
2580 const addProjectFromInput = useCallback(
25532581 async (rawCwd: string) => {
25542582 const cwd = rawCwd.trim();
25552583 if (!cwd || isAddingProject) return;
......@@ -2575,7 +2603,6 @@ export default function Sidebar() {
25752603 }
25762604
25772605 const projectId = newProjectId();
2578 const createdAt = new Date().toISOString();
25792606 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
25802607 try {
25812608 await api.orchestration.dispatchCommand({
......@@ -2588,7 +2615,7 @@ export default function Sidebar() {
25882615 provider: "codex",
25892616 model: DEFAULT_MODEL_BY_PROVIDER.codex,
25902617 },
2591 createdAt,
2618 createdAt: new Date().toISOString(),
25922619 });
25932620 if (activeEnvironmentId !== null) {
25942621 await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), {
......@@ -2624,7 +2651,7 @@ export default function Sidebar() {
26242651 );
26252652
26262653 const handleAddProject = () => {
2627 void addProjectFromPath(newCwd);
2654 void addProjectFromInput(newCwd);
26282655 };
26292656
26302657 const canAddProject = newCwd.trim().length > 0 && !isAddingProject;
......@@ -2640,7 +2667,7 @@ export default function Sidebar() {
26402667 // Ignore picker failures and leave the current thread selection unchanged.
26412668 }
26422669 if (pickedPath) {
2643 await addProjectFromPath(pickedPath);
2670 await addProjectFromInput(pickedPath);
26442671 } else if (!shouldBrowseForProjectImmediately) {
26452672 addProjectInputRef.current?.focus();
26462673 }
......@@ -2771,7 +2798,7 @@ export default function Sidebar() {
27712798 const visibleSidebarThreadKeys = useMemo(
27722799 () =>
27732800 sortedProjects.flatMap((project) => {
2774 const projectThreads = sortThreadsForSidebar(
2801 const projectThreads = sortThreads(
27752802 (threadsByProjectKey.get(project.projectKey) ?? []).filter(
27762803 (thread) => thread.archivedAt === null,
27772804 ),
......@@ -3042,6 +3069,11 @@ export default function Sidebar() {
30423069 desktopUpdateState && showArm64IntelBuildWarning
30433070 ? getArm64IntelBuildWarningDescription(desktopUpdateState)
30443071 : null;
3072 const commandPaletteShortcutLabel = shortcutLabelForCommand(
3073 keybindings,
3074 "commandPalette.toggle",
3075 newThreadShortcutLabelOptions,
3076 );
30453077 const handleDesktopUpdateButtonClick = useCallback(() => {
30463078 const bridge = window.desktopBridge;
30473079 if (!bridge || !desktopUpdateState) return;
......@@ -3167,6 +3199,7 @@ export default function Sidebar() {
31673199 activeRouteProjectKey={activeRouteProjectKey}
31683200 routeThreadKey={routeThreadKey}
31693201 newThreadShortcutLabel={newThreadShortcutLabel}
3202 commandPaletteShortcutLabel={commandPaletteShortcutLabel}
31703203 threadJumpLabelByKey={visibleThreadJumpLabelByKey}
31713204 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
31723205 expandThreadListForProject={expandThreadListForProject}
apps/web/src/components/ui/command.tsx+6-3
......@@ -30,7 +30,7 @@ function CommandDialogBackdrop({ className, ...props }: CommandDialogPrimitive.B
3030 return (
3131 <CommandDialogPrimitive.Backdrop
3232 className={cn(
33 "fixed inset-0 z-50 bg-black/32 backdrop-blur-sm transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0",
33 "fixed inset-0 z-50 bg-background/60 transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0",
3434 className,
3535 )}
3636 data-slot="command-dialog-backdrop"
......@@ -90,11 +90,14 @@ function Command({
9090
9191function CommandInput({
9292 className,
93 wrapperClassName,
9394 placeholder = undefined,
9495 ...props
95}: React.ComponentProps<typeof AutocompleteInput>) {
96}: React.ComponentProps<typeof AutocompleteInput> & {
97 wrapperClassName?: string | undefined;
98}) {
9699 return (
97 <div className="px-2.5 py-1.5">
100 <div className={cn("px-2.5 py-1.5", wrapperClassName)}>
98101 <AutocompleteInput
99102 autoFocus
100103 className={cn(
apps/web/src/components/ui/toast.tsx+2-2
......@@ -209,7 +209,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
209209 <Toast.Portal data-slot="toast-portal">
210210 <Toast.Viewport
211211 className={cn(
212 "fixed z-50 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-header-offset:52px] [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",
212 "fixed z-100 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-header-offset:52px] [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",
213213 // Vertical positioning
214214 "data-[position*=top]:top-[calc(var(--toast-inset)+var(--toast-header-offset))]",
215215 "data-[position*=bottom]:bottom-(--toast-inset)",
......@@ -381,7 +381,7 @@ function AnchoredToasts() {
381381
382382 return (
383383 <Toast.Positioner
384 className="z-50 max-w-[min(--spacing(64),var(--available-width))]"
384 className="z-100 max-w-[min(--spacing(64),var(--available-width))]"
385385 data-slot="toast-positioner"
386386 key={toast.id}
387387 sideOffset={positionerProps.sideOffset ?? 4}
apps/web/src/composerHandleContext.ts created+10
......@@ -0,0 +1,10 @@
1import { createContext, useContext, type MutableRefObject } from "react";
2import type { ChatComposerHandle } from "./components/chat/ChatComposer";
3
4export type ComposerHandleRef = MutableRefObject<ChatComposerHandle | null>;
5
6export const ComposerHandleContext = createContext<ComposerHandleRef | null>(null);
7
8export function useComposerHandleContext(): ComposerHandleRef | null {
9 return useContext(ComposerHandleContext);
10}
apps/web/src/keybindings.test.ts+26
......@@ -101,6 +101,11 @@ const DEFAULT_BINDINGS = compile([
101101 command: "diff.toggle",
102102 whenAst: whenNot(whenIdentifier("terminalFocus")),
103103 },
104 {
105 shortcut: modShortcut("k"),
106 command: "commandPalette.toggle",
107 whenAst: whenNot(whenIdentifier("terminalFocus")),
108 },
104109 { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
105110 { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" },
106111 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
......@@ -249,6 +254,10 @@ describe("shortcutLabelForCommand", () => {
249254 it("returns effective labels for non-terminal commands", () => {
250255 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
251256 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");
257 assert.strictEqual(
258 shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"),
259 "⌘K",
260 );
252261 assert.strictEqual(
253262 shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"),
254263 "Ctrl+O",
......@@ -382,6 +391,23 @@ describe("chat/editor shortcuts", () => {
382391 );
383392 });
384393
394 it("matches commandPalette.toggle shortcut outside terminal focus", () => {
395 assert.strictEqual(
396 resolveShortcutCommand(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, {
397 platform: "MacIntel",
398 context: { terminalFocus: false },
399 }),
400 "commandPalette.toggle",
401 );
402 assert.notStrictEqual(
403 resolveShortcutCommand(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, {
404 platform: "MacIntel",
405 context: { terminalFocus: true },
406 }),
407 "commandPalette.toggle",
408 );
409 });
410
385411 it("matches diff.toggle shortcut outside terminal focus", () => {
386412 assert.isTrue(
387413 isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
apps/web/src/lib/chatThreadActions.test.ts created+107
......@@ -0,0 +1,107 @@
1import { scopeProjectRef } from "@t3tools/client-runtime";
2import { EnvironmentId, ProjectId } from "@t3tools/contracts";
3import { describe, expect, it, vi } from "vitest";
4import {
5 resolveThreadActionProjectRef,
6 startNewLocalThreadFromContext,
7 startNewThreadFromContext,
8 type ChatThreadActionContext,
9} from "./chatThreadActions";
10
11const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
12const PROJECT_ID = ProjectId.make("project-1");
13const FALLBACK_PROJECT_ID = ProjectId.make("project-2");
14
15function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatThreadActionContext {
16 return {
17 activeDraftThread: null,
18 activeThread: undefined,
19 defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID),
20 defaultThreadEnvMode: "local",
21 handleNewThread: async () => {},
22 ...overrides,
23 };
24}
25
26describe("chatThreadActions", () => {
27 it("prefers the active draft thread project when resolving thread actions", () => {
28 const projectRef = resolveThreadActionProjectRef(
29 createContext({
30 activeDraftThread: {
31 environmentId: ENVIRONMENT_ID,
32 projectId: PROJECT_ID,
33 branch: "feature/refactor",
34 worktreePath: "/tmp/worktree",
35 envMode: "worktree",
36 },
37 }),
38 );
39
40 expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
41 });
42
43 it("falls back to the default project ref when there is no active thread context", () => {
44 const projectRef = resolveThreadActionProjectRef(
45 createContext({
46 defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID),
47 }),
48 );
49
50 expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
51 });
52
53 it("starts a contextual new thread from the active draft thread", async () => {
54 const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {});
55
56 const didStart = await startNewThreadFromContext(
57 createContext({
58 activeDraftThread: {
59 environmentId: ENVIRONMENT_ID,
60 projectId: PROJECT_ID,
61 branch: "feature/refactor",
62 worktreePath: "/tmp/worktree",
63 envMode: "worktree",
64 },
65 handleNewThread,
66 }),
67 );
68
69 expect(didStart).toBe(true);
70 expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), {
71 branch: "feature/refactor",
72 worktreePath: "/tmp/worktree",
73 envMode: "worktree",
74 });
75 });
76
77 it("starts a local thread with the configured default env mode", async () => {
78 const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {});
79
80 const didStart = await startNewLocalThreadFromContext(
81 createContext({
82 defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID),
83 defaultThreadEnvMode: "worktree",
84 handleNewThread,
85 }),
86 );
87
88 expect(didStart).toBe(true);
89 expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), {
90 envMode: "worktree",
91 });
92 });
93
94 it("does not start a thread when there is no project context", async () => {
95 const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {});
96
97 const didStart = await startNewThreadFromContext(
98 createContext({
99 defaultProjectRef: null,
100 handleNewThread,
101 }),
102 );
103
104 expect(didStart).toBe(false);
105 expect(handleNewThread).not.toHaveBeenCalled();
106 });
107});
apps/web/src/lib/chatThreadActions.ts created+98
......@@ -0,0 +1,98 @@
1import { scopeProjectRef } from "@t3tools/client-runtime";
2import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts";
3import type { DraftThreadEnvMode } from "../composerDraftStore";
4
5interface ThreadContextLike {
6 environmentId: EnvironmentId;
7 projectId: ProjectId;
8 branch: string | null;
9 worktreePath: string | null;
10}
11
12interface DraftThreadContextLike extends ThreadContextLike {
13 envMode: DraftThreadEnvMode;
14}
15
16interface NewThreadHandler {
17 (
18 projectRef: ScopedProjectRef,
19 options?: {
20 branch?: string | null;
21 worktreePath?: string | null;
22 envMode?: DraftThreadEnvMode;
23 },
24 ): Promise<void>;
25}
26
27type NewThreadOptions = NonNullable<Parameters<NewThreadHandler>[1]>;
28
29export interface ChatThreadActionContext {
30 readonly activeDraftThread: DraftThreadContextLike | null;
31 readonly activeThread: ThreadContextLike | undefined;
32 readonly defaultProjectRef: ScopedProjectRef | null;
33 readonly defaultThreadEnvMode: DraftThreadEnvMode;
34 readonly handleNewThread: NewThreadHandler;
35}
36
37export function resolveThreadActionProjectRef(
38 context: ChatThreadActionContext,
39): ScopedProjectRef | null {
40 if (context.activeThread) {
41 return scopeProjectRef(context.activeThread.environmentId, context.activeThread.projectId);
42 }
43 if (context.activeDraftThread) {
44 return scopeProjectRef(
45 context.activeDraftThread.environmentId,
46 context.activeDraftThread.projectId,
47 );
48 }
49 return context.defaultProjectRef;
50}
51
52function buildContextualThreadOptions(context: ChatThreadActionContext): NewThreadOptions {
53 return {
54 branch: context.activeThread?.branch ?? context.activeDraftThread?.branch ?? null,
55 worktreePath:
56 context.activeThread?.worktreePath ?? context.activeDraftThread?.worktreePath ?? null,
57 envMode:
58 context.activeDraftThread?.envMode ??
59 (context.activeThread?.worktreePath ? "worktree" : "local"),
60 };
61}
62
63function buildDefaultThreadOptions(context: ChatThreadActionContext): NewThreadOptions {
64 return {
65 envMode: context.defaultThreadEnvMode,
66 };
67}
68
69export async function startNewThreadInProjectFromContext(
70 context: ChatThreadActionContext,
71 projectRef: ScopedProjectRef,
72): Promise<void> {
73 await context.handleNewThread(projectRef, buildContextualThreadOptions(context));
74}
75
76export async function startNewThreadFromContext(
77 context: ChatThreadActionContext,
78): Promise<boolean> {
79 const projectRef = resolveThreadActionProjectRef(context);
80 if (!projectRef) {
81 return false;
82 }
83
84 await startNewThreadInProjectFromContext(context, projectRef);
85 return true;
86}
87
88export async function startNewLocalThreadFromContext(
89 context: ChatThreadActionContext,
90): Promise<boolean> {
91 const projectRef = resolveThreadActionProjectRef(context);
92 if (!projectRef) {
93 return false;
94 }
95
96 await context.handleNewThread(projectRef, buildDefaultThreadOptions(context));
97 return true;
98}
apps/web/src/lib/threadSort.test.ts created+187
......@@ -0,0 +1,187 @@
1import { describe, expect, it } from "vitest";
2import { DEFAULT_RUNTIME_MODE, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
3import type { Thread } from "../types";
4import { getLatestThreadForProject, sortThreads } from "./threadSort";
5
6const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
7const PROJECT_ID = ProjectId.make("project-1");
8
9function makeThread(overrides: Partial<Thread> = {}): Thread {
10 return {
11 id: ThreadId.make("thread-1"),
12 environmentId: LOCAL_ENVIRONMENT_ID,
13 codexThreadId: null,
14 projectId: PROJECT_ID,
15 title: "Thread",
16 modelSelection: { provider: "codex", model: "gpt-5.4" },
17 runtimeMode: DEFAULT_RUNTIME_MODE,
18 interactionMode: "default",
19 session: null,
20 messages: [],
21 proposedPlans: [],
22 error: null,
23 createdAt: "2026-03-09T10:00:00.000Z",
24 archivedAt: null,
25 updatedAt: "2026-03-09T10:00:00.000Z",
26 latestTurn: null,
27 branch: null,
28 worktreePath: null,
29 turnDiffSummaries: [],
30 activities: [],
31 ...overrides,
32 };
33}
34
35describe("sortThreads", () => {
36 it("sorts threads by the latest user message in recency mode", () => {
37 const sorted = sortThreads(
38 [
39 makeThread({
40 id: ThreadId.make("thread-1"),
41 updatedAt: "2026-03-09T10:10:00.000Z",
42 messages: [
43 {
44 id: "message-1" as never,
45 role: "user",
46 text: "older",
47 createdAt: "2026-03-09T10:01:00.000Z",
48 streaming: false,
49 completedAt: "2026-03-09T10:01:00.000Z",
50 },
51 ],
52 }),
53 makeThread({
54 id: ThreadId.make("thread-2"),
55 createdAt: "2026-03-09T10:05:00.000Z",
56 updatedAt: "2026-03-09T10:05:00.000Z",
57 messages: [
58 {
59 id: "message-2" as never,
60 role: "user",
61 text: "newer",
62 createdAt: "2026-03-09T10:06:00.000Z",
63 streaming: false,
64 completedAt: "2026-03-09T10:06:00.000Z",
65 },
66 ],
67 }),
68 ],
69 "updated_at",
70 );
71
72 expect(sorted.map((thread) => thread.id)).toEqual([
73 ThreadId.make("thread-2"),
74 ThreadId.make("thread-1"),
75 ]);
76 });
77
78 it("falls back to thread timestamps when there is no user message", () => {
79 const sorted = sortThreads(
80 [
81 makeThread({
82 id: ThreadId.make("thread-1"),
83 updatedAt: "2026-03-09T10:01:00.000Z",
84 messages: [
85 {
86 id: "message-1" as never,
87 role: "assistant",
88 text: "assistant only",
89 createdAt: "2026-03-09T10:02:00.000Z",
90 streaming: false,
91 completedAt: "2026-03-09T10:02:00.000Z",
92 },
93 ],
94 }),
95 makeThread({
96 id: ThreadId.make("thread-2"),
97 createdAt: "2026-03-09T10:05:00.000Z",
98 updatedAt: "2026-03-09T10:05:00.000Z",
99 messages: [],
100 }),
101 ],
102 "updated_at",
103 );
104
105 expect(sorted.map((thread) => thread.id)).toEqual([
106 ThreadId.make("thread-2"),
107 ThreadId.make("thread-1"),
108 ]);
109 });
110
111 it("falls back to id ordering when threads have no sortable timestamps", () => {
112 const sorted = sortThreads(
113 [
114 makeThread({
115 id: ThreadId.make("thread-1"),
116 createdAt: "" as never,
117 updatedAt: undefined,
118 messages: [],
119 }),
120 makeThread({
121 id: ThreadId.make("thread-2"),
122 createdAt: "" as never,
123 updatedAt: undefined,
124 messages: [],
125 }),
126 ],
127 "updated_at",
128 );
129
130 expect(sorted.map((thread) => thread.id)).toEqual([
131 ThreadId.make("thread-2"),
132 ThreadId.make("thread-1"),
133 ]);
134 });
135
136 it("can sort threads by createdAt when configured", () => {
137 const sorted = sortThreads(
138 [
139 makeThread({
140 id: ThreadId.make("thread-1"),
141 createdAt: "2026-03-09T10:05:00.000Z",
142 updatedAt: "2026-03-09T10:05:00.000Z",
143 }),
144 makeThread({
145 id: ThreadId.make("thread-2"),
146 createdAt: "2026-03-09T10:00:00.000Z",
147 updatedAt: "2026-03-09T10:10:00.000Z",
148 }),
149 ],
150 "created_at",
151 );
152
153 expect(sorted.map((thread) => thread.id)).toEqual([
154 ThreadId.make("thread-1"),
155 ThreadId.make("thread-2"),
156 ]);
157 });
158
159 it("returns the latest active thread for a project", () => {
160 const latestThread = getLatestThreadForProject(
161 [
162 makeThread({
163 id: ThreadId.make("thread-1"),
164 createdAt: "2026-03-09T10:00:00.000Z",
165 updatedAt: "2026-03-09T10:01:00.000Z",
166 archivedAt: null,
167 }),
168 makeThread({
169 id: ThreadId.make("thread-2"),
170 createdAt: "2026-03-09T10:05:00.000Z",
171 updatedAt: "2026-03-09T10:10:00.000Z",
172 archivedAt: "2026-03-10T00:00:00.000Z",
173 }),
174 makeThread({
175 id: ThreadId.make("thread-3"),
176 createdAt: "2026-03-09T10:06:00.000Z",
177 updatedAt: "2026-03-09T10:06:00.000Z",
178 archivedAt: null,
179 }),
180 ],
181 PROJECT_ID,
182 "updated_at",
183 );
184
185 expect(latestThread?.id).toBe(ThreadId.make("thread-3"));
186 });
187});
apps/web/src/lib/threadSort.ts created+73
......@@ -0,0 +1,73 @@
1import type { ProjectId } from "@t3tools/contracts";
2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import type { Thread } from "../types";
4
5export type ThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & {
6 latestUserMessageAt?: string | null;
7 messages?: Pick<Thread["messages"][number], "createdAt" | "role">[];
8};
9
10export function toSortableTimestamp(iso: string | undefined): number | null {
11 if (!iso) return null;
12 const ms = Date.parse(iso);
13 return Number.isFinite(ms) ? ms : null;
14}
15
16function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
17 if (thread.latestUserMessageAt) {
18 return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;
19 }
20
21 let latestUserMessageTimestamp: number | null = null;
22
23 for (const message of thread.messages ?? []) {
24 if (message.role !== "user") continue;
25 const messageTimestamp = toSortableTimestamp(message.createdAt);
26 if (messageTimestamp === null) continue;
27 latestUserMessageTimestamp =
28 latestUserMessageTimestamp === null
29 ? messageTimestamp
30 : Math.max(latestUserMessageTimestamp, messageTimestamp);
31 }
32
33 if (latestUserMessageTimestamp !== null) {
34 return latestUserMessageTimestamp;
35 }
36
37 return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY;
38}
39
40export function getThreadSortTimestamp(
41 thread: ThreadSortInput,
42 sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,
43): number {
44 if (sortOrder === "created_at") {
45 return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY;
46 }
47 return getLatestUserMessageTimestamp(thread);
48}
49
50export function sortThreads<T extends Pick<Thread, "id"> & ThreadSortInput>(
51 threads: readonly T[],
52 sortOrder: SidebarThreadSortOrder,
53): T[] {
54 return threads.toSorted((left, right) => {
55 const rightTimestamp = getThreadSortTimestamp(right, sortOrder);
56 const leftTimestamp = getThreadSortTimestamp(left, sortOrder);
57 const byTimestamp =
58 rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
59 if (byTimestamp !== 0) return byTimestamp;
60 return right.id.localeCompare(left.id);
61 });
62}
63
64export function getLatestThreadForProject<
65 T extends Pick<Thread, "id" | "projectId" | "archivedAt"> & ThreadSortInput,
66>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null {
67 return (
68 sortThreads(
69 threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null),
70 sortOrder,
71 )[0] ?? null
72 );
73}
apps/web/src/routes/__root.tsx+6-3
......@@ -12,6 +12,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query";
1212
1313import { APP_DISPLAY_NAME } from "../branding";
1414import { AppSidebarLayout } from "../components/AppSidebarLayout";
15import { CommandPalette } from "../components/CommandPalette";
1516import {
1617 SlowRpcAckToastCoordinator,
1718 WebSocketConnectionCoordinator,
......@@ -93,9 +94,11 @@ function RootRouteView() {
9394 <WebSocketConnectionCoordinator />
9495 <SlowRpcAckToastCoordinator />
9596 <WebSocketConnectionSurface>
96 <AppSidebarLayout>
97 <Outlet />
98 </AppSidebarLayout>
97 <CommandPalette>
98 <AppSidebarLayout>
99 <Outlet />
100 </AppSidebarLayout>
101 </CommandPalette>
99102 </WebSocketConnectionSurface>
100103 </AnchoredToastProvider>
101104 </ToastProvider>
apps/web/src/routes/_chat.tsx+29-24
......@@ -1,12 +1,16 @@
1import { scopeProjectRef } from "@t3tools/client-runtime";
21import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
32import { useEffect } from "react";
43
4import { useCommandPaletteStore } from "../commandPaletteStore";
55import {
66 ensurePrimaryEnvironmentReady,
77 resolveInitialServerAuthGateState,
88} from "../environments/primary";
99import { useHandleNewThread } from "../hooks/useHandleNewThread";
10import {
11 startNewLocalThreadFromContext,
12 startNewThreadFromContext,
13} from "../lib/chatThreadActions";
1014import { isTerminalFocused } from "../lib/terminalFocus";
1115import { resolveShortcutCommand } from "../keybindings";
1216import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
......@@ -31,20 +35,6 @@ function ChatRouteGlobalShortcuts() {
3135 useEffect(() => {
3236 const onWindowKeyDown = (event: KeyboardEvent) => {
3337 if (event.defaultPrevented) return;
34
35 if (event.key === "Escape" && selectedThreadKeysSize > 0) {
36 event.preventDefault();
37 clearSelection();
38 return;
39 }
40
41 const projectRef = activeThread
42 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
43 : activeDraftThread && routeThreadRef
44 ? scopeProjectRef(routeThreadRef.environmentId, activeDraftThread.projectId)
45 : defaultProjectRef;
46 if (!projectRef) return;
47
4838 const command = resolveShortcutCommand(event, keybindings, {
4939 context: {
5040 terminalFocus: isTerminalFocused(),
......@@ -52,13 +42,27 @@ function ChatRouteGlobalShortcuts() {
5242 },
5343 });
5444
45 if (useCommandPaletteStore.getState().open) {
46 return;
47 }
48
49 if (event.key === "Escape" && selectedThreadKeysSize > 0) {
50 event.preventDefault();
51 clearSelection();
52 return;
53 }
54
5555 if (command === "chat.newLocal") {
5656 event.preventDefault();
5757 event.stopPropagation();
58 void handleNewThread(projectRef, {
59 envMode: resolveSidebarNewThreadEnvMode({
58 void startNewLocalThreadFromContext({
59 activeDraftThread,
60 activeThread,
61 defaultProjectRef,
62 defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({
6063 defaultEnvMode: appSettings.defaultThreadEnvMode,
6164 }),
65 handleNewThread,
6266 });
6367 return;
6468 }
......@@ -66,13 +70,15 @@ function ChatRouteGlobalShortcuts() {
6670 if (command === "chat.new") {
6771 event.preventDefault();
6872 event.stopPropagation();
69 void handleNewThread(projectRef, {
70 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
71 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
72 envMode:
73 activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"),
73 void startNewThreadFromContext({
74 activeDraftThread,
75 activeThread,
76 defaultProjectRef,
77 defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({
78 defaultEnvMode: appSettings.defaultThreadEnvMode,
79 }),
80 handleNewThread,
7481 });
75 return;
7682 }
7783 };
7884
......@@ -87,7 +93,6 @@ function ChatRouteGlobalShortcuts() {
8793 handleNewThread,
8894 keybindings,
8995 defaultProjectRef,
90 routeThreadRef,
9196 selectedThreadKeysSize,
9297 terminalOpen,
9398 appSettings.defaultThreadEnvMode,
packages/contracts/src/keybindings.test.ts+6
......@@ -41,6 +41,12 @@ it.effect("parses keybinding rules", () =>
4141 });
4242 assert.strictEqual(parsedDiffToggle.command, "diff.toggle");
4343
44 const parsedCommandPalette = yield* decode(KeybindingRule, {
45 key: "mod+k",
46 command: "commandPalette.toggle",
47 });
48 assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle");
49
4450 const parsedLocal = yield* decode(KeybindingRule, {
4551 key: "mod+shift+n",
4652 command: "chat.newLocal",
packages/contracts/src/keybindings.ts+1
......@@ -33,6 +33,7 @@ const STATIC_KEYBINDING_COMMANDS = [
3333 "terminal.new",
3434 "terminal.close",
3535 "diff.toggle",
36 "commandPalette.toggle",
3637 "chat.new",
3738 "chat.newLocal",
3839 "editor.openFavorite",