diff --git a/src/__tests__/renderer/components/AgentTaskListBar.test.tsx b/src/__tests__/renderer/components/AgentTaskListBar.test.tsx
new file mode 100644
index 0000000000..39cb0fc2ab
--- /dev/null
+++ b/src/__tests__/renderer/components/AgentTaskListBar.test.tsx
@@ -0,0 +1,192 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { AgentTaskListBar } from '../../../renderer/components/AgentTaskListBar';
+import { mockTheme } from '../../helpers/mockTheme';
+import type { LogEntry } from '../../../renderer/types';
+
+// The bar reads two booleans off the settings store; stub them rather than
+// hydrating the whole store from IPC.
+let showBar = true;
+let autoExpand = false;
+vi.mock('../../../renderer/stores/settingsStore', () => ({
+ useSettingsStore: (
+ selector: (s: { showAgentTaskListBar: boolean; autoExpandAgentTaskListBar: boolean }) => unknown
+ ) => selector({ showAgentTaskListBar: showBar, autoExpandAgentTaskListBar: autoExpand }),
+}));
+
+function todoEntry(id: string, statuses: string[]): LogEntry {
+ return {
+ id,
+ timestamp: 0,
+ source: 'tool',
+ text: 'TodoWrite',
+ metadata: {
+ toolState: {
+ status: 'completed',
+ input: {
+ todos: statuses.map((status, i) => ({
+ content: `task ${i}`,
+ status,
+ activeForm: `doing task ${i}`,
+ })),
+ },
+ },
+ },
+ } as unknown as LogEntry;
+}
+
+describe('AgentTaskListBar', () => {
+ beforeEach(() => {
+ showBar = true;
+ autoExpand = false;
+ // The bar remembers its expanded state; start every case collapsed.
+ globalThis.localStorage?.clear();
+ });
+
+ it('renders nothing until an agent writes a checklist', () => {
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('shows the active task and progress count, collapsed by default', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('doing task 1 (1/3)')).toBeInTheDocument();
+ // Collapsed: the individual task rows are not rendered yet.
+ expect(screen.queryByText('task 2')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Expand agent task list' })).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ );
+ });
+
+ it('expands to the full list on click', () => {
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Expand agent task list' }));
+
+ expect(screen.getByText('task 0')).toBeInTheDocument();
+ expect(screen.getByText('doing task 1')).toBeInTheDocument();
+ expect(screen.getByText('task 2')).toBeInTheDocument();
+ });
+
+ it('tracks the newest checklist as the agent rewrites it', () => {
+ const { rerender } = render(
+
+ );
+ expect(screen.getByText('doing task 0 (0/2)')).toBeInTheDocument();
+
+ rerender(
+
+ );
+ expect(screen.getByText('doing task 1 (1/2)')).toBeInTheDocument();
+ });
+
+ it('dismisses the current list, and comes back when the agent writes a new one', () => {
+ const first = todoEntry('a', ['in_progress', 'pending']);
+ const { rerender } = render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Hide this task list' }));
+ expect(screen.queryByTestId('agent-task-list-bar')).not.toBeInTheDocument();
+
+ // A re-render alone must not resurrect the dismissed list...
+ rerender( );
+ expect(screen.queryByTestId('agent-task-list-bar')).not.toBeInTheDocument();
+
+ // ...but the next checklist update does.
+ rerender(
+
+ );
+ expect(screen.getByTestId('agent-task-list-bar')).toBeInTheDocument();
+ });
+
+ it('stays hidden when the setting is off', () => {
+ showBar = false;
+ const { container } = render(
+
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('ignores a checklist written inside a subagent', () => {
+ const nested = todoEntry('b', ['pending', 'pending']);
+ nested.metadata = { ...nested.metadata, parentToolUseId: 'task_1' };
+
+ render(
+
+ );
+
+ // The parent's plan, not the delegated worker's private one.
+ expect(screen.getByText('doing task 0 (0/2)')).toBeInTheDocument();
+ });
+
+ describe('with auto-expand on', () => {
+ beforeEach(() => {
+ autoExpand = true;
+ });
+
+ it('opens a new checklist to its full list', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('task 2')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Collapse agent task list' })).toHaveAttribute(
+ 'aria-expanded',
+ 'true'
+ );
+ });
+
+ it('re-expands when the agent writes the next checklist, after a manual collapse', () => {
+ const first = todoEntry('a', ['in_progress', 'pending']);
+ const { rerender } = render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse agent task list' }));
+ expect(screen.queryByText('task 1')).not.toBeInTheDocument();
+
+ // The collapse applies to THAT checklist only.
+ rerender(
+
+ );
+ expect(screen.getByText('task 0')).toBeInTheDocument();
+ });
+
+ it('does not carry the sticky collapsed preference over', () => {
+ // A user who collapsed the bar with auto-expand OFF still gets the
+ // full list once they turn auto-expand ON.
+ globalThis.localStorage?.setItem('agentTaskList.bar.expanded', 'false');
+
+ render( );
+
+ expect(screen.getByText('doing task 0')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/__tests__/renderer/utils/agentTaskList.test.ts b/src/__tests__/renderer/utils/agentTaskList.test.ts
index 446c031a24..1945b087ce 100644
--- a/src/__tests__/renderer/utils/agentTaskList.test.ts
+++ b/src/__tests__/renderer/utils/agentTaskList.test.ts
@@ -1,8 +1,10 @@
import { describe, it, expect } from 'vitest';
import {
extractAgentTaskList,
+ findLatestAgentTaskList,
summarizeAgentTaskList,
} from '../../../renderer/utils/agentTaskList';
+import type { LogEntry } from '../../../renderer/types';
describe('extractAgentTaskList', () => {
it('extracts a Claude Code / OpenCode TodoWrite todos array', () => {
@@ -128,3 +130,59 @@ describe('summarizeAgentTaskList', () => {
expect(summarizeAgentTaskList(list)).toBe('Patch the parser (0/1)');
});
});
+
+describe('findLatestAgentTaskList', () => {
+ const toolEntry = (id: string, input: unknown) =>
+ ({
+ id,
+ timestamp: 0,
+ source: 'tool',
+ text: 'TodoWrite',
+ metadata: { toolState: { status: 'completed', input } },
+ }) as unknown as LogEntry;
+
+ const todos = (...statuses: string[]) => ({
+ todos: statuses.map((status, i) => ({ content: `task ${i}`, status })),
+ });
+
+ it('returns the newest checklist in the log', () => {
+ const latest = findLatestAgentTaskList([
+ toolEntry('a', todos('in_progress', 'pending')),
+ toolEntry('b', { file_path: '/tmp/x.ts' }),
+ toolEntry('c', todos('completed', 'in_progress')),
+ ]);
+
+ expect(latest?.entryId).toBe('c');
+ expect(latest?.list.completed).toBe(1);
+ });
+
+ it('returns null when no tool call carried a checklist', () => {
+ expect(findLatestAgentTaskList([toolEntry('a', { command: 'ls' })])).toBeNull();
+ expect(findLatestAgentTaskList([])).toBeNull();
+ expect(findLatestAgentTaskList(undefined)).toBeNull();
+ });
+
+ it('ignores non-tool entries', () => {
+ const chat = { id: 'msg', timestamp: 0, source: 'ai', text: 'todos: 1. do it' } as LogEntry;
+ expect(findLatestAgentTaskList([chat])).toBeNull();
+ });
+
+ it("skips a subagent's private checklist", () => {
+ const nested = toolEntry('b', todos('pending', 'pending'));
+ nested.metadata = { ...nested.metadata, parentToolUseId: 'task_1' };
+
+ const latest = findLatestAgentTaskList([
+ toolEntry('a', todos('in_progress', 'pending')),
+ nested,
+ ]);
+
+ expect(latest?.entryId).toBe('a');
+ });
+
+ it('returns null when the only checklist came from a subagent', () => {
+ const nested = toolEntry('a', todos('pending'));
+ nested.metadata = { ...nested.metadata, parentToolUseId: 'task_1' };
+
+ expect(findLatestAgentTaskList([nested])).toBeNull();
+ });
+});
diff --git a/src/renderer/components/AgentTaskItems.tsx b/src/renderer/components/AgentTaskItems.tsx
new file mode 100644
index 0000000000..d8ff568313
--- /dev/null
+++ b/src/renderer/components/AgentTaskItems.tsx
@@ -0,0 +1,55 @@
+import type { Theme } from '../types';
+import type { AgentTask } from '../utils/agentTaskList';
+
+/**
+ * The task rows of an agent checklist: status glyph, label, completed styling.
+ *
+ * Shared by the two surfaces that render a checklist - the inline
+ * `AgentTaskListCard` in the transcript and the docked `AgentTaskListBar` above
+ * the composer - so "what a completed task looks like" is decided once. The
+ * surfaces differ in chrome and placement, not in how a task reads.
+ */
+
+/** Status glyph + color for a single task row. */
+export function taskGlyph(task: AgentTask, theme: Theme): { glyph: string; color: string } {
+ if (task.status === 'completed') return { glyph: '✓', color: theme.colors.success };
+ if (task.status === 'in_progress') return { glyph: '▸', color: theme.colors.warning };
+ return { glyph: '○', color: theme.colors.textDim };
+}
+
+interface AgentTaskItemsProps {
+ theme: Theme;
+ tasks: AgentTask[];
+ className?: string;
+}
+
+export function AgentTaskItems({ theme, tasks, className }: AgentTaskItemsProps) {
+ return (
+
+ {tasks.map((task, index) => {
+ const { glyph, color } = taskGlyph(task, theme);
+ return (
+
+
+ {glyph}
+
+
+ {task.status === 'in_progress' && task.activeForm ? task.activeForm : task.content}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/renderer/components/AgentTaskListBar.tsx b/src/renderer/components/AgentTaskListBar.tsx
new file mode 100644
index 0000000000..5d6af014fb
--- /dev/null
+++ b/src/renderer/components/AgentTaskListBar.tsx
@@ -0,0 +1,131 @@
+import React, { useMemo, useState } from 'react';
+import { ListChecks, X } from 'lucide-react';
+import type { LogEntry, Theme } from '../types';
+import { findLatestAgentTaskList, summarizeAgentTaskList } from '../utils/agentTaskList';
+import { AgentTaskItems } from './AgentTaskItems';
+import { usePersistedToggle } from '../hooks/ui/usePersistedToggle';
+import { useSettingsStore } from '../stores/settingsStore';
+
+/**
+ * The agent's current checklist, docked directly above the composer.
+ *
+ * `AgentTaskListCard` already renders every checklist update inline, but those
+ * cards scroll away as the conversation grows, so "what is the agent working on
+ * and how much is left" stops being answerable without scrolling back. This bar
+ * pins the NEWEST checklist in the active tab in place instead: collapsed to a
+ * one-line summary by default, expandable to the full list, and rewritten in
+ * place as the agent updates its plan.
+ *
+ * Nothing new is captured for it - the list is derived from the tab's own logs,
+ * so it survives tab switches and app restarts for free and there is no second
+ * copy of the state to fall out of sync with the transcript.
+ *
+ * Two Display settings drive it, both off by default: `showAgentTaskListBar`
+ * renders it at all, and `autoExpandAgentTaskListBar` opens each NEW checklist
+ * to its full list rather than the one-line summary.
+ */
+
+interface AgentTaskListBarProps {
+ theme: Theme;
+ /** Conversation log of the active AI tab. */
+ logs: LogEntry[] | undefined;
+}
+
+export const AgentTaskListBar = React.memo(function AgentTaskListBar({
+ theme,
+ logs,
+}: AgentTaskListBarProps) {
+ const enabled = useSettingsStore((s) => s.showAgentTaskListBar);
+ const autoExpand = useSettingsStore((s) => s.autoExpandAgentTaskListBar);
+ const { value: stickyExpanded, toggle: toggleStickyExpanded } = usePersistedToggle(
+ 'agentTaskList.bar.expanded',
+ false
+ );
+ // Keyed by the source log entry, so dismissing hides THIS list and the next
+ // checklist the agent writes brings the bar back on its own.
+ const [dismissedEntryId, setDismissedEntryId] = useState(null);
+ // Under auto-expand the sticky preference is not the answer: the bar opens
+ // for every new checklist, and a click only overrides THAT list. Keying the
+ // override off the entry id is what re-expands on the next update without an
+ // effect to re-sync a second copy of the state.
+ const [expandOverride, setExpandOverride] = useState<{
+ entryId: string;
+ value: boolean;
+ } | null>(null);
+
+ // PERF: the composer re-renders on every keystroke but `logs` only changes
+ // when the agent writes, so memoizing on it keeps the reverse scan off the
+ // typing path entirely.
+ const latest = useMemo(() => (enabled ? findLatestAgentTaskList(logs) : null), [enabled, logs]);
+ if (!latest || latest.entryId === dismissedEntryId) return null;
+
+ const isExpanded = autoExpand
+ ? expandOverride?.entryId === latest.entryId
+ ? expandOverride.value
+ : true
+ : stickyExpanded;
+ const toggleExpanded = () => {
+ if (autoExpand) setExpandOverride({ entryId: latest.entryId, value: !isExpanded });
+ else toggleStickyExpanded();
+ };
+
+ const { tasks, completed } = latest.list;
+ const percent = tasks.length > 0 ? Math.round((completed / tasks.length) * 100) : 0;
+ const isComplete = completed === tasks.length;
+
+ return (
+
+
+
+
+ {summarizeAgentTaskList(latest.list)}
+
+
+
+ {isExpanded ? '▾' : '▸'}
+
+ setDismissedEntryId(latest.entryId)}
+ className="flex-shrink-0 rounded p-0.5 opacity-50 transition-opacity hover:opacity-100"
+ aria-label="Hide this task list"
+ title="Hide this task list until the agent updates it"
+ >
+
+
+
+ {isExpanded && (
+
+ )}
+
+ );
+});
+
+AgentTaskListBar.displayName = 'AgentTaskListBar';
diff --git a/src/renderer/components/AgentTaskListCard.tsx b/src/renderer/components/AgentTaskListCard.tsx
index f61c1a8f44..1c1934b103 100644
--- a/src/renderer/components/AgentTaskListCard.tsx
+++ b/src/renderer/components/AgentTaskListCard.tsx
@@ -1,7 +1,8 @@
import { useState } from 'react';
import type { Theme } from '../types';
-import type { AgentTask, AgentTaskList } from '../utils/agentTaskList';
+import type { AgentTaskList } from '../utils/agentTaskList';
import { summarizeAgentTaskList } from '../utils/agentTaskList';
+import { AgentTaskItems } from './AgentTaskItems';
/**
* Inline task list card for the chat history.
@@ -13,6 +14,10 @@ import { summarizeAgentTaskList } from '../utils/agentTaskList';
* tool log has always shown. Clicking it expands the individual task items with
* their states - the GUI equivalent of Claude Code's Ctrl+T overlay, kept inline
* with the agent rather than in a separate panel.
+ *
+ * This is the historical, scroll-with-the-conversation view. `AgentTaskListBar`
+ * is its docked counterpart above the composer, for following the current list
+ * without scrolling back to find it.
*/
interface AgentTaskListCardProps {
@@ -20,13 +25,6 @@ interface AgentTaskListCardProps {
taskList: AgentTaskList;
}
-/** Status glyph + color for a single task row. */
-function taskGlyph(task: AgentTask, theme: Theme): { glyph: string; color: string } {
- if (task.status === 'completed') return { glyph: '✓', color: theme.colors.success };
- if (task.status === 'in_progress') return { glyph: '▸', color: theme.colors.warning };
- return { glyph: '○', color: theme.colors.textDim };
-}
-
export function AgentTaskListCard({ theme, taskList }: AgentTaskListCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const { tasks, completed } = taskList;
@@ -54,36 +52,7 @@ export function AgentTaskListCard({ theme, taskList }: AgentTaskListCardProps) {
/>
- {isExpanded && (
-
- {tasks.map((task, index) => {
- const { glyph, color } = taskGlyph(task, theme);
- return (
-
-
- {glyph}
-
-
- {task.status === 'in_progress' && task.activeForm
- ? task.activeForm
- : task.content}
-
-
- );
- })}
-
- )}
+ {isExpanded && }
);
}
diff --git a/src/renderer/components/InputArea/InputArea.tsx b/src/renderer/components/InputArea/InputArea.tsx
index 15351dc85e..8c672719da 100644
--- a/src/renderer/components/InputArea/InputArea.tsx
+++ b/src/renderer/components/InputArea/InputArea.tsx
@@ -13,6 +13,7 @@ import { CrossAgentResponseIndicator } from '../CrossAgentResponseIndicator';
import { getActiveTab } from '../../utils/tabHelpers';
import { MergeProgressOverlay } from '../MergeProgressOverlay';
import { ExecutionQueueIndicator } from '../ExecutionQueueIndicator';
+import { AgentTaskListBar } from '../AgentTaskListBar';
import { ContextWarningSash } from '../ContextWarningSash';
import { SummarizeProgressOverlay } from '../SummarizeProgressOverlay';
import { WizardInputPanel } from '../InlineWizard';
@@ -499,6 +500,10 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
/>
)}
+ {/* AgentTaskListBar - the agent's current checklist, pinned above the
+ composer so it doesn't scroll away with the conversation. */}
+ {session.inputMode === 'ai' && }
+
+
void;
+ autoExpandAgentTaskListBar: boolean;
+ setAutoExpandAgentTaskListBar: (value: boolean) => void;
+}
+
+export function AgentTaskListSection({
+ theme,
+ showAgentTaskListBar,
+ setShowAgentTaskListBar,
+ autoExpandAgentTaskListBar,
+ setAutoExpandAgentTaskListBar,
+}: AgentTaskListSectionProps) {
+ return (
+
+
Agent Task List
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/renderer/components/Settings/tabs/DisplayTab/components/index.ts b/src/renderer/components/Settings/tabs/DisplayTab/components/index.ts
index 33a97c6975..412dc2bdf2 100644
--- a/src/renderer/components/Settings/tabs/DisplayTab/components/index.ts
+++ b/src/renderer/components/Settings/tabs/DisplayTab/components/index.ts
@@ -1,4 +1,5 @@
export { AccessibilitySection } from './AccessibilitySection';
+export { AgentTaskListSection } from './AgentTaskListSection';
export { BionifyInfoModal } from './BionifyInfoModal';
export { ContextWarningsSection } from './ContextWarningsSection';
export { DocumentGraphSection } from './DocumentGraphSection';
diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts
index 8ef4e2b5ba..e83df29946 100644
--- a/src/renderer/hooks/settings/useSettings.ts
+++ b/src/renderer/hooks/settings/useSettings.ts
@@ -471,6 +471,14 @@ export interface UseSettingsReturn {
showFullGroupLabelInBookmarks: boolean;
setShowFullGroupLabelInBookmarks: (value: boolean) => void;
+ /** Show the agent's current checklist docked above the composer. */
+ showAgentTaskListBar: boolean;
+ setShowAgentTaskListBar: (value: boolean) => void;
+
+ /** Open that docked checklist in full whenever the agent writes a new one. */
+ autoExpandAgentTaskListBar: boolean;
+ setAutoExpandAgentTaskListBar: (value: boolean) => void;
+
// File Edit & Preview
fileEditWordWrap: boolean;
setFileEditWordWrap: (value: boolean) => void;
diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts
index 2ba660b1db..8d9467c926 100644
--- a/src/renderer/stores/settingsStore.ts
+++ b/src/renderer/stores/settingsStore.ts
@@ -428,6 +428,10 @@ export interface SettingsStoreState
directorNotesSettings: DirectorNotesSettings;
useNativeTitleBar: boolean;
autoHideMenuBar: boolean;
+ /** Show the agent's current checklist docked above the composer. */
+ showAgentTaskListBar: boolean;
+ /** Open that docked checklist in full whenever the agent writes a new one. */
+ autoExpandAgentTaskListBar: boolean;
// File Edit & Preview
fileEditWordWrap: boolean;
fileEditShowLineNumbers: boolean;
@@ -538,6 +542,8 @@ export interface SettingsStoreActions
setDirectorNotesSettings: (value: DirectorNotesSettings) => void;
setUseNativeTitleBar: (value: boolean) => void;
setAutoHideMenuBar: (value: boolean) => void;
+ setShowAgentTaskListBar: (value: boolean) => void;
+ setAutoExpandAgentTaskListBar: (value: boolean) => void;
setFileEditWordWrap: (value: boolean) => void;
setFileEditShowLineNumbers: (value: boolean) => void;
setFilePreviewToolbarButtonVisibility: (button: FilePreviewToolbarButton, value: boolean) => void;
@@ -775,6 +781,8 @@ export const useSettingsStore = create()((set, get, api) => {
directorNotesSettings: DEFAULT_DIRECTOR_NOTES_SETTINGS,
useNativeTitleBar: isWindowsPlatform(),
autoHideMenuBar: false,
+ showAgentTaskListBar: false,
+ autoExpandAgentTaskListBar: false,
fileEditWordWrap: true,
fileEditShowLineNumbers: true,
filePreviewToolbarVisibility: { ...DEFAULT_FILE_PREVIEW_TOOLBAR_VISIBILITY },
@@ -1315,6 +1323,16 @@ export const useSettingsStore = create()((set, get, api) => {
window.maestro.settings.set('autoHideMenuBar', value);
},
+ setShowAgentTaskListBar: (value) => {
+ set({ showAgentTaskListBar: value });
+ window.maestro.settings.set('showAgentTaskListBar', value);
+ },
+
+ setAutoExpandAgentTaskListBar: (value) => {
+ set({ autoExpandAgentTaskListBar: value });
+ window.maestro.settings.set('autoExpandAgentTaskListBar', value);
+ },
+
setFileEditWordWrap: (value) => {
set({ fileEditWordWrap: value });
window.maestro.settings.set('fileEditWordWrap', value);
@@ -2405,6 +2423,12 @@ export async function loadAllSettings(): Promise {
hydrateLeftPanelDisplaySettings(allSettings, patch);
+ if (allSettings['showAgentTaskListBar'] !== undefined)
+ patch.showAgentTaskListBar = allSettings['showAgentTaskListBar'] as boolean;
+
+ if (allSettings['autoExpandAgentTaskListBar'] !== undefined)
+ patch.autoExpandAgentTaskListBar = allSettings['autoExpandAgentTaskListBar'] as boolean;
+
if (allSettings['fileEditWordWrap'] !== undefined)
patch.fileEditWordWrap = allSettings['fileEditWordWrap'] as boolean;
@@ -2692,6 +2716,8 @@ export function getSettingsActions() {
setShowLeftPanelGitIndicator: state.setShowLeftPanelGitIndicator,
setShowLeftPanelCueIndicator: state.setShowLeftPanelCueIndicator,
setShowLeftPanelStartupCommandIndicator: state.setShowLeftPanelStartupCommandIndicator,
+ setShowAgentTaskListBar: state.setShowAgentTaskListBar,
+ setAutoExpandAgentTaskListBar: state.setAutoExpandAgentTaskListBar,
setFileEditWordWrap: state.setFileEditWordWrap,
setFileEditShowLineNumbers: state.setFileEditShowLineNumbers,
setFilePreviewToolbarButtonVisibility: state.setFilePreviewToolbarButtonVisibility,
diff --git a/src/renderer/utils/agentTaskList.ts b/src/renderer/utils/agentTaskList.ts
index f6f0df25a6..86ab798ec7 100644
--- a/src/renderer/utils/agentTaskList.ts
+++ b/src/renderer/utils/agentTaskList.ts
@@ -12,6 +12,8 @@
* the same structure gets the richer rendering for free.
*/
+import type { LogEntry } from '../types';
+
/** Normalized task state shared by every agent's checklist format. */
export type AgentTaskStatus = 'pending' | 'in_progress' | 'completed';
@@ -119,3 +121,40 @@ export function summarizeAgentTaskList(list: AgentTaskList): string {
if (!label) return `${tasks.length} tasks`;
return `${label} (${completed}/${tasks.length})`;
}
+
+/** A checklist plus the log entry it came from. */
+export interface LatestAgentTaskList {
+ /**
+ * Id of the tool entry the checklist was read from. The docked bar keys its
+ * dismissal off this, so dismissing hides that one list and the next
+ * checklist the agent writes brings the bar back.
+ */
+ entryId: string;
+ list: AgentTaskList;
+}
+
+/**
+ * Newest checklist in a tab's conversation, or null when the agent has not
+ * written one. Agents rewrite the whole list on every update, so the last
+ * checklist-shaped tool call in the log IS the current state - there is nothing
+ * to merge across entries.
+ *
+ * Checklists written INSIDE a subagent (`metadata.parentToolUseId`) are skipped.
+ * A delegated worker keeps its own private plan, and it is written last, so
+ * without this guard a Task tool call would replace the plan the user is
+ * actually following with a scratch list they never asked to see.
+ */
+export function findLatestAgentTaskList(
+ logs: readonly LogEntry[] | undefined
+): LatestAgentTaskList | null {
+ if (!logs) return null;
+ for (let i = logs.length - 1; i >= 0; i--) {
+ const entry = logs[i];
+ if (entry.metadata?.parentToolUseId) continue;
+ const toolState = entry.metadata?.toolState;
+ if (!toolState || toolState.input === undefined) continue;
+ const list = extractAgentTaskList(toolState.input);
+ if (list) return { entryId: entry.id, list };
+ }
+ return null;
+}
diff --git a/src/shared/settingsMetadataAppearance.ts b/src/shared/settingsMetadataAppearance.ts
index e15824573a..afc9f179bc 100644
--- a/src/shared/settingsMetadataAppearance.ts
+++ b/src/shared/settingsMetadataAppearance.ts
@@ -123,6 +123,20 @@ export const APPEARANCE_SETTINGS_METADATA: Record = {
default: true,
category: 'appearance',
},
+ showAgentTaskListBar: {
+ description:
+ "Dock the agent's current checklist (TodoWrite / update_plan) in a collapsible bar above the composer.",
+ type: 'boolean',
+ default: false,
+ category: 'appearance',
+ },
+ autoExpandAgentTaskListBar: {
+ description:
+ 'Open the docked agent task list to its full checklist whenever the agent writes a new one, instead of the one-line summary. Requires showAgentTaskListBar.',
+ type: 'boolean',
+ default: false,
+ category: 'appearance',
+ },
showWorktreePill: {
description: 'Show the WORKTREE badge next to worktree child agents in the left panel.',
type: 'boolean',