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 {...@@ -17,6 +17,10 @@ export interface CommandPaletteItem {
17 readonly description?: string;17 readonly description?: string;
18 readonly timestamp?: string;18 readonly timestamp?: string;
19 readonly icon: ReactNode;19 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;
20 readonly shortcutCommand?: KeybindingCommand;24 readonly shortcutCommand?: KeybindingCommand;
21}25}
2226
...@@ -102,20 +106,24 @@ export function buildProjectActionItems(input: {...@@ -102,20 +106,24 @@ export function buildProjectActionItems(input: {
102 }));106 }));
103}107}
104108
105export function buildThreadActionItems(input: {109export type BuildThreadActionItemsThread = Pick<
106 threads: ReadonlyArray<110 SidebarThreadSummary,
107 Pick<111 "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title"
108 SidebarThreadSummary,112> & {
109 "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title"113 updatedAt?: string | undefined;
110 > & {114 latestUserMessageAt?: string | null;
111 updatedAt?: string | undefined;115};
112 latestUserMessageAt?: string | null;116
113 }117export function buildThreadActionItems<TThread extends BuildThreadActionItemsThread>(input: {
114 >;118 threads: ReadonlyArray<TThread>;
115 activeThreadId?: Thread["id"];119 activeThreadId?: Thread["id"];
116 projectTitleById: ReadonlyMap<Project["id"], string>;120 projectTitleById: ReadonlyMap<Project["id"], string>;
117 sortOrder: SidebarThreadSortOrder;121 sortOrder: SidebarThreadSortOrder;
118 icon: ReactNode;122 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;
119 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;127 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
120 limit?: number;128 limit?: number;
121}): CommandPaletteActionItem[] {129}): CommandPaletteActionItem[] {
...@@ -140,6 +148,9 @@ export function buildThreadActionItems(input: {...@@ -140,6 +148,9 @@ export function buildThreadActionItems(input: {
140 descriptionParts.push("Current thread");148 descriptionParts.push("Current thread");
141 }149 }
142150
151 const leadingContent = input.renderLeadingContent?.(thread);
152 const trailingContent = input.renderTrailingContent?.(thread);
153
143 return {154 return {
144 kind: "action",155 kind: "action",
145 value: `thread:${thread.id}`,156 value: `thread:${thread.id}`,
...@@ -150,6 +161,8 @@ export function buildThreadActionItems(input: {...@@ -150,6 +161,8 @@ export function buildThreadActionItems(input: {
150 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,161 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
151 ),162 ),
152 icon: input.icon,163 icon: input.icon,
164 ...(leadingContent ? { titleLeadingContent: leadingContent } : {}),
165 ...(trailingContent ? { titleTrailingContent: trailingContent } : {}),
153 run: async () => {166 run: async () => {
154 await input.runThread(thread);167 await input.runThread(thread);
155 },168 },
apps/web/src/components/CommandPalette.tsx+3
...@@ -89,6 +89,7 @@ import {...@@ -89,6 +89,7 @@ import {
89import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";89import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
90import { CommandPaletteResults } from "./CommandPaletteResults";90import { CommandPaletteResults } from "./CommandPaletteResults";
91import { ProjectFavicon } from "./ProjectFavicon";91import { ProjectFavicon } from "./ProjectFavicon";
92import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
92import { useServerKeybindings } from "../rpc/serverState";93import { useServerKeybindings } from "../rpc/serverState";
93import { resolveShortcutCommand } from "../keybindings";94import { resolveShortcutCommand } from "../keybindings";
94import {95import {
...@@ -504,6 +505,8 @@ function OpenCommandPaletteDialog() {...@@ -504,6 +505,8 @@ function OpenCommandPaletteDialog() {
504 projectTitleById,505 projectTitleById,
505 sortOrder: settings.sidebarThreadSortOrder,506 sortOrder: settings.sidebarThreadSortOrder,
506 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,507 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
508 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
509 renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,
507 runThread: async (thread) => {510 runThread: async (thread) => {
508 await navigate({511 await navigate({
509 to: "/$environmentId/$threadId",512 to: "/$environmentId/$threadId",
apps/web/src/components/CommandPaletteResults.tsx+8-2
...@@ -86,14 +86,20 @@ function CommandPaletteResultRow(props: {...@@ -86,14 +86,20 @@ function CommandPaletteResultRow(props: {
86 {props.item.icon}86 {props.item.icon}
87 {props.item.description ? (87 {props.item.description ? (
88 <span className="flex min-w-0 flex-1 flex-col">88 <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>
90 <span className="truncate text-muted-foreground/70 text-xs">94 <span className="truncate text-muted-foreground/70 text-xs">
91 {props.item.description}95 {props.item.description}
92 </span>96 </span>
93 </span>97 </span>
94 ) : (98 ) : (
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}
96 <span className="truncate">{props.item.title}</span>101 <span className="truncate">{props.item.title}</span>
102 {props.item.titleTrailingContent}
97 </span>103 </span>
98 )}104 )}
99 {props.item.timestamp ? (105 {props.item.timestamp ? (
apps/web/src/components/Sidebar.tsx+6-108
...@@ -11,6 +11,12 @@ import {...@@ -11,6 +11,12 @@ import {
11 TerminalIcon,11 TerminalIcon,
12 TriangleAlertIcon,12 TriangleAlertIcon,
13} from "lucide-react";13} from "lucide-react";
14import {
15 prStatusIndicator,
16 resolveThreadPr,
17 terminalStatusFromRunningIds,
18 ThreadStatusLabel,
19} from "./ThreadStatusIndicators";
14import { ProjectFavicon } from "./ProjectFavicon";20import { ProjectFavicon } from "./ProjectFavicon";
15import { autoAnimate } from "@formkit/auto-animate";21import { autoAnimate } from "@formkit/auto-animate";
16import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";22import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";
...@@ -38,7 +44,6 @@ import {...@@ -38,7 +44,6 @@ import {
38 type SidebarProjectGroupingMode,44 type SidebarProjectGroupingMode,
39 type ThreadEnvMode,45 type ThreadEnvMode,
40 ThreadId,46 ThreadId,
41 type GitStatusResult,
42} from "@t3tools/contracts";47} from "@t3tools/contracts";
43import {48import {
44 parseScopedThreadKey,49 parseScopedThreadKey,
...@@ -264,113 +269,6 @@ function buildThreadJumpLabelMap(input: {...@@ -264,113 +269,6 @@ function buildThreadJumpLabelMap(input: {
264 return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;269 return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;
265}270}
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
374interface SidebarThreadRowProps {272interface SidebarThreadRowProps {
375 thread: SidebarThreadSummary;273 thread: SidebarThreadSummary;
376 projectCwd: string | null;274 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}