feat(tickets): ajoute les pièces jointes sur tickets (#108)
Stockage flat côté ticket + métadonnées d'attachments, lecture exposée côté backend/MCP, et UI minimale de liste/ajout dans TicketDetail. Traverse le domaine (Issue, ports), l'application (usecases + assistant de ticket), les adaptateurs infra/MCP (issues store, orchestrateur), les DTO backend/web- server/app-tauri, et le frontend (domain/ports/adapters/hooks/UI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -29,6 +29,7 @@ import type {
|
||||
Sprint,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketAttachmentContent,
|
||||
TicketBulkResult,
|
||||
TicketCarnet,
|
||||
TicketChat,
|
||||
@ -122,6 +123,11 @@ export class HttpSystemGateway implements SystemGateway {
|
||||
return unsupportedOnWeb("Native file picker");
|
||||
}
|
||||
|
||||
pickFile(): Promise<string | null> {
|
||||
// Desktop-only, same rationale as `pickFolder`.
|
||||
return unsupportedOnWeb("Native file picker");
|
||||
}
|
||||
|
||||
onAppExitWorkGuard(
|
||||
_handler: (state: AppExitWorkGuardState) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
@ -321,6 +327,36 @@ export class HttpTicketGateway implements TicketGateway {
|
||||
request: { projectId, ref, carnet, expectedVersion },
|
||||
});
|
||||
}
|
||||
addAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
path: string,
|
||||
expectedVersion: number,
|
||||
mime?: string | null,
|
||||
): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_attachment_add", {
|
||||
request: { projectId, ref, path, expectedVersion, mime },
|
||||
});
|
||||
}
|
||||
readAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
): Promise<TicketAttachmentContent> {
|
||||
return this.http.invoke<TicketAttachmentContent>("ticket_attachment_read", {
|
||||
request: { projectId, ref, attachmentId },
|
||||
});
|
||||
}
|
||||
markAttachmentSummarized(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_attachment_mark_summarized", {
|
||||
request: { projectId, ref, attachmentId, expectedVersion },
|
||||
});
|
||||
}
|
||||
link(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
|
||||
@ -66,6 +66,7 @@ import type {
|
||||
SystemPermissionSet,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketAttachmentContent,
|
||||
TicketBulkResult,
|
||||
TicketCarnet,
|
||||
TicketChat,
|
||||
@ -179,6 +180,22 @@ function sameTicketCreator(
|
||||
return true;
|
||||
}
|
||||
|
||||
function filenameFromPath(path: string): string {
|
||||
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? "attachment";
|
||||
}
|
||||
|
||||
function inferAttachmentMime(filename: string, supplied?: string | null): string {
|
||||
if (supplied?.trim()) return supplied.trim();
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".md")) return "text/markdown";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
if (lower.endsWith(".json")) return "application/json";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
export class MockSystemGateway implements SystemGateway {
|
||||
private listeners = new Set<(e: DomainEvent) => void>();
|
||||
|
||||
@ -219,6 +236,11 @@ export class MockSystemGateway implements SystemGateway {
|
||||
return "/home/user/mock-plugin.ideaplug";
|
||||
}
|
||||
|
||||
/** Returns a deterministic fake path — never opens a native dialog. */
|
||||
async pickFile(): Promise<string | null> {
|
||||
return "/home/user/mock-attachment.txt";
|
||||
}
|
||||
|
||||
private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
|
||||
/** Count of `confirmAppExit()` calls, for test assertions. */
|
||||
confirmAppExitCallCount = 0;
|
||||
@ -2710,6 +2732,7 @@ export class MockTicketGateway implements TicketGateway {
|
||||
/** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
|
||||
private chatSessions = new Map<string, string>();
|
||||
private chatCounter = 0;
|
||||
private attachmentCounter = 0;
|
||||
|
||||
constructor(private readonly system?: MockSystemGateway) {}
|
||||
|
||||
@ -2717,6 +2740,7 @@ export class MockTicketGateway implements TicketGateway {
|
||||
_seedTicket(projectId: string, ticket: Ticket): Ticket {
|
||||
const stored = structuredClone(ticket);
|
||||
if (stored.sprintId === undefined) stored.sprintId = null;
|
||||
stored.attachments ??= [];
|
||||
this.projectTickets(projectId).set(stored.ref, stored);
|
||||
this.counters.set(
|
||||
projectId,
|
||||
@ -2810,6 +2834,7 @@ export class MockTicketGateway implements TicketGateway {
|
||||
sprintId: null,
|
||||
links: [],
|
||||
assignedAgentIds: [...(input.assignedAgentIds ?? [])],
|
||||
attachments: [],
|
||||
createdBy: { kind: "user" },
|
||||
updatedBy: { kind: "user" },
|
||||
createdAt: now,
|
||||
@ -3021,6 +3046,83 @@ export class MockTicketGateway implements TicketGateway {
|
||||
return structuredClone(ticket);
|
||||
}
|
||||
|
||||
async addAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
path: string,
|
||||
expectedVersion: number,
|
||||
mime?: string | null,
|
||||
): Promise<Ticket> {
|
||||
const ticket = this.require(projectId, ref);
|
||||
this.guard(ticket, expectedVersion);
|
||||
const filename = filenameFromPath(path);
|
||||
this.attachmentCounter += 1;
|
||||
ticket.attachments.push({
|
||||
id: `att-${this.attachmentCounter}`,
|
||||
filename,
|
||||
mime: inferAttachmentMime(filename, mime),
|
||||
sizeBytes: Math.max(1, filename.length * 128),
|
||||
addedBy: { kind: "user" },
|
||||
addedAt: Date.now(),
|
||||
summarizedInCarnet: false,
|
||||
summarizedBy: null,
|
||||
summarizedAt: null,
|
||||
});
|
||||
this.bump(ticket);
|
||||
this.system?.emit({
|
||||
type: "issueUpdated",
|
||||
issueRef: ref,
|
||||
version: ticket.version,
|
||||
});
|
||||
return structuredClone(ticket);
|
||||
}
|
||||
|
||||
async readAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
): Promise<TicketAttachmentContent> {
|
||||
const ticket = this.require(projectId, ref);
|
||||
const attachment = ticket.attachments.find((item) => item.id === attachmentId);
|
||||
if (!attachment) {
|
||||
throw {
|
||||
code: "NOT_FOUND",
|
||||
message: `attachment ${attachmentId} not found`,
|
||||
} as GatewayError;
|
||||
}
|
||||
return {
|
||||
attachment: structuredClone(attachment),
|
||||
contentBase64: btoa(`mock content for ${attachment.filename}`),
|
||||
};
|
||||
}
|
||||
|
||||
async markAttachmentSummarized(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket> {
|
||||
const ticket = this.require(projectId, ref);
|
||||
this.guard(ticket, expectedVersion);
|
||||
const attachment = ticket.attachments.find((item) => item.id === attachmentId);
|
||||
if (!attachment) {
|
||||
throw {
|
||||
code: "NOT_FOUND",
|
||||
message: `attachment ${attachmentId} not found`,
|
||||
} as GatewayError;
|
||||
}
|
||||
attachment.summarizedInCarnet = true;
|
||||
attachment.summarizedBy = { kind: "user" };
|
||||
attachment.summarizedAt = Date.now();
|
||||
this.bump(ticket);
|
||||
this.system?.emit({
|
||||
type: "issueUpdated",
|
||||
issueRef: ref,
|
||||
version: ticket.version,
|
||||
});
|
||||
return structuredClone(ticket);
|
||||
}
|
||||
|
||||
async link(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
|
||||
@ -50,6 +50,11 @@ export class TauriSystemGateway implements SystemGateway {
|
||||
return typeof result === "string" ? result : null;
|
||||
}
|
||||
|
||||
async pickFile(): Promise<string | null> {
|
||||
const result = await open({ directory: false, multiple: false });
|
||||
return typeof result === "string" ? result : null;
|
||||
}
|
||||
|
||||
async onAppExitWorkGuard(
|
||||
handler: (state: AppExitWorkGuardState) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
|
||||
@ -54,4 +54,47 @@ describe("TauriTicketGateway invoke payloads", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps ticket attachment commands in the request DTO", async () => {
|
||||
const gateway = new TauriTicketGateway();
|
||||
|
||||
await gateway.addAttachment(
|
||||
"proj-1",
|
||||
"#12",
|
||||
"/tmp/note.txt",
|
||||
3,
|
||||
"text/plain",
|
||||
);
|
||||
await gateway.readAttachment("proj-1", "#12", "att-1");
|
||||
await gateway.markAttachmentSummarized("proj-1", "#12", "att-1", 4);
|
||||
|
||||
expect(invoke).toHaveBeenNthCalledWith(1, "ticket_attachment_add", {
|
||||
request: {
|
||||
projectId: "proj-1",
|
||||
ref: "#12",
|
||||
path: "/tmp/note.txt",
|
||||
expectedVersion: 3,
|
||||
mime: "text/plain",
|
||||
},
|
||||
});
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "ticket_attachment_read", {
|
||||
request: {
|
||||
projectId: "proj-1",
|
||||
ref: "#12",
|
||||
attachmentId: "att-1",
|
||||
},
|
||||
});
|
||||
expect(invoke).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"ticket_attachment_mark_summarized",
|
||||
{
|
||||
request: {
|
||||
projectId: "proj-1",
|
||||
ref: "#12",
|
||||
attachmentId: "att-1",
|
||||
expectedVersion: 4,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,6 +17,7 @@ import type {
|
||||
ReplyChunk,
|
||||
Sprint,
|
||||
Ticket,
|
||||
TicketAttachmentContent,
|
||||
TicketBulkResult,
|
||||
TicketCarnet,
|
||||
TicketChat,
|
||||
@ -134,6 +135,39 @@ export class TauriTicketGateway implements TicketGateway {
|
||||
});
|
||||
}
|
||||
|
||||
async addAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
path: string,
|
||||
expectedVersion: number,
|
||||
mime?: string | null,
|
||||
): Promise<Ticket> {
|
||||
return invoke<Ticket>("ticket_attachment_add", {
|
||||
request: { projectId, ref, path, expectedVersion, mime },
|
||||
});
|
||||
}
|
||||
|
||||
async readAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
): Promise<TicketAttachmentContent> {
|
||||
return invoke<TicketAttachmentContent>("ticket_attachment_read", {
|
||||
request: { projectId, ref, attachmentId },
|
||||
});
|
||||
}
|
||||
|
||||
async markAttachmentSummarized(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket> {
|
||||
return invoke<Ticket>("ticket_attachment_mark_summarized", {
|
||||
request: { projectId, ref, attachmentId, expectedVersion },
|
||||
});
|
||||
}
|
||||
|
||||
async link(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
|
||||
@ -1473,6 +1473,25 @@ export type TicketActor =
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "system" };
|
||||
|
||||
/** Metadata for a file attached to a ticket (mirror of `TicketAttachmentDto`). */
|
||||
export interface TicketAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
mime: string;
|
||||
sizeBytes: number;
|
||||
addedBy: TicketActor;
|
||||
addedAt: number;
|
||||
summarizedInCarnet: boolean;
|
||||
summarizedBy?: TicketActor | null;
|
||||
summarizedAt?: number | null;
|
||||
}
|
||||
|
||||
/** Raw ticket attachment content (mirror of `TicketAttachmentContentDto`). */
|
||||
export interface TicketAttachmentContent {
|
||||
attachment: TicketAttachment;
|
||||
contentBase64: string;
|
||||
}
|
||||
|
||||
/** One directed link from a ticket to another (mirror of `TicketLinkDto`). */
|
||||
export interface TicketLink {
|
||||
targetRef: TicketRef;
|
||||
@ -1510,6 +1529,7 @@ export interface Ticket {
|
||||
carnet?: string;
|
||||
links: TicketLink[];
|
||||
assignedAgentIds: string[];
|
||||
attachments: TicketAttachment[];
|
||||
createdBy: TicketActor;
|
||||
updatedBy: TicketActor;
|
||||
createdAt: number;
|
||||
|
||||
@ -76,6 +76,18 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function TicketDetail({
|
||||
projectId,
|
||||
ticketRef,
|
||||
@ -389,6 +401,72 @@ export function TicketDetail({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Attachments (#108) ── */}
|
||||
<Section title="Pièces jointes">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted">
|
||||
{t.attachments.length === 0
|
||||
? "Aucune pièce jointe."
|
||||
: `${t.attachments.length} pièce${
|
||||
t.attachments.length > 1 ? "s" : ""
|
||||
} jointe${t.attachments.length > 1 ? "s" : ""}.`}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={vm.busy}
|
||||
loading={vm.busy}
|
||||
onClick={() => void vm.attachFile()}
|
||||
>
|
||||
Joindre
|
||||
</Button>
|
||||
</div>
|
||||
{t.attachments.length > 0 && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{t.attachments.map((attachment) => (
|
||||
<li
|
||||
key={attachment.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-raised/30 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-content">
|
||||
{attachment.filename}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
{formatBytes(attachment.sizeBytes)} ·{" "}
|
||||
{attachment.mime || "application/octet-stream"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
attachment.summarizedInCarnet
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-raised text-muted",
|
||||
)}
|
||||
>
|
||||
Résumé : {attachment.summarizedInCarnet ? "oui" : "non"}
|
||||
</span>
|
||||
{!attachment.summarizedInCarnet && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={vm.busy}
|
||||
onClick={() =>
|
||||
void vm.markAttachmentSummarized(attachment.id)
|
||||
}
|
||||
aria-label={`marquer résumé ${attachment.filename}`}
|
||||
>
|
||||
Marquer résumé
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── Links (F5) ── */}
|
||||
<Section title="Linked tickets">
|
||||
{t.links.length === 0 ? (
|
||||
|
||||
@ -172,6 +172,46 @@ describe("MockTicketGateway", () => {
|
||||
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
|
||||
});
|
||||
|
||||
it("adds ticket attachments and marks them summarized (#108)", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "With attachment" });
|
||||
|
||||
const attached = await ticket.addAttachment(
|
||||
PROJECT_ID,
|
||||
t.ref,
|
||||
"/tmp/spec.pdf",
|
||||
t.version,
|
||||
"application/pdf",
|
||||
);
|
||||
expect(attached.version).toBe(2);
|
||||
expect(attached.attachments).toMatchObject([
|
||||
{
|
||||
filename: "spec.pdf",
|
||||
mime: "application/pdf",
|
||||
summarizedInCarnet: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const content = await ticket.readAttachment(
|
||||
PROJECT_ID,
|
||||
t.ref,
|
||||
attached.attachments[0].id,
|
||||
);
|
||||
expect(content.attachment.filename).toBe("spec.pdf");
|
||||
expect(content.contentBase64).toBeTruthy();
|
||||
|
||||
const summarized = await ticket.markAttachmentSummarized(
|
||||
PROJECT_ID,
|
||||
t.ref,
|
||||
attached.attachments[0].id,
|
||||
attached.version,
|
||||
);
|
||||
expect(summarized.version).toBe(3);
|
||||
expect(summarized.attachments[0]).toMatchObject({
|
||||
summarizedInCarnet: true,
|
||||
summarizedBy: { kind: "user" },
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => {
|
||||
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" });
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Doomed" });
|
||||
@ -515,6 +555,36 @@ describe("TicketsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows, adds, and marks ticket attachments from the detail (#108)", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Attachable" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("Attachable"));
|
||||
const dialog = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
|
||||
|
||||
expect(within(dialog).getByText("Pièces jointes")).toBeTruthy();
|
||||
expect(within(dialog).getByText("Aucune pièce jointe.")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByText("Joindre"));
|
||||
|
||||
expect(await within(dialog).findByText("mock-attachment.txt")).toBeTruthy();
|
||||
expect(within(dialog).getByText(/text\/plain/)).toBeTruthy();
|
||||
expect(within(dialog).getByText("Résumé : non")).toBeTruthy();
|
||||
|
||||
const attached = await ticket.read(PROJECT_ID, t.ref);
|
||||
expect(attached.attachments).toHaveLength(1);
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByLabelText("marquer résumé mock-attachment.txt"),
|
||||
);
|
||||
|
||||
expect(await within(dialog).findByText("Résumé : oui")).toBeTruthy();
|
||||
await waitFor(async () => {
|
||||
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
||||
expect(fresh.attachments[0].summarizedInCarnet).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("assigns only known project agents", async () => {
|
||||
const known = await seedAgent(agent, "Backend");
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Assignable" });
|
||||
|
||||
@ -45,6 +45,8 @@ export interface TicketDetailViewModel {
|
||||
priority?: Ticket["priority"];
|
||||
}) => Promise<boolean>;
|
||||
saveCarnet: (carnet: string) => Promise<boolean>;
|
||||
attachFile: () => Promise<boolean>;
|
||||
markAttachmentSummarized: (attachmentId: string) => Promise<boolean>;
|
||||
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
|
||||
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
|
||||
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
|
||||
@ -193,6 +195,26 @@ export function useTicketDetail(
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const attachFile: TicketDetailViewModel["attachFile"] = useCallback(async () => {
|
||||
if (!system) return false;
|
||||
try {
|
||||
const path = await system.pickFile();
|
||||
if (!path) return false;
|
||||
return run((version) => gateway.addAttachment(projectId, ref, path, version));
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return false;
|
||||
}
|
||||
}, [system, run, gateway, projectId, ref]);
|
||||
|
||||
const markAttachmentSummarized: TicketDetailViewModel["markAttachmentSummarized"] = useCallback(
|
||||
(attachmentId) =>
|
||||
run((version) =>
|
||||
gateway.markAttachmentSummarized(projectId, ref, attachmentId, version),
|
||||
),
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const link: TicketDetailViewModel["link"] = useCallback(
|
||||
(targetRef, kind) =>
|
||||
run((version) => gateway.link(projectId, ref, targetRef, kind, version)),
|
||||
@ -243,6 +265,8 @@ export function useTicketDetail(
|
||||
refresh,
|
||||
updateFields,
|
||||
saveCarnet,
|
||||
attachFile,
|
||||
markAttachmentSummarized,
|
||||
link,
|
||||
unlink,
|
||||
assign,
|
||||
|
||||
@ -28,6 +28,7 @@ function ticket(over: Partial<Ticket> = {}): Ticket {
|
||||
sprintId: null,
|
||||
links: [],
|
||||
assignedAgentIds: [],
|
||||
attachments: [],
|
||||
createdBy: { kind: "user" },
|
||||
updatedBy: { kind: "user" },
|
||||
createdAt: 1,
|
||||
|
||||
@ -68,6 +68,7 @@ import type {
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketBulkResult,
|
||||
TicketAttachmentContent,
|
||||
TicketChat,
|
||||
TicketCarnet,
|
||||
TicketLinkKind,
|
||||
@ -96,6 +97,12 @@ export interface SystemGateway {
|
||||
* cancelled. Same sanctioned-picker rule as {@link pickFolder}.
|
||||
*/
|
||||
pickArchiveFile(): Promise<string | null>;
|
||||
/**
|
||||
* Opens a native file picker for one local file and returns its path, or
|
||||
* `null` when cancelled. Used for ticket attachments; no file bytes cross the
|
||||
* frontend boundary.
|
||||
*/
|
||||
pickFile(): Promise<string | null>;
|
||||
/**
|
||||
* Subscribes to the app-exit work-in-progress guard (ticket #83): fired when
|
||||
* closing the main window is intercepted because it would interrupt active
|
||||
@ -1096,6 +1103,27 @@ export interface TicketGateway {
|
||||
carnet: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket>;
|
||||
/** Adds a local file path as a ticket attachment and returns the updated ticket. */
|
||||
addAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
path: string,
|
||||
expectedVersion: number,
|
||||
mime?: string | null,
|
||||
): Promise<Ticket>;
|
||||
/** Reads raw attachment content as base64. Not used by the minimal v1 UI. */
|
||||
readAttachment(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
): Promise<TicketAttachmentContent>;
|
||||
/** Marks an attachment as summarized in the ticket carnet. */
|
||||
markAttachmentSummarized(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
attachmentId: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket>;
|
||||
/** Links this ticket to another `#id` with the given relationship kind. */
|
||||
link(
|
||||
projectId: string,
|
||||
|
||||
Reference in New Issue
Block a user