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>
115 lines
3.5 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|