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", () => {
264264 expect(item?.description).toBe("T3 Code · #feat/search");
265265 });
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
267281 it("filters archived threads out of thread search items", () => {
268282 const items = buildThreadActionItems({
269283 threads: [
apps/web/src/components/CommandPalette.logic.ts+16-2
......@@ -154,7 +154,16 @@ export function buildProjectActionItems(input: {
154154
155155export type BuildThreadActionItemsThread = Pick<
156156 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"
158167> & {
159168 updatedAt: string;
160169 latestUserMessageAt?: string | null;
......@@ -170,6 +179,8 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
170179 renderLeadingContent?: (thread: TThread) => ReactNode;
171180 /** Optional content rendered inline after the title text per-thread. */
172181 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;
173184 getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined;
174185 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
175186 limit?: number;
......@@ -198,6 +209,9 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
198209 const leadingContent = input.renderLeadingContent?.(thread);
199210 const trailingContent = input.renderTrailingContent?.(thread);
200211 const contentMatch = input.getContentMatch?.(thread);
212 const description = input.renderDescription
213 ? input.renderDescription(thread, { projectTitle })
214 : descriptionParts.join(` · `);
201215
202216 return Object.assign(
203217 {
......@@ -210,7 +224,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
210224 contentMatch?.snippet ?? ``,
211225 ],
212226 title: thread.title,
213 description: descriptionParts.join(` · `),
227 description,
214228 timestamp: formatRelativeTimeLabel(
215229 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
216230 ),
apps/web/src/components/CommandPalette.tsx+48-1
......@@ -124,9 +124,14 @@ import { ProjectFavicon } from "./ProjectFavicon";
124124import { ProjectFilePicker } from "./files/ProjectFilePicker";
125125import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
126126import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
127import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle";
127128import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
128129import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
129import { resolveDefaultProviderModelSelection } from "../providerInstances";
130import {
131 deriveProviderInstanceEntries,
132 resolveDefaultProviderModelSelection,
133 type ProviderInstanceEntry,
134} from "../providerInstances";
130135import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
131136import { CommandDialog, CommandDialogPopup } from "./ui/command";
132137import { Button } from "./ui/button";
......@@ -584,6 +589,18 @@ function OpenCommandPaletteDialog(props: {
584589 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
585590 const { theme, themeHalves, resolvedTheme } = useTheme();
586591 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]);
587604 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
588605 const currentView = viewStack.at(-1) ?? null;
589606 const environmentIds = useMemo(
......@@ -816,6 +833,10 @@ function OpenCommandPaletteDialog(props: {
816833 new Map<ProjectId, string>(projects.map((project) => [project.id, project.workspaceRoot])),
817834 [projects],
818835 );
836 const projectFaviconPathById = useMemo(
837 () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)),
838 [projects],
839 );
819840 const projectTitleById = useMemo(
820841 () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.title])),
821842 [projects],
......@@ -1000,6 +1021,29 @@ function OpenCommandPaletteDialog(props: {
10001021 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
10011022 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
10021023 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 },
10031047 getContentMatch: (thread) => {
10041048 const match = threadContentMatchByKey.get(
10051049 threadSearchMatchKey({
......@@ -1026,7 +1070,10 @@ function OpenCommandPaletteDialog(props: {
10261070 activeThreadId,
10271071 clientSettings.sidebarThreadSortOrder,
10281072 navigate,
1073 projectCwdById,
1074 projectFaviconPathById,
10291075 projectTitleById,
1076 providerEntryByEnvironmentAndInstanceId,
10301077 threadContentMatchByKey,
10311078 threadSearchQuery,
10321079 threads,
apps/web/src/components/CommandPaletteResults.tsx+2-2
......@@ -142,7 +142,7 @@ function DisabledCommandPaletteResultRow(props: {
142142 <ThreadContentMatch match={props.item.threadContentMatch} />
143143 ) : null}
144144 {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">
146146 {props.item.description}
147147 </span>
148148 ) : null}
......@@ -193,7 +193,7 @@ function CommandPaletteResultRow(props: {
193193 <ThreadContentMatch match={props.item.threadContentMatch} />
194194 ) : null}
195195 {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">
197197 {props.item.description}
198198 </span>
199199 ) : 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}