diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 80715b3..4637a5a 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -27,6 +27,7 @@ import { TauriGitGateway } from "./git"; import { TauriPermissionGateway } from "./permission"; import { TauriWorkStateGateway } from "./workState"; import { TauriConversationGateway } from "./conversation"; +import { TauriTicketGateway } from "./ticket"; function notImplemented(what: string): never { const err: GatewayError = { @@ -61,6 +62,7 @@ export function createTauriGateways(): Gateways { permission: new TauriPermissionGateway(), workState: new TauriWorkStateGateway(), conversation: new TauriConversationGateway(), + ticket: new TauriTicketGateway(), }; } @@ -80,4 +82,5 @@ export { TauriPermissionGateway, TauriWorkStateGateway, TauriConversationGateway, + TauriTicketGateway, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index a520224..249d1b5 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -38,6 +38,11 @@ import type { SkillScope, Template, TerminalSession, + Ticket, + TicketCarnet, + TicketLink, + TicketLinkKind, + TicketList, TurnPage, TurnView, Unsubscribe, @@ -70,6 +75,10 @@ import type { TemplateGateway, TerminalGateway, TerminalHandle, + TicketGateway, + TicketListQuery, + CreateTicketInput, + UpdateTicketInput, WorkStateGateway, } from "@/ports"; import { normalizeProjectWorkState } from "../workStateNormalization"; @@ -1783,6 +1792,274 @@ export class MockConversationGateway implements ConversationGateway { } } +/** + * In-memory {@link TicketGateway} with real optimistic-concurrency semantics: + * every mutation checks `expectedVersion` against the stored version and rejects + * a stale write with an `INVALID` {@link GatewayError} whose message contains + * `"version conflict"` (mirroring the backend `IssueStoreError::VersionConflict` + * → `AppError::Invalid` mapping). Mutations emit the matching `Issue*` domain + * event through the injected {@link MockSystemGateway} so event-driven refresh + * can be exercised offline. + */ +export class MockTicketGateway implements TicketGateway { + private tickets = new Map>(); + private carnets = new Map>(); + private counters = new Map(); + + constructor(private readonly system?: MockSystemGateway) {} + + /** Seeds a ticket for deterministic tests; returns the stored clone. */ + _seedTicket(projectId: string, ticket: Ticket): Ticket { + const stored = structuredClone(ticket); + this.projectTickets(projectId).set(stored.ref, stored); + this.counters.set( + projectId, + Math.max(this.counters.get(projectId) ?? 0, stored.number), + ); + return structuredClone(stored); + } + + private projectTickets(projectId: string): Map { + let map = this.tickets.get(projectId); + if (!map) { + map = new Map(); + this.tickets.set(projectId, map); + } + return map; + } + + private projectCarnets(projectId: string): Map { + let map = this.carnets.get(projectId); + if (!map) { + map = new Map(); + this.carnets.set(projectId, map); + } + return map; + } + + private require(projectId: string, ref: string): Ticket { + const ticket = this.projectTickets(projectId).get(ref); + if (!ticket) { + throw { code: "NOT_FOUND", message: `ticket ${ref} not found` } as GatewayError; + } + return ticket; + } + + private guard(ticket: Ticket, expectedVersion: number): void { + if (ticket.version !== expectedVersion) { + throw { + code: "INVALID", + message: `issue version conflict: expected ${expectedVersion}, actual ${ticket.version}`, + } as GatewayError; + } + } + + async create(projectId: string, input: CreateTicketInput): Promise { + const number = (this.counters.get(projectId) ?? 0) + 1; + this.counters.set(projectId, number); + const now = Date.now(); + const ticket: Ticket = { + id: `mock-ticket-${projectId}-${number}`, + ref: `#${number}`, + number, + title: input.title, + description: input.description ?? "", + status: input.status ?? "open", + priority: input.priority ?? "medium", + links: [], + assignedAgentIds: [...(input.assignedAgentIds ?? [])], + createdBy: { kind: "user" }, + updatedBy: { kind: "user" }, + createdAt: now, + updatedAt: now, + version: 1, + }; + this.projectTickets(projectId).set(ticket.ref, ticket); + this.system?.emit({ + type: "issueCreated", + issueId: ticket.id, + issueRef: ticket.ref, + }); + return structuredClone(ticket); + } + + async read( + projectId: string, + ref: string, + includeCarnet = false, + ): Promise { + const ticket = structuredClone(this.require(projectId, ref)); + if (includeCarnet) { + ticket.carnet = this.projectCarnets(projectId).get(ref) ?? ""; + } + return ticket; + } + + async list(projectId: string, query?: TicketListQuery): Promise { + const q = query ?? {}; + const text = q.text?.trim().toLowerCase(); + const rows = [...this.projectTickets(projectId).values()] + .filter((t) => !q.status || t.status === q.status) + .filter((t) => !q.priority || t.priority === q.priority) + .filter( + (t) => + !q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId), + ) + .filter( + (t) => + !text || + t.title.toLowerCase().includes(text) || + t.description.toLowerCase().includes(text), + ) + .sort((a, b) => b.number - a.number); + const start = Number.parseInt(q.cursor ?? "0", 10) || 0; + const limit = Math.min(Math.max(q.limit ?? 100, 1), 500); + const end = Math.min(rows.length, start + limit); + return { + items: rows.slice(start, end).map((t) => ({ + ref: t.ref, + path: `.ideai/tickets/${t.number}.md`, + title: t.title, + status: t.status, + priority: t.priority, + assignedAgentIds: [...t.assignedAgentIds], + updatedAt: t.updatedAt, + })), + ...(end < rows.length ? { nextCursor: String(end) } : {}), + }; + } + + private bump(ticket: Ticket): void { + ticket.version += 1; + ticket.updatedAt = Date.now(); + ticket.updatedBy = { kind: "user" }; + } + + async update( + projectId: string, + ref: string, + input: UpdateTicketInput, + ): Promise { + const ticket = this.require(projectId, ref); + this.guard(ticket, input.expectedVersion); + if (input.title !== undefined) ticket.title = input.title; + if (input.description !== undefined) ticket.description = input.description; + if (input.status !== undefined) ticket.status = input.status; + if (input.priority !== undefined) ticket.priority = input.priority; + if (input.assignedAgentIds !== undefined) { + ticket.assignedAgentIds = [...input.assignedAgentIds]; + } + this.bump(ticket); + this.system?.emit({ + type: "issueUpdated", + issueRef: ref, + version: ticket.version, + }); + return structuredClone(ticket); + } + + async readCarnet(projectId: string, ref: string): Promise { + const ticket = this.require(projectId, ref); + return { + ref, + carnet: this.projectCarnets(projectId).get(ref) ?? "", + version: ticket.version, + }; + } + + async updateCarnet( + projectId: string, + ref: string, + carnet: string, + expectedVersion: number, + ): Promise { + const ticket = this.require(projectId, ref); + this.guard(ticket, expectedVersion); + this.projectCarnets(projectId).set(ref, carnet); + this.bump(ticket); + this.system?.emit({ + type: "issueCarnetUpdated", + issueRef: ref, + version: ticket.version, + }); + return structuredClone(ticket); + } + + async link( + projectId: string, + ref: string, + targetRef: string, + kind: TicketLinkKind, + expectedVersion: number, + ): Promise { + const ticket = this.require(projectId, ref); + this.guard(ticket, expectedVersion); + const exists = ticket.links.some( + (l: TicketLink) => l.targetRef === targetRef && l.kind === kind, + ); + if (!exists) ticket.links.push({ targetRef, kind }); + this.bump(ticket); + this.system?.emit({ + type: "issueLinked", + issueRef: ref, + target: targetRef, + kind, + version: ticket.version, + }); + return structuredClone(ticket); + } + + async unlink( + projectId: string, + ref: string, + targetRef: string, + expectedVersion: number, + kind?: TicketLinkKind, + ): Promise { + const ticket = this.require(projectId, ref); + this.guard(ticket, expectedVersion); + ticket.links = ticket.links.filter( + (l: TicketLink) => + l.targetRef !== targetRef || (kind !== undefined && l.kind !== kind), + ); + this.bump(ticket); + this.system?.emit({ + type: "issueUnlinked", + issueRef: ref, + target: targetRef, + kind: kind ?? "relatesTo", + version: ticket.version, + }); + return structuredClone(ticket); + } + + async assign( + projectId: string, + ref: string, + agentId: string, + assigned: boolean, + expectedVersion: number, + ): Promise { + const ticket = this.require(projectId, ref); + this.guard(ticket, expectedVersion); + const has = ticket.assignedAgentIds.includes(agentId); + if (assigned && !has) ticket.assignedAgentIds.push(agentId); + if (!assigned && has) { + ticket.assignedAgentIds = ticket.assignedAgentIds.filter( + (id) => id !== agentId, + ); + } + this.bump(ticket); + this.system?.emit({ + type: assigned ? "issueAgentAssigned" : "issueAgentUnassigned", + issueRef: ref, + agentId, + version: ticket.version, + }); + return structuredClone(ticket); + } +} + function mostRestrictive( project?: PermissionSet["fallback"], agent?: PermissionSet["fallback"], @@ -1796,8 +2073,9 @@ function mostRestrictive( /** Builds the full set of mock gateways. */ export function createMockGateways(): Gateways { const agentGateway = new MockAgentGateway(); + const systemGateway = new MockSystemGateway(); return { - system: new MockSystemGateway(), + system: systemGateway, agent: agentGateway, input: new MockInputGateway(), terminal: new MockTerminalGateway(), @@ -1813,6 +2091,7 @@ export function createMockGateways(): Gateways { permission: new MockPermissionGateway(), workState: new MockWorkStateGateway(), conversation: new MockConversationGateway(), + ticket: new MockTicketGateway(systemGateway), }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index dd3062a..4e9bf09 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -29,6 +29,7 @@ describe("createMockGateways", () => { "system", "template", "terminal", + "ticket", "workState", ]); }); diff --git a/frontend/src/adapters/ticket.ts b/frontend/src/adapters/ticket.ts new file mode 100644 index 0000000..3b25729 --- /dev/null +++ b/frontend/src/adapters/ticket.ts @@ -0,0 +1,125 @@ +/** + * 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 { invoke } from "@tauri-apps/api/core"; + +import type { + GatewayError, + Ticket, + TicketCarnet, + 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 { + return invoke("ticket_create", { + request: { projectId, ...input }, + }); + } + + async read( + projectId: string, + ref: string, + includeCarnet = false, + ): Promise { + return invoke("ticket_read", { + request: { projectId, ref, includeCarnet }, + }); + } + + async list(projectId: string, query?: TicketListQuery): Promise { + return invoke("ticket_list", { + request: { projectId, ...(query ?? {}) }, + }); + } + + async update( + projectId: string, + ref: string, + input: UpdateTicketInput, + ): Promise { + return invoke("ticket_update", { + request: { projectId, ref, ...input }, + }); + } + + async readCarnet(projectId: string, ref: string): Promise { + return invoke("ticket_read_carnet", { + request: { projectId, ref }, + }); + } + + async updateCarnet( + projectId: string, + ref: string, + carnet: string, + expectedVersion: number, + ): Promise { + return invoke("ticket_update_carnet", { + request: { projectId, ref, carnet, expectedVersion }, + }); + } + + async link( + projectId: string, + ref: string, + targetRef: string, + kind: TicketLinkKind, + expectedVersion: number, + ): Promise { + return invoke("ticket_link", { + request: { projectId, ref, targetRef, kind, expectedVersion }, + }); + } + + async unlink( + projectId: string, + ref: string, + targetRef: string, + expectedVersion: number, + kind?: TicketLinkKind, + ): Promise { + return invoke("ticket_unlink", { + request: { projectId, ref, targetRef, expectedVersion, kind }, + }); + } + + async assign( + projectId: string, + ref: string, + agentId: string, + assigned: boolean, + expectedVersion: number, + ): Promise { + return invoke("ticket_assign", { + request: { projectId, ref, agentId, assigned, expectedVersion }, + }); + } +} diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index ae67206..2dc4a0f 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -104,8 +104,60 @@ export type DomainEvent = */ source?: "mcp" | "file"; } + | { type: "issueCreated"; issueId: string; issueRef: string } + | { type: "issueUpdated"; issueRef: string; version: number } + | { + type: "issueStatusChanged"; + issueRef: string; + status: TicketStatus; + version: number; + } + | { + type: "issuePriorityChanged"; + issueRef: string; + priority: TicketPriority; + version: number; + } + | { type: "issueCarnetUpdated"; issueRef: string; version: number } + | { + type: "issueLinked"; + issueRef: string; + target: string; + kind: TicketLinkKind; + version: number; + } + | { + type: "issueUnlinked"; + issueRef: string; + target: string; + kind: TicketLinkKind; + version: number; + } + | { + type: "issueAgentAssigned"; + issueRef: string; + agentId: string; + version: number; + } + | { + type: "issueAgentUnassigned"; + issueRef: string; + agentId: string; + version: number; + } | { type: "ptyOutput"; sessionId: string; bytes: number[] }; +/** + * Whether a domain event is one of the ticket (`Issue*`) events. A small helper + * so ticket view-models refresh on any ticket mutation without re-enumerating + * every variant at each call site. + */ +export function isTicketEvent( + event: DomainEvent, +): event is Extract { + return event.type.startsWith("issue"); +} + /** Where a project physically lives (mirror of the backend `RemoteRef`). */ export type RemoteRef = | { kind: "local" } @@ -709,3 +761,84 @@ export interface GitBranches { branches: string[]; current: string | null; } + +// --------------------------------------------------------------------------- +// Tickets (public wire name for the backend `Issue` model — "ticket" is the +// user-visible term; all commands/DTOs/events speak `ticket`/`issue`). Mirrors +// the `Ticket*Dto` shapes in `crates/app-tauri/src/tickets.rs` (camelCase). +// --------------------------------------------------------------------------- + +/** A ticket reference on the wire, always `#` (e.g. `"#42"`). */ +export type TicketRef = string; + +/** Lifecycle status of a ticket. */ +export type TicketStatus = "open" | "inProgress" | "QA" | "closed"; + +/** Priority of a ticket. */ +export type TicketPriority = "low" | "medium" | "high" | "critical"; + +/** The kind of relationship between two tickets. */ +export type TicketLinkKind = + | "relatesTo" + | "blocks" + | "blockedBy" + | "duplicates" + | "dependsOn"; + +/** Who performed a ticket mutation (mirror of `TicketActorDto`, tagged `kind`). */ +export type TicketActor = + | { kind: "user" } + | { kind: "agent"; agentId: string } + | { kind: "system" }; + +/** One directed link from a ticket to another (mirror of `TicketLinkDto`). */ +export interface TicketLink { + targetRef: TicketRef; + kind: TicketLinkKind; +} + +/** The full ticket, as returned by `ticket_read`/`ticket_create`/… . */ +export interface Ticket { + id: string; + ref: TicketRef; + number: number; + title: string; + description: string; + status: TicketStatus; + priority: TicketPriority; + /** Present only when the ticket was read with `includeCarnet`. */ + carnet?: string; + links: TicketLink[]; + assignedAgentIds: string[]; + createdBy: TicketActor; + updatedBy: TicketActor; + createdAt: number; + updatedAt: number; + /** Optimistic-concurrency version; echoed back as `expectedVersion` on writes. */ + version: number; +} + +/** A ticket summary row from `ticket_list` (mirror of `TicketSummaryDto`). */ +export interface TicketSummary { + ref: TicketRef; + path: string; + title: string; + status: TicketStatus; + priority: TicketPriority; + assignedAgentIds: string[]; + updatedAt: number; +} + +/** One page of ticket summaries (mirror of `TicketListDto`). */ +export interface TicketList { + items: TicketSummary[]; + /** Opaque cursor for the next page, absent when the list is exhausted. */ + nextCursor?: string; +} + +/** The scoped-to-a-ticket Markdown carnet (mirror of `TicketCarnetDto`). */ +export interface TicketCarnet { + ref: TicketRef; + carnet: string; + version: number; +} diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 7915fd1..e11e83c 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -39,6 +39,7 @@ import { MemoryPanel } from "@/features/memory"; import { EmbedderSettings } from "@/features/embedder"; import { PermissionsPanel } from "@/features/permissions"; import { ProjectWorkStatePanel } from "@/features/workstate"; +import { TicketsView } from "@/features/tickets"; import { ConversationViewer } from "@/features/conversations"; import { GitPanel, GitGraphView } from "@/features/git"; import { Button, Input, Panel, Tabs, cn } from "@/shared"; @@ -50,6 +51,7 @@ type SidebarTab = | "projects" | "context" | "work" + | "tickets" | "agents" | "templates" | "skills" @@ -61,6 +63,7 @@ const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [ { id: "projects", label: "Projects" }, { id: "context", label: "Context" }, { id: "work", label: "Work" }, + { id: "tickets", label: "Tickets" }, { id: "agents", label: "Agents" }, { id: "templates", label: "Templates" }, { id: "skills", label: "Skills" }, @@ -304,6 +307,14 @@ export function ProjectsView() {

Open a project to view work state.

)} + {/* Tickets panel */} + {sidebarTab === "tickets" && active && ( + + )} + {sidebarTab === "tickets" && !active && ( +

Open a project to view tickets.

+ )} + {/* Agents panel — only rendered when active project exists */} {sidebarTab === "agents" && active && ( diff --git a/frontend/src/features/tickets/TicketDetail.tsx b/frontend/src/features/tickets/TicketDetail.tsx new file mode 100644 index 0000000..bd7f5ed --- /dev/null +++ b/frontend/src/features/tickets/TicketDetail.tsx @@ -0,0 +1,450 @@ +/** + * Ticket detail / edit overlay (F3, F4, F5, F7). + * + * A full-screen dialog mirroring the ticket: editable title/description, status + * and priority, the ticket-scoped Markdown **carnet** (replaceable save, F4), + * links to other tickets (F5) and agent assignments limited to known agents + * (F5). Optimistic-concurrency conflicts surface a clear banner and reload + * (F3). `#ref`s are clickable throughout (F7), and a one-click "delegation + * context" prefill lets the user hand an agent "work on #N" (F7). + */ + +import { useEffect, useState } from "react"; + +import type { TicketLinkKind } from "@/domain"; +import { Button, Input, Spinner, cn } from "@/shared"; +import { useTicketDetail } from "./useTicketDetail"; +import { useProjectAgents } from "./useProjectAgents"; +import { + PriorityBadge, + StatusBadge, + TICKET_LINK_KINDS, + TICKET_PRIORITIES, + TICKET_STATUSES, + TicketRef, + linkKindLabel, + linkifyTicketRefs, + priorityLabel, + statusLabel, +} from "./ticketMeta"; + +const selectClass = cn( + "h-9 rounded-md bg-raised px-3 text-sm text-content", + "border border-border outline-none transition-colors", + "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", +); + +const textareaClass = cn( + "w-full rounded-md bg-raised p-3 text-sm text-content", + "border border-border outline-none transition-colors", + "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", +); + +export interface TicketDetailProps { + projectId: string; + ticketRef: string; + onClose: () => void; + /** Navigate to another ticket (from a link or an inline `#ref`, F7). */ + onOpenRef: (ref: string) => void; +} + +async function copyToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + return false; +} + +function Section({ + title, + hint, + children, +}: { + title: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+
+

+ {title} +

+ {hint &&

{hint}

} +
+ {children} +
+ ); +} + +export function TicketDetail({ + projectId, + ticketRef, + onClose, + onOpenRef, +}: TicketDetailProps) { + const vm = useTicketDetail(projectId, ticketRef); + const { agents, nameOf } = useProjectAgents(projectId); + const t = vm.ticket; + + // Local draft state, re-seeded whenever the loaded ticket version changes so a + // conflict-reload or an external edit refreshes the fields. + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [carnet, setCarnet] = useState(""); + const [linkTarget, setLinkTarget] = useState(""); + const [linkKind, setLinkKind] = useState("relatesTo"); + const [assignPick, setAssignPick] = useState(""); + const [copied, setCopied] = useState(false); + + const version = t?.version; + useEffect(() => { + if (!t) return; + setTitle(t.title); + setDescription(t.description); + setCarnet(t.carnet ?? ""); + }, [t, version]); + + const dirtyFields = + !!t && (title !== t.title || description !== t.description); + const dirtyCarnet = !!t && carnet !== (t.carnet ?? ""); + const assignable = agents.filter((a) => !t?.assignedAgentIds.includes(a.id)); + + async function copyDelegation() { + if (!t) return; + const ok = await copyToClipboard( + `Travaille sur le ticket ${t.ref} : ${t.title}`, + ); + if (ok) { + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + } + } + + return ( +
+ {/* ── Header ── */} +
+
+ + {t && } + {t && } + + {t?.title ?? ticketRef} + +
+
+ + +
+
+ + {vm.conflict && ( +

+ This ticket was modified elsewhere and reloaded. Re-apply your change. +

+ )} + {vm.error && !vm.conflict && ( +

+ {vm.error} +

+ )} + + {vm.busy && !t ? ( +
+ + Loading ticket… +
+ ) : !t ? ( +
+ Ticket not found. +
+ ) : ( +
+ {/* ── Fields (F3) ── */} +
+ + setTitle(e.target.value)} + disabled={vm.busy} + /> + +