| author | |
| committer | |
| log | ac1264e2cab1f357cc683bc52c32bcec23845f32 |
| tree | ae70a6c9ac9ecb2587f1b5e10a57bcc742c5285d |
| parent | 2eb099fdc896506a2663bc7fe3117b6062ac345f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Cursor <cursoragent@cursor.com>5 files changed, 192 insertions(+), 5 deletions(-)
apps/web/src/components/CommandPalette.logic.test.ts+14| ... | ... | @@ -264,6 +264,20 @@ describe("buildThreadActionItems", () => { |
| 264 | 264 | expect(item?.description).toBe("T3 Code · #feat/search"); |
| 265 | 265 | }); |
| 266 | 266 | |
| 267 | it("prefers renderDescription when provided", () => { | |
| 268 | const [item] = buildThreadActionItems({ | |
| 269 | threads: [makeThread({ branch: "feat/search", worktreePath: "/tmp/wt" })], | |
| 270 | projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), | |
| 271 | sortOrder: "updated_at", | |
| 272 | icon: null, | |
| 273 | renderDescription: (thread, { projectTitle }) => | |
| 274 | `${projectTitle}:${thread.branch}:${thread.worktreePath ? "wt" : "local"}`, | |
| 275 | runThread: async (_thread) => undefined, | |
| 276 | }); | |
| 277 | ||
| 278 | expect(item?.description).toBe("T3 Code:feat/search:wt"); | |
| 279 | }); | |
| 280 | ||
| 267 | 281 | it("filters archived threads out of thread search items", () => { |
| 268 | 282 | const items = buildThreadActionItems({ |
| 269 | 283 | threads: [ |
apps/web/src/components/CommandPalette.logic.ts+16-2| ... | ... | @@ -154,7 +154,16 @@ export function buildProjectActionItems(input: { |
| 154 | 154 | |
| 155 | 155 | export type BuildThreadActionItemsThread = Pick< |
| 156 | 156 | SidebarThreadSummary, |
| 157 | "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" | |
| 157 | | "archivedAt" | |
| 158 | | "branch" | |
| 159 | | "createdAt" | |
| 160 | | "environmentId" | |
| 161 | | "id" | |
| 162 | | "modelSelection" | |
| 163 | | "projectId" | |
| 164 | | "session" | |
| 165 | | "title" | |
| 166 | | "worktreePath" | |
| 158 | 167 | > & { |
| 159 | 168 | updatedAt: string; |
| 160 | 169 | latestUserMessageAt?: string | null; |
| ... | ... | @@ -170,6 +179,8 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr |
| 170 | 179 | renderLeadingContent?: (thread: TThread) => ReactNode; |
| 171 | 180 | /** Optional content rendered inline after the title text per-thread. */ |
| 172 | 181 | renderTrailingContent?: (thread: TThread) => ReactNode; |
| 182 | /** Optional rich description (e.g. favicon + workspace icons). Falls back to text. */ | |
| 183 | renderDescription?: (thread: TThread, meta: { projectTitle: string | undefined }) => ReactNode; | |
| 173 | 184 | getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined; |
| 174 | 185 | runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>; |
| 175 | 186 | limit?: number; |
| ... | ... | @@ -198,6 +209,9 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr |
| 198 | 209 | const leadingContent = input.renderLeadingContent?.(thread); |
| 199 | 210 | const trailingContent = input.renderTrailingContent?.(thread); |
| 200 | 211 | const contentMatch = input.getContentMatch?.(thread); |
| 212 | const description = input.renderDescription | |
| 213 | ? input.renderDescription(thread, { projectTitle }) | |
| 214 | : descriptionParts.join(` · `); | |
| 201 | 215 | |
| 202 | 216 | return Object.assign( |
| 203 | 217 | { |
| ... | ... | @@ -210,7 +224,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr |
| 210 | 224 | contentMatch?.snippet ?? ``, |
| 211 | 225 | ], |
| 212 | 226 | title: thread.title, |
| 213 | description: descriptionParts.join(` · `), | |
| 227 | description, | |
| 214 | 228 | timestamp: formatRelativeTimeLabel( |
| 215 | 229 | thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, |
| 216 | 230 | ), |
apps/web/src/components/CommandPalette.tsx+48-1| ... | ... | @@ -124,9 +124,14 @@ import { ProjectFavicon } from "./ProjectFavicon"; |
| 124 | 124 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; |
| 125 | 125 | import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; |
| 126 | 126 | import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; |
| 127 | import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle"; | |
| 127 | 128 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; |
| 128 | 129 | import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; |
| 129 | import { resolveDefaultProviderModelSelection } from "../providerInstances"; | |
| 130 | import { | |
| 131 | deriveProviderInstanceEntries, | |
| 132 | resolveDefaultProviderModelSelection, | |
| 133 | type ProviderInstanceEntry, | |
| 134 | } from "../providerInstances"; | |
| 130 | 135 | import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; |
| 131 | 136 | import { CommandDialog, CommandDialogPopup } from "./ui/command"; |
| 132 | 137 | import { Button } from "./ui/button"; |
| ... | ... | @@ -584,6 +589,18 @@ function OpenCommandPaletteDialog(props: { |
| 584 | 589 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 585 | 590 | const { theme, themeHalves, resolvedTheme } = useTheme(); |
| 586 | 591 | const providers = useAtomValue(primaryServerProvidersAtom); |
| 592 | const providerEntryByEnvironmentAndInstanceId = useMemo(() => { | |
| 593 | const map = new Map<string, ProviderInstanceEntry>(); | |
| 594 | for (const environment of environments) { | |
| 595 | const environmentProviders = | |
| 596 | environment.serverConfig?.providers ?? | |
| 597 | (environment.environmentId === primaryEnvironmentId ? providers : []); | |
| 598 | for (const entry of deriveProviderInstanceEntries(environmentProviders)) { | |
| 599 | map.set(`${environment.environmentId}:${entry.instanceId}`, entry); | |
| 600 | } | |
| 601 | } | |
| 602 | return map; | |
| 603 | }, [environments, primaryEnvironmentId, providers]); | |
| 587 | 604 | const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]); |
| 588 | 605 | const currentView = viewStack.at(-1) ?? null; |
| 589 | 606 | const environmentIds = useMemo( |
| ... | ... | @@ -816,6 +833,10 @@ function OpenCommandPaletteDialog(props: { |
| 816 | 833 | new Map<ProjectId, string>(projects.map((project) => [project.id, project.workspaceRoot])), |
| 817 | 834 | [projects], |
| 818 | 835 | ); |
| 836 | const projectFaviconPathById = useMemo( | |
| 837 | () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)), | |
| 838 | [projects], | |
| 839 | ); | |
| 819 | 840 | const projectTitleById = useMemo( |
| 820 | 841 | () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.title])), |
| 821 | 842 | [projects], |
| ... | ... | @@ -1000,6 +1021,29 @@ function OpenCommandPaletteDialog(props: { |
| 1000 | 1021 | icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />, |
| 1001 | 1022 | renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />, |
| 1002 | 1023 | renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />, |
| 1024 | renderDescription: (thread, { projectTitle }) => { | |
| 1025 | const modelInstanceId = | |
| 1026 | thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; | |
| 1027 | const providerEntry = | |
| 1028 | providerEntryByEnvironmentAndInstanceId.get( | |
| 1029 | `${thread.environmentId}:${modelInstanceId}`, | |
| 1030 | ) ?? null; | |
| 1031 | return ( | |
| 1032 | <ThreadCommandSubtitle | |
| 1033 | environmentId={thread.environmentId} | |
| 1034 | projectCwd={projectCwdById.get(thread.projectId) ?? null} | |
| 1035 | projectFaviconPath={projectFaviconPathById.get(thread.projectId) ?? null} | |
| 1036 | projectTitle={projectTitle ?? null} | |
| 1037 | branch={thread.branch} | |
| 1038 | worktreePath={thread.worktreePath} | |
| 1039 | isCurrent={thread.id === activeThreadId} | |
| 1040 | driverKind={providerEntry?.driverKind ?? null} | |
| 1041 | providerDisplayName={ | |
| 1042 | thread.session?.providerName ?? providerEntry?.displayName ?? modelInstanceId | |
| 1043 | } | |
| 1044 | /> | |
| 1045 | ); | |
| 1046 | }, | |
| 1003 | 1047 | getContentMatch: (thread) => { |
| 1004 | 1048 | const match = threadContentMatchByKey.get( |
| 1005 | 1049 | threadSearchMatchKey({ |
| ... | ... | @@ -1026,7 +1070,10 @@ function OpenCommandPaletteDialog(props: { |
| 1026 | 1070 | activeThreadId, |
| 1027 | 1071 | clientSettings.sidebarThreadSortOrder, |
| 1028 | 1072 | navigate, |
| 1073 | projectCwdById, | |
| 1074 | projectFaviconPathById, | |
| 1029 | 1075 | projectTitleById, |
| 1076 | providerEntryByEnvironmentAndInstanceId, | |
| 1030 | 1077 | threadContentMatchByKey, |
| 1031 | 1078 | threadSearchQuery, |
| 1032 | 1079 | threads, |
apps/web/src/components/CommandPaletteResults.tsx+2-2| ... | ... | @@ -142,7 +142,7 @@ function DisabledCommandPaletteResultRow(props: { |
| 142 | 142 | <ThreadContentMatch match={props.item.threadContentMatch} /> |
| 143 | 143 | ) : null} |
| 144 | 144 | {props.item.description ? ( |
| 145 | <span className="truncate text-muted-foreground/70 text-xs"> | |
| 145 | <span className="min-w-0 text-muted-foreground/70 text-xs"> | |
| 146 | 146 | {props.item.description} |
| 147 | 147 | </span> |
| 148 | 148 | ) : null} |
| ... | ... | @@ -193,7 +193,7 @@ function CommandPaletteResultRow(props: { |
| 193 | 193 | <ThreadContentMatch match={props.item.threadContentMatch} /> |
| 194 | 194 | ) : null} |
| 195 | 195 | {props.item.description ? ( |
| 196 | <span className="truncate text-muted-foreground/70 text-xs"> | |
| 196 | <span className="min-w-0 text-muted-foreground/70 text-xs"> | |
| 197 | 197 | {props.item.description} |
| 198 | 198 | </span> |
| 199 | 199 | ) : null} |
apps/web/src/components/ThreadCommandSubtitle.tsx created+112| ... | ... | @@ -0,0 +1,112 @@ |
| 1 | import type { EnvironmentId, ProviderDriverKind } from "@t3tools/contracts"; | |
| 2 | import { FolderGit2Icon, FolderIcon, GitBranchIcon } from "lucide-react"; | |
| 3 | import { ProjectFavicon } from "./ProjectFavicon"; | |
| 4 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; | |
| 5 | import { cn } from "~/lib/utils"; | |
| 6 | ||
| 7 | /** | |
| 8 | * Flip this while reviewing command-palette thread subtitles. | |
| 9 | * - favicon-workspace-harness: favicon + Folder/FolderGit2 + branch + harness (default) | |
| 10 | * - favicon-workspace: same without harness | |
| 11 | * - favicon-branch-harness: GitBranch for local, FolderGit2 for worktrees, + harness | |
| 12 | */ | |
| 13 | export type ThreadCommandSubtitleVariant = | |
| 14 | | "favicon-workspace-harness" | |
| 15 | | "favicon-workspace" | |
| 16 | | "favicon-branch-harness"; | |
| 17 | ||
| 18 | export const THREAD_COMMAND_SUBTITLE_VARIANT: ThreadCommandSubtitleVariant = | |
| 19 | "favicon-workspace-harness"; | |
| 20 | ||
| 21 | const META_ICON_CLASS = "size-3 shrink-0 text-muted-foreground/70"; | |
| 22 | ||
| 23 | function Dot() { | |
| 24 | return <span className="shrink-0 text-muted-foreground/50">·</span>; | |
| 25 | } | |
| 26 | ||
| 27 | function WorkspaceIcon(props: { variant: ThreadCommandSubtitleVariant; isWorktree: boolean }) { | |
| 28 | if (props.isWorktree) { | |
| 29 | return <FolderGit2Icon className={META_ICON_CLASS} aria-hidden />; | |
| 30 | } | |
| 31 | if (props.variant === "favicon-branch-harness") { | |
| 32 | return <GitBranchIcon className={META_ICON_CLASS} aria-hidden />; | |
| 33 | } | |
| 34 | return <FolderIcon className={META_ICON_CLASS} aria-hidden />; | |
| 35 | } | |
| 36 | ||
| 37 | export function ThreadCommandSubtitle(props: { | |
| 38 | environmentId: EnvironmentId; | |
| 39 | projectCwd: string | null; | |
| 40 | projectFaviconPath?: string | null; | |
| 41 | projectTitle: string | null; | |
| 42 | branch: string | null; | |
| 43 | worktreePath: string | null; | |
| 44 | isCurrent: boolean; | |
| 45 | driverKind?: ProviderDriverKind | null; | |
| 46 | providerDisplayName?: string | null; | |
| 47 | variant?: ThreadCommandSubtitleVariant; | |
| 48 | className?: string; | |
| 49 | }) { | |
| 50 | const variant = props.variant ?? THREAD_COMMAND_SUBTITLE_VARIANT; | |
| 51 | const isWorktree = props.worktreePath != null && props.worktreePath.trim().length > 0; | |
| 52 | const showHarness = | |
| 53 | variant !== "favicon-workspace" && props.driverKind != null && props.providerDisplayName; | |
| 54 | ||
| 55 | const projectLabel = props.projectTitle?.trim() || null; | |
| 56 | const branchLabel = props.branch?.trim() || null; | |
| 57 | ||
| 58 | if (!projectLabel && !branchLabel && !props.isCurrent && !showHarness) { | |
| 59 | return null; | |
| 60 | } | |
| 61 | ||
| 62 | return ( | |
| 63 | <span | |
| 64 | className={cn( | |
| 65 | "inline-flex min-w-0 max-w-full items-center gap-1 text-xs text-muted-foreground/70", | |
| 66 | props.className, | |
| 67 | )} | |
| 68 | > | |
| 69 | {projectLabel ? ( | |
| 70 | <span className="inline-flex min-w-0 items-center gap-1"> | |
| 71 | {props.projectCwd ? ( | |
| 72 | <ProjectFavicon | |
| 73 | environmentId={props.environmentId} | |
| 74 | cwd={props.projectCwd} | |
| 75 | faviconPath={props.projectFaviconPath} | |
| 76 | className="size-3 shrink-0" | |
| 77 | /> | |
| 78 | ) : null} | |
| 79 | <span className="min-w-0 truncate">{projectLabel}</span> | |
| 80 | </span> | |
| 81 | ) : null} | |
| 82 | ||
| 83 | {branchLabel ? ( | |
| 84 | <> | |
| 85 | {projectLabel ? <Dot /> : null} | |
| 86 | <span className="inline-flex min-w-0 items-center gap-1"> | |
| 87 | <WorkspaceIcon variant={variant} isWorktree={isWorktree} /> | |
| 88 | <span className="min-w-0 truncate">{branchLabel}</span> | |
| 89 | </span> | |
| 90 | </> | |
| 91 | ) : null} | |
| 92 | ||
| 93 | {showHarness && props.driverKind ? ( | |
| 94 | <> | |
| 95 | {projectLabel || branchLabel ? <Dot /> : null} | |
| 96 | <ProviderInstanceIcon | |
| 97 | driverKind={props.driverKind} | |
| 98 | displayName={props.providerDisplayName ?? props.driverKind} | |
| 99 | iconClassName="size-3 shrink-0 opacity-70" | |
| 100 | /> | |
| 101 | </> | |
| 102 | ) : null} | |
| 103 | ||
| 104 | {props.isCurrent ? ( | |
| 105 | <> | |
| 106 | {projectLabel || branchLabel || showHarness ? <Dot /> : null} | |
| 107 | <span className="shrink-0">Current thread</span> | |
| 108 | </> | |
| 109 | ) : null} | |
| 110 | </span> | |
| 111 | ); | |
| 112 | } |