From 17685a08e1e121c77a02714bd7cb4752c940a770 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 20 Jun 2026 18:06:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(workstate):=20UI=20live-state=20des=20conv?= =?UTF-8?q?ersations/d=C3=A9l=C3=A9gations=20(Lot=20A=20frontend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute le port et l'adaptateur workState (+ mock) côté frontend, le type domaine associé, et la feature `workstate` (panneau ProjectWorkStatePanel + hook useProjectWorkState) consommant le read-model live exposé par le backend. Intègre le panneau dans ProjectsView. Tests verts (workstate + projects). Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/index.ts | 3 + frontend/src/adapters/mock/index.ts | 16 +++ frontend/src/adapters/workState.ts | 17 +++ frontend/src/domain/index.ts | 30 +++++ .../src/features/projects/ProjectsView.tsx | 11 ++ .../src/features/projects/projects.test.tsx | 17 ++- .../workstate/ProjectWorkStatePanel.tsx | 106 +++++++++++++++++ frontend/src/features/workstate/index.ts | 6 + .../features/workstate/useProjectWorkState.ts | 73 ++++++++++++ .../src/features/workstate/workstate.test.tsx | 111 ++++++++++++++++++ frontend/src/ports/index.ts | 8 ++ 11 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 frontend/src/adapters/workState.ts create mode 100644 frontend/src/features/workstate/ProjectWorkStatePanel.tsx create mode 100644 frontend/src/features/workstate/index.ts create mode 100644 frontend/src/features/workstate/useProjectWorkState.ts create mode 100644 frontend/src/features/workstate/workstate.test.tsx diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index bf00690..3763702 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -25,6 +25,7 @@ import { TauriMemoryGateway } from "./memory"; import { TauriEmbedderGateway } from "./embedder"; import { TauriGitGateway } from "./git"; import { TauriPermissionGateway } from "./permission"; +import { TauriWorkStateGateway } from "./workState"; function notImplemented(what: string): never { const err: GatewayError = { @@ -57,6 +58,7 @@ export function createTauriGateways(): Gateways { memory: new TauriMemoryGateway(), embedder: new TauriEmbedderGateway(), permission: new TauriPermissionGateway(), + workState: new TauriWorkStateGateway(), }; } @@ -74,4 +76,5 @@ export { TauriEmbedderGateway, TauriGitGateway, TauriPermissionGateway, + TauriWorkStateGateway, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 7c04261..65a3b89 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -31,6 +31,7 @@ import type { PermissionSet, Project, ProjectPermissions, + ProjectWorkState, ProfileAvailability, ResumableAgent, Skill, @@ -64,6 +65,7 @@ import type { TemplateGateway, TerminalGateway, TerminalHandle, + WorkStateGateway, } from "@/ports"; import { applyOperation, singleLeafTree } from "@/features/layout/layout"; @@ -1648,6 +1650,19 @@ export class MockPermissionGateway implements PermissionGateway { } } +export class MockWorkStateGateway implements WorkStateGateway { + private states = new Map(); + + /** Seeds the read-model returned for a project (deterministic tests/dev). */ + _setProjectWorkState(projectId: string, state: ProjectWorkState): void { + this.states.set(projectId, structuredClone(state)); + } + + async getProjectWorkState(projectId: string): Promise { + return structuredClone(this.states.get(projectId) ?? { agents: [] }); + } +} + function mostRestrictive( project?: PermissionSet["fallback"], agent?: PermissionSet["fallback"], @@ -1676,6 +1691,7 @@ export function createMockGateways(): Gateways { memory: new MockMemoryGateway(), embedder: new MockEmbedderGateway(), permission: new MockPermissionGateway(), + workState: new MockWorkStateGateway(), }; } diff --git a/frontend/src/adapters/workState.ts b/frontend/src/adapters/workState.ts new file mode 100644 index 0000000..cde9d38 --- /dev/null +++ b/frontend/src/adapters/workState.ts @@ -0,0 +1,17 @@ +/** + * Tauri adapter for {@link WorkStateGateway}. + * + * This is the only frontend place that knows the `get_project_work_state` + * command name; features consume the gateway port through DI. + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { ProjectWorkState } from "@/domain"; +import type { WorkStateGateway } from "@/ports"; + +export class TauriWorkStateGateway implements WorkStateGateway { + getProjectWorkState(projectId: string): Promise { + return invoke("get_project_work_state", { projectId }); + } +} diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 5bab372..06c9adb 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -133,6 +133,36 @@ export interface GatewayError { message: string; } +// --------------------------------------------------------------------------- +// Work state (UX conversations/delegations live read-model) +// --------------------------------------------------------------------------- + +/** Live session currently associated with an agent in the work-state read-model. */ +export interface LiveWorkSession { + nodeId: string; + sessionId: string; + kind: "pty" | "structured"; +} + +/** Busy/idle status for an agent in the work-state read-model. */ +export type WorkBusyState = + | { state: "idle" } + | { state: "busy"; ticket: string; sinceMs: number }; + +/** One agent row in the project work-state read-model. */ +export interface AgentWorkState { + agentId: string; + name: string; + profileId: string; + live?: LiveWorkSession; + busy: WorkBusyState; +} + +/** Minimal read-only live-state surface for a project. */ +export interface ProjectWorkState { + agents: AgentWorkState[]; +} + // --------------------------------------------------------------------------- // Permissions (LP1) // --------------------------------------------------------------------------- diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 4993699..0e3478e 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -38,6 +38,7 @@ import { SkillsPanel } from "@/features/skills"; import { MemoryPanel } from "@/features/memory"; import { EmbedderSettings } from "@/features/embedder"; import { PermissionsPanel } from "@/features/permissions"; +import { ProjectWorkStatePanel } from "@/features/workstate"; import { GitPanel, GitGraphView } from "@/features/git"; import { Button, Input, Panel, Tabs, cn } from "@/shared"; import { useGateways } from "@/app/di"; @@ -47,6 +48,7 @@ import { useProjects } from "./useProjects"; type SidebarTab = | "projects" | "context" + | "work" | "agents" | "templates" | "skills" @@ -57,6 +59,7 @@ type SidebarTab = const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [ { id: "projects", label: "Projects" }, { id: "context", label: "Context" }, + { id: "work", label: "Work" }, { id: "agents", label: "Agents" }, { id: "templates", label: "Templates" }, { id: "skills", label: "Skills" }, @@ -282,6 +285,14 @@ export function ProjectsView() {

Open a project to edit context.

)} + {/* Work-state panel */} + {sidebarTab === "work" && active && ( + + )} + {sidebarTab === "work" && !active && ( +

Open a project to view work state.

+ )} + {/* Agents panel — only rendered when active project exists */} {sidebarTab === "agents" && active && ( diff --git a/frontend/src/features/projects/projects.test.tsx b/frontend/src/features/projects/projects.test.tsx index fac8e55..09842e9 100644 --- a/frontend/src/features/projects/projects.test.tsx +++ b/frontend/src/features/projects/projects.test.tsx @@ -12,7 +12,7 @@ import { fireEvent, } from "@testing-library/react"; -import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway } from "@/adapters/mock"; +import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, MockWorkStateGateway } from "@/adapters/mock"; import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { ProjectsView } from "./ProjectsView"; @@ -33,6 +33,7 @@ function renderView( profile: new MockProfileGateway(), template: new MockTemplateGateway(agentGateway), git: new MockGitGateway(), + workState: new MockWorkStateGateway(), } as unknown as Gateways; return { project, @@ -130,6 +131,20 @@ describe("ProjectsView (with MockProjectGateway)", () => { expect([a.id, b.id]).toHaveLength(2); }); + it("shows and renders the Work sidebar tab for an active project", async () => { + const project = new MockProjectGateway(); + await project.createProject("alpha", "/p/a"); + renderView(project); + + await screen.findByText("/p/a"); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + await screen.findByRole("tab", { name: "alpha" }); + + fireEvent.click(screen.getByRole("button", { name: "Work" })); + + expect(await screen.findByText("No agent work state.")).toBeTruthy(); + }); + it("closing a tab removes it from the tab bar", async () => { renderView(); await createProject("alpha", "/home/me/alpha"); diff --git a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx new file mode 100644 index 0000000..53629d9 --- /dev/null +++ b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx @@ -0,0 +1,106 @@ +/** + * Read-only project work-state panel: one row per agent, showing live/offline + * and idle/busy state from the backend read-model. + */ + +import type { AgentWorkState } from "@/domain"; +import { Button, Panel, Spinner, cn } from "@/shared"; +import { useProjectWorkState } from "./useProjectWorkState"; + +export interface ProjectWorkStatePanelProps { + projectId: string; +} + +function shortTicket(ticket: string): string { + return ticket.length <= 8 ? ticket : ticket.slice(0, 8); +} + +function AgentRow({ agent }: { agent: AgentWorkState }) { + const live = agent.live !== undefined; + const busy = agent.busy.state === "busy"; + return ( +
  • + + + {agent.name} + + {agent.profileId} + + + + {live ? "Live" : "Offline"} + + + {busy ? "Busy" : "Idle"} + + {agent.busy.state === "busy" && ( + + {shortTicket(agent.busy.ticket)} + + )} + +
  • + ); +} + +export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) { + const vm = useProjectWorkState(projectId); + const agents = vm.state?.agents ?? []; + + return ( + void vm.refresh()} + loading={vm.busy} + > + Refresh + + } + > + {vm.error && ( +

    + {vm.error} +

    + )} + {vm.busy && vm.state === null ? ( +
    + + Loading work state… +
    + ) : agents.length === 0 ? ( +

    No agent work state.

    + ) : ( +
      + {agents.map((agent) => ( + + ))} +
    + )} +
    + ); +} diff --git a/frontend/src/features/workstate/index.ts b/frontend/src/features/workstate/index.ts new file mode 100644 index 0000000..8ba5e3a --- /dev/null +++ b/frontend/src/features/workstate/index.ts @@ -0,0 +1,6 @@ +/** Work-state feature: read-only live/busy read-model for a project. */ + +export { ProjectWorkStatePanel } from "./ProjectWorkStatePanel"; +export type { ProjectWorkStatePanelProps } from "./ProjectWorkStatePanel"; +export { useProjectWorkState } from "./useProjectWorkState"; +export type { ProjectWorkStateViewModel } from "./useProjectWorkState"; diff --git a/frontend/src/features/workstate/useProjectWorkState.ts b/frontend/src/features/workstate/useProjectWorkState.ts new file mode 100644 index 0000000..035e663 --- /dev/null +++ b/frontend/src/features/workstate/useProjectWorkState.ts @@ -0,0 +1,73 @@ +/** + * View-model hook for the project work-state read-model. + * + * It consumes the WorkStateGateway only, and optionally subscribes to existing + * domain events through SystemGateway to refresh the read-only snapshot. + */ + +import { useCallback, useEffect, useState } from "react"; + +import type { GatewayError, ProjectWorkState } from "@/domain"; +import { useGateways } from "@/app/di"; + +export interface ProjectWorkStateViewModel { + state: ProjectWorkState | null; + error: string | null; + busy: boolean; + refresh: () => Promise; +} + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +export function useProjectWorkState(projectId: string): ProjectWorkStateViewModel { + const { workState, system } = useGateways(); + const [state, setState] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + setBusy(true); + setError(null); + try { + setState(await workState.getProjectWorkState(projectId)); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, [projectId, workState]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (!system) return; + let unsubscribe: (() => void) | undefined; + let cancelled = false; + void system.onDomainEvent((event) => { + if ( + event.type === "agentLaunched" || + event.type === "agentExited" || + event.type === "agentBusyChanged" || + event.type === "orchestratorRequestProcessed" + ) { + void refresh(); + } + }).then((u) => { + if (cancelled) u(); + else unsubscribe = u; + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [refresh, system]); + + return { state, error, busy, refresh }; +} diff --git a/frontend/src/features/workstate/workstate.test.tsx b/frontend/src/features/workstate/workstate.test.tsx new file mode 100644 index 0000000..20dec69 --- /dev/null +++ b/frontend/src/features/workstate/workstate.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; + +import { DIProvider } from "@/app/di"; +import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock"; +import type { Gateways } from "@/ports"; +import { ProjectWorkStatePanel } from "./ProjectWorkStatePanel"; + +const PROJECT_ID = "project-work-state-test"; + +function renderPanel( + workState: MockWorkStateGateway = new MockWorkStateGateway(), + system: MockSystemGateway = new MockSystemGateway(), +) { + const gateways = { workState, system } as unknown as Gateways; + return { + workState, + system, + ...render( + + + , + ), + }; +} + +describe("ProjectWorkStatePanel", () => { + it("renders the empty state", async () => { + renderPanel(); + + expect(await screen.findByText("No agent work state.")).toBeTruthy(); + }); + + it("renders an idle offline agent", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(PROJECT_ID, { + agents: [ + { + agentId: "agent-1", + name: "Planner", + profileId: "codex", + busy: { state: "idle" }, + }, + ], + }); + + renderPanel(workState); + + expect(await screen.findByText("Planner")).toBeTruthy(); + expect(screen.getByText("Offline")).toBeTruthy(); + expect(screen.getByText("Idle")).toBeTruthy(); + }); + + it("renders a live busy agent with a short ticket", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(PROJECT_ID, { + agents: [ + { + agentId: "agent-2", + name: "Builder", + profileId: "claude", + live: { + nodeId: "node-a", + sessionId: "session-a", + kind: "pty", + }, + busy: { + state: "busy", + ticket: "4c65d981-da5e-412d-85ed-3b9779b670fa", + sinceMs: 123, + }, + }, + ], + }); + + renderPanel(workState); + + expect(await screen.findByText("Builder")).toBeTruthy(); + expect(screen.getByText("Live")).toBeTruthy(); + expect(screen.getByText("Busy")).toBeTruthy(); + expect(screen.getByLabelText("busy ticket 4c65d981")).toBeTruthy(); + }); + + it("refreshes when a relevant domain event fires", async () => { + const workState = new MockWorkStateGateway(); + const system = new MockSystemGateway(); + renderPanel(workState, system); + + await screen.findByText("No agent work state."); + const spy = vi.spyOn(workState, "getProjectWorkState"); + workState._setProjectWorkState(PROJECT_ID, { + agents: [ + { + agentId: "agent-3", + name: "Responder", + profileId: "gemini", + busy: { state: "busy", ticket: "ticket-123456789", sinceMs: 456 }, + }, + ], + }); + + system.emit({ + type: "agentBusyChanged", + agentId: "agent-3", + busy: true, + }); + + expect(await screen.findByText("Responder")).toBeTruthy(); + await waitFor(() => expect(spy).toHaveBeenCalled()); + }); +}); diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 61a1c88..0b463de 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -33,6 +33,7 @@ import type { PermissionSet, Project, ProjectPermissions, + ProjectWorkState, ProfileAvailability, ResumableAgent, Skill, @@ -639,6 +640,12 @@ export interface PermissionGateway { ): Promise; } +/** Read-only live work-state read-model for conversations/delegations. */ +export interface WorkStateGateway { + /** Reads the current per-agent live/offline and idle/busy state for a project. */ + getProjectWorkState(projectId: string): Promise; +} + /** * The full set of gateways the app depends on, injected via the DI provider. * The composition (real vs mock) is chosen in `app/`. @@ -658,4 +665,5 @@ export interface Gateways { memory: MemoryGateway; embedder: EmbedderGateway; permission: PermissionGateway; + workState: WorkStateGateway; }