This commit is contained in:
@ -48,8 +48,10 @@ if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
import type { DomainEvent } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import {
|
||||
MOCK_REFERENCE_PROFILES,
|
||||
MockAgentGateway,
|
||||
MockLayoutGateway,
|
||||
MockProfileGateway,
|
||||
MockSystemGateway,
|
||||
MockTerminalGateway,
|
||||
} from "@/adapters/mock";
|
||||
@ -69,12 +71,14 @@ interface GridSetup {
|
||||
async function makeSplitAgentGrid(): Promise<GridSetup> {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const profileGateway = new MockProfileGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const system = new MockSystemGateway();
|
||||
await profileGateway.saveProfile(MOCK_REFERENCE_PROFILES[0]);
|
||||
|
||||
const agent = await agentGateway.createAgent("p1", {
|
||||
name: "Worker",
|
||||
profileId: "claude",
|
||||
profileId: "mock-claude",
|
||||
});
|
||||
|
||||
const initial = await layout.loadLayout("p1");
|
||||
@ -98,6 +102,7 @@ async function makeSplitAgentGrid(): Promise<GridSetup> {
|
||||
gateways: {
|
||||
layout,
|
||||
agent: agentGateway,
|
||||
profile: profileGateway,
|
||||
terminal,
|
||||
system,
|
||||
} as unknown as Gateways,
|
||||
@ -174,6 +179,24 @@ function expectedOperation(action: CellAction, cellId: string) {
|
||||
}
|
||||
|
||||
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"])(
|
||||
"keeps %s above/clickable over a terminal launch error",
|
||||
async (action) => {
|
||||
|
||||
@ -922,6 +922,13 @@ function LeafView({
|
||||
if (!conversationId) {
|
||||
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
|
||||
// enriched details (last topic + tokens) to enrich it; failure or empty
|
||||
// ⇒ degraded mode (status only). Inspection never blocks the resume.
|
||||
@ -982,6 +989,7 @@ function LeafView({
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 2,
|
||||
left: 4,
|
||||
// Clear the xterm viewport scrollbar (rendered flush against the right
|
||||
// edge, ~15px wide). Without this offset the right-most control — the
|
||||
// close button — sits *behind* the scrollbar and is hard to click.
|
||||
@ -992,6 +1000,8 @@ function LeafView({
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Agent selector */}
|
||||
@ -1053,7 +1063,8 @@ function LeafView({
|
||||
border: "1px solid var(--color-border, #3a3a3a)",
|
||||
borderRadius: 3,
|
||||
padding: "1px 2px",
|
||||
maxWidth: 100,
|
||||
width: 96,
|
||||
minWidth: 56,
|
||||
}}
|
||||
>
|
||||
<option value="">Plain</option>
|
||||
@ -1079,6 +1090,7 @@ function LeafView({
|
||||
border: "1px solid var(--color-border, #3a3a3a)",
|
||||
borderRadius: 3,
|
||||
background: "var(--color-surface, #1e1e1e)",
|
||||
maxWidth: 128,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@ -1100,6 +1112,10 @@ function LeafView({
|
||||
fontSize: 11,
|
||||
padding: "1px 6px",
|
||||
cursor: "pointer",
|
||||
maxWidth: 66,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
TUI native
|
||||
@ -1122,6 +1138,10 @@ function LeafView({
|
||||
fontSize: 11,
|
||||
padding: "1px 6px",
|
||||
cursor: "pointer",
|
||||
maxWidth: 70,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
CLI custom
|
||||
|
||||
@ -313,4 +313,40 @@ describe("LayoutGrid — resume popup wiring (T7)", () => {
|
||||
expect(screen.queryByTestId("resume-conversation-popup")).toBeNull();
|
||||
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.
|
||||
*/
|
||||
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 { ProjectTabs } from "./ProjectTabs";
|
||||
@ -27,6 +27,7 @@ function renderTabs(props: Partial<React.ComponentProps<typeof ProjectTabs>> = {
|
||||
onClose={props.onClose ?? vi.fn()}
|
||||
projectsPanelOpen={props.projectsPanelOpen ?? false}
|
||||
onOpenProjectsPanel={onOpenProjectsPanel}
|
||||
activityByProjectId={props.activityByProjectId}
|
||||
/>,
|
||||
);
|
||||
return { onOpenProjectsPanel };
|
||||
@ -74,3 +75,24 @@ describe("ProjectTabs add-project button (#46)", () => {
|
||||
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 { Button, Tabs, cn } from "@/shared";
|
||||
import type { ProjectActivityStatus } from "./projectActivity";
|
||||
|
||||
export interface ProjectTabsProps {
|
||||
items: TabItem[];
|
||||
@ -22,6 +23,8 @@ export interface ProjectTabsProps {
|
||||
projectsPanelOpen: boolean;
|
||||
/** Open the Projects panel (floating) — create/switch project. */
|
||||
onOpenProjectsPanel: () => void;
|
||||
/** Per-project custom CLI activity, keyed by project id. Missing = none. */
|
||||
activityByProjectId?: Record<string, ProjectActivityStatus>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@ -32,8 +35,19 @@ export function ProjectTabs({
|
||||
onClose,
|
||||
projectsPanelOpen,
|
||||
onOpenProjectsPanel,
|
||||
activityByProjectId = {},
|
||||
className,
|
||||
}: ProjectTabsProps) {
|
||||
const tabs = items.map((item) => ({
|
||||
...item,
|
||||
leading: (
|
||||
<ProjectActivityDot
|
||||
status={activityByProjectId[item.id] ?? "none"}
|
||||
label={item.label}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -45,7 +59,7 @@ export function ProjectTabs({
|
||||
<p className="px-2 text-sm text-muted">No open tabs.</p>
|
||||
) : (
|
||||
<Tabs
|
||||
items={items}
|
||||
items={tabs}
|
||||
value={activeTabId}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
@ -72,3 +86,32 @@ export function ProjectTabs({
|
||||
</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";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useProjects } from "./useProjects";
|
||||
import { useProjectActivity } from "./useProjectActivity";
|
||||
import { ProjectTabs } from "./ProjectTabs";
|
||||
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
|
||||
import {
|
||||
@ -201,6 +202,7 @@ function rendezvousToastStateLabel(state: string): string {
|
||||
|
||||
export function ProjectsView() {
|
||||
const vm = useProjects();
|
||||
const projectActivity = useProjectActivity(vm.openTabs.map((tab) => tab.id));
|
||||
const {
|
||||
system,
|
||||
window: windowGateway,
|
||||
@ -859,6 +861,7 @@ export function ProjectsView() {
|
||||
activeTabId={vm.activeTabId}
|
||||
onSelect={(id) => vm.activateTab(id)}
|
||||
onClose={(id) => void vm.closeTab(id)}
|
||||
activityByProjectId={projectActivity}
|
||||
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
|
||||
onOpenProjectsPanel={() => {
|
||||
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 { IconButton } from "./IconButton";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface TabItem {
|
||||
/** Stable id, returned by `onSelect`/`onClose`. */
|
||||
id: string;
|
||||
/** Visible label. */
|
||||
label: string;
|
||||
/** Optional leading visual, such as a status indicator. */
|
||||
leading?: ReactNode;
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
@ -52,11 +55,12 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
|
||||
aria-selected={active}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.leading}
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
{onClose && (
|
||||
<IconButton
|
||||
|
||||
Reference in New Issue
Block a user