"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) }
navigator.clipboard.writeText(task.identifier)}
>
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 (
runAction(() => resumeTaskTree(taskId), "Task tree resumed")}
>
Resume tree
);
}
if (!hasTree) return null;
return (
runAction(() => pauseTaskTree(taskId), "Task tree paused")}
>
Pause tree
);
}
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 (
runAction(() => restoreTaskTree(taskId), "Task tree restored")}
>
Restore tree
);
}
if (!hasTree) return null;
return (
Cancel tree
);
}
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 (
<>
New Sub-Task
attachInputRef.current?.click()}
>
Attach files
>
);
}
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 && (
Advanced
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 (
);
}