authorgravatar for gianmarcosimone89@gmail.comGianmarco <gianmarcosimone89@gmail.com> 2026-08-12 23:32:58+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-12 17:32:58-04:00
logac1264e2cab1f357cc683bc52c32bcec23845f32
treeae70a6c9ac9ecb2587f1b5e10a57bcc742c5285d
parent2eb099fdc896506a2663bc7fe3117b6062ac345f
signature Signed by PGP key B5690EEEBB952194

feat(web): project favicon and workspace icons in command subtitles (#6330)

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,6 +264,20 @@ describe("buildThreadActionItems", () => {
264 expect(item?.description).toBe("T3 Code · #feat/search");264 expect(item?.description).toBe("T3 Code · #feat/search");
265 });265 });
266266
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 it("filters archived threads out of thread search items", () => {281 it("filters archived threads out of thread search items", () => {
268 const items = buildThreadActionItems({282 const items = buildThreadActionItems({
269 threads: [283 threads: [
apps/web/src/components/CommandPalette.logic.ts+16-2
...@@ -154,7 +154,16 @@ export function buildProjectActionItems(input: {...@@ -154,7 +154,16 @@ export function buildProjectActionItems(input: {
154154
155export type BuildThreadActionItemsThread = Pick<155export type BuildThreadActionItemsThread = Pick<
156 SidebarThreadSummary,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 updatedAt: string;168 updatedAt: string;
160 latestUserMessageAt?: string | null;169 latestUserMessageAt?: string | null;
...@@ -170,6 +179,8 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr...@@ -170,6 +179,8 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
170 renderLeadingContent?: (thread: TThread) => ReactNode;179 renderLeadingContent?: (thread: TThread) => ReactNode;
171 /** Optional content rendered inline after the title text per-thread. */180 /** Optional content rendered inline after the title text per-thread. */
172 renderTrailingContent?: (thread: TThread) => ReactNode;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 getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined;184 getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined;
174 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;185 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
175 limit?: number;186 limit?: number;
...@@ -198,6 +209,9 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr...@@ -198,6 +209,9 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
198 const leadingContent = input.renderLeadingContent?.(thread);209 const leadingContent = input.renderLeadingContent?.(thread);
199 const trailingContent = input.renderTrailingContent?.(thread);210 const trailingContent = input.renderTrailingContent?.(thread);
200 const contentMatch = input.getContentMatch?.(thread);211 const contentMatch = input.getContentMatch?.(thread);
212 const description = input.renderDescription
213 ? input.renderDescription(thread, { projectTitle })
214 : descriptionParts.join(` · `);
201215
202 return Object.assign(216 return Object.assign(
203 {217 {
...@@ -210,7 +224,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr...@@ -210,7 +224,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
210 contentMatch?.snippet ?? ``,224 contentMatch?.snippet ?? ``,
211 ],225 ],
212 title: thread.title,226 title: thread.title,
213 description: descriptionParts.join(` · `),227 description,
214 timestamp: formatRelativeTimeLabel(228 timestamp: formatRelativeTimeLabel(
215 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,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,9 +124,14 @@ import { ProjectFavicon } from "./ProjectFavicon";
124import { ProjectFilePicker } from "./files/ProjectFilePicker";124import { ProjectFilePicker } from "./files/ProjectFilePicker";
125import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";125import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
126import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";126import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
127import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle";
127import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";128import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
128import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";129import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
129import { resolveDefaultProviderModelSelection } from "../providerInstances";130import {
131 deriveProviderInstanceEntries,
132 resolveDefaultProviderModelSelection,
133 type ProviderInstanceEntry,
134} from "../providerInstances";
130import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";135import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
131import { CommandDialog, CommandDialogPopup } from "./ui/command";136import { CommandDialog, CommandDialogPopup } from "./ui/command";
132import { Button } from "./ui/button";137import { Button } from "./ui/button";
...@@ -584,6 +589,18 @@ function OpenCommandPaletteDialog(props: {...@@ -584,6 +589,18 @@ function OpenCommandPaletteDialog(props: {
584 const keybindings = useAtomValue(primaryServerKeybindingsAtom);589 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
585 const { theme, themeHalves, resolvedTheme } = useTheme();590 const { theme, themeHalves, resolvedTheme } = useTheme();
586 const providers = useAtomValue(primaryServerProvidersAtom);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 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);604 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
588 const currentView = viewStack.at(-1) ?? null;605 const currentView = viewStack.at(-1) ?? null;
589 const environmentIds = useMemo(606 const environmentIds = useMemo(
...@@ -816,6 +833,10 @@ function OpenCommandPaletteDialog(props: {...@@ -816,6 +833,10 @@ function OpenCommandPaletteDialog(props: {
816 new Map<ProjectId, string>(projects.map((project) => [project.id, project.workspaceRoot])),833 new Map<ProjectId, string>(projects.map((project) => [project.id, project.workspaceRoot])),
817 [projects],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 const projectTitleById = useMemo(840 const projectTitleById = useMemo(
820 () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.title])),841 () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.title])),
821 [projects],842 [projects],
...@@ -1000,6 +1021,29 @@ function OpenCommandPaletteDialog(props: {...@@ -1000,6 +1021,29 @@ function OpenCommandPaletteDialog(props: {
1000 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,1021 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
1001 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,1022 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
1002 renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,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 getContentMatch: (thread) => {1047 getContentMatch: (thread) => {
1004 const match = threadContentMatchByKey.get(1048 const match = threadContentMatchByKey.get(
1005 threadSearchMatchKey({1049 threadSearchMatchKey({
...@@ -1026,7 +1070,10 @@ function OpenCommandPaletteDialog(props: {...@@ -1026,7 +1070,10 @@ function OpenCommandPaletteDialog(props: {
1026 activeThreadId,1070 activeThreadId,
1027 clientSettings.sidebarThreadSortOrder,1071 clientSettings.sidebarThreadSortOrder,
1028 navigate,1072 navigate,
1073 projectCwdById,
1074 projectFaviconPathById,
1029 projectTitleById,1075 projectTitleById,
1076 providerEntryByEnvironmentAndInstanceId,
1030 threadContentMatchByKey,1077 threadContentMatchByKey,
1031 threadSearchQuery,1078 threadSearchQuery,
1032 threads,1079 threads,
apps/web/src/components/CommandPaletteResults.tsx+2-2
...@@ -142,7 +142,7 @@ function DisabledCommandPaletteResultRow(props: {...@@ -142,7 +142,7 @@ function DisabledCommandPaletteResultRow(props: {
142 <ThreadContentMatch match={props.item.threadContentMatch} />142 <ThreadContentMatch match={props.item.threadContentMatch} />
143 ) : null}143 ) : null}
144 {props.item.description ? (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 {props.item.description}146 {props.item.description}
147 </span>147 </span>
148 ) : null}148 ) : null}
...@@ -193,7 +193,7 @@ function CommandPaletteResultRow(props: {...@@ -193,7 +193,7 @@ function CommandPaletteResultRow(props: {
193 <ThreadContentMatch match={props.item.threadContentMatch} />193 <ThreadContentMatch match={props.item.threadContentMatch} />
194 ) : null}194 ) : null}
195 {props.item.description ? (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 {props.item.description}197 {props.item.description}
198 </span>198 </span>
199 ) : null}199 ) : null}
apps/web/src/components/ThreadCommandSubtitle.tsx created+112
...@@ -0,0 +1,112 @@
1import type { EnvironmentId, ProviderDriverKind } from "@t3tools/contracts";
2import { FolderGit2Icon, FolderIcon, GitBranchIcon } from "lucide-react";
3import { ProjectFavicon } from "./ProjectFavicon";
4import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
5import { 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 */
13export type ThreadCommandSubtitleVariant =
14 | "favicon-workspace-harness"
15 | "favicon-workspace"
16 | "favicon-branch-harness";
17
18export const THREAD_COMMAND_SUBTITLE_VARIANT: ThreadCommandSubtitleVariant =
19 "favicon-workspace-harness";
20
21const META_ICON_CLASS = "size-3 shrink-0 text-muted-foreground/70";
22
23function Dot() {
24 return <span className="shrink-0 text-muted-foreground/50">·</span>;
25}
26
27function 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
37export 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}