From 2de03bc02fefeeb40ea9becaf3b501cc513b1289 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Tue, 5 May 2026 16:30:21 +0100
Subject: fix(doc-mode): make task rows clickable and render live transcript
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Task rows in the directive sidebar's `tasks/` subfolder were rendered as
inert `<div>` elements with no click handler, and EditorShell had no
branch for `selection.taskId` — so clicking a task did nothing visible.

- StepRow and TaskRow are now `<button>` elements that call
  `onSelect(directiveId, taskId)` and navigate to
  `/directives/<dirId>?task=<taskId>`.
- EditorShell renders DocumentTaskStream with a breadcrumb when
  `selection.taskId` is set (winning over the document path).
- Step rows whose backing task hasn't been spawned yet stay disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 makima/frontend/src/routes/document-directives.tsx | 160 +++++++++++++++++++--
 1 file changed, 149 insertions(+), 11 deletions(-)

(limited to 'makima/frontend/src/routes/document-directives.tsx')

diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index c5cf151..1714aed 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -28,6 +28,7 @@ import {
   pickUpOrders,
 } from "../lib/api";
 import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu";
+import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
 
 // Status dot color, matching the existing tabular UI's badge palette so the
 // document mode feels like a sibling of the existing list, not a foreign UI.
@@ -177,6 +178,8 @@ interface DirectiveFolderProps {
   onCreateEphemeralTask: (directive: DirectiveSummary) => void;
   /** Right-click handler — opens DirectiveContextMenu with start/pause/PR/etc. */
   onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void;
+  /** Click handler for task/step rows — navigates to the live transcript. */
+  onSelectTask: (directiveId: string, taskId: string) => void;
   /**
    * Document refresh trigger — bumped externally so the folder refetches its
    * document list after a create/update happens elsewhere. Primarily used so
@@ -195,8 +198,13 @@ function DirectiveFolder({
   onCreateDocument,
   onCreateEphemeralTask,
   onContextMenu,
+  onSelectTask,
   refreshNonce,
 }: DirectiveFolderProps) {
+  const selectedTaskIdForFolder =
+    selection && selection.directiveId === directive.id
+      ? selection.taskId
+      : null;
   const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft;
   const orchestratorRunning = !!directive.orchestratorTaskId;
 
@@ -370,9 +378,12 @@ function DirectiveFolder({
                   />
                   <DocumentTasksFolder
                     documentId={doc.id}
+                    directiveId={directive.id}
                     depth="normal"
                     defaultOpen={doc.status === "active"}
                     refreshNonce={refreshNonce}
+                    selectedTaskId={selectedTaskIdForFolder}
+                    onSelectTask={onSelectTask}
                   />
                 </div>
               ))}
@@ -411,9 +422,12 @@ function DirectiveFolder({
                         />
                         <DocumentTasksFolder
                           documentId={doc.id}
+                          directiveId={directive.id}
                           depth="deep"
                           defaultOpen={false}
                           refreshNonce={refreshNonce}
+                          selectedTaskId={selectedTaskIdForFolder}
+                          onSelectTask={onSelectTask}
                         />
                       </div>
                     ))}
@@ -504,6 +518,9 @@ function DocumentRow({
 
 interface DocumentTasksFolderProps {
   documentId: string;
+  /** Parent directive id — needed so a clicked task row can navigate to
+   *  /directives/<directiveId>?task=<taskId>. */
+  directiveId: string;
   /** Visual indent depth — mirrors the parent DocumentRow's indent so the
    *  tasks/ row sits one level deeper than its parent doc. */
   depth: "normal" | "deep";
@@ -514,13 +531,20 @@ interface DocumentTasksFolderProps {
   /** Bumped externally so the folder refetches its task list after a save
    *  or status change elsewhere. Same nonce used for the directive folder. */
   refreshNonce: number;
+  /** Currently-selected task id (drives row highlight). */
+  selectedTaskId: string | null;
+  /** Click handler for step/task rows — navigates to the live transcript. */
+  onSelectTask: (directiveId: string, taskId: string) => void;
 }
 
 function DocumentTasksFolder({
   documentId,
+  directiveId,
   depth,
   defaultOpen,
   refreshNonce,
+  selectedTaskId,
+  onSelectTask,
 }: DocumentTasksFolderProps) {
   const [open, setOpen] = useState(defaultOpen);
   const [data, setData] = useState<DocumentTasksResponse | null>(null);
@@ -591,10 +615,24 @@ function DocumentTasksFolder({
             </div>
           )}
           {data?.steps.map((step) => (
-            <StepRow key={`step-${step.id}`} step={step} padLeft={rowPadLeft} />
+            <StepRow
+              key={`step-${step.id}`}
+              step={step}
+              directiveId={directiveId}
+              selected={!!selectedTaskId && step.taskId === selectedTaskId}
+              padLeft={rowPadLeft}
+              onSelect={onSelectTask}
+            />
           ))}
           {data?.tasks.map((task) => (
-            <TaskRow key={`task-${task.id}`} task={task} padLeft={rowPadLeft} />
+            <TaskRow
+              key={`task-${task.id}`}
+              task={task}
+              directiveId={directiveId}
+              selected={task.id === selectedTaskId}
+              padLeft={rowPadLeft}
+              onSelect={onSelectTask}
+            />
           ))}
         </div>
       )}
@@ -628,15 +666,43 @@ const TASK_STATUS_DOT: Record<string, string> = {
 
 interface StepRowProps {
   step: DirectiveStep;
+  directiveId: string;
+  selected: boolean;
   padLeft: string;
+  onSelect: (directiveId: string, taskId: string) => void;
 }
 
-function StepRow({ step, padLeft }: StepRowProps) {
+function StepRow({
+  step,
+  directiveId,
+  selected,
+  padLeft,
+  onSelect,
+}: StepRowProps) {
   const dot = STEP_STATUS_DOT[step.status] ?? "bg-[#556677]";
+  // Steps without an underlying task can't be opened — the executor
+  // hasn't started yet so there's no transcript to show. Render them
+  // disabled so the user can see them in the list but knows they're
+  // inert. Same for steps stuck in pending/skipped.
+  const taskId = step.taskId;
+  const clickable = !!taskId;
   return (
-    <div
-      title={`${step.name} (${step.status})`}
-      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] text-[#9bc3ff]`}
+    <button
+      type="button"
+      disabled={!clickable}
+      onClick={() => clickable && onSelect(directiveId, taskId!)}
+      title={
+        clickable
+          ? `${step.name} (${step.status})`
+          : `${step.name} — no task spawned yet (${step.status})`
+      }
+      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] transition-colors ${
+        selected
+          ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
+          : clickable
+          ? "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
+          : "text-[#556677] border-l-2 border-transparent cursor-not-allowed"
+      }`}
     >
       <FileIcon />
       <span
@@ -648,24 +714,39 @@ function StepRow({ step, padLeft }: StepRowProps) {
       <span className="text-[9px] uppercase tracking-wide text-[#556677]">
         step
       </span>
-    </div>
+    </button>
   );
 }
 
 interface TaskRowProps {
   task: Task;
+  directiveId: string;
+  selected: boolean;
   padLeft: string;
+  onSelect: (directiveId: string, taskId: string) => void;
 }
 
-function TaskRow({ task, padLeft }: TaskRowProps) {
+function TaskRow({
+  task,
+  directiveId,
+  selected,
+  padLeft,
+  onSelect,
+}: TaskRowProps) {
   const dot = TASK_STATUS_DOT[task.status] ?? "bg-[#556677]";
   // Supervisor tasks get a small "sup" tag so the user can spot
   // contract orchestrators in the list.
   const isSup = task.isSupervisor;
   return (
-    <div
+    <button
+      type="button"
+      onClick={() => onSelect(directiveId, task.id)}
       title={`${task.name} (${task.status})`}
-      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] text-[#9bc3ff]`}
+      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] transition-colors ${
+        selected
+          ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
+          : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
+      }`}
     >
       <FileIcon />
       <span
@@ -677,7 +758,7 @@ function TaskRow({ task, padLeft }: TaskRowProps) {
       <span className="text-[9px] uppercase tracking-wide text-[#556677]">
         {isSup ? "sup" : "task"}
       </span>
-    </div>
+    </button>
   );
 }
 
@@ -695,6 +776,7 @@ interface SidebarProps {
   onCreateContract: () => void;
   onCreateEphemeralTask: (directive: DirectiveSummary) => void;
   onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void;
+  onSelectTask: (directiveId: string, taskId: string) => void;
   refreshNonce: number;
 }
 
@@ -708,6 +790,7 @@ function DocumentSidebar({
   onCreateContract,
   onCreateEphemeralTask,
   onContextMenu,
+  onSelectTask,
   refreshNonce,
 }: SidebarProps) {
   // Flat sort: active first, then idle, paused, draft, inactive, archived.
@@ -797,6 +880,7 @@ function DocumentSidebar({
                 onCreateDocument={onCreateDocument}
                 onCreateEphemeralTask={onCreateEphemeralTask}
                 onContextMenu={onContextMenu}
+                onSelectTask={onSelectTask}
                 refreshNonce={refreshNonce}
               />
             ))}
@@ -927,6 +1011,50 @@ function EditorShell({
     );
   }
 
+  // --- Task path: task row clicked in the sidebar ------------------------
+  // Renders the live transcript via DocumentTaskStream. Selection wins over
+  // the document path when both are somehow present (defensive).
+  if (selection?.taskId) {
+    const taskId = selection.taskId;
+    // Resolve a human label for the task: orchestrator/completion are
+    // labelled by role; step tasks borrow the step name; everything else
+    // is an ephemeral and just shows the task id slice. Look-up uses the
+    // already-fetched directive (with steps).
+    const stepWithTask = directive.steps.find((s) => s.taskId === taskId);
+    const label =
+      taskId === directive.orchestratorTaskId
+        ? "orchestrator"
+        : taskId === directive.completionTaskId
+        ? "completion"
+        : stepWithTask?.name ?? taskId.slice(0, 8);
+    const isStepBound =
+      taskId === directive.orchestratorTaskId ||
+      taskId === directive.completionTaskId ||
+      !!stepWithTask;
+    return (
+      <div className="flex-1 flex flex-col h-full overflow-hidden">
+        <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
+          <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
+            <FileIcon />
+            <span>directives /</span>
+            <span className="text-[#9bc3ff]">
+              {directive.title.trim().length > 0
+                ? directive.title
+                : directive.id.slice(0, 8)}
+            </span>
+            <span>/</span>
+            <span className="text-white">{label}</span>
+          </div>
+        </div>
+        <DocumentTaskStream
+          taskId={taskId}
+          label={label}
+          ephemeral={!isStepBound}
+        />
+      </div>
+    );
+  }
+
   // --- Document path: documentId selected --------------------------------
   if (documentId) {
     if (docLoading && !doc) {
@@ -1161,6 +1289,15 @@ export default function DocumentDirectivesPage() {
     [bumpRefresh, navigate],
   );
 
+  // Click on a task or step row → open the live transcript pane via
+  // ?task=<id>. EditorShell switches to DocumentTaskStream when this is set.
+  const handleSelectTask = useCallback(
+    (directiveId: string, taskId: string) => {
+      navigate(`/directives/${directiveId}?task=${taskId}`);
+    },
+    [navigate],
+  );
+
   // Modal state for the two new creation surfaces in the sidebar:
   //   * + New contract  → opens NewContractModal, calls useDirectives.create
   //   * + New ephemeral task (per directive) → opens NewEphemeralTaskModal
@@ -1265,6 +1402,7 @@ export default function DocumentDirectivesPage() {
             onCreateContract={() => setShowNewContract(true)}
             onCreateEphemeralTask={(d) => setNewEphemeralFor(d)}
             onContextMenu={handleContextMenu}
+            onSelectTask={handleSelectTask}
             refreshNonce={refreshNonce}
           />
         </div>
-- 
cgit v1.2.3

