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 { TauriEmbedderGateway } from "./embedder";
|
||||||
import { TauriGitGateway } from "./git";
|
import { TauriGitGateway } from "./git";
|
||||||
import { TauriPermissionGateway } from "./permission";
|
import { TauriPermissionGateway } from "./permission";
|
||||||
|
import { TauriWorkStateGateway } from "./workState";
|
||||||
|
|
||||||
function notImplemented(what: string): never {
|
function notImplemented(what: string): never {
|
||||||
const err: GatewayError = {
|
const err: GatewayError = {
|
||||||
@ -57,6 +58,7 @@ export function createTauriGateways(): Gateways {
|
|||||||
memory: new TauriMemoryGateway(),
|
memory: new TauriMemoryGateway(),
|
||||||
embedder: new TauriEmbedderGateway(),
|
embedder: new TauriEmbedderGateway(),
|
||||||
permission: new TauriPermissionGateway(),
|
permission: new TauriPermissionGateway(),
|
||||||
|
workState: new TauriWorkStateGateway(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,4 +76,5 @@ export {
|
|||||||
TauriEmbedderGateway,
|
TauriEmbedderGateway,
|
||||||
TauriGitGateway,
|
TauriGitGateway,
|
||||||
TauriPermissionGateway,
|
TauriPermissionGateway,
|
||||||
|
TauriWorkStateGateway,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import type {
|
|||||||
PermissionSet,
|
PermissionSet,
|
||||||
Project,
|
Project,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
|
ProjectWorkState,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
Skill,
|
Skill,
|
||||||
@ -64,6 +65,7 @@ import type {
|
|||||||
TemplateGateway,
|
TemplateGateway,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
|
WorkStateGateway,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
|
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(
|
function mostRestrictive(
|
||||||
project?: PermissionSet["fallback"],
|
project?: PermissionSet["fallback"],
|
||||||
agent?: PermissionSet["fallback"],
|
agent?: PermissionSet["fallback"],
|
||||||
@ -1676,6 +1691,7 @@ export function createMockGateways(): Gateways {
|
|||||||
memory: new MockMemoryGateway(),
|
memory: new MockMemoryGateway(),
|
||||||
embedder: new MockEmbedderGateway(),
|
embedder: new MockEmbedderGateway(),
|
||||||
permission: new MockPermissionGateway(),
|
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;
|
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)
|
// Permissions (LP1)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import { SkillsPanel } from "@/features/skills";
|
|||||||
import { MemoryPanel } from "@/features/memory";
|
import { MemoryPanel } from "@/features/memory";
|
||||||
import { EmbedderSettings } from "@/features/embedder";
|
import { EmbedderSettings } from "@/features/embedder";
|
||||||
import { PermissionsPanel } from "@/features/permissions";
|
import { PermissionsPanel } from "@/features/permissions";
|
||||||
|
import { ProjectWorkStatePanel } from "@/features/workstate";
|
||||||
import { GitPanel, GitGraphView } from "@/features/git";
|
import { GitPanel, GitGraphView } from "@/features/git";
|
||||||
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
@ -47,6 +48,7 @@ import { useProjects } from "./useProjects";
|
|||||||
type SidebarTab =
|
type SidebarTab =
|
||||||
| "projects"
|
| "projects"
|
||||||
| "context"
|
| "context"
|
||||||
|
| "work"
|
||||||
| "agents"
|
| "agents"
|
||||||
| "templates"
|
| "templates"
|
||||||
| "skills"
|
| "skills"
|
||||||
@ -57,6 +59,7 @@ type SidebarTab =
|
|||||||
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||||||
{ id: "projects", label: "Projects" },
|
{ id: "projects", label: "Projects" },
|
||||||
{ id: "context", label: "Context" },
|
{ id: "context", label: "Context" },
|
||||||
|
{ id: "work", label: "Work" },
|
||||||
{ id: "agents", label: "Agents" },
|
{ id: "agents", label: "Agents" },
|
||||||
{ id: "templates", label: "Templates" },
|
{ id: "templates", label: "Templates" },
|
||||||
{ id: "skills", label: "Skills" },
|
{ id: "skills", label: "Skills" },
|
||||||
@ -282,6 +285,14 @@ export function ProjectsView() {
|
|||||||
<p className="text-sm text-muted">Open a project to edit context.</p>
|
<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 */}
|
{/* Agents panel — only rendered when active project exists */}
|
||||||
{sidebarTab === "agents" && active && (
|
{sidebarTab === "agents" && active && (
|
||||||
<AgentsPanel projectId={active.id} projectRoot={active.root} />
|
<AgentsPanel projectId={active.id} projectRoot={active.root} />
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
fireEvent,
|
fireEvent,
|
||||||
} from "@testing-library/react";
|
} 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 type { Gateways } from "@/ports";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { ProjectsView } from "./ProjectsView";
|
import { ProjectsView } from "./ProjectsView";
|
||||||
@ -33,6 +33,7 @@ function renderView(
|
|||||||
profile: new MockProfileGateway(),
|
profile: new MockProfileGateway(),
|
||||||
template: new MockTemplateGateway(agentGateway),
|
template: new MockTemplateGateway(agentGateway),
|
||||||
git: new MockGitGateway(),
|
git: new MockGitGateway(),
|
||||||
|
workState: new MockWorkStateGateway(),
|
||||||
} as unknown as Gateways;
|
} as unknown as Gateways;
|
||||||
return {
|
return {
|
||||||
project,
|
project,
|
||||||
@ -130,6 +131,20 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
expect([a.id, b.id]).toHaveLength(2);
|
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 () => {
|
it("closing a tab removes it from the tab bar", async () => {
|
||||||
renderView();
|
renderView();
|
||||||
await createProject("alpha", "/home/me/alpha");
|
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,
|
PermissionSet,
|
||||||
Project,
|
Project,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
|
ProjectWorkState,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
Skill,
|
Skill,
|
||||||
@ -639,6 +640,12 @@ export interface PermissionGateway {
|
|||||||
): Promise<EffectivePermissions | null>;
|
): 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 full set of gateways the app depends on, injected via the DI provider.
|
||||||
* The composition (real vs mock) is chosen in `app/`.
|
* The composition (real vs mock) is chosen in `app/`.
|
||||||
@ -658,4 +665,5 @@ export interface Gateways {
|
|||||||
memory: MemoryGateway;
|
memory: MemoryGateway;
|
||||||
embedder: EmbedderGateway;
|
embedder: EmbedderGateway;
|
||||||
permission: PermissionGateway;
|
permission: PermissionGateway;
|
||||||
|
workState: WorkStateGateway;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user