/** * 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, useRef, useState } from "react"; import type { TicketLinkKind } from "@/domain"; import { Button, FloatingWindow, Input, Spinner, cn } from "@/shared"; import { useTicketDetail } from "./useTicketDetail"; import { useProjectAgents } from "./useProjectAgents"; import { TicketAssistantPanel } from "./TicketAssistantPanel"; import { TicketPicker } from "./TicketPicker"; 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 only on a genuine (re)load of the ticket — // initial load, an external-event refresh, or a conflict reload — tracked via // the hook's `reloadCount`. It is deliberately NOT re-seeded on an ordinary // optimistic mutation (e.g. changing the priority), which bumps the ticket's // version but must not clobber an in-progress, unsaved title/description edit // (#9). const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [carnet, setCarnet] = useState(""); const [linkKind, setLinkKind] = useState("relatesTo"); const [assignPick, setAssignPick] = useState(""); const [copied, setCopied] = useState(false); // Confirmation shown when the user tries to close with unsaved changes. const [confirmClose, setConfirmClose] = useState(false); // Confirmation shown before deleting the ticket (#6). const [confirmDelete, setConfirmDelete] = useState(false); // Add-link TicketPicker open state (#17): replaces the manual `#id` input. const [showLinkPicker, setShowLinkPicker] = useState(false); // AI writing-assistant window open state (#17): opened from the bottom-right // floating button so the assistant is an obvious, first-class affordance. const [showAssistant, setShowAssistant] = useState(false); // The ticket was deleted (by this view or another actor): close the surface. // The removal from lists flows from the same `issueDeleted` event (#6). useEffect(() => { if (vm.deleted) onClose(); }, [vm.deleted, onClose]); const reloadCount = vm.reloadCount; const seededReload = useRef(-1); // The persisted values the draft was last seeded from. Lets a benign reload // (echo of one's own priority change, or an external edit to an untouched // field) adopt fresh values field-by-field WITHOUT clobbering a field the user // is currently editing (#9). const baseline = useRef<{ title: string; description: string; carnet: string; } | null>(null); useEffect(() => { if (!t) return; // Re-seed once per (re)load. An optimistic mutation (e.g. a priority change) // changes `t` without bumping `reloadCount`, so this guard short-circuits and // preserves the draft (#9). if (seededReload.current === reloadCount) return; seededReload.current = reloadCount; const nextCarnet = t.carnet ?? ""; const base = baseline.current; // Force a full reset on the initial load and on a conflict reload (the local // edit is stale — the user must re-apply). A benign reload preserves any // field the user has edited since the last seed. const forced = base === null || vm.conflict; if (forced) { setTitle(t.title); setDescription(t.description); setCarnet(nextCarnet); } else { setTitle((cur) => (cur === base.title ? t.title : cur)); setDescription((cur) => (cur === base.description ? t.description : cur)); setCarnet((cur) => (cur === base.carnet ? nextCarnet : cur)); } baseline.current = { title: t.title, description: t.description, carnet: nextCarnet, }; }, [t, reloadCount, vm.conflict]); const dirtyFields = !!t && (title !== t.title || description !== t.description); const dirtyCarnet = !!t && carnet !== (t.carnet ?? ""); const dirty = dirtyFields || dirtyCarnet; const assignable = agents.filter((a) => !t?.assignedAgentIds.includes(a.id)); /** Close guard: prompt before discarding unsaved edits, else close directly. */ function requestClose() { if (dirty) setConfirmClose(true); else onClose(); } /** Save whatever is dirty, then close only if every save succeeded. */ async function saveAndClose() { let ok = true; if (dirtyFields) ok = (await vm.updateFields({ title, description })) && ok; if (ok && dirtyCarnet) ok = (await vm.saveCarnet(carnet)) && ok; setConfirmClose(false); // On a failed save (e.g. a version conflict) stay open so the banner shows. if (ok) onClose(); } 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 ( {/* ── Toolbar (badges + ticket-level actions) ── */}
{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} />