"use client"; import { useEffect, useState, useCallback, useRef } from "react"; import Link from "next/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 { useAppStore } from "@/components/state-provider"; import { selectLiveSessionForTask } from "@/lib/state/slices/session/selectors"; 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"; type OfficeSimplePaneProps = { task: Task; comments: TaskComment[]; timeline?: TimelineEvent[]; activity: TaskActivityEntry[]; sessions: TaskSession[]; onToggleAdvanced?: () => void; onCommentsChanged?: () => void; }; function TaskBreadcrumb({ task }: { task: Task }) { return ( Tasks {task.parentIdentifier && ( <> {task.parentTitle} )} {task.title} ); } function TaskHeaderRow({ task, activeHold }: { task: Task; activeHold: TreeHold | null }) { return (
{task.identifier} {task.projectName && {task.projectName}} {activeHold?.mode === "pause" && Paused} {activeHold?.mode === "cancel" && Cancelled (tree)}
Copy identifier
); } 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 (

Sub-tasks

{items.map((child) => ( {child.identifier} {child.title} {activeHold && {holdLabel}} ))}
); } type TreeActionRunner = (action: () => Promise, message: string) => Promise; function PauseResumeButton({ taskId, activeHold, hasTree, busy, runAction, }: { taskId: string; activeHold: TreeHold | null; hasTree: boolean; busy: boolean; runAction: TreeActionRunner; }) { if (activeHold?.mode === "pause") { return ( ); } if (!hasTree) return null; return ( ); } 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 ( ); } if (!hasTree) return null; return ( ); } function TreeControls({ task, preview, activeHold, onChanged, }: { task: Task; preview: TreePreview | null; activeHold: TreeHold | null; onChanged: () => Promise; }) { 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, 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 (
setCancelOpen(true)} runAction={runAction} /> {preview && hasTree && ( {preview.task_count} tasks affected )} { setCancelOpen(false); void runAction(() => cancelTaskTree(task.id), "Task tree cancelled"); }} />
); } function TaskActionRow({ task, treePreview, activeHold, onTreeChanged, onNewSubIssue, }: { task: Task; treePreview: TreePreview | null; activeHold: TreeHold | null; onTreeChanged: () => Promise; onNewSubIssue: () => void; }) { const attachInputRef = useRef(null); const handleAttachFiles = useCallback( async (e: React.ChangeEvent) => { 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 ( <>
); } function useTaskTreePreview(taskId: string) { const [treePreview, setTreePreview] = useState(null); const [activeHold, setActiveHold] = useState(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 }; } // useChatReadOnly returns true when the chat input should be hidden. // // Office tasks always accept comments — a user comment is the canonical // way to wake the agent again (the reactivity pipeline fires // task_comment runs). So even after the assignee's session has gone // IDLE / COMPLETED, the input must stay so the user can ask a follow-up. // // Kanban / quick-chat keeps the legacy "finished tasks are read-only" // behaviour: once the last session terminates the input goes away. function useChatReadOnly(task: Task, sessions: TaskSession[]): boolean { const liveSession = useAppStore((s) => selectLiveSessionForTask(s, task.id)); if (isOfficeTask(task)) return false; if (liveSession) return false; if (sessions.length === 0) return false; const lastSession = [...sessions].sort((a, b) => (b.startedAt ?? "").localeCompare(a.startedAt ?? ""), )[0]; if (!lastSession) return false; return ( lastSession.state === "COMPLETED" || lastSession.state === "FAILED" || lastSession.state === "CANCELLED" ); } // isOfficeTask mirrors the backend's IsOfficeTask: any task that has a // project_id, or was created via the agent / routine origin paths. function isOfficeTask(task: Task): boolean { if (task.projectId) return true; return false; } export function OfficeSimplePane({ task, comments, timeline, activity, sessions, onToggleAdvanced, onCommentsChanged, }: OfficeSimplePaneProps) { const [subIssueOpen, setSubIssueOpen] = useState(false); const [scrollParent, setScrollParent] = useState(null); const { treePreview, activeHold, refreshTreePreview } = useTaskTreePreview(task.id); const isReadOnly = useChatReadOnly(task, sessions); return (
{onToggleAdvanced && (
onToggleAdvanced()} />
)} Open in advanced view
{task.executionPolicy && ( )}

{task.title}

{task.description && (
{task.description}
)} setSubIssueOpen(true)} /> {hasBlockerChain(task.children) ? ( ) : ( )}
); } /** * 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 (
); }