diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index eeb51a7fb13..d217c24a137 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -14,7 +14,9 @@ export interface CommandPaletteItem { readonly value: string; readonly searchTerms: ReadonlyArray; readonly title: 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 description?: string; readonly timestamp?: string; @@ -72,24 +74,26 @@ 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; - renderLeadingContent?: (thread: Pick) => ReactNode; - renderTrailingContent?: (thread: Pick) => ReactNode; }): CommandPaletteActionItem[] { const sortedThreads = sortThreads( input.threads.filter((thread) => thread.archivedAt === null), @@ -115,26 +119,22 @@ export function buildThreadActionItems(input: { const leadingContent = input.renderLeadingContent?.(thread); const trailingContent = input.renderTrailingContent?.(thread); - return Object.assign( - { - kind: "action" as const, - value: `thread:${thread.id}`, - searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""], - title: thread.title, - description: descriptionParts.join(" · "), - timestamp: formatRelativeTimeLabel( - thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, - ), - icon: input.icon, - }, - leadingContent ? { titleLeadingContent: leadingContent } : {}, - trailingContent ? { titleTrailingContent: trailingContent } : {}, - { - run: async () => { - await input.runThread(thread); - }, + return { + kind: "action", + value: `thread:${thread.id}`, + searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""], + title: thread.title, + description: descriptionParts.join(" · "), + timestamp: formatRelativeTimeLabel( + 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 155414709e0..81727624e30 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -54,6 +54,7 @@ import { } from "./CommandPalette.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { ProjectFavicon } from "./ProjectFavicon"; +import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { useServerKeybindings } from "../rpc/serverState"; import { resolveShortcutCommand } from "../keybindings"; import { @@ -248,6 +249,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 3e59149c327..28ad6d2d3f7 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -79,14 +79,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 5db32903b06..811c4a49ecc 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"; @@ -225,122 +231,6 @@ type SidebarProjectSnapshot = Project & { /** Labels for remote environments this project lives in. */ remoteEnvironmentLabels: readonly string[]; }; -interface TerminalStatusIndicator { - label: "Terminal process running"; - colorClass: string; - pulse: boolean; -} - -interface PrStatusIndicator { - label: "PR open" | "PR closed" | "PR merged" | "MR open" | "MR closed" | "MR 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, - }; -} - -type GitHostProvider = NonNullable; - -interface ThreadPrWithProvider { - pr: ThreadPr; - gitHostProvider: GitHostProvider | undefined; -} - -function prStatusIndicator(input: ThreadPrWithProvider): PrStatusIndicator | null { - const { pr, gitHostProvider } = input; - if (!pr) return null; - - const label = gitHostProvider === "gitlab" ? "MR" : "PR"; - - if (pr.state === "open") { - return { - label: `${label} open`, - colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} ${label} open: ${pr.title}`, - url: pr.url, - }; - } - if (pr.state === "closed") { - return { - label: `${label} closed`, - colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} ${label} closed: ${pr.title}`, - url: pr.url, - }; - } - if (pr.state === "merged") { - return { - label: `${label} merged`, - colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} ${label} 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; diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx new file mode 100644 index 00000000000..96b00e9c8b7 --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -0,0 +1,251 @@ +import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@marcode/client-runtime"; +import type { GitStatusResult } from "@marcode/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" | "MR open" | "MR closed" | "MR merged"; + colorClass: string; + tooltip: string; + url: string; +} + +export interface TerminalStatusIndicator { + label: "Terminal process running"; + colorClass: string; + pulse: boolean; +} + +export type ThreadPr = GitStatusResult["pr"]; + +type GitHostProvider = NonNullable; + +export interface ThreadPrWithProvider { + pr: ThreadPr; + gitHostProvider: GitHostProvider | undefined; +} + +export function prStatusIndicator(input: ThreadPrWithProvider): PrStatusIndicator | null { + const { pr, gitHostProvider } = input; + if (!pr) return null; + + const label = gitHostProvider === "gitlab" ? "MR" : "PR"; + + if (pr.state === "open") { + return { + label: `${label} open`, + colorClass: "text-emerald-600 dark:text-emerald-300/90", + tooltip: `#${pr.number} ${label} open: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "closed") { + return { + label: `${label} closed`, + colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltip: `#${pr.number} ${label} closed: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "merged") { + return { + label: `${label} merged`, + colorClass: "text-violet-600 dark:text-violet-300/90", + tooltip: `#${pr.number} ${label} 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, gitHostProvider: gitStatus.data?.gitHostProvider }); + 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} + + ); +}