diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 71183661c111..3beabc9c7c38 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; -import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard"; +import { ContextPreviewHover, type ContextPreviewPlacement } from "./contextPresentationRegistry"; import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; import { ThreadTerminalStatusButton } from "./ThreadStatusIndicators"; import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; @@ -17,8 +17,6 @@ import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullR import { useAtomValue } from "@effect/atom-react"; import { assistantCitationsToLabels } from "@t3tools/shared/assistantCitations"; import { composerFileMentionsToLabels } from "@t3tools/shared/composerInlineTokens"; -import * as Option from "effect/Option"; -import { Atom } from "effect/reactivity"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import * as Schema from "effect/Schema"; import { @@ -93,17 +91,14 @@ import { AlarmClockOffIcon, EyeIcon, PencilIcon, - ArrowRightLeftIcon, BotIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon, - CircleAlertIcon, CircleCheckIcon, ClockIcon, Columns2Icon, FileTextIcon, - GitBranchIcon, LayersIcon, PinIcon, PinOffIcon, @@ -201,12 +196,13 @@ import { useServerConfigs, useThreadShells, } from "../state/entities"; -import { environmentPresentations } from "../state/presentation"; +import { + providerEntriesByEnvironmentAtom, + type ProviderEntrySummary, +} from "../state/providerEntries"; import { primaryServerKeybindingsAtom } from "../state/server"; import { environmentProjects } from "../state/projects"; -import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; -import { useEnvironmentQuery } from "../state/query"; import { useThreadSearch } from "../state/queries"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { runtime } from "../lib/runtime"; @@ -246,7 +242,6 @@ import { openGranolaNotePicker } from "./chat/GranolaNotePicker"; import { openSlackMessagePicker } from "./chat/SlackMessagePicker"; import { openDiscordMessagePicker } from "./chat/DiscordMessagePicker"; import { openTranscriptExportDialog } from "./TranscriptExportDialog"; -import { StartedByHoverLine } from "./chat/StartedByChip"; import { animateSidebarLayoutChanges, applySidebarThreadDrop, @@ -315,14 +310,12 @@ import { sortThreadsForSidebar, sortWorkingThreadsBySend, useThreadJumpHintVisibility, - useRetainedValue, useSidebarRowSubscriptionLease, withSidebarTabThreads, type SidebarListItem, type SidebarListMarker, type SidebarSection, } from "./Sidebar.logic"; -import { resolveCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { createSidebarCollisionDetection, createSidebarSortingStrategy, @@ -332,12 +325,10 @@ import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; import { animateSidebarDisclosure, createSidebarListMotion } from "./Sidebar.motion"; import { ThreadPullRequestBadgeControl, - ThreadPullRequestsMiniList, ThreadWorktreeIndicator, prStatusIndicator, resolveThreadPullRequestBadge, terminalStatusFromRunningIds, - type TerminalStatusIndicator, useLinkedThreadPullRequest, ThreadSearchPullRequestNumber, } from "./ThreadStatusIndicators"; @@ -355,11 +346,7 @@ import { useMediaQuery } from "../hooks/useMediaQuery"; import { splitMenuAction, useSplitViewStore } from "../splitViewStore"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; -import { - deriveProviderEntriesByEnvironment, - shouldShowInstanceBadge, - type ProviderInstanceEntry, -} from "../providerInstances"; +import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useStableCallback } from "../hooks/useStableCallback"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -399,73 +386,12 @@ import { type DraftSessionState, } from "../composerDraftStore"; -// The fields thread rows show. Provider status pushes refresh `checkedAt`, -// status and auth on every provider; leaving those out keeps each -// environment's map, and every memoized row, stable across them. -type SidebarProviderEntry = Pick< - ProviderInstanceEntry, - | "instanceId" - | "driverKind" - | "enabled" - | "displayName" - | "accentColor" - | "acpRegistryAgentId" - | "acpRegistryIconUrl" - | "models" ->; - -function sameSidebarProviderEntries( - left: ReadonlyMap, - right: ReadonlyMap, -): boolean { - if (left.size !== right.size) return false; - for (const [instanceId, entry] of right) { - const other = left.get(instanceId); - if ( - other === undefined || - other.driverKind !== entry.driverKind || - other.enabled !== entry.enabled || - other.displayName !== entry.displayName || - other.accentColor !== entry.accentColor || - other.acpRegistryAgentId !== entry.acpRegistryAgentId || - other.acpRegistryIconUrl !== entry.acpRegistryIconUrl || - other.models !== entry.models - ) { - return false; - } - } - return true; -} - -// Threads on non-primary environments (T3 Connect, hosted) resolve their -// provider entry from their own environment's config: default instance ids -// are driver slugs, so a flat map would collide across environments. -const sidebarProviderEntriesAtom = Atom.make((get) => { - const providersByEnvironment = get(environmentPresentations.providersByEnvironmentAtom); - const derived = deriveProviderEntriesByEnvironment( - [...get(environmentPresentations.serverConfigsAtom)].map( - ([environmentId, config]) => - [environmentId, providersByEnvironment.get(environmentId) ?? [], config.settings] as const, - ), - ); - const previous = Option.getOrNull( - get.self>>(), - ); - const next = new Map>(); - let changed = previous === null || previous.size !== derived.size; - for (const [environmentId, entries] of derived) { - const prior = previous?.get(environmentId); - if (prior !== undefined && sameSidebarProviderEntries(prior, entries)) { - next.set(environmentId, prior); - } else { - next.set(environmentId, entries); - changed = true; - } - } - return !changed && previous !== null ? previous : next; -}).pipe(Atom.withLabel("sidebar-provider-entries")); - -const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); +const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); +const SIDEBAR_THREAD_PREVIEW_PLACEMENT: ContextPreviewPlacement = { + side: "right", + align: "start", + sideOffset: 4, +}; // Collapsed shelves share one empty list so a route change alone does not // give the sidebar list a new identity. const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; @@ -572,7 +498,7 @@ function SidebarProviderStack(props: { thread: SidebarThreadSummary; /** An unsent composer pick, which becomes the current provider before the next turn. */ draftModelSelection?: ReturnType; - providerEntryByInstanceId: ReadonlyMap; + providerEntryByInstanceId: ReadonlyMap; }) { const stack = resolveThreadProviderStack( props.draftModelSelection @@ -625,145 +551,6 @@ function SidebarProviderStack(props: { ); } -function SidebarThreadTooltip({ - thread, - project, - projectDisplayName, - environmentLabel, - environmentMachine, - providerEntry, - providerEntryByInstanceId, - showInstanceBadge, - modelInstanceId, - modelLabel, - branchMismatch, - terminalStatus, - terminalProcessCount, -}: { - thread: SidebarThreadSummary; - project: ProjectFaviconProject | null; - projectDisplayName: string | null; - environmentLabel: string | null; - environmentMachine: EnvironmentMachineKind; - providerEntry: SidebarProviderEntry | null; - providerEntryByInstanceId: ReadonlyMap; - showInstanceBadge: boolean; - modelInstanceId: string; - modelLabel: string; - branchMismatch: { - threadBranch: string; - currentBranch: string; - } | null; - terminalStatus: TerminalStatusIndicator | null; - terminalProcessCount: number; -}) { - const driverKind = providerEntry?.driverKind ?? null; - const previousProviderNames = thread.providerInstanceHistory - .filter((instanceId) => instanceId !== modelInstanceId) - .map((instanceId) => providerEntryByInstanceId.get(instanceId)?.displayName ?? instanceId); - const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); - return ( - - 0 ? ( -
- -
- ) : null - } - > - {projectDisplayName ? ( -
- {project ? : null} -
{projectDisplayName}
-
- ) : null} - {environmentLabel ? ( -
- -
{environmentLabel}
-
- ) : null} - {thread.branch ? ( -
- - -
- ) : null} - {branchMismatch ? ( -
- -
- You're currently checked out on another branch. -
-
- ) : null} - {driverKind ? ( -
- -
- {showInstanceBadge && providerEntry - ? `${modelLabel} · ${providerEntry.displayName}` - : modelLabel} -
-
- ) : null} - {previousProviderNames.length > 0 ? ( -
- -
- Handed off from {previousProviderNames.join(", ")} -
-
- ) : null} - {thread.startedBy !== null ? : null} - {terminalStatus ? ( -
- -
- {terminalProcessLabel(terminalProcessCount)} -
-
- ) : null} - {thread.runtime?.lastError ? ( -
- -
- {thread.runtime.lastErrorClass === "usage_limit" - ? "Usage limit reached" - : "Error occurred"} -
-
- ) : null} -
-
- ); -} function SnoozeMenuButton(props: { open: boolean; onOpenChange: (open: boolean) => void; @@ -1360,7 +1147,7 @@ type SidebarThreadRowProps = { scratchMachineLabel: string | null; project: EnvironmentProject | null; projectDisplayName: string | null; - providerEntryByInstanceId: ReadonlyMap; + providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; onThreadClick: ( event: ReactMouseEvent, @@ -1465,8 +1252,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const terminalProcessCount = runningTerminalIds.length; const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isRouteThread; const savedDraftCount = useThreadMessageDrafts(threadRef).length; const handleDiscardDraftClick = (event: ReactMouseEvent) => { @@ -1474,7 +1259,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP event.stopPropagation(); discardComposerDraft(threadRef); }; - const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, thread.linkedPullRequest, @@ -1482,18 +1266,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP thread.pullRequests, thread.branchPullRequest, ); - const gitStatus = useEnvironmentQuery( - leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd, includeRemote: false }, - }) - : null, - ); - const visibleGitStatus = useRetainedValue( - JSON.stringify([thread.environmentId, gitCwd]), - gitStatus.data, - ); const pr = linkedPullRequestStatus?.pr ?? null; const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); const currentLinkedPr = supportsMultiplePullRequests @@ -1522,47 +1294,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ? { ...resolvedTopStatus, label: "Goal" } : resolvedTopStatus; const isWokeStatus = topStatus?.icon === "woke"; - const branchMismatch = resolveCheckoutBranchMismatch({ - effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", - activeWorktreePath: thread.worktreePath, - activeThreadBranch: thread.branch, - currentGitBranch: visibleGitStatus?.refName ?? null, - }); const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); // An unsent composer pick wins, so the icon follows the composer before the next turn. const draftModelSelection = useComposerDraftActiveModelSelection(threadRef); - const modelSelection = draftModelSelection ?? thread.modelSelection; - const modelInstanceId = - draftModelSelection?.instanceId ?? - thread.runtime?.providerInstanceId ?? - thread.modelSelection.instanceId; - const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const showInstanceBadge = - providerEntry !== null && - shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); - const selectedModel = providerEntry?.models.find((model) => model.slug === modelSelection.model); - const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) - : modelSelection.model; const isRemote = thread.environmentId !== props.currentEnvironmentId; const showsScratchMachine = !thread.branch && props.scratchMachineLabel !== null; - const detailsTooltip = ( - - ); const handleClick = (event: ReactMouseEvent) => { // The group header stands for every tab. A tab already open in it stays open. onThreadClick(event, rowThreadRef, true); @@ -2007,174 +1743,184 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP sortable?.isDragging && "relative z-20", )} > - - - } - > - {accessibleTitle} - - {props.project ? : null} - - {draftIndicator} - {title} - {tabCountBadge} - {agentIndicator} - {pinIndicator} - {props.inSplit ? : null} - {terminalStatusIcon} - {savedDraftsIndicator} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {prBadge} - {sortable?.isDragging ? ( - dragDestination - ) : ( + {accessibleTitle} + {props.project ? ( + + ) : null} + + {draftIndicator} + {title} + {tabCountBadge} + {agentIndicator} + {pinIndicator} + {props.inSplit ? : null} + {terminalStatusIcon} + {savedDraftsIndicator} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {prBadge} + {sortable?.isDragging ? ( + dragDestination + ) : ( - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - - {props.snoozeWakeLabelText} - - ) : isWoke ? ( + + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + + {props.snoozeWakeLabelText} + + ) : isWoke ? ( + + + + Woke + + } + /> + Dismiss Woke notification + + ) : ( + + + + )} + + {variantAction === "unhide" ? ( - - Woke - + aria-label="Unhide thread" + onClick={handleUnhideClick} + className={cn( + "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", + isWoke && "group-any-hover/sidebar-row:static", + )} + /> } - /> - Dismiss Woke notification + > + + + Unhide thread - ) : ( - - + + + ) + ) : !canOperateThread || !props.settlementSupported ? null : variantAction === + "unsettle" ? ( + + } - /> - - )} - - {variantAction === "unhide" ? ( - - - } - > - - - Unhide thread - - ) : variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( + > + + + Un-settle thread + + ) : ( - ) - ) : !canOperateThread || !props.settlementSupported ? null : variantAction === - "unsettle" ? ( - - - } - > - - - Un-settle thread - - ) : ( - - )} - - )} - {props.sweepAction !== null ? dragDestination : null} - {props.jumpLabel ? : null} - - {detailsTooltip} - + )} + + )} + {props.sweepAction !== null ? dragDestination : null} + {props.jumpLabel ? : null} + + } + /> {tabList} ); @@ -2192,321 +1938,325 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP sortable?.isDragging && "relative z-20", )} > - - - } - > - {accessibleTitle} - {/* Sized by content (78px with the title) so the title line can + + {accessibleTitle} + {/* Sized by content (78px with the title) so the title line can animate away when the group's tabs open. */} -
-
- {draftIndicator} - {props.project ? ( - - ) : null} - {props.projectDisplayName ? ( - - {props.projectDisplayName} - - ) : ( - - )} - {agentIndicator} - {/* Status at rest, hover actions once they fit. The pin is the - trailing item, outside that swap, so it cannot slide sideways. */} - {sortable?.isDragging ? ( - <> - {dragDestination} - {pinIndicator} - - ) : ( - - - {/* Read-only status labels yield to the hover actions. Woke is - itself an action, so it stays pointer-enabled and visible - while the other controls appear beside it. */} - - {headerStatus ? ( - isWokeStatus ? ( - - - - {headerStatus.label} - - } - /> - Dismiss Woke notification - - ) : ( - - - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {headerStatus.label} - {status === "working" ? ( - - - - ) : status === "waiting" ? ( - - - - ) : null} - - ) - ) : unifyTabs ? null : variantAction === "unsnooze" && - props.snoozeWakeLabelText !== null ? ( - props.snoozeWakeLabelText - ) : variantAction === "unsettle" ? ( - settledTimeLabel(thread) - ) : ( - threadTimeLabel(thread) - )} - - {showHoverActions || onNewTab || showUnpinnedPinAction ? ( - - {hasUnsentDraft ? ( - - - } - > - - - Discard draft - - ) : null} - {onNewTab ? ( - - - } - > - - - New tab - - ) : null} - {showSnoozeButton ? ( - - ) : null} - {canOperateThread && rowAction.supported ? ( - - - } - > - - {rowAction.text} - - {rowAction.label} - - ) : null} - {showUnpinnedPinAction ? ( - - - } - > - - - Pin thread - - ) : null} - - ) : null} - - {pinIndicator} - - )} - {props.sweepAction !== null ? dragDestination : null} -
- {/* An open tab list starts with this thread, so its title moves there. */} - {props.tabs != null ? ( - -
{title}
-
- ) : ( -
{title}
- )} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} -
- {/* Always the branch. The plan step used to take this slot while - working, but it truncated to a half-sentence and dropped the - branch, so the row lost its most stable identifier. */} - {thread.branch ? ( - <> - - - +
+
+ {draftIndicator} + {props.project ? ( + + ) : null} + {props.projectDisplayName ? ( + + {props.projectDisplayName} - - ) : showsScratchMachine ? ( - <> - - - {props.scratchMachineLabel} + ) : ( + + )} + {agentIndicator} + {/* Status at rest, hover actions once they fit. The pin is the + trailing item, outside that swap, so it cannot slide sideways. */} + {sortable?.isDragging ? ( + <> + {dragDestination} + {pinIndicator} + + ) : ( + + + {/* Read-only status labels yield to the hover actions. Woke is + itself an action, so it stays pointer-enabled and visible + while the other controls appear beside it. */} + + {headerStatus ? ( + isWokeStatus ? ( + + + + {headerStatus.label} + + } + /> + Dismiss Woke notification + + ) : ( + + + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {headerStatus.label} + {status === "working" ? ( + + + + ) : status === "waiting" ? ( + + + + ) : null} + + ) + ) : unifyTabs ? null : variantAction === "unsnooze" && + props.snoozeWakeLabelText !== null ? ( + props.snoozeWakeLabelText + ) : variantAction === "unsettle" ? ( + settledTimeLabel(thread) + ) : ( + threadTimeLabel(thread) + )} + + {showHoverActions || onNewTab || showUnpinnedPinAction ? ( + + {hasUnsentDraft ? ( + + + } + > + + + Discard draft + + ) : null} + {onNewTab ? ( + + + } + > + + + New tab + + ) : null} + {showSnoozeButton ? ( + + ) : null} + {canOperateThread && rowAction.supported ? ( + + + } + > + + {rowAction.text} + + {rowAction.label} + + ) : null} + {showUnpinnedPinAction ? ( + + + } + > + + + Pin thread + + ) : null} + + ) : null} + + {pinIndicator} - + )} + {props.sweepAction !== null ? dragDestination : null} +
+ {/* An open tab list starts with this thread, so its title moves there. */} + {props.tabs != null ? ( + +
{title}
+
) : ( - +
{title}
)} - {tabCountBadge} - {props.inSplit && !unifyTabs ? : null} - {unifyTabs ? null : terminalStatusIcon} - {savedDraftsIndicator} - {prBadge} - {diff ? ( - - - +{formatDiffCount(diff.insertions)} - {" "} - - −{formatDiffCount(diff.deletions)} - + {isRegeneratingTitle ? ( + + Regenerating title ) : null} - - {isRemote && !showsScratchMachine ? ( - + {/* Always the branch. The plan step used to take this slot while + working, but it truncated to a half-sentence and dropped the + branch, so the row lost its most stable identifier. */} + {thread.branch ? ( + <> + + + + + + ) : showsScratchMachine ? ( + <> + + {props.scratchMachineLabel} + + + ) : ( + + )} + {tabCountBadge} + {props.inSplit && !unifyTabs ? : null} + {unifyTabs ? null : terminalStatusIcon} + {savedDraftsIndicator} + {prBadge} + {diff ? ( + + + +{formatDiffCount(diff.insertions)} + {" "} + + −{formatDiffCount(diff.deletions)} + ) : null} - {unifyTabs ? null : ( - - )} - + + {isRemote && !showsScratchMachine ? ( + + + + ) : null} + {unifyTabs ? null : ( + + )} + +
+ {unifyTabs || !props.jumpLabel ? null : }
- {unifyTabs || !props.jumpLabel ? null : } -
- {detailsTooltip} -
+ } + /> {tabList} ); @@ -2858,10 +2608,9 @@ function SidebarTabPreviewList(props: { label: string; testId: string; activeKey?: string | null | undefined; - providerEntriesByEnvironment: ReadonlyMap>; + providerEntriesByEnvironment: ReadonlyMap>; tabSortOrder: SidebarTabSortOrder; openedAtByThreadKey: Readonly>; - resolveTabPlace: (thread: SidebarThreadSummary) => SidebarTabPlace; onOpenTab: (threadRef: ScopedThreadRef) => void; }) { return ( @@ -2876,33 +2625,36 @@ function SidebarTabPreviewList(props: { const providerEntries = props.providerEntriesByEnvironment.get(thread.environmentId); return (
  • - - props.onOpenTab(scopeThreadRef(thread.environmentId, thread.id))} - className={cn( - "flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", - active ? "bg-sidebar-row-active" : "hover:bg-sidebar-row-hover", - )} + props.onOpenTab(scopeThreadRef(thread.environmentId, thread.id))} + className={cn( + "flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", + active ? "bg-sidebar-row-active" : "hover:bg-sidebar-row-hover", + )} + > + - } - > - - - - + + } + />
  • ); })} @@ -2916,12 +2668,11 @@ function SidebarTabPreviewList(props: { function SidebarTabGroupPreview(props: { tabs: readonly SidebarThreadSummary[]; activeKey: string | null; - providerEntriesByEnvironment: ReadonlyMap>; + providerEntriesByEnvironment: ReadonlyMap>; tabSortOrder: SidebarTabSortOrder; tabSortDirection: SidebarTabSortDirection; tabManualRanks: Readonly>; openedAtByThreadKey: Readonly>; - resolveTabPlace: (thread: SidebarThreadSummary) => SidebarTabPlace; onOpenTab: (threadRef: ScopedThreadRef) => void; }) { const { openedAtByThreadKey } = props; @@ -2943,7 +2694,6 @@ function SidebarTabGroupPreview(props: { providerEntriesByEnvironment={props.providerEntriesByEnvironment} tabSortOrder={props.tabSortOrder} openedAtByThreadKey={openedAtByThreadKey} - resolveTabPlace={props.resolveTabPlace} onOpenTab={props.onOpenTab} /> ); @@ -2956,10 +2706,9 @@ function SidebarTabGroupPreview(props: { function SidebarTabOverflowRow(props: { hidden: readonly SidebarThreadSummary[]; expanded: boolean; - providerEntriesByEnvironment: ReadonlyMap>; + providerEntriesByEnvironment: ReadonlyMap>; tabSortOrder: SidebarTabSortOrder; openedAtByThreadKey: Readonly>; - resolveTabPlace: (thread: SidebarThreadSummary) => SidebarTabPlace; onToggle: () => void; onOpenTab: (threadRef: ScopedThreadRef) => void; onPreviewOpenChange?: ((open: boolean) => void) | undefined; @@ -3097,7 +2846,6 @@ function SidebarTabOverflowRow(props: { providerEntriesByEnvironment={providerEntriesByEnvironment} tabSortOrder={props.tabSortOrder} openedAtByThreadKey={props.openedAtByThreadKey} - resolveTabPlace={props.resolveTabPlace} onOpenTab={props.onOpenTab} /> @@ -3127,11 +2875,8 @@ const SidebarTabRow = memo(function SidebarTabRow(props: { /** One side of the split view on screen: the routed chat or the one beside it. */ inSplit: boolean; jumpLabel: string | null; - environmentLabel: string | null; - environmentMachine: EnvironmentMachineKind; - project: EnvironmentProject | null; projectDisplayName: string | null; - providerEntryByInstanceId: ReadonlyMap; + providerEntryByInstanceId: ReadonlyMap; isRenaming: boolean; renamingTitle: string; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; @@ -3212,152 +2957,145 @@ const SidebarTabRow = memo(function SidebarTabRow(props: { {...sortable?.listeners} className={cn("list-none", sortable?.isDragging && "relative z-20")} > - - props.onThreadClick(event, threadRef)} - onDoubleClick={(event) => { - if (props.isRenaming || event.metaKey || event.ctrlKey || event.shiftKey) return; - event.preventDefault(); - props.onStartRename(threadRef, thread.title); - }} - onKeyDown={(event) => { - if (event.target !== event.currentTarget) return; - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - props.onThreadActivate(threadRef); - }} - onContextMenu={(event) => { - event.preventDefault(); - props.onContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, - }); - }} - /> - } - > - {props.isRenaming ? ( - - ) : ( - - {thread.title} - - )} - {props.inSplit || isPairTarget ? : null} - {terminalStatus ? ( - - ) : null} - {topStatus ? ( - - - {topStatus.label} - - ) : null} - {/* Time and close share one grid cell so swapping them on hover keeps the row width stable. */} - - - {props.timeLabel ?? threadTimeLabel(thread)} - - - - {driverKind ? ( - - props.onThreadClick(event, threadRef)} + onDoubleClick={(event) => { + if (props.isRenaming || event.metaKey || event.ctrlKey || event.shiftKey) return; + event.preventDefault(); + props.onStartRename(threadRef, thread.title); + }} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + props.onThreadActivate(threadRef); + }} + onContextMenu={(event) => { + event.preventDefault(); + props.onContextMenu(threadRef, { + x: event.clientX, + y: event.clientY, + }); + }} + > + {props.isRenaming ? ( + + ) : ( + + {thread.title} + + )} + {props.inSplit || isPairTarget ? : null} + {terminalStatus ? ( + + ) : null} + {topStatus ? ( + + + {topStatus.label} + + ) : null} + {/* Time and close share one grid cell so swapping them on hover keeps the row width stable. */} + + + {props.timeLabel ?? threadTimeLabel(thread)} + + - ) : null} - {props.jumpLabel ? : null} - - - + {driverKind ? ( + + + + ) : null} + {props.jumpLabel ? : null} + + } + /> ); }); -/** Where a tab lives, for its hover card. */ -interface SidebarTabPlace { - environmentLabel: string | null; - environmentMachine: EnvironmentMachineKind; - project: EnvironmentProject | null; - projectDisplayName: string | null; -} /** The provider and model a tab runs on. An unsent composer pick wins, so the icon follows the composer before the next turn. */ function useSidebarTabProvider( thread: SidebarThreadSummary, threadRef: ScopedThreadRef, - providerEntryByInstanceId: ReadonlyMap, + providerEntryByInstanceId: ReadonlyMap, ) { const draftModelSelection = useComposerDraftActiveModelSelection(threadRef); const modelSelection = draftModelSelection ?? thread.modelSelection; @@ -3378,37 +3116,6 @@ function useSidebarTabProvider( modelLabel: selectedModel ? getTriggerDisplayModelLabel(selectedModel) : modelSelection.model, }; } -/** A tab's hover card. It mounts only while open, so its subscriptions cost nothing at rest. */ -function SidebarTabTooltip(props: { - thread: SidebarThreadSummary; - place: SidebarTabPlace; - providerEntryByInstanceId: ReadonlyMap; -}) { - const { thread, place } = props; - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const provider = useSidebarTabProvider(thread, threadRef, props.providerEntryByInstanceId); - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - return ( - - ); -} function latestRunDiff(thread: SidebarThreadSummary): { insertions: number; deletions: number; @@ -3421,9 +3128,6 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { thread: SidebarThreadSummary; project: EnvironmentProject | null; projectDisplayName: string | null; - environmentLabel: string | null; - environmentMachine: EnvironmentMachineKind; - providerEntryByInstanceId: ReadonlyMap; isHighlighted: boolean; isRouteActive: boolean; resultId: string; @@ -3444,45 +3148,6 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease( props.isHighlighted || props.isRouteActive, ); - const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; - const gitStatus = useEnvironmentQuery( - leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd, includeRemote: false }, - }) - : null, - ); - const visibleGitStatus = useRetainedValue( - JSON.stringify([thread.environmentId, gitCwd]), - gitStatus.data, - ); - const branchMismatch = resolveCheckoutBranchMismatch({ - effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", - activeWorktreePath: thread.worktreePath, - activeThreadBranch: thread.branch, - currentGitBranch: visibleGitStatus?.refName ?? null, - }); - // An unsent composer pick wins, so the icon follows the composer before the next turn. - const draftModelSelection = useComposerDraftActiveModelSelection(threadRef); - const modelSelection = draftModelSelection ?? thread.modelSelection; - const modelInstanceId = - draftModelSelection?.instanceId ?? - thread.runtime?.providerInstanceId ?? - thread.modelSelection.instanceId; - const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const showInstanceBadge = - providerEntry !== null && - shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); - const selectedModel = providerEntry?.models.find((model) => model.slug === modelSelection.model); - const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) - : modelSelection.model; - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const [isFileDragOver, setIsFileDragOver] = useState(false); const onFileDropThreads = props.onFileDropThreads; const fileDropHandlers = makeWorkspaceFileDropHandlers({ @@ -3500,98 +3165,87 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { }, [isFileDragOver]); return (
  • - - - } - > - {props.project ? ( - - ) : null} - - - + {props.project ? ( + - {thread.title} + /> + ) : null} + + + + {thread.title} + + + + + + + - - - - - - + ) : null} - {props.searchMatch ? ( - - ) : null} - - - - + + } + />
  • ); }); @@ -3790,7 +3444,7 @@ export default function Sidebar() { const projectGroups = useSidebarProjectGroups(); const projectGroupsRef = useRef(projectGroups); - const providerEntriesByEnvironment = useAtomValue(sidebarProviderEntriesAtom); + const providerEntriesByEnvironment = useAtomValue(providerEntriesByEnvironmentAtom); const projectByKey = new Map( projects.map((project) => [`${project.environmentId}:${project.id}`, project]), ); @@ -3801,15 +3455,6 @@ export default function Sidebar() { ), ), ); - const resolveTabPlace = (thread: SidebarThreadSummary): SidebarTabPlace => { - const projectKey = `${thread.environmentId}:${thread.projectId}` as const; - return { - environmentLabel: environmentLabelById.get(thread.environmentId) ?? null, - environmentMachine: environmentMachineById.get(thread.environmentId) ?? "server", - project: projectByKey.get(projectKey) ?? null, - projectDisplayName: projectDisplayNameByKey.get(projectKey) ?? null, - }; - }; const nowMinute = useNowMinute(); // Snooze wake times are second-precise, so classifying with the quantized // minute would hold a woken thread on the shelf for up to a minute. @@ -6604,14 +6249,6 @@ export default function Sidebar() { `${thread.environmentId}:${thread.projectId}`, ) ?? null } - environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} - environmentMachine={ - environmentMachineById.get(thread.environmentId) ?? "server" - } - providerEntryByInstanceId={ - providerEntriesByEnvironment.get(thread.environmentId) ?? - EMPTY_PROVIDER_ENTRIES - } isHighlighted={activeSearchResultIndex === index} isRouteActive={highlightedRouteThreadKey === threadKey} resultId={`sidebar-thread-search-result-${index}`} @@ -6850,7 +6487,6 @@ export default function Sidebar() { tabSortDirection={tabSortDirection} tabManualRanks={tabManualRanks} openedAtByThreadKey={openedAtByThreadKey} - resolveTabPlace={resolveTabPlace} onOpenTab={openTab} /> ) : undefined @@ -6903,14 +6539,6 @@ export default function Sidebar() { ? (jumpLabelByKey.get(tabKey) ?? null) : null } - environmentLabel={ - environmentLabelById.get(tab.environmentId) ?? null - } - environmentMachine={ - environmentMachineById.get(tab.environmentId) ?? - "server" - } - project={projectByKey.get(tabProjectKey) ?? null} projectDisplayName={ projectDisplayNameByKey.get(tabProjectKey) ?? null } @@ -6944,7 +6572,6 @@ export default function Sidebar() { providerEntriesByEnvironment={providerEntriesByEnvironment} tabSortOrder={tabSortOrder} openedAtByThreadKey={openedAtByThreadKey} - resolveTabPlace={resolveTabPlace} onToggle={() => toggleTabOverflow(threadKey)} onOpenTab={openTab} onPreviewOpenChange={(open) => diff --git a/apps/web/src/components/ThreadHoverCard.tsx b/apps/web/src/components/ThreadHoverCard.tsx index 21e1bdd588f8..cbfa63e690b3 100644 --- a/apps/web/src/components/ThreadHoverCard.tsx +++ b/apps/web/src/components/ThreadHoverCard.tsx @@ -1,11 +1,48 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import { BotIcon, FolderIcon, GitBranchIcon } from "lucide-react"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + latestThreadPreviewItems, + type ThreadPreviewItem, +} from "@t3tools/client-runtime/work-log/tool-preview"; +import type { ThreadContextRecord, ThreadId } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { + ArrowRightLeftIcon, + BotIcon, + CircleAlertIcon, + GitBranchIcon, + TerminalIcon, +} from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; -import { TooltipPopup } from "./ui/tooltip"; + +import { useComposerDraftActiveModelSelection } from "~/composerDraftStore"; +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { cn } from "~/lib/utils"; +import { shouldShowInstanceBadge } from "~/providerInstances"; +import { useProject, useThreadProjection, useThreadShell } from "~/state/entities"; +import { useEnvironmentIdentities, useEnvironmentMachines } from "~/state/environments"; +import { + providerEntriesByEnvironmentAtom, + type ProviderEntrySummary, +} from "~/state/providerEntries"; +import { useEnvironmentQuery } from "~/state/query"; +import { useThreadRunningTerminalIds } from "~/state/terminalSessions"; +import { vcsEnvironment } from "~/state/vcs"; +import { resolveCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { PreviewCornerAction } from "./chat/PreviewCornerAction"; +import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; +import { StartedByHoverLine } from "./chat/StartedByChip"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { ThreadPullRequestsMiniList, terminalStatusFromRunningIds } from "./ThreadStatusIndicators"; +import { MiddleTruncate } from "./ui/middle-truncate"; +import { TooltipPopup } from "./ui/tooltip"; + +/* + * The one hover preview of a thread. It is the context presentation registry's `thread` body, + * so chips, thread links, sidebar rows and an agent's read of a thread all show this. + */ export function ThreadHoverCardPopup({ className, ...props }: ComponentProps) { return ( @@ -17,64 +54,293 @@ export function ThreadHoverCardPopup({ className, ...props }: ComponentProps; + /** Items the read returned beyond `items`. */ + moreItems: number; +} + +export interface ThreadPreviewProps { + record: Pick; + /** On another machine than the one the message is for, so its agent can't read it. */ + unreadable?: boolean | undefined; + read?: ThreadReadSnapshot | undefined; + /** For surfaces where the trigger doesn't already open the thread. */ + onOpenThread?: ((threadId: ThreadId) => void) | undefined; +} + +/** + * Mounted only while open: metadata draws from the shell the client already holds, and the + * latest messages arrive once the thread's detail loads. + */ +export function ThreadPreview(props: ThreadPreviewProps) { + const { environmentId, threadId } = props.record; + const shell = useThreadShell(scopeThreadRef(environmentId, threadId)); + if (props.unreadable) { + return ( +

    + On another machine, so this machine's agent can't read it. Remove it or switch back to send. +

    + ); + } + if (shell) return ; + if (props.read) return ; + return

    Thread no longer available

    ; +} + +function ThreadPreviewLayout(props: { title: string; + status: string | null; + onOpen: (() => void) | null; children: ReactNode; + items: ReadonlyArray; + moreItems: number; footer?: ReactNode; }) { return ( -
    -
    - {title} +
    +
    + + {props.title} + + {props.status ? ( + + {props.status.replaceAll("_", " ")} + + ) : null} + {props.onOpen ? ( + Open thread + ) : null}
    -
    {children}
    - {footer} +
    {props.children}
    + {props.items.length > 0 ? ( +
    + {props.moreItems > 0 ? ( +

    +{props.moreItems} earlier

    + ) : null} +
    + {props.items.map((item) => ( +
    +
    {item.label}
    +
    + {item.text} +
    +
    + ))} +
    +
    + ) : null} + {props.footer}
    ); } -/** - * Built from the thread shell the client already holds, so hovering never fetches history. - * Orchestration v2 shells omit the latest message, so a missing preview doesn't mean an empty thread. - */ -export function ThreadPreviewHoverCard(props: { - shell: EnvironmentThreadShell; - project: EnvironmentProject | null; - title: string; -}) { - const { shell, project } = props; - const preview = shell.source.latestVisibleMessage?.text.trim(); +function PreviewLine(props: { icon: ReactNode; children: ReactNode; className?: string }) { + return ( +
    + {props.icon} +
    {props.children}
    +
    + ); +} + +const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); + +function LiveThreadPreview(props: ThreadPreviewProps & { shell: EnvironmentThreadShell }) { + const { shell: thread, read, onOpenThread } = props; + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const project = useProject(scopeProjectRef(thread.environmentId, thread.projectId)); + const environmentLabel = + useEnvironmentIdentities().find((env) => env.environmentId === thread.environmentId)?.label ?? + null; + const environmentMachine = useEnvironmentMachines().get(thread.environmentId) ?? "server"; + const providerEntries = + useAtomValue(providerEntriesByEnvironmentAtom).get(thread.environmentId) ?? + EMPTY_PROVIDER_ENTRIES; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + // An unsent composer pick wins, so the preview follows the composer before the next turn. + const draftModelSelection = useComposerDraftActiveModelSelection(threadRef); + const gitCwd = thread.worktreePath ?? project?.workspaceRoot ?? null; + const gitStatus = useEnvironmentQuery( + thread.branch !== null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd, includeRemote: false }, + }) + : null, + ); + // A read already carries the messages it saw; only a live preview loads the thread. + const detail = useThreadProjection(read ? null : threadRef); + + const modelSelection = draftModelSelection ?? thread.modelSelection; + const modelInstanceId = + draftModelSelection?.instanceId ?? + thread.runtime?.providerInstanceId ?? + thread.modelSelection.instanceId; + const providerEntry = providerEntries.get(modelInstanceId) ?? null; + const showInstanceBadge = + providerEntry !== null && shouldShowInstanceBadge(providerEntry, providerEntries.values()); + const selectedModel = providerEntry?.models.find((model) => model.slug === modelSelection.model); + const modelLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : modelSelection.model; + const previousProviderNames = thread.providerInstanceHistory + .filter((instanceId) => instanceId !== modelInstanceId) + .map((instanceId) => providerEntries.get(instanceId)?.displayName ?? instanceId); + const branchMismatch = resolveCheckoutBranchMismatch({ + effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", + activeWorktreePath: thread.worktreePath, + activeThreadBranch: thread.branch, + currentGitBranch: gitStatus.data?.refName ?? null, + }); + const items = read?.items ?? latestThreadPreviewItems(detail?.projection.messages ?? []); + return ( - + onOpenThread(thread.id) : null} + items={items} + moreItems={read?.moreItems ?? 0} + footer={ + supportsMultiplePullRequests && thread.pullRequests.length > 0 ? ( +
    + +
    + ) : null + } + > {project ? ( + }> + {project.title} + + ) : null} + {environmentLabel ? ( + + } + > + {environmentLabel} + + ) : null} + {thread.branch ? (
    - - {project.title} + +
    ) : null} -
    - - {shell.modelSelection.model} - - {shell.source.status.replaceAll("_", " ")} - -
    - {shell.branch ? ( -
    - - {shell.branch} + {branchMismatch ? ( +
    + +
    + You're currently checked out on another branch. +
    +
    + ) : null} + + ) : ( + + ) + } + > + {showInstanceBadge && providerEntry + ? `${modelLabel} · ${providerEntry.displayName}` + : modelLabel} + + {previousProviderNames.length > 0 ? ( + } + > + Handed off from {previousProviderNames.join(", ")} + + ) : null} + {thread.startedBy !== null ? : null} + {terminalStatus ? ( + + } + > + {runningTerminalIds.length} terminal{" "} + {runningTerminalIds.length === 1 ? "process" : "processes"} running + + ) : null} + {thread.runtime?.lastError ? ( +
    + +
    + {thread.runtime.lastErrorClass === "usage_limit" + ? "Usage limit reached" + : "Error occurred"} +
    ) : null} - {preview ? ( -

    - {preview.slice(0, 600)} - {preview.length > 600 ? "…" : ""} -

    + + ); +} + +/** A thread this client doesn't hold, such as one on another machine: what the read saw. */ +function ReadThreadPreview(props: ThreadPreviewProps & { read: ThreadReadSnapshot }) { + const { read, onOpenThread } = props; + return ( + onOpenThread(props.record.threadId) : null} + items={read.items} + moreItems={read.moreItems} + > + {read.model ? ( + }> + {read.model} + + ) : null} + {read.branch ? ( + }> + {read.branch} + ) : null} - + ); } diff --git a/apps/web/src/components/chat/AgentFleetRow.tsx b/apps/web/src/components/chat/AgentFleetRow.tsx index 34a542ac09be..615ddc33fded 100644 --- a/apps/web/src/components/chat/AgentFleetRow.tsx +++ b/apps/web/src/components/chat/AgentFleetRow.tsx @@ -12,7 +12,7 @@ * - The Agents panel's details toggle hides every row's second line (`agentListViewStore.ts`). * - Only a working row on screen (or an open preview) subscribes to its child thread. */ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { subagentFromRecord, subagentIdentityParts, @@ -37,7 +37,8 @@ import type { ScopedThreadRef, TurnItemId, } from "@t3tools/contracts"; -import { CheckIcon, CornerDownRightIcon, XIcon } from "lucide-react"; +import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display"; +import { CheckIcon, CornerDownRightIcon, FolderIcon, GitBranchIcon, XIcon } from "lucide-react"; import { useRef, type MouseEvent, type ReactNode } from "react"; import { useAgentListViewStore } from "~/agentListViewStore"; @@ -49,17 +50,19 @@ import { shouldShowInstanceBadge, type ProviderInstanceEntry, } from "~/providerInstances"; -import { useThreadProjection, useThreadShells } from "~/state/entities"; +import { useProject, useThreadProjection, useThreadShell, useThreadShells } from "~/state/entities"; import { useEnvironmentProviders } from "~/state/environments"; import { formatSecondsTimestamp } from "~/timestampFormat"; import { AgentElapsed, STATUS_VISUALS, StatusDot } from "../AgentStatus"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card"; import { AgentUsageFooter, ToolCallList } from "./AgentActivityParts"; import { AgentHandle } from "./AgentHandle"; import { showAgentInPanel } from "./agentContextMenu"; import { CursorPreviewCard } from "./CursorPreviewCard"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; +import { SubagentModelLabel } from "./SubagentModelLabel"; import { SubagentActivityLine } from "./SubagentActivityLine"; export type ProviderEntries = ReadonlyMap; @@ -154,11 +157,23 @@ export function AgentPreviewContent(props: { const totalCalls = Math.max(calls.length, agent.usage?.toolUses ?? 0); const outcome = agent.error ?? (live ? null : agent.result); const prompt = record?.prompt.trim() || null; - const identity = [ - STATUS_VISUALS[agent.status].label, - ...(props.provider ? [props.provider.displayName] : []), - ...subagentIdentityParts(agent, runStats.runs), - ]; + const ownerShell = useThreadShell( + scopeThreadRef(props.parentRef.environmentId, entry.ownerThreadId), + )?.source; + const ownerProject = useProject( + ownerShell ? scopeProjectRef(props.parentRef.environmentId, ownerShell.projectId) : null, + ); + const agentProject = useProject( + entry.shell ? scopeProjectRef(props.parentRef.environmentId, entry.shell.projectId) : null, + ); + // Workspace lines appear only where the agent works somewhere its owner doesn't. + const { workspace } = resolveSubagentMetadata({ + model: agent.model, + parentThread: ownerShell, + childThread: entry.shell, + parentProject: ownerProject, + childProject: agentProject, + }); return (
    props.onOpen()}>
    @@ -169,14 +184,41 @@ export function AgentPreviewContent(props: {
    - + {entry.handle ? : null}
    -

    - {identity.join(" · ")} +

    + + {[STATUS_VISUALS[agent.status].label, props.provider?.displayName] + .filter(Boolean) + .join(" · ")}{" "} + · + + + {runStats.runs > 1 ? · run {runStats.runs} : null}

    + {workspace.map(({ label, value }) => { + const Icon = label === "Branch" ? GitBranchIcon : FolderIcon; + return ( +
    + + {label} + +
    + ); + })}
    {live && agent.progress ? ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index db26e12b759d..b2d2667c8714 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2272,10 +2272,11 @@ function MessageAuthorHeading({ children }: { children: string }) { function UserMessageRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - return ctx.workflowOpening?.messageId === row.message.id ? ( + return ctx.workflowOpening?.messageId === row.message.id && ctx.threadRef !== null ? ( ctx.onOpenThread(ThreadId.make(threadId))} /> ) : ( diff --git a/apps/web/src/components/chat/SubagentModelLabel.tsx b/apps/web/src/components/chat/SubagentModelLabel.tsx new file mode 100644 index 000000000000..470fe21b3b99 --- /dev/null +++ b/apps/web/src/components/chat/SubagentModelLabel.tsx @@ -0,0 +1,91 @@ +import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display"; +import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution"; +import type { + OrchestrationV2Subagent, + OrchestrationV2ThreadShell, + ProviderInstanceId, + ServerProvider, +} from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue, resolveSelectableModel } from "@t3tools/shared/model"; + +import { getTraitsSpeedDisplay, TraitsSpeedIcon } from "./TraitsSpeed"; + +/** + * An agent's model as the catalog names it, with the effort and speed it was started with. + * Those come from the agent's own selection, or its thread's for an app-owned agent, and only + * when that selection is for the model it reports running. + */ +export function SubagentModelLabel(props: { + model: string | null; + providerInstanceId: ProviderInstanceId; + origin: OrchestrationV2Subagent["origin"] | null; + modelSelection: OrchestrationV2Subagent["modelSelection"] | undefined; + childThread: Pick | null; + provider: ServerProvider | undefined; +}) { + const { provider } = props; + const { modelLabel } = resolveSubagentMetadata({ model: props.model, provider }); + const model = props.model?.trim(); + const modelSlug = provider + ? resolveSelectableModel(provider.driver, model, provider.models) + : model; + const providerModel = provider?.models.find((candidate) => candidate.slug === modelSlug); + const childSelection = + props.modelSelection ?? + (props.origin === "app_owned" ? props.childThread?.modelSelection : undefined); + const childModel = provider + ? (resolveSelectableModel(provider.driver, childSelection?.model, provider.models) ?? + childSelection?.model.trim()) + : childSelection?.model.trim(); + const matchingSelection = + childModel === (modelSlug ?? model) && childSelection?.instanceId === props.providerInstanceId + ? childSelection + : undefined; + const effort = REASONING_EFFORT_OPTION_IDS.map((id) => + getModelSelectionStringOptionValue(matchingSelection, id), + ).find(Boolean); + const speed = provider + ? providerModel?.capabilities?.optionDescriptors + ?.map((descriptor) => { + const saved = matchingSelection?.options?.find((option) => option.id === descriptor.id); + if ( + descriptor.id === "fastMode" && + descriptor.type === "boolean" && + typeof saved?.value === "boolean" + ) { + return getTraitsSpeedDisplay(provider.driver, { + ...descriptor, + currentValue: saved.value, + }); + } + if ( + provider.driver === "codex" && + descriptor.id === "serviceTier" && + descriptor.type === "select" && + typeof saved?.value === "string" && + descriptor.options.some((option) => option.id === saved.value) + ) { + return getTraitsSpeedDisplay(provider.driver, { + ...descriptor, + currentValue: saved.value, + }); + } + return null; + }) + .find(Boolean) + : undefined; + return ( + + {modelLabel} + {effort || speed?.speedIcon ? ( + + {effort ? " · " : null} + {speed?.speedIcon && provider ? ( + + ) : null} + {effort} + + ) : null} + + ); +} diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx deleted file mode 100644 index 62d9aaa5fbdc..000000000000 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import type { - OrchestrationV2SubagentUsage, - OrchestrationV2ThreadShell, - OrchestrationV2TurnItemStatus, - OrchestrationProjectShell, - ServerProvider, - ProviderDriverKind, - ProviderInstanceId, - OrchestrationV2Subagent, -} from "@t3tools/contracts"; -import { - resolveSubagentMetadata, - subagentDetailPreview, -} from "@t3tools/client-runtime/state/subagent-display"; -import { formatSubagentTokenCount } from "@t3tools/client-runtime/state/subagentRuntime"; -import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution"; -import { getModelSelectionStringOptionValue, resolveSelectableModel } from "@t3tools/shared/model"; -import { AgentHandle } from "./AgentHandle"; -import { getTraitsSpeedDisplay, TraitsSpeedIcon } from "./TraitsSpeed"; -import type { ReactNode } from "react"; -import { - BotIcon, - CheckIcon, - CircleDashedIcon, - CircleXIcon, - FolderIcon, - GitBranchIcon, - SigmaIcon, - TerminalIcon, -} from "lucide-react"; -import { ThreadHoverCard } from "../ThreadHoverCard"; -import { MiddleTruncate } from "../ui/middle-truncate"; -import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; -import { cn } from "~/lib/utils"; -import { deriveProviderInstanceEntries, shouldShowInstanceBadge } from "../../providerInstances"; - -/** Geometry and preview limits stay identical in lineage and timeline tooltips. */ -export function SubagentTooltipContent(props: { - title: string; - model: string | null; - providerInstanceId: ProviderInstanceId; - origin: OrchestrationV2Subagent["origin"]; - modelSelection?: OrchestrationV2Subagent["modelSelection"]; - provider?: ServerProvider | undefined; - providers?: ReadonlyArray | undefined; - driver?: ProviderDriverKind | undefined; - elapsed?: ReactNode; - parentThread?: Pick | undefined; - childThread?: - | Pick - | undefined; - parentProject?: Pick | undefined; - childProject?: Pick | undefined; - status: OrchestrationV2TurnItemStatus; - result?: string | null | undefined; - progress?: string | null | undefined; - /** Fork: the subagent's reported usage, where the provider reports it. */ - usage?: OrchestrationV2SubagentUsage | null | undefined; - /** Fork: the `@handle` it is referenced by in chat. */ - handle?: string | null | undefined; -}) { - const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props); - const preview = subagentDetailPreview(props); - const model = props.model?.trim(); - const modelSlug = props.provider - ? resolveSelectableModel(props.provider.driver, model, props.provider.models) - : model; - const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug); - const childSelection = - props.modelSelection ?? - (props.origin === "app_owned" ? props.childThread?.modelSelection : undefined); - const childModel = props.provider - ? (resolveSelectableModel( - props.provider.driver, - childSelection?.model, - props.provider.models, - ) ?? childSelection?.model.trim()) - : childSelection?.model.trim(); - const provider = props.provider; - const matchingSelection = - childModel === (modelSlug ?? model) && childSelection?.instanceId === props.providerInstanceId - ? childSelection - : undefined; - const effort = REASONING_EFFORT_OPTION_IDS.map((id) => - getModelSelectionStringOptionValue(matchingSelection, id), - ).find(Boolean); - const speed = provider - ? providerModel?.capabilities?.optionDescriptors - ?.map((descriptor) => { - const saved = matchingSelection?.options?.find((option) => option.id === descriptor.id); - if ( - descriptor.id === "fastMode" && - descriptor.type === "boolean" && - typeof saved?.value === "boolean" - ) { - return getTraitsSpeedDisplay(provider.driver, { - ...descriptor, - currentValue: saved.value, - }); - } - if ( - provider.driver === "codex" && - descriptor.id === "serviceTier" && - descriptor.type === "select" && - typeof saved?.value === "string" && - descriptor.options.some((option) => option.id === saved.value) - ) { - return getTraitsSpeedDisplay(provider.driver, { - ...descriptor, - currentValue: saved.value, - }); - } - return null; - }) - .find(Boolean) - : undefined; - const driver = props.provider?.driver ?? props.driver; - const entries = deriveProviderInstanceEntries(props.providers ?? []); - const entry = entries.find((candidate) => candidate.instanceId === props.provider?.instanceId); - const showInstanceBadge = entry !== undefined && shouldShowInstanceBadge(entry, entries); - const working = ["running", "in_progress", "pending", "waiting"].includes(props.status); - const failed = ["failed", "error"].includes(props.status); - const StatusIcon = working - ? CircleDashedIcon - : failed - ? CircleXIcon - : props.status === "completed" - ? CheckIcon - : CircleDashedIcon; - return ( - - {props.handle ? ( -
    - -
    - ) : null} -
    - {driver ? ( - - ) : ( - - )} - - - {showInstanceBadge ? `${modelLabel} · ${entry.displayName}` : modelLabel} - - {effort || speed?.speedIcon ? ( - - {effort ? " · " : null} - {speed?.speedIcon && provider ? ( - - ) : null} - {effort} - - ) : null} - -
    -
    - - - {props.status.replaceAll("_", " ")} - - {props.elapsed} -
    - {metadata.map(({ label, value }) => { - const Icon = label === "Branch" ? GitBranchIcon : FolderIcon; - return ( -
    - - {label} - -
    - ); - })} - {props.usage ? ( -
    - - - {[ - `${formatSubagentTokenCount(props.usage.totalTokens)} tokens`, - props.usage.toolUses !== undefined - ? `${props.usage.toolUses} tool ${props.usage.toolUses === 1 ? "call" : "calls"}` - : null, - ] - .filter(Boolean) - .join(" · ")} - -
    - ) : null} - {preview ? ( -
    - - -
    - ) : null} -
    - ); -} diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx index 2e00e47ee7da..17e879435426 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -2,13 +2,18 @@ import type { SubagentWorkflowGroup, RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; -import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; +import { + ThreadId, + type EnvironmentId, + type ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; import { ChevronDownIcon, LoaderCircleIcon, SquareIcon } from "lucide-react"; import { useState, type ReactNode } from "react"; import { AgentElapsed } from "./AgentElapsed"; -import { SubagentTooltipContent } from "./SubagentTooltipContent"; -import { ThreadHoverCardPopup } from "../ThreadHoverCard"; +import { ContextPreviewHover } from "../contextPresentationRegistry"; import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon"; import { cn } from "../../lib/utils"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; @@ -48,17 +53,19 @@ function phaseStatusLabel(phase: SubagentWorkflowGroup["phases"][number]) { function WorkflowMemberRow({ member, + environmentId, + ownerThreadId, providerInstanceId, provider, - providers, driver, onOpen, isThreadAvailable, }: { member: RuntimeSubagent; + environmentId: EnvironmentId; + ownerThreadId: ThreadId; providerInstanceId: ProviderInstanceId; provider: ServerProvider | undefined; - providers: ReadonlyArray | undefined; driver: ProviderDriverKind | undefined; onOpen: (threadId: string) => void; isThreadAvailable: (threadId: string) => boolean; @@ -67,43 +74,40 @@ function WorkflowMemberRow({ const canOpen = threadId !== null && isThreadAvailable(threadId); return (
  • - - canOpen && onOpen(threadId)} - /> - } - > - - {member.title} - {member.status} - {member.startedAt ? ( - - - - ) : null} - - - } - status={member.status} - result={member.result ?? member.error} - progress={member.progress} - /> - - + canOpen && onOpen(threadId)} + > + + {member.title} + {member.status} + {member.startedAt ? ( + + + + ) : null} + + } + />
  • ); } @@ -111,9 +115,10 @@ function WorkflowMemberRow({ export function ThreadLineageWorkflowRow({ group, header, + environmentId, + ownerThreadId, providerInstanceId, provider, - providers, driver, onOpenThread, isThreadAvailable, @@ -123,9 +128,11 @@ export function ThreadLineageWorkflowRow({ }: { readonly group: SubagentWorkflowGroup; readonly header: ReactNode; + readonly environmentId: EnvironmentId; + /** The thread whose workflow this is, which owns its member agents. */ + readonly ownerThreadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; readonly provider: ServerProvider | undefined; - readonly providers: ReadonlyArray | undefined; readonly driver: ProviderDriverKind | undefined; readonly onOpenThread: (threadId: string) => void; readonly isThreadAvailable: (threadId: string) => boolean; @@ -221,9 +228,10 @@ export function ThreadLineageWorkflowRow({ { }); // Tooltip and hover card content renders inline so the suite can read every row's agent // details across many states without hovering each row. +// Hover previews render in place, so a test reads them without hovering. vi.mock(import("../ui/tooltip"), async () => { const { cloneElement } = await import("react"); return { + TooltipProvider: (({ children }: { children: ReactNode }) => children) as never, Tooltip: (({ children }: { children: ReactNode }) => children) as never, - TooltipTrigger: (({ render, children }: { render: ReactElement; children: ReactNode }) => - cloneElement(render, {}, children)) as never, + TooltipTrigger: (({ render, children }: { render: ReactElement; children?: ReactNode }) => + children === undefined ? render : cloneElement(render, {}, children)) as never, TooltipPopup: (({ children }: { children: ReactNode }) => state.showTooltips ?
    {children}
    : null) as never, }; }); +vi.mock(import("../ui/preview-card"), async (original) => ({ + ...(await original()), + PreviewCard: (({ children }: { children: ReactNode }) => children) as never, + PreviewCardTrigger: (({ render }: { render: ReactElement }) => render) as never, + PreviewCardPopup: (({ children }: { children: ReactNode }) => + state.showTooltips ?
    {children}
    : null) as never, +})); import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.logic.test.ts similarity index 97% rename from apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx rename to apps/web/src/components/chat/ThreadRelationshipsControl.logic.test.ts index 05a97c9e81a7..3eb6baf25b57 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.logic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveThreadLineageWindow } from "./ThreadRelationshipsControl"; +import { resolveThreadLineageWindow } from "./ThreadRelationshipsControl.logic"; const rows = Array.from({ length: 20 }, (_, index) => `row-${index}`); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.logic.ts b/apps/web/src/components/chat/ThreadRelationshipsControl.logic.ts new file mode 100644 index 000000000000..519520062d33 --- /dev/null +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.logic.ts @@ -0,0 +1,7 @@ +export function resolveThreadLineageWindow( + rows: ReadonlyArray, + visibleCount: number, +): { readonly visibleRows: ReadonlyArray; readonly hiddenCount: number } { + const visibleRows = rows.slice(0, visibleCount); + return { visibleRows, hiddenCount: rows.length - visibleRows.length }; +} diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index bbde9e320e5f..672f9fe7d236 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -1,9 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; -import { ThreadHoverCardPopup } from "../ThreadHoverCard"; +import { ContextPreviewHover, type ContextPreviewPlacement } from "../contextPresentationRegistry"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; -import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { @@ -61,7 +60,13 @@ import { SquareIcon, UnplugIcon, } from "lucide-react"; -import { useMemo, useState, type MouseEvent, type ReactNode } from "react"; +import { + useState, + type ComponentProps, + type MouseEvent, + type ReactElement, + type ReactNode, +} from "react"; import { useAgentListViewStore } from "../../agentListViewStore"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; @@ -94,17 +99,12 @@ import { THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./threadDetailsPanelStyles"; +import { resolveThreadLineageWindow } from "./ThreadRelationshipsControl.logic"; + +const RELATIONSHIP_PREVIEW_PLACEMENT: ContextPreviewPlacement = { side: "left" }; const THREAD_LINEAGE_INITIAL_COUNT = 6; const THREAD_LINEAGE_PAGE_COUNT = 12; -export function resolveThreadLineageWindow( - rows: ReadonlyArray, - visibleCount: number, -): { readonly visibleRows: ReadonlyArray; readonly hiddenCount: number } { - const visibleRows = rows.slice(0, visibleCount); - return { visibleRows, hiddenCount: rows.length - visibleRows.length }; -} - function ThreadLineageRowList(props: { readonly hiddenCount: number; readonly onShowMore: () => void; @@ -598,35 +598,6 @@ export function ThreadRelationshipsPanel(props: { const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop) ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" : ""; - const project = projects.find((project) => project.id === node?.thread?.projectId); - const relationshipHint = node?.missing - ? "This related thread is unavailable" - : `Open ${relationship.toLowerCase()} in this chat`; - const RelationshipPopup = agent ? ThreadHoverCardPopup : TooltipPopup; - const relationshipTooltip = agent ? ( - } - status={agent.status} - result={agent.result} - progress={agent.progress} - usage={agent.usage} - handle={agentHandles.get(threadId)} - parentThread={currentThread ?? undefined} - childThread={node?.thread ?? undefined} - parentProject={currentProject} - childProject={project} - /> - ) : ( - relationshipHint - ); // Fork: working agents add their latest tool call, failed agents their error. const agentLive = agent !== undefined && isActiveSubagentStatus(agent.status); // One truncated line; the full error stays in the hover card and the agent tab. @@ -697,35 +668,58 @@ export function ThreadRelationshipsPanel(props: { ) : null} ); - const relationshipLink = ( - - openThread(threadId)} - onContextMenu={onAgentContextMenu} - multiline={secondLine !== null} - /> - } - > - {relationshipContent} - - {relationshipTooltip} - + const relationshipPreview = (trigger: ReactElement>) => + agent ? ( + + ) : ( + + ); + const relationshipLink = relationshipPreview( + openThread(threadId)} + onContextMenu={onAgentContextMenu} + multiline={secondLine !== null} + > + {relationshipContent} + , ); if (workflowGroup && agent) { return ( openThread(memberThreadId as ThreadId)} isThreadAvailable={(memberThreadId) => @@ -745,24 +739,18 @@ export function ThreadRelationshipsPanel(props: { > {isMergeTarget ? (
    - - openThread(threadId)} - /> - } + {relationshipPreview( + openThread(threadId)} > {relationshipContent} - - {relationshipTooltip} - + , + )}
    [0] & { - model: string | null; - elapsed: ReactNode; - usage: OrchestrationV2SubagentUsage | null; - modelSelection: OrchestrationV2Subagent["modelSelection"]; - handle: string | null; - }, -) { - const environmentId = props.parentRef.environmentId; - // The popup only mounts while open, so the full provider list is read here - // instead of in every timeline row. - const providers = - useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; - const parent = useThreadShell(props.parentRef)?.source; - const child = useThreadShell( - props.threadId ? scopeThreadRef(environmentId, props.threadId) : null, - )?.source; - const parentProject = useProject( - parent ? scopeProjectRef(environmentId, parent.projectId) : null, - ); - const childProject = useProject(child ? scopeProjectRef(environmentId, child.projectId) : null); - return ( - provider.instanceId === props.providerInstanceId)} - providers={providers} - driver={props.driver} - elapsed={props.elapsed} - status={props.status} - result={props.result} - progress={props.progress} - usage={props.usage} - parentThread={parent} - childThread={child} - parentProject={parentProject ?? undefined} - childProject={childProject ?? undefined} - handle={props.handle} - /> - ); -} - function ModelChangeRow(props: { readonly item: Extract; readonly environmentId: EnvironmentId; diff --git a/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx b/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx index 8f28e93b0081..c98cbb7a2d95 100644 --- a/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx +++ b/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx @@ -1,5 +1,5 @@ import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; @@ -90,6 +90,7 @@ async function render(script: string, query = "") { undefined} />
    diff --git a/apps/web/src/components/chat/WorkflowOpeningBlock.tsx b/apps/web/src/components/chat/WorkflowOpeningBlock.tsx index 1bcd32afabe4..9e363e17dad4 100644 --- a/apps/web/src/components/chat/WorkflowOpeningBlock.tsx +++ b/apps/web/src/components/chat/WorkflowOpeningBlock.tsx @@ -1,29 +1,25 @@ -import { useAtomValue } from "@effect/atom-react"; import { isTerminalSubagentStatus, workflowOpeningHeadline, type RuntimeSubagent, type WorkflowOpening, } from "@t3tools/client-runtime/state/subagentRuntime"; -import type { - EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, - ServerProvider, +import { + ThreadId, + type EnvironmentId, + type ProviderDriverKind, + type ProviderInstanceId, } from "@t3tools/contracts"; import { ChevronRightIcon, WorkflowIcon } from "lucide-react"; import { useState } from "react"; import { cn } from "../../lib/utils"; -import { providersIgnoringCheckedAtAtom } from "../../state/server"; import ChatMarkdown from "../ChatMarkdown"; -import { ThreadHoverCardPopup } from "../ThreadHoverCard"; +import { ContextPreviewHover } from "../contextPresentationRegistry"; import { Button } from "../ui/button"; -import { Tooltip, TooltipTrigger } from "../ui/tooltip"; import { AgentElapsed } from "./AgentElapsed"; import { findRevealRef } from "./markdownFindContext"; import { shouldCollapseUserMessage } from "./MessagesTimeline.logic"; -import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { EnvironmentSubagentAvatar } from "./V2LifecycleRow"; const FOLDED_SCRIPT_MASK = "linear-gradient(to bottom, black calc(100% - 1.75rem), transparent)"; @@ -39,10 +35,11 @@ function scriptMarkdown(script: string) { export function WorkflowOpeningBlock(props: { readonly opening: WorkflowOpening; readonly environmentId: EnvironmentId; + /** The thread that runs the workflow, which owns its member agents. */ + readonly ownerThreadId: ThreadId; readonly onOpenThread: (threadId: string) => void; }) { const { opening } = props; - const providers = useAtomValue(providersIgnoringCheckedAtAtom(props.environmentId)); const headline = workflowOpeningHeadline(opening); const phases = opening.group?.phases.filter((phase) => phase.members.length > 0) ?? []; const memberRow = (member: RuntimeSubagent) => @@ -51,8 +48,8 @@ export function WorkflowOpeningBlock(props: { key={member.id} member={member} environmentId={props.environmentId} + ownerThreadId={props.ownerThreadId} providerInstanceId={opening.providerInstanceId} - providers={providers} driver={opening.driver ?? undefined} onOpenThread={props.onOpenThread} /> @@ -107,8 +104,8 @@ export function WorkflowOpeningBlock(props: { function WorkflowMemberRow(props: { readonly member: RuntimeSubagent; readonly environmentId: EnvironmentId; + readonly ownerThreadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; - readonly providers: ReadonlyArray; readonly driver: ProviderDriverKind | undefined; readonly onOpenThread: (threadId: string) => void; }) { @@ -125,79 +122,78 @@ function WorkflowMemberRow(props: { .trim(); const className = "group/subagent flex w-full min-w-0 items-center gap-2.5 rounded-md px-2 py-1.5 text-left"; + const content = ( + <> + + + {member.title} + {detail ? ( + + {detail} + + ) : null} + + {member.startedAt ? ( + + + + ) : null} + {threadId !== null ? ( + + ) : null} + + ); return (
  • - - - ) : ( -
  • + ) : ( + + ) + } + /> ); } diff --git a/apps/web/src/components/contextChipParts.tsx b/apps/web/src/components/contextChipParts.tsx index fddb9b35e6ad..647fd72a4bf3 100644 --- a/apps/web/src/components/contextChipParts.tsx +++ b/apps/web/src/components/contextChipParts.tsx @@ -164,6 +164,10 @@ export function HoverPopover(props: { * focus back to wherever it was before the press, such as the composer. */ stayOpenAfterPress?: boolean | undefined; + side?: "top" | "right" | "bottom" | "left" | undefined; + align?: "start" | "center" | "end" | undefined; + sideOffset?: number | undefined; + disabled?: boolean | undefined; children: ReactNode; }) { const pressedInsideRef = useRef(false); @@ -171,6 +175,7 @@ export function HoverPopover(props: { const popupRef = useRef(null); return ( { if (open) return; if (pressedInsideRef.current && details.reason === "trigger-hover") { @@ -203,7 +208,9 @@ export function HoverPopover(props: { /> { preview: TPreview; } +/** Where a surface's hover opens, relative to its trigger. The registry still picks the container. */ +export interface ContextPreviewPlacement { + side?: "top" | "right" | "bottom" | "left" | undefined; + align?: "start" | "center" | "end" | undefined; + sideOffset?: number | undefined; +} + /** * How a kind previews on hover. `details: "none"` is a decision, not a gap: the chip has nothing * to show beyond its label. @@ -252,12 +257,27 @@ export function buttonContextChip(input: { }; } -/** A chip with its kind's registered hover preview around it. */ +/** + * A trigger with its kind's registered hover preview around it. Rows and links use this as + * chips do; `disabled` keeps the trigger mounted while the preview can't open, such as mid-drag. + */ export function ContextPreviewHover( - props: ContextChipSpec & { kind: K }, + props: ContextChipSpec & { + kind: K; + placement?: ContextPreviewPlacement | undefined; + disabled?: boolean | undefined; + }, ) { const spec: ContextPreview = CONTEXT_PRESENTATION_DEFINITIONS[props.kind].preview; + const side = props.placement?.side ?? "top"; + const placement = { + side, + ...(props.placement?.align ? { align: props.placement.align } : {}), + ...(props.placement?.sideOffset !== undefined + ? { sideOffset: props.placement.sideOffset } + : {}), + }; switch (spec.details) { case "none": return props.trigger; @@ -265,15 +285,15 @@ export function ContextPreviewHover( const Body = spec.Body; const Popup = spec.Popup; return ( - + {Popup ? ( - + ) : ( @@ -301,6 +321,8 @@ export function ContextPreviewHover( accessibleLabel={props.accessibleLabel} onIntent={spec.onIntent} stayOpenAfterPress={spec.stayOpenAfterPress} + {...placement} + disabled={props.disabled} > @@ -309,9 +331,9 @@ export function ContextPreviewHover( case "card": { const Body = spec.Body; return ( - + - + diff --git a/apps/web/src/components/contextPreviews.tsx b/apps/web/src/components/contextPreviews.tsx index ae7b0714b81c..2b208cba8966 100644 --- a/apps/web/src/components/contextPreviews.tsx +++ b/apps/web/src/components/contextPreviews.tsx @@ -1,21 +1,25 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; -import { deriveThreadAgentFleet } from "@t3tools/client-runtime/state/agent-fleet"; +import { + agentFleetEntryFromRuntime, + deriveThreadAgentFleet, +} from "@t3tools/client-runtime/state/agent-fleet"; +import type { RuntimeSubagent } from "@t3tools/client-runtime/state/subagentRuntime"; import type { ElementContextRecord, EnvironmentId, PreviewAnnotationContextRecord, + ProviderInstanceId, ScopedThreadRef, ServerProviderSkill, SubagentContextRecord, - ThreadContextRecord, } from "@t3tools/contracts"; import { filePreviewKind } from "@t3tools/shared/filePreview"; import { PaintbrushIcon } from "lucide-react"; import { formatWorkspaceRelativePath } from "~/filePathDisplay"; import type { ReviewCommentContext } from "~/reviewCommentContext"; -import { useProject, useThreadProjection, useThreadShell } from "~/state/entities"; +import { useProject, useThreadProjection } from "~/state/entities"; import ChatMarkdown from "./ChatMarkdown"; import { showAgentInPanel } from "./chat/agentContextMenu"; import { @@ -29,7 +33,6 @@ import { ImageAttachmentPreview } from "./contextChipParts"; import { AttachmentQuickLookPreview } from "./files/AttachmentQuickLook"; import { ReadOnlySourcePreview } from "./files/AttachmentFilePreview"; import { ReviewCommentCard } from "./ReviewCommentCard"; -import { ThreadPreviewHoverCard } from "./ThreadHoverCard"; /* * Hover bodies for context kinds that have no better home. Every surface reaches these through @@ -321,33 +324,6 @@ export function ReviewCommentPreview(props: ReviewCommentPreviewProps) { ); } -export interface ThreadPreviewProps { - record: Pick; - /** On another machine than the one the message is for, so its agent can't read it. */ - unreadable?: boolean | undefined; -} - -export function ThreadPreview(props: ThreadPreviewProps) { - const { environmentId, threadId } = props.record; - const shell = useThreadShell({ environmentId, threadId }); - const project = useProject(shell ? { environmentId, projectId: shell.projectId } : null); - if (props.unreadable) { - return ( -

    - On another machine, so this machine's agent can't read it. Remove it or switch back to send. -

    - ); - } - if (!shell) return

    Thread no longer available

    ; - return ( - - ); -} - export type SubagentChipRecord = Pick< SubagentContextRecord, "environmentId" | "ownerThreadId" | "childThreadId" | "subagentId" | "handle" | "title" | "status" @@ -355,6 +331,11 @@ export type SubagentChipRecord = Pick< export interface SubagentPreviewProps { record: SubagentChipRecord; + /** + * What the surface already shows of the agent, for one its owner doesn't list as its own, + * such as a workflow member whose thread doesn't exist yet. + */ + fallback?: { agent: RuntimeSubagent; providerInstanceId: ProviderInstanceId } | undefined; } /** The agent's Agents panel preview, found among its owner's agents. Mounted while open. */ @@ -373,7 +354,13 @@ export function SubagentPreview(props: SubagentPreviewProps) { threadId: record.ownerThreadId, subagents: owner?.subagents ?? [], shells, - }).find((candidate) => candidate.key === key) ?? null; + }).find((candidate) => candidate.key === key) ?? + (props.fallback + ? agentFleetEntryFromRuntime({ + ...props.fallback, + ownerThreadId: record.ownerThreadId, + }) + : null); if (entry === null) { return (

    diff --git a/apps/web/src/state/providerEntries.ts b/apps/web/src/state/providerEntries.ts new file mode 100644 index 000000000000..9ff64dca4182 --- /dev/null +++ b/apps/web/src/state/providerEntries.ts @@ -0,0 +1,74 @@ +import { Atom } from "effect/reactivity"; +import * as Option from "effect/Option"; + +import { + deriveProviderEntriesByEnvironment, + type ProviderInstanceEntry, +} from "../providerInstances"; +import { environmentPresentations } from "./presentation"; + +// The fields thread rows and previews show. Provider status pushes refresh `checkedAt`, +// status and auth on every provider; leaving those out keeps each +// environment's map, and every memoized row, stable across them. +export type ProviderEntrySummary = Pick< + ProviderInstanceEntry, + | "instanceId" + | "driverKind" + | "enabled" + | "displayName" + | "accentColor" + | "acpRegistryAgentId" + | "acpRegistryIconUrl" + | "models" +>; + +function sameProviderEntries( + left: ReadonlyMap, + right: ReadonlyMap, +): boolean { + if (left.size !== right.size) return false; + for (const [instanceId, entry] of right) { + const other = left.get(instanceId); + if ( + other === undefined || + other.driverKind !== entry.driverKind || + other.enabled !== entry.enabled || + other.displayName !== entry.displayName || + other.accentColor !== entry.accentColor || + other.acpRegistryAgentId !== entry.acpRegistryAgentId || + other.acpRegistryIconUrl !== entry.acpRegistryIconUrl || + other.models !== entry.models + ) { + return false; + } + } + return true; +} + +// Threads on non-primary environments (T3 Connect, hosted) resolve their +// provider entry from their own environment's config: default instance ids +// are driver slugs, so a flat map would collide across environments. +export const providerEntriesByEnvironmentAtom = Atom.make((get) => { + const providersByEnvironment = get(environmentPresentations.providersByEnvironmentAtom); + const derived = deriveProviderEntriesByEnvironment( + [...get(environmentPresentations.serverConfigsAtom)].map( + ([environmentId, config]) => + [environmentId, providersByEnvironment.get(environmentId) ?? [], config.settings] as const, + ), + ); + const previous = Option.getOrNull( + get.self>>(), + ); + const next = new Map>(); + let changed = previous === null || previous.size !== derived.size; + for (const [environmentId, entries] of derived) { + const prior = previous?.get(environmentId); + if (prior !== undefined && sameProviderEntries(prior, entries)) { + next.set(environmentId, prior); + } else { + next.set(environmentId, entries); + changed = true; + } + } + return !changed && previous !== null ? previous : next; +}).pipe(Atom.withLabel("provider-entries-by-environment")); diff --git a/docs/fork-differences.md b/docs/fork-differences.md index aee0a7150e58..b2d3bd0da50d 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -282,8 +282,12 @@ thread details panel, one row each, newest first, and removed the right-panel Ag and start time, a second line with a working agent's latest tool call (a static `…` while it runs, a `waiting` badge when the agent waits on the user), a finished agent's first result line (`subagentResultSummaryLine`) or a failed agent's error (the toolbar's - details toggle hides these second lines, remembered per device), and hover previews (status, provider, compact model with reasoning effort and `run N`, - prompt, result or error, the latest five tool calls, usage). Clicking a tool call in a preview + details toggle hides these second lines, remembered per device), and hover previews (status, + provider, the model as the provider's catalog names it with the effort and speed it was started + with, `run N`, the branch or worktree when it differs from its owner's, prompt, result or error, + the latest five tool calls, usage). The same preview is every agent's hover: conversation agent + rows, Lineage rows and workflow members, and `@agent` chips all draw it through the context + presentation registry. Clicking a tool call in a preview opens the agent on that call, expanded and scrolled into view (`agentDrillStore.ts` `focusToolCall`). Agents spawned by an agent sit indented under it, found through child-thread lineage in the thread shells (`deriveThreadAgentFleet`); a filter keeps a non-matching agent as diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index ede9f4ed948f..ca55b493c91e 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -16,6 +16,7 @@ import noOutsetStateIndicators from "./rules/no-outset-state-indicators.ts"; import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts"; import noRawResizeObserver from "./rules/no-raw-resize-observer.ts"; import noTestInLoop from "./rules/no-test-in-loop.ts"; +import noUnregisteredContextPreview from "./rules/no-unregistered-context-preview.ts"; import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts"; import noUnscopedHas from "./rules/no-unscoped-has.ts"; import preferCatchTags from "./rules/prefer-catch-tags.ts"; @@ -45,6 +46,7 @@ export default definePlugin({ "no-raw-resize-observer": noRawResizeObserver, "no-test-in-loop": noTestInLoop, "no-rpc-permission-bypass": noRpcPermissionBypass, + "no-unregistered-context-preview": noUnregisteredContextPreview, "no-unscoped-has": noUnscopedHas, "prefer-catch-tags": preferCatchTags, "prefer-effect-fn": preferEffectFn, diff --git a/oxlint-plugin-t3code/rules/no-unregistered-context-preview.test.ts b/oxlint-plugin-t3code/rules/no-unregistered-context-preview.test.ts new file mode 100644 index 000000000000..91f7bab32208 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unregistered-context-preview.test.ts @@ -0,0 +1,51 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-unregistered-context-preview", { + filename: "fixture.tsx", +}); + +describe("t3code/no-unregistered-context-preview", () => { + rule.valid( + "allows the registry's own exports", + `import { ContextPreviewHover } from "./contextPresentationRegistry";`, + ); + + rule.valid( + "allows non-component helpers from a preview module", + `import { skillPreviewDescription } from "../contextPreviews";`, + ); + + rule.valid( + "allows type imports from a preview module", + `import type { SubagentChipRecord } from "../contextPreviews"; +import { type ThreadPreviewProps } from "~/components/ThreadHoverCard";`, + ); + + rule.valid( + "allows other exports of a partly restricted module", + `import { useProviderEntries } from "./chat/AgentFleetRow";`, + ); + + rule.invalid( + "reports a preview body imported from a preview module", + `import { ThreadPreview } from "./ThreadHoverCard";`, + (output) => { + assert.match(output, /ThreadPreview is a hover preview body/); + }, + ); + + rule.invalid( + "reports a preview popup imported through an alias path", + `import { ThreadHoverCardPopup } from "~/components/ThreadHoverCard";`, + ); + + rule.invalid( + "reports a named preview body from a partly restricted module", + `import { AgentPreviewContent } from "./AgentFleetRow";`, + (output) => { + assert.match(output, /ContextPreviewHover or ContextPreviewBody/); + }, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-unregistered-context-preview.ts b/oxlint-plugin-t3code/rules/no-unregistered-context-preview.ts new file mode 100644 index 000000000000..addc01fe37cc --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unregistered-context-preview.ts @@ -0,0 +1,56 @@ +import { defineRule } from "@oxlint/plugins"; + +/* + * Hover previews drift when a surface renders a kind's preview body itself, so the bodies are + * reachable only through the context presentation registry. Modules map to the exports that + * are preview bodies; `null` means every component the module exports. + */ +const PREVIEW_MODULES = new Map | null>([ + ["ThreadHoverCard", null], + ["contextPreviews", null], + ["AgentFleetRow", new Set(["AgentPreviewContent"])], +]); + +const COMPONENT_NAME = /^[A-Z]/u; + +const moduleBasename = (source: string) => + source + .split("/") + .at(-1) + ?.replace(/\.(tsx?|jsx?)$/u, ""); + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require hover previews to come from the context presentation registry, so a kind previews the same everywhere.", + }, + }, + create(context) { + return { + ImportDeclaration(node) { + if (node.importKind === "type") return; + const source = typeof node.source.value === "string" ? node.source.value : null; + const basename = source === null ? undefined : moduleBasename(source); + if (basename === undefined || !PREVIEW_MODULES.has(basename)) return; + const restricted = PREVIEW_MODULES.get(basename) ?? null; + for (const specifier of node.specifiers) { + if (specifier.type !== "ImportSpecifier") continue; + if (specifier.importKind === "type") continue; + const imported = + specifier.imported.type === "Identifier" + ? specifier.imported.name + : String(specifier.imported.value); + const isPreview = + restricted === null ? COMPONENT_NAME.test(imported) : restricted.has(imported); + if (!isPreview) continue; + context.report({ + node: specifier, + message: `${imported} is a hover preview body. Draw previews with ContextPreviewHover or ContextPreviewBody from contextPresentationRegistry, adding a placement or preview input there if a surface needs one.`, + }); + } + }, + }; + }, +}); diff --git a/packages/client-runtime/src/state/agentFleet.ts b/packages/client-runtime/src/state/agentFleet.ts index 91f9567dae0a..ad0cc39cf251 100644 --- a/packages/client-runtime/src/state/agentFleet.ts +++ b/packages/client-runtime/src/state/agentFleet.ts @@ -7,15 +7,15 @@ * spawned are found through thread shells' lineage alone, so listing them never * subscribes to another thread's projection. */ -import type { - ModelSelection, - OrchestrationV2Run, - OrchestrationV2RunAttempt, - OrchestrationV2Subagent, - OrchestrationV2SubagentUsage, - OrchestrationV2ThreadShell, - ProviderInstanceId, +import { ThreadId, + type ModelSelection, + type OrchestrationV2Run, + type OrchestrationV2RunAttempt, + type OrchestrationV2Subagent, + type OrchestrationV2SubagentUsage, + type OrchestrationV2ThreadShell, + type ProviderInstanceId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -205,6 +205,31 @@ function subjectOf( }; } +/** + * An agent the fleet has no row for, such as a workflow member whose thread doesn't exist yet, + * shaped as a row from what its surface already knows. Handles name threads, so it has none. + */ +export function agentFleetEntryFromRuntime(input: { + readonly agent: RuntimeSubagent; + readonly ownerThreadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; +}): AgentFleetEntry { + const { agent } = input; + const title = formatSubagentDisplayTitle(agent.title); + return { + key: agent.childThreadId ?? `subagent:${agent.id}`, + childThreadId: agent.childThreadId === null ? null : ThreadId.make(agent.childThreadId), + ownerThreadId: input.ownerThreadId, + subagent: null, + shell: null, + agent, + providerInstanceId: input.providerInstanceId, + title, + handle: "", + subject: subjectOf(agent, title, agent.firstSeenAt), + }; +} + /** * Every agent of `threadId`: the agents it spawned (its projection's subagent * records, plus any subagent child shell without one), then, recursively, the diff --git a/packages/client-runtime/src/work-log/toolPreview.test.ts b/packages/client-runtime/src/work-log/toolPreview.test.ts index bfc134da2cb3..fc1570746d54 100644 --- a/packages/client-runtime/src/work-log/toolPreview.test.ts +++ b/packages/client-runtime/src/work-log/toolPreview.test.ts @@ -1,8 +1,14 @@ -import { ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/contracts"; +import { + MessageId, + ThreadId, + TurnItemId, + type OrchestrationV2ConversationMessage, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; -import { resolveToolPreview } from "./toolPreview.ts"; +import { latestThreadPreviewItems, resolveToolPreview } from "./toolPreview.ts"; function tool(toolName: string, input: unknown, output?: unknown): OrchestrationV2TurnItem { const at = DateTime.makeUnsafe("2026-10-09T15:00:00.000Z"); @@ -857,3 +863,48 @@ describe("resolveToolPreview", () => { ).toBeNull(); }); }); + +function message( + id: string, + role: OrchestrationV2ConversationMessage["role"], + text: string, + overrides: Partial = {}, +): OrchestrationV2ConversationMessage { + const at = DateTime.makeUnsafe("2026-10-20T22:00:00.000Z"); + return { + id: MessageId.make(id), + threadId: ThreadId.make("thread-preview"), + runId: null, + nodeId: null, + role, + text, + attachments: [], + streaming: false, + createdAt: at, + updatedAt: at, + createdBy: role === "user" ? "user" : "agent", + creationSource: "web", + ...overrides, + }; +} + +describe("latestThreadPreviewItems", () => { + it("shows the newest conversation messages, oldest first, as a read of the thread would", () => { + const items = latestThreadPreviewItems([ + message("m1", "user", "First ask"), + message("m2", "assistant", "First answer"), + message("m3", "system", "Context handoff"), + message("m4", "user", " Second ask "), + message("m5", "assistant", " "), + message("m6", "assistant", "const run = workflow();", { workflowScript: true }), + message("m7", "assistant", "Second answer"), + message("m8", "user", "Third ask"), + ]); + expect(items).toEqual([ + { key: "m2", label: "Agent", text: "First answer", activity: false }, + { key: "m4", label: "User", text: "Second ask", activity: false }, + { key: "m7", label: "Agent", text: "Second answer", activity: false }, + { key: "m8", label: "User", text: "Third ask", activity: false }, + ]); + }); +}); diff --git a/packages/client-runtime/src/work-log/toolPreview.ts b/packages/client-runtime/src/work-log/toolPreview.ts index a72acf938521..86846f50d56a 100644 --- a/packages/client-runtime/src/work-log/toolPreview.ts +++ b/packages/client-runtime/src/work-log/toolPreview.ts @@ -1,4 +1,8 @@ -import { ScheduledTaskSchedule, type OrchestrationV2TurnItem } from "@t3tools/contracts"; +import { + ScheduledTaskSchedule, + type OrchestrationV2ConversationMessage, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; import { resolveT3McpToolId } from "@t3tools/shared/t3McpToolPresentation"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; @@ -97,6 +101,15 @@ export interface PropertiesToolPreview extends PreviewHeader { readonly rows: ReadonlyArray; } +/** One line of a thread preview's recent history. */ +export interface ThreadPreviewItem { + readonly key: string; + readonly label: string; + readonly text: string; + /** A tool call, command or edit, summarized to one line, rather than a message. */ + readonly activity: boolean; +} + export interface ThreadToolPreview { readonly kind: "thread"; readonly threadId: string; @@ -105,13 +118,7 @@ export interface ThreadToolPreview { readonly model: string | null; readonly branch: string | null; /** The newest items the call returned, oldest first. */ - readonly items: ReadonlyArray<{ - readonly key: string; - readonly label: string; - readonly text: string; - /** A tool call, command or edit, summarized to one line, rather than a message. */ - readonly activity: boolean; - }>; + readonly items: ReadonlyArray; /** Items the call returned beyond `items`. */ readonly moreItems: number; } @@ -327,6 +334,29 @@ function threadActivityText(type: string, item: Record_): string | null { return str(item.title) ?? first ?? null; } +/** + * The thread's latest conversation, as a hover shows it: the same rows a read of the thread + * gives, from the messages the client already holds. + */ +export function latestThreadPreviewItems( + messages: ReadonlyArray, +): ReadonlyArray { + const items: ThreadPreviewItem[] = []; + for (let index = messages.length - 1; index >= 0 && items.length < MAX_THREAD_ITEMS; index--) { + const message = messages[index]!; + if (message.role === "system" || message.workflowScript) continue; + const text = message.text.trim(); + if (!text) continue; + items.unshift({ + key: message.id, + label: message.role === "user" ? "User" : "Agent", + text, + activity: false, + }); + } + return items; +} + function threadPreview(result: Record_): ThreadToolPreview | null { const thread = isRecord(result.thread) ? result.thread : null; const threadId = str(thread?.threadId); diff --git a/vite.config.ts b/vite.config.ts index a23e1720c730..7700167cd102 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -262,6 +262,15 @@ export default defineConfig({ files: ["apps/web/src/components/pullRequest/pullRequestIcons.tsx"], rules: { "eslint/no-restricted-imports": ["error", { paths: RESTRICTED_IMPORT_PATHS }] }, }, + { + files: ["apps/web/src/**"], + excludeFiles: [ + "apps/web/src/components/contextPresentationRegistry.tsx", + "apps/web/src/components/contextPreviews.tsx", + "**/*.{test,spec}.{ts,tsx}", + ], + rules: { "t3code/no-unregistered-context-preview": "error" }, + }, { files: ["apps/mobile/src/**"], rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" },