Files
IdeA/frontend/src/adapters/agent.ts
Blomios 0660f52e2b fix(terminals): decouple PTY lifecycle from view lifecycle (no kill on navigation)
Navigating (layout/tab switch) tore the xterm view down and called
handle.close(), killing the backend PTY and cutting off running AIs. Now
the view's cleanup only detaches; only an explicit user action kills a PTY.

Backend:
- PortablePtyAdapter: per-session scrollback ring buffer (~100KB, most
  recent) + re-subscribable fan-out broadcast replacing the single-take
  output_rx. Reader thread feeds both the ring buffer and current
  subscribers; on EOF it closes subscribers (streams end) while keeping
  scrollback for late re-attach.
- PtyPort: new scrollback() method; subscribe_output is now re-subscribable
  (all impls + test fakes updated).
- reattach_terminal IPC command: returns scrollback and re-wires a fresh
  output channel on the live session without re-spawning.
- CloseRequested hook kills all live PTYs cleanly on app shutdown.
- TerminalSessions::handles() to enumerate live sessions at shutdown.

Frontend:
- TerminalHandle.detach(); TerminalGateway/AgentGateway.reattach() + mocks.
- TerminalView cleanup detaches (never close); on mount it re-attaches to a
  persisted session (repainting scrollback) instead of opening a new PTY.
- LayoutGrid persists the cell's session id via setSession; AgentsPanel
  tracks per-agent session ids — both drive reattach-vs-open.

Tests: ring buffer bounds to 100KB keeping newest bytes; scrollback retained;
re-subscription delivers post-reattach output; TerminalView detaches (not
closes) on unmount and reattaches with a known session; mock detach/reattach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:24:48 +02:00

115 lines
3.5 KiB
TypeScript

/**
* Tauri adapter for {@link AgentGateway} (L6).
*
* NOTE: The Tauri commands wired here (`list_agents`, `create_agent`, …) are
* defined in the backend `app-tauri` crate and will be registered in a
* subsequent lot. This adapter is complete on the frontend side; the mock
* gateway covers tests and offline dev today. The real mode will work
* transparently once the commands are registered.
*
* Commands use snake_case (Tauri convention); payload keys are camelCase
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent
* with the other adapters in this directory.
*/
import { Channel, invoke } from "@tauri-apps/api/core";
import type { Agent } from "@/domain";
import type {
AgentGateway,
CreateAgentInput,
OpenTerminalOptions,
ReattachResult,
TerminalHandle,
} from "@/ports";
import { makeTerminalHandle } from "./terminal";
/** Wire shape returned by the `launch_agent` command (mirrors `open_terminal`). */
interface LaunchAgentResponse {
sessionId: string;
cwd: string;
rows: number;
cols: number;
}
export class TauriAgentGateway implements AgentGateway {
listAgents(projectId: string): Promise<Agent[]> {
return invoke<Agent[]>("list_agents", { projectId });
}
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
// The `create_agent` command takes a single `request` DTO; `projectId` must
// live *inside* it (camelCase), not at the top level.
return invoke<Agent>("create_agent", {
request: {
projectId,
name: input.name,
profileId: input.profileId,
initialContent: input.initialContent ?? null,
},
});
}
readContext(projectId: string, agentId: string): Promise<string> {
return invoke<string>("read_agent_context", { projectId, agentId });
}
async updateContext(
projectId: string,
agentId: string,
content: string,
): Promise<void> {
// `update_agent_context` takes a single `request` DTO.
await invoke("update_agent_context", {
request: { projectId, agentId, content },
});
}
async deleteAgent(projectId: string, agentId: string): Promise<void> {
await invoke("delete_agent", { projectId, agentId });
}
async launchAgent(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
// Per-session output channel. The backend serialises chunks as byte arrays.
const channel = new Channel<number[]>();
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
const res = await invoke<LaunchAgentResponse>("launch_agent", {
request: {
projectId,
agentId,
rows: options.rows,
cols: options.cols,
},
onOutput: channel,
});
return makeTerminalHandle(res.sessionId, channel);
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
// Agent sessions reattach through the same session-based `reattach_terminal`
// command as plain terminals (the PTY is identified by its session id).
const channel = new Channel<number[]>();
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
const res = await invoke<{ sessionId: string; scrollback: number[] }>(
"reattach_terminal",
{ sessionId, onOutput: channel },
);
return {
handle: makeTerminalHandle(res.sessionId, channel),
scrollback: Uint8Array.from(res.scrollback),
};
}
}