This commit is contained in:
@ -48,8 +48,10 @@ if (typeof globalThis.ResizeObserver === "undefined") {
|
|||||||
import type { DomainEvent } from "@/domain";
|
import type { DomainEvent } from "@/domain";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import {
|
import {
|
||||||
|
MOCK_REFERENCE_PROFILES,
|
||||||
MockAgentGateway,
|
MockAgentGateway,
|
||||||
MockLayoutGateway,
|
MockLayoutGateway,
|
||||||
|
MockProfileGateway,
|
||||||
MockSystemGateway,
|
MockSystemGateway,
|
||||||
MockTerminalGateway,
|
MockTerminalGateway,
|
||||||
} from "@/adapters/mock";
|
} from "@/adapters/mock";
|
||||||
@ -69,12 +71,14 @@ interface GridSetup {
|
|||||||
async function makeSplitAgentGrid(): Promise<GridSetup> {
|
async function makeSplitAgentGrid(): Promise<GridSetup> {
|
||||||
const layout = new MockLayoutGateway();
|
const layout = new MockLayoutGateway();
|
||||||
const agentGateway = new MockAgentGateway();
|
const agentGateway = new MockAgentGateway();
|
||||||
|
const profileGateway = new MockProfileGateway();
|
||||||
const terminal = new MockTerminalGateway();
|
const terminal = new MockTerminalGateway();
|
||||||
const system = new MockSystemGateway();
|
const system = new MockSystemGateway();
|
||||||
|
await profileGateway.saveProfile(MOCK_REFERENCE_PROFILES[0]);
|
||||||
|
|
||||||
const agent = await agentGateway.createAgent("p1", {
|
const agent = await agentGateway.createAgent("p1", {
|
||||||
name: "Worker",
|
name: "Worker",
|
||||||
profileId: "claude",
|
profileId: "mock-claude",
|
||||||
});
|
});
|
||||||
|
|
||||||
const initial = await layout.loadLayout("p1");
|
const initial = await layout.loadLayout("p1");
|
||||||
@ -98,6 +102,7 @@ async function makeSplitAgentGrid(): Promise<GridSetup> {
|
|||||||
gateways: {
|
gateways: {
|
||||||
layout,
|
layout,
|
||||||
agent: agentGateway,
|
agent: agentGateway,
|
||||||
|
profile: profileGateway,
|
||||||
terminal,
|
terminal,
|
||||||
system,
|
system,
|
||||||
} as unknown as Gateways,
|
} as unknown as Gateways,
|
||||||
@ -174,6 +179,24 @@ function expectedOperation(action: CellAction, cellId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("LayoutGrid — ticket #48 cell control layering", () => {
|
describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||||||
|
it("keeps custom CLI mode controls compact inside the cell toolbar (#151)", async () => {
|
||||||
|
const setup = await makeSplitAgentGrid();
|
||||||
|
|
||||||
|
renderGrid(setup.gateways);
|
||||||
|
|
||||||
|
const group = await screen.findByRole("group", {
|
||||||
|
name: `mode CLI agent ${setup.bId}`,
|
||||||
|
});
|
||||||
|
const { controls } = controlsFor(setup.bId);
|
||||||
|
|
||||||
|
expect(group.textContent).toBe("TUI nativeCLI custom");
|
||||||
|
expect(group.style.maxWidth).toBe("128px");
|
||||||
|
expect(controls.style.left).toBe("4px");
|
||||||
|
expect(controls.style.right).toBe("16px");
|
||||||
|
expect(controls.style.justifyContent).toBe("flex-end");
|
||||||
|
expect(controls.style.overflow).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
it.each<CellAction>(["selector", "split", "close"])(
|
it.each<CellAction>(["selector", "split", "close"])(
|
||||||
"keeps %s above/clickable over a terminal launch error",
|
"keeps %s above/clickable over a terminal launch error",
|
||||||
async (action) => {
|
async (action) => {
|
||||||
|
|||||||
@ -922,6 +922,13 @@ function LeafView({
|
|||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
return doLaunch(opts, onData, undefined);
|
return doLaunch(opts, onData, undefined);
|
||||||
}
|
}
|
||||||
|
// If the cell was closed/remounted while the agent was running, resume
|
||||||
|
// silently. Layout/project changes must not interrupt an ongoing turn
|
||||||
|
// with a modal choice; the persisted conversation id is the continuity
|
||||||
|
// signal and `agentWasRunning` tells us this was not an intentional stop.
|
||||||
|
if (agentWasRunning) {
|
||||||
|
return doLaunch(opts, onData, conversationId);
|
||||||
|
}
|
||||||
// Resume case: defer the launch behind the popup. Fetch the best-effort
|
// Resume case: defer the launch behind the popup. Fetch the best-effort
|
||||||
// enriched details (last topic + tokens) to enrich it; failure or empty
|
// enriched details (last topic + tokens) to enrich it; failure or empty
|
||||||
// ⇒ degraded mode (status only). Inspection never blocks the resume.
|
// ⇒ degraded mode (status only). Inspection never blocks the resume.
|
||||||
@ -982,6 +989,7 @@ function LeafView({
|
|||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 2,
|
top: 2,
|
||||||
|
left: 4,
|
||||||
// Clear the xterm viewport scrollbar (rendered flush against the right
|
// Clear the xterm viewport scrollbar (rendered flush against the right
|
||||||
// edge, ~15px wide). Without this offset the right-most control — the
|
// edge, ~15px wide). Without this offset the right-most control — the
|
||||||
// close button — sits *behind* the scrollbar and is hard to click.
|
// close button — sits *behind* the scrollbar and is hard to click.
|
||||||
@ -992,6 +1000,8 @@ function LeafView({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 2,
|
gap: 2,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Agent selector */}
|
{/* Agent selector */}
|
||||||
@ -1053,7 +1063,8 @@ function LeafView({
|
|||||||
border: "1px solid var(--color-border, #3a3a3a)",
|
border: "1px solid var(--color-border, #3a3a3a)",
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
padding: "1px 2px",
|
padding: "1px 2px",
|
||||||
maxWidth: 100,
|
width: 96,
|
||||||
|
minWidth: 56,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">Plain</option>
|
<option value="">Plain</option>
|
||||||
@ -1079,6 +1090,7 @@ function LeafView({
|
|||||||
border: "1px solid var(--color-border, #3a3a3a)",
|
border: "1px solid var(--color-border, #3a3a3a)",
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
background: "var(--color-surface, #1e1e1e)",
|
background: "var(--color-surface, #1e1e1e)",
|
||||||
|
maxWidth: 128,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@ -1100,6 +1112,10 @@ function LeafView({
|
|||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
padding: "1px 6px",
|
padding: "1px 6px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
maxWidth: 66,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
TUI native
|
TUI native
|
||||||
@ -1122,6 +1138,10 @@ function LeafView({
|
|||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
padding: "1px 6px",
|
padding: "1px 6px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
maxWidth: 70,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
CLI custom
|
CLI custom
|
||||||
|
|||||||
@ -313,4 +313,40 @@ describe("LayoutGrid — resume popup wiring (T7)", () => {
|
|||||||
expect(screen.queryByTestId("resume-conversation-popup")).toBeNull();
|
expect(screen.queryByTestId("resume-conversation-popup")).toBeNull();
|
||||||
expect(inspectConversation).not.toHaveBeenCalled();
|
expect(inspectConversation).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not show the popup for a remounted cell whose agent was still running (#158)", async () => {
|
||||||
|
const layout = new MockLayoutGateway();
|
||||||
|
const agent = new MockAgentGateway();
|
||||||
|
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
||||||
|
const leafId = await seedResumeLeaf(layout, a.id);
|
||||||
|
await layout.mutateLayout("p1", {
|
||||||
|
type: "setAgentRunning",
|
||||||
|
target: leafId,
|
||||||
|
running: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const launchAgent = vi.fn(
|
||||||
|
async (
|
||||||
|
_p: string,
|
||||||
|
_ag: string,
|
||||||
|
_opts: OpenTerminalOptions,
|
||||||
|
_onData: (b: Uint8Array) => void,
|
||||||
|
) => makeHandle("sess-running-resume"),
|
||||||
|
);
|
||||||
|
const inspectConversation = vi.fn();
|
||||||
|
const stubAgent = {
|
||||||
|
listAgents: () => Promise.resolve([a]),
|
||||||
|
launchAgent,
|
||||||
|
reattach: vi.fn(),
|
||||||
|
inspectConversation,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderGrid(layout, stubAgent);
|
||||||
|
|
||||||
|
await waitFor(() => expect(launchAgent).toHaveBeenCalledTimes(1));
|
||||||
|
expect(screen.queryByTestId("resume-conversation-popup")).toBeNull();
|
||||||
|
expect(inspectConversation).not.toHaveBeenCalled();
|
||||||
|
const opts = launchAgent.mock.calls[0][2] as OpenTerminalOptions;
|
||||||
|
expect(opts.conversationId).toBe("prior-conv");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
* read as faint text rather than a control.
|
* read as faint text rather than a control.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||||
|
|
||||||
import type { TabItem } from "@/shared";
|
import type { TabItem } from "@/shared";
|
||||||
import { ProjectTabs } from "./ProjectTabs";
|
import { ProjectTabs } from "./ProjectTabs";
|
||||||
@ -27,6 +27,7 @@ function renderTabs(props: Partial<React.ComponentProps<typeof ProjectTabs>> = {
|
|||||||
onClose={props.onClose ?? vi.fn()}
|
onClose={props.onClose ?? vi.fn()}
|
||||||
projectsPanelOpen={props.projectsPanelOpen ?? false}
|
projectsPanelOpen={props.projectsPanelOpen ?? false}
|
||||||
onOpenProjectsPanel={onOpenProjectsPanel}
|
onOpenProjectsPanel={onOpenProjectsPanel}
|
||||||
|
activityByProjectId={props.activityByProjectId}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
return { onOpenProjectsPanel };
|
return { onOpenProjectsPanel };
|
||||||
@ -74,3 +75,24 @@ describe("ProjectTabs add-project button (#46)", () => {
|
|||||||
expect(onOpenProjectsPanel).toHaveBeenCalledTimes(1);
|
expect(onOpenProjectsPanel).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ProjectTabs project activity dots (#159)", () => {
|
||||||
|
it("renders the neutral activity dot to the left of a project name by default", () => {
|
||||||
|
renderTabs({ items: TABS });
|
||||||
|
|
||||||
|
const tab = screen.getByRole("tab", { name: /alpha/ });
|
||||||
|
expect(
|
||||||
|
within(tab).getByTitle("alpha : aucune conversation active"),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders idle and busy project activity states", () => {
|
||||||
|
renderTabs({
|
||||||
|
items: TABS,
|
||||||
|
activityByProjectId: { a: "idle", b: "busy" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTitle("alpha : conversation ouverte")).toBeTruthy();
|
||||||
|
expect(screen.getByTitle("beta : activité en cours")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import type { TabItem } from "@/shared";
|
import type { TabItem } from "@/shared";
|
||||||
import { Button, Tabs, cn } from "@/shared";
|
import { Button, Tabs, cn } from "@/shared";
|
||||||
|
import type { ProjectActivityStatus } from "./projectActivity";
|
||||||
|
|
||||||
export interface ProjectTabsProps {
|
export interface ProjectTabsProps {
|
||||||
items: TabItem[];
|
items: TabItem[];
|
||||||
@ -22,6 +23,8 @@ export interface ProjectTabsProps {
|
|||||||
projectsPanelOpen: boolean;
|
projectsPanelOpen: boolean;
|
||||||
/** Open the Projects panel (floating) — create/switch project. */
|
/** Open the Projects panel (floating) — create/switch project. */
|
||||||
onOpenProjectsPanel: () => void;
|
onOpenProjectsPanel: () => void;
|
||||||
|
/** Per-project custom CLI activity, keyed by project id. Missing = none. */
|
||||||
|
activityByProjectId?: Record<string, ProjectActivityStatus>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,8 +35,19 @@ export function ProjectTabs({
|
|||||||
onClose,
|
onClose,
|
||||||
projectsPanelOpen,
|
projectsPanelOpen,
|
||||||
onOpenProjectsPanel,
|
onOpenProjectsPanel,
|
||||||
|
activityByProjectId = {},
|
||||||
className,
|
className,
|
||||||
}: ProjectTabsProps) {
|
}: ProjectTabsProps) {
|
||||||
|
const tabs = items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
leading: (
|
||||||
|
<ProjectActivityDot
|
||||||
|
status={activityByProjectId[item.id] ?? "none"}
|
||||||
|
label={item.label}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -45,7 +59,7 @@ export function ProjectTabs({
|
|||||||
<p className="px-2 text-sm text-muted">No open tabs.</p>
|
<p className="px-2 text-sm text-muted">No open tabs.</p>
|
||||||
) : (
|
) : (
|
||||||
<Tabs
|
<Tabs
|
||||||
items={items}
|
items={tabs}
|
||||||
value={activeTabId}
|
value={activeTabId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@ -72,3 +86,32 @@ export function ProjectTabs({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProjectActivityDot({
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
status: ProjectActivityStatus;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const title =
|
||||||
|
status === "busy"
|
||||||
|
? `${label} : activité en cours`
|
||||||
|
: status === "idle"
|
||||||
|
? `${label} : conversation ouverte`
|
||||||
|
: `${label} : aucune conversation active`;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid={`project-activity-${label}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
title={title}
|
||||||
|
className={cn(
|
||||||
|
"h-2.5 w-2.5 shrink-0 rounded-full border",
|
||||||
|
status === "busy" &&
|
||||||
|
"animate-pulse border-warning bg-warning shadow-[0_0_0_2px_rgba(212,155,58,0.16)]",
|
||||||
|
status === "idle" && "border-success bg-success",
|
||||||
|
status === "none" && "border-border-strong bg-muted",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -70,6 +70,7 @@ import {
|
|||||||
} from "@/shared";
|
} from "@/shared";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useProjects } from "./useProjects";
|
import { useProjects } from "./useProjects";
|
||||||
|
import { useProjectActivity } from "./useProjectActivity";
|
||||||
import { ProjectTabs } from "./ProjectTabs";
|
import { ProjectTabs } from "./ProjectTabs";
|
||||||
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
|
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
|
||||||
import {
|
import {
|
||||||
@ -201,6 +202,7 @@ function rendezvousToastStateLabel(state: string): string {
|
|||||||
|
|
||||||
export function ProjectsView() {
|
export function ProjectsView() {
|
||||||
const vm = useProjects();
|
const vm = useProjects();
|
||||||
|
const projectActivity = useProjectActivity(vm.openTabs.map((tab) => tab.id));
|
||||||
const {
|
const {
|
||||||
system,
|
system,
|
||||||
window: windowGateway,
|
window: windowGateway,
|
||||||
@ -859,6 +861,7 @@ export function ProjectsView() {
|
|||||||
activeTabId={vm.activeTabId}
|
activeTabId={vm.activeTabId}
|
||||||
onSelect={(id) => vm.activateTab(id)}
|
onSelect={(id) => vm.activateTab(id)}
|
||||||
onClose={(id) => void vm.closeTab(id)}
|
onClose={(id) => void vm.closeTab(id)}
|
||||||
|
activityByProjectId={projectActivity}
|
||||||
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
|
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
|
||||||
onOpenProjectsPanel={() => {
|
onOpenProjectsPanel={() => {
|
||||||
setSettingsSection(null);
|
setSettingsSection(null);
|
||||||
|
|||||||
76
frontend/src/features/projects/projectActivity.test.ts
Normal file
76
frontend/src/features/projects/projectActivity.test.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { projectActivityStatus } from "./projectActivity";
|
||||||
|
|
||||||
|
describe("projectActivityStatus (#159)", () => {
|
||||||
|
it("is none when no custom CLI conversation is visible in the work-state", () => {
|
||||||
|
expect(projectActivityStatus({ agents: [], conversations: [] })).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idle when a structured custom CLI session has a conversation but no work", () => {
|
||||||
|
expect(
|
||||||
|
projectActivityStatus({
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
agentId: "a1",
|
||||||
|
name: "A",
|
||||||
|
profileId: "p",
|
||||||
|
live: { kind: "structured", nodeId: "n1", sessionId: "s1" },
|
||||||
|
busy: { state: "idle" },
|
||||||
|
tickets: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conversations: [],
|
||||||
|
}),
|
||||||
|
).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is busy when any agent is busy", () => {
|
||||||
|
expect(
|
||||||
|
projectActivityStatus({
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
agentId: "a1",
|
||||||
|
name: "A",
|
||||||
|
profileId: "p",
|
||||||
|
busy: { state: "busy", ticket: "ticket-1", sinceMs: 1 },
|
||||||
|
tickets: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conversations: [],
|
||||||
|
}),
|
||||||
|
).toBe("busy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is busy for running or pending background work tied to a conversation", () => {
|
||||||
|
expect(
|
||||||
|
projectActivityStatus({
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
agentId: "a1",
|
||||||
|
name: "A",
|
||||||
|
profileId: "p",
|
||||||
|
busy: { state: "idle" },
|
||||||
|
tickets: [],
|
||||||
|
backgroundTasks: [
|
||||||
|
{
|
||||||
|
taskId: "t1",
|
||||||
|
ownerAgentId: "a1",
|
||||||
|
projectId: "p1",
|
||||||
|
kind: "command",
|
||||||
|
status: "running",
|
||||||
|
exitCode: null,
|
||||||
|
summary: null,
|
||||||
|
stdoutTail: null,
|
||||||
|
stderrTail: null,
|
||||||
|
conversationId: "c1",
|
||||||
|
updatedAtMs: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conversations: [],
|
||||||
|
}),
|
||||||
|
).toBe("busy");
|
||||||
|
});
|
||||||
|
});
|
||||||
34
frontend/src/features/projects/projectActivity.ts
Normal file
34
frontend/src/features/projects/projectActivity.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { BackgroundCompletion, ProjectWorkState } from "@/domain";
|
||||||
|
|
||||||
|
export type ProjectActivityStatus = "none" | "idle" | "busy";
|
||||||
|
|
||||||
|
const ACTIVE_BACKGROUND_STATUSES: ReadonlySet<BackgroundCompletion["status"]> =
|
||||||
|
new Set(["pending", "running"]);
|
||||||
|
|
||||||
|
export function projectActivityStatus(
|
||||||
|
state: ProjectWorkState | null | undefined,
|
||||||
|
): ProjectActivityStatus {
|
||||||
|
if (!state) return "none";
|
||||||
|
|
||||||
|
const hasBusyAgent = state.agents.some((agent) => {
|
||||||
|
if (agent.busy.state === "busy") return true;
|
||||||
|
if (agent.tickets.some((ticket) => ticket.status === "inProgress")) return true;
|
||||||
|
return (agent.backgroundTasks ?? []).some((task) =>
|
||||||
|
ACTIVE_BACKGROUND_STATUSES.has(task.status),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (hasBusyAgent) return "busy";
|
||||||
|
|
||||||
|
const hasConversation = state.conversations.length > 0 ||
|
||||||
|
state.agents.some((agent) => {
|
||||||
|
if (agent.live?.kind === "structured") return true;
|
||||||
|
if (agent.tickets.some((ticket) => ticket.conversationId.trim() !== "")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (agent.backgroundTasks ?? []).some(
|
||||||
|
(task) => task.conversationId?.trim(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasConversation ? "idle" : "none";
|
||||||
|
}
|
||||||
84
frontend/src/features/projects/useProjectActivity.ts
Normal file
84
frontend/src/features/projects/useProjectActivity.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type { ProjectWorkState } from "@/domain";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
import {
|
||||||
|
projectActivityStatus,
|
||||||
|
type ProjectActivityStatus,
|
||||||
|
} from "./projectActivity";
|
||||||
|
|
||||||
|
export type ProjectActivityMap = Record<string, ProjectActivityStatus>;
|
||||||
|
|
||||||
|
const REFRESH_EVENTS = new Set([
|
||||||
|
"agentLaunched",
|
||||||
|
"agentExited",
|
||||||
|
"agentBusyChanged",
|
||||||
|
"delegationReady",
|
||||||
|
"orchestratorRequestProcessed",
|
||||||
|
"backgroundTaskChanged",
|
||||||
|
"agentInboxChanged",
|
||||||
|
"agentWakeChanged",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function useProjectActivity(projectIds: string[]): ProjectActivityMap {
|
||||||
|
const { system, workState } = useGateways();
|
||||||
|
const projectKey = [...new Set(projectIds)].sort().join("\0");
|
||||||
|
const stableProjectIds = useMemo(
|
||||||
|
() => [...new Set(projectIds)].sort(),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[projectKey],
|
||||||
|
);
|
||||||
|
const [states, setStates] = useState<Record<string, ProjectWorkState | null>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (stableProjectIds.length === 0) {
|
||||||
|
setStates({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries = await Promise.all(
|
||||||
|
stableProjectIds.map(async (projectId) => {
|
||||||
|
try {
|
||||||
|
return [
|
||||||
|
projectId,
|
||||||
|
await workState.getProjectWorkState(projectId),
|
||||||
|
] as const;
|
||||||
|
} catch {
|
||||||
|
return [projectId, null] as const;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setStates(Object.fromEntries(entries));
|
||||||
|
}, [stableProjectIds, workState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!system) return;
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
void system
|
||||||
|
.onDomainEvent((event) => {
|
||||||
|
if (REFRESH_EVENTS.has(event.type)) void refresh();
|
||||||
|
})
|
||||||
|
.then((un) => {
|
||||||
|
if (cancelled) un();
|
||||||
|
else unsubscribe = un;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, [refresh, system]);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const next: ProjectActivityMap = {};
|
||||||
|
for (const projectId of stableProjectIds) {
|
||||||
|
next[projectId] = projectActivityStatus(states[projectId]);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}, [stableProjectIds, states]);
|
||||||
|
}
|
||||||
@ -7,12 +7,15 @@
|
|||||||
|
|
||||||
import { cn } from "../lib/cn";
|
import { cn } from "../lib/cn";
|
||||||
import { IconButton } from "./IconButton";
|
import { IconButton } from "./IconButton";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export interface TabItem {
|
export interface TabItem {
|
||||||
/** Stable id, returned by `onSelect`/`onClose`. */
|
/** Stable id, returned by `onSelect`/`onClose`. */
|
||||||
id: string;
|
id: string;
|
||||||
/** Visible label. */
|
/** Visible label. */
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Optional leading visual, such as a status indicator. */
|
||||||
|
leading?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabsProps {
|
export interface TabsProps {
|
||||||
@ -52,11 +55,12 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
|
|||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
onClick={() => onSelect(tab.id)}
|
onClick={() => onSelect(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1 text-sm focus-visible:outline-none",
|
"flex items-center gap-1.5 px-2 py-1 text-sm focus-visible:outline-none",
|
||||||
active ? "font-semibold text-content" : "text-muted hover:text-content",
|
active ? "font-semibold text-content" : "text-muted hover:text-content",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.leading}
|
||||||
|
<span>{tab.label}</span>
|
||||||
</button>
|
</button>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
Reference in New Issue
Block a user