Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions backend/internal/adapters/agent/claudecode/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
20 changes: 19 additions & 1 deletion backend/internal/cli/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
Binary file added docs/screenshots/pr-2541/live-idle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/pr-2541/live-working.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
107 changes: 104 additions & 3 deletions frontend/src/renderer/components/ShellTopbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: () => navigateMock,
useParams: () => paramsMock,
};
});

vi.mock("../hooks/useWorkspaceQuery", () => ({
useWorkspaceQuery: () => useWorkspaceQueryMock(),
workspaceQueryKey: ["workspaces"],
}));

vi.mock("../lib/api-client", () => ({
Expand All @@ -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",
Expand All @@ -49,6 +75,33 @@ const orchestrator: WorkspaceSession = {
prs: [],
};

function sessionWith(overrides: Partial<WorkspaceSession> = {}): 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(
<QueryClientProvider client={new QueryClient()}>
<ShellTopbar />
</QueryClientProvider>,
);
}

function renderKill(session: WorkspaceSession = worker, orchestratorId?: string) {
const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -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", () => {
Expand Down
23 changes: 10 additions & 13 deletions frontend/src/renderer/components/ShellTopbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import {
findProjectOrchestrator,
isOrchestratorSession,
sessionIsActive,
workerDisplayStatus,
type WorkerDisplayStatus,
type SessionActivityState,
type WorkspaceSession,
} from "../types/workspace";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
Expand All @@ -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<WorkerDisplayStatus, { label: string; tone: string; breathe: boolean }> = {
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<SessionActivityState, { label: string; tone: string; breathe: boolean }> = {
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 },
};

Expand Down Expand Up @@ -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 (
<span
className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold leading-none"
Expand Down
Loading