Skip to content

Commit 84b6b29

Browse files
authored
Merge pull request #48 from tyulyukov/marcode/port-thread-status-command-palette
feat(command-palette): display thread status indicators in quick access menu
2 parents adf7da2 + bb67567 commit 84b6b29

5 files changed

Lines changed: 299 additions & 149 deletions

File tree

apps/web/src/components/CommandPalette.logic.ts

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ export interface CommandPaletteItem {
1414
readonly value: string;
1515
readonly searchTerms: ReadonlyArray<string>;
1616
readonly title: ReactNode;
17+
/** Optional content rendered inline before the title text. */
1718
readonly titleLeadingContent?: ReactNode;
19+
/** Optional content rendered inline after the title text (before the timestamp). */
1820
readonly titleTrailingContent?: ReactNode;
1921
readonly description?: string;
2022
readonly timestamp?: string;
@@ -72,24 +74,26 @@ export function buildProjectActionItems(input: {
7274
}));
7375
}
7476

75-
export function buildThreadActionItems(input: {
76-
threads: ReadonlyArray<
77-
Pick<
78-
SidebarThreadSummary,
79-
"archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title"
80-
> & {
81-
updatedAt?: string | undefined;
82-
latestUserMessageAt?: string | null;
83-
}
84-
>;
77+
export type BuildThreadActionItemsThread = Pick<
78+
SidebarThreadSummary,
79+
"archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title"
80+
> & {
81+
updatedAt?: string | undefined;
82+
latestUserMessageAt?: string | null;
83+
};
84+
85+
export function buildThreadActionItems<TThread extends BuildThreadActionItemsThread>(input: {
86+
threads: ReadonlyArray<TThread>;
8587
activeThreadId?: Thread["id"];
8688
projectTitleById: ReadonlyMap<Project["id"], string>;
8789
sortOrder: SidebarThreadSortOrder;
8890
icon: ReactNode;
91+
/** Optional content rendered inline before the title text per-thread. */
92+
renderLeadingContent?: (thread: TThread) => ReactNode;
93+
/** Optional content rendered inline after the title text per-thread. */
94+
renderTrailingContent?: (thread: TThread) => ReactNode;
8995
runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
9096
limit?: number;
91-
renderLeadingContent?: (thread: Pick<SidebarThreadSummary, "id">) => ReactNode;
92-
renderTrailingContent?: (thread: Pick<SidebarThreadSummary, "id">) => ReactNode;
9397
}): CommandPaletteActionItem[] {
9498
const sortedThreads = sortThreads(
9599
input.threads.filter((thread) => thread.archivedAt === null),
@@ -115,26 +119,22 @@ export function buildThreadActionItems(input: {
115119
const leadingContent = input.renderLeadingContent?.(thread);
116120
const trailingContent = input.renderTrailingContent?.(thread);
117121

118-
return Object.assign(
119-
{
120-
kind: "action" as const,
121-
value: `thread:${thread.id}`,
122-
searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""],
123-
title: thread.title,
124-
description: descriptionParts.join(" · "),
125-
timestamp: formatRelativeTimeLabel(
126-
thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
127-
),
128-
icon: input.icon,
129-
},
130-
leadingContent ? { titleLeadingContent: leadingContent } : {},
131-
trailingContent ? { titleTrailingContent: trailingContent } : {},
132-
{
133-
run: async () => {
134-
await input.runThread(thread);
135-
},
122+
return {
123+
kind: "action",
124+
value: `thread:${thread.id}`,
125+
searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""],
126+
title: thread.title,
127+
description: descriptionParts.join(" · "),
128+
timestamp: formatRelativeTimeLabel(
129+
thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
130+
),
131+
icon: input.icon,
132+
...(leadingContent ? { titleLeadingContent: leadingContent } : {}),
133+
...(trailingContent ? { titleTrailingContent: trailingContent } : {}),
134+
run: async () => {
135+
await input.runThread(thread);
136136
},
137-
);
137+
};
138138
});
139139
}
140140

apps/web/src/components/CommandPalette.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
} from "./CommandPalette.logic";
5555
import { CommandPaletteResults } from "./CommandPaletteResults";
5656
import { ProjectFavicon } from "./ProjectFavicon";
57+
import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
5758
import { useServerKeybindings } from "../rpc/serverState";
5859
import { resolveShortcutCommand } from "../keybindings";
5960
import {
@@ -248,6 +249,8 @@ function OpenCommandPaletteDialog() {
248249
projectTitleById,
249250
sortOrder: settings.sidebarThreadSortOrder,
250251
icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
252+
renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
253+
renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,
251254
runThread: async (thread) => {
252255
await navigate({
253256
to: "/$environmentId/$threadId",

apps/web/src/components/CommandPaletteResults.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,20 @@ function CommandPaletteResultRow(props: {
7979
{props.item.icon}
8080
{props.item.description ? (
8181
<span className="flex min-w-0 flex-1 flex-col">
82-
<span className="truncate text-sm text-foreground">{props.item.title}</span>
82+
<span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">
83+
{props.item.titleLeadingContent}
84+
<span className="truncate">{props.item.title}</span>
85+
{props.item.titleTrailingContent}
86+
</span>
8387
<span className="truncate text-muted-foreground/70 text-xs">
8488
{props.item.description}
8589
</span>
8690
</span>
8791
) : (
88-
<span className="flex min-w-0 items-center gap-1.5 truncate text-sm text-foreground">
92+
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">
93+
{props.item.titleLeadingContent}
8994
<span className="truncate">{props.item.title}</span>
95+
{props.item.titleTrailingContent}
9096
</span>
9197
)}
9298
{props.item.timestamp ? (

apps/web/src/components/Sidebar.tsx

Lines changed: 6 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import {
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";
@@ -225,122 +231,6 @@ type SidebarProjectSnapshot = Project & {
225231
/** Labels for remote environments this project lives in. */
226232
remoteEnvironmentLabels: readonly string[];
227233
};
228-
interface TerminalStatusIndicator {
229-
label: "Terminal process running";
230-
colorClass: string;
231-
pulse: boolean;
232-
}
233-
234-
interface PrStatusIndicator {
235-
label: "PR open" | "PR closed" | "PR merged" | "MR open" | "MR closed" | "MR merged";
236-
colorClass: string;
237-
tooltip: string;
238-
url: string;
239-
}
240-
241-
type ThreadPr = GitStatusResult["pr"];
242-
243-
function ThreadStatusLabel({
244-
status,
245-
compact = false,
246-
}: {
247-
status: ThreadStatusPill;
248-
compact?: boolean;
249-
}) {
250-
if (compact) {
251-
return (
252-
<span
253-
title={status.label}
254-
className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`}
255-
>
256-
<span
257-
className={`size-[9px] rounded-full ${status.dotClass} ${
258-
status.pulse ? "animate-pulse" : ""
259-
}`}
260-
/>
261-
<span className="sr-only">{status.label}</span>
262-
</span>
263-
);
264-
}
265-
266-
return (
267-
<span
268-
title={status.label}
269-
className={`inline-flex items-center gap-1 text-[10px] ${status.colorClass}`}
270-
>
271-
<span
272-
className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${
273-
status.pulse ? "animate-pulse" : ""
274-
}`}
275-
/>
276-
<span className="hidden md:inline">{status.label}</span>
277-
</span>
278-
);
279-
}
280-
281-
function terminalStatusFromRunningIds(
282-
runningTerminalIds: string[],
283-
): TerminalStatusIndicator | null {
284-
if (runningTerminalIds.length === 0) {
285-
return null;
286-
}
287-
return {
288-
label: "Terminal process running",
289-
colorClass: "text-teal-600 dark:text-teal-300/90",
290-
pulse: true,
291-
};
292-
}
293-
294-
type GitHostProvider = NonNullable<GitStatusResult["gitHostProvider"]>;
295-
296-
interface ThreadPrWithProvider {
297-
pr: ThreadPr;
298-
gitHostProvider: GitHostProvider | undefined;
299-
}
300-
301-
function prStatusIndicator(input: ThreadPrWithProvider): PrStatusIndicator | null {
302-
const { pr, gitHostProvider } = input;
303-
if (!pr) return null;
304-
305-
const label = gitHostProvider === "gitlab" ? "MR" : "PR";
306-
307-
if (pr.state === "open") {
308-
return {
309-
label: `${label} open`,
310-
colorClass: "text-emerald-600 dark:text-emerald-300/90",
311-
tooltip: `#${pr.number} ${label} open: ${pr.title}`,
312-
url: pr.url,
313-
};
314-
}
315-
if (pr.state === "closed") {
316-
return {
317-
label: `${label} closed`,
318-
colorClass: "text-zinc-500 dark:text-zinc-400/80",
319-
tooltip: `#${pr.number} ${label} closed: ${pr.title}`,
320-
url: pr.url,
321-
};
322-
}
323-
if (pr.state === "merged") {
324-
return {
325-
label: `${label} merged`,
326-
colorClass: "text-violet-600 dark:text-violet-300/90",
327-
tooltip: `#${pr.number} ${label} merged: ${pr.title}`,
328-
url: pr.url,
329-
};
330-
}
331-
return null;
332-
}
333-
334-
function resolveThreadPr(
335-
threadBranch: string | null,
336-
gitStatus: GitStatusResult | null,
337-
): ThreadPr | null {
338-
if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) {
339-
return null;
340-
}
341-
342-
return gitStatus.pr ?? null;
343-
}
344234

345235
interface SidebarThreadRowProps {
346236
thread: SidebarThreadSummary;

0 commit comments

Comments
 (0)