diff --git a/desktop/src/components/chat/SessionTaskBar.tsx b/desktop/src/components/chat/SessionTaskBar.tsx new file mode 100644 index 00000000..c798586f --- /dev/null +++ b/desktop/src/components/chat/SessionTaskBar.tsx @@ -0,0 +1,130 @@ +import { useCLITaskStore } from '../../stores/cliTaskStore' +import type { CLITask } from '../../types/cliTask' + +const statusConfig = { + pending: { + icon: 'radio_button_unchecked', + color: 'var(--color-text-tertiary)', + label: 'pending', + }, + in_progress: { + icon: 'pending', + color: 'var(--color-warning)', + label: 'active', + }, + completed: { + icon: 'check_circle', + color: 'var(--color-success)', + label: 'done', + }, +} as const + +export function SessionTaskBar() { + const { tasks, expanded, toggleExpanded } = useCLITaskStore() + + if (tasks.length === 0) return null + + const completedCount = tasks.filter((t) => t.status === 'completed').length + const totalCount = tasks.length + const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0 + + return ( +
+ {/* Header — always visible, clickable to toggle */} + + + {/* Expanded task list */} + {expanded && ( +
+ {tasks.map((task) => ( + + ))} +
+ )} +
+ ) +} + +function TaskItem({ task }: { task: CLITask }) { + const config = statusConfig[task.status] + + return ( +
+ + {config.icon} + + +
+
+ + #{task.id} + + + {task.subject} + +
+ + {task.status === 'in_progress' && task.activeForm && ( +
+ + + {task.activeForm} + +
+ )} + + {task.owner && ( + + person + {task.owner} + + )} +
+
+ ) +} diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 7e68c8a3..3282d476 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -4,7 +4,6 @@ import { useTeamStore } from '../../stores/teamStore' import { EmptySession } from '../../pages/EmptySession' import { ActiveSession } from '../../pages/ActiveSession' import { ScheduledTasks } from '../../pages/ScheduledTasks' -import { Tasks } from '../../pages/Tasks' import { Settings } from '../../pages/Settings' import { AgentTranscript } from '../../pages/AgentTranscript' @@ -21,10 +20,6 @@ export function ContentRouter() { return } - if (activeView === 'tasks') { - return - } - if (activeView === 'terminal') { return } diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index a402370b..0c84db6d 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -86,13 +86,6 @@ export function Sidebar() { > Scheduled - setActiveView('tasks')} - icon={} - > - Tasks - {/* Project filter */} @@ -288,11 +281,3 @@ function ClockIcon() { ) } -function TaskIcon() { - return ( - - - - - ) -} diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 64db4b76..f8a30c1e 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -4,6 +4,7 @@ import { useChatStore } from '../stores/chatStore' import { MessageList } from '../components/chat/MessageList' import { ChatInput } from '../components/chat/ChatInput' import { TeamStatusBar } from '../components/teams/TeamStatusBar' +import { SessionTaskBar } from '../components/chat/SessionTaskBar' export function ActiveSession() { const activeSessionId = useSessionStore((s) => s.activeSessionId) @@ -79,6 +80,9 @@ export function ActiveSession() { {/* Message stream */} + {/* Session task bar — sticky at bottom */} + + {/* Team status bar */} diff --git a/desktop/src/pages/Tasks.tsx b/desktop/src/pages/Tasks.tsx deleted file mode 100644 index 9dd19cc8..00000000 --- a/desktop/src/pages/Tasks.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { useEffect } from 'react' -import { useCLITaskStore } from '../stores/cliTaskStore' -import type { CLITask, TaskListSummary } from '../types/cliTask' - -export function Tasks() { - const { taskLists, selectedListId, tasks, isLoading, fetchTaskLists, selectTaskList, clearSelection } = useCLITaskStore() - - useEffect(() => { fetchTaskLists() }, [fetchTaskLists]) - - return ( -
- {/* Sidebar: task lists */} -
-
-

Task Lists

-

CLI task tracking sessions

-
-
- {isLoading && taskLists.length === 0 ? ( -
-
-
- ) : taskLists.length === 0 ? ( -
- task_alt -

No task lists found

-
- ) : ( - taskLists.map((list) => ( - selectTaskList(list.id)} - /> - )) - )} -
-
- - {/* Main: task details */} -
- {selectedListId ? ( - - ) : ( -
- checklist -

Select a task list to view tasks

-

{taskLists.length} task list{taskLists.length !== 1 ? 's' : ''} available

-
- )} -
-
- ) -} - -// ─── Task List Item ───────────────────────────────────────── - -function TaskListItem({ list, selected, onClick }: { list: TaskListSummary; selected: boolean; onClick: () => void }) { - const isTeamOrNamed = !list.id.match(/^[0-9a-f]{8}-/) - const displayName = isTeamOrNamed ? list.id : list.id.slice(0, 8) + '...' - - const progressPercent = list.taskCount > 0 - ? Math.round((list.completedCount / list.taskCount) * 100) - : 0 - - return ( - - ) -} - -// ─── Task List Detail ────────────────────────────────────── - -function TaskListDetail({ listId, tasks, isLoading, onBack }: { - listId: string - tasks: CLITask[] - isLoading: boolean - onBack: () => void -}) { - const completedCount = tasks.filter((t) => t.status === 'completed').length - const inProgressCount = tasks.filter((t) => t.status === 'in_progress').length - const pendingCount = tasks.filter((t) => t.status === 'pending').length - - return ( -
- {/* Header */} -
- -
-

{listId}

-
- {tasks.length} tasks - {completedCount > 0 && } - {inProgressCount > 0 && } - {pendingCount > 0 && } -
-
-
- - {/* Task list */} - {isLoading ? ( -
-
-
- ) : tasks.length === 0 ? ( -
-

No tasks in this list

-
- ) : ( -
- {tasks.map((task) => ( - - ))} -
- )} -
- ) -} - -// ─── Task Card ────────────────────────────────────────────── - -function TaskCard({ task, allTasks }: { task: CLITask; allTasks: CLITask[] }) { - const statusConfig = { - pending: { icon: 'radio_button_unchecked', color: 'text-[var(--color-text-tertiary)]', bg: '' }, - in_progress: { icon: 'pending', color: 'text-[var(--color-warning)]', bg: 'bg-amber-50/50' }, - completed: { icon: 'check_circle', color: 'text-[var(--color-success)]', bg: '' }, - } - - const config = statusConfig[task.status] - - // Resolve blocker names - const blockerNames = task.blockedBy - .map((id) => allTasks.find((t) => t.id === id)) - .filter(Boolean) - .map((t) => `#${t!.id} ${t!.subject}`) - - const blockingNames = task.blocks - .map((id) => allTasks.find((t) => t.id === id)) - .filter(Boolean) - .map((t) => `#${t!.id}`) - - return ( -
- {/* Status icon */} - - {config.icon} - - - {/* Content */} -
-
- #{task.id} - {task.subject} -
- - {task.description && ( -

{task.description}

- )} - - {/* Meta row */} -
- {task.owner && ( - - person - {task.owner} - - )} - {task.blockedBy.length > 0 && ( - - block - blocked by {task.blockedBy.map((id) => `#${id}`).join(', ')} - - )} - {task.blocks.length > 0 && ( - - arrow_forward - blocks {blockingNames.join(', ')} - - )} -
-
-
- ) -} - -// ─── Status Badge ─────────────────────────────────────────── - -function StatusBadge({ status, count }: { status: string; count: number }) { - const styles: Record = { - completed: 'text-[var(--color-success)]', - in_progress: 'text-[var(--color-warning)]', - pending: 'text-[var(--color-text-tertiary)]', - } - - const labels: Record = { - completed: 'done', - in_progress: 'active', - pending: 'pending', - } - - return ( - - {count} {labels[status] || status} - - ) -} diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 03f12b2c..4b3cbbb1 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand' import { wsManager } from '../api/websocket' import { sessionsApi } from '../api/sessions' import { useTeamStore } from './teamStore' +import { useCLITaskStore } from './cliTaskStore' import type { MessageEntry } from '../types/session' import type { PermissionMode } from '../types/settings' import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage, TokenUsage } from '../types/chat' @@ -41,6 +42,11 @@ type ChatStore = { handleServerMessage: (msg: ServerMessage) => void } +const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList']) + +/** Track tool_use IDs for task-related tools, so we can refresh on tool_result */ +const pendingTaskToolUseIds = new Set() + let msgCounter = 0 const nextId = () => `msg-${++msgCounter}-${Date.now()}` let elapsedTimer: ReturnType | null = null @@ -94,8 +100,9 @@ export const useChatStore = create((set, get) => ({ get().handleServerMessage(msg) }) - // Load history + // Load history and tasks get().loadHistory(sessionId) + useCLITaskStore.getState().fetchSessionTasks(sessionId) sessionsApi.getSlashCommands(sessionId) .then(({ commands }) => { if (get().connectedSessionId === sessionId) { @@ -112,6 +119,7 @@ export const useChatStore = create((set, get) => ({ disconnectSession: () => { wsManager.disconnect() if (elapsedTimer) clearInterval(elapsedTimer) + useCLITaskStore.getState().clearTasks() set({ connectedSessionId: null, chatState: 'idle' }) }, @@ -262,12 +270,13 @@ export const useChatStore = create((set, get) => ({ }) break - case 'tool_use_complete': + case 'tool_use_complete': { + const toolName = msg.toolName || get().activeToolName || 'unknown' set((s) => ({ messages: [...s.messages, { id: nextId(), type: 'tool_use', - toolName: msg.toolName || s.activeToolName || 'unknown', + toolName, toolUseId: msg.toolUseId || s.activeToolUseId || '', input: msg.input, timestamp: Date.now(), @@ -277,7 +286,14 @@ export const useChatStore = create((set, get) => ({ activeThinkingId: null, streamingToolInput: '', })) + // Track task-related tool calls — refresh will happen on tool_result + // when the tool has actually finished executing and written to disk + if (TASK_TOOL_NAMES.has(toolName)) { + const useId = msg.toolUseId || get().activeToolUseId + if (useId) pendingTaskToolUseIds.add(useId) + } break + } case 'tool_result': set((s) => ({ @@ -292,6 +308,11 @@ export const useChatStore = create((set, get) => ({ chatState: 'thinking', activeThinkingId: null, })) + // Refresh tasks after a task tool has finished executing + if (pendingTaskToolUseIds.has(msg.toolUseId)) { + pendingTaskToolUseIds.delete(msg.toolUseId) + useCLITaskStore.getState().refreshTasks() + } break case 'permission_request': diff --git a/desktop/src/stores/cliTaskStore.ts b/desktop/src/stores/cliTaskStore.ts index 1b67fb4b..a2cb41d5 100644 --- a/desktop/src/stores/cliTaskStore.ts +++ b/desktop/src/stores/cliTaskStore.ts @@ -1,45 +1,64 @@ import { create } from 'zustand' import { cliTasksApi } from '../api/cliTasks' -import type { CLITask, TaskListSummary } from '../types/cliTask' +import type { CLITask } from '../types/cliTask' type CLITaskStore = { - taskLists: TaskListSummary[] - selectedListId: string | null + /** Current session ID being tracked */ + sessionId: string | null + /** Tasks for the current session */ tasks: CLITask[] - isLoading: boolean + /** Whether the task bar is expanded */ + expanded: boolean - fetchTaskLists: () => Promise - selectTaskList: (id: string) => Promise - clearSelection: () => void + /** Fetch tasks for a given session (uses sessionId as taskListId) */ + fetchSessionTasks: (sessionId: string) => Promise + /** Refresh tasks for the currently tracked session */ + refreshTasks: () => Promise + /** Clear task tracking state */ + clearTasks: () => void + /** Toggle expanded state */ + toggleExpanded: () => void } -export const useCLITaskStore = create((set) => ({ - taskLists: [], - selectedListId: null, +export const useCLITaskStore = create((set, get) => ({ + sessionId: null, tasks: [], - isLoading: false, + expanded: true, - fetchTaskLists: async () => { - set({ isLoading: true }) + fetchSessionTasks: async (sessionId) => { + set({ sessionId }) try { - const { lists } = await cliTasksApi.listTaskLists() - set({ taskLists: lists, isLoading: false }) + const { tasks } = await cliTasksApi.getTasksForList(sessionId) + // Only update if still tracking the same session + if (get().sessionId === sessionId) { + set({ tasks }) + } } catch { - set({ isLoading: false }) + // No tasks for this session — that's fine + if (get().sessionId === sessionId) { + set({ tasks: [] }) + } } }, - selectTaskList: async (id) => { - set({ selectedListId: id, isLoading: true }) + refreshTasks: async () => { + const { sessionId } = get() + if (!sessionId) return try { - const { tasks } = await cliTasksApi.getTasksForList(id) - set({ tasks, isLoading: false }) + const { tasks } = await cliTasksApi.getTasksForList(sessionId) + if (get().sessionId === sessionId) { + set({ tasks }) + } } catch { - set({ tasks: [], isLoading: false }) + // ignore } }, - clearSelection: () => { - set({ selectedListId: null, tasks: [] }) + clearTasks: () => { + set({ sessionId: null, tasks: [] }) + }, + + toggleExpanded: () => { + set((s) => ({ expanded: !s.expanded })) }, })) diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts index a25820ee..bfd43982 100644 --- a/desktop/src/stores/uiStore.ts +++ b/desktop/src/stores/uiStore.ts @@ -7,7 +7,7 @@ export type Toast = { duration?: number } -type ActiveView = 'code' | 'scheduled' | 'tasks' | 'terminal' | 'history' | 'settings' +type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings' type UIStore = { theme: 'light' | 'dark'