authorgravatar for Noojuno@users.noreply.github.comJono Kemball <Noojuno@users.noreply.github.com> 2026-04-17 17:21:57+12:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-16 22:21:57-07:00
logd8d329691a3db759d6054af35e373005e9efcabd
treea6de329c58eb7162acd5f4d3e87e383c3cf2a466
parent54179c86b8b3ce54dc3abcfb3467ff330f03be2f
signature Signed by PGP key B5690EEEBB952194

Show thread status in command palette (#2107)


5 files changed, 281 insertions(+), 120 deletions(-)

apps/web/src/components/CommandPalette.logic.ts+23-10
......@@ -17,6 +17,10 @@ export interface CommandPaletteItem {
1717 readonly description?: string;
1818 readonly timestamp?: string;
1919 readonly icon: ReactNode;
20 /** Optional content rendered inline before the title text. */
21 readonly titleLeadingContent?: ReactNode;
22 /** Optional content rendered inline after the title text (before the timestamp). */
23 readonly titleTrailingContent?: ReactNode;
2024 readonly shortcutCommand?: KeybindingCommand;
2125}
2226
......@@ -102,20 +106,24 @@ export function buildProjectActionItems(input: {
102106 }));
103107}
104108
105export function buildThreadActionItems(input: {
106 threads: ReadonlyArray<
107 Pick<
108 SidebarThreadSummary,
109 "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title"
110 > & {
111 updatedAt?: string | undefined;
112 latestUserMessageAt?: string | null;
113 }
114 >;
109export type BuildThreadActionItemsThread = Pick<
110 SidebarThreadSummary,
111 "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title"
112> & {
113 updatedAt?: string | undefined;
114 latestUserMessageAt?: string | null;
115};
116
117export function buildThreadActionItems<TThread extends BuildThreadActionItemsThread>(input: {
118 threads: ReadonlyArray<TThread>;
115119 activeThreadId?: Thread["id"];
116120 projectTitleById: ReadonlyMap<Project["id"], string>;
117121 sortOrder: SidebarThreadSortOrder;
118122 icon: ReactNode;
123 /** Optional content rendered inline before the title text per-thread. */
124 renderLeadingContent?: (thread: TThread) => ReactNode;
125 /** Optional content rendered inline after the title text per-thread. */
126 renderTrailingContent?: (thread: TThread) => ReactNode;
119127 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
120128 limit?: number;
121129}): CommandPaletteActionItem[] {
......@@ -140,6 +148,9 @@ export function buildThreadActionItems(input: {
140148 descriptionParts.push("Current thread");
141149 }
142150
151 const leadingContent = input.renderLeadingContent?.(thread);
152 const trailingContent = input.renderTrailingContent?.(thread);
153
143154 return {
144155 kind: "action",
145156 value: `thread:${thread.id}`,
......@@ -150,6 +161,8 @@ export function buildThreadActionItems(input: {
150161 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
151162 ),
152163 icon: input.icon,
164 ...(leadingContent ? { titleLeadingContent: leadingContent } : {}),
165 ...(trailingContent ? { titleTrailingContent: trailingContent } : {}),
153166 run: async () => {
154167 await input.runThread(thread);
155168 },
apps/web/src/components/CommandPalette.tsx+3
......@@ -89,6 +89,7 @@ import {
8989import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
9090import { CommandPaletteResults } from "./CommandPaletteResults";
9191import { ProjectFavicon } from "./ProjectFavicon";
92import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
9293import { useServerKeybindings } from "../rpc/serverState";
9394import { resolveShortcutCommand } from "../keybindings";
9495import {
......@@ -504,6 +505,8 @@ function OpenCommandPaletteDialog() {
504505 projectTitleById,
505506 sortOrder: settings.sidebarThreadSortOrder,
506507 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
508 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
509 renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,
507510 runThread: async (thread) => {
508511 await navigate({
509512 to: "/$environmentId/$threadId",
apps/web/src/components/CommandPaletteResults.tsx+8-2
......@@ -86,14 +86,20 @@ function CommandPaletteResultRow(props: {
8686 {props.item.icon}
8787 {props.item.description ? (
8888 <span className="flex min-w-0 flex-1 flex-col">
89 <span className="truncate text-sm text-foreground">{props.item.title}</span>
89 <span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">
90 {props.item.titleLeadingContent}
91 <span className="truncate">{props.item.title}</span>
92 {props.item.titleTrailingContent}
93 </span>
9094 <span className="truncate text-muted-foreground/70 text-xs">
9195 {props.item.description}
9296 </span>
9397 </span>
9498 ) : (
95 <span className="flex min-w-0 items-center gap-1.5 truncate text-sm text-foreground">
99 <span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">
100 {props.item.titleLeadingContent}
96101 <span className="truncate">{props.item.title}</span>
102 {props.item.titleTrailingContent}
97103 </span>
98104 )}
99105 {props.item.timestamp ? (
apps/web/src/components/Sidebar.tsx+6-108
......@@ -11,6 +11,12 @@ import {
1111 TerminalIcon,
1212 TriangleAlertIcon,
1313} from "lucide-react";
14import {
15 prStatusIndicator,
16 resolveThreadPr,
17 terminalStatusFromRunningIds,
18 ThreadStatusLabel,
19} from "./ThreadStatusIndicators";
1420import { ProjectFavicon } from "./ProjectFavicon";
1521import { autoAnimate } from "@formkit/auto-animate";
1622import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";
......@@ -38,7 +44,6 @@ import {
3844 type SidebarProjectGroupingMode,
3945 type ThreadEnvMode,
4046 ThreadId,
41 type GitStatusResult,
4247} from "@t3tools/contracts";
4348import {
4449 parseScopedThreadKey,
......@@ -264,113 +269,6 @@ function buildThreadJumpLabelMap(input: {
264269 return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;
265270}
266271
267interface TerminalStatusIndicator {
268 label: "Terminal process running";
269 colorClass: string;
270 pulse: boolean;
271}
272
273interface PrStatusIndicator {
274 label: "PR open" | "PR closed" | "PR merged";
275 colorClass: string;
276 tooltip: string;
277 url: string;
278}
279
280type ThreadPr = GitStatusResult["pr"];
281
282function ThreadStatusLabel({
283 status,
284 compact = false,
285}: {
286 status: ThreadStatusPill;
287 compact?: boolean;
288}) {
289 if (compact) {
290 return (
291 <span
292 title={status.label}
293 className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`}
294 >
295 <span
296 className={`size-[9px] rounded-full ${status.dotClass} ${
297 status.pulse ? "animate-pulse" : ""
298 }`}
299 />
300 <span className="sr-only">{status.label}</span>
301 </span>
302 );
303 }
304
305 return (
306 <span
307 title={status.label}
308 className={`inline-flex items-center gap-1 text-[10px] ${status.colorClass}`}
309 >
310 <span
311 className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${
312 status.pulse ? "animate-pulse" : ""
313 }`}
314 />
315 <span className="hidden md:inline">{status.label}</span>
316 </span>
317 );
318}
319
320function terminalStatusFromRunningIds(
321 runningTerminalIds: string[],
322): TerminalStatusIndicator | null {
323 if (runningTerminalIds.length === 0) {
324 return null;
325 }
326 return {
327 label: "Terminal process running",
328 colorClass: "text-teal-600 dark:text-teal-300/90",
329 pulse: true,
330 };
331}
332
333function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {
334 if (!pr) return null;
335
336 if (pr.state === "open") {
337 return {
338 label: "PR open",
339 colorClass: "text-emerald-600 dark:text-emerald-300/90",
340 tooltip: `#${pr.number} PR open: ${pr.title}`,
341 url: pr.url,
342 };
343 }
344 if (pr.state === "closed") {
345 return {
346 label: "PR closed",
347 colorClass: "text-zinc-500 dark:text-zinc-400/80",
348 tooltip: `#${pr.number} PR closed: ${pr.title}`,
349 url: pr.url,
350 };
351 }
352 if (pr.state === "merged") {
353 return {
354 label: "PR merged",
355 colorClass: "text-violet-600 dark:text-violet-300/90",
356 tooltip: `#${pr.number} PR merged: ${pr.title}`,
357 url: pr.url,
358 };
359 }
360 return null;
361}
362
363function resolveThreadPr(
364 threadBranch: string | null,
365 gitStatus: GitStatusResult | null,
366): ThreadPr | null {
367 if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) {
368 return null;
369 }
370
371 return gitStatus.pr ?? null;
372}
373
374272interface SidebarThreadRowProps {
375273 thread: SidebarThreadSummary;
376274 projectCwd: string | null;
apps/web/src/components/ThreadStatusIndicators.tsx created+241
......@@ -0,0 +1,241 @@
1import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
2import type { GitStatusResult } from "@t3tools/contracts";
3import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
4import { useMemo } from "react";
5import { usePrimaryEnvironmentId } from "../environments/primary";
6import {
7 useSavedEnvironmentRegistryStore,
8 useSavedEnvironmentRuntimeStore,
9} from "../environments/runtime";
10import { useGitStatus } from "../lib/gitStatusState";
11import { type AppState, selectProjectByRef, useStore } from "../store";
12import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
13import { useUiStateStore } from "../uiStateStore";
14import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";
15import type { SidebarThreadSummary } from "../types";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
17
18export interface PrStatusIndicator {
19 label: "PR open" | "PR closed" | "PR merged";
20 colorClass: string;
21 tooltip: string;
22 url: string;
23}
24
25export interface TerminalStatusIndicator {
26 label: "Terminal process running";
27 colorClass: string;
28 pulse: boolean;
29}
30
31export type ThreadPr = GitStatusResult["pr"];
32
33export function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {
34 if (!pr) return null;
35
36 if (pr.state === "open") {
37 return {
38 label: "PR open",
39 colorClass: "text-emerald-600 dark:text-emerald-300/90",
40 tooltip: `#${pr.number} PR open: ${pr.title}`,
41 url: pr.url,
42 };
43 }
44 if (pr.state === "closed") {
45 return {
46 label: "PR closed",
47 colorClass: "text-zinc-500 dark:text-zinc-400/80",
48 tooltip: `#${pr.number} PR closed: ${pr.title}`,
49 url: pr.url,
50 };
51 }
52 if (pr.state === "merged") {
53 return {
54 label: "PR merged",
55 colorClass: "text-violet-600 dark:text-violet-300/90",
56 tooltip: `#${pr.number} PR merged: ${pr.title}`,
57 url: pr.url,
58 };
59 }
60 return null;
61}
62
63export function resolveThreadPr(
64 threadBranch: string | null,
65 gitStatus: GitStatusResult | null,
66): ThreadPr | null {
67 if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) {
68 return null;
69 }
70
71 return gitStatus.pr ?? null;
72}
73
74export function terminalStatusFromRunningIds(
75 runningTerminalIds: string[],
76): TerminalStatusIndicator | null {
77 if (runningTerminalIds.length === 0) {
78 return null;
79 }
80 return {
81 label: "Terminal process running",
82 colorClass: "text-teal-600 dark:text-teal-300/90",
83 pulse: true,
84 };
85}
86
87export function ThreadStatusLabel({
88 status,
89 compact = false,
90}: {
91 status: ThreadStatusPill;
92 compact?: boolean;
93}) {
94 if (compact) {
95 return (
96 <span
97 title={status.label}
98 className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`}
99 >
100 <span
101 className={`size-[9px] rounded-full ${status.dotClass} ${
102 status.pulse ? "animate-pulse" : ""
103 }`}
104 />
105 <span className="sr-only">{status.label}</span>
106 </span>
107 );
108 }
109
110 return (
111 <span
112 title={status.label}
113 className={`inline-flex items-center gap-1 text-[10px] ${status.colorClass}`}
114 >
115 <span
116 className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${
117 status.pulse ? "animate-pulse" : ""
118 }`}
119 />
120 <span className="hidden md:inline">{status.label}</span>
121 </span>
122 );
123}
124
125/**
126 * Non-interactive leading status icons for a thread row in compact contexts
127 * like the command palette. Shows the PR state icon (if present) and the
128 * thread status dot, matching the sidebar's leading indicators.
129 */
130export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) {
131 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
132 const lastVisitedAt = useUiStateStore(
133 (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)],
134 );
135 const threadProjectCwd = useStore(
136 useMemo(
137 () => (state: AppState) =>
138 selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ??
139 null,
140 [thread.environmentId, thread.projectId],
141 ),
142 );
143 const gitCwd = thread.worktreePath ?? threadProjectCwd;
144 const gitStatus = useGitStatus({
145 environmentId: thread.environmentId,
146 cwd: thread.branch != null ? gitCwd : null,
147 });
148 const pr = resolveThreadPr(thread.branch, gitStatus.data);
149 const prStatus = prStatusIndicator(pr);
150 const threadStatus = resolveThreadStatusPill({
151 thread: {
152 ...thread,
153 lastVisitedAt,
154 },
155 });
156
157 if (!prStatus && !threadStatus) {
158 return null;
159 }
160
161 return (
162 <span className="inline-flex shrink-0 items-center gap-1.5">
163 {prStatus ? (
164 <Tooltip>
165 <TooltipTrigger
166 render={
167 <span
168 aria-label={prStatus.tooltip}
169 className={`inline-flex items-center justify-center ${prStatus.colorClass}`}
170 />
171 }
172 >
173 <GitPullRequestIcon className="size-3" />
174 </TooltipTrigger>
175 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
176 </Tooltip>
177 ) : null}
178 {threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null}
179 </span>
180 );
181}
182
183/**
184 * Non-interactive trailing status icons for a thread row in compact contexts
185 * like the command palette. Shows a terminal-running indicator and a remote
186 * environment indicator, matching the sidebar's trailing indicators.
187 */
188export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) {
189 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
190 const runningTerminalIds = useTerminalStateStore(
191 (state) =>
192 selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds,
193 );
194 const primaryEnvironmentId = usePrimaryEnvironmentId();
195 const isRemoteThread =
196 primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
197 const remoteEnvLabel = useSavedEnvironmentRuntimeStore(
198 (state) => state.byId[thread.environmentId]?.descriptor?.label ?? null,
199 );
200 const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore(
201 (state) => state.byId[thread.environmentId]?.label ?? null,
202 );
203 const threadEnvironmentLabel = isRemoteThread
204 ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote")
205 : null;
206 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
207
208 if (!terminalStatus && !isRemoteThread) {
209 return null;
210 }
211
212 return (
213 <span className="inline-flex shrink-0 items-center gap-1.5">
214 {terminalStatus ? (
215 <span
216 role="img"
217 aria-label={terminalStatus.label}
218 title={terminalStatus.label}
219 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
220 >
221 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />
222 </span>
223 ) : null}
224 {isRemoteThread ? (
225 <Tooltip>
226 <TooltipTrigger
227 render={
228 <span
229 aria-label={threadEnvironmentLabel ?? "Remote"}
230 className="inline-flex items-center justify-center"
231 />
232 }
233 >
234 <CloudIcon className="size-3 text-muted-foreground/60" />
235 </TooltipTrigger>
236 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
237 </Tooltip>
238 ) : null}
239 </span>
240 );
241}