feat(tickets): surface frontend V1 du système de tickets
Surface UI complète des tickets (domaine Issue exposé « ticket »), validée QA de bout en bout : cargo build + tests backend verts, npm run build + npm test (459) verts. - Domaine : DTO Ticket, 9 events Issue* + guard isTicketEvent. - Ports : TicketGateway (+ types query/input) ajouté à Gateways. - Adapters : TauriTicketGateway réel (+ isTicketVersionConflict), wiring, et MockTicketGateway pour les tests. - Feature tickets : hooks (useTickets, useTicketDetail, useProjectAgents), TicketsPanel, TicketDetail, TicketsView, ticketMeta + tests. - ProjectsView : onglet sidebar « Tickets ». Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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,
|
||||
};
|
||||
|
||||
@ -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<string, Map<string, Ticket>>();
|
||||
private carnets = new Map<string, Map<string, string>>();
|
||||
private counters = new Map<string, number>();
|
||||
|
||||
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<string, Ticket> {
|
||||
let map = this.tickets.get(projectId);
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
this.tickets.set(projectId, map);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private projectCarnets(projectId: string): Map<string, string> {
|
||||
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<Ticket> {
|
||||
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<Ticket> {
|
||||
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<TicketList> {
|
||||
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<Ticket> {
|
||||
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<TicketCarnet> {
|
||||
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<Ticket> {
|
||||
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<Ticket> {
|
||||
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<Ticket> {
|
||||
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<Ticket> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ describe("createMockGateways", () => {
|
||||
"system",
|
||||
"template",
|
||||
"terminal",
|
||||
"ticket",
|
||||
"workState",
|
||||
]);
|
||||
});
|
||||
|
||||
125
frontend/src/adapters/ticket.ts
Normal file
125
frontend/src/adapters/ticket.ts
Normal file
@ -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<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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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<DomainEvent, { type: `issue${string}` }> {
|
||||
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 `#<n>` (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;
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
<p className="text-sm text-muted">Open a project to view work state.</p>
|
||||
)}
|
||||
|
||||
{/* Tickets panel */}
|
||||
{sidebarTab === "tickets" && active && (
|
||||
<TicketsView projectId={active.id} />
|
||||
)}
|
||||
{sidebarTab === "tickets" && !active && (
|
||||
<p className="text-sm text-muted">Open a project to view tickets.</p>
|
||||
)}
|
||||
|
||||
{/* Agents panel — only rendered when active project exists */}
|
||||
{sidebarTab === "agents" && active && (
|
||||
<AgentsPanel projectId={active.id} projectRoot={active.root} />
|
||||
|
||||
450
frontend/src/features/tickets/TicketDetail.tsx
Normal file
450
frontend/src/features/tickets/TicketDetail.tsx
Normal file
@ -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<boolean> {
|
||||
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 (
|
||||
<section className="flex flex-col gap-2 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
{title}
|
||||
</h4>
|
||||
{hint && <p className="mt-0.5 text-xs text-muted">{hint}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<TicketLinkKind>("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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`ticket ${ticketRef}`}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<TicketRef ticketRef={ticketRef} />
|
||||
{t && <StatusBadge status={t.status} />}
|
||||
{t && <PriorityBadge priority={t.priority} />}
|
||||
<span className="truncate text-sm font-medium text-content">
|
||||
{t?.title ?? ticketRef}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!t}
|
||||
onClick={() => void copyDelegation()}
|
||||
title="Copy a delegation prompt to hand this ticket to an agent"
|
||||
>
|
||||
{copied ? "Copied" : "Delegation context"}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{vm.conflict && (
|
||||
<p
|
||||
role="alert"
|
||||
className="shrink-0 border-b border-warning/40 bg-warning/10 px-5 py-2 text-sm text-warning"
|
||||
>
|
||||
This ticket was modified elsewhere and reloaded. Re-apply your change.
|
||||
</p>
|
||||
)}
|
||||
{vm.error && !vm.conflict && (
|
||||
<p
|
||||
role="alert"
|
||||
className="shrink-0 border-b border-danger/40 bg-danger/10 px-5 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{vm.busy && !t ? (
|
||||
<div className="flex flex-1 items-center gap-2 p-5 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>Loading ticket…</span>
|
||||
</div>
|
||||
) : !t ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted">
|
||||
Ticket not found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* ── Fields (F3) ── */}
|
||||
<Section title="Details">
|
||||
<label className="text-xs font-medium text-muted" htmlFor="td-title">
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
id="td-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={vm.busy}
|
||||
/>
|
||||
<label
|
||||
className="mt-2 text-xs font-medium text-muted"
|
||||
htmlFor="td-desc"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="td-desc"
|
||||
className={cn(textareaClass, "min-h-24")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={vm.busy}
|
||||
/>
|
||||
{description.trim() && (
|
||||
<p className="whitespace-pre-wrap break-words rounded-md bg-raised/40 px-3 py-2 text-xs text-muted">
|
||||
{linkifyTicketRefs(description, onOpenRef)}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!dirtyFields || vm.busy}
|
||||
loading={vm.busy && dirtyFields}
|
||||
onClick={() =>
|
||||
void vm.updateFields({ title, description })
|
||||
}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
{dirtyFields && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={vm.busy}
|
||||
onClick={() => {
|
||||
setTitle(t.title);
|
||||
setDescription(t.description);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Status & priority (F3) ── */}
|
||||
<Section title="Status & priority">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-muted">
|
||||
Status
|
||||
<select
|
||||
aria-label="ticket status"
|
||||
className={selectClass}
|
||||
value={t.status}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) =>
|
||||
void vm.updateFields({
|
||||
status: e.target.value as (typeof TICKET_STATUSES)[number],
|
||||
})
|
||||
}
|
||||
>
|
||||
{TICKET_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted">
|
||||
Priority
|
||||
<select
|
||||
aria-label="ticket priority"
|
||||
className={selectClass}
|
||||
value={t.priority}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) =>
|
||||
void vm.updateFields({
|
||||
priority:
|
||||
e.target.value as (typeof TICKET_PRIORITIES)[number],
|
||||
})
|
||||
}
|
||||
>
|
||||
{TICKET_PRIORITIES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{priorityLabel(p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Carnet (F4) ── */}
|
||||
<Section
|
||||
title="Carnet"
|
||||
hint="Markdown notebook scoped to this ticket only — separate from project memory. Saving replaces the whole carnet."
|
||||
>
|
||||
<textarea
|
||||
aria-label="ticket carnet"
|
||||
className={cn(textareaClass, "min-h-40 font-mono")}
|
||||
value={carnet}
|
||||
onChange={(e) => setCarnet(e.target.value)}
|
||||
disabled={vm.busy}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!dirtyCarnet || vm.busy}
|
||||
loading={vm.busy && dirtyCarnet}
|
||||
onClick={() => void vm.saveCarnet(carnet)}
|
||||
>
|
||||
Save carnet
|
||||
</Button>
|
||||
{dirtyCarnet && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={vm.busy}
|
||||
onClick={() => setCarnet(t.carnet ?? "")}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Links (F5) ── */}
|
||||
<Section title="Linked tickets">
|
||||
{t.links.length === 0 ? (
|
||||
<p className="text-xs text-muted">No linked tickets.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{t.links.map((l) => (
|
||||
<li
|
||||
key={`${l.kind}-${l.targetRef}`}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span className="text-xs text-muted">
|
||||
{linkKindLabel(l.kind)}
|
||||
</span>
|
||||
<TicketRef ticketRef={l.targetRef} onOpen={onOpenRef} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.unlink(l.targetRef, l.kind)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
aria-label="link kind"
|
||||
className={selectClass}
|
||||
value={linkKind}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) => setLinkKind(e.target.value as TicketLinkKind)}
|
||||
>
|
||||
{TICKET_LINK_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{linkKindLabel(k)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
aria-label="link target ref"
|
||||
placeholder="#id"
|
||||
className="w-24"
|
||||
value={linkTarget}
|
||||
onChange={(e) => setLinkTarget(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!linkTarget.trim() || vm.busy}
|
||||
onClick={async () => {
|
||||
const target = linkTarget.trim().startsWith("#")
|
||||
? linkTarget.trim()
|
||||
: `#${linkTarget.trim()}`;
|
||||
const ok = await vm.link(target, linkKind);
|
||||
if (ok) setLinkTarget("");
|
||||
}}
|
||||
>
|
||||
Add link
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Assignments (F5) ── */}
|
||||
<Section
|
||||
title="Assigned agents"
|
||||
hint="Only agents known to this project can be assigned."
|
||||
>
|
||||
{t.assignedAgentIds.length === 0 ? (
|
||||
<p className="text-xs text-muted">No agents assigned.</p>
|
||||
) : (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{t.assignedAgentIds.map((id) => (
|
||||
<li
|
||||
key={id}
|
||||
className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content"
|
||||
>
|
||||
{nameOf(id)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`unassign ${nameOf(id)}`}
|
||||
className="text-muted hover:text-danger disabled:opacity-50"
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.assign(id, false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<select
|
||||
aria-label="assign agent"
|
||||
className={selectClass}
|
||||
value={assignPick}
|
||||
disabled={vm.busy || assignable.length === 0}
|
||||
onChange={(e) => setAssignPick(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{assignable.length === 0
|
||||
? "No more agents"
|
||||
: "Select an agent…"}
|
||||
</option>
|
||||
{assignable.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!assignPick || vm.busy}
|
||||
onClick={async () => {
|
||||
const ok = await vm.assign(assignPick, true);
|
||||
if (ok) setAssignPick("");
|
||||
}}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
frontend/src/features/tickets/TicketsPanel.tsx
Normal file
256
frontend/src/features/tickets/TicketsPanel.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Tickets list panel (F2) — the project's ticket board in the sidebar.
|
||||
*
|
||||
* Filterable by status / priority / assignee + free-text search; each row shows
|
||||
* the `#ref`, title, status, priority and assigned agents. Clicking a row (or
|
||||
* its `#ref`) opens the detail via `onOpen`. Refreshes live on `Issue*` events
|
||||
* through {@link useTickets}.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { TicketPriority, TicketStatus } from "@/domain";
|
||||
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
||||
import { useTickets } from "./useTickets";
|
||||
import { useProjectAgents } from "./useProjectAgents";
|
||||
import {
|
||||
PriorityBadge,
|
||||
StatusBadge,
|
||||
TICKET_PRIORITIES,
|
||||
TICKET_STATUSES,
|
||||
TicketRef,
|
||||
priorityLabel,
|
||||
statusLabel,
|
||||
} from "./ticketMeta";
|
||||
|
||||
const selectClass = cn(
|
||||
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
);
|
||||
|
||||
export interface TicketsPanelProps {
|
||||
projectId: string;
|
||||
/** Opens the detail overlay for the given `#ref` (F7). */
|
||||
onOpen: (ref: string) => void;
|
||||
}
|
||||
|
||||
export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
const vm = useTickets(projectId);
|
||||
const { agents, nameOf } = useProjectAgents(projectId);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newPriority, setNewPriority] = useState<TicketPriority>("medium");
|
||||
|
||||
const items = vm.list?.items ?? [];
|
||||
|
||||
async function submitCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim()) return;
|
||||
const created = await vm.create({
|
||||
title: newTitle.trim(),
|
||||
priority: newPriority,
|
||||
});
|
||||
if (created) {
|
||||
setNewTitle("");
|
||||
setNewPriority("medium");
|
||||
setShowCreate(false);
|
||||
onOpen(created.ref);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Tickets"
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowCreate((v) => !v)}
|
||||
>
|
||||
{showCreate ? "Cancel" : "New"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void vm.refresh()}
|
||||
loading={vm.busy}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<form
|
||||
onSubmit={submitCreate}
|
||||
className="mb-3 flex flex-col gap-2 rounded-md border border-border bg-raised/50 p-2"
|
||||
>
|
||||
<Input
|
||||
aria-label="new ticket title"
|
||||
placeholder="Ticket title"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
aria-label="new ticket priority"
|
||||
className={selectClass}
|
||||
value={newPriority}
|
||||
onChange={(e) => setNewPriority(e.target.value as TicketPriority)}
|
||||
>
|
||||
{TICKET_PRIORITIES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{priorityLabel(p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!newTitle.trim() || vm.busy}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ── Filters ── */}
|
||||
<div className="mb-3 flex flex-col gap-2">
|
||||
<Input
|
||||
aria-label="search tickets"
|
||||
placeholder="Search title/description…"
|
||||
value={vm.query.text ?? ""}
|
||||
onChange={(e) =>
|
||||
vm.setQuery({ ...vm.query, text: e.target.value || undefined })
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
aria-label="filter by status"
|
||||
className={selectClass}
|
||||
value={vm.query.status ?? ""}
|
||||
onChange={(e) =>
|
||||
vm.setQuery({
|
||||
...vm.query,
|
||||
status: (e.target.value || undefined) as TicketStatus | undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{TICKET_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="filter by priority"
|
||||
className={selectClass}
|
||||
value={vm.query.priority ?? ""}
|
||||
onChange={(e) =>
|
||||
vm.setQuery({
|
||||
...vm.query,
|
||||
priority: (e.target.value || undefined) as
|
||||
| TicketPriority
|
||||
| undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">All priorities</option>
|
||||
{TICKET_PRIORITIES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{priorityLabel(p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="filter by assignee"
|
||||
className={selectClass}
|
||||
value={vm.query.assignedAgentId ?? ""}
|
||||
onChange={(e) =>
|
||||
vm.setQuery({
|
||||
...vm.query,
|
||||
assignedAgentId: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">All assignees</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── List ── */}
|
||||
{vm.busy && vm.list === null ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>Loading tickets…</span>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-muted">No tickets.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{items.map((t) => (
|
||||
<li key={t.ref} className="py-2 first:pt-0 last:pb-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<TicketRef ticketRef={t.ref} onOpen={onOpen} className="mt-0.5" />
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => onOpen(t.ref)}
|
||||
>
|
||||
<span className="block truncate text-sm font-medium text-content hover:underline">
|
||||
{t.title}
|
||||
</span>
|
||||
{t.assignedAgentIds.length > 0 && (
|
||||
<span className="mt-0.5 block truncate text-xs text-muted">
|
||||
{t.assignedAgentIds.map(nameOf).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
<StatusBadge status={t.status} />
|
||||
<PriorityBadge priority={t.priority} />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{vm.list?.nextCursor && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={vm.busy}
|
||||
onClick={() =>
|
||||
vm.setQuery({
|
||||
...vm.query,
|
||||
limit: (vm.query.limit ?? 100) + 100,
|
||||
})
|
||||
}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
34
frontend/src/features/tickets/TicketsView.tsx
Normal file
34
frontend/src/features/tickets/TicketsView.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Tickets feature container: the sidebar list (F2) plus the full-screen detail
|
||||
* overlay (F3/F4/F5/F7). Owns the currently-open `#ref` so a click in the list
|
||||
* — or on any inline `#ref` inside the detail — swaps the overlay to that
|
||||
* ticket.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { TicketsPanel } from "./TicketsPanel";
|
||||
import { TicketDetail } from "./TicketDetail";
|
||||
|
||||
export interface TicketsViewProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function TicketsView({ projectId }: TicketsViewProps) {
|
||||
const [openRef, setOpenRef] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TicketsPanel projectId={projectId} onOpen={setOpenRef} />
|
||||
{openRef && (
|
||||
<TicketDetail
|
||||
key={`${projectId}-${openRef}`}
|
||||
projectId={projectId}
|
||||
ticketRef={openRef}
|
||||
onClose={() => setOpenRef(null)}
|
||||
onOpenRef={setOpenRef}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
frontend/src/features/tickets/index.ts
Normal file
22
frontend/src/features/tickets/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/** Tickets feature (F1–F5, F7): project ticket board, detail/edit, carnet,
|
||||
* links & assignments, and clickable `#ref` navigation. */
|
||||
|
||||
export { TicketsView } from "./TicketsView";
|
||||
export type { TicketsViewProps } from "./TicketsView";
|
||||
export { TicketsPanel } from "./TicketsPanel";
|
||||
export type { TicketsPanelProps } from "./TicketsPanel";
|
||||
export { TicketDetail } from "./TicketDetail";
|
||||
export type { TicketDetailProps } from "./TicketDetail";
|
||||
export { useTickets } from "./useTickets";
|
||||
export type { TicketsViewModel } from "./useTickets";
|
||||
export { useTicketDetail } from "./useTicketDetail";
|
||||
export type { TicketDetailViewModel } from "./useTicketDetail";
|
||||
export {
|
||||
TicketRef,
|
||||
StatusBadge,
|
||||
PriorityBadge,
|
||||
linkifyTicketRefs,
|
||||
statusLabel,
|
||||
priorityLabel,
|
||||
linkKindLabel,
|
||||
} from "./ticketMeta";
|
||||
169
frontend/src/features/tickets/ticketMeta.tsx
Normal file
169
frontend/src/features/tickets/ticketMeta.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Shared presentational helpers for tickets: human labels, badge styling, and
|
||||
* the clickable `#ref` element (F7). Kept pure/presentational so both the list
|
||||
* and the detail surfaces render tickets consistently.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { TicketLinkKind, TicketPriority, TicketStatus } from "@/domain";
|
||||
import { cn } from "@/shared";
|
||||
|
||||
export const TICKET_STATUSES: TicketStatus[] = [
|
||||
"open",
|
||||
"inProgress",
|
||||
"QA",
|
||||
"closed",
|
||||
];
|
||||
|
||||
export const TICKET_PRIORITIES: TicketPriority[] = [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"critical",
|
||||
];
|
||||
|
||||
export const TICKET_LINK_KINDS: TicketLinkKind[] = [
|
||||
"relatesTo",
|
||||
"blocks",
|
||||
"blockedBy",
|
||||
"duplicates",
|
||||
"dependsOn",
|
||||
];
|
||||
|
||||
export function statusLabel(status: TicketStatus): string {
|
||||
switch (status) {
|
||||
case "open":
|
||||
return "Open";
|
||||
case "inProgress":
|
||||
return "In progress";
|
||||
case "QA":
|
||||
return "QA";
|
||||
case "closed":
|
||||
return "Closed";
|
||||
}
|
||||
}
|
||||
|
||||
export function priorityLabel(priority: TicketPriority): string {
|
||||
return priority.charAt(0).toUpperCase() + priority.slice(1);
|
||||
}
|
||||
|
||||
export function linkKindLabel(kind: TicketLinkKind): string {
|
||||
switch (kind) {
|
||||
case "relatesTo":
|
||||
return "relates to";
|
||||
case "blocks":
|
||||
return "blocks";
|
||||
case "blockedBy":
|
||||
return "blocked by";
|
||||
case "duplicates":
|
||||
return "duplicates";
|
||||
case "dependsOn":
|
||||
return "depends on";
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(status: TicketStatus): string {
|
||||
switch (status) {
|
||||
case "open":
|
||||
return "bg-raised text-muted";
|
||||
case "inProgress":
|
||||
return "bg-warning/15 text-warning";
|
||||
case "QA":
|
||||
return "bg-primary/15 text-primary";
|
||||
case "closed":
|
||||
return "bg-success/15 text-success";
|
||||
}
|
||||
}
|
||||
|
||||
function priorityClass(priority: TicketPriority): string {
|
||||
switch (priority) {
|
||||
case "low":
|
||||
return "bg-raised text-muted";
|
||||
case "medium":
|
||||
return "bg-primary/10 text-primary";
|
||||
case "high":
|
||||
return "bg-warning/15 text-warning";
|
||||
case "critical":
|
||||
return "bg-danger/15 text-danger";
|
||||
}
|
||||
}
|
||||
|
||||
function badgeBase(className?: string): string {
|
||||
return cn(
|
||||
"inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
className,
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: TicketStatus }) {
|
||||
return <span className={badgeBase(statusClass(status))}>{statusLabel(status)}</span>;
|
||||
}
|
||||
|
||||
export function PriorityBadge({ priority }: { priority: TicketPriority }) {
|
||||
return (
|
||||
<span className={badgeBase(priorityClass(priority))}>
|
||||
{priorityLabel(priority)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A clickable ticket reference badge (`#42`). Renders as a button when
|
||||
* `onOpen` is provided (F7 — opens the ticket detail), else as plain text.
|
||||
*/
|
||||
export function TicketRef({
|
||||
ticketRef,
|
||||
onOpen,
|
||||
className,
|
||||
}: {
|
||||
ticketRef: string;
|
||||
onOpen?: (ref: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const base = cn(
|
||||
"inline-flex shrink-0 items-center rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content",
|
||||
className,
|
||||
);
|
||||
if (!onOpen) return <code className={base}>{ticketRef}</code>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`open ticket ${ticketRef}`}
|
||||
className={cn(base, "transition-colors hover:bg-primary/15 hover:text-primary")}
|
||||
onClick={() => onOpen(ticketRef)}
|
||||
>
|
||||
{ticketRef}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const REF_PATTERN = /#\d+/g;
|
||||
|
||||
/**
|
||||
* Linkifies inline `#42` references inside arbitrary text (F7). Non-ref segments
|
||||
* are preserved verbatim; each `#n` becomes a clickable {@link TicketRef} when
|
||||
* `onOpen` is provided.
|
||||
*/
|
||||
export function linkifyTicketRefs(
|
||||
text: string,
|
||||
onOpen?: (ref: string) => void,
|
||||
): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let index = 0;
|
||||
for (const match of text.matchAll(REF_PATTERN)) {
|
||||
const start = match.index ?? 0;
|
||||
if (start > last) out.push(text.slice(last, start));
|
||||
out.push(
|
||||
<TicketRef
|
||||
key={`ref-${index++}-${start}`}
|
||||
ticketRef={match[0]}
|
||||
onOpen={onOpen}
|
||||
/>,
|
||||
);
|
||||
last = start + match[0].length;
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last));
|
||||
return out;
|
||||
}
|
||||
245
frontend/src/features/tickets/tickets.test.tsx
Normal file
245
frontend/src/features/tickets/tickets.test.tsx
Normal file
@ -0,0 +1,245 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockSystemGateway,
|
||||
MockTicketGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { Agent } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { TicketsView } from "./TicketsView";
|
||||
import { TicketDetail } from "./TicketDetail";
|
||||
|
||||
const PROJECT_ID = "project-tickets-test";
|
||||
|
||||
function stubClipboard() {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
return writeText;
|
||||
}
|
||||
|
||||
async function seedAgent(agent: MockAgentGateway, name: string): Promise<Agent> {
|
||||
return agent.createAgent(PROJECT_ID, { name, profileId: "p-1" });
|
||||
}
|
||||
|
||||
function renderView(
|
||||
ticket: MockTicketGateway,
|
||||
system: MockSystemGateway,
|
||||
agent: MockAgentGateway,
|
||||
) {
|
||||
const gateways = { ticket, system, agent } as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TicketsView projectId={PROJECT_ID} />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MockTicketGateway", () => {
|
||||
let system: MockSystemGateway;
|
||||
let ticket: MockTicketGateway;
|
||||
|
||||
beforeEach(() => {
|
||||
system = new MockSystemGateway();
|
||||
ticket = new MockTicketGateway(system);
|
||||
});
|
||||
|
||||
it("assigns sequential #refs and emits issueCreated", async () => {
|
||||
const events: string[] = [];
|
||||
await system.onDomainEvent((e) => events.push(e.type));
|
||||
|
||||
const a = await ticket.create(PROJECT_ID, { title: "First" });
|
||||
const b = await ticket.create(PROJECT_ID, { title: "Second" });
|
||||
|
||||
expect(a.ref).toBe("#1");
|
||||
expect(b.ref).toBe("#2");
|
||||
expect(b.version).toBe(1);
|
||||
expect(events).toContain("issueCreated");
|
||||
});
|
||||
|
||||
it("filters by status and searches text", async () => {
|
||||
await ticket.create(PROJECT_ID, { title: "alpha bug", status: "open" });
|
||||
const t2 = await ticket.create(PROJECT_ID, { title: "beta task" });
|
||||
await ticket.update(PROJECT_ID, t2.ref, {
|
||||
status: "closed",
|
||||
expectedVersion: t2.version,
|
||||
});
|
||||
|
||||
const open = await ticket.list(PROJECT_ID, { status: "open" });
|
||||
expect(open.items.map((i) => i.ref)).toEqual(["#1"]);
|
||||
|
||||
const search = await ticket.list(PROJECT_ID, { text: "beta" });
|
||||
expect(search.items.map((i) => i.ref)).toEqual(["#2"]);
|
||||
});
|
||||
|
||||
it("rejects a stale write with a version conflict", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "x" });
|
||||
await ticket.update(PROJECT_ID, t.ref, {
|
||||
title: "y",
|
||||
expectedVersion: t.version,
|
||||
});
|
||||
|
||||
await expect(
|
||||
ticket.update(PROJECT_ID, t.ref, {
|
||||
title: "z",
|
||||
expectedVersion: t.version, // stale (1, now 2)
|
||||
}),
|
||||
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
|
||||
});
|
||||
|
||||
it("links and unlinks tickets, bumping the version", async () => {
|
||||
const a = await ticket.create(PROJECT_ID, { title: "a" });
|
||||
await ticket.create(PROJECT_ID, { title: "b" });
|
||||
const linked = await ticket.link(PROJECT_ID, a.ref, "#2", "blocks", a.version);
|
||||
expect(linked.links).toEqual([{ targetRef: "#2", kind: "blocks" }]);
|
||||
const unlinked = await ticket.unlink(
|
||||
PROJECT_ID,
|
||||
a.ref,
|
||||
"#2",
|
||||
linked.version,
|
||||
"blocks",
|
||||
);
|
||||
expect(unlinked.links).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TicketsView", () => {
|
||||
let system: MockSystemGateway;
|
||||
let ticket: MockTicketGateway;
|
||||
let agent: MockAgentGateway;
|
||||
|
||||
beforeEach(() => {
|
||||
system = new MockSystemGateway();
|
||||
ticket = new MockTicketGateway(system);
|
||||
agent = new MockAgentGateway();
|
||||
});
|
||||
|
||||
it("shows the empty state then a created ticket", async () => {
|
||||
renderView(ticket, system, agent);
|
||||
expect(await screen.findByText("No tickets.")).toBeTruthy();
|
||||
|
||||
await ticket.create(PROJECT_ID, { title: "Live created" });
|
||||
// The panel refreshes on the issueCreated event.
|
||||
expect(await screen.findByText("Live created")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the detail and edits status with a version bump", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("Editable"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
const statusSelect = within(dialog).getByLabelText("ticket status");
|
||||
fireEvent.change(statusSelect, { target: { value: "inProgress" } });
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
||||
expect(fresh.status).toBe("inProgress");
|
||||
expect(fresh.version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("saves the ticket-scoped carnet (replaceable)", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "WithCarnet" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("WithCarnet"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText("ticket carnet"), {
|
||||
target: { value: "## notes\nscoped body" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByText("Save carnet"));
|
||||
|
||||
await waitFor(async () => {
|
||||
const carnet = await ticket.readCarnet(PROJECT_ID, t.ref);
|
||||
expect(carnet.carnet).toBe("## notes\nscoped body");
|
||||
});
|
||||
});
|
||||
|
||||
it("assigns only known project agents", async () => {
|
||||
const known = await seedAgent(agent, "Backend");
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Assignable" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("Assignable"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText("assign agent"), {
|
||||
target: { value: known.id },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByText("Assign"));
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
||||
expect(fresh.assignedAgentIds).toEqual([known.id]);
|
||||
});
|
||||
expect(within(dialog).getAllByText("Backend").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("surfaces a version-conflict banner and reloads (F3)", async () => {
|
||||
// No system gateway ⇒ the detail does not auto-refresh on events, so we can
|
||||
// deterministically make its held version go stale under an external write.
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Racy" });
|
||||
const gateways = { ticket, agent } as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TicketDetail
|
||||
projectId={PROJECT_ID}
|
||||
ticketRef={t.ref}
|
||||
onClose={() => {}}
|
||||
onOpenRef={() => {}}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
// The detail now holds version 1. Bump the store to version 2 behind its back.
|
||||
await ticket.update(PROJECT_ID, t.ref, {
|
||||
description: "external",
|
||||
expectedVersion: t.version,
|
||||
});
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText("Title"), {
|
||||
target: { value: "Racy edited" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByText("Save changes"));
|
||||
|
||||
expect(
|
||||
await within(dialog).findByText(/modified elsewhere and reloaded/i),
|
||||
).toBeTruthy();
|
||||
// After the reload the detail field reflects the reloaded (title unchanged)
|
||||
// ticket rather than the abandoned local edit.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(within(dialog).getByLabelText("Title") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("Racy"),
|
||||
);
|
||||
});
|
||||
|
||||
it("copies a delegation context prompt (F7)", async () => {
|
||||
const writeText = stubClipboard();
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Delegate me" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("Delegate me"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByText("Delegation context"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
`Travaille sur le ticket ${t.ref} : Delegate me`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
47
frontend/src/features/tickets/useProjectAgents.ts
Normal file
47
frontend/src/features/tickets/useProjectAgents.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Minimal read-only list of a project's agents, used by the ticket surfaces to
|
||||
* (a) resolve agent ids to names and (b) offer only *known* agents when
|
||||
* assigning (F5). Deliberately lighter than the full `useAgents` view-model —
|
||||
* tickets never launch or mutate agents.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { Agent } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
export interface ProjectAgents {
|
||||
agents: Agent[];
|
||||
/** Maps an agent id to a display name, falling back to a short id. */
|
||||
nameOf: (agentId: string) => string;
|
||||
}
|
||||
|
||||
export function useProjectAgents(projectId: string): ProjectAgents {
|
||||
const { agent } = useGateways();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void agent
|
||||
.listAgents(projectId)
|
||||
.then((list) => {
|
||||
if (!cancelled) setAgents(list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAgents([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, agent]);
|
||||
|
||||
const byId = useMemo(
|
||||
() => new Map(agents.map((a) => [a.id, a.name])),
|
||||
[agents],
|
||||
);
|
||||
|
||||
const nameOf = (agentId: string): string =>
|
||||
byId.get(agentId) ?? `${agentId.slice(0, 8)}…`;
|
||||
|
||||
return { agents, nameOf };
|
||||
}
|
||||
179
frontend/src/features/tickets/useTicketDetail.ts
Normal file
179
frontend/src/features/tickets/useTicketDetail.ts
Normal file
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* View-model hook for a single ticket's detail/edit surface (F3/F4/F5).
|
||||
*
|
||||
* Owns optimistic-concurrency handling: every mutation sends the ticket's
|
||||
* current `version` as `expectedVersion`. When the backend reports a version
|
||||
* conflict (the ticket moved underneath — e.g. an agent edited it), the hook
|
||||
* flips `conflict`, reloads the fresh ticket and surfaces a clear message so the
|
||||
* user can re-apply their change against the new version.
|
||||
*
|
||||
* The loaded ticket always includes its carnet body (F4); mutations that return
|
||||
* a carnet-less ticket preserve the last known carnet so the editor never blanks
|
||||
* out unexpectedly.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { isTicketEvent, type GatewayError, type Ticket, type TicketLinkKind } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
export interface TicketDetailViewModel {
|
||||
ticket: Ticket | null;
|
||||
error: string | null;
|
||||
/** Set when the last write lost an optimistic-concurrency race (F3). */
|
||||
conflict: boolean;
|
||||
busy: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
updateFields: (input: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: Ticket["status"];
|
||||
priority?: Ticket["priority"];
|
||||
}) => Promise<boolean>;
|
||||
saveCarnet: (carnet: string) => Promise<boolean>;
|
||||
link: (targetRef: string, kind: TicketLinkKind) => Promise<boolean>;
|
||||
unlink: (targetRef: string, kind?: TicketLinkKind) => Promise<boolean>;
|
||||
assign: (agentId: string, assigned: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
/** A generic `INVALID` from the backend whose message flags a version conflict. */
|
||||
function isVersionConflict(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
typeof (e as GatewayError).message === "string" &&
|
||||
(e as GatewayError).message.includes("version conflict")
|
||||
);
|
||||
}
|
||||
|
||||
export function useTicketDetail(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
): TicketDetailViewModel {
|
||||
const { ticket: gateway, system } = useGateways();
|
||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setTicket(await gateway.read(projectId, ref, true));
|
||||
setConflict(false);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [projectId, ref, gateway]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Refresh when *another* actor mutates this same ticket while it is open.
|
||||
useEffect(() => {
|
||||
if (!system) return;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void system
|
||||
.onDomainEvent((event) => {
|
||||
if (isTicketEvent(event) && event.issueRef === ref) void refresh();
|
||||
})
|
||||
.then((u) => {
|
||||
if (cancelled) u();
|
||||
else unsubscribe = u;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [refresh, system, ref]);
|
||||
|
||||
/**
|
||||
* Runs one mutation, preserving the loaded carnet (mutations return a
|
||||
* carnet-less ticket) and translating a version conflict into `conflict` +
|
||||
* a reload so the UI can prompt a clean retry.
|
||||
*/
|
||||
const run = useCallback(
|
||||
async (op: (version: number) => Promise<Ticket>, carnet?: string): Promise<boolean> => {
|
||||
if (!ticket) return false;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await op(ticket.version);
|
||||
setTicket({
|
||||
...updated,
|
||||
carnet: carnet !== undefined ? carnet : ticket.carnet,
|
||||
});
|
||||
setConflict(false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (isVersionConflict(e)) {
|
||||
setConflict(true);
|
||||
setError(
|
||||
"This ticket was modified elsewhere. It has been reloaded — re-apply your change.",
|
||||
);
|
||||
await refresh();
|
||||
} else {
|
||||
setError(describe(e));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[ticket, gateway, refresh],
|
||||
);
|
||||
|
||||
const updateFields: TicketDetailViewModel["updateFields"] = useCallback(
|
||||
(input) => run((version) => gateway.update(projectId, ref, { ...input, expectedVersion: version })),
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const saveCarnet: TicketDetailViewModel["saveCarnet"] = useCallback(
|
||||
(carnet) =>
|
||||
run((version) => gateway.updateCarnet(projectId, ref, carnet, version), carnet),
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const link: TicketDetailViewModel["link"] = useCallback(
|
||||
(targetRef, kind) =>
|
||||
run((version) => gateway.link(projectId, ref, targetRef, kind, version)),
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const unlink: TicketDetailViewModel["unlink"] = useCallback(
|
||||
(targetRef, kind) =>
|
||||
run((version) => gateway.unlink(projectId, ref, targetRef, version, kind)),
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
const assign: TicketDetailViewModel["assign"] = useCallback(
|
||||
(agentId, assigned) =>
|
||||
run((version) => gateway.assign(projectId, ref, agentId, assigned, version)),
|
||||
[run, gateway, projectId, ref],
|
||||
);
|
||||
|
||||
return {
|
||||
ticket,
|
||||
error,
|
||||
conflict,
|
||||
busy,
|
||||
refresh,
|
||||
updateFields,
|
||||
saveCarnet,
|
||||
link,
|
||||
unlink,
|
||||
assign,
|
||||
};
|
||||
}
|
||||
96
frontend/src/features/tickets/useTickets.ts
Normal file
96
frontend/src/features/tickets/useTickets.ts
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* View-model hook for the project ticket list (F2).
|
||||
*
|
||||
* Consumes the {@link TicketGateway} for reads/creates and subscribes to the
|
||||
* `Issue*` domain events (via {@link SystemGateway}) to refresh the list on any
|
||||
* ticket mutation — including those made by agents through MCP. Filters/search
|
||||
* are part of the query and drive a re-fetch when they change.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { isTicketEvent, type GatewayError, type Ticket, type TicketList } from "@/domain";
|
||||
import type { CreateTicketInput, TicketListQuery } from "@/ports";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
export interface TicketsViewModel {
|
||||
list: TicketList | null;
|
||||
error: string | null;
|
||||
busy: boolean;
|
||||
query: TicketListQuery;
|
||||
setQuery: (next: TicketListQuery) => void;
|
||||
refresh: () => Promise<void>;
|
||||
create: (input: CreateTicketInput) => Promise<Ticket | null>;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export function useTickets(projectId: string): TicketsViewModel {
|
||||
const { ticket, system } = useGateways();
|
||||
const [list, setList] = useState<TicketList | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [query, setQuery] = useState<TicketListQuery>({});
|
||||
|
||||
// Stable dependency key so `refresh` only changes when a filter actually does.
|
||||
const queryKey = useMemo(() => JSON.stringify(query), [query]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setList(await ticket.list(projectId, query));
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
// `query` is captured via `queryKey`; listing it directly would rebuild the
|
||||
// callback on every render (a new object identity each time).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId, ticket, queryKey]);
|
||||
|
||||
const create = useCallback(
|
||||
async (input: CreateTicketInput): Promise<Ticket | null> => {
|
||||
setError(null);
|
||||
try {
|
||||
const created = await ticket.create(projectId, input);
|
||||
await refresh();
|
||||
return created;
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[projectId, ticket, refresh],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!system) return;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void system
|
||||
.onDomainEvent((event) => {
|
||||
if (isTicketEvent(event)) void refresh();
|
||||
})
|
||||
.then((u) => {
|
||||
if (cancelled) u();
|
||||
else unsubscribe = u;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [refresh, system]);
|
||||
|
||||
return { list, error, busy, query, setQuery, refresh, create };
|
||||
}
|
||||
@ -41,6 +41,12 @@ import type {
|
||||
SkillScope,
|
||||
Template,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketCarnet,
|
||||
TicketLinkKind,
|
||||
TicketList,
|
||||
TicketPriority,
|
||||
TicketStatus,
|
||||
TurnPage,
|
||||
Unsubscribe,
|
||||
} from "@/domain";
|
||||
@ -688,6 +694,98 @@ export interface ConversationGateway {
|
||||
): Promise<TurnPage>;
|
||||
}
|
||||
|
||||
/** Filter/search criteria for {@link TicketGateway.list}. */
|
||||
export interface TicketListQuery {
|
||||
status?: TicketStatus;
|
||||
priority?: TicketPriority;
|
||||
assignedAgentId?: string;
|
||||
/** Free-text match over title/description. */
|
||||
text?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
/** Input for {@link TicketGateway.create}. */
|
||||
export interface CreateTicketInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: TicketPriority;
|
||||
status?: TicketStatus;
|
||||
assignedAgentIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields to change on {@link TicketGateway.update}. Only the provided keys are
|
||||
* touched; `expectedVersion` is mandatory (optimistic concurrency — a stale
|
||||
* value surfaces a `versionConflict` {@link GatewayError}).
|
||||
*/
|
||||
export interface UpdateTicketInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: TicketStatus;
|
||||
priority?: TicketPriority;
|
||||
assignedAgentIds?: string[];
|
||||
expectedVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket gateway (public wire name for the backend `Issue` model). The only
|
||||
* frontend place that knows the `ticket_*` command names; features consume this
|
||||
* port through DI. Every mutation carries an `expectedVersion` and rejects with
|
||||
* a `versionConflict` {@link GatewayError} when the ticket moved underneath.
|
||||
*/
|
||||
export interface TicketGateway {
|
||||
/** Creates a ticket and returns it. */
|
||||
create(projectId: string, input: CreateTicketInput): Promise<Ticket>;
|
||||
/** Reads one ticket, optionally including its carnet body. */
|
||||
read(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
includeCarnet?: boolean,
|
||||
): Promise<Ticket>;
|
||||
/** Lists tickets matching the (optional) filter/search query. */
|
||||
list(projectId: string, query?: TicketListQuery): Promise<TicketList>;
|
||||
/** Applies partial edits (title/description/status/priority/assignees). */
|
||||
update(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
input: UpdateTicketInput,
|
||||
): Promise<Ticket>;
|
||||
/** Reads the ticket-scoped Markdown carnet. */
|
||||
readCarnet(projectId: string, ref: string): Promise<TicketCarnet>;
|
||||
/** Replaces (not appends) the ticket-scoped carnet body. */
|
||||
updateCarnet(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
carnet: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket>;
|
||||
/** Links this ticket to another `#id` with the given relationship kind. */
|
||||
link(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
targetRef: string,
|
||||
kind: TicketLinkKind,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket>;
|
||||
/** Removes a link (optionally scoped to a single kind). */
|
||||
unlink(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
targetRef: string,
|
||||
expectedVersion: number,
|
||||
kind?: TicketLinkKind,
|
||||
): Promise<Ticket>;
|
||||
/** Assigns/unassigns an agent to the ticket. */
|
||||
assign(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
agentId: string,
|
||||
assigned: boolean,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full set of gateways the app depends on, injected via the DI provider.
|
||||
* The composition (real vs mock) is chosen in `app/`.
|
||||
@ -709,4 +807,5 @@ export interface Gateways {
|
||||
permission: PermissionGateway;
|
||||
workState: WorkStateGateway;
|
||||
conversation: ConversationGateway;
|
||||
ticket: TicketGateway;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user