diff --git a/components/workspace/NavigatorPanel.tsx b/components/workspace/NavigatorPanel.tsx
index 44f511a..42b8958 100644
--- a/components/workspace/NavigatorPanel.tsx
+++ b/components/workspace/NavigatorPanel.tsx
@@ -1,11 +1,16 @@
-'use client';
+"use client";
-import { useRouter, useSearchParams, usePathname } from 'next/navigation';
-import { useCallback, useMemo, useState } from 'react';
-import { FilterBar, type GroupKey, type SortKey, type WorkspaceView } from './structure/FilterBar';
-import { StructureView } from './structure/StructureView';
-import type { TaskEdge } from '@/lib/db/schema';
-import type { TaskGraphSlim } from '@/lib/data/views';
+import { useRouter, useSearchParams, usePathname } from "next/navigation";
+import { useCallback, useMemo, useState } from "react";
+import {
+ FilterBar,
+ type GroupKey,
+ type SortKey,
+ type WorkspaceView,
+} from "./structure/FilterBar";
+import { StructureView } from "./structure/StructureView";
+import type { TaskEdge } from "@/lib/db/schema";
+import type { TaskGraphSlim } from "@/lib/data/views";
interface NavigatorPanelProps {
/** All project tasks (slim, augmented with composed `taskRef`). */
@@ -37,11 +42,11 @@ interface NavigatorPanelProps {
*/
function readFilterCount(searchParams: URLSearchParams): number {
let count = 0;
- for (const key of ['tags', 'cat', 'status', 'pri'] as const) {
+ for (const key of ["tags", "cat", "status", "pri"] as const) {
const value = searchParams.get(key);
- if (value) count += value.split(',').filter(Boolean).length;
+ if (value) count += value.split(",").filter(Boolean).length;
}
- if (searchParams.get('q')?.trim()) count += 1;
+ if (searchParams.get("q")?.trim()) count += 1;
return count;
}
@@ -53,8 +58,8 @@ function readFilterCount(searchParams: URLSearchParams): number {
* @returns Workspace view identifier.
*/
function readView(raw: string | null): WorkspaceView {
- if (raw === 'graph') return 'graph';
- return 'structure';
+ if (raw === "graph") return "graph";
+ return "structure";
}
/**
@@ -64,8 +69,9 @@ function readView(raw: string | null): WorkspaceView {
* @returns Sort key.
*/
function readSort(raw: string | null): SortKey {
- if (raw === 'updated' || raw === 'identifier' || raw === 'priority') return raw;
- return 'status';
+ if (raw === "updated" || raw === "identifier" || raw === "priority")
+ return raw;
+ return "status";
}
/**
@@ -76,8 +82,8 @@ function readSort(raw: string | null): SortKey {
* @returns Group key.
*/
function readGroup(raw: string | null): GroupKey {
- if (raw === 'category' || raw === 'none') return raw;
- return 'status';
+ if (raw === "category" || raw === "none") return raw;
+ return "status";
}
/**
@@ -98,43 +104,60 @@ export function NavigatorPanel({
selectedNodeId,
onSelectNode,
onGraphChange,
- className = '',
+ className = "",
}: NavigatorPanelProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
- const view = readView(searchParams.get('view'));
- const sort = readSort(searchParams.get('sort'));
- const group = readGroup(searchParams.get('group'));
- const filterCount = useMemo(() => readFilterCount(new URLSearchParams(searchParams.toString())), [searchParams]);
+ const view = readView(searchParams.get("view"));
+ const sort = readSort(searchParams.get("sort"));
+ const group = readGroup(searchParams.get("group"));
+ const filterCount = useMemo(
+ () => readFilterCount(new URLSearchParams(searchParams.toString())),
+ [searchParams],
+ );
const [filterOpen, setFilterOpen] = useState(false);
- const updateParam = useCallback((key: string, value: string | null) => {
- const next = new URLSearchParams(searchParams.toString());
- if (value === null || value === '') next.delete(key);
- else next.set(key, value);
- const nextQs = next.toString();
- const currentQs = searchParams.toString();
- // Skip when nothing changed — e.g. clicking the already-active option.
- // Each `router.replace` triggers an RSC refetch of the project layout,
- // so eliding no-op replaces avoids unnecessary server work.
- if (nextQs === currentQs) return;
- router.replace(nextQs ? `${pathname}?${nextQs}` : pathname, { scroll: false });
- }, [router, pathname, searchParams]);
+ const updateParam = useCallback(
+ (key: string, value: string | null) => {
+ const next = new URLSearchParams(searchParams.toString());
+ if (value === null || value === "") next.delete(key);
+ else next.set(key, value);
+ const nextQs = next.toString();
+ const currentQs = searchParams.toString();
+ // Skip when nothing changed — e.g. clicking the already-active option.
+ // Each `router.replace` triggers an RSC refetch of the project layout,
+ // so eliding no-op replaces avoids unnecessary server work.
+ if (nextQs === currentQs) return;
+ router.replace(nextQs ? `${pathname}?${nextQs}` : pathname, {
+ scroll: false,
+ });
+ },
+ [router, pathname, searchParams],
+ );
- const handleViewChange = useCallback((next: WorkspaceView) => {
- updateParam('view', next === 'structure' ? null : next);
- if (next !== 'structure') setFilterOpen(false);
- }, [updateParam]);
+ const handleViewChange = useCallback(
+ (next: WorkspaceView) => {
+ updateParam("view", next === "structure" ? null : next);
+ if (next !== "structure") setFilterOpen(false);
+ },
+ [updateParam],
+ );
- const handleSortChange = useCallback((next: SortKey) => {
- updateParam('sort', next === 'status' ? null : next);
- }, [updateParam]);
+ const handleSortChange = useCallback(
+ (next: SortKey) => {
+ updateParam("sort", next === "status" ? null : next);
+ },
+ [updateParam],
+ );
- const handleGroupChange = useCallback((next: GroupKey) => {
- updateParam('group', next === 'status' ? null : next);
- }, [updateParam]);
+ const handleGroupChange = useCallback(
+ (next: GroupKey) => {
+ updateParam("group", next === "status" ? null : next);
+ },
+ [updateParam],
+ );
return (
diff --git a/components/workspace/WorkspaceHeader.tsx b/components/workspace/WorkspaceHeader.tsx
index 7b92ae5..9c1ee14 100644
--- a/components/workspace/WorkspaceHeader.tsx
+++ b/components/workspace/WorkspaceHeader.tsx
@@ -1,10 +1,10 @@
-'use client';
+"use client";
-import { useState } from 'react';
-import { useRouter } from 'next/navigation';
-import { TopBar } from '@/components/layout/TopBar';
-import { ProjectSettingsModal } from '@/components/workspace/project-settings/ProjectSettingsModal';
-import type { ProjectStatus } from '@/lib/types';
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { TopBar } from "@/components/layout/TopBar";
+import { ProjectSettingsModal } from "@/components/workspace/project-settings/ProjectSettingsModal";
+import type { ProjectStatus } from "@/lib/types";
interface WorkspaceHeaderProps {
/** @param projectId - UUID of the project. */
@@ -71,7 +71,13 @@ export function WorkspaceHeader({
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
projectId={projectId}
- project={{ title: projectName, description, identifier, status, categories }}
+ project={{
+ title: projectName,
+ description,
+ identifier,
+ status,
+ categories,
+ }}
team={team}
taskCount={taskCount}
canRename={canRename}
diff --git a/components/workspace/detail/ActivitySection.tsx b/components/workspace/detail/ActivitySection.tsx
index a616cb2..f72040d 100644
--- a/components/workspace/detail/ActivitySection.tsx
+++ b/components/workspace/detail/ActivitySection.tsx
@@ -1,8 +1,8 @@
-'use client';
+"use client";
-import { Avatar } from '@/components/shared/Avatar';
-import type { HistoryEntry } from '@/lib/types';
-import { SectionHeader } from './SectionHeader';
+import { Avatar } from "@/components/shared/Avatar";
+import type { HistoryEntry } from "@/lib/types";
+import { SectionHeader } from "./SectionHeader";
interface ActivitySectionProps {
/** History entries from the schema. */
@@ -50,11 +50,11 @@ interface ActivityRowProps {
* @returns List item element.
*/
function ActivityRow({ entry, isLast }: ActivityRowProps) {
- const author = entry.actor === 'ai' ? 'agent' : 'user';
+ const author = entry.actor === "ai" ? "agent" : "user";
return (
-
+
{!isLast && (
- {author} {' '}
+ {author} {" "}
{entry.label.toLowerCase()}
@@ -82,10 +82,10 @@ function ActivityRow({ entry, isLast }: ActivityRowProps) {
*/
function formatRelative(iso: string): string {
const ts = Date.parse(iso);
- if (Number.isNaN(ts)) return '—';
+ if (Number.isNaN(ts)) return "—";
const diff = Date.now() - ts;
const min = Math.floor(diff / 60_000);
- if (min < 1) return 'now';
+ if (min < 1) return "now";
if (min < 60) return `${min}m`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h`;
diff --git a/components/workspace/detail/CriteriaSection.tsx b/components/workspace/detail/CriteriaSection.tsx
index 3945c8c..56b283d 100644
--- a/components/workspace/detail/CriteriaSection.tsx
+++ b/components/workspace/detail/CriteriaSection.tsx
@@ -1,14 +1,14 @@
-'use client';
+"use client";
-import { useState, useEffect, useCallback, useRef } from 'react';
-import { AutoGrowTextarea } from '@/components/shared/AutoGrowTextarea';
-import { Checkbox } from '@/components/shared/Checkbox';
-import { Markdown } from '@/components/shared/Markdown';
-import { useUndo, UndoButton } from '@/hooks/useUndo';
-import { updateTask } from '@/lib/graph/mutations';
-import { IconPlus, IconTrash } from '@/components/shared/icons';
-import type { AcceptanceCriterion } from '@/lib/types';
-import { SectionHeader } from './SectionHeader';
+import { useState, useEffect, useCallback, useRef } from "react";
+import { AutoGrowTextarea } from "@/components/shared/AutoGrowTextarea";
+import { Checkbox } from "@/components/shared/Checkbox";
+import { Markdown } from "@/components/shared/Markdown";
+import { useUndo, UndoButton } from "@/hooks/useUndo";
+import { updateTask } from "@/lib/graph/mutations";
+import { IconPlus, IconTrash } from "@/components/shared/icons";
+import type { AcceptanceCriterion } from "@/lib/types";
+import { SectionHeader } from "./SectionHeader";
/**
* Hash criteria into a stable string so reference-changing prop updates
@@ -19,7 +19,7 @@ import { SectionHeader } from './SectionHeader';
* @returns Pipe-joined `|` signature.
*/
function signatureFor(items: AcceptanceCriterion[] | undefined | null): string {
- return (items ?? []).map((c) => `${c.checked}|${c.text}`).join('||');
+ return (items ?? []).map((c) => `${c.checked}|${c.text}`).join("||");
}
interface CriteriaSectionProps {
@@ -39,7 +39,11 @@ interface CriteriaSectionProps {
* @param props - Section configuration.
* @returns Checklist plus add affordance.
*/
-export function CriteriaSection({ taskId, criteria, onGraphChange }: CriteriaSectionProps) {
+export function CriteriaSection({
+ taskId,
+ criteria,
+ onGraphChange,
+}: CriteriaSectionProps) {
const [local, setLocal] = useState(() => criteria ?? []);
const [syncedSig, setSyncedSig] = useState(() => signatureFor(criteria));
const [prevTaskId, setPrevTaskId] = useState(taskId);
@@ -50,10 +54,16 @@ export function CriteriaSection({ taskId, criteria, onGraphChange }: CriteriaSec
const [adding, setAdding] = useState(false);
const cancelRef = useRef(false);
- useEffect(() => { localRef.current = local; }, [local]);
- useEffect(() => () => {
- if (suppressTimerRef.current !== null) window.clearTimeout(suppressTimerRef.current);
- }, []);
+ useEffect(() => {
+ localRef.current = local;
+ }, [local]);
+ useEffect(
+ () => () => {
+ if (suppressTimerRef.current !== null)
+ window.clearTimeout(suppressTimerRef.current);
+ },
+ [],
+ );
/**
* Mark a 1-second window where incoming SSE refreshes should not
@@ -61,7 +71,8 @@ export function CriteriaSection({ taskId, criteria, onGraphChange }: CriteriaSec
*/
const markMutation = () => {
setSuppressing(true);
- if (suppressTimerRef.current !== null) window.clearTimeout(suppressTimerRef.current);
+ if (suppressTimerRef.current !== null)
+ window.clearTimeout(suppressTimerRef.current);
suppressTimerRef.current = window.setTimeout(() => {
setSuppressing(false);
suppressTimerRef.current = null;
@@ -80,62 +91,98 @@ export function CriteriaSection({ taskId, criteria, onGraphChange }: CriteriaSec
setAdding(false);
}
- const handleToggle = useCallback(async (id: string) => {
- const next = localRef.current.map((c) => (c.id === id ? { ...c, checked: !c.checked } : c));
- setLocal(next);
- markMutation();
- await updateTask(taskId, { acceptanceCriteria: next }, true);
- }, [taskId]);
+ const handleToggle = useCallback(
+ async (id: string) => {
+ const next = localRef.current.map((c) =>
+ c.id === id ? { ...c, checked: !c.checked } : c,
+ );
+ setLocal(next);
+ markMutation();
+ await updateTask(taskId, { acceptanceCriteria: next }, true);
+ },
+ [taskId],
+ );
- const handleRename = useCallback(async (id: string, text: string) => {
- const trimmed = text.trim();
- if (!trimmed) { setEditingId(null); return; }
- const target = localRef.current.find((c) => c.id === id);
- if (target && trimmed === target.text) { setEditingId(null); return; }
- const next = localRef.current.map((c) => (c.id === id ? { ...c, text: trimmed } : c));
- setLocal(next);
- setEditingId(null);
- markMutation();
- await updateTask(taskId, { acceptanceCriteria: next }, true);
- }, [taskId]);
+ const handleRename = useCallback(
+ async (id: string, text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed) {
+ setEditingId(null);
+ return;
+ }
+ const target = localRef.current.find((c) => c.id === id);
+ if (target && trimmed === target.text) {
+ setEditingId(null);
+ return;
+ }
+ const next = localRef.current.map((c) =>
+ c.id === id ? { ...c, text: trimmed } : c,
+ );
+ setLocal(next);
+ setEditingId(null);
+ markMutation();
+ await updateTask(taskId, { acceptanceCriteria: next }, true);
+ },
+ [taskId],
+ );
- const handleRestore = useCallback(async (item: { criterion: AcceptanceCriterion; index: number }) => {
- const next = [...localRef.current];
- next.splice(item.index, 0, item.criterion);
- setLocal(next);
- markMutation();
- await updateTask(taskId, { acceptanceCriteria: next }, true);
- }, [taskId]);
+ const handleRestore = useCallback(
+ async (item: { criterion: AcceptanceCriterion; index: number }) => {
+ const next = [...localRef.current];
+ next.splice(item.index, 0, item.criterion);
+ setLocal(next);
+ markMutation();
+ await updateTask(taskId, { acceptanceCriteria: next }, true);
+ },
+ [taskId],
+ );
- const { canUndo, push: pushUndo, undo } = useUndo<{ criterion: AcceptanceCriterion; index: number }>({
+ const {
+ canUndo,
+ push: pushUndo,
+ undo,
+ } = useUndo<{ criterion: AcceptanceCriterion; index: number }>({
onUndo: handleRestore,
resetOn: taskId,
keyboard: true,
});
- const handleDelete = useCallback(async (id: string) => {
- const index = localRef.current.findIndex((c) => c.id === id);
- if (index === -1) return;
- const removed = localRef.current[index];
- const next = localRef.current.filter((c) => c.id !== id);
- setLocal(next);
- setEditingId(null);
- pushUndo({ criterion: removed, index });
- markMutation();
- await updateTask(taskId, { acceptanceCriteria: next }, true);
- }, [taskId, pushUndo]);
+ const handleDelete = useCallback(
+ async (id: string) => {
+ const index = localRef.current.findIndex((c) => c.id === id);
+ if (index === -1) return;
+ const removed = localRef.current[index];
+ const next = localRef.current.filter((c) => c.id !== id);
+ setLocal(next);
+ setEditingId(null);
+ pushUndo({ criterion: removed, index });
+ markMutation();
+ await updateTask(taskId, { acceptanceCriteria: next }, true);
+ },
+ [taskId, pushUndo],
+ );
- const handleAdd = useCallback(async (text: string) => {
- const trimmed = text.trim();
- if (!trimmed) { setAdding(false); return; }
- const newCriterion: AcceptanceCriterion = { id: crypto.randomUUID(), text: trimmed, checked: false };
- const next = [...localRef.current, newCriterion];
- setLocal(next);
- setAdding(false);
- markMutation();
- await updateTask(taskId, { acceptanceCriteria: next }, true);
- onGraphChange?.();
- }, [taskId, onGraphChange]);
+ const handleAdd = useCallback(
+ async (text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed) {
+ setAdding(false);
+ return;
+ }
+ const newCriterion: AcceptanceCriterion = {
+ id: crypto.randomUUID(),
+ text: trimmed,
+ checked: false,
+ };
+ const next = [...localRef.current, newCriterion];
+ setLocal(next);
+ setAdding(false);
+ markMutation();
+ await updateTask(taskId, { acceptanceCriteria: next }, true);
+ onGraphChange?.();
+ },
+ [taskId, onGraphChange],
+ );
const totalCount = local.length;
const doneCount = local.filter((c) => c.checked).length;
@@ -171,7 +218,10 @@ export function CriteriaSection({ taskId, criteria, onGraphChange }: CriteriaSec
onToggle={() => handleToggle(c.id)}
onStartEdit={() => setEditingId(c.id)}
onCommit={(text) => handleRename(c.id, text)}
- onCancel={() => { cancelRef.current = false; setEditingId(null); }}
+ onCancel={() => {
+ cancelRef.current = false;
+ setEditingId(null);
+ }}
onDelete={() => handleDelete(c.id)}
cancelRef={cancelRef}
/>
@@ -204,11 +254,14 @@ interface CriterionAddFormProps {
* @returns Form element.
*/
function CriterionAddForm({ onSubmit, onCancel }: CriterionAddFormProps) {
- const [text, setText] = useState('');
+ const [text, setText] = useState("");
const submit = () => {
const trimmed = text.trim();
- if (!trimmed) { onCancel(); return; }
+ if (!trimmed) {
+ onCancel();
+ return;
+ }
onSubmit(trimmed);
};
@@ -221,8 +274,14 @@ function CriterionAddForm({ onSubmit, onCancel }: CriterionAddFormProps) {
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
- if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ submit();
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ onCancel();
+ }
}}
placeholder="What needs to be true for this task to be done?"
className="w-full resize-none rounded-md border border-border-strong bg-surface px-2.5 py-1.5 text-[12px] text-text-primary outline-none transition-colors placeholder:text-text-muted focus:border-accent"
@@ -274,10 +333,23 @@ interface CriterionRowProps {
* @param props - Row configuration.
* @returns Row element.
*/
-function CriterionRow({ criterion, editing, onToggle, onStartEdit, onCommit, onCancel, onDelete, cancelRef }: CriterionRowProps) {
+function CriterionRow({
+ criterion,
+ editing,
+ onToggle,
+ onStartEdit,
+ onCommit,
+ onCancel,
+ onDelete,
+ cancelRef,
+}: CriterionRowProps) {
return (
-
+
{editing ? (
{
- if (cancelRef.current) { cancelRef.current = false; onCancel(); }
- else onCommit(e.target.value);
+ if (cancelRef.current) {
+ cancelRef.current = false;
+ onCancel();
+ } else onCommit(e.target.value);
}}
onKeyDown={(e) => {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.currentTarget.blur(); }
- if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ e.currentTarget.blur();
+ }
+ if (e.key === "Escape") {
+ cancelRef.current = true;
+ e.currentTarget.blur();
+ }
}}
className="w-full resize-none rounded-md border border-border-strong bg-surface px-2 py-1 text-[13px] text-text-primary outline-none transition-colors focus:border-accent"
/>
@@ -298,7 +378,9 @@ function CriterionRow({ criterion, editing, onToggle, onStartEdit, onCommit, onC
{criterion.text}
diff --git a/components/workspace/detail/DecisionsSection.tsx b/components/workspace/detail/DecisionsSection.tsx
index 3523f27..d730c95 100644
--- a/components/workspace/detail/DecisionsSection.tsx
+++ b/components/workspace/detail/DecisionsSection.tsx
@@ -1,21 +1,24 @@
-'use client';
+"use client";
-import { useState, useEffect, useCallback, useRef } from 'react';
-import { AutoGrowTextarea } from '@/components/shared/AutoGrowTextarea';
-import { Avatar } from '@/components/shared/Avatar';
-import { Markdown } from '@/components/shared/Markdown';
-import { useUndo, UndoButton } from '@/hooks/useUndo';
-import { updateTask } from '@/lib/graph/mutations';
-import { IconPlus, IconTrash } from '@/components/shared/icons';
-import type { Decision } from '@/lib/types';
-import { SectionHeader } from './SectionHeader';
+import { useState, useEffect, useCallback, useRef } from "react";
+import { AutoGrowTextarea } from "@/components/shared/AutoGrowTextarea";
+import { Avatar } from "@/components/shared/Avatar";
+import { Markdown } from "@/components/shared/Markdown";
+import { useUndo, UndoButton } from "@/hooks/useUndo";
+import { updateTask } from "@/lib/graph/mutations";
+import { IconPlus, IconTrash } from "@/components/shared/icons";
+import type { Decision } from "@/lib/types";
+import { SectionHeader } from "./SectionHeader";
/** Map a decision source to a display author (`user` vs `agent`). */
-const SOURCE_AUTHOR: Record
= {
- brainstorm: { name: 'user', isAgent: false },
- refinement: { name: 'user', isAgent: false },
- planning: { name: 'agent', isAgent: true },
- execution: { name: 'agent', isAgent: true },
+const SOURCE_AUTHOR: Record<
+ Decision["source"],
+ { name: string; isAgent: boolean }
+> = {
+ brainstorm: { name: "user", isAgent: false },
+ refinement: { name: "user", isAgent: false },
+ planning: { name: "agent", isAgent: true },
+ execution: { name: "agent", isAgent: true },
};
/**
@@ -26,7 +29,7 @@ const SOURCE_AUTHOR: Record d.text).join('||');
+ return (items ?? []).map((d) => d.text).join("||");
}
interface DecisionsSectionProps {
@@ -45,7 +48,11 @@ interface DecisionsSectionProps {
* @param props - Section configuration.
* @returns Decisions list plus add affordance.
*/
-export function DecisionsSection({ taskId, decisions, onGraphChange }: DecisionsSectionProps) {
+export function DecisionsSection({
+ taskId,
+ decisions,
+ onGraphChange,
+}: DecisionsSectionProps) {
const [local, setLocal] = useState(() => decisions ?? []);
const [syncedSig, setSyncedSig] = useState(() => signatureFor(decisions));
const [prevTaskId, setPrevTaskId] = useState(taskId);
@@ -56,10 +63,16 @@ export function DecisionsSection({ taskId, decisions, onGraphChange }: Decisions
const [adding, setAdding] = useState(false);
const cancelRef = useRef(false);
- useEffect(() => { localRef.current = local; }, [local]);
- useEffect(() => () => {
- if (suppressTimerRef.current !== null) window.clearTimeout(suppressTimerRef.current);
- }, []);
+ useEffect(() => {
+ localRef.current = local;
+ }, [local]);
+ useEffect(
+ () => () => {
+ if (suppressTimerRef.current !== null)
+ window.clearTimeout(suppressTimerRef.current);
+ },
+ [],
+ );
/**
* Mark a 1-second window where SSE refreshes won't clobber the
@@ -67,7 +80,8 @@ export function DecisionsSection({ taskId, decisions, onGraphChange }: Decisions
*/
const markMutation = () => {
setSuppressing(true);
- if (suppressTimerRef.current !== null) window.clearTimeout(suppressTimerRef.current);
+ if (suppressTimerRef.current !== null)
+ window.clearTimeout(suppressTimerRef.current);
suppressTimerRef.current = window.setTimeout(() => {
setSuppressing(false);
suppressTimerRef.current = null;
@@ -86,59 +100,86 @@ export function DecisionsSection({ taskId, decisions, onGraphChange }: Decisions
setAdding(false);
}
- const handleRename = useCallback(async (id: string, text: string) => {
- const trimmed = text.trim();
- if (!trimmed) { setEditingId(null); return; }
- const target = localRef.current.find((d) => d.id === id);
- if (target && trimmed === target.text) { setEditingId(null); return; }
- const next = localRef.current.map((d) => (d.id === id ? { ...d, text: trimmed } : d));
- setLocal(next);
- setEditingId(null);
- markMutation();
- await updateTask(taskId, { decisions: next }, true);
- }, [taskId]);
+ const handleRename = useCallback(
+ async (id: string, text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed) {
+ setEditingId(null);
+ return;
+ }
+ const target = localRef.current.find((d) => d.id === id);
+ if (target && trimmed === target.text) {
+ setEditingId(null);
+ return;
+ }
+ const next = localRef.current.map((d) =>
+ d.id === id ? { ...d, text: trimmed } : d,
+ );
+ setLocal(next);
+ setEditingId(null);
+ markMutation();
+ await updateTask(taskId, { decisions: next }, true);
+ },
+ [taskId],
+ );
- const handleRestore = useCallback(async (item: { decision: Decision; index: number }) => {
- const next = [...localRef.current];
- next.splice(item.index, 0, item.decision);
- setLocal(next);
- markMutation();
- await updateTask(taskId, { decisions: next }, true);
- }, [taskId]);
+ const handleRestore = useCallback(
+ async (item: { decision: Decision; index: number }) => {
+ const next = [...localRef.current];
+ next.splice(item.index, 0, item.decision);
+ setLocal(next);
+ markMutation();
+ await updateTask(taskId, { decisions: next }, true);
+ },
+ [taskId],
+ );
- const { canUndo, push: pushUndo, undo } = useUndo<{ decision: Decision; index: number }>({
+ const {
+ canUndo,
+ push: pushUndo,
+ undo,
+ } = useUndo<{ decision: Decision; index: number }>({
onUndo: handleRestore,
resetOn: taskId,
});
- const handleDelete = useCallback(async (id: string) => {
- const index = localRef.current.findIndex((d) => d.id === id);
- if (index === -1) return;
- const removed = localRef.current[index];
- const next = localRef.current.filter((d) => d.id !== id);
- setLocal(next);
- setEditingId(null);
- pushUndo({ decision: removed, index });
- markMutation();
- await updateTask(taskId, { decisions: next }, true);
- }, [taskId, pushUndo]);
+ const handleDelete = useCallback(
+ async (id: string) => {
+ const index = localRef.current.findIndex((d) => d.id === id);
+ if (index === -1) return;
+ const removed = localRef.current[index];
+ const next = localRef.current.filter((d) => d.id !== id);
+ setLocal(next);
+ setEditingId(null);
+ pushUndo({ decision: removed, index });
+ markMutation();
+ await updateTask(taskId, { decisions: next }, true);
+ },
+ [taskId, pushUndo],
+ );
- const handleAdd = useCallback(async (text: string) => {
- const trimmed = text.trim();
- if (!trimmed) { setAdding(false); return; }
- const newDecision: Decision = {
- id: crypto.randomUUID(),
- text: trimmed,
- date: new Date().toISOString().slice(0, 10),
- source: 'refinement',
- };
- const next = [...localRef.current, newDecision];
- setLocal(next);
- setAdding(false);
- markMutation();
- await updateTask(taskId, { decisions: next }, true);
- onGraphChange?.();
- }, [taskId, onGraphChange]);
+ const handleAdd = useCallback(
+ async (text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed) {
+ setAdding(false);
+ return;
+ }
+ const newDecision: Decision = {
+ id: crypto.randomUUID(),
+ text: trimmed,
+ date: new Date().toISOString().slice(0, 10),
+ source: "refinement",
+ };
+ const next = [...localRef.current, newDecision];
+ setLocal(next);
+ setAdding(false);
+ markMutation();
+ await updateTask(taskId, { decisions: next }, true);
+ onGraphChange?.();
+ },
+ [taskId, onGraphChange],
+ );
return (
@@ -170,7 +211,10 @@ export function DecisionsSection({ taskId, decisions, onGraphChange }: Decisions
editing={editingId === d.id}
onStartEdit={() => setEditingId(d.id)}
onCommit={(text) => handleRename(d.id, text)}
- onCancel={() => { cancelRef.current = false; setEditingId(null); }}
+ onCancel={() => {
+ cancelRef.current = false;
+ setEditingId(null);
+ }}
onDelete={() => handleDelete(d.id)}
cancelRef={cancelRef}
/>
@@ -203,11 +247,14 @@ interface DecisionAddFormProps {
* @returns Form element.
*/
function DecisionAddForm({ onSubmit, onCancel }: DecisionAddFormProps) {
- const [text, setText] = useState('');
+ const [text, setText] = useState("");
const submit = () => {
const trimmed = text.trim();
- if (!trimmed) { onCancel(); return; }
+ if (!trimmed) {
+ onCancel();
+ return;
+ }
onSubmit(trimmed);
};
@@ -220,8 +267,14 @@ function DecisionAddForm({ onSubmit, onCancel }: DecisionAddFormProps) {
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
- if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ submit();
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ onCancel();
+ }
}}
placeholder="What did you decide, and why?"
className="w-full resize-none rounded-md border border-border-strong bg-surface px-2.5 py-1.5 text-[12px] text-text-primary outline-none transition-colors placeholder:text-text-muted focus:border-accent"
@@ -271,20 +324,32 @@ interface DecisionCardProps {
* @param props - Card configuration.
* @returns Card element.
*/
-function DecisionCard({ decision, editing, onStartEdit, onCommit, onCancel, onDelete, cancelRef }: DecisionCardProps) {
+function DecisionCard({
+ decision,
+ editing,
+ onStartEdit,
+ onCommit,
+ onCancel,
+ onDelete,
+ cancelRef,
+}: DecisionCardProps) {
const author = SOURCE_AUTHOR[decision.source] ?? SOURCE_AUTHOR.refinement;
return (
- {author.isAgent ? `agent: ${decision.source}` : `user: ${decision.source}`}
+ {author.isAgent
+ ? `agent: ${decision.source}`
+ : `user: ${decision.source}`}
·
-
{decision.date}
+
+ {decision.date}
+
{decision.source}
@@ -304,18 +369,28 @@ function DecisionCard({ decision, editing, onStartEdit, onCommit, onCancel, onDe
autoFocus
rows={1}
onBlur={(e) => {
- if (cancelRef.current) { cancelRef.current = false; onCancel(); }
- else onCommit(e.target.value);
+ if (cancelRef.current) {
+ cancelRef.current = false;
+ onCancel();
+ } else onCommit(e.target.value);
}}
onKeyDown={(e) => {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.currentTarget.blur(); }
- if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ e.currentTarget.blur();
+ }
+ if (e.key === "Escape") {
+ cancelRef.current = true;
+ e.currentTarget.blur();
+ }
}}
className="w-full resize-none rounded-md border border-border-strong bg-surface px-2 py-1 text-[13px] text-text-primary outline-none transition-colors focus:border-accent"
/>
) : (
- {decision.text}
+
+ {decision.text}
+
)}
diff --git a/components/workspace/detail/DescriptionSection.tsx b/components/workspace/detail/DescriptionSection.tsx
index be418f1..97419d7 100644
--- a/components/workspace/detail/DescriptionSection.tsx
+++ b/components/workspace/detail/DescriptionSection.tsx
@@ -1,10 +1,10 @@
-'use client';
+"use client";
-import { useState, useRef, useCallback } from 'react';
-import { AutoGrowTextarea } from '@/components/shared/AutoGrowTextarea';
-import { Markdown } from '@/components/shared/Markdown';
-import { updateTask } from '@/lib/graph/mutations';
-import { SectionHeader } from './SectionHeader';
+import { useState, useRef, useCallback } from "react";
+import { AutoGrowTextarea } from "@/components/shared/AutoGrowTextarea";
+import { Markdown } from "@/components/shared/Markdown";
+import { updateTask } from "@/lib/graph/mutations";
+import { SectionHeader } from "./SectionHeader";
interface DescriptionSectionProps {
/** Task UUID. */
@@ -22,7 +22,11 @@ interface DescriptionSectionProps {
* @param props - Section configuration.
* @returns Section with markdown body or auto-grow textarea.
*/
-export function DescriptionSection({ taskId, description, onGraphChange }: DescriptionSectionProps) {
+export function DescriptionSection({
+ taskId,
+ description,
+ onGraphChange,
+}: DescriptionSectionProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(description);
const [prevDescription, setPrevDescription] = useState(description);
@@ -57,7 +61,12 @@ export function DescriptionSection({ taskId, description, onGraphChange }: Descr
void handleSave();
}
}}
- onKeyDown={(e) => { if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); } }}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ cancelRef.current = true;
+ e.currentTarget.blur();
+ }
+ }}
autoFocus
rows={3}
className="w-full resize-none rounded-md border border-border-strong bg-surface px-3 py-2 text-[13.5px] text-text-primary outline-none transition-colors focus:border-accent"
@@ -68,9 +77,13 @@ export function DescriptionSection({ taskId, description, onGraphChange }: Descr
className="group/edit relative cursor-text rounded-md border border-transparent px-3 py-2 transition-all hover:border-border hover:bg-surface/40"
>
{description ? (
-
{description}
+
+ {description}
+
) : (
-
Click to add a description…
+
+ Click to add a description…
+
)}
)}
diff --git a/components/workspace/detail/DetailHeader.tsx b/components/workspace/detail/DetailHeader.tsx
index 537dc05..b8a7b6f 100644
--- a/components/workspace/detail/DetailHeader.tsx
+++ b/components/workspace/detail/DetailHeader.tsx
@@ -1,11 +1,16 @@
-'use client';
+"use client";
-import { motion } from 'motion/react';
-import { useState, useRef, useEffect } from 'react';
-import { MonoId } from '@/components/shared/MonoId';
-import { IconPanelLeft, IconPanelRight, IconSettings, IconX } from '@/components/shared/icons';
-import { updateTask } from '@/lib/graph/mutations';
-import type { TaskStatus } from '@/lib/types';
+import { motion } from "motion/react";
+import { useState, useRef, useEffect } from "react";
+import { MonoId } from "@/components/shared/MonoId";
+import {
+ IconPanelLeft,
+ IconPanelRight,
+ IconSettings,
+ IconX,
+} from "@/components/shared/icons";
+import { updateTask } from "@/lib/graph/mutations";
+import type { TaskStatus } from "@/lib/types";
interface DetailHeaderProps {
/** Task UUID. */
@@ -79,13 +84,17 @@ export function DetailHeader({
useEffect(() => {
const handler = (e: KeyboardEvent) => {
- if (e.key !== 'Escape') return;
+ if (e.key !== "Escape") return;
if (editing) return;
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
+ if (
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement
+ )
+ return;
onClose();
};
- document.addEventListener('keydown', handler);
- return () => document.removeEventListener('keydown', handler);
+ document.addEventListener("keydown", handler);
+ return () => document.removeEventListener("keydown", handler);
}, [editing, onClose]);
/** Save the title if it changed; restore on Esc. */
@@ -108,7 +117,9 @@ export function DetailHeader({
·
-
{projectName}
+
+ {projectName}
+
@@ -117,12 +128,16 @@ export function DetailHeader({
type="button"
onClick={onToggleNavigator}
aria-pressed={navigatorClosed}
- aria-label={navigatorClosed ? 'Show structure' : 'Hide structure'}
- title={navigatorClosed ? 'Show structure — slide back' : 'Hide structure — focus on task'}
+ aria-label={navigatorClosed ? "Show structure" : "Hide structure"}
+ title={
+ navigatorClosed
+ ? "Show structure — slide back"
+ : "Hide structure — focus on task"
+ }
className={`hidden h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition-colors xl:inline-flex ${
navigatorClosed
- ? 'border-accent/30 bg-accent/10 text-accent-light'
- : 'border-border-strong text-text-muted hover:bg-surface-hover hover:text-text-secondary'
+ ? "border-accent/30 bg-accent/10 text-accent-light"
+ : "border-border-strong text-text-muted hover:bg-surface-hover hover:text-text-secondary"
}`}
>
@@ -134,12 +149,16 @@ export function DetailHeader({
type="button"
onClick={onTogglePropRail}
aria-pressed={!propRailOpen}
- aria-label={propRailOpen ? 'Hide properties' : 'Show properties'}
- title={propRailOpen ? 'Hide properties — give the canvas more room' : 'Show properties'}
+ aria-label={propRailOpen ? "Hide properties" : "Show properties"}
+ title={
+ propRailOpen
+ ? "Hide properties — give the canvas more room"
+ : "Show properties"
+ }
className={`hidden h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition-colors xl:inline-flex ${
!propRailOpen
- ? 'border-accent/30 bg-accent/10 text-accent-light'
- : 'border-border-strong text-text-muted hover:bg-surface-hover hover:text-text-secondary'
+ ? "border-accent/30 bg-accent/10 text-accent-light"
+ : "border-border-strong text-text-muted hover:bg-surface-hover hover:text-text-secondary"
}`}
>
@@ -150,12 +169,12 @@ export function DetailHeader({
type="button"
onClick={onToggleDrawer}
aria-pressed={drawerOpen}
- aria-label={drawerOpen ? 'Hide properties' : 'Show properties'}
- title={drawerOpen ? 'Hide properties' : 'Show properties'}
+ aria-label={drawerOpen ? "Hide properties" : "Show properties"}
+ title={drawerOpen ? "Hide properties" : "Show properties"}
className={`inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition-colors xl:hidden ${
drawerOpen
- ? 'border-accent/30 bg-accent/10 text-accent-light'
- : 'border-border-strong text-text-muted hover:bg-surface-hover hover:text-text-secondary'
+ ? "border-accent/30 bg-accent/10 text-accent-light"
+ : "border-border-strong text-text-muted hover:bg-surface-hover hover:text-text-secondary"
}`}
>
@@ -189,18 +208,24 @@ export function DetailHeader({
}
}}
onKeyDown={(e) => {
- if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); }
- if (e.key === 'Escape') { cancelledRef.current = true; e.currentTarget.blur(); }
+ if (e.key === "Enter") {
+ e.preventDefault();
+ e.currentTarget.blur();
+ }
+ if (e.key === "Escape") {
+ cancelledRef.current = true;
+ e.currentTarget.blur();
+ }
}}
className="-mx-1 w-[calc(100%+0.5rem)] rounded bg-transparent px-1 text-[22px] font-semibold leading-[1.25] text-text-primary outline-none ring-1 ring-accent/40 transition focus:ring-accent/70"
- style={{ letterSpacing: '-0.005em' }}
+ style={{ letterSpacing: "-0.005em" }}
/>
) : (
setEditing(true)}
initial={false}
className="-mx-1 cursor-text rounded px-1 text-[22px] font-semibold leading-[1.25] text-text-primary transition-colors hover:bg-surface-raised/40"
- style={{ letterSpacing: '-0.005em' }}
+ style={{ letterSpacing: "-0.005em" }}
>
{title}
diff --git a/components/workspace/detail/DetailView.tsx b/components/workspace/detail/DetailView.tsx
index 9e1d45c..bb6642a 100644
--- a/components/workspace/detail/DetailView.tsx
+++ b/components/workspace/detail/DetailView.tsx
@@ -1,19 +1,19 @@
-'use client';
+"use client";
-import { useMemo } from 'react';
-import type { TaskEdge } from '@/lib/db/schema';
-import type { TaskFull, TaskGraphSlim, TaskLinkRef } from '@/lib/data/views';
-import type { TaskStatus } from '@/lib/types';
-import { BundlePreview } from '@/components/workspace/BundlePreview';
-import { DetailHeader } from './DetailHeader';
-import { DescriptionSection } from './DescriptionSection';
-import { CriteriaSection } from './CriteriaSection';
-import { DecisionsSection } from './DecisionsSection';
-import { LinksSection } from './LinksSection';
-import { RelationshipsSection } from './RelationshipsSection';
-import { ExecutionSection } from './ExecutionSection';
-import { ActivitySection } from './ActivitySection';
-import { SectionHeader } from './SectionHeader';
+import { useMemo } from "react";
+import type { TaskEdge } from "@/lib/db/schema";
+import type { TaskFull, TaskGraphSlim, TaskLinkRef } from "@/lib/data/views";
+import type { TaskStatus } from "@/lib/types";
+import { BundlePreview } from "@/components/workspace/BundlePreview";
+import { DetailHeader } from "./DetailHeader";
+import { DescriptionSection } from "./DescriptionSection";
+import { CriteriaSection } from "./CriteriaSection";
+import { DecisionsSection } from "./DecisionsSection";
+import { LinksSection } from "./LinksSection";
+import { RelationshipsSection } from "./RelationshipsSection";
+import { ExecutionSection } from "./ExecutionSection";
+import { ActivitySection } from "./ActivitySection";
+import { SectionHeader } from "./SectionHeader";
interface DetailViewProps {
/** Task UUID. */
@@ -93,12 +93,21 @@ export function DetailView({
() => allTasks.find((t) => t.id === taskId)?.state,
[allTasks, taskId],
);
- const ready = currentState === 'ready';
- const plannable = currentState === 'plannable';
+ const ready = currentState === "ready";
+ const plannable = currentState === "plannable";
- const prerequisites = useMemo(() => buildPrerequisites(taskId, allEdges, taskMap), [taskId, allEdges, taskMap]);
- const neighbors = useMemo(() => buildNeighbors(taskId, allEdges, taskMap), [taskId, allEdges, taskMap]);
- const downstream = useMemo(() => buildDownstream(taskId, allEdges, taskMap), [taskId, allEdges, taskMap]);
+ const prerequisites = useMemo(
+ () => buildPrerequisites(taskId, allEdges, taskMap),
+ [taskId, allEdges, taskMap],
+ );
+ const neighbors = useMemo(
+ () => buildNeighbors(taskId, allEdges, taskMap),
+ [taskId, allEdges, taskMap],
+ );
+ const downstream = useMemo(
+ () => buildDownstream(taskId, allEdges, taskMap),
+ [taskId, allEdges, taskMap],
+ );
return (
@@ -133,7 +142,16 @@ export function DetailView({
/>
- } />
+
+ }
+ />
= {
- draft: 'planning',
- plannable: 'planning',
- planned: 'working',
- ready: 'planning',
- in_progress: 'agent',
- in_review: 'execution',
- done: 'execution',
- cancelled: 'execution',
+ draft: "planning",
+ plannable: "planning",
+ planned: "working",
+ ready: "planning",
+ in_progress: "agent",
+ in_review: "execution",
+ done: "execution",
+ cancelled: "execution",
};
/**
@@ -212,13 +230,17 @@ const BUNDLE_BADGE_CAPTION: Record = {
* @param props - Badge props.
* @returns Inline badge element.
*/
-function BundleStageBadge({ status, isReady, isPlannable }: BundleStageBadgeProps) {
+function BundleStageBadge({
+ status,
+ isReady,
+ isPlannable,
+}: BundleStageBadgeProps) {
let stage: string = status;
- if (status === 'draft' && isPlannable) stage = 'plannable';
- else if (status === 'planned' && isReady) stage = 'ready';
+ if (status === "draft" && isPlannable) stage = "plannable";
+ else if (status === "planned" && isReady) stage = "ready";
return (
- {BUNDLE_BADGE_CAPTION[stage] ?? 'working'}
+ {BUNDLE_BADGE_CAPTION[stage] ?? "working"}
);
}
@@ -249,10 +271,16 @@ function buildPrerequisites(
): BundleNeighbor[] {
const out: BundleNeighbor[] = [];
for (const edge of edges) {
- if (edge.sourceTaskId !== taskId || edge.edgeType !== 'depends_on') continue;
+ if (edge.sourceTaskId !== taskId || edge.edgeType !== "depends_on")
+ continue;
const info = taskMap.get(edge.targetTaskId);
if (!info) continue;
- out.push({ id: edge.targetTaskId, taskRef: info.taskRef, title: info.title, status: info.status });
+ out.push({
+ id: edge.targetTaskId,
+ taskRef: info.taskRef,
+ title: info.title,
+ status: info.status,
+ });
}
return out;
}
@@ -273,17 +301,23 @@ function buildNeighbors(
const out: BundleNeighbor[] = [];
const seen = new Set();
for (const edge of edges) {
- if (edge.edgeType !== 'relates_to') continue;
- const otherId = edge.sourceTaskId === taskId
- ? edge.targetTaskId
- : edge.targetTaskId === taskId
- ? edge.sourceTaskId
- : null;
+ if (edge.edgeType !== "relates_to") continue;
+ const otherId =
+ edge.sourceTaskId === taskId
+ ? edge.targetTaskId
+ : edge.targetTaskId === taskId
+ ? edge.sourceTaskId
+ : null;
if (!otherId || seen.has(otherId)) continue;
const info = taskMap.get(otherId);
if (!info) continue;
seen.add(otherId);
- out.push({ id: otherId, taskRef: info.taskRef, title: info.title, status: info.status });
+ out.push({
+ id: otherId,
+ taskRef: info.taskRef,
+ title: info.title,
+ status: info.status,
+ });
}
return out;
}
@@ -303,10 +337,16 @@ function buildDownstream(
): BundleNeighbor[] {
const out: BundleNeighbor[] = [];
for (const edge of edges) {
- if (edge.edgeType !== 'depends_on' || edge.targetTaskId !== taskId) continue;
+ if (edge.edgeType !== "depends_on" || edge.targetTaskId !== taskId)
+ continue;
const info = taskMap.get(edge.sourceTaskId);
if (!info) continue;
- out.push({ id: edge.sourceTaskId, taskRef: info.taskRef, title: info.title, status: info.status });
+ out.push({
+ id: edge.sourceTaskId,
+ taskRef: info.taskRef,
+ title: info.title,
+ status: info.status,
+ });
}
return out;
}
diff --git a/components/workspace/detail/ExecutionSection.tsx b/components/workspace/detail/ExecutionSection.tsx
index 2e6ffeb..9e1098b 100644
--- a/components/workspace/detail/ExecutionSection.tsx
+++ b/components/workspace/detail/ExecutionSection.tsx
@@ -1,7 +1,7 @@
-'use client';
+"use client";
-import { Markdown } from '@/components/shared/Markdown';
-import { SectionHeader } from './SectionHeader';
+import { Markdown } from "@/components/shared/Markdown";
+import { SectionHeader } from "./SectionHeader";
interface ExecutionSectionProps {
/** Execution record markdown, or null when not present. */
@@ -23,7 +23,9 @@ export function ExecutionSection({ record }: ExecutionSectionProps) {
- {record}
+
+ {record}
+
);
diff --git a/components/workspace/detail/LinksSection.tsx b/components/workspace/detail/LinksSection.tsx
index b8741eb..780a885 100644
--- a/components/workspace/detail/LinksSection.tsx
+++ b/components/workspace/detail/LinksSection.tsx
@@ -1,12 +1,16 @@
-'use client';
-
-import { useCallback, useEffect, useRef, useState } from 'react';
-import type { ComponentType } from 'react';
-import { useUndo, UndoButton } from '@/hooks/useUndo';
-import { addTaskLink, removeTaskLink, updateTaskLink } from '@/lib/graph/mutations';
-import { classifyLink } from '@/lib/links/classify';
-import { IconPencil, IconPlus, IconTrash } from '@/components/shared/icons';
-import type { IconProps } from '@/components/shared/icons';
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { ComponentType } from "react";
+import { useUndo, UndoButton } from "@/hooks/useUndo";
+import {
+ addTaskLink,
+ removeTaskLink,
+ updateTaskLink,
+} from "@/lib/graph/mutations";
+import { classifyLink } from "@/lib/links/classify";
+import { IconPencil, IconPlus, IconTrash } from "@/components/shared/icons";
+import type { IconProps } from "@/components/shared/icons";
import {
IconFigma,
IconGitHub,
@@ -17,33 +21,33 @@ import {
IconNotion,
IconReddit,
IconStackOverflow,
-} from '@/components/shared/host-icons';
-import type { TaskLinkRef } from '@/lib/data/views';
-import { SectionHeader } from './SectionHeader';
+} from "@/components/shared/host-icons";
+import type { TaskLinkRef } from "@/lib/data/views";
+import { SectionHeader } from "./SectionHeader";
/**
* Host -> glyph map. Hosts in the classifier's recognised set get their own
* mark; everything else falls back to the globe.
*/
const HOST_ICONS: Record> = {
- 'github.com': IconGitHub,
- 'www.github.com': IconGitHub,
- 'gitlab.com': IconGitLab,
- 'www.gitlab.com': IconGitLab,
- 'linear.app': IconLinear,
- 'notion.so': IconNotion,
- 'www.notion.so': IconNotion,
- 'figma.com': IconFigma,
- 'www.figma.com': IconFigma,
- 'google.com': IconGoogle,
- 'www.google.com': IconGoogle,
- 'docs.google.com': IconGoogle,
- 'drive.google.com': IconGoogle,
- 'reddit.com': IconReddit,
- 'www.reddit.com': IconReddit,
- 'old.reddit.com': IconReddit,
- 'stackoverflow.com': IconStackOverflow,
- 'www.stackoverflow.com': IconStackOverflow,
+ "github.com": IconGitHub,
+ "www.github.com": IconGitHub,
+ "gitlab.com": IconGitLab,
+ "www.gitlab.com": IconGitLab,
+ "linear.app": IconLinear,
+ "notion.so": IconNotion,
+ "www.notion.so": IconNotion,
+ "figma.com": IconFigma,
+ "www.figma.com": IconFigma,
+ "google.com": IconGoogle,
+ "www.google.com": IconGoogle,
+ "docs.google.com": IconGoogle,
+ "drive.google.com": IconGoogle,
+ "reddit.com": IconReddit,
+ "www.reddit.com": IconReddit,
+ "old.reddit.com": IconReddit,
+ "stackoverflow.com": IconStackOverflow,
+ "www.stackoverflow.com": IconStackOverflow,
};
/**
@@ -70,11 +74,11 @@ function HostGlyph({ host, size = 14 }: { host: string; size?: number }) {
* doc is violet ("knowledge"), fallback link is neutral muted.
*/
const KIND_COLORS: Record = {
- pull_request: 'var(--color-accent)',
- issue: 'var(--color-glyph-progress)',
- commit: 'var(--color-glyph-done)',
- doc: 'var(--color-glyph-review)',
- link: 'var(--color-text-muted)',
+ pull_request: "var(--color-accent)",
+ issue: "var(--color-glyph-progress)",
+ commit: "var(--color-glyph-done)",
+ doc: "var(--color-glyph-review)",
+ link: "var(--color-text-muted)",
};
/**
@@ -91,8 +95,8 @@ function KindDot({ kind }: { kind: string }) {
const color = KIND_COLORS[kind] ?? KIND_COLORS.link;
return (
`${l.id}|${l.url}`).join('||');
+ return (items ?? []).map((l) => `${l.id}|${l.url}`).join("||");
}
interface LinksSectionProps {
@@ -151,7 +155,11 @@ interface LinksSectionProps {
* @param props - Section configuration.
* @returns Links list plus add affordance.
*/
-export function LinksSection({ taskId, links, onGraphChange }: LinksSectionProps) {
+export function LinksSection({
+ taskId,
+ links,
+ onGraphChange,
+}: LinksSectionProps) {
const [local, setLocal] = useState(() => links ?? []);
const [syncedSig, setSyncedSig] = useState(() => signatureFor(links));
const [prevTaskId, setPrevTaskId] = useState(taskId);
@@ -163,11 +171,18 @@ export function LinksSection({ taskId, links, onGraphChange }: LinksSectionProps
const suppressTimerRef = useRef(null);
const errorTimerRef = useRef(null);
- useEffect(() => { localRef.current = local; }, [local]);
- useEffect(() => () => {
- if (suppressTimerRef.current !== null) window.clearTimeout(suppressTimerRef.current);
- if (errorTimerRef.current !== null) window.clearTimeout(errorTimerRef.current);
- }, []);
+ useEffect(() => {
+ localRef.current = local;
+ }, [local]);
+ useEffect(
+ () => () => {
+ if (suppressTimerRef.current !== null)
+ window.clearTimeout(suppressTimerRef.current);
+ if (errorTimerRef.current !== null)
+ window.clearTimeout(errorTimerRef.current);
+ },
+ [],
+ );
/**
* Mark a 1s window where SSE refreshes won't clobber the optimistic
@@ -175,7 +190,8 @@ export function LinksSection({ taskId, links, onGraphChange }: LinksSectionProps
*/
const markMutation = () => {
setSuppressing(true);
- if (suppressTimerRef.current !== null) window.clearTimeout(suppressTimerRef.current);
+ if (suppressTimerRef.current !== null)
+ window.clearTimeout(suppressTimerRef.current);
suppressTimerRef.current = window.setTimeout(() => {
setSuppressing(false);
suppressTimerRef.current = null;
@@ -188,7 +204,8 @@ export function LinksSection({ taskId, links, onGraphChange }: LinksSectionProps
*/
const flashError = (message: string) => {
setError(message);
- if (errorTimerRef.current !== null) window.clearTimeout(errorTimerRef.current);
+ if (errorTimerRef.current !== null)
+ window.clearTimeout(errorTimerRef.current);
errorTimerRef.current = window.setTimeout(() => {
setError(null);
errorTimerRef.current = null;
@@ -218,13 +235,17 @@ export function LinksSection({ taskId, links, onGraphChange }: LinksSectionProps
setLocal(next);
onGraphChange?.();
} catch {
- flashError('Could not restore link.');
+ flashError("Could not restore link.");
}
},
[taskId, onGraphChange],
);
- const { canUndo, push: pushUndo, undo } = useUndo<{ url: string; index: number }>({
+ const {
+ canUndo,
+ push: pushUndo,
+ undo,
+ } = useUndo<{ url: string; index: number }>({
onUndo: handleRestore,
resetOn: taskId,
});
@@ -241,7 +262,7 @@ export function LinksSection({ taskId, links, onGraphChange }: LinksSectionProps
await removeTaskLink(linkId);
onGraphChange?.();
} catch {
- flashError('Delete failed.');
+ flashError("Delete failed.");
}
},
[pushUndo, onGraphChange],
@@ -250,14 +271,17 @@ export function LinksSection({ taskId, links, onGraphChange }: LinksSectionProps
const handleAdd = useCallback(
async (url: string) => {
const trimmed = url.trim();
- if (!trimmed) { setAdding(false); return; }
+ if (!trimmed) {
+ setAdding(false);
+ return;
+ }
// Light client-side validation so the user gets immediate feedback;
// shares the same classifier the server runs, so scheme-less input
// (`google.com`) and non-http schemes stay consistent across surfaces.
try {
classifyLink(trimmed);
} catch {
- flashError('Invalid URL.');
+ flashError("Invalid URL.");
return;
}
setAdding(false);
@@ -271,7 +295,7 @@ export function LinksSection({ taskId, links, onGraphChange }: LinksSectionProps
}
onGraphChange?.();
} catch {
- flashError('Could not add link.');
+ flashError("Could not add link.");
}
},
[taskId, onGraphChange],
@@ -280,13 +304,19 @@ export function LinksSection({ taskId, links, onGraphChange }: LinksSectionProps
const handleEdit = useCallback(
async (linkId: string, url: string) => {
const trimmed = url.trim();
- if (!trimmed) { setEditingId(null); return; }
+ if (!trimmed) {
+ setEditingId(null);
+ return;
+ }
const target = localRef.current.find((l) => l.id === linkId);
- if (target && trimmed === target.url) { setEditingId(null); return; }
+ if (target && trimmed === target.url) {
+ setEditingId(null);
+ return;
+ }
try {
classifyLink(trimmed);
} catch {
- flashError('Invalid URL.');
+ flashError("Invalid URL.");
return;
}
setEditingId(null);
@@ -299,7 +329,7 @@ export function LinksSection({ taskId, links, onGraphChange }: LinksSectionProps
setLocal(next);
onGraphChange?.();
} catch {
- flashError('Could not update link.');
+ flashError("Could not update link.");
}
},
[onGraphChange],
@@ -359,7 +389,10 @@ export function LinksSection({ taskId, links, onGraphChange }: LinksSectionProps
{adding && (
void handleAdd(url)}
- onCancel={() => { setAdding(false); setError(null); }}
+ onCancel={() => {
+ setAdding(false);
+ setError(null);
+ }}
/>
)}
@@ -432,7 +465,7 @@ function LinkCard({
{display}
- {link.kind.replace(/_/g, ' ')}
+ {link.kind.replace(/_/g, " ")}
{
const trimmed = url.trim();
- if (!trimmed) { onCancel(); return; }
+ if (!trimmed) {
+ onCancel();
+ return;
+ }
onSubmit(trimmed);
};
@@ -492,8 +528,14 @@ function LinkEditForm({ initialUrl, onSubmit, onCancel }: LinkEditFormProps) {
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
- if (e.key === 'Enter') { e.preventDefault(); submit(); }
- if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
+ if (e.key === "Enter") {
+ e.preventDefault();
+ submit();
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ onCancel();
+ }
}}
className="w-full rounded-md border border-border-strong bg-surface px-2.5 py-1.5 font-mono text-[12px] text-text-primary outline-none transition-colors placeholder:text-text-muted focus:border-accent"
/>
@@ -535,11 +577,14 @@ interface LinkAddFormProps {
* @returns Form element.
*/
function LinkAddForm({ onSubmit, onCancel }: LinkAddFormProps) {
- const [url, setUrl] = useState('');
+ const [url, setUrl] = useState("");
const submit = () => {
const trimmed = url.trim();
- if (!trimmed) { onCancel(); return; }
+ if (!trimmed) {
+ onCancel();
+ return;
+ }
onSubmit(trimmed);
};
@@ -553,8 +598,14 @@ function LinkAddForm({ onSubmit, onCancel }: LinkAddFormProps) {
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
- if (e.key === 'Enter') { e.preventDefault(); submit(); }
- if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
+ if (e.key === "Enter") {
+ e.preventDefault();
+ submit();
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ onCancel();
+ }
}}
placeholder="github.com/owner/repo/pull/1"
className="w-full rounded-md border border-border-strong bg-surface px-2.5 py-1.5 font-mono text-[12px] text-text-primary outline-none transition-colors placeholder:text-text-muted focus:border-accent"
diff --git a/components/workspace/detail/PlanSection.tsx b/components/workspace/detail/PlanSection.tsx
index 2b55def..3964543 100644
--- a/components/workspace/detail/PlanSection.tsx
+++ b/components/workspace/detail/PlanSection.tsx
@@ -1,16 +1,16 @@
-'use client';
+"use client";
-import { useState, useCallback, useMemo } from 'react';
-import { Button } from '@/components/shared/Button';
-import { CopyButton } from '@/components/shared/CopyButton';
-import { Markdown } from '@/components/shared/Markdown';
-import { useUndo, UndoButton } from '@/hooks/useUndo';
-import { updateTask } from '@/lib/graph/mutations';
-import type { TaskEdge } from '@/lib/db/schema';
-import { SectionHeader } from './SectionHeader';
+import { useState, useCallback, useMemo } from "react";
+import { Button } from "@/components/shared/Button";
+import { CopyButton } from "@/components/shared/CopyButton";
+import { Markdown } from "@/components/shared/Markdown";
+import { useUndo, UndoButton } from "@/hooks/useUndo";
+import { updateTask } from "@/lib/graph/mutations";
+import type { TaskEdge } from "@/lib/db/schema";
+import { SectionHeader } from "./SectionHeader";
/** Lifecycle action types — match the PlanTab undo identifiers. */
-type LifecycleAction = 'plan-saved' | 'start-impl' | 'mark-done';
+type LifecycleAction = "plan-saved" | "start-impl" | "mark-done";
interface PlanSectionProps {
/** Task UUID. */
@@ -52,88 +52,105 @@ export function PlanSection({
}: PlanSectionProps) {
const [plan, setPlan] = useState(existingPlan);
const [execution, setExecution] = useState(existingExecution);
- const [planInput, setPlanInput] = useState('');
- const [executionInput, setExecutionInput] = useState('');
+ const [planInput, setPlanInput] = useState("");
+ const [executionInput, setExecutionInput] = useState("");
const [saving, setSaving] = useState(false);
- const started = status === 'in_progress' || status === 'done';
+ const started = status === "in_progress" || status === "done";
- const handleUndoAction = useCallback(async (action: LifecycleAction) => {
- setSaving(true);
- if (action === 'plan-saved') {
- await updateTask(taskId, { implementationPlan: null, status: 'draft' });
- setPlan(null);
- setPlanInput('');
- } else if (action === 'start-impl') {
- await updateTask(taskId, { status: 'planned' });
- } else {
- await updateTask(taskId, { executionRecord: null, status: 'in_progress' });
- setExecution(null);
- }
- setSaving(false);
- onGraphChange?.();
- }, [taskId, onGraphChange]);
+ const handleUndoAction = useCallback(
+ async (action: LifecycleAction) => {
+ setSaving(true);
+ if (action === "plan-saved") {
+ await updateTask(taskId, { implementationPlan: null, status: "draft" });
+ setPlan(null);
+ setPlanInput("");
+ } else if (action === "start-impl") {
+ await updateTask(taskId, { status: "planned" });
+ } else {
+ await updateTask(taskId, {
+ executionRecord: null,
+ status: "in_progress",
+ });
+ setExecution(null);
+ }
+ setSaving(false);
+ onGraphChange?.();
+ },
+ [taskId, onGraphChange],
+ );
- const { canUndo, push: pushUndo, undo } = useUndo({ onUndo: handleUndoAction, resetOn: taskId });
+ const {
+ canUndo,
+ push: pushUndo,
+ undo,
+ } = useUndo({ onUndo: handleUndoAction, resetOn: taskId });
const unmetDeps = useMemo(() => {
if (!taskMap) return [] as { id: string; title: string; taskRef: string }[];
return edges
- .filter((e) => e.sourceTaskId === taskId && e.edgeType === 'depends_on')
+ .filter((e) => e.sourceTaskId === taskId && e.edgeType === "depends_on")
.map((e) => {
const info = taskMap.get(e.targetTaskId);
- return info && info.status !== 'done' && info.status !== 'cancelled'
+ return info && info.status !== "done" && info.status !== "cancelled"
? { id: e.targetTaskId, title: info.title, taskRef: info.taskRef }
: null;
})
- .filter((d): d is { id: string; title: string; taskRef: string } => d !== null);
+ .filter(
+ (d): d is { id: string; title: string; taskRef: string } => d !== null,
+ );
}, [edges, taskId, taskMap]);
const handleSavePlan = useCallback(async () => {
const trimmed = planInput.trim();
if (!trimmed) return;
setSaving(true);
- await updateTask(taskId, { implementationPlan: trimmed, status: 'planned' });
+ await updateTask(taskId, {
+ implementationPlan: trimmed,
+ status: "planned",
+ });
setPlan(trimmed);
setSaving(false);
- pushUndo('plan-saved');
+ pushUndo("plan-saved");
onGraphChange?.();
}, [taskId, planInput, pushUndo, onGraphChange]);
const handleStartImpl = useCallback(async () => {
setSaving(true);
- await updateTask(taskId, { status: 'in_progress' });
+ await updateTask(taskId, { status: "in_progress" });
setSaving(false);
- pushUndo('start-impl');
+ pushUndo("start-impl");
onGraphChange?.();
}, [taskId, pushUndo, onGraphChange]);
const handleMarkDone = useCallback(async () => {
setSaving(true);
- const record = executionInput.trim() || 'Completed';
- await updateTask(taskId, { executionRecord: record, status: 'done' });
+ const record = executionInput.trim() || "Completed";
+ await updateTask(taskId, { executionRecord: record, status: "done" });
setExecution(record);
setSaving(false);
- pushUndo('mark-done');
+ pushUndo("mark-done");
onGraphChange?.();
}, [taskId, executionInput, pushUndo, onGraphChange]);
const handleReplan = useCallback(() => {
setPlan(null);
- setPlanInput('');
+ setPlanInput("");
}, []);
if (!plan) {
return (
- } />
+ }
+ />
- Copy the planning context into your CLI agent (Claude Code, Cursor, Codex, …), then paste the implementation plan back here.
+ Copy the planning context into your CLI agent (Claude Code, Cursor,
+ Codex, …), then paste the implementation plan back here.
- {unmetDeps.length > 0 && (
-
- )}
+ {unmetDeps.length > 0 &&
}
@@ -143,7 +160,9 @@ export function PlanSection({
-
{planningContext}
+
+ {planningContext}
+
@@ -164,7 +183,7 @@ export function PlanSection({
disabled={!planInput.trim() || saving}
onClick={handleSavePlan}
>
- {saving ? 'Saving…' : 'Save plan & mark planned'}
+ {saving ? "Saving…" : "Save plan & mark planned"}
@@ -176,7 +195,12 @@ export function PlanSection({
}
+ badge={
+
+ }
trailing={
@@ -195,21 +219,31 @@ export function PlanSection({
/>
- {plan}
+
+ {plan}
+
{execution ? (
- {execution}
+
+ {execution}
+
) : !started ? (
- Plan saved. Start implementation to claim this task and begin tracking execution.
+ Plan saved. Start implementation to claim this task and begin
+ tracking execution.
-
- {saving ? 'Starting…' : 'Start implementation'}
+
+ {saving ? "Starting…" : "Start implementation"}
) : (
@@ -224,8 +258,14 @@ export function PlanSection({
rows={5}
className="w-full resize-none rounded-md border border-border-strong bg-surface px-3 py-2 font-mono text-[11.5px] text-text-primary placeholder:text-text-muted outline-none focus:border-accent"
/>
-
- {saving ? 'Saving…' : 'Mark as done'}
+
+ {saving ? "Saving…" : "Mark as done"}
)}
@@ -254,8 +294,13 @@ function UnmetDepsHint({ deps }: UnmetDepsHintProps) {
{deps.map((d) => (
-
- {d.taskRef}
+
+
+ {d.taskRef}
+
{d.title}
))}
@@ -268,7 +313,7 @@ interface PhaseBadgeProps {
/** Badge label text. */
label: string;
/** Tone keyed to status palette. */
- tone: 'draft' | 'planned' | 'progress' | 'done';
+ tone: "draft" | "planned" | "progress" | "done";
}
/**
@@ -279,14 +324,28 @@ interface PhaseBadgeProps {
*/
function PhaseBadge({ label, tone }: PhaseBadgeProps) {
const map = {
- draft: { fg: 'text-text-muted', bg: 'bg-text-muted/10', border: 'border-text-muted/20' },
- planned: { fg: 'text-planned', bg: 'bg-planned/10', border: 'border-planned/25' },
- progress: { fg: 'text-progress', bg: 'bg-progress/10', border: 'border-progress/25' },
- done: { fg: 'text-done', bg: 'bg-done/10', border: 'border-done/25' },
+ draft: {
+ fg: "text-text-muted",
+ bg: "bg-text-muted/10",
+ border: "border-text-muted/20",
+ },
+ planned: {
+ fg: "text-planned",
+ bg: "bg-planned/10",
+ border: "border-planned/25",
+ },
+ progress: {
+ fg: "text-progress",
+ bg: "bg-progress/10",
+ border: "border-progress/25",
+ },
+ done: { fg: "text-done", bg: "bg-done/10", border: "border-done/25" },
} as const;
const cls = map[tone];
return (
-
+
{label}
);
@@ -294,7 +353,7 @@ function PhaseBadge({ label, tone }: PhaseBadgeProps) {
interface PhaseCardProps {
/** Tone keyed to status palette. */
- tone: 'planned' | 'progress' | 'done';
+ tone: "planned" | "progress" | "done";
/** Card title. */
title: string;
/** Card body. */
@@ -310,15 +369,26 @@ interface PhaseCardProps {
*/
function PhaseCard({ tone, title, children }: PhaseCardProps) {
const map = {
- planned: { ring: 'border-planned/20', bg: 'bg-planned/5', fg: 'text-planned' },
- progress: { ring: 'border-progress/20', bg: 'bg-progress/5', fg: 'text-progress' },
- done: { ring: 'border-done/20', bg: 'bg-done/5', fg: 'text-done' },
+ planned: {
+ ring: "border-planned/20",
+ bg: "bg-planned/5",
+ fg: "text-planned",
+ },
+ progress: {
+ ring: "border-progress/20",
+ bg: "bg-progress/5",
+ fg: "text-progress",
+ },
+ done: { ring: "border-done/20", bg: "bg-done/5", fg: "text-done" },
} as const;
const cls = map[tone];
return (
-
+
{title}
{children}
diff --git a/components/workspace/detail/PropRail.tsx b/components/workspace/detail/PropRail.tsx
index 021febd..2ea9765 100644
--- a/components/workspace/detail/PropRail.tsx
+++ b/components/workspace/detail/PropRail.tsx
@@ -1,21 +1,21 @@
-'use client';
-
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { createPortal } from 'react-dom';
-import { AnimatePresence, motion } from 'motion/react';
-import { useQuery, useQueryClient } from '@tanstack/react-query';
-import { Avatar } from '@/components/shared/Avatar';
-import { Markdown } from '@/components/shared/Markdown';
-import { MonoId } from '@/components/shared/MonoId';
-import { PriorityIcon } from '@/components/shared/PriorityIcon';
-import { StatusGlyph, STATUS_META } from '@/components/shared/StatusGlyph';
-import { Dropdown } from '@/components/shared/Dropdown';
-import { useUndo, UndoButton } from '@/hooks/useUndo';
-import { popoverFixedStyle, usePopoverAnchor } from '@/hooks/usePopoverAnchor';
-import { updateTask } from '@/lib/graph/mutations';
-import { projectColor } from '@/lib/ui/project-color';
-import { listTeamMembersAction } from '@/lib/actions/team-members';
-import type { MemberView } from '@/lib/actions/team-members-map';
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { AnimatePresence, motion } from "motion/react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Avatar } from "@/components/shared/Avatar";
+import { Markdown } from "@/components/shared/Markdown";
+import { MonoId } from "@/components/shared/MonoId";
+import { PriorityIcon } from "@/components/shared/PriorityIcon";
+import { StatusGlyph, STATUS_META } from "@/components/shared/StatusGlyph";
+import { Dropdown } from "@/components/shared/Dropdown";
+import { useUndo, UndoButton } from "@/hooks/useUndo";
+import { popoverFixedStyle, usePopoverAnchor } from "@/hooks/usePopoverAnchor";
+import { updateTask } from "@/lib/graph/mutations";
+import { projectColor } from "@/lib/ui/project-color";
+import { listTeamMembersAction } from "@/lib/actions/team-members";
+import type { MemberView } from "@/lib/actions/team-members-map";
import {
IconBranch,
IconChevronDown,
@@ -27,10 +27,10 @@ import {
IconTag,
IconUser,
IconX,
-} from '@/components/shared/icons';
-import type { TaskEdge } from '@/lib/db/schema';
-import type { Priority, Estimate, TaskStatus } from '@/lib/types';
-import type { AssigneeRef, ProjectGraphSlim, TaskFull } from '@/lib/data/views';
+} from "@/components/shared/icons";
+import type { TaskEdge } from "@/lib/db/schema";
+import type { Priority, Estimate, TaskStatus } from "@/lib/types";
+import type { AssigneeRef, ProjectGraphSlim, TaskFull } from "@/lib/data/views";
/**
* Subset of task fields safe to patch onto both the `TaskFull` and
@@ -46,15 +46,22 @@ type TaskPatch = Partial<{
category: string | null;
tags: string[];
}>;
-import { PRIORITY_COLOR, PRIORITY_DISPLAY_ORDER } from '@/lib/ui/priority';
-import { projectKeys, taskKeys, teamKeys } from '@/lib/query/keys';
+import { PRIORITY_COLOR, PRIORITY_DISPLAY_ORDER } from "@/lib/ui/priority";
+import { projectKeys, taskKeys, teamKeys } from "@/lib/query/keys";
/** Display order for the Status dropdown — matches the lifecycle ribbon. */
-const STATUS_OPTIONS: readonly TaskStatus[] = ['draft', 'planned', 'in_progress', 'in_review', 'done', 'cancelled'];
+const STATUS_OPTIONS: readonly TaskStatus[] = [
+ "draft",
+ "planned",
+ "in_progress",
+ "in_review",
+ "done",
+ "cancelled",
+];
/** Display order for the Estimate dropdown — Fibonacci story points. */
const ESTIMATE_OPTIONS: readonly Estimate[] = [1, 2, 3, 5, 8, 13];
/** Sentinel used by dropdowns to model the "clear" action under `string` schemas. */
-const SENTINEL_CLEAR = '__clear__';
+const SENTINEL_CLEAR = "__clear__";
interface PropRailProps {
/** Task UUID. */
@@ -128,15 +135,11 @@ export function PropRail({
// an O(edges) hit on every refetch even when nothing relevant changed.
// Centralising the pass also gives `DepGroup` a stable `items` prop so a
// future `React.memo` on the sub-row can short-circuit.
- const {
- dependsOnItems,
- blocksItems,
- totalDeps,
- } = useMemo(() => {
+ const { dependsOnItems, blocksItems, totalDeps } = useMemo(() => {
const dependsOnArr: { edgeId: string; otherId: string }[] = [];
const blocksArr: { edgeId: string; otherId: string }[] = [];
for (const e of edges) {
- if (e.edgeType !== 'depends_on') continue;
+ if (e.edgeType !== "depends_on") continue;
if (e.sourceTaskId === taskId) {
dependsOnArr.push({ edgeId: e.id, otherId: e.targetTaskId });
} else if (e.targetTaskId === taskId) {
@@ -166,88 +169,113 @@ export function PropRail({
// in `TaskPatch` is present on both shapes, so the same patch object
// is safe to spread onto either.
const queryClient = useQueryClient();
- const applyOptimisticPatch = useCallback((patch: TaskPatch) => {
- const taskKey = taskKeys.detail(projectId, taskId);
- const graphKey = projectKeys.graph(projectId);
-
- void queryClient.cancelQueries({ queryKey: taskKey });
- void queryClient.cancelQueries({ queryKey: graphKey });
+ const applyOptimisticPatch = useCallback(
+ (patch: TaskPatch) => {
+ const taskKey = taskKeys.detail(projectId, taskId);
+ const graphKey = projectKeys.graph(projectId);
+
+ void queryClient.cancelQueries({ queryKey: taskKey });
+ void queryClient.cancelQueries({ queryKey: graphKey });
+
+ queryClient.setQueryData
(taskKey, (prev) =>
+ prev ? { ...prev, ...patch } : prev,
+ );
+ queryClient.setQueryData(graphKey, (prev) =>
+ prev
+ ? {
+ ...prev,
+ tasks: prev.tasks.map((t) =>
+ t.id === taskId ? { ...t, ...patch } : t,
+ ),
+ }
+ : prev,
+ );
+ },
+ [projectId, taskId, queryClient],
+ );
- queryClient.setQueryData(taskKey, (prev) =>
- prev ? { ...prev, ...patch } : prev,
- );
- queryClient.setQueryData(graphKey, (prev) =>
- prev
- ? {
- ...prev,
- tasks: prev.tasks.map((t) =>
- t.id === taskId ? { ...t, ...patch } : t,
- ),
- }
- : prev,
- );
- }, [projectId, taskId, queryClient]);
-
- const handleRestoreStatus = useCallback(async (prev: TaskStatus) => {
- applyOptimisticPatch({ status: prev });
- try {
- await updateTask(taskId, { status: prev });
- } finally {
- onGraphChange?.();
- }
- }, [taskId, applyOptimisticPatch, onGraphChange]);
+ const handleRestoreStatus = useCallback(
+ async (prev: TaskStatus) => {
+ applyOptimisticPatch({ status: prev });
+ try {
+ await updateTask(taskId, { status: prev });
+ } finally {
+ onGraphChange?.();
+ }
+ },
+ [taskId, applyOptimisticPatch, onGraphChange],
+ );
- const { canUndo: canUndoStatus, push: pushStatusUndo, undo: undoStatus } = useUndo({
+ const {
+ canUndo: canUndoStatus,
+ push: pushStatusUndo,
+ undo: undoStatus,
+ } = useUndo({
onUndo: handleRestoreStatus,
resetOn: taskId,
});
- const handleStatusChange = useCallback(async (next: TaskStatus) => {
- if (next === status) return;
- pushStatusUndo(status);
- applyOptimisticPatch({ status: next });
- try {
- await updateTask(taskId, { status: next });
- } finally {
- onGraphChange?.();
- }
- }, [taskId, status, pushStatusUndo, applyOptimisticPatch, onGraphChange]);
-
- const handleCategoryChange = useCallback(async (next: string | null) => {
- applyOptimisticPatch({ category: next });
- try {
- await updateTask(taskId, { category: next });
- } finally {
- onGraphChange?.();
- }
- }, [taskId, applyOptimisticPatch, onGraphChange]);
-
- const handleTagsChange = useCallback(async (next: string[]) => {
- applyOptimisticPatch({ tags: next });
- try {
- await updateTask(taskId, { tags: next }, true);
- } finally {
- onGraphChange?.();
- }
- }, [taskId, applyOptimisticPatch, onGraphChange]);
-
- const handlePriorityChange = useCallback(async (next: Priority | null) => {
- applyOptimisticPatch({ priority: next });
- try {
- await updateTask(taskId, { priority: next });
- } finally {
- onGraphChange?.();
- }
- }, [taskId, applyOptimisticPatch, onGraphChange]);
-
- const handleEstimateChange = useCallback(async (next: Estimate | null) => {
- applyOptimisticPatch({ estimate: next });
- try {
- await updateTask(taskId, { estimate: next });
- } finally {
- onGraphChange?.();
- }
- }, [taskId, applyOptimisticPatch, onGraphChange]);
+ const handleStatusChange = useCallback(
+ async (next: TaskStatus) => {
+ if (next === status) return;
+ pushStatusUndo(status);
+ applyOptimisticPatch({ status: next });
+ try {
+ await updateTask(taskId, { status: next });
+ } finally {
+ onGraphChange?.();
+ }
+ },
+ [taskId, status, pushStatusUndo, applyOptimisticPatch, onGraphChange],
+ );
+
+ const handleCategoryChange = useCallback(
+ async (next: string | null) => {
+ applyOptimisticPatch({ category: next });
+ try {
+ await updateTask(taskId, { category: next });
+ } finally {
+ onGraphChange?.();
+ }
+ },
+ [taskId, applyOptimisticPatch, onGraphChange],
+ );
+
+ const handleTagsChange = useCallback(
+ async (next: string[]) => {
+ applyOptimisticPatch({ tags: next });
+ try {
+ await updateTask(taskId, { tags: next }, true);
+ } finally {
+ onGraphChange?.();
+ }
+ },
+ [taskId, applyOptimisticPatch, onGraphChange],
+ );
+
+ const handlePriorityChange = useCallback(
+ async (next: Priority | null) => {
+ applyOptimisticPatch({ priority: next });
+ try {
+ await updateTask(taskId, { priority: next });
+ } finally {
+ onGraphChange?.();
+ }
+ },
+ [taskId, applyOptimisticPatch, onGraphChange],
+ );
+
+ const handleEstimateChange = useCallback(
+ async (next: Estimate | null) => {
+ applyOptimisticPatch({ estimate: next });
+ try {
+ await updateTask(taskId, { estimate: next });
+ } finally {
+ onGraphChange?.();
+ }
+ },
+ [taskId, applyOptimisticPatch, onGraphChange],
+ );
// Optimistic assignee updates: rewrite both the task-detail cache
// (drives PropRail's trigger + name resolution) and the slim graph
@@ -278,112 +306,149 @@ export function PropRail({
const pendingAssigneeWritesRef = useRef(0);
const latestAssigneeIntentRef = useRef(null);
- const applyAssigneesOptimistically = useCallback((nextUserIds: string[]) => {
- const taskKey = taskKeys.detail(projectId, taskId);
- const graphKey = projectKeys.graph(projectId);
-
- // Resolve the new assignee projection from the team-members cache. If
- // the cache is cold (graph-rooted entry, picker opened before the
- // query resolved), `getQueryData` returns undefined; the safe fall
- // back is to keep `prev.assignees` for unresolved ids so a cold cache
- // cannot drop an assignee.
- const cachedMembers =
- queryClient.getQueryData(teamKeys.members(organizationId));
- const memberById = new Map((cachedMembers ?? []).map((m) => [m.userId, m]));
-
- queryClient.setQueryData(taskKey, (prev) => {
- if (!prev) return prev;
- const prevById = new Map(prev.assignees.map((a) => [a.userId, a]));
- const nextAssignees: AssigneeRef[] = nextUserIds
- .map((id) => {
- const fromCache = memberById.get(id);
- if (fromCache) {
- return { userId: fromCache.userId, name: fromCache.name, email: fromCache.email };
- }
- return prevById.get(id);
- })
- .filter((a): a is AssigneeRef => a !== undefined)
- .sort((a, b) => a.name.localeCompare(b.name));
- return { ...prev, assignees: nextAssignees };
- });
- queryClient.setQueryData(graphKey, (prev) =>
- prev
- ? {
- ...prev,
- tasks: prev.tasks.map((t) =>
- t.id === taskId
- ? { ...t, assigneeUserIds: nextUserIds, assigneeCount: nextUserIds.length }
- : t,
- ),
- }
- : prev,
- );
- }, [projectId, taskId, organizationId, queryClient]);
-
- const handleAssigneesChange = useCallback((nextUserIds: string[]) => {
- const taskKey = taskKeys.detail(projectId, taskId);
- const graphKey = projectKeys.graph(projectId);
-
- // Track latest user intent — re-applied after each mutation lands so
- // an SSE-driven refetch that completed mid-chain can't reveal an
- // intermediate server state.
- latestAssigneeIntentRef.current = nextUserIds;
-
- // Cancel any in-flight refetch — `RealtimeBridge` may have invalidated
- // the query from a previous mutation's SSE event, and the resulting
- // refetch is currently in flight. Without cancellation, that refetch
- // can complete after our `setQueryData` below and overwrite it.
- void queryClient.cancelQueries({ queryKey: taskKey });
- void queryClient.cancelQueries({ queryKey: graphKey });
-
- applyAssigneesOptimistically(nextUserIds);
+ const applyAssigneesOptimistically = useCallback(
+ (nextUserIds: string[]) => {
+ const taskKey = taskKeys.detail(projectId, taskId);
+ const graphKey = projectKeys.graph(projectId);
+
+ // Resolve the new assignee projection from the team-members cache. If
+ // the cache is cold (graph-rooted entry, picker opened before the
+ // query resolved), `getQueryData` returns undefined; the safe fall
+ // back is to keep `prev.assignees` for unresolved ids so a cold cache
+ // cannot drop an assignee.
+ const cachedMembers = queryClient.getQueryData(
+ teamKeys.members(organizationId),
+ );
+ const memberById = new Map(
+ (cachedMembers ?? []).map((m) => [m.userId, m]),
+ );
+
+ queryClient.setQueryData(taskKey, (prev) => {
+ if (!prev) return prev;
+ const prevById = new Map(prev.assignees.map((a) => [a.userId, a]));
+ const nextAssignees: AssigneeRef[] = nextUserIds
+ .map((id) => {
+ const fromCache = memberById.get(id);
+ if (fromCache) {
+ return {
+ userId: fromCache.userId,
+ name: fromCache.name,
+ email: fromCache.email,
+ };
+ }
+ return prevById.get(id);
+ })
+ .filter((a): a is AssigneeRef => a !== undefined)
+ .sort((a, b) => a.name.localeCompare(b.name));
+ return { ...prev, assignees: nextAssignees };
+ });
+ queryClient.setQueryData(graphKey, (prev) =>
+ prev
+ ? {
+ ...prev,
+ tasks: prev.tasks.map((t) =>
+ t.id === taskId
+ ? {
+ ...t,
+ assigneeUserIds: nextUserIds,
+ assigneeCount: nextUserIds.length,
+ }
+ : t,
+ ),
+ }
+ : prev,
+ );
+ },
+ [projectId, taskId, organizationId, queryClient],
+ );
- pendingAssigneeWritesRef.current += 1;
- const myTurn = assigneeMutationChainRef.current.then(async () => {
- try {
- // Skip when newer clicks have queued after this step —
- // `overwriteArrays=true` makes the later write a complete superset
- // so this intermediate call is redundant, and dropping it reduces
- // the SSE storm that drives race #2 above.
- if (pendingAssigneeWritesRef.current > 1) return;
- await updateTask(taskId, { assigneeIds: nextUserIds }, true);
- } finally {
- pendingAssigneeWritesRef.current -= 1;
- if (pendingAssigneeWritesRef.current === 0) {
- // Final mutation drained — let the broker-triggered refetch sync
- // the server's settled view back in.
- latestAssigneeIntentRef.current = null;
- onGraphChange?.();
- } else {
- // More clicks queued: an SSE event from this step's response is
- // about to (or already has) triggered an invalidating refetch
- // that would land an intermediate snapshot on the cache. Cancel
- // it and re-apply the latest intent so the trigger stays at the
- // user's latest selection.
- void queryClient.cancelQueries({ queryKey: taskKey });
- void queryClient.cancelQueries({ queryKey: graphKey });
- const latest = latestAssigneeIntentRef.current;
- if (latest !== null) applyAssigneesOptimistically(latest);
+ const handleAssigneesChange = useCallback(
+ (nextUserIds: string[]) => {
+ const taskKey = taskKeys.detail(projectId, taskId);
+ const graphKey = projectKeys.graph(projectId);
+
+ // Track latest user intent — re-applied after each mutation lands so
+ // an SSE-driven refetch that completed mid-chain can't reveal an
+ // intermediate server state.
+ latestAssigneeIntentRef.current = nextUserIds;
+
+ // Cancel any in-flight refetch — `RealtimeBridge` may have invalidated
+ // the query from a previous mutation's SSE event, and the resulting
+ // refetch is currently in flight. Without cancellation, that refetch
+ // can complete after our `setQueryData` below and overwrite it.
+ void queryClient.cancelQueries({ queryKey: taskKey });
+ void queryClient.cancelQueries({ queryKey: graphKey });
+
+ applyAssigneesOptimistically(nextUserIds);
+
+ pendingAssigneeWritesRef.current += 1;
+ const myTurn = assigneeMutationChainRef.current.then(async () => {
+ try {
+ // Skip when newer clicks have queued after this step —
+ // `overwriteArrays=true` makes the later write a complete superset
+ // so this intermediate call is redundant, and dropping it reduces
+ // the SSE storm that drives race #2 above.
+ if (pendingAssigneeWritesRef.current > 1) return;
+ await updateTask(taskId, { assigneeIds: nextUserIds }, true);
+ } finally {
+ pendingAssigneeWritesRef.current -= 1;
+ if (pendingAssigneeWritesRef.current === 0) {
+ // Final mutation drained — let the broker-triggered refetch sync
+ // the server's settled view back in.
+ latestAssigneeIntentRef.current = null;
+ onGraphChange?.();
+ } else {
+ // More clicks queued: an SSE event from this step's response is
+ // about to (or already has) triggered an invalidating refetch
+ // that would land an intermediate snapshot on the cache. Cancel
+ // it and re-apply the latest intent so the trigger stays at the
+ // user's latest selection.
+ void queryClient.cancelQueries({ queryKey: taskKey });
+ void queryClient.cancelQueries({ queryKey: graphKey });
+ const latest = latestAssigneeIntentRef.current;
+ if (latest !== null) applyAssigneesOptimistically(latest);
+ }
}
- }
- });
- assigneeMutationChainRef.current = myTurn.catch(() => {});
- return myTurn;
- }, [projectId, taskId, queryClient, onGraphChange, applyAssigneesOptimistically]);
+ });
+ assigneeMutationChainRef.current = myTurn.catch(() => {});
+ return myTurn;
+ },
+ [
+ projectId,
+ taskId,
+ queryClient,
+ onGraphChange,
+ applyAssigneesOptimistically,
+ ],
+ );
return (