diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 3e2f1ec890c..866db58fb47 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -17,6 +17,10 @@ export interface CommandPaletteItem { readonly description?: string; readonly timestamp?: string; readonly icon: ReactNode; + /** Optional content rendered inline before the title text. */ + readonly titleLeadingContent?: ReactNode; + /** Optional content rendered inline after the title text (before the timestamp). */ + readonly titleTrailingContent?: ReactNode; readonly shortcutCommand?: KeybindingCommand; } @@ -102,20 +106,24 @@ export function buildProjectActionItems(input: { })); } -export function buildThreadActionItems(input: { - threads: ReadonlyArray< - Pick< - SidebarThreadSummary, - "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" - > & { - updatedAt?: string | undefined; - latestUserMessageAt?: string | null; - } - >; +export type BuildThreadActionItemsThread = Pick< + SidebarThreadSummary, + "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" +> & { + updatedAt?: string | undefined; + latestUserMessageAt?: string | null; +}; + +export function buildThreadActionItems(input: { + threads: ReadonlyArray; activeThreadId?: Thread["id"]; projectTitleById: ReadonlyMap; sortOrder: SidebarThreadSortOrder; icon: ReactNode; + /** Optional content rendered inline before the title text per-thread. */ + renderLeadingContent?: (thread: TThread) => ReactNode; + /** Optional content rendered inline after the title text per-thread. */ + renderTrailingContent?: (thread: TThread) => ReactNode; runThread: (thread: Pick) => Promise; limit?: number; }): CommandPaletteActionItem[] { @@ -140,6 +148,9 @@ export function buildThreadActionItems(input: { descriptionParts.push("Current thread"); } + const leadingContent = input.renderLeadingContent?.(thread); + const trailingContent = input.renderTrailingContent?.(thread); + return { kind: "action", value: `thread:${thread.id}`, @@ -150,6 +161,8 @@ export function buildThreadActionItems(input: { thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ), icon: input.icon, + ...(leadingContent ? { titleLeadingContent: leadingContent } : {}), + ...(trailingContent ? { titleTrailingContent: trailingContent } : {}), run: async () => { await input.runThread(thread); }, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index fbbeda10139..929a9f87e9c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -89,6 +89,7 @@ import { import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { ProjectFavicon } from "./ProjectFavicon"; +import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { useServerKeybindings } from "../rpc/serverState"; import { resolveShortcutCommand } from "../keybindings"; import { @@ -504,6 +505,8 @@ function OpenCommandPaletteDialog() { projectTitleById, sortOrder: settings.sidebarThreadSortOrder, icon: , + renderLeadingContent: (thread) => , + renderTrailingContent: (thread) => , runThread: async (thread) => { await navigate({ to: "/$environmentId/$threadId", diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index e2841d58805..8cdf0694a08 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -86,14 +86,20 @@ function CommandPaletteResultRow(props: { {props.item.icon} {props.item.description ? ( - {props.item.title} + + {props.item.titleLeadingContent} + {props.item.title} + {props.item.titleTrailingContent} + {props.item.description} ) : ( - + + {props.item.titleLeadingContent} {props.item.title} + {props.item.titleTrailingContent} )} {props.item.timestamp ? ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c3fae158b14..9939833a951 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -11,6 +11,12 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; +import { + prStatusIndicator, + resolveThreadPr, + terminalStatusFromRunningIds, + ThreadStatusLabel, +} from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; @@ -38,7 +44,6 @@ import { type SidebarProjectGroupingMode, type ThreadEnvMode, ThreadId, - type GitStatusResult, } from "@t3tools/contracts"; import { parseScopedThreadKey, @@ -264,113 +269,6 @@ function buildThreadJumpLabelMap(input: { return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } -interface TerminalStatusIndicator { - label: "Terminal process running"; - colorClass: string; - pulse: boolean; -} - -interface PrStatusIndicator { - label: "PR open" | "PR closed" | "PR merged"; - colorClass: string; - tooltip: string; - url: string; -} - -type ThreadPr = GitStatusResult["pr"]; - -function ThreadStatusLabel({ - status, - compact = false, -}: { - status: ThreadStatusPill; - compact?: boolean; -}) { - if (compact) { - return ( - - - {status.label} - - ); - } - - return ( - - - {status.label} - - ); -} - -function terminalStatusFromRunningIds( - runningTerminalIds: string[], -): TerminalStatusIndicator | null { - if (runningTerminalIds.length === 0) { - return null; - } - return { - label: "Terminal process running", - colorClass: "text-teal-600 dark:text-teal-300/90", - pulse: true, - }; -} - -function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { - if (!pr) return null; - - if (pr.state === "open") { - return { - label: "PR open", - colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} PR open: ${pr.title}`, - url: pr.url, - }; - } - if (pr.state === "closed") { - return { - label: "PR closed", - colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} PR closed: ${pr.title}`, - url: pr.url, - }; - } - if (pr.state === "merged") { - return { - label: "PR merged", - colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} PR merged: ${pr.title}`, - url: pr.url, - }; - } - return null; -} - -function resolveThreadPr( - threadBranch: string | null, - gitStatus: GitStatusResult | null, -): ThreadPr | null { - if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { - return null; - } - - return gitStatus.pr ?? null; -} - interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx new file mode 100644 index 00000000000..497e0f88339 --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -0,0 +1,241 @@ +import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import type { GitStatusResult } from "@t3tools/contracts"; +import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; +import { useMemo } from "react"; +import { usePrimaryEnvironmentId } from "../environments/primary"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; +import { useGitStatus } from "../lib/gitStatusState"; +import { type AppState, selectProjectByRef, useStore } from "../store"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { useUiStateStore } from "../uiStateStore"; +import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; +import type { SidebarThreadSummary } from "../types"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export interface PrStatusIndicator { + label: "PR open" | "PR closed" | "PR merged"; + colorClass: string; + tooltip: string; + url: string; +} + +export interface TerminalStatusIndicator { + label: "Terminal process running"; + colorClass: string; + pulse: boolean; +} + +export type ThreadPr = GitStatusResult["pr"]; + +export function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { + if (!pr) return null; + + if (pr.state === "open") { + return { + label: "PR open", + colorClass: "text-emerald-600 dark:text-emerald-300/90", + tooltip: `#${pr.number} PR open: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "closed") { + return { + label: "PR closed", + colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltip: `#${pr.number} PR closed: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "merged") { + return { + label: "PR merged", + colorClass: "text-violet-600 dark:text-violet-300/90", + tooltip: `#${pr.number} PR merged: ${pr.title}`, + url: pr.url, + }; + } + return null; +} + +export function resolveThreadPr( + threadBranch: string | null, + gitStatus: GitStatusResult | null, +): ThreadPr | null { + if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { + return null; + } + + return gitStatus.pr ?? null; +} + +export function terminalStatusFromRunningIds( + runningTerminalIds: string[], +): TerminalStatusIndicator | null { + if (runningTerminalIds.length === 0) { + return null; + } + return { + label: "Terminal process running", + colorClass: "text-teal-600 dark:text-teal-300/90", + pulse: true, + }; +} + +export function ThreadStatusLabel({ + status, + compact = false, +}: { + status: ThreadStatusPill; + compact?: boolean; +}) { + if (compact) { + return ( + + + {status.label} + + ); + } + + return ( + + + {status.label} + + ); +} + +/** + * Non-interactive leading status icons for a thread row in compact contexts + * like the command palette. Shows the PR state icon (if present) and the + * thread status dot, matching the sidebar's leading indicators. + */ +export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const lastVisitedAt = useUiStateStore( + (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], + ); + const threadProjectCwd = useStore( + useMemo( + () => (state: AppState) => + selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? + null, + [thread.environmentId, thread.projectId], + ), + ); + const gitCwd = thread.worktreePath ?? threadProjectCwd; + const gitStatus = useGitStatus({ + environmentId: thread.environmentId, + cwd: thread.branch != null ? gitCwd : null, + }); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr); + const threadStatus = resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt, + }, + }); + + if (!prStatus && !threadStatus) { + return null; + } + + return ( + + {prStatus ? ( + + + } + > + + + {prStatus.tooltip} + + ) : null} + {threadStatus ? : null} + + ); +} + +/** + * Non-interactive trailing status icons for a thread row in compact contexts + * like the command palette. Shows a terminal-running indicator and a remote + * environment indicator, matching the sidebar's trailing indicators. + */ +export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const runningTerminalIds = useTerminalStateStore( + (state) => + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, + ); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const remoteEnvLabel = useSavedEnvironmentRuntimeStore( + (state) => state.byId[thread.environmentId]?.descriptor?.label ?? null, + ); + const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( + (state) => state.byId[thread.environmentId]?.label ?? null, + ); + const threadEnvironmentLabel = isRemoteThread + ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") + : null; + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + + if (!terminalStatus && !isRemoteThread) { + return null; + } + + return ( + + {terminalStatus ? ( + + + + ) : null} + {isRemoteThread ? ( + + + } + > + + + {threadEnvironmentLabel} + + ) : null} + + ); +}