History

Show thread status in command palette (#2107)

Jono Kemball committed 7 months ago

Commit d8d32961 parent 54179c85 files · +281 −120Browse files

Unified · Split

5 files
  1. apps/web/src/components/CommandPalette.logic.ts +23 −10
  2. apps/web/src/components/CommandPalette.tsx +3 −0
  3. apps/web/src/components/CommandPaletteResults.tsx +8 −2
  4. apps/web/src/components/Sidebar.tsx +6 −108
  5. apps/web/src/components/ThreadStatusIndicators.tsx +241 −0
apps/web/src/components/CommandPalette.logic.ts+23−10
@@ -17,6 +17,10 @@ export interface CommandPaletteItem { · 16 unmodified lines
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: { · 79 unmodified lines
102106 }));
103107 }
104108
105−export 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− >;
109+export type BuildThreadActionItemsThread = Pick<
110+ SidebarThreadSummary,
111+ "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title"
112+> & {
113+ updatedAt?: string | undefined;
114+ latestUserMessageAt?: string | null;
115+};
116+
117+export 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: { · 18 unmodified lines
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: { · 4 unmodified lines
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−0
@@ -89,6 +89,7 @@ import { · 88 unmodified lines
8989 import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
9090 import { CommandPaletteResults } from "./CommandPaletteResults";
9191 import { ProjectFavicon } from "./ProjectFavicon";
92+import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
9293 import { useServerKeybindings } from "../rpc/serverState";
9394 import { resolveShortcutCommand } from "../keybindings";
9495 import {
@@ -504,6 +505,8 @@ function OpenCommandPaletteDialog() { · 409 unmodified lines
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: { · 85 unmodified lines
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 { · 10 unmodified lines
1111 TerminalIcon,
1212 TriangleAlertIcon,
1313 } from "lucide-react";
14+import {
15+ prStatusIndicator,
16+ resolveThreadPr,
17+ terminalStatusFromRunningIds,
18+ ThreadStatusLabel,
19+} from "./ThreadStatusIndicators";
1420 import { ProjectFavicon } from "./ProjectFavicon";
1521 import { autoAnimate } from "@formkit/auto-animate";
1622 import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";
@@ -38,7 +44,6 @@ import { · 21 unmodified lines
3844 type SidebarProjectGroupingMode,
3945 type ThreadEnvMode,
4046 ThreadId,
41− type GitStatusResult,
4247 } from "@t3tools/contracts";
4348 import {
4449 parseScopedThreadKey,
@@ -264,113 +269,6 @@ function buildThreadJumpLabelMap(input: { · 219 unmodified lines
264269 return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;
265270 }
266271
267−interface TerminalStatusIndicator {
268− label: "Terminal process running";
269− colorClass: string;
270− pulse: boolean;
271−}
272−
273−interface PrStatusIndicator {
274− label: "PR open" | "PR closed" | "PR merged";
275− colorClass: string;
276− tooltip: string;
277− url: string;
278−}
279−
280−type ThreadPr = GitStatusResult["pr"];
281−
282−function 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−
320−function 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−
333−function 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−
363−function 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−
374272 interface SidebarThreadRowProps {
375273 thread: SidebarThreadSummary;
376274 projectCwd: string | null;
apps/web/src/components/ThreadStatusIndicators.tsx+241−0
@@ -0,0 +1,241 @@
1+import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
2+import type { GitStatusResult } from "@t3tools/contracts";
3+import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
4+import { useMemo } from "react";
5+import { usePrimaryEnvironmentId } from "../environments/primary";
6+import {
7+ useSavedEnvironmentRegistryStore,
8+ useSavedEnvironmentRuntimeStore,
9+} from "../environments/runtime";
10+import { useGitStatus } from "../lib/gitStatusState";
11+import { type AppState, selectProjectByRef, useStore } from "../store";
12+import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
13+import { useUiStateStore } from "../uiStateStore";
14+import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";
15+import type { SidebarThreadSummary } from "../types";
16+import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
17+
18+export interface PrStatusIndicator {
19+ label: "PR open" | "PR closed" | "PR merged";
20+ colorClass: string;
21+ tooltip: string;
22+ url: string;
23+}
24+
25+export interface TerminalStatusIndicator {
26+ label: "Terminal process running";
27+ colorClass: string;
28+ pulse: boolean;
29+}
30+
31+export type ThreadPr = GitStatusResult["pr"];
32+
33+export 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+
63+export 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+
74+export 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+
87+export 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+ */
130+export 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+ */
188+export 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+}