From 05a7cda610f5b6d8db5d49eb69d9540b31d1e41f Mon Sep 17 00:00:00 2001 From: turingcat Date: Sat, 19 Sep 2026 23:57:37 +0800 Subject: [PATCH] feat(sidebar): surface ACP attention states --- src/app/workspace/layout.tsx | 2 + ...debar-conversation-attention-hook.test.tsx | 112 +++++++ .../sidebar-conversation-attention.test.ts | 308 ++++++++++++++++++ .../sidebar-conversation-attention.ts | 280 ++++++++++++++++ .../sidebar-conversation-card.test.tsx | 109 +++++++ .../sidebar-conversation-card.tsx | 131 +++++--- .../sidebar-conversation-list.test.tsx | 39 +++ .../sidebar-conversation-list.tsx | 15 + src/i18n/messages/ar.json | 2 + src/i18n/messages/de.json | 2 + src/i18n/messages/en.json | 2 + src/i18n/messages/es.json | 2 + src/i18n/messages/fr.json | 2 + src/i18n/messages/ja.json | 2 + src/i18n/messages/ko.json | 2 + src/i18n/messages/pt.json | 2 + src/i18n/messages/zh-CN.json | 2 + src/i18n/messages/zh-TW.json | 2 + 18 files changed, 975 insertions(+), 41 deletions(-) create mode 100644 src/components/conversations/sidebar-conversation-attention-hook.test.tsx create mode 100644 src/components/conversations/sidebar-conversation-attention.test.ts create mode 100644 src/components/conversations/sidebar-conversation-attention.ts diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index f08058fa7e..2b73ea6b82 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -16,6 +16,7 @@ import { import type { ImperativePanelGroupHandle } from "react-resizable-panels" import { FolderTitleBar } from "@/components/layout/folder-title-bar" import { Sidebar } from "@/components/layout/sidebar" +import { SidebarConversationCompletionBridge } from "@/components/conversations/sidebar-conversation-attention" import { StatusBar } from "@/components/layout/status-bar" import { AppWorkspaceProvider, @@ -1282,6 +1283,7 @@ function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) { + diff --git a/src/components/conversations/sidebar-conversation-attention-hook.test.tsx b/src/components/conversations/sidebar-conversation-attention-hook.test.tsx new file mode 100644 index 0000000000..45fb96f9e4 --- /dev/null +++ b/src/components/conversations/sidebar-conversation-attention-hook.test.tsx @@ -0,0 +1,112 @@ +import { StrictMode, useState } from "react" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { + ConnectionState, + ConnectionStoreApi, +} from "@/contexts/acp-connections-context" +import type { TabItem } from "@/stores/tab-store" + +const fake = vi.hoisted(() => ({ + store: null as ConnectionStoreApi | null, + connections: new Map(), + listeners: new Map void>>(), +})) + +vi.mock("@/contexts/acp-connections-context", () => ({ + useConnectionStore: () => fake.store, +})) + +import { useSidebarConversationCompletion } from "./sidebar-conversation-attention" + +const tabs: TabItem[] = [ + { + id: "tab-11", + kind: "conversation", + folderId: 1, + conversationId: 11, + agentType: "claude_code", + title: "conv-11", + isPinned: false, + }, +] + +function connection(status: ConnectionState["status"]): ConnectionState { + return { contextKey: "tab-11", status } as ConnectionState +} + +function CompletionProbe({ testId }: { testId: string }) { + const completionKeys = useSidebarConversationCompletion(tabs, null) + return {[...completionKeys].join(",")} +} + +function notifyConnection(status: ConnectionState["status"]) { + fake.connections.set("tab-11", connection(status)) + for (const listener of fake.listeners.get("tab-11") ?? []) listener() +} + +beforeEach(() => { + fake.connections = new Map([["tab-11", connection("prompting")]]) + fake.listeners = new Map() + fake.store = { + getConnection: (key) => fake.connections.get(key), + getConnectPending: () => undefined, + getActiveKey: () => null, + subscribeKey: (key, callback) => { + const listeners = fake.listeners.get(key) ?? new Set() + listeners.add(callback) + fake.listeners.set(key, listeners) + return () => { + listeners.delete(callback) + if (listeners.size === 0) fake.listeners.delete(key) + } + }, + subscribeActiveKey: () => () => {}, + } +}) + +describe("sidebar completion hook lifecycle", () => { + it("keeps observing completion after Strict Mode replays effects", async () => { + render( + + + + ) + + await waitFor(() => { + expect(fake.listeners.get("tab-11")?.size).toBeGreaterThan(0) + }) + + act(() => notifyConnection("connected")) + + expect(screen.getByTestId("completion").textContent).toBe("claude_code:11") + }) + + it("preserves unread completion while the sidebar consumer is unmounted", async () => { + function Harness() { + const [showSidebar, setShowSidebar] = useState(true) + return ( + <> + + {showSidebar ? : null} + + + ) + } + + render() + + await waitFor(() => { + expect(fake.listeners.get("tab-11")?.size).toBeGreaterThan(0) + }) + fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" })) + + act(() => notifyConnection("connected")) + + fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" })) + expect(screen.getByTestId("sidebar").textContent).toBe("claude_code:11") + }) +}) diff --git a/src/components/conversations/sidebar-conversation-attention.test.ts b/src/components/conversations/sidebar-conversation-attention.test.ts new file mode 100644 index 0000000000..233212728d --- /dev/null +++ b/src/components/conversations/sidebar-conversation-attention.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it, vi } from "vitest" + +import type { + ConnectionState, + ConnectionStoreApi, +} from "@/contexts/acp-connections-context" +import type { TabItem } from "@/stores/tab-store" +import { + connectionNeedsAttention, + createSidebarAttentionStore, + reduceSidebarCompletionState, + sidebarAttentionTargets, + type SidebarCompletionObservation, + type SidebarCompletionState, +} from "./sidebar-conversation-attention" + +function connection(patch: Partial = {}): ConnectionState { + return { + connectionId: "connection-1", + contextKey: "tab-1", + agentType: "claude_code", + workingDir: "/tmp/project", + status: "prompting", + promptCapabilities: { image: false, audio: false, embedded_context: false }, + supportsFork: false, + selectorsReady: true, + sessionId: "session-1", + modes: null, + configOptions: null, + availableCommands: null, + usage: null, + liveMessage: null, + pendingPermission: null, + pendingUserMessage: null, + steeredMessageIds: [], + pendingQuestion: null, + pendingAskQuestion: null, + pendingPlanApproval: null, + claudeApiRetry: null, + sessionFailures: [], + asyncTasks: [], + error: null, + loadError: null, + loadErrorCommand: null, + lastAppliedSeq: 0, + isDelegationChild: false, + parentToolUseId: null, + parentConnectionId: null, + isViewer: false, + configStale: false, + configStaleKind: null, + configStaleDismissed: false, + backgroundOutstanding: 0, + outOfTurnToolCalls: null, + ...patch, + } +} + +function tab(id: string, conversationId: number | null): TabItem { + return { + id, + kind: "conversation", + folderId: 1, + conversationId, + agentType: "claude_code", + title: id, + isPinned: false, + } +} + +function store( + connections: Map +): ConnectionStoreApi & { + listeners: Map void>> +} { + const listeners = new Map void>>() + return { + listeners, + getConnection: (key) => connections.get(key), + getConnectPending: () => undefined, + getActiveKey: () => null, + subscribeKey: (key, callback) => { + const callbacks = listeners.get(key) ?? new Set() + callbacks.add(callback) + listeners.set(key, callbacks) + return () => { + callbacks.delete(callback) + if (callbacks.size === 0) listeners.delete(key) + } + }, + subscribeActiveKey: () => () => {}, + } +} + +describe("connectionNeedsAttention", () => { + it.each([ + { + name: "permission request", + patch: { + pendingPermission: { + request_id: "permission-1", + tool_call: {}, + options: [], + }, + }, + }, + { + name: "free-text question", + patch: { + pendingQuestion: { tool_call_id: "tool-1", question: "Continue?" }, + }, + }, + { + name: "structured question", + patch: { + pendingAskQuestion: { + question_id: "question-1", + questions: [ + { + id: "choice-1", + question: "Choose", + header: "Choice", + multi_select: false, + options: [], + }, + ], + created_at: "2026-09-19T00:00:00.000Z", + }, + }, + }, + { + name: "plan approval", + patch: { + pendingPlanApproval: { + approval_id: "approval-1", + tool_call_id: "tool-1", + plan_markdown: "Plan", + created_at: "2026-09-19T00:00:00.000Z", + }, + }, + }, + ])("returns true for a pending $name", ({ patch }) => { + expect(connectionNeedsAttention(connection(patch))).toBe(true) + }) + + it("ignores an empty structured-question payload", () => { + expect( + connectionNeedsAttention( + connection({ + pendingAskQuestion: { + question_id: "question-1", + questions: [], + created_at: "2026-09-19T00:00:00.000Z", + }, + }) + ) + ).toBe(false) + }) +}) + +describe("sidebar conversation attention external store", () => { + it("maps attention from open tab context keys to conversation keys", () => { + const connections = new Map([ + [ + "tab-11", + connection({ + contextKey: "tab-11", + pendingQuestion: { tool_call_id: "tool-1", question: "Continue?" }, + }), + ], + ["tab-12", connection({ contextKey: "tab-12" })], + ]) + const targets = sidebarAttentionTargets([ + tab("tab-11", 11), + tab("tab-12", 12), + tab("draft", null), + ]) + const attention = createSidebarAttentionStore(targets, store(connections)) + + expect(attention.getSnapshot()).toBe("claude_code:11") + }) + + it("subscribes only to open persisted conversation tabs and cleans up", () => { + const connectionStore = store(new Map()) + const attention = createSidebarAttentionStore( + sidebarAttentionTargets([ + tab("tab-11", 11), + tab("tab-12", 12), + tab("draft", null), + ]), + connectionStore + ) + const listener = vi.fn() + + const unsubscribe = attention.subscribe(listener) + expect([...connectionStore.listeners.keys()].sort()).toEqual([ + "tab-11", + "tab-12", + ]) + + connectionStore.listeners.get("tab-11")?.forEach((callback) => callback()) + expect(listener).toHaveBeenCalledOnce() + + unsubscribe() + expect(connectionStore.listeners.size).toBe(0) + }) +}) + +function observation( + contextKey: string, + conversationKey: string, + status: ConnectionState["status"] +): SidebarCompletionObservation { + return { contextKey, conversationKey, status } +} + +function completionState( + observations: SidebarCompletionObservation[] = [], + unreadConversationKeys: string[] = [] +): SidebarCompletionState { + return { + observations: new Map( + observations.map((item) => [item.contextKey, item] as const) + ), + unreadConversationKeys: new Set(unreadConversationKeys), + } +} + +describe("sidebar conversation completion state", () => { + it("does not mark an already-connected conversation unread on first observation", () => { + const next = reduceSidebarCompletionState( + completionState(), + [observation("tab-11", "claude_code:11", "connected")], + null + ) + + expect([...next.unreadConversationKeys]).toEqual([]) + }) + + it("marks a background conversation unread when prompting finishes", () => { + const previous = completionState([ + observation("tab-11", "claude_code:11", "prompting"), + ]) + + const next = reduceSidebarCompletionState( + previous, + [observation("tab-11", "claude_code:11", "connected")], + "claude_code:12" + ) + + expect([...next.unreadConversationKeys]).toEqual(["claude_code:11"]) + }) + + it("treats a completion in the active conversation as already read", () => { + const previous = completionState([ + observation("tab-11", "claude_code:11", "prompting"), + ]) + + const next = reduceSidebarCompletionState( + previous, + [observation("tab-11", "claude_code:11", "connected")], + "claude_code:11" + ) + + expect([...next.unreadConversationKeys]).toEqual([]) + }) + + it("clears an unread completion when that conversation becomes active", () => { + const previous = completionState( + [observation("tab-11", "claude_code:11", "connected")], + ["claude_code:11"] + ) + + const next = reduceSidebarCompletionState( + previous, + [observation("tab-11", "claude_code:11", "connected")], + "claude_code:11" + ) + + expect([...next.unreadConversationKeys]).toEqual([]) + }) + + it("clears the prior completion when a new prompt starts", () => { + const previous = completionState( + [observation("tab-11", "claude_code:11", "connected")], + ["claude_code:11"] + ) + + const next = reduceSidebarCompletionState( + previous, + [observation("tab-11", "claude_code:11", "prompting")], + null + ) + + expect([...next.unreadConversationKeys]).toEqual([]) + }) + + it("drops unread state when the conversation tab closes", () => { + const previous = completionState( + [observation("tab-11", "claude_code:11", "connected")], + ["claude_code:11"] + ) + + const next = reduceSidebarCompletionState(previous, [], null) + + expect([...next.unreadConversationKeys]).toEqual([]) + }) +}) diff --git a/src/components/conversations/sidebar-conversation-attention.ts b/src/components/conversations/sidebar-conversation-attention.ts new file mode 100644 index 0000000000..fd057f2d1c --- /dev/null +++ b/src/components/conversations/sidebar-conversation-attention.ts @@ -0,0 +1,280 @@ +"use client" + +import { useEffect, useMemo, useSyncExternalStore } from "react" + +import { + useConnectionStore, + type ConnectionState, + type ConnectionStoreApi, +} from "@/contexts/acp-connections-context" +import type { ConnectionStatus } from "@/lib/types" +import { useTabStore, type TabItem } from "@/stores/tab-store" + +interface SidebarAttentionTarget { + contextKey: string + conversationKey: string +} + +export interface SidebarCompletionObservation extends SidebarAttentionTarget { + status: ConnectionStatus | undefined +} + +export interface SidebarCompletionState { + observations: ReadonlyMap + unreadConversationKeys: ReadonlySet +} + +export function connectionNeedsAttention( + connection: ConnectionState | undefined +): boolean { + if (!connection) return false + return Boolean( + connection.pendingPermission || + connection.pendingQuestion || + (connection.pendingAskQuestion?.questions.length ?? 0) > 0 || + connection.pendingPlanApproval + ) +} + +export function sidebarAttentionTargets( + tabs: readonly TabItem[] +): SidebarAttentionTarget[] { + return tabs.flatMap((tab) => + tab.conversationId == null + ? [] + : [ + { + contextKey: tab.id, + conversationKey: `${tab.agentType}:${tab.conversationId}`, + }, + ] + ) +} + +export function createSidebarAttentionStore( + targets: readonly SidebarAttentionTarget[], + store: ConnectionStoreApi +) { + const contextKeys = [...new Set(targets.map((target) => target.contextKey))] + + return { + subscribe(callback: () => void) { + const unsubscribers = contextKeys.map((key) => + store.subscribeKey(key, callback) + ) + return () => { + for (const unsubscribe of unsubscribers) unsubscribe() + } + }, + getSnapshot() { + return targets + .filter((target) => + connectionNeedsAttention(store.getConnection(target.contextKey)) + ) + .map((target) => target.conversationKey) + .sort() + .join("\n") + }, + } +} + +export function reduceSidebarCompletionState( + state: SidebarCompletionState, + observations: readonly SidebarCompletionObservation[], + activeConversationKey: string | null +): SidebarCompletionState { + const nextObservations = new Map( + observations.map((observation) => [observation.contextKey, observation]) + ) + const openConversationKeys = new Set( + observations.map((observation) => observation.conversationKey) + ) + const unreadConversationKeys = new Set( + [...state.unreadConversationKeys].filter((key) => + openConversationKeys.has(key) + ) + ) + + for (const observation of observations) { + const previous = state.observations.get(observation.contextKey) + + if (observation.status === "prompting") { + unreadConversationKeys.delete(observation.conversationKey) + } else if ( + previous?.status === "prompting" && + observation.status === "connected" + ) { + if (observation.conversationKey === activeConversationKey) { + unreadConversationKeys.delete(observation.conversationKey) + } else { + unreadConversationKeys.add(observation.conversationKey) + } + } + } + + if (activeConversationKey) { + unreadConversationKeys.delete(activeConversationKey) + } + + return { + observations: nextObservations, + unreadConversationKeys, + } +} + +interface SidebarCompletionTracker { + subscribe: (callback: () => void) => () => void + getSnapshot: () => string + setInputs: ( + targets: readonly SidebarAttentionTarget[], + activeConversationKey: string | null + ) => void +} + +function createSidebarCompletionTracker( + store: ConnectionStoreApi +): SidebarCompletionTracker { + let targets: readonly SidebarAttentionTarget[] = [] + let targetsSignature = "" + let activeConversationKey: string | null = null + let state: SidebarCompletionState = { + observations: new Map(), + unreadConversationKeys: new Set(), + } + let snapshot = "" + let unsubscribeConnections: (() => void)[] = [] + const listeners = new Set<() => void>() + + const publish = () => { + const observations = targets.map((target) => ({ + ...target, + status: store.getConnection(target.contextKey)?.status, + })) + state = reduceSidebarCompletionState( + state, + observations, + activeConversationKey + ) + const nextSnapshot = [...state.unreadConversationKeys].sort().join("\n") + if (nextSnapshot === snapshot) return + snapshot = nextSnapshot + for (const listener of listeners) listener() + } + + const unsubscribeAllConnections = () => { + for (const unsubscribe of unsubscribeConnections) unsubscribe() + unsubscribeConnections = [] + } + + return { + subscribe(callback) { + listeners.add(callback) + return () => listeners.delete(callback) + }, + getSnapshot() { + return snapshot + }, + setInputs(nextTargets, nextActiveConversationKey) { + const nextSignature = nextTargets + .map((target) => `${target.contextKey}\t${target.conversationKey}`) + .join("\n") + const targetsChanged = nextSignature !== targetsSignature + + targets = nextTargets + targetsSignature = nextSignature + activeConversationKey = nextActiveConversationKey + + if (targetsChanged) { + unsubscribeAllConnections() + const contextKeys = [ + ...new Set(targets.map((target) => target.contextKey)), + ] + unsubscribeConnections = contextKeys.map((key) => + store.subscribeKey(key, publish) + ) + } + + publish() + }, + } +} + +const completionTrackers = new WeakMap< + ConnectionStoreApi, + SidebarCompletionTracker +>() + +function getSidebarCompletionTracker( + store: ConnectionStoreApi +): SidebarCompletionTracker { + const existing = completionTrackers.get(store) + if (existing) return existing + + const tracker = createSidebarCompletionTracker(store) + completionTrackers.set(store, tracker) + return tracker +} + +const EMPTY_ATTENTION_KEYS: ReadonlySet = new Set() + +export function useSidebarConversationAttention( + tabs: readonly TabItem[] +): ReadonlySet { + const connectionStore = useConnectionStore() + const targets = useMemo(() => sidebarAttentionTargets(tabs), [tabs]) + const attentionStore = useMemo( + () => createSidebarAttentionStore(targets, connectionStore), + [targets, connectionStore] + ) + const snapshot = useSyncExternalStore( + attentionStore.subscribe, + attentionStore.getSnapshot, + attentionStore.getSnapshot + ) + + return useMemo( + () => (snapshot ? new Set(snapshot.split("\n")) : EMPTY_ATTENTION_KEYS), + [snapshot] + ) +} + +const EMPTY_COMPLETION_KEYS: ReadonlySet = new Set() + +export function useSidebarConversationCompletion( + tabs: readonly TabItem[], + activeTabId: string | null +): ReadonlySet { + const connectionStore = useConnectionStore() + const targets = useMemo(() => sidebarAttentionTargets(tabs), [tabs]) + const completionTracker = useMemo( + () => getSidebarCompletionTracker(connectionStore), + [connectionStore] + ) + const activeConversationKey = useMemo(() => { + const activeTab = tabs.find((tab) => tab.id === activeTabId) + return activeTab?.conversationId == null + ? null + : `${activeTab.agentType}:${activeTab.conversationId}` + }, [tabs, activeTabId]) + const snapshot = useSyncExternalStore( + completionTracker.subscribe, + completionTracker.getSnapshot, + completionTracker.getSnapshot + ) + + useEffect(() => { + completionTracker.setInputs(targets, activeConversationKey) + }, [completionTracker, targets, activeConversationKey]) + + return useMemo( + () => (snapshot ? new Set(snapshot.split("\n")) : EMPTY_COMPLETION_KEYS), + [snapshot] + ) +} + +export function SidebarConversationCompletionBridge() { + const tabs = useTabStore((state) => state.tabs) + const activeTabId = useTabStore((state) => state.activeTabId) + useSidebarConversationCompletion(tabs, activeTabId) + return null +} diff --git a/src/components/conversations/sidebar-conversation-card.test.tsx b/src/components/conversations/sidebar-conversation-card.test.tsx index b7bd49e090..14601a6ea2 100644 --- a/src/components/conversations/sidebar-conversation-card.test.tsx +++ b/src/components/conversations/sidebar-conversation-card.test.tsx @@ -210,6 +210,115 @@ describe("SidebarConversationCard pin action", () => { }) }) +describe("SidebarConversationCard attention indicator", () => { + function renderCard( + c: DbConversationSummary, + needsAttention: boolean, + hasUnreadCompletion = false, + isSelected = false + ) { + return renderWithIntl( + + ) + } + + it("shows a persistent needs-input bell instead of the running spinner", () => { + const running = { ...conv(1), status: "in_progress" } + const { queryByTitle, getByTitle } = renderCard(running, true) + + expect(getByTitle("Needs your input")).toBeDefined() + expect(queryByTitle("Running")).toBeNull() + }) + + it("highlights an unselected conversation that needs input", () => { + const { container } = renderCard(conv(2), true) + const row = container.querySelector( + '[data-conversation-id="2"]' + )?.parentElement + + expect(row?.className).toContain("bg-amber-500/10") + }) + + it("keeps the needs-input highlight when the conversation is selected", () => { + const { container } = renderCard(conv(7), true, false, true) + const row = container.querySelector( + '[data-conversation-id="7"]' + )?.parentElement + + expect(row?.className).toContain("bg-amber-500/10") + }) + + it("restores the running spinner when attention clears", () => { + const running = { ...conv(3), status: "in_progress" } + const { rerender, queryByTitle, getByTitle } = renderCard(running, true) + + rerender( + + + + ) + + expect(queryByTitle("Needs your input")).toBeNull() + expect(getByTitle("Running")).toBeDefined() + }) + + it("shows an unread completion icon instead of the running spinner", () => { + const running = { ...conv(4), status: "in_progress" } + const { queryByTitle, getByTitle } = renderCard(running, false, true) + + expect(getByTitle("Completed - click to review")).toBeDefined() + expect(queryByTitle("Running")).toBeNull() + }) + + it("highlights an unselected conversation with an unread completion", () => { + const { container } = renderCard(conv(5), false, true) + const row = container.querySelector( + '[data-conversation-id="5"]' + )?.parentElement + + expect(row?.className).toContain("bg-emerald-500/10") + }) + + it("keeps the needs-input bell above an unread completion", () => { + const { queryByTitle, getByTitle } = renderCard(conv(6), true, true) + + expect(getByTitle("Needs your input")).toBeDefined() + expect(queryByTitle("Completed - click to review")).toBeNull() + }) + + it("associates the attention status with the conversation button", () => { + const { container } = renderCard(conv(8), true) + const button = container.querySelector('[data-conversation-id="8"]') + const descriptionId = button?.getAttribute("aria-describedby") + + expect(descriptionId).toBeTruthy() + expect(document.getElementById(descriptionId ?? "")?.textContent).toBe( + "Needs your input" + ) + }) +}) + // The hover-reveal icon buttons live in the row's right slot as siblings of the // clickable row button (never nested). They carry only an aria-label (icon, no // text), so getByLabelText addresses them unambiguously — distinct from the diff --git a/src/components/conversations/sidebar-conversation-card.tsx b/src/components/conversations/sidebar-conversation-card.tsx index 3dc7fc8332..a0060652c1 100644 --- a/src/components/conversations/sidebar-conversation-card.tsx +++ b/src/components/conversations/sidebar-conversation-card.tsx @@ -4,6 +4,7 @@ import { memo, useState, useCallback, + useId, type CSSProperties, type FocusEvent, } from "react" @@ -21,6 +22,7 @@ import { FolderX, Info, ChevronRight, + BellRing, } from "lucide-react" import { useTranslations } from "next-intl" import { useImeGuard } from "@/hooks/use-ime-guard" @@ -173,6 +175,8 @@ interface SidebarConversationCardProps { conversation: DbConversationSummary isSelected: boolean isOpenInTab?: boolean + needsAttention?: boolean + hasUnreadCompletion?: boolean timeLabel?: string onSelect: (id: number, agentType: string, folderId: number) => void onDoubleClick?: (id: number, agentType: string, folderId: number) => void @@ -196,6 +200,8 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ conversation, isSelected, isOpenInTab = false, + needsAttention = false, + hasUnreadCompletion = false, timeLabel, onSelect, onDoubleClick, @@ -212,6 +218,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ const t = useTranslations("Folder.conversationCard") const ime = useImeGuard() const tSidebar = useTranslations("Folder.sidebar") + const statusDescriptionId = useId() const tStatus = useTranslations("Folder.statusLabels") const tDetails = useTranslations("Folder.sessionDetails") const [renameOpen, setRenameOpen] = useState(false) @@ -350,13 +357,22 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ "group relative flex h-[1.9375rem] w-full items-center", "rounded-full text-sidebar-foreground", "transition-colors duration-[120ms]", - isSelected - ? "bg-sidebar-primary/8" - : "hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]" + needsAttention + ? "bg-amber-500/10 hover:bg-amber-500/15 dark:bg-amber-400/10 dark:hover:bg-amber-400/15" + : hasUnreadCompletion + ? "bg-emerald-500/10 hover:bg-emerald-500/15 dark:bg-emerald-400/10 dark:hover:bg-emerald-400/15" + : isSelected + ? "bg-sidebar-primary/8" + : "hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]" )} > + )} - )} - - - )} + + )} diff --git a/src/components/conversations/sidebar-conversation-list.test.tsx b/src/components/conversations/sidebar-conversation-list.test.tsx index 006ffb812e..2b46d53159 100644 --- a/src/components/conversations/sidebar-conversation-list.test.tsx +++ b/src/components/conversations/sidebar-conversation-list.test.tsx @@ -65,6 +65,13 @@ const stableTabFns = vi.hoisted(() => ({ })) const stableAgents = vi.hoisted(() => ({ sortedTypes: ["claude_code"] })) +const sidebarAttention = vi.hoisted(() => ({ keys: new Set() })) +const sidebarCompletion = vi.hoisted(() => ({ keys: new Set() })) + +vi.mock("./sidebar-conversation-attention", () => ({ + useSidebarConversationAttention: () => sidebarAttention.keys, + useSidebarConversationCompletion: () => sidebarCompletion.keys, +})) // Context functions are stable refs in production (useCallback values); the // mocks must be too, else the list's folder callbacks (which close over them) @@ -331,6 +338,7 @@ beforeEach(() => { virtuaCtl.scrollOffset = 0 virtuaCtl.onScroll = null virtuaCtl.scrollToIndex.mockClear() + sidebarAttention.keys.clear() }) describe("SidebarConversationList — single status event re-render scope", () => { @@ -411,6 +419,37 @@ describe("SidebarConversationList — single status event re-render scope", () = }) }) +describe("SidebarConversationList — interaction attention", () => { + beforeEach(() => { + const folders = [folder(1, "Folder 1")] + useAppWorkspaceStore.setState({ + folders, + allFolders: folders, + conversations: [conv(11, 1, { status: "in_progress" })], + }) + store.activeTabId = "tab-11" + store.tabSpec = [ + { + id: "tab-11", + conversationId: 11, + agentType: "claude_code", + folderId: 1, + title: "conv-11", + isPinned: false, + }, + ] + }) + + it("passes the live attention state to the matching conversation card", () => { + sidebarAttention.keys.add("claude_code:11") + + render(tree()) + + expect(document.querySelector('[title="Needs your input"]')).not.toBeNull() + expect(document.querySelector('[title="Running"]')).toBeNull() + }) +}) + describe("SidebarConversationList — Pinned section (migration semantics)", () => { beforeEach(() => { probes.card = 0 diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index f754d1d9ed..d2b0c85db6 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -177,6 +177,10 @@ import { import { cn } from "@/lib/utils" import { FolderAliasLabel } from "./folder-alias-label" import { toErrorMessage } from "@/lib/app-error" +import { + useSidebarConversationAttention, + useSidebarConversationCompletion, +} from "./sidebar-conversation-attention" // Layout effect on the client (so the sticky overlay is positioned before // paint) but a no-op-safe passive effect during the static-export prerender. @@ -926,6 +930,11 @@ export function SidebarConversationList({ const activeTabId = useTabStore((s) => s.activeTabId) const tabs = useTabStore((s) => s.tabs) + const attentionConversationKeys = useSidebarConversationAttention(tabs) + const completedConversationKeys = useSidebarConversationCompletion( + tabs, + activeTabId + ) const { openTab, closeConversationTab, @@ -3009,6 +3018,12 @@ export function SidebarConversationList({ selectedConversation?.id === conv.id } isOpenInTab={openTabKeys.has(`${conv.agent_type}:${conv.id}`)} + needsAttention={attentionConversationKeys.has( + `${conv.agent_type}:${conv.id}` + )} + hasUnreadCompletion={completedConversationKeys.has( + `${conv.agent_type}:${conv.id}` + )} timeLabel={formatRelative( sortMode === "updated" ? conv.updated_at : conv.created_at, now diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 4a1aa40f67..9c31c6aeb2 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1912,6 +1912,8 @@ "sectionOrderMoveDown": "تحريك لأسفل", "sectionOrderItemLabel": "{name} — الموضع {position} من {total}", "statusRunningBadge": "قيد التشغيل", + "statusNeedsAttentionBadge": "يحتاج إلى إدخالك", + "statusUnreadCompletionBadge": "اكتملت - انقر للمراجعة", "runningCountBadge": "{count} جلسة قيد التشغيل", "statusCancelledBadge": "ملغى", "worktreeRemovedBadge": "تمت إزالة شجرة العمل الأصلية", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index e86c65284b..8517e4e4f3 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1912,6 +1912,8 @@ "sectionOrderMoveDown": "Nach unten", "sectionOrderItemLabel": "{name} — Position {position} von {total}", "statusRunningBadge": "Läuft", + "statusNeedsAttentionBadge": "Benötigt deine Eingabe", + "statusUnreadCompletionBadge": "Abgeschlossen - zum Prüfen klicken", "runningCountBadge": "{count, plural, one {# laufende Sitzung} other {# laufende Sitzungen}}", "statusCancelledBadge": "Abgebrochen", "worktreeRemovedBadge": "Quell-Worktree entfernt", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index e443fcae3b..1df3e9aa1b 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1912,6 +1912,8 @@ "sectionOrderMoveDown": "Move down", "sectionOrderItemLabel": "{name} — position {position} of {total}", "statusRunningBadge": "Running", + "statusNeedsAttentionBadge": "Needs your input", + "statusUnreadCompletionBadge": "Completed - click to review", "runningCountBadge": "{count, plural, one {# session running} other {# sessions running}}", "statusCancelledBadge": "Cancelled", "worktreeRemovedBadge": "Source worktree removed", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 14d1b1cbae..7fca97f5f7 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1912,6 +1912,8 @@ "sectionOrderMoveDown": "Bajar", "sectionOrderItemLabel": "{name} — posición {position} de {total}", "statusRunningBadge": "Ejecutando", + "statusNeedsAttentionBadge": "Necesita tu respuesta", + "statusUnreadCompletionBadge": "Completado: haz clic para revisar", "runningCountBadge": "{count, plural, one {# sesión en ejecución} other {# sesiones en ejecución}}", "statusCancelledBadge": "Cancelado", "worktreeRemovedBadge": "Worktree de origen eliminado", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index d202efaec5..bfc7a762b0 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1912,6 +1912,8 @@ "sectionOrderMoveDown": "Descendre", "sectionOrderItemLabel": "{name} — position {position} sur {total}", "statusRunningBadge": "En cours", + "statusNeedsAttentionBadge": "Attend votre réponse", + "statusUnreadCompletionBadge": "Terminé - cliquez pour consulter", "runningCountBadge": "{count, plural, one {# session en cours} other {# sessions en cours}}", "statusCancelledBadge": "Annulé", "worktreeRemovedBadge": "Worktree d'origine supprimé", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index ae969c2388..5626908300 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1912,6 +1912,8 @@ "sectionOrderMoveDown": "下へ移動", "sectionOrderItemLabel": "{name} — {total} 件中 {position} 番目", "statusRunningBadge": "実行中", + "statusNeedsAttentionBadge": "入力が必要です", + "statusUnreadCompletionBadge": "完了しました。クリックして確認", "runningCountBadge": "{count} 件の会話が実行中", "statusCancelledBadge": "キャンセル済み", "worktreeRemovedBadge": "元の worktree は削除済み", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index bd275ba99c..d71ca7de0e 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1912,6 +1912,8 @@ "sectionOrderMoveDown": "아래로 이동", "sectionOrderItemLabel": "{name} — {total}개 중 {position}번째", "statusRunningBadge": "실행 중", + "statusNeedsAttentionBadge": "입력이 필요합니다", + "statusUnreadCompletionBadge": "완료됨 - 클릭하여 확인", "runningCountBadge": "{count}개 세션 실행 중", "statusCancelledBadge": "취소됨", "worktreeRemovedBadge": "원본 worktree가 삭제됨", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index daae1a55fb..d1cb4129a3 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1912,6 +1912,8 @@ "sectionOrderMoveDown": "Mover para baixo", "sectionOrderItemLabel": "{name} — posição {position} de {total}", "statusRunningBadge": "Executando", + "statusNeedsAttentionBadge": "Precisa da sua resposta", + "statusUnreadCompletionBadge": "Concluído - clique para revisar", "runningCountBadge": "{count, plural, one {# sessão em execução} other {# sessões em execução}}", "statusCancelledBadge": "Cancelado", "worktreeRemovedBadge": "Worktree de origem removida", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 25dedd6e4b..92b69e43c2 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1912,6 +1912,8 @@ "sectionOrderMoveDown": "下移", "sectionOrderItemLabel": "{name} — 第 {position} 位,共 {total} 位", "statusRunningBadge": "运行中", + "statusNeedsAttentionBadge": "等待你的输入", + "statusUnreadCompletionBadge": "已完成,点击查看", "runningCountBadge": "{count} 个会话进行中", "statusCancelledBadge": "已取消", "worktreeRemovedBadge": "源 worktree 已删除", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 16d8afb045..e52b8a3dd9 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1912,6 +1912,8 @@ "sectionOrderMoveDown": "下移", "sectionOrderItemLabel": "{name} — 第 {position} 位,共 {total} 位", "statusRunningBadge": "運行中", + "statusNeedsAttentionBadge": "等待你的輸入", + "statusUnreadCompletionBadge": "已完成,點擊查看", "runningCountBadge": "{count} 個會話進行中", "statusCancelledBadge": "已取消", "worktreeRemovedBadge": "來源 worktree 已刪除",