refactor: move task tracking from standalone page into session bottom bar

Remove the separate Tasks sidebar page (wrong concept — was showing
scheduled tasks). Instead, display CLI task progress as a sticky bar
at the bottom of the active session, matching the official desktop app
behavior. Tasks are refreshed in real-time by detecting TaskCreate/
TaskUpdate tool_result messages over WebSocket.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-07 13:00:45 +08:00
parent 56b4fe3585
commit 9b74e064c7
8 changed files with 201 additions and 286 deletions

View File

@ -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 (
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface)] shrink-0">
{/* Header — always visible, clickable to toggle */}
<button
onClick={toggleExpanded}
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-[var(--color-surface-hover)] transition-colors"
>
<span
className="material-symbols-outlined text-[16px]"
style={{ color: 'var(--color-text-secondary)' }}
>
checklist
</span>
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
Tasks
</span>
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden max-w-[200px]">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progressPercent}%`,
backgroundColor: completedCount === totalCount
? 'var(--color-success)'
: 'var(--color-brand)',
}}
/>
</div>
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">
{completedCount}/{totalCount}
</span>
<span
className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)] transition-transform duration-200"
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
expand_less
</span>
</button>
{/* Expanded task list */}
{expanded && (
<div className="px-4 pb-2 flex flex-col gap-0.5 max-h-[240px] overflow-y-auto">
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</div>
)}
</div>
)
}
function TaskItem({ task }: { task: CLITask }) {
const config = statusConfig[task.status]
return (
<div className="flex items-start gap-2 py-1.5 px-1 rounded-md">
<span
className="material-symbols-outlined text-[16px] mt-px shrink-0"
style={{ color: config.color, fontVariationSettings: "'FILL' 1" }}
>
{config.icon}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-mono text-[var(--color-text-tertiary)]">
#{task.id}
</span>
<span className={`text-xs ${
task.status === 'completed'
? 'text-[var(--color-text-tertiary)] line-through'
: 'text-[var(--color-text-primary)]'
}`}>
{task.subject}
</span>
</div>
{task.status === 'in_progress' && task.activeForm && (
<div className="flex items-center gap-1 mt-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-warning)] animate-pulse" />
<span className="text-[10px] text-[var(--color-warning)]">
{task.activeForm}
</span>
</div>
)}
{task.owner && (
<span className="text-[10px] text-[var(--color-text-tertiary)] mt-0.5 inline-flex items-center gap-0.5">
<span className="material-symbols-outlined text-[10px]">person</span>
{task.owner}
</span>
)}
</div>
</div>
)
}

View File

@ -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 <ScheduledTasks />
}
if (activeView === 'tasks') {
return <Tasks />
}
if (activeView === 'terminal') {
return <TerminalPlaceholder />
}

View File

@ -86,13 +86,6 @@ export function Sidebar() {
>
Scheduled
</NavItem>
<NavItem
active={activeView === 'tasks'}
onClick={() => setActiveView('tasks')}
icon={<TaskIcon />}
>
Tasks
</NavItem>
</div>
{/* Project filter */}
@ -288,11 +281,3 @@ function ClockIcon() {
)
}
function TaskIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
)
}

View File

@ -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 */}
<MessageList />
{/* Session task bar — sticky at bottom */}
<SessionTaskBar />
{/* Team status bar */}
<TeamStatusBar />

View File

@ -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 (
<div className="flex-1 flex overflow-hidden">
{/* Sidebar: task lists */}
<div className="w-64 border-r border-[var(--color-border)] flex flex-col overflow-hidden bg-[var(--color-surface)]">
<div className="px-4 py-4 border-b border-[var(--color-border)]">
<h2 className="text-sm font-bold text-[var(--color-text-primary)]">Task Lists</h2>
<p className="text-[10px] text-[var(--color-text-tertiary)] mt-0.5">CLI task tracking sessions</p>
</div>
<div className="flex-1 overflow-y-auto py-1">
{isLoading && taskLists.length === 0 ? (
<div className="flex justify-center py-8">
<div className="animate-spin w-4 h-4 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
) : taskLists.length === 0 ? (
<div className="px-4 py-8 text-center">
<span className="material-symbols-outlined text-[28px] text-[var(--color-text-tertiary)] block mb-2">task_alt</span>
<p className="text-xs text-[var(--color-text-tertiary)]">No task lists found</p>
</div>
) : (
taskLists.map((list) => (
<TaskListItem
key={list.id}
list={list}
selected={selectedListId === list.id}
onClick={() => selectTaskList(list.id)}
/>
))
)}
</div>
</div>
{/* Main: task details */}
<div className="flex-1 overflow-y-auto bg-[var(--color-surface)]">
{selectedListId ? (
<TaskListDetail
listId={selectedListId}
tasks={tasks}
isLoading={isLoading}
onBack={clearSelection}
/>
) : (
<div className="flex flex-col items-center justify-center h-full">
<span className="material-symbols-outlined text-[48px] text-[var(--color-text-tertiary)] mb-3">checklist</span>
<p className="text-sm text-[var(--color-text-secondary)]">Select a task list to view tasks</p>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">{taskLists.length} task list{taskLists.length !== 1 ? 's' : ''} available</p>
</div>
)}
</div>
</div>
)
}
// ─── 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 (
<button
onClick={onClick}
className={`w-full flex flex-col gap-1 px-4 py-2.5 text-left transition-colors ${
selected
? 'bg-[var(--color-surface-selected)]'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
{isTeamOrNamed ? 'group' : 'terminal'}
</span>
<span className="text-xs font-medium text-[var(--color-text-primary)] truncate">{displayName}</span>
</div>
<div className="flex items-center gap-2 ml-5">
{/* Mini progress bar */}
<div className="flex-1 h-1 rounded-full bg-[var(--color-border)] overflow-hidden">
<div
className="h-full rounded-full bg-[var(--color-success)] transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-[10px] text-[var(--color-text-tertiary)] flex-shrink-0">
{list.completedCount}/{list.taskCount}
</span>
</div>
</button>
)
}
// ─── 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 (
<div className="px-8 py-6">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<button onClick={onBack} className="p-1 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors text-[var(--color-text-secondary)] lg:hidden">
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
</button>
<div className="flex-1">
<h1 className="text-lg font-bold text-[var(--color-text-primary)] truncate">{listId}</h1>
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--color-text-tertiary)]">
<span>{tasks.length} tasks</span>
{completedCount > 0 && <StatusBadge status="completed" count={completedCount} />}
{inProgressCount > 0 && <StatusBadge status="in_progress" count={inProgressCount} />}
{pendingCount > 0 && <StatusBadge status="pending" count={pendingCount} />}
</div>
</div>
</div>
{/* Task list */}
{isLoading ? (
<div className="flex justify-center py-12">
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
) : tasks.length === 0 ? (
<div className="text-center py-12">
<p className="text-sm text-[var(--color-text-tertiary)]">No tasks in this list</p>
</div>
) : (
<div className="flex flex-col gap-2">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} allTasks={tasks} />
))}
</div>
)}
</div>
)
}
// ─── 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 (
<div className={`flex gap-3 px-4 py-3 rounded-xl border border-[var(--color-border)] transition-colors hover:border-[var(--color-border-focus)] ${config.bg}`}>
{/* Status icon */}
<span className={`material-symbols-outlined text-[20px] mt-0.5 flex-shrink-0 ${config.color}`} style={{ fontVariationSettings: "'FILL' 1" }}>
{config.icon}
</span>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-[var(--color-text-tertiary)]">#{task.id}</span>
<span className="text-sm font-medium text-[var(--color-text-primary)]">{task.subject}</span>
</div>
{task.description && (
<p className="text-xs text-[var(--color-text-secondary)] mt-0.5 line-clamp-2">{task.description}</p>
)}
{/* Meta row */}
<div className="flex items-center gap-3 mt-1.5 flex-wrap">
{task.owner && (
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[12px]">person</span>
{task.owner}
</span>
)}
{task.blockedBy.length > 0 && (
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-error)]" title={blockerNames.join(', ')}>
<span className="material-symbols-outlined text-[12px]">block</span>
blocked by {task.blockedBy.map((id) => `#${id}`).join(', ')}
</span>
)}
{task.blocks.length > 0 && (
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-tertiary)]" title={blockingNames.join(', ')}>
<span className="material-symbols-outlined text-[12px]">arrow_forward</span>
blocks {blockingNames.join(', ')}
</span>
)}
</div>
</div>
</div>
)
}
// ─── Status Badge ───────────────────────────────────────────
function StatusBadge({ status, count }: { status: string; count: number }) {
const styles: Record<string, string> = {
completed: 'text-[var(--color-success)]',
in_progress: 'text-[var(--color-warning)]',
pending: 'text-[var(--color-text-tertiary)]',
}
const labels: Record<string, string> = {
completed: 'done',
in_progress: 'active',
pending: 'pending',
}
return (
<span className={`${styles[status] || ''}`}>
{count} {labels[status] || status}
</span>
)
}

View File

@ -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<string>()
let msgCounter = 0
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
let elapsedTimer: ReturnType<typeof setInterval> | null = null
@ -94,8 +100,9 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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':

View File

@ -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<void>
selectTaskList: (id: string) => Promise<void>
clearSelection: () => void
/** Fetch tasks for a given session (uses sessionId as taskListId) */
fetchSessionTasks: (sessionId: string) => Promise<void>
/** Refresh tasks for the currently tracked session */
refreshTasks: () => Promise<void>
/** Clear task tracking state */
clearTasks: () => void
/** Toggle expanded state */
toggleExpanded: () => void
}
export const useCLITaskStore = create<CLITaskStore>((set) => ({
taskLists: [],
selectedListId: null,
export const useCLITaskStore = create<CLITaskStore>((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 }))
},
}))

View File

@ -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'