Ajoute le chat assistant IA attaché à un ticket (#8). Backend Rust : - use cases OpenTicketAssistant/CloseTicketAssistant + tests - politique d'outils par agent (domain/agent_tool_policy) et policy MCP - store de contexte assistant + gabarit default_ticket_assistant.md + tests - events TicketAssistantOpened/Closed - commandes Tauri open_ticket_chat/close_ticket_chat et câblage state/lib/events Frontend : - gateway (ports, adapters ticket + mock, domain) - hook useTicketAssistant + composant TicketAssistantPanel - intégration dans TicketDetail Tests verts : vitest tickets.test.tsx (23), cargo test application::ticket_assistant (1), infrastructure::assistant_context_store (2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
222 lines
6.1 KiB
TypeScript
222 lines
6.1 KiB
TypeScript
/**
|
|
* Tauri adapter for {@link TicketGateway}.
|
|
*
|
|
* The single frontend owner of the `ticket_*` command names and their request
|
|
* envelopes (every command takes one camelCase `request` object). Features
|
|
* consume the gateway port through DI and never call `invoke()` directly.
|
|
*
|
|
* Optimistic concurrency: the backend rejects a stale write with an `INVALID`
|
|
* {@link GatewayError} whose message contains `"version conflict"`; use
|
|
* {@link isTicketVersionConflict} to branch on it in the UI.
|
|
*/
|
|
|
|
import { Channel, invoke } from "@tauri-apps/api/core";
|
|
|
|
import type {
|
|
GatewayError,
|
|
ReplyChunk,
|
|
Sprint,
|
|
Ticket,
|
|
TicketCarnet,
|
|
TicketChat,
|
|
TicketLinkKind,
|
|
TicketList,
|
|
} from "@/domain";
|
|
import type {
|
|
CreateTicketInput,
|
|
TicketGateway,
|
|
TicketListQuery,
|
|
UpdateTicketInput,
|
|
} from "@/ports";
|
|
|
|
/**
|
|
* Whether a gateway error is an optimistic-concurrency conflict (the ticket was
|
|
* mutated since it was read). The backend returns a generic `INVALID` code, so
|
|
* we key off the stable message fragment emitted by the domain store error.
|
|
*/
|
|
export function isTicketVersionConflict(error: unknown): boolean {
|
|
if (!error || typeof error !== "object") return false;
|
|
const message = (error as GatewayError).message;
|
|
return typeof message === "string" && message.includes("version conflict");
|
|
}
|
|
|
|
export class TauriTicketGateway implements TicketGateway {
|
|
async create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
|
|
return invoke<Ticket>("ticket_create", {
|
|
request: { projectId, ...input },
|
|
});
|
|
}
|
|
|
|
async read(
|
|
projectId: string,
|
|
ref: string,
|
|
includeCarnet = false,
|
|
): Promise<Ticket> {
|
|
return invoke<Ticket>("ticket_read", {
|
|
request: { projectId, ref, includeCarnet },
|
|
});
|
|
}
|
|
|
|
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
|
|
return invoke<TicketList>("ticket_list", {
|
|
request: { projectId, ...(query ?? {}) },
|
|
});
|
|
}
|
|
|
|
async update(
|
|
projectId: string,
|
|
ref: string,
|
|
input: UpdateTicketInput,
|
|
): Promise<Ticket> {
|
|
return invoke<Ticket>("ticket_update", {
|
|
request: { projectId, ref, ...input },
|
|
});
|
|
}
|
|
|
|
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
|
return invoke<TicketCarnet>("ticket_read_carnet", {
|
|
request: { projectId, ref },
|
|
});
|
|
}
|
|
|
|
async updateCarnet(
|
|
projectId: string,
|
|
ref: string,
|
|
carnet: string,
|
|
expectedVersion: number,
|
|
): Promise<Ticket> {
|
|
return invoke<Ticket>("ticket_update_carnet", {
|
|
request: { projectId, ref, carnet, expectedVersion },
|
|
});
|
|
}
|
|
|
|
async link(
|
|
projectId: string,
|
|
ref: string,
|
|
targetRef: string,
|
|
kind: TicketLinkKind,
|
|
expectedVersion: number,
|
|
): Promise<Ticket> {
|
|
return invoke<Ticket>("ticket_link", {
|
|
request: { projectId, ref, targetRef, kind, expectedVersion },
|
|
});
|
|
}
|
|
|
|
async unlink(
|
|
projectId: string,
|
|
ref: string,
|
|
targetRef: string,
|
|
expectedVersion: number,
|
|
kind?: TicketLinkKind,
|
|
): Promise<Ticket> {
|
|
return invoke<Ticket>("ticket_unlink", {
|
|
request: { projectId, ref, targetRef, expectedVersion, kind },
|
|
});
|
|
}
|
|
|
|
async assign(
|
|
projectId: string,
|
|
ref: string,
|
|
agentId: string,
|
|
assigned: boolean,
|
|
expectedVersion: number,
|
|
): Promise<Ticket> {
|
|
return invoke<Ticket>("ticket_assign", {
|
|
request: { projectId, ref, agentId, assigned, expectedVersion },
|
|
});
|
|
}
|
|
|
|
async listSprints(projectId: string): Promise<Sprint[]> {
|
|
// `sprint_list` returns a `SprintListDto { items }`; unwrap to the array.
|
|
const list = await invoke<{ items: Sprint[] }>("sprint_list", {
|
|
request: { projectId },
|
|
});
|
|
return list.items;
|
|
}
|
|
|
|
async setTicketSprint(
|
|
projectId: string,
|
|
ref: string,
|
|
sprintId: string | null,
|
|
expectedVersion: number,
|
|
): Promise<Ticket> {
|
|
// Two backend commands: assign to a sprint, or unassign (clear membership).
|
|
if (sprintId === null) {
|
|
return invoke<Ticket>("ticket_unassign_sprint", {
|
|
request: { projectId, ref, expectedVersion },
|
|
});
|
|
}
|
|
return invoke<Ticket>("ticket_assign_sprint", {
|
|
request: { projectId, ref, sprintId, expectedVersion },
|
|
});
|
|
}
|
|
|
|
async createSprint(projectId: string, name: string): Promise<Sprint> {
|
|
return invoke<Sprint>("sprint_create", {
|
|
request: { projectId, name },
|
|
});
|
|
}
|
|
|
|
async renameSprint(
|
|
projectId: string,
|
|
sprintId: string,
|
|
name: string,
|
|
expectedVersion: number,
|
|
): Promise<Sprint> {
|
|
return invoke<Sprint>("sprint_rename", {
|
|
request: { projectId, sprintId, name, expectedVersion },
|
|
});
|
|
}
|
|
|
|
async reorderSprints(
|
|
projectId: string,
|
|
orderedIds: string[],
|
|
): Promise<Sprint[]> {
|
|
// `sprint_reorder` returns a `SprintListDto { items }`; unwrap to the array.
|
|
const list = await invoke<{ items: Sprint[] }>("sprint_reorder", {
|
|
request: { projectId, orderedIds },
|
|
});
|
|
return list.items;
|
|
}
|
|
|
|
async deleteSprint(projectId: string, sprintId: string): Promise<void> {
|
|
await invoke<void>("sprint_delete", {
|
|
request: { projectId, sprintId },
|
|
});
|
|
}
|
|
|
|
async openTicketChat(
|
|
projectId: string,
|
|
issueRef: string,
|
|
profileId: string,
|
|
): Promise<TicketChat> {
|
|
return invoke<TicketChat>("open_ticket_chat", {
|
|
request: { projectId, issueRef, profileId },
|
|
});
|
|
}
|
|
|
|
async closeTicketChat(projectId: string, issueRef: string): Promise<void> {
|
|
await invoke<void>("close_ticket_chat", {
|
|
request: { projectId, issueRef },
|
|
});
|
|
}
|
|
|
|
async sendTicketChat(
|
|
sessionId: string,
|
|
message: string,
|
|
onChunk: (chunk: ReplyChunk) => void,
|
|
): Promise<void> {
|
|
// Per-turn reply channel, tagged on `kind` (`textDelta`|`toolActivity`|
|
|
// `final`). Reuses the shared `agent_send` chat transport (the same command
|
|
// and ChatBridge the structured chat cell uses). Wire the handler before the
|
|
// invoke so no early chunk is missed.
|
|
const channel = new Channel<ReplyChunk>();
|
|
channel.onmessage = (chunk) => onChunk(chunk);
|
|
await invoke<void>("agent_send", {
|
|
sessionId,
|
|
prompt: message,
|
|
onReply: channel,
|
|
});
|
|
}
|
|
}
|