summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 14:49:49 +0900
committerAdam Malczewski <[email protected]>2026-06-02 14:49:49 +0900
commitecb001ec7a2e573d8dedf5064e860e5a3e7788fd (patch)
tree531371ba6f449a144b5bb45a3d226b025983bd4b /packages/frontend/src
parent7c527b4d8a72159954405e720d5bf776802dc0ff (diff)
downloaddispatch-ecb001ec7a2e573d8dedf5064e860e5a3e7788fd.tar.gz
dispatch-ecb001ec7a2e573d8dedf5064e860e5a3e7788fd.zip
feat(todo): port opencode's declarative whole-list todo tool
Replace the imperative id-based CRUD todo tool (add/update/list/get/remove) with opencode's declarative whole-list design: a single `todos` param that replaces the entire list each call. No model-visible ids, no delta reasoning, no "task not found" spirals. - core: TaskItem { id, content, status }; statuses pending|in_progress| completed|cancelled. TaskList.setTasks/getTasks/onChange. New rich TODO_DESCRIPTION adapted from opencode's todowrite.txt. - api: TASK_MANAGEMENT_GUIDANCE system-prompt section (from anthropic.txt); updated TOOL_DESCRIPTIONS.todo. Reload fix: TabStatusSnapshot now carries per-tab tasks so getAllStatuses rehydrates the panel on reconnect. - frontend: mirror types; hydrate tasks from snapshot in both restore paths; upgrade sidebar Tasks panel to render content + all four statuses + progress. - tests: new core task-list.test.ts (15); updated api TaskList mocks + getAllStatuses task-snapshot coverage. bun run check clean; 569 tests pass; all packages typecheck.
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/lib/components/TaskListPanel.svelte68
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts10
-rw-r--r--packages/frontend/src/lib/types.ts8
3 files changed, 51 insertions, 35 deletions
diff --git a/packages/frontend/src/lib/components/TaskListPanel.svelte b/packages/frontend/src/lib/components/TaskListPanel.svelte
index 17ade55..1f84bb8 100644
--- a/packages/frontend/src/lib/components/TaskListPanel.svelte
+++ b/packages/frontend/src/lib/components/TaskListPanel.svelte
@@ -1,34 +1,55 @@
<script lang="ts">
-interface TaskItem {
- id: string;
- title: string;
- description: string;
- status: "pending" | "in_progress" | "done";
-}
+import type { TaskItem } from "../types.js";
const { tasks }: { tasks: TaskItem[] } = $props();
-const doneCount = $derived(tasks.filter((t) => t.status === "done").length);
+type Status = TaskItem["status"];
+
+const completedCount = $derived(tasks.filter((t) => t.status === "completed").length);
const inProgressCount = $derived(tasks.filter((t) => t.status === "in_progress").length);
+const cancelledCount = $derived(tasks.filter((t) => t.status === "cancelled").length);
+// "Active" total excludes cancelled items, so progress reads as work that still counts.
+const activeTotal = $derived(tasks.length - cancelledCount);
-function checkboxClass(status: TaskItem["status"]): string {
+function checkboxClass(status: Status): string {
switch (status) {
case "pending":
return "checkbox checkbox-sm rounded-sm checkbox-secondary";
case "in_progress":
return "checkbox checkbox-sm rounded-sm checkbox-info";
- case "done":
+ case "completed":
return "checkbox checkbox-sm rounded-sm checkbox-success";
+ case "cancelled":
+ return "checkbox checkbox-sm rounded-sm checkbox-neutral";
}
}
-function isChecked(status: TaskItem["status"]): boolean {
- return status === "done";
+function isChecked(status: Status): boolean {
+ return status === "completed";
}
-function isIndeterminate(status: TaskItem["status"]): boolean {
+function isIndeterminate(status: Status): boolean {
return status === "in_progress";
}
+
+function rowClass(status: Status): string {
+ if (status === "completed") return "opacity-60";
+ if (status === "cancelled") return "opacity-40";
+ return "";
+}
+
+function textClass(status: Status): string {
+ switch (status) {
+ case "completed":
+ return "line-through text-base-content/50";
+ case "cancelled":
+ return "line-through text-base-content/40";
+ case "in_progress":
+ return "font-semibold";
+ default:
+ return "";
+ }
+}
</script>
<div class="flex flex-col gap-2">
@@ -36,13 +57,11 @@ function isIndeterminate(status: TaskItem["status"]): boolean {
<p class="text-xs text-base-content/50">No tasks yet.</p>
{:else}
<p class="text-xs text-base-content/60">
- {doneCount}/{tasks.length} done{#if inProgressCount > 0}, {inProgressCount} in progress{/if}
+ {completedCount}/{activeTotal} completed{#if inProgressCount > 0}, {inProgressCount} in progress{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if}
</p>
<ul class="flex flex-col gap-0.5">
{#each tasks as task (task.id)}
- <li
- class="flex items-start gap-2 rounded p-1.5 transition-colors {task.status === 'done' ? 'opacity-60' : ''}"
- >
+ <li class="flex items-start gap-2 rounded p-1.5 transition-colors {rowClass(task.status)}">
<input
type="checkbox"
class={checkboxClass(task.status)}
@@ -51,20 +70,9 @@ function isIndeterminate(status: TaskItem["status"]): boolean {
disabled
tabindex="-1"
/>
- <div class="flex flex-col gap-0.5 min-w-0">
- <span
- class="text-xs leading-tight {task.status === 'done'
- ? 'line-through text-base-content/50'
- : task.status === 'in_progress'
- ? 'font-semibold'
- : ''}"
- >
- {task.title}
- </span>
- {#if task.description}
- <p class="text-xs text-base-content/50 line-clamp-2">{task.description}</p>
- {/if}
- </div>
+ <span class="text-xs leading-tight min-w-0 {textClass(task.status)}">
+ {task.content}
+ </span>
</li>
{/each}
</ul>
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index ec718bd..875287b 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -844,7 +844,9 @@ export function createTabStore() {
modelId: row.modelId ?? null,
reasoningEffort: DEFAULT_REASONING_EFFORT,
currentAssistantId,
- tasks: [],
+ // Rehydrate the todo list from the backend snapshot so a reload
+ // doesn't blank the Tasks panel mid-task.
+ tasks: snap?.tasks ?? [],
injectedSkills: [],
parentTabId: row.parentTabId ?? null,
persistent: true,
@@ -974,6 +976,10 @@ export function createTabStore() {
updateTab(t.id, { agentStatus: backendStatus });
}
+ // Rehydrate the todo list from the snapshot (backend truth)
+ // so a reconnect/reload doesn't blank the Tasks panel.
+ updateTab(t.id, { tasks: snap?.tasks ?? [] });
+
if (backendStatus === "running") {
// Seed the in-flight assistant message from the snapshot.
// This handles the "browser just reopened mid-stream"
@@ -1940,7 +1946,7 @@ export function createTabStore() {
`Persistent: ${tab.persistent}`,
`Working directory: ${tab.workingDirectory ?? "default"}`,
`Reasoning effort: ${tab.reasoningEffort}`,
- `Pending tasks: ${tab.tasks.length}`,
+ `Todos: ${tab.tasks.length} (${tab.tasks.filter((t) => t.status === "completed").length} completed)`,
"",
];
const TOOL_RESULT_MAX = 300;
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
index 173f68c..384028c 100644
--- a/packages/frontend/src/lib/types.ts
+++ b/packages/frontend/src/lib/types.ts
@@ -131,6 +131,8 @@ export interface TabStatusSnapshot {
currentAssistantId?: string;
/** turn_id of the in-flight turn; present iff status === "running". */
currentTurnId?: string;
+ /** The tab's todo list, for rehydrating the Tasks panel on reload. */
+ tasks?: TaskItem[];
}
export type AgentEvent =
@@ -221,10 +223,10 @@ export type AgentEvent =
| { type: "message-cancelled"; tabId: string; messageId: string };
export interface TaskItem {
+ /** Stable positional id for Svelte keying only; never shown to the model. */
id: string;
- title: string;
- description: string;
- status: "pending" | "in_progress" | "done";
+ content: string;
+ status: "pending" | "in_progress" | "completed" | "cancelled";
}
export interface PermissionPrompt {