diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index e17ca5f..da3dff8 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -25,6 +25,17 @@ import type { WorkspaceState, } from './types'; +// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by +// GET /api/coder/sessions/:id/agent-sessions; drives the AgentComposerBar +// resumed/new-session chip via useAgentSessions. `has_session` is true when a +// resumable backend session id exists for that agent in the chat. +export interface AgentSessionInfo { + agent: string; + status: string; + has_session: boolean; + last_active_at: string | null; +} + export class ApiError extends Error { constructor( public status: number, @@ -363,6 +374,11 @@ export const api = { request( `/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`, ), + // v2.6 Phase 1-UX §9b: per-(chat,agent) backend-session state for the + // resumed/new-session chip. Chat-scoped (NOT foldable into the project-level + // provider snapshot). Proxied to boocoder at /api/sessions/:id/agent-sessions. + agentSessions: (sessionId: string) => + request(`/api/coder/sessions/${sessionId}/agent-sessions`), skillInvoke: ( sessionId: string, paneId: string, diff --git a/apps/web/src/components/AgentComposerBar.tsx b/apps/web/src/components/AgentComposerBar.tsx index 2dab033..aa74d11 100644 --- a/apps/web/src/components/AgentComposerBar.tsx +++ b/apps/web/src/components/AgentComposerBar.tsx @@ -1,9 +1,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react'; -import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons'; +import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'lucide-react'; import { api } from '@/api/client'; import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types'; import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot'; +import { providerIcon } from '@/components/coder/providerIcons'; +import { useAgentSessions } from '@/hooks/useAgentSessions'; import { DropdownMenu, DropdownMenuContent, @@ -172,9 +173,36 @@ interface Props { onChange: (next: AgentSessionConfig) => void; onProviderCommandsChange?: (commands: AgentCommand[]) => void; connected?: boolean; + // v2.6 Phase 1-UX §9b: chat id for the resumed/new-session chip. Optional so + // BooChat and any other AgentComposerBar caller renders no chip and is + // otherwise unaffected. When present + connected + the chat has ≥1 prior + // turn, a chip right of the Provider picker reports whether switching to the + // current provider resumes an agent session, replays history (boocode), or + // starts fresh. + sessionId?: string; + // True once the chat has at least one prior turn — gates the chip so it stays + // hidden on a brand-new chat. Defaults to false (no chip). + hasPriorTurn?: boolean; } -export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) { +// Relative-time formatter for the resumed-chip title (e.g. "3m ago"). +function relativeTime(iso: string | null): string { + if (!iso) return 'unknown'; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return 'unknown'; + const diffMs = Date.now() - then; + if (diffMs < 0) return 'just now'; + const sec = Math.floor(diffMs / 1000); + if (sec < 60) return 'just now'; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + const day = Math.floor(hr / 24); + return `${day}d ago`; +} + +export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn }: Props) { const allEntries = useProviderSnapshot(projectPath); // 5.5 — the composer picker only offers ENABLED providers that are ready (or // still loading). Disabled (enabled:false) and unavailable/error providers are @@ -186,6 +214,13 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma ); const [refreshing, setRefreshing] = useState(false); + // v2.6 Phase 1-UX §9b: chat-scoped agent-session rows for the resumed/new + // chip. Hook is unconditional (hooks rule); it self-no-ops when sessionId is + // undefined or the chat has no prior turn, so BooChat callers cost nothing. + const { sessions: agentSessions } = useAgentSessions( + sessionId && hasPriorTurn ? sessionId : undefined, + ); + const hydratedRef = useRef(false); useEffect(() => { @@ -294,21 +329,30 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma ); } - const providerIcon = (name: string) => { - switch (name) { - case 'claude': return ; - case 'opencode': return ; - case 'goose': return ; - case 'qwen': return ; - default: return ; - } - }; - const providerOptions = entries.map((e) => ({ id: e.name, label: e.label })); const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label })); const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label })); const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label })); + // v2.6 Phase 1-UX §9b: resumed / history / new-session chip. Only meaningful + // when this is a real chat (sessionId), the WS is connected, and the chat has + // ≥1 prior turn — otherwise render nothing so fresh chats and non-coder + // callers stay clean. + const sessionRow = agentSessions.find((s) => s.agent === value.provider); + const sessionChip: { label: string; title: string } | null = + sessionId && hasPriorTurn && connected + ? value.provider === 'boocode' + ? // Native boocode never holds an agent_sessions row — it reconstructs + // the conversation from the chat transcript each turn. + { label: 'history', title: 'BooCode replays the chat transcript each turn' } + : sessionRow?.has_session + ? { + label: 'resumed', + title: `Resuming ${value.provider} · last active ${relativeTime(sessionRow.last_active_at)}`, + } + : { label: 'new session', title: `${value.provider} starts a fresh session this turn` } + : null; + return (
+ {sessionChip && ( + + {sessionChip.label} + + )} ; + case 'opencode': + return ; + case 'goose': + return ; + case 'qwen': + return ; + default: + return ; + } +} + +/** + * Human label for a provider/agent name. `null` → "manual" (a RightRail-staged + * change with no dispatching agent, per §9a). Unknown names pass through + * verbatim so a future provider still reads sensibly. + */ +export function providerLabel(name: string | null): string { + switch (name) { + case null: + return 'manual'; + case 'boocode': + return 'BooCode'; + case 'opencode': + return 'opencode'; + case 'claude': + return 'Claude'; + case 'goose': + return 'goose'; + case 'qwen': + return 'Qwen'; + default: + return name; + } +} diff --git a/apps/web/src/components/panes/CoderPane.tsx b/apps/web/src/components/panes/CoderPane.tsx index fd06cf3..266107c 100644 --- a/apps/web/src/components/panes/CoderPane.tsx +++ b/apps/web/src/components/panes/CoderPane.tsx @@ -16,6 +16,8 @@ import { toast } from 'sonner'; import { isSlashCommandToken, mergeCommandsByName, parseSlashInput, slashQuery } from '@/lib/slash-command'; import { mergeWireToolCall } from '@/lib/coder-tools'; import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList'; +import { providerIcon, providerLabel } from '@/components/coder/providerIcons'; +import { refreshAgentSessions } from '@/hooks/useAgentSessions'; import { cn } from '@/lib/utils'; // --------------------------------------------------------------------------- @@ -56,6 +58,10 @@ interface PendingChange { diff?: string; new_content?: string; status: 'pending' | 'approved' | 'rejected'; + // v2.6 Phase 1-UX §9a: which agent staged this change. 'boocode' for native + // write tools, the dispatched agent for worktree edits, null for a manual + // RightRail-staged create (renders as a neutral "manual" badge). + agent: string | null; } interface Props { @@ -394,6 +400,15 @@ function DiffPanel({ }) { const pending = changes.filter((c) => c.status === 'pending'); + // v2.6 Phase 1-UX §9a: when pending changes span >1 distinct agent, surface a + // one-line "Changes from , " note so mixed provenance is obvious. Null + // (manual) counts as its own bucket and renders as "manual". + const distinctAgents = Array.from(new Set(pending.map((c) => c.agent))); + const mixedNote = + distinctAgents.length > 1 + ? `Changes from ${distinctAgents.map((a) => providerLabel(a)).join(', ')}` + : null; + return (
@@ -410,6 +425,11 @@ function DiffPanel({
+ {mixedNote && ( +
+ {mixedNote} +
+ )}
{pending.length === 0 ? (
@@ -420,14 +440,25 @@ function DiffPanel({ {pending.map((change) => (
- + + + {providerIcon(change.agent, 11)} + {providerLabel(change.agent)} + - {change.file_path} + {change.file_path}