diff --git a/backend/internal/adapters/agent/claudecode/activity.go b/backend/internal/adapters/agent/claudecode/activity.go index a0e49119d8..d6fabcf352 100644 --- a/backend/internal/adapters/agent/claudecode/activity.go +++ b/backend/internal/adapters/agent/claudecode/activity.go @@ -21,8 +21,10 @@ func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bo case "user-prompt-submit": return domain.ActivityActive, true case "stop": - // End of a turn: the agent is idle but alive (not exited). A following - // Notification(idle_prompt) upgrades this to the sticky waiting_input. + // End of a turn (including a user interrupt): the agent is idle but + // alive (not exited). A following Notification(idle_prompt) also maps to + // idle, so an interrupted or finished turn reads Idle until the next + // prompt — only a real permission request flips it to waiting_input. return domain.ActivityIdle, true case "notification": return notificationState(payload) @@ -33,18 +35,23 @@ func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bo } } -// notificationState reports waiting_input only for the notification types that -// mean "the agent is blocked on the user": a pending tool-permission prompt or -// an idle prompt awaiting the next instruction. Other types (auth_success, -// elicitation_*) carry no activity meaning, as does a malformed payload. +// notificationState reports waiting_input only when the agent is genuinely +// blocked on the user: a pending tool-permission prompt (permission_prompt). +// idle_prompt means the agent finished its turn and is sitting idle at the +// prompt awaiting the next instruction — that is Idle, not a blocking request, +// so a stop/interrupt reads Idle rather than "Input Needed". Other types +// (auth_success, elicitation_*) carry no activity meaning, as does a malformed +// payload. func notificationState(payload []byte) (domain.ActivityState, bool) { var p struct { NotificationType string `json:"notification_type"` } _ = json.Unmarshal(payload, &p) switch p.NotificationType { - case "idle_prompt", "permission_prompt": + case "permission_prompt": return domain.ActivityWaitingInput, true + case "idle_prompt": + return domain.ActivityIdle, true default: return "", false } diff --git a/backend/internal/adapters/agent/claudecode/activity_test.go b/backend/internal/adapters/agent/claudecode/activity_test.go index c503dc6290..bd8cb881b8 100644 --- a/backend/internal/adapters/agent/claudecode/activity_test.go +++ b/backend/internal/adapters/agent/claudecode/activity_test.go @@ -16,7 +16,7 @@ func TestDeriveActivityState(t *testing.T) { }{ {"user prompt -> active", "user-prompt-submit", `{}`, domain.ActivityActive, true}, {"stop -> idle", "stop", `{}`, domain.ActivityIdle, true}, - {"notification idle_prompt -> waiting_input", "notification", `{"notification_type":"idle_prompt"}`, domain.ActivityWaitingInput, true}, + {"notification idle_prompt -> idle", "notification", `{"notification_type":"idle_prompt"}`, domain.ActivityIdle, true}, {"notification permission_prompt -> waiting_input", "notification", `{"notification_type":"permission_prompt"}`, domain.ActivityWaitingInput, true}, {"notification auth_success -> no signal", "notification", `{"notification_type":"auth_success"}`, "", false}, {"notification empty type -> no signal", "notification", `{}`, "", false}, diff --git a/backend/internal/cli/hooks_test.go b/backend/internal/cli/hooks_test.go index 25723f87eb..91260def66 100644 --- a/backend/internal/cli/hooks_test.go +++ b/backend/internal/cli/hooks_test.go @@ -62,7 +62,7 @@ func TestHooks_NotificationReportsWaitingInput(t *testing.T) { writeRunFileFor(t, cfg, srv) _, errOut, err := executeCLI(t, Deps{ - In: strings.NewReader(`{"notification_type":"idle_prompt"}`), + In: strings.NewReader(`{"notification_type":"permission_prompt"}`), ProcessAlive: func(int) bool { return true }, }, "hooks", "claude-code", "notification") if err != nil { @@ -76,6 +76,24 @@ func TestHooks_NotificationReportsWaitingInput(t *testing.T) { } } +func TestHooks_IdlePromptReportsIdle(t *testing.T) { + t.Setenv("AO_SESSION_ID", "ao-7") + cfg := setConfigEnv(t) + srv, capture := activityServer(t, http.StatusOK, `{"ok":true,"sessionId":"ao-7","state":"idle"}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + In: strings.NewReader(`{"notification_type":"idle_prompt"}`), + ProcessAlive: func(int) bool { return true }, + }, "hooks", "claude-code", "notification") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if got := capturedState(t, capture); got != "idle" { + t.Errorf("state = %q, want idle (idle_prompt is not a blocking request)", got) + } +} + func TestHooks_SessionEndReportsExited(t *testing.T) { t.Setenv("AO_SESSION_ID", "ao-7") cfg := setConfigEnv(t) diff --git a/docs/screenshots/pr-2541/live-idle.png b/docs/screenshots/pr-2541/live-idle.png new file mode 100644 index 0000000000..a1b828e990 Binary files /dev/null and b/docs/screenshots/pr-2541/live-idle.png differ diff --git a/docs/screenshots/pr-2541/live-working.png b/docs/screenshots/pr-2541/live-working.png new file mode 100644 index 0000000000..6fa49fc5bb Binary files /dev/null and b/docs/screenshots/pr-2541/live-working.png differ diff --git a/docs/screenshots/pr-2541/topbar-activity-states-all.png b/docs/screenshots/pr-2541/topbar-activity-states-all.png new file mode 100644 index 0000000000..b0ccabbf65 Binary files /dev/null and b/docs/screenshots/pr-2541/topbar-activity-states-all.png differ diff --git a/frontend/src/renderer/components/ShellTopbar.test.tsx b/frontend/src/renderer/components/ShellTopbar.test.tsx index 27b9733aef..bc054ccc1d 100644 --- a/frontend/src/renderer/components/ShellTopbar.test.tsx +++ b/frontend/src/renderer/components/ShellTopbar.test.tsx @@ -2,12 +2,29 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { WorkspaceSession } from "../types/workspace"; -import { TopbarKillButton } from "./ShellTopbar"; +import type { SessionActivityState, WorkspaceSession, WorkspaceSummary } from "../types/workspace"; +import { ShellTopbar, TopbarKillButton } from "./ShellTopbar"; -const { onKilledMock, postMock } = vi.hoisted(() => ({ +const { navigateMock, onKilledMock, paramsMock, postMock, useWorkspaceQueryMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), onKilledMock: vi.fn(), + paramsMock: { projectId: undefined as string | undefined, sessionId: undefined as string | undefined }, postMock: vi.fn(), + useWorkspaceQueryMock: vi.fn(), +})); + +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => navigateMock, + useParams: () => paramsMock, + }; +}); + +vi.mock("../hooks/useWorkspaceQuery", () => ({ + useWorkspaceQuery: () => useWorkspaceQueryMock(), + workspaceQueryKey: ["workspaces"], })); vi.mock("../lib/api-client", () => ({ @@ -23,6 +40,15 @@ vi.mock("../lib/api-client", () => ({ }, })); +vi.mock("../lib/spawn-orchestrator", () => ({ spawnOrchestrator: vi.fn() })); +vi.mock("../lib/telemetry", () => ({ + addRendererExceptionStep: vi.fn(), + captureRendererEvent: vi.fn(), + captureRendererException: vi.fn(), +})); +vi.mock("./NewTaskDialog", () => ({ NewTaskDialog: () => null })); +vi.mock("./NotificationCenter", () => ({ NotificationCenter: () => null })); + const worker: WorkspaceSession = { id: "sess-1", workspaceId: "proj-1", @@ -49,6 +75,33 @@ const orchestrator: WorkspaceSession = { prs: [], }; +function sessionWith(overrides: Partial = {}): WorkspaceSession { + return { + ...worker, + activity: { state: "active", lastActivityAt: "2026-06-10T00:00:00Z" }, + ...overrides, + }; +} + +function renderTopbar(session: WorkspaceSession) { + const data: WorkspaceSummary[] = [ + { + id: session.workspaceId, + name: session.workspaceName, + path: "/repo/my-app", + sessions: [session], + }, + ]; + useWorkspaceQueryMock.mockReturnValue({ data, isError: false, isLoading: false }); + paramsMock.projectId = session.workspaceId; + paramsMock.sessionId = session.id; + return render( + + + , + ); +} + function renderKill(session: WorkspaceSession = worker, orchestratorId?: string) { const queryClient = new QueryClient({ defaultOptions: { @@ -65,9 +118,57 @@ function renderKill(session: WorkspaceSession = worker, orchestratorId?: string) } beforeEach(() => { + navigateMock.mockReset(); onKilledMock.mockReset(); + paramsMock.projectId = undefined; + paramsMock.sessionId = undefined; postMock.mockReset(); postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined }); + useWorkspaceQueryMock.mockReset(); + useWorkspaceQueryMock.mockReturnValue({ data: [], isError: false, isLoading: false }); +}); + +describe("ShellTopbar status pill", () => { + it.each([ + ["active", "Working"], + ["idle", "Idle"], + ["waiting_input", "Input Needed"], + ["exited", "Exited"], + ] as const)("renders %s activity as %s", (state: SessionActivityState, label) => { + renderTopbar(sessionWith({ activity: { state, lastActivityAt: "2026-06-10T00:00:00Z" } })); + + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it.each([ + ["ci_failed", "ci_failed", "idle", "Idle", "CI failed"], + ["mergeable", "mergeable", "active", "Working", "Ready"], + ["merged", "done", "exited", "Exited", "Done"], + ["changes_requested", "needs_you", "waiting_input", "Input Needed", "Needs input"], + ] as const)( + "ignores coarse %s/%s topbar status in favor of activity", + (status, displayStatus, state, label, hidden) => { + renderTopbar( + sessionWith({ + status, + displayStatus, + activity: { state, lastActivityAt: "2026-06-10T00:00:00Z" }, + }), + ); + + expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.queryByText(hidden)).not.toBeInTheDocument(); + }, + ); + + it("uses a compact unknown state when activity is missing or unknown", () => { + const first = renderTopbar(sessionWith({ activity: undefined })); + expect(screen.getByText("Unknown")).toBeInTheDocument(); + + first.unmount(); + renderTopbar(sessionWith({ activity: { state: "unknown", lastActivityAt: "" } })); + expect(screen.getByText("Unknown")).toBeInTheDocument(); + }); }); describe("TopbarKillButton", () => { diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx index 9dae74f168..2c47251679 100644 --- a/frontend/src/renderer/components/ShellTopbar.tsx +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -7,8 +7,7 @@ import { findProjectOrchestrator, isOrchestratorSession, sessionIsActive, - workerDisplayStatus, - type WorkerDisplayStatus, + type SessionActivityState, type WorkspaceSession, } from "../types/workspace"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; @@ -29,16 +28,13 @@ const isLinux = const dragStyle = isMac ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined; const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined; -// Session status → pill tone, mirroring agent-orchestrator's StatusBadge -// (working=orange & breathing, input=amber, fail=red, ready=green, done=neutral). -// Tones are theme vars so the pill tracks the light/dark status palettes. -const STATUS_PILL: Record = { - working: { label: "Working", tone: "var(--orange)", breathe: true }, - needs_you: { label: "Needs input", tone: "var(--amber)", breathe: false }, - ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false }, - no_signal: { label: "No signal", tone: "var(--fg-muted)", breathe: false }, - mergeable: { label: "Ready", tone: "var(--green)", breathe: false }, - done: { label: "Done", tone: "var(--fg-muted)", breathe: false }, +// Topbar shows only the raw agent activity state. SCM/context badges stay in +// the inspector Summary > Activity row. +const TOPBAR_ACTIVITY_PILL: Record = { + active: { label: "Working", tone: "var(--orange)", breathe: true }, + idle: { label: "Idle", tone: "var(--fg-muted)", breathe: false }, + waiting_input: { label: "Input Needed", tone: "var(--amber)", breathe: false }, + exited: { label: "Exited", tone: "var(--fg-muted)", breathe: false }, unknown: { label: "Unknown", tone: "var(--fg-muted)", breathe: false }, }; @@ -353,7 +349,8 @@ export function TopbarKillButton({ // StatusBadge --pill: tinted bordered pill (inset 25%-tone hairline + 7%-tone // fill) with a 6px dot that breathes while the agent is working. function SessionStatusPill({ session }: { session: WorkspaceSession }) { - const { label, tone, breathe } = STATUS_PILL[workerDisplayStatus(session)]; + const activityState = session.activity?.state ?? "unknown"; + const { label, tone, breathe } = TOPBAR_ACTIVITY_PILL[activityState]; return (