2d73a3d1db
* fix: keep completed office comments editable Keep the office task detail composer editable after completed sessions load, including projectless office tasks. Update verification tests for SHA-pinned GitHub actions. Extract the dockview main area to satisfy the existing max-lines lint rule. * fix: preserve upload artifact version guard * fix: clarify upload artifact pin check * fix: wake completed office sessions on comments * fix: queue completed office comment runs * fix: preserve dispatcher session lookup errors * fix: resolve office comment wake review issues * fix: stabilize comment wake idempotency * fix: salt custom comment wake keys * fix: address comment wake review cleanup * fix: avoid comment payload allocation overflow pattern * fix: salt comment wake action variants * fix: resolve cross-task primary by target step * fix: resolve cross-task participant roles * fix: surface cross-task comment run status * fix: use target steps for cross-task queue runs * fix: avoid comment run lookup scan regression * fix: cover cross-task queue run edge cases * fix: index comment payload run lookup * fix: support postgres comment run index * fix: tighten queue run target test coverage * fix: route source comment run updates * fix: preserve cross-task comment wakes * fix: tighten comment wake identity * fix: align comment wake routing * fix: remove stale primary resolver * fix: dedupe comment run status * fix: tighten legacy comment wake dedupe * fix: remove unreachable comment wake guard * fix: skip duplicate comment reactivity wakes * fix: route comment wakes synchronously * fix: share comment engine dispatch contracts * fix: preserve comment wake fallbacks * fix: preserve done comment runner * fix: guard empty task runner fallback * fix: handle empty task runner fallback * fix: stabilize completed comment wakes * fix: index comment runner fallback --------- Co-authored-by: root <root@kandev.ts.net>
556 lines
16 KiB
TypeScript
556 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback, useRef } from "react";
|
|
import Link from "@/components/routing/app-link";
|
|
import {
|
|
IconCopy,
|
|
IconPlayerPause,
|
|
IconPlayerPlay,
|
|
IconPlus,
|
|
IconRestore,
|
|
IconTrash,
|
|
IconPaperclip,
|
|
} from "@tabler/icons-react";
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Label } from "@kandev/ui/label";
|
|
import { Switch } from "@kandev/ui/switch";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import {
|
|
Breadcrumb,
|
|
BreadcrumbList,
|
|
BreadcrumbItem,
|
|
BreadcrumbLink,
|
|
BreadcrumbPage,
|
|
BreadcrumbSeparator,
|
|
} from "@kandev/ui/breadcrumb";
|
|
import { TaskProperties } from "./task-properties";
|
|
import { ChatActivityTabs } from "./chat-activity-tabs";
|
|
import { ExecutionIndicator } from "@/app/office/components/execution-indicator";
|
|
import { OfficeTopbarPortal } from "@/app/office/components/office-topbar-portal";
|
|
import { TaskDocuments } from "./task-documents";
|
|
import { TaskDetailContextPanel } from "../task-detail-context-panel";
|
|
import { useTaskContext } from "@/hooks/use-task-context";
|
|
import { StatusIcon } from "@/app/office/tasks/[id]/status-icon";
|
|
import { StageProgressBar } from "./stage-progress-bar";
|
|
import { SubtaskStepper } from "./subtask-stepper";
|
|
import { hasBlockerChain } from "./workflow-sort";
|
|
import { NewTaskDialog } from "@/app/office/components/new-task-dialog";
|
|
import { ActiveSessionRefProvider } from "./components/active-session-ref-context";
|
|
import { TopbarWorkingIndicator } from "./components/topbar-working-indicator";
|
|
import { TreeCancelDialog } from "@/components/task/TreeCancelDialog";
|
|
import {
|
|
cancelTaskTree,
|
|
pauseTaskTree,
|
|
previewTaskTree,
|
|
restoreTaskTree,
|
|
resumeTaskTree,
|
|
type TreeHold,
|
|
type TreePreview,
|
|
} from "@/lib/api/domains/tree-api";
|
|
import type {
|
|
Task,
|
|
TaskComment,
|
|
TaskActivityEntry,
|
|
TaskSession,
|
|
TimelineEvent,
|
|
} from "@/app/office/tasks/[id]/types";
|
|
import { toast } from "sonner";
|
|
|
|
const COMMENTABLE_DONE_SESSION_STATES = new Set<TaskSession["state"]>([
|
|
"CREATED",
|
|
"STARTING",
|
|
"RUNNING",
|
|
"IDLE",
|
|
"WAITING_FOR_INPUT",
|
|
"COMPLETED",
|
|
]);
|
|
|
|
type OfficeSimplePaneProps = {
|
|
task: Task;
|
|
comments: TaskComment[];
|
|
timeline?: TimelineEvent[];
|
|
activity: TaskActivityEntry[];
|
|
sessions: TaskSession[];
|
|
onToggleAdvanced?: () => void;
|
|
onCommentsChanged?: () => void;
|
|
};
|
|
|
|
function sessionSortTime(session: TaskSession): number {
|
|
const value = session.updatedAt ?? session.completedAt ?? session.startedAt ?? "";
|
|
const time = Date.parse(value);
|
|
return Number.isNaN(time) ? 0 : time;
|
|
}
|
|
|
|
function latestSession(sessions: TaskSession[]): TaskSession | undefined {
|
|
return sessions.reduce<TaskSession | undefined>((latest, session) => {
|
|
if (!latest) return session;
|
|
return sessionSortTime(session) >= sessionSortTime(latest) ? session : latest;
|
|
}, undefined);
|
|
}
|
|
|
|
function commentsReadOnly(task: Task, sessions: TaskSession[]): boolean {
|
|
if (task.status === "cancelled") return true;
|
|
if (task.status !== "done") return false;
|
|
const session = latestSession(sessions);
|
|
return !session || !COMMENTABLE_DONE_SESSION_STATES.has(session.state);
|
|
}
|
|
|
|
function TaskBreadcrumb({ task }: { task: Task }) {
|
|
return (
|
|
<Breadcrumb>
|
|
<BreadcrumbList className="text-sm">
|
|
<BreadcrumbItem>
|
|
<BreadcrumbLink asChild>
|
|
<Link href="/office/tasks">Tasks</Link>
|
|
</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
{task.parentIdentifier && (
|
|
<>
|
|
<BreadcrumbSeparator />
|
|
<BreadcrumbItem>
|
|
<BreadcrumbLink asChild>
|
|
<Link href={`/office/tasks/${task.parentId}`}>{task.parentTitle}</Link>
|
|
</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
</>
|
|
)}
|
|
<BreadcrumbSeparator />
|
|
<BreadcrumbItem>
|
|
<BreadcrumbPage>{task.title}</BreadcrumbPage>
|
|
</BreadcrumbItem>
|
|
</BreadcrumbList>
|
|
</Breadcrumb>
|
|
);
|
|
}
|
|
|
|
function TaskHeaderRow({ task, activeHold }: { task: Task; activeHold: TreeHold | null }) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<StatusIcon status={task.status} />
|
|
<span className="text-sm font-mono text-muted-foreground">{task.identifier}</span>
|
|
{task.projectName && <Badge variant="outline">{task.projectName}</Badge>}
|
|
{activeHold?.mode === "pause" && <Badge variant="outline">Paused</Badge>}
|
|
{activeHold?.mode === "cancel" && <Badge variant="outline">Cancelled (tree)</Badge>}
|
|
<div className="ml-auto">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 cursor-pointer"
|
|
onClick={() => navigator.clipboard.writeText(task.identifier)}
|
|
>
|
|
<IconCopy className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Copy identifier</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChildIssuesList({
|
|
items,
|
|
activeHold,
|
|
}: {
|
|
items: Task["children"];
|
|
activeHold: TreeHold | null;
|
|
}) {
|
|
if (items.length === 0) return null;
|
|
const holdLabel = activeHold?.mode === "pause" ? "Paused" : "Cancelled (tree)";
|
|
return (
|
|
<div className="mt-8" data-testid="child-issues-list">
|
|
<h2 className="text-sm font-semibold mb-4">Sub-tasks</h2>
|
|
<div className="border border-border rounded-lg divide-y divide-border">
|
|
{items.map((child) => (
|
|
<Link
|
|
key={child.id}
|
|
href={`/office/tasks/${child.id}`}
|
|
className="flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-accent/50 transition-colors"
|
|
>
|
|
<StatusIcon status={child.status} className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="text-xs text-muted-foreground font-mono shrink-0">
|
|
{child.identifier}
|
|
</span>
|
|
<span className="flex-1 truncate">{child.title}</span>
|
|
{activeHold && <Badge variant="outline">{holdLabel}</Badge>}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type TreeActionRunner = (action: () => Promise<unknown>, message: string) => Promise<void>;
|
|
|
|
function PauseResumeButton({
|
|
taskId,
|
|
activeHold,
|
|
hasTree,
|
|
busy,
|
|
runAction,
|
|
}: {
|
|
taskId: string;
|
|
activeHold: TreeHold | null;
|
|
hasTree: boolean;
|
|
busy: boolean;
|
|
runAction: TreeActionRunner;
|
|
}) {
|
|
if (activeHold?.mode === "pause") {
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="cursor-pointer"
|
|
disabled={busy}
|
|
onClick={() => runAction(() => resumeTaskTree(taskId), "Task tree resumed")}
|
|
>
|
|
<IconPlayerPlay className="h-3.5 w-3.5 mr-1" /> Resume tree
|
|
</Button>
|
|
);
|
|
}
|
|
if (!hasTree) return null;
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="cursor-pointer"
|
|
disabled={busy || activeHold?.mode === "cancel"}
|
|
onClick={() => runAction(() => pauseTaskTree(taskId), "Task tree paused")}
|
|
>
|
|
<IconPlayerPause className="h-3.5 w-3.5 mr-1" /> Pause tree
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function CancelRestoreButton({
|
|
taskId,
|
|
activeHold,
|
|
hasTree,
|
|
busy,
|
|
onCancel,
|
|
runAction,
|
|
}: {
|
|
taskId: string;
|
|
activeHold: TreeHold | null;
|
|
hasTree: boolean;
|
|
busy: boolean;
|
|
onCancel: () => void;
|
|
runAction: TreeActionRunner;
|
|
}) {
|
|
if (activeHold?.mode === "cancel") {
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="cursor-pointer"
|
|
disabled={busy}
|
|
onClick={() => runAction(() => restoreTaskTree(taskId), "Task tree restored")}
|
|
>
|
|
<IconRestore className="h-3.5 w-3.5 mr-1" /> Restore tree
|
|
</Button>
|
|
);
|
|
}
|
|
if (!hasTree) return null;
|
|
return (
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
className="cursor-pointer"
|
|
disabled={busy}
|
|
onClick={onCancel}
|
|
>
|
|
<IconTrash className="h-3.5 w-3.5 mr-1" /> Cancel tree
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function TreeControls({
|
|
task,
|
|
preview,
|
|
activeHold,
|
|
onChanged,
|
|
}: {
|
|
task: Task;
|
|
preview: TreePreview | null;
|
|
activeHold: TreeHold | null;
|
|
onChanged: () => Promise<void>;
|
|
}) {
|
|
const [cancelOpen, setCancelOpen] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const hasTree = (preview?.task_count ?? 1) > 1 || task.children.length > 0;
|
|
|
|
const runAction = async (action: () => Promise<unknown>, message: string) => {
|
|
setBusy(true);
|
|
try {
|
|
await action();
|
|
toast.success(message);
|
|
await onChanged();
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Tree action failed");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
if (!hasTree && !activeHold) return null;
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-2 mt-6">
|
|
<PauseResumeButton
|
|
taskId={task.id}
|
|
activeHold={activeHold}
|
|
hasTree={hasTree}
|
|
busy={busy}
|
|
runAction={runAction}
|
|
/>
|
|
<CancelRestoreButton
|
|
taskId={task.id}
|
|
activeHold={activeHold}
|
|
hasTree={hasTree}
|
|
busy={busy}
|
|
onCancel={() => setCancelOpen(true)}
|
|
runAction={runAction}
|
|
/>
|
|
{preview && hasTree && (
|
|
<span className="inline-flex items-center text-xs text-muted-foreground">
|
|
{preview.task_count} tasks affected
|
|
</span>
|
|
)}
|
|
<TreeCancelDialog
|
|
open={cancelOpen}
|
|
onOpenChange={setCancelOpen}
|
|
taskCount={preview?.task_count ?? task.children.length + 1}
|
|
activeRunCount={preview?.active_run_count ?? 0}
|
|
onConfirm={() => {
|
|
setCancelOpen(false);
|
|
void runAction(() => cancelTaskTree(task.id), "Task tree cancelled");
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskActionRow({
|
|
task,
|
|
treePreview,
|
|
activeHold,
|
|
onTreeChanged,
|
|
onNewSubIssue,
|
|
}: {
|
|
task: Task;
|
|
treePreview: TreePreview | null;
|
|
activeHold: TreeHold | null;
|
|
onTreeChanged: () => Promise<void>;
|
|
onNewSubIssue: () => void;
|
|
}) {
|
|
const attachInputRef = useRef<HTMLInputElement>(null);
|
|
const handleAttachFiles = useCallback(
|
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
const { createOrUpdateDocument } = await import("@/lib/api/domains/office-extended-api");
|
|
for (const file of Array.from(files)) {
|
|
try {
|
|
const content = await file.text();
|
|
const key = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
await createOrUpdateDocument(task.id, key, {
|
|
title: file.name,
|
|
content: content.slice(0, 100_000),
|
|
});
|
|
toast.success(`Uploaded ${file.name}`);
|
|
} catch {
|
|
toast.error(`Failed to upload ${file.name}`);
|
|
}
|
|
}
|
|
e.target.value = "";
|
|
},
|
|
[task.id],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="flex gap-2 mt-6">
|
|
<Button variant="outline" size="sm" className="cursor-pointer" onClick={onNewSubIssue}>
|
|
<IconPlus className="h-3.5 w-3.5 mr-1" /> New Sub-Task
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="cursor-pointer"
|
|
onClick={() => attachInputRef.current?.click()}
|
|
>
|
|
<IconPaperclip className="h-3.5 w-3.5 mr-1" /> Attach files
|
|
</Button>
|
|
<input
|
|
ref={attachInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleAttachFiles}
|
|
/>
|
|
</div>
|
|
<TreeControls
|
|
task={task}
|
|
preview={treePreview}
|
|
activeHold={activeHold}
|
|
onChanged={onTreeChanged}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function useTaskTreePreview(taskId: string) {
|
|
const [treePreview, setTreePreview] = useState<TreePreview | null>(null);
|
|
const [activeHold, setActiveHold] = useState<TreeHold | null>(null);
|
|
|
|
const refreshTreePreview = useCallback(async () => {
|
|
try {
|
|
const preview = await previewTaskTree(taskId);
|
|
setTreePreview(preview);
|
|
setActiveHold(preview.active_hold ?? null);
|
|
} catch {
|
|
setTreePreview(null);
|
|
setActiveHold(null);
|
|
}
|
|
}, [taskId]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
previewTaskTree(taskId)
|
|
.then((preview) => {
|
|
if (cancelled) return;
|
|
setTreePreview(preview);
|
|
setActiveHold(preview.active_hold ?? null);
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return;
|
|
setTreePreview(null);
|
|
setActiveHold(null);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [taskId]);
|
|
|
|
return { treePreview, activeHold, refreshTreePreview };
|
|
}
|
|
|
|
export function OfficeSimplePane({
|
|
task,
|
|
comments,
|
|
timeline,
|
|
activity,
|
|
sessions,
|
|
onToggleAdvanced,
|
|
onCommentsChanged,
|
|
}: OfficeSimplePaneProps) {
|
|
const [subIssueOpen, setSubIssueOpen] = useState(false);
|
|
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
|
const { treePreview, activeHold, refreshTreePreview } = useTaskTreePreview(task.id);
|
|
|
|
return (
|
|
<ActiveSessionRefProvider>
|
|
<div className="flex h-full">
|
|
<OfficeTopbarPortal>
|
|
<TaskBreadcrumb task={task} />
|
|
<TopbarWorkingIndicator taskId={task.id} />
|
|
<span className="flex-1" />
|
|
<ExecutionIndicator status={task.status} />
|
|
{onToggleAdvanced && (
|
|
<div className="flex items-center gap-2">
|
|
<Label
|
|
htmlFor="advanced-toggle"
|
|
className="text-xs text-muted-foreground cursor-pointer"
|
|
>
|
|
Advanced
|
|
</Label>
|
|
<Switch
|
|
id="advanced-toggle"
|
|
checked={false}
|
|
onCheckedChange={() => onToggleAdvanced()}
|
|
/>
|
|
</div>
|
|
)}
|
|
<Link
|
|
href={`/t/${task.id}`}
|
|
className="text-xs text-muted-foreground underline-offset-2 hover:underline cursor-pointer whitespace-nowrap"
|
|
data-testid="task-cross-link"
|
|
>
|
|
Open in advanced view
|
|
</Link>
|
|
</OfficeTopbarPortal>
|
|
<div ref={setScrollParent} className="flex-1 min-w-0 overflow-y-auto p-6">
|
|
<TaskHeaderRow task={task} activeHold={activeHold} />
|
|
{task.executionPolicy && (
|
|
<StageProgressBar
|
|
executionPolicy={task.executionPolicy}
|
|
executionState={task.executionState}
|
|
/>
|
|
)}
|
|
<h1 className="text-xl font-semibold mt-4">{task.title}</h1>
|
|
{task.description && (
|
|
<div className="prose prose-sm mt-4 max-w-none text-sm whitespace-pre-wrap">
|
|
{task.description}
|
|
</div>
|
|
)}
|
|
<TaskActionRow
|
|
task={task}
|
|
treePreview={treePreview}
|
|
activeHold={activeHold}
|
|
onTreeChanged={refreshTreePreview}
|
|
onNewSubIssue={() => setSubIssueOpen(true)}
|
|
/>
|
|
<TaskDocuments taskId={task.id} />
|
|
<TaskContextSection taskId={task.id} revisionKey={task.updatedAt} />
|
|
<ChatActivityTabs
|
|
task={task}
|
|
comments={comments}
|
|
timeline={timeline}
|
|
activity={activity}
|
|
sessions={sessions}
|
|
scrollParent={scrollParent}
|
|
readOnly={commentsReadOnly(task, sessions)}
|
|
onCommentsChanged={onCommentsChanged}
|
|
/>
|
|
{hasBlockerChain(task.children) ? (
|
|
<SubtaskStepper items={task.children} activeHold={activeHold} />
|
|
) : (
|
|
<ChildIssuesList items={task.children} activeHold={activeHold} />
|
|
)}
|
|
</div>
|
|
<div className="w-80 border-l border-border shrink-0 overflow-y-auto p-4">
|
|
<TaskProperties task={task} />
|
|
</div>
|
|
<NewTaskDialog
|
|
open={subIssueOpen}
|
|
onOpenChange={setSubIssueOpen}
|
|
parentTaskId={task.id}
|
|
defaultProjectId={task.projectId}
|
|
defaultAssigneeId={task.assigneeAgentProfileId}
|
|
/>
|
|
</div>
|
|
</ActiveSessionRefProvider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Office task-handoffs phase 8.2 — task detail context section.
|
|
*
|
|
* Mounts the TaskDetailContextPanel under TaskDocuments, fetching the
|
|
* context envelope on mount and re-fetching whenever the task ID
|
|
* changes. The panel renders nothing when the backend returns null
|
|
* (no HandoffService configured) so the prior layout is preserved.
|
|
*/
|
|
function TaskContextSection({ taskId, revisionKey }: { taskId: string; revisionKey: string }) {
|
|
const ctx = useTaskContext(taskId, revisionKey);
|
|
if (!ctx) return null;
|
|
return (
|
|
<div className="mt-4">
|
|
<TaskDetailContextPanel context={ctx} />
|
|
</div>
|
|
);
|
|
}
|