Corrige la perte de session à la navigation: le démontage du CustomAgentChatView appelait close_agent_session (destructeur) au lieu de simplement détacher le transport. Désormais: - nouvelle commande Tauri detach_agent_chat (+ ChatBridge::detach) qui libère le canal sans tuer la session ni le scrollback retenu; distinct de close_agent_session qui reste le close destructeur explicite; - le frontend appelle detachAgentChat à l'unmount puis restore sa vue depuis un cache de session (turns + chunkCount) à la réattache; - message « Reprise effectuée: N événements rattrapés… » quand la réattache rejoue plus de chunks que le cache local n'en avait conservé. Enrichit le rendu tools/MCP/progress: - ProgressSettler (chat bridge) émet un progress completed synthétique quand un spinner reste ouvert (nouvelle entité progress, chunk text/final/error): fini les spinners fantômes; - les adapters claude/codex/opencode/openai_compat extraient un aperçu du payload (arguments/input/command/message/params) attaché au progress; - bounded_reply_progress_preview (domain) garde les 4 premières lignes non vides, normalisation CR/LF; - previewText (frontend) multiligne whitespace-pre-wrap, source/nativeEvent affichés sous le titre; suppression du flag settled et du Spinner inline devenus redondants (l'état est porté par le stage + le settler). Tests: detach_agent_chat gateway (Tauri + mock), ProgressSettler, bornage reply_progress, item_preview/tool_preview par adapter, réattache avec cache et notice de rattrapage.
364 lines
11 KiB
TypeScript
364 lines
11 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,
|
|
AgentContextDocument,
|
|
EffortSelection,
|
|
GatewayError,
|
|
ExecuteSlashCommandResult,
|
|
ReplyChunk,
|
|
ResumableAgent,
|
|
SlashCommand,
|
|
TerminalSession,
|
|
} from "@/domain";
|
|
import type {
|
|
AgentChatHandle,
|
|
AgentGateway,
|
|
ChatDraftScope,
|
|
ChatDraftSnapshot,
|
|
ConversationDetails,
|
|
CreateAgentInput,
|
|
LiveAgent,
|
|
OpenTerminalOptions,
|
|
ReattachAgentChatResult,
|
|
ReattachResult,
|
|
SendAgentChatOptions,
|
|
StoppedLiveAgent,
|
|
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;
|
|
/** Backend-derived routing: `chat` for structured sessions, `pty` for native TUI. */
|
|
cellKind?: "chat" | "pty";
|
|
/** Conversation id minted by this launch (omitted when nothing was assigned). */
|
|
assignedConversationId?: string;
|
|
}
|
|
|
|
function structuredLaunchRoutedToPtyError(
|
|
projectId: string,
|
|
agentId: string,
|
|
options: OpenTerminalOptions,
|
|
response: LaunchAgentResponse,
|
|
): GatewayError {
|
|
const actual = response.cellKind ?? "missing";
|
|
return {
|
|
code: "STRUCTURED_ROUTED_TO_PTY",
|
|
message:
|
|
`custom CLI launch for agent ${agentId} in project ${projectId} ` +
|
|
`returned cellKind=${actual}; expected chat. ` +
|
|
`sessionId=${response.sessionId}; nodeId=${options.nodeId ?? "none"}`,
|
|
};
|
|
}
|
|
|
|
export class TauriAgentGateway implements AgentGateway {
|
|
listAgents(projectId: string): Promise<Agent[]> {
|
|
return invoke<Agent[]>("list_agents", { projectId });
|
|
}
|
|
|
|
listLiveAgents(projectId: string): Promise<LiveAgent[]> {
|
|
return invoke<LiveAgent[]>("list_live_agents", { projectId });
|
|
}
|
|
|
|
listResumableAgents(projectId: string): Promise<ResumableAgent[]> {
|
|
// `list_resumable_agents` takes a top-level `projectId` (camelCase) and
|
|
// returns `{ resumable: ResumableAgentDto[] }`. The DTO is already camelCase
|
|
// and shape-compatible with `ResumableAgent`, so we just unwrap the list.
|
|
return invoke<{ resumable: ResumableAgent[] }>("list_resumable_agents", {
|
|
projectId,
|
|
}).then((res) => res.resumable);
|
|
}
|
|
|
|
attachLiveAgent(
|
|
projectId: string,
|
|
agentId: string,
|
|
nodeId: string,
|
|
): Promise<LiveAgent> {
|
|
return invoke<LiveAgent>("attach_live_agent", {
|
|
request: { projectId, agentId, nodeId },
|
|
});
|
|
}
|
|
|
|
stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
|
|
return invoke<StoppedLiveAgent>("stop_live_agent", {
|
|
request: { projectId, agentId },
|
|
});
|
|
}
|
|
|
|
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,
|
|
},
|
|
});
|
|
}
|
|
|
|
changeAgentProfile(
|
|
projectId: string,
|
|
agentId: string,
|
|
profileId: string,
|
|
rows: number,
|
|
cols: number,
|
|
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }> {
|
|
// `change_agent_profile` takes a single `request` DTO; the camelCase keys
|
|
// match the backend `ChangeAgentProfileRequestDto`. The response carries the
|
|
// mutated agent and, when a live session was hot-swapped, the relaunched one
|
|
// (omitted otherwise — `relaunchedSession` stays `undefined`).
|
|
return invoke<{ agent: Agent; relaunchedSession?: TerminalSession }>(
|
|
"change_agent_profile",
|
|
{ request: { projectId, agentId, profileId, rows, cols } },
|
|
);
|
|
}
|
|
|
|
updateAgentEffort(
|
|
projectId: string,
|
|
agentId: string,
|
|
effort: EffortSelection | null,
|
|
): Promise<Agent> {
|
|
return invoke<Agent>("update_agent_effort", {
|
|
request: { projectId, agentId, effort },
|
|
});
|
|
}
|
|
|
|
readContext(projectId: string, agentId: string): Promise<string> {
|
|
return invoke<AgentContextDocument>("read_agent_context", {
|
|
projectId,
|
|
agentId,
|
|
}).then((res) => res.content);
|
|
}
|
|
|
|
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,
|
|
// Resume id: the leaf's persisted conversation id, when any (T4b). The
|
|
// backend resumes it; absent ⇒ a fresh cell that may get a new id.
|
|
conversationId: options.conversationId ?? null,
|
|
// Hosting cell: lets the backend enforce one-live-session-per-agent.
|
|
nodeId: options.nodeId ?? null,
|
|
},
|
|
onOutput: channel,
|
|
});
|
|
|
|
const base = makeTerminalHandle(res.sessionId, channel);
|
|
// Surface the id assigned by this launch (so the caller persists it on the
|
|
// leaf and resumes next time).
|
|
return {
|
|
...base,
|
|
...(res.assignedConversationId
|
|
? { assignedConversationId: res.assignedConversationId }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async launchAgentChat(
|
|
projectId: string,
|
|
agentId: string,
|
|
options: OpenTerminalOptions,
|
|
): Promise<AgentChatHandle> {
|
|
// `launch_agent` is the routing authority. For structured/headless profiles
|
|
// the backend ignores the PTY output channel and returns a structured session
|
|
// id; for non-structured profiles it may still route to PTY, so callers gate
|
|
// this method on `profile.structuredAdapter`.
|
|
const channel = new Channel<number[]>();
|
|
const res = await invoke<LaunchAgentResponse>("launch_agent", {
|
|
request: {
|
|
projectId,
|
|
agentId,
|
|
rows: options.rows,
|
|
cols: options.cols,
|
|
cellKind: "chat",
|
|
conversationId: options.conversationId ?? null,
|
|
nodeId: options.nodeId ?? null,
|
|
},
|
|
onOutput: channel,
|
|
});
|
|
if (res.cellKind !== "chat") {
|
|
const error = structuredLaunchRoutedToPtyError(projectId, agentId, options, res);
|
|
console.error("[ticket149] launchAgentChat:routed-to-non-chat", {
|
|
projectId,
|
|
agentId,
|
|
request: {
|
|
rows: options.rows,
|
|
cols: options.cols,
|
|
cellKind: "chat",
|
|
conversationId: options.conversationId ?? null,
|
|
nodeId: options.nodeId ?? null,
|
|
},
|
|
response: res,
|
|
error,
|
|
});
|
|
throw error;
|
|
}
|
|
return {
|
|
sessionId: res.sessionId,
|
|
cellKind: "chat",
|
|
...(res.assignedConversationId
|
|
? { assignedConversationId: res.assignedConversationId }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async reattachAgentChat(
|
|
sessionId: string,
|
|
onChunk: (chunk: ReplyChunk) => void,
|
|
): Promise<ReattachAgentChatResult> {
|
|
const channel = new Channel<ReplyChunk>();
|
|
channel.onmessage = onChunk;
|
|
return invoke<ReattachAgentChatResult>("reattach_agent_chat", {
|
|
sessionId,
|
|
onReply: channel,
|
|
});
|
|
}
|
|
|
|
async detachAgentChat(sessionId: string): Promise<void> {
|
|
await invoke("detach_agent_chat", { sessionId });
|
|
}
|
|
|
|
async sendAgentChat(
|
|
sessionId: string,
|
|
prompt: string,
|
|
onChunk: (chunk: ReplyChunk) => void,
|
|
options: SendAgentChatOptions = {},
|
|
): Promise<void> {
|
|
const channel = new Channel<ReplyChunk>();
|
|
channel.onmessage = onChunk;
|
|
await invoke("agent_send", {
|
|
sessionId,
|
|
prompt,
|
|
...(options.attachments ? { attachments: options.attachments } : {}),
|
|
onReply: channel,
|
|
});
|
|
}
|
|
|
|
async listSlashCommands(prefix?: string): Promise<SlashCommand[]> {
|
|
const request = prefix === undefined ? {} : { prefix };
|
|
const res = await invoke<{ commands: SlashCommand[] }>("list_slash_commands", {
|
|
request,
|
|
});
|
|
return res.commands;
|
|
}
|
|
|
|
async executeSlashCommand(
|
|
name: string,
|
|
options: { sessionId?: string | null; arguments?: unknown[] } = {},
|
|
): Promise<ExecuteSlashCommandResult> {
|
|
return invoke<ExecuteSlashCommandResult>("execute_slash_command", {
|
|
request: {
|
|
name,
|
|
sessionId: options.sessionId ?? null,
|
|
arguments: options.arguments ?? [],
|
|
},
|
|
});
|
|
}
|
|
|
|
readChatDraft(scope: ChatDraftScope): Promise<ChatDraftSnapshot> {
|
|
return invoke<ChatDraftSnapshot>("read_chat_draft", {
|
|
request: scope,
|
|
});
|
|
}
|
|
|
|
async saveChatDraft(scope: ChatDraftScope, text: string): Promise<void> {
|
|
await invoke("save_chat_draft", {
|
|
request: { ...scope, text },
|
|
});
|
|
}
|
|
|
|
async clearChatDraft(scope: ChatDraftScope): Promise<void> {
|
|
await invoke("clear_chat_draft", {
|
|
request: scope,
|
|
});
|
|
}
|
|
|
|
async closeAgentChat(sessionId: string): Promise<void> {
|
|
await invoke("close_agent_session", { sessionId });
|
|
}
|
|
|
|
async cancelAgentChat(sessionId: string): Promise<void> {
|
|
await invoke("cancel_agent_chat", { sessionId });
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
inspectConversation(
|
|
projectId: string,
|
|
agentId: string,
|
|
conversationId: string,
|
|
): Promise<ConversationDetails> {
|
|
// `inspect_conversation` takes a single `request` DTO; absent fields are
|
|
// omitted from the response (best-effort), so an empty object is valid.
|
|
return invoke<ConversationDetails>("inspect_conversation", {
|
|
request: { projectId, agentId, conversationId },
|
|
});
|
|
}
|
|
}
|