agent conversation fix

This commit is contained in:
2026-06-27 12:42:37 +02:00
parent c2d1a669c5
commit 1fc7869160
38 changed files with 1173 additions and 1549 deletions

View File

@ -20,6 +20,7 @@ import { useEffect, useRef, useState } from "react";
import type { Agent } from "@/domain";
import type { LayoutNode } from "@/domain";
import type { ProjectWorkState } from "@/domain";
import type {
ConversationDetails,
LiveAgent,
@ -32,6 +33,7 @@ import {
useWritePortal,
} from "@/features/terminals";
import { useGateways } from "@/app/di";
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";
@ -42,10 +44,13 @@ interface LayoutGridProps {
cwd: string;
/** Active layout id; when provided the grid loads/mutates this layout. */
layoutId?: string;
/** Opens the read-only canonical transcript for a conversation. */
onOpenConversation?: (conversationId: string) => void;
}
export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
export function LayoutGrid({ projectId, cwd, layoutId, onOpenConversation }: LayoutGridProps) {
const vm = useLayout(projectId, layoutId);
const work = useProjectWorkState(projectId);
if (!vm.layout) {
return (
@ -79,6 +84,9 @@ export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
parentSplit={null}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
workState={work.state}
refreshWorkState={work.refresh}
onOpenConversation={onOpenConversation}
/>
</div>
);
@ -92,9 +100,22 @@ interface NodeViewProps {
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
visibleNodeIds: Set<string>;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
}
function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: NodeViewProps) {
function NodeView({
node,
cwd,
vm,
parentSplit,
projectId,
visibleNodeIds,
workState,
refreshWorkState,
onOpenConversation,
}: NodeViewProps) {
switch (node.type) {
case "leaf":
return (
@ -109,6 +130,9 @@ function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: Nod
parentSplit={parentSplit}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
/>
);
case "split":
@ -119,6 +143,9 @@ function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: Nod
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
/>
);
case "grid":
@ -129,6 +156,9 @@ function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: Nod
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
/>
);
}
@ -145,6 +175,9 @@ interface LeafViewProps {
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
visibleNodeIds: Set<string>;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
}
/**
@ -191,7 +224,21 @@ function goToCell(nodeId: string): void {
}, 1200);
}
function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId, visibleNodeIds }: LeafViewProps) {
function LeafView({
id,
session,
agent,
conversationId,
agentWasRunning,
cwd,
vm,
parentSplit,
projectId,
visibleNodeIds,
workState,
refreshWorkState,
onOpenConversation,
}: LeafViewProps) {
// A cell can be closed only when it lives inside a (binary) split: closing it
// collapses the parent split, keeping the *sibling*. Splits are always binary
// in this model (a split wraps a leaf into a 2-child container), so the kept
@ -200,7 +247,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
// the wrong terminal. The root cell (no parent split) cannot be closed.
const canClose = parentSplit !== null && parentSplit.siblings === 2;
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
const { agent: agentGateway, system } = useGateways();
const { agent: agentGateway, input, system } = useGateways();
// The single write-portal of this cell (ARCHITECTURE §20). It owns the human
// line counter, the local delegation FIFO, the handshake (b→e) and the overlay
@ -266,6 +313,24 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
// Build the terminal opener based on whether an agent is pinned.
const agentId = agent ?? null;
const agentWork = agentId
? workState?.agents.find((row) => row.agentId === agentId)
: undefined;
const activeTicket = agentWork?.tickets.find((ticket) => ticket.status === "inProgress");
const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket);
const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined;
const historyConversationId =
activeTicket?.conversationId ?? conversationId ?? null;
async function interruptCurrentTurn(): Promise<void> {
if (!agentId || !input) return;
try {
await input.interrupt(projectId, agentId);
await refreshWorkState();
} catch (err) {
setBusyNotice({ message: describeNotice(err) });
}
}
/** The live session for `candidate`, if any. */
const liveFor = (candidate: string): LiveAgent | undefined =>
@ -308,7 +373,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
/** A live session whose previous host cell no longer exists in the layout. */
const backgroundLive = (candidate: string): LiveAgent | undefined => {
const live = liveFor(candidate);
return live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId)
return live && live.kind === "pty" && live.nodeId !== id && !visibleNodeIds.has(live.nodeId)
? live
: undefined;
};
@ -523,7 +588,10 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
return;
}
const isBackground =
live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId);
live &&
live.kind === "pty" &&
live.nodeId !== id &&
!visibleNodeIds.has(live.nodeId);
if (isBackground) {
if (!agentGateway?.attachLiveAgent || !live.sessionId) {
setBusyNotice({
@ -593,6 +661,87 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
</button>
)}
</div>
{agentId && (busyByWorkState || historyConversationId) && (
<div
role="status"
aria-live="polite"
style={{
position: "absolute",
top: 30,
left: 4,
zIndex: 3,
display: "flex",
maxWidth: "calc(100% - 8px)",
alignItems: "center",
gap: 4,
overflow: "hidden",
}}
>
{busyByWorkState && (
<span
title={delegatedTicket?.requesterLabel ?? activeTicket?.taskPreview}
style={{
maxWidth: 180,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
border: "1px solid var(--color-warning, #d49b3a)",
borderRadius: 3,
background: "rgba(212, 155, 58, 0.16)",
color: "var(--color-warning, #d49b3a)",
padding: "1px 6px",
fontSize: 11,
fontWeight: 600,
}}
>
{delegatedTicket
? `Busy · ${delegatedTicket.requesterLabel}`
: "Busy"}
</span>
)}
{busyByWorkState && (
<button
type="button"
aria-label={`cancel current turn ${id}`}
title="Cancel current turn"
onClick={() => void interruptCurrentTurn()}
disabled={!input}
style={{
flexShrink: 0,
border: "1px solid var(--color-danger, #d45a5a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-danger, #d45a5a)",
cursor: input ? "pointer" : "default",
fontSize: 11,
padding: "1px 6px",
}}
>
Cancel
</button>
)}
{historyConversationId && onOpenConversation && (
<button
type="button"
aria-label={`open cell conversation ${id}`}
title="Open conversation history"
onClick={() => onOpenConversation(historyConversationId)}
style={{
flexShrink: 0,
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
cursor: "pointer",
fontSize: 11,
padding: "1px 6px",
}}
>
History
</button>
)}
</div>
)}
{/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the
raw xterm {@link TerminalView}. Agent cells are **native terminals**
(ARCHITECTURE §20): every human keystroke (Enter included) reaches the
@ -720,9 +869,21 @@ interface SplitViewProps {
vm: LayoutViewModel;
projectId: string;
visibleNodeIds: Set<string>;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
}
function SplitView({ split, cwd, vm, projectId, visibleNodeIds }: SplitViewProps) {
function SplitView({
split,
cwd,
vm,
projectId,
visibleNodeIds,
workState,
refreshWorkState,
onOpenConversation,
}: SplitViewProps) {
const isRow = split.direction === "row";
const baseWeights = split.children.map((c) => c.weight);
const containerRef = useRef<HTMLDivElement | null>(null);
@ -764,6 +925,9 @@ function SplitView({ split, cwd, vm, projectId, visibleNodeIds }: SplitViewProps
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
parentSplit={{
container: split.id,
index: i,
@ -856,9 +1020,21 @@ interface GridViewProps {
vm: LayoutViewModel;
projectId: string;
visibleNodeIds: Set<string>;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
}
function GridView({ grid, cwd, vm, projectId, visibleNodeIds }: GridViewProps) {
function GridView({
grid,
cwd,
vm,
projectId,
visibleNodeIds,
workState,
refreshWorkState,
onOpenConversation,
}: GridViewProps) {
const cols = normalizeWeights(grid.colWeights)
.map((p) => `${p}fr`)
.join(" ");
@ -896,6 +1072,9 @@ function GridView({ grid, cwd, vm, projectId, visibleNodeIds }: GridViewProps) {
parentSplit={null}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
workState={workState}
refreshWorkState={refreshWorkState}
onOpenConversation={onOpenConversation}
/>
</div>
))}

View File

@ -158,7 +158,7 @@ describe("singleton agent — dropdown guard (T6)", () => {
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
{ agentId: a.id, nodeId: "some-other-node" } as LiveAgent,
{ agentId: a.id, nodeId: "some-other-node", kind: "pty" } as LiveAgent,
]);
renderGrid(layout, agent);
@ -176,6 +176,41 @@ describe("singleton agent — dropdown guard (T6)", () => {
);
});
it("does not reattach a structured/headless session as an xterm PTY", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", { name: "Headless", profileId: "p" });
const attach = vi.spyOn(agent, "attachLiveAgent");
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
{
agentId: a.id,
nodeId: "headless-node",
sessionId: "structured-session",
kind: "structured",
},
]);
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
renderGrid(layout, agent);
const option = await screen.findByRole("option", { name: /Headless/ });
expect((option as HTMLOptionElement).disabled).toBe(false);
const select = screen.getByRole("combobox") as HTMLSelectElement;
fireEvent.change(select, { target: { value: a.id } });
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
const leaf = leaves(updated).find((l) => l.id === leafId)!;
expect(leaf.agent).toBe(a.id);
expect(leaf.session).toBeNull();
});
expect(attach).not.toHaveBeenCalled();
});
it("the agent pinned on THIS cell stays selectable even while live (same node)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();

View File

@ -383,6 +383,7 @@ export function ProjectsView() {
projectId={active.id}
cwd={active.root}
layoutId={activeLayout?.id}
onOpenConversation={setViewerConversationId}
/>
)}
</>