taskchef 9.1.0 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -136,3 +136,16 @@ test("interrupted details show the recorded error after the response and clear i
136
136
  rerender(<MantineProvider><NextTaskDetail {...props} task={{ ...task, status: "working" }} /></MantineProvider>);
137
137
  expect(screen.queryByLabelText("Latest turn error")).toBeNull();
138
138
  });
139
+
140
+ test("approval panel shows the live request and opens chat without answering it", () => {
141
+ const open=vi.fn();
142
+ const task={...fixtureTask(), status:'needs_input' as const, needsApproval:true, pendingApprovals:[{id:'16',turnId:'turn',title:'Browser use',message:'Allow upload to Gmail filters?',origin:'https://mail.google.com'}]};
143
+ const {rerender}=render(<MantineProvider><NextTaskDetail task={task} opened busy={false} error={null} onClose={()=>{}} onOpenCodex={open}/></MantineProvider>);
144
+ const panel=screen.getByRole('region',{name:'Pending approvals'});
145
+ expect(within(panel).getByText('Allow upload to Gmail filters?')).toBeVisible();
146
+ expect(within(panel).getByText('Origin: https://mail.google.com')).toBeVisible();
147
+ fireEvent.click(within(panel).getByRole('button',{name:'Open chat to approve'}));
148
+ expect(open).toHaveBeenCalledTimes(1);
149
+ rerender(<MantineProvider><NextTaskDetail task={{...task,needsApproval:false,pendingApprovals:[]}} opened busy={false} error={null} onClose={()=>{}} onOpenCodex={open}/></MantineProvider>);
150
+ expect(screen.queryByRole('region',{name:'Pending approvals'})).not.toBeInTheDocument();
151
+ });
@@ -4,6 +4,7 @@ import { IconArrowUpRight, IconClock, IconFolder, IconHelpCircle, IconHourglass,
4
4
  import { useCallback, useEffect, useState, type ReactNode } from "react";
5
5
  import type { Task, SavedUsage } from "../../ui/types";
6
6
  import { PullRequestInfo, nextRunLabel } from "../../ui/components/CardStatusIcons";
7
+ import { ApprovalPanel } from "../../ui/components/ApprovalNotice";
7
8
  import { OpenChatButton } from "../../ui/components/OpenChatButton";
8
9
  import { QueuedPrompts } from "../../ui/components/QueuedPrompts";
9
10
  import { TurnContent } from "../../ui/components/TurnContent";
@@ -48,14 +49,14 @@ export function NextTaskDetail({ task, opened, busy, usageLoading = false, error
48
49
  const count = task.observed?.directChildCount;
49
50
  const source = task.observed?.source === "vscode" ? "Desktop" : task.observed?.source === "cli" ? "CLI" : task.observed?.source === "exec" ? "Exec" : null;
50
51
  const input = task.inputSource === "ordinary" ? "Human" : task.inputSource === "scheduled" ? "Scheduled" : task.inputSource === "unverified" ? "Unknown" : null;
51
- const label = task.status === "needs_input" || task.status === "interrupted" ? "Waiting for review" : task.status === "working" ? "Running" : task.status === "completed" ? "Done" : task.statusLabel;
52
+ const label = task.needsApproval ? "Needs approval" : task.status === "needs_input" || task.status === "interrupted" ? "Waiting for review" : task.status === "working" ? "Running" : task.status === "completed" ? "Done" : task.statusLabel;
52
53
  const tokenIncomplete = !!task.sessionUsage && [task.sessionUsage.latest, task.sessionUsage.total].some(value => value.partial);
53
54
  const costIncomplete = !!task.sessionUsage && [task.sessionUsage.latest, task.sessionUsage.total].some(value => value.costPartial);
54
55
  const content = <Stack gap="md">
55
56
  <Group justify="space-between" align="flex-start" wrap="nowrap" gap="xs" pr={8}>
56
57
  <Group gap="xs" mt={0} className="taskchef-detail-meta">
57
58
  <Tooltip label={task.project?.path || "No project"}><Text size="sm" c="dimmed" className="taskchef-detail-project"><IconFolder size={14} /><span>{task.project?.name || "No project"}</span></Text></Tooltip>
58
- {label && <Badge variant="light" color="gray">{label}</Badge>}
59
+ {label && <Badge variant="light" color={task.needsApproval ? "orange" : "gray"}>{label}</Badge>}
59
60
  {task.needsAnswer && <Badge variant="light" color="blue">Needs answer</Badge>}
60
61
  {task.status === "interrupted" && <Badge variant="light" color="orange">Interrupted</Badge>}
61
62
  </Group>
@@ -65,8 +66,10 @@ export function NextTaskDetail({ task, opened, busy, usageLoading = false, error
65
66
  </Group>
66
67
  </Group>
67
68
  {error && <Alert color="red">{error}</Alert>}
69
+ {mobile && task.needsApproval && <ApprovalPanel task={task} busy={busy} onOpen={onOpenCodex} />}
68
70
  <Box className="taskchef-detail-layout">
69
71
  <Box className="taskchef-detail-reply">
72
+ {!mobile && task.needsApproval && <ApprovalPanel task={task} busy={busy} onOpen={onOpenCodex} />}
70
73
  <TurnContent task={task} loadImage={loadImage} workedDurationMs={duration} onLightboxChange={setLightboxOpen} onAnnotationChange={annotationChange} />
71
74
  {task.needsAnswer && <section aria-label="Pending questions" className="taskchef-pending-questions">
72
75
  <div className="taskchef-question-header"><IconHelpCircle size={14} aria-hidden /><span>{task.pendingQuestions?.length === 1 ? "Question" : "Questions"}</span></div>
package/src/mcp-app.js CHANGED
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { parse as parseToml } from "smol-toml";
8
8
  import { z } from "zod";
9
9
  import { NextGitHub } from "./next-github.js";
10
+ import { CodexLiveState } from "./codex-live-state.js";
10
11
  import { NextNotifications } from "./next-notifications.js";
11
12
  import { CodexSessionScanner } from "./codex-session-scanner.js";
12
13
  import { isCodexThreadDeepLinkId, openThreadInCodex, openPluginSettingsInCodex } from "./codex-app.js";
@@ -55,6 +56,7 @@ export async function pluginSettingsUrl({ pluginRoot = fileURLToPath(new URL("..
55
56
 
56
57
  export function registerTaskChefApp(server, {
57
58
  createScanner = () => new CodexSessionScanner(),
59
+ createLiveState = () => new CodexLiveState(),
58
60
  openThread = openThreadInCodex,
59
61
  getSettingsUrl = pluginSettingsUrl,
60
62
  openSettings = openPluginSettingsInCodex,
@@ -63,15 +65,23 @@ export function registerTaskChefApp(server, {
63
65
  createGitHub = (options) => new NextGitHub(options),
64
66
  } = {}) {
65
67
  const scanner = createScanner();
68
+ const liveState = createLiveState();
66
69
  const notificationStore = new NextNotifications(notificationsPath);
67
70
  const github = createGitHub({ stateDir: dirname(settingsPath) });
68
71
  let boardRevision = 0;
69
72
  let boardSignature;
70
73
  let githubAuth;
74
+ let ipcErrorReported = false;
71
75
  const turnUsageCache = new Map();
72
76
  const usagePending = new Map();
73
77
  async function boardSnapshot(settings, force = false, scope = { date: "all", taskIds: [] }) {
74
- const snapshot = await scanner.refresh({ force });
78
+ const databaseSnapshot = await scanner.refresh({ force });
79
+ await liveState.refresh(databaseSnapshot.healthy === false ? [] : databaseSnapshot.tasks);
80
+ if (liveState.error && !ipcErrorReported) {
81
+ ipcErrorReported = true;
82
+ await notificationStore.errorOnce(`codex-ipc:${TASKCHEF_VERSION}`, "Live approval updates unavailable", `${liveState.error}. TaskChef will continue showing saved chat data. Restart Codex or update TaskChef to restore live approval updates.`).catch(() => { /* Optional IPC reporting must not hide saved tasks. */ });
83
+ }
84
+ const snapshot = { ...databaseSnapshot, tasks: databaseSnapshot.tasks.map(task => liveState.decorate(task)) };
75
85
  const visibleIds = new Set(scope.taskIds ?? []);
76
86
  const displayTasks = snapshot.tasks.map(task => {
77
87
  if (!visibleIds.has(task.id) || task.pullRequests?.length || !scanner.earlierPullRequest) return task;
@@ -98,12 +108,13 @@ export function registerTaskChefApp(server, {
98
108
  const present = new Set(tasks.map(task => task.id));
99
109
  for (const id of turnUsageCache.keys()) if (!present.has(id)) turnUsageCache.delete(id);
100
110
  result.snapshot = { ...result.snapshot, tasks };
101
- const signature = JSON.stringify([result.snapshot.revision, result.snapshot.tasks.map((task) => [task.id, task.status, task.pullRequests, task.earlierPullRequest, task.turnUsage])]);
111
+ const signature = JSON.stringify([result.snapshot.revision, result.snapshot.tasks.map((task) => [task.id, task.status, task.needsApproval, task.pendingApprovals, task.pullRequests, task.earlierPullRequest, task.turnUsage])]);
102
112
  if (signature !== boardSignature) { boardRevision += 1; boardSignature = signature; }
103
113
  return { ...result.snapshot, revision: boardRevision };
104
114
  }
105
115
  async function decoratedTask(task, force = false, includeHistory = false) {
106
116
  if (!task) return task;
117
+ task = liveState.decorate(task);
107
118
  const settings = await readSettings();
108
119
  if (!includeHistory) {
109
120
  const { snapshot } = await github.enrich({ healthy: true, tasks: [task] }, settings, { force });
@@ -158,7 +169,7 @@ export function registerTaskChefApp(server, {
158
169
  } finally { await release(); }
159
170
  });
160
171
  const originalClose = server.close.bind(server);
161
- server.close = async () => { scanner.close(); await originalClose(); };
172
+ server.close = async () => { scanner.close(); liveState.close(); await originalClose(); };
162
173
  const appOnly = { ui: { resourceUri: TASKCHEF_APP_URI, visibility: ["app"] } };
163
174
  server.registerResource("TaskChef task board", TASKCHEF_APP_URI, {
164
175
  description: "TaskChef read-only Codex session board", mimeType: RESOURCE_MIME_TYPE,
@@ -202,6 +213,7 @@ export function registerTaskChefApp(server, {
202
213
  }, async ({ taskId, refreshGithub }) => {
203
214
  const snapshot = await scanner.refresh();
204
215
  if (!snapshot.healthy) throw new Error(snapshot.scan.error);
216
+ await liveState.refresh(snapshot.tasks);
205
217
  const task = await decoratedTask(await scanner.taskDetail(taskId), refreshGithub === true, true);
206
218
  if (!task) throw new Error("Task not found.");
207
219
  return { structuredContent: { task }, content: [] };
@@ -35,8 +35,8 @@ export class NextNotifications {
35
35
  return publicState(state);
36
36
  } finally { await release(); }
37
37
  }
38
- add(state, { taskId = null, title, detail = "", kind, read = false }) {
39
- state.items.unshift({ id: randomUUID(), taskId, title, detail, kind, read, timestamp: this.now() });
38
+ add(state, { id = randomUUID(), taskId = null, title, detail = "", kind, read = false }) {
39
+ state.items.unshift({ id, taskId, title, detail, kind, read, timestamp: this.now() });
40
40
  }
41
41
  async reconcile(getSnapshot, settings) {
42
42
  const observedAt = this.clock();
@@ -54,7 +54,7 @@ export class NextNotifications {
54
54
  const current = { turn, status: task.status ?? null, announced: [...announced] };
55
55
  // Archiving changes the board label, but must still consume the saved terminal event.
56
56
  const terminal = task.observed?.lastTurnEvent;
57
- const event = (task.status === "needs_input" || terminal === "completed") && task.inputSource === "ordinary"
57
+ const event = ((task.status === "needs_input" && terminal !== "inProgress") || terminal === "completed") && task.inputSource === "ordinary"
58
58
  ? "ready" : task.status === "interrupted" || terminal === "interrupted" || terminal === "failed" ? "interrupted" : null;
59
59
  const eligible = !task.observed?.archive && !task.manualDone
60
60
  && (settings.showExec || task.observed?.source !== "exec")
@@ -73,6 +73,32 @@ export class NextNotifications {
73
73
  });
74
74
  return { snapshot, notifications };
75
75
  }
76
+ async errorOnce(key, title, detail) {
77
+ // Older release writers strip unknown fields from notifications.json.
78
+ const path = `${this.path}.errors.json`;
79
+ const readWatermark = async () => {
80
+ try { return z.array(z.string()).parse(JSON.parse(await readFile(path, "utf8"))); }
81
+ catch { return []; } // A damaged optional watermark must not prevent an alert.
82
+ };
83
+ let needsWatermark = false;
84
+ const result = await this.update(async state => {
85
+ if ((await readWatermark()).includes(key)) return;
86
+ const id = `system-error:${key}`;
87
+ // If the previous process stopped after saving the alert, finish its
88
+ // watermark without adding a duplicate notification.
89
+ if (!state.items.some(item => item.id === id)) this.add(state, { id, title, detail, kind: "error" });
90
+ needsWatermark = true;
91
+ });
92
+ // Commit the alert first. A failed write can never suppress an unseen alert.
93
+ if (needsWatermark) {
94
+ const release = await acquireFileLock(dirname(this.path));
95
+ try {
96
+ const announced = await readWatermark();
97
+ if (!announced.includes(key)) await writeDurableAtomic(path, JSON.stringify([...announced, key]));
98
+ } finally { await release(); }
99
+ }
100
+ return result;
101
+ }
76
102
  async confirmation(task) {
77
103
  return this.update((state) => this.add(state, { taskId: task.id, title: `${task.title} marked Done`, kind: "confirmation", read: true }));
78
104
  }
@@ -0,0 +1,24 @@
1
+ import { IconArrowUpRight, IconShieldCheck } from "@tabler/icons-react";
2
+ import type { Task } from "../types";
3
+
4
+ export function ApprovalCover({ task, onOpen }: { task: Task; onOpen: () => void }) {
5
+ const request = task.pendingApprovals?.[0];
6
+ return <button type="button" className="taskchef-approval-cover" aria-label={`Open chat to approve for ${task.title}`} onClick={onOpen}>
7
+ <span className="taskchef-approval-heading"><IconShieldCheck size={17} aria-hidden />Needs approval</span>
8
+ <span className="taskchef-approval-preview">{request?.message ?? "Codex needs permission to continue."}</span>
9
+ <span className="taskchef-approval-link">Open chat to approve<IconArrowUpRight size={14} aria-hidden /></span>
10
+ </button>;
11
+ }
12
+
13
+ export function ApprovalPanel({ task, busy, onOpen }: { task: Task; busy: boolean; onOpen: () => void }) {
14
+ return <section className="taskchef-approval-panel" aria-label="Pending approvals">
15
+ <div className="taskchef-approval-heading"><IconShieldCheck size={17} aria-hidden />Permission required</div>
16
+ {task.pendingApprovals?.length ? task.pendingApprovals.map(request => <div className="taskchef-approval-request" key={request.id}>
17
+ <div className="taskchef-approval-title">{request.title}</div>
18
+ <p>{request.message}</p>
19
+ {request.origin && <div className="taskchef-approval-origin">Origin: {request.origin}</div>}
20
+ </div>) : <p>Codex needs permission to continue.</p>}
21
+ <p className="taskchef-approval-guidance">Open the chat to review this request and allow or deny it.</p>
22
+ <button type="button" disabled={busy} className="taskchef-approval-action" onClick={onOpen}>Open chat to approve<IconArrowUpRight size={14} aria-hidden /></button>
23
+ </section>;
24
+ }
@@ -1,3 +1,4 @@
1
+ import { TaskCard } from "./TaskCard";
1
2
  import { MantineProvider } from "@mantine/core";
2
3
  import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
3
4
  import { afterEach, expect, test, vi } from "vitest";
@@ -386,3 +387,24 @@ test("interrupted cards show the recorded capacity reason even without a reply",
386
387
  rerender(<MantineProvider><TaskBoard {...props} tasks={[{ ...interrupted, status: "working" }]} /></MantineProvider>);
387
388
  expect(screen.queryByLabelText("Latest turn error")).toBeNull();
388
389
  });
390
+
391
+ test("approval cover replaces the image cover and opens the native chat", () => {
392
+ const open=vi.fn(),detail=vi.fn(),load=vi.fn();
393
+ const pending={...task(1,'needs_input'),needsApproval:true,replyImage:{url:'/old.png',alt:'Old cover'},pendingApprovals:[{id:'16',turnId:'turn',title:'Browser use',message:'Allow upload to Gmail filters?',origin:'https://mail.google.com'}]};
394
+ const props={tasks:[pending],lanes:[{status:'needs_input' as const,label:'Waiting'}],completedLimit:5,onMoreCompleted:vi.fn(),onOpenCodex:open,onOpenDetail:detail,loadImage:load};
395
+ const {rerender}=render(<MantineProvider><TaskBoard {...props}/></MantineProvider>);
396
+ fireEvent.click(screen.getByRole('button',{name:`Open chat to approve for ${pending.title}`}));
397
+ expect(open).toHaveBeenCalledWith(pending);
398
+ expect(detail).not.toHaveBeenCalled();
399
+ expect(load).not.toHaveBeenCalled();
400
+ rerender(<MantineProvider><TaskBoard {...props} tasks={[{...pending,needsApproval:false,pendingApprovals:[],replyImage:null}]}/></MantineProvider>);
401
+ expect(screen.queryByText('Needs approval')).not.toBeInTheDocument();
402
+ });
403
+
404
+ test("list cards show the approval cover without stale turn content", () => {
405
+ const pending={...task(1,'needs_input'),needsApproval:true,promptExcerpt:'Old request',replyExcerpt:'Stale reply',observed:{archive:false,lastTurnEvent:'inProgress',lastTurnEventAt:null,recentFileActivity:false}};
406
+ render(<MantineProvider><TaskCard task={pending} onOpenCodex={vi.fn()} onOpenDetail={vi.fn()}/></MantineProvider>);
407
+ expect(screen.getByText('Needs approval')).toBeVisible();
408
+ expect(screen.queryByText('Stale reply')).not.toBeInTheDocument();
409
+ expect(screen.queryByText('Old request')).not.toBeInTheDocument();
410
+ });
@@ -6,6 +6,7 @@ import { hasLinkedCodexThread, latestTurnPresentation } from "../state.js";
6
6
  import type { Task, TaskStatus } from "../types";
7
7
  import { LinkedText } from "./LinkedText";
8
8
  import { TurnContent } from "./TurnContent";
9
+ import { ApprovalCover } from "./ApprovalNotice";
9
10
  import { QueuedPrompts } from "./QueuedPrompts";
10
11
  import { ReplyCover } from "./ReplyCover";
11
12
  import { ElapsedTime, RelativeTime } from "./RelativeTime";
@@ -52,7 +53,7 @@ export function TaskBoard({
52
53
  }) {
53
54
  const draggedTask = useRef<Task | null>(null);
54
55
  const [dragging, setDragging] = useState<Task | null>(null);
55
- const isWaiting = (task: Task) => ["needs_input", "interrupted"].includes(task.status ?? "");
56
+ const isWaiting = (task: Task) => !task.needsApproval && ["needs_input", "interrupted"].includes(task.status ?? "");
56
57
  const canDrop = (task: Task, destination: TaskStatus) => destination === "archived" || (isWaiting(task) && (destination === "completed"
57
58
  ? !task.scheduled && !task.pullRequests?.some(pr => pr.state !== "merged")
58
59
  : destination === "scheduled" && !!task.scheduled && task.inputSource === "ordinary" && !!task.turnId));
@@ -228,7 +229,7 @@ function BoardCard({ showCardUsage, task, onOpenCodex, onOpenDetail, loadImage,
228
229
  const cover = task.replyImage ?? task.promptImage;
229
230
  return (
230
231
  <Paper draggable={draggable} onDragStart={draggable ? onDragStart : undefined} onDragEnd={onDragEnd} data-chat-id={task.id} className="taskchef-board-card" component="article" px="sm" pt="sm" pb={6} withBorder>
231
- {task.observed && cover && <ReplyCover key={`${task.turnId}:${cover.url}`} task={task} image={cover} loadImage={loadImage} onOpen={() => onOpenDetail(task)} />}
232
+ {task.needsApproval ? <ApprovalCover task={task} onOpen={() => onOpenCodex(task)} /> : task.observed && cover && <ReplyCover key={`${task.turnId}:${cover.url}`} task={task} image={cover} loadImage={loadImage} onOpen={() => onOpenDetail(task)} />}
232
233
  <Title className="taskchef-board-title" order={3} size="h5">
233
234
  <button className="taskchef-title-button" onClick={() => onOpenDetail(task)} type="button">{task.title}</button>
234
235
  {task.observed?.unread && <span className="taskchef-chat-unread-dot" role="img" aria-label="Unread chat" />}
@@ -236,7 +237,7 @@ function BoardCard({ showCardUsage, task, onOpenCodex, onOpenDetail, loadImage,
236
237
  <Text className="taskchef-board-project" size="xs"><IconFolder size={14} stroke={1.5} aria-hidden /><span>{task.project.name}</span></Text>
237
238
  {task.needsAnswer && <Tooltip label="Open chat to answer." ><Badge color="blue" size="xs" variant="light">Needs answer</Badge></Tooltip>}
238
239
  {task.status === "interrupted" && <Badge color="orange" size="xs" variant="light">Interrupted</Badge>}
239
- {task.observed ? <TurnContent task={task} compact /> : <Text component="div" className="taskchef-board-excerpt taskchef-preserve-lines" size="sm"><LinkedText task={task} text={excerpt} /></Text>}
240
+ {task.needsApproval ? null : task.observed ? <TurnContent task={task} compact /> : <Text component="div" className="taskchef-board-excerpt taskchef-preserve-lines" size="sm"><LinkedText task={task} text={excerpt} /></Text>}
240
241
  <QueuedPrompts task={task} compact loadImage={loadImage} onOpen={() => onOpenDetail(task)} />
241
242
  <Box className="taskchef-board-card-footer">
242
243
  <CardStatusLine turnId={task.turnId} animateUsage={task.status === "working"} turnUsage={showCardUsage ? task.turnUsage : undefined} subagentCount={task.observed?.directChildCount} pullRequests={task.pullRequests} earlierPullRequest={task.earlierPullRequest} scheduled={task.scheduled} nextRunAt={task.nextRunAt} durationMs={task.observed?.latestTurnDurationMs} startedAt={task.observed && task.status === "working" ? task.observed.lastTurnEventAt : null}>
@@ -1,3 +1,4 @@
1
+ import { ApprovalCover } from "./ApprovalNotice";
1
2
  import { Box, Paper, Stack, Text, Title } from "@mantine/core";
2
3
  import { IconArrowUpRight } from "@tabler/icons-react";
3
4
  import { latestTurnPresentation } from "../state.js";
@@ -23,6 +24,7 @@ export function TaskCard({
23
24
  return (
24
25
  <Paper className="taskchef-task-row" component="article" pb={8} pt="md" px="sm" radius="md" withBorder>
25
26
  <Stack className="taskchef-task-main" gap="sm">
27
+ {task.needsApproval && <ApprovalCover task={task} onOpen={() => onOpenCodex(task)} />}
26
28
  <Box>
27
29
  <Box className="taskchef-card-heading">
28
30
  <Title order={3} size="h5">
@@ -38,7 +40,7 @@ export function TaskCard({
38
40
  {task.scheduled && <Text size="xs" c="violet">Active schedule</Text>}
39
41
  </Box>
40
42
 
41
- {task.observed ? <TurnContent compact task={task} /> : <Box className="taskchef-summary-grid">
43
+ {task.needsApproval ? null : task.observed ? <TurnContent compact task={task} /> : <Box className="taskchef-summary-grid">
42
44
  <Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
43
45
  <Text className="taskchef-preserve-lines" lineClamp={3} size="sm">
44
46
  <LinkedText task={task} text={latest.requestSummary} />
package/src/ui/styles.css CHANGED
@@ -342,3 +342,18 @@ body {
342
342
 
343
343
  .taskchef-queue-icon { flex: 0 0 16px; color: var(--mantine-color-dimmed); margin-top: 2px; }
344
344
  .taskchef-queue-content { flex: 1; min-width: 0; }
345
+
346
+ .taskchef-approval-cover { display: flex; flex-direction: column; gap: 10px; width: 100%; padding: 14px; margin-bottom: 10px; border: 1px solid light-dark(#d4a64a, #886731); border-radius: 12px; background: light-dark(#fff6df, #30271a); color: light-dark(#6f4c12, #f1cf8b); text-align: left; cursor: pointer; }
347
+ .taskchef-approval-heading { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; }
348
+ .taskchef-approval-heading svg { flex: none; }
349
+ .taskchef-approval-preview { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font-size: 12px; line-height: 1.5; }
350
+ .taskchef-approval-link { display: flex; align-items: center; gap: 6px; font-size: 12px; }
351
+ .taskchef-approval-panel { padding: 16px; border: 1px solid light-dark(#d4a64a, #886731); border-radius: 16px; background: light-dark(#fff6df, #30271a); color: light-dark(#6f4c12, #f1cf8b); }
352
+ .taskchef-approval-request { margin-top: 16px; }
353
+ .taskchef-approval-title { font-size: 12px; opacity: .85; }
354
+ .taskchef-approval-panel p { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; }
355
+ .taskchef-approval-origin { font-size: 12px; overflow-wrap: anywhere; }
356
+ .taskchef-approval-panel .taskchef-approval-guidance { margin-top: 16px; font-size: 12px; }
357
+ .taskchef-approval-action { display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; padding: 7px 12px; border: 1px solid currentColor; border-radius: 16px; color: inherit; background: transparent; font: inherit; font-size: 12px; cursor: pointer; }
358
+ .taskchef-approval-action:disabled { opacity: .5; cursor: default; }
359
+ .taskchef-approval-cover:focus-visible, .taskchef-approval-action:focus-visible { outline: 2px solid var(--mantine-primary-color-filled); outline-offset: 3px; }
package/src/ui/types.ts CHANGED
@@ -122,6 +122,8 @@ export interface Task {
122
122
  replyText?: string | null;
123
123
  replyExcerpt?: string | null;
124
124
  annotations?: { text: string; annotation: string }[];
125
+ needsApproval?: boolean;
126
+ pendingApprovals?: { id: string; turnId: string; title: string; message: string; origin: string | null }[];
125
127
  needsAnswer?: boolean;
126
128
  pendingQuestions?: { id: string; title: string; options: string[] }[];
127
129
  queuedPrompts?: { id: string; text: string; images: { url: string; alt: string }[] }[];