agent conversation fix
This commit is contained in:
@ -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>
|
||||
))}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -383,6 +383,7 @@ export function ProjectsView() {
|
||||
projectId={active.id}
|
||||
cwd={active.root}
|
||||
layoutId={activeLayout?.id}
|
||||
onOpenConversation={setViewerConversationId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user