feat(workstate): UI live-state des conversations/délégations (Lot A frontend)
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 <noreply@anthropic.com>
This commit is contained in:
@ -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,
|
||||
};
|
||||
|
||||
@ -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<string, ProjectWorkState>();
|
||||
|
||||
/** 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<ProjectWorkState> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
17
frontend/src/adapters/workState.ts
Normal file
17
frontend/src/adapters/workState.ts
Normal file
@ -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<ProjectWorkState> {
|
||||
return invoke<ProjectWorkState>("get_project_work_state", { projectId });
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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() {
|
||||
<p className="text-sm text-muted">Open a project to edit context.</p>
|
||||
)}
|
||||
|
||||
{/* Work-state panel */}
|
||||
{sidebarTab === "work" && active && (
|
||||
<ProjectWorkStatePanel projectId={active.id} />
|
||||
)}
|
||||
{sidebarTab === "work" && !active && (
|
||||
<p className="text-sm text-muted">Open a project to view work state.</p>
|
||||
)}
|
||||
|
||||
{/* Agents panel — only rendered when active project exists */}
|
||||
{sidebarTab === "agents" && active && (
|
||||
<AgentsPanel projectId={active.id} projectRoot={active.root} />
|
||||
|
||||
@ -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");
|
||||
|
||||
106
frontend/src/features/workstate/ProjectWorkStatePanel.tsx
Normal file
106
frontend/src/features/workstate/ProjectWorkStatePanel.tsx
Normal file
@ -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 (
|
||||
<li className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0">
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium text-content">
|
||||
{agent.name}
|
||||
</span>
|
||||
<code className="truncate text-xs text-muted">{agent.profileId}</code>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
live
|
||||
? "bg-success/15 text-success"
|
||||
: "bg-raised text-muted",
|
||||
)}
|
||||
>
|
||||
{live ? "Live" : "Offline"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
busy
|
||||
? "bg-warning/15 text-warning"
|
||||
: "bg-raised text-muted",
|
||||
)}
|
||||
>
|
||||
{busy ? "Busy" : "Idle"}
|
||||
</span>
|
||||
{agent.busy.state === "busy" && (
|
||||
<code
|
||||
aria-label={`busy ticket ${shortTicket(agent.busy.ticket)}`}
|
||||
title={agent.busy.ticket}
|
||||
className="rounded bg-raised px-1.5 py-0.5 text-xs text-muted"
|
||||
>
|
||||
{shortTicket(agent.busy.ticket)}
|
||||
</code>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) {
|
||||
const vm = useProjectWorkState(projectId);
|
||||
const agents = vm.state?.agents ?? [];
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Work"
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void vm.refresh()}
|
||||
loading={vm.busy}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
{vm.busy && vm.state === null ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>Loading work state…</span>
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<p className="text-sm text-muted">No agent work state.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{agents.map((agent) => (
|
||||
<AgentRow key={agent.agentId} agent={agent} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
6
frontend/src/features/workstate/index.ts
Normal file
6
frontend/src/features/workstate/index.ts
Normal file
@ -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";
|
||||
73
frontend/src/features/workstate/useProjectWorkState.ts
Normal file
73
frontend/src/features/workstate/useProjectWorkState.ts
Normal file
@ -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<void>;
|
||||
}
|
||||
|
||||
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<ProjectWorkState | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
111
frontend/src/features/workstate/workstate.test.tsx
Normal file
111
frontend/src/features/workstate/workstate.test.tsx
Normal file
@ -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(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectWorkStatePanel projectId={PROJECT_ID} />
|
||||
</DIProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
@ -33,6 +33,7 @@ import type {
|
||||
PermissionSet,
|
||||
Project,
|
||||
ProjectPermissions,
|
||||
ProjectWorkState,
|
||||
ProfileAvailability,
|
||||
ResumableAgent,
|
||||
Skill,
|
||||
@ -639,6 +640,12 @@ export interface PermissionGateway {
|
||||
): Promise<EffectivePermissions | null>;
|
||||
}
|
||||
|
||||
/** 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<ProjectWorkState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user