feat: add Provider management UI, CLI Tasks page, and enhance NewTaskModal

- Settings: new Providers tab with full CRUD, activation, and connectivity
  test; Model tab shows active provider name; General tab simplified
- Tasks: new CLI Tasks page displaying task lists from ~/.claude/tasks/
  with status, owner, and dependency (blocks/blockedBy) visualization
- NewTaskModal: add Advanced options (model, permission mode, working dir)
- Backend: fix TaskService to parse CLI V2 task format; extend /api/tasks
  with /lists endpoint for grouped queries
- Fix ModelInfo.context type from number to string

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-07 10:05:50 +08:00
parent da2be1f81b
commit d059a8b487
17 changed files with 1087 additions and 133 deletions

View File

@ -0,0 +1,28 @@
import { api } from './client'
import type { CLITask, TaskListSummary } from '../types/cliTask'
type TaskListsResponse = { lists: TaskListSummary[] }
type TasksResponse = { tasks: CLITask[] }
type TaskResponse = { task: CLITask }
export const cliTasksApi = {
/** List all task lists with summaries */
listTaskLists() {
return api.get<TaskListsResponse>('/api/tasks/lists')
},
/** Get all tasks for a specific task list */
getTasksForList(taskListId: string) {
return api.get<TasksResponse>(`/api/tasks/lists/${encodeURIComponent(taskListId)}`)
},
/** Get a single task */
getTask(taskListId: string, taskId: string) {
return api.get<TaskResponse>(`/api/tasks/lists/${encodeURIComponent(taskListId)}/${taskId}`)
},
/** List all tasks across all task lists */
listAll() {
return api.get<TasksResponse>('/api/tasks')
},
}

View File

@ -1,7 +1,7 @@
import { api } from './client'
import type { ModelInfo, EffortLevel } from '../types/settings'
type ModelsResponse = { models: ModelInfo[] }
type ModelsResponse = { models: ModelInfo[]; provider: { id: string; name: string } | null }
type CurrentModelResponse = { model: ModelInfo }
type EffortResponse = { level: EffortLevel; available: EffortLevel[] }

View File

@ -0,0 +1,46 @@
import { api } from './client'
import type {
Provider,
CreateProviderInput,
UpdateProviderInput,
TestProviderConfigInput,
ProviderTestResult,
} from '../types/provider'
type ProvidersResponse = { providers: Provider[] }
type ProviderResponse = { provider: Provider }
type TestResultResponse = { result: ProviderTestResult }
export const providersApi = {
list() {
return api.get<ProvidersResponse>('/api/providers')
},
get(id: string) {
return api.get<ProviderResponse>(`/api/providers/${id}`)
},
create(input: CreateProviderInput) {
return api.post<ProviderResponse>('/api/providers', input)
},
update(id: string, input: UpdateProviderInput) {
return api.put<ProviderResponse>(`/api/providers/${id}`, input)
},
delete(id: string) {
return api.delete<{ ok: true }>(`/api/providers/${id}`)
},
activate(id: string, modelId: string) {
return api.post<{ ok: true }>(`/api/providers/${id}/activate`, { modelId })
},
test(id: string) {
return api.post<TestResultResponse>(`/api/providers/${id}/test`)
},
testConfig(input: TestProviderConfigInput) {
return api.post<TestResultResponse>('/api/providers/test', input)
},
}

View File

@ -4,6 +4,7 @@ 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'
@ -20,6 +21,10 @@ export function ContentRouter() {
return <ScheduledTasks />
}
if (activeView === 'tasks') {
return <Tasks />
}
if (activeView === 'terminal') {
return <TerminalPlaceholder />
}

View File

@ -86,6 +86,13 @@ export function Sidebar() {
>
Scheduled
</NavItem>
<NavItem
active={activeView === 'tasks'}
onClick={() => setActiveView('tasks')}
icon={<TaskIcon />}
>
Tasks
</NavItem>
</div>
{/* Project filter */}
@ -280,3 +287,12 @@ function ClockIcon() {
</svg>
)
}
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

@ -1,5 +1,6 @@
import { useState } from 'react'
import { useTaskStore } from '../../stores/taskStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { Modal } from '../shared/Modal'
import { Input } from '../shared/Input'
import { Textarea } from '../shared/Textarea'
@ -13,17 +14,31 @@ type Props = {
const FREQUENCY_OPTIONS = [
{ value: '0 * * * *', label: 'Hourly' },
{ value: '0 9 * * *', label: 'Daily' },
{ value: '0 9 * * 1-5', label: 'Weekdays' },
{ value: '0 9 * * 1', label: 'Weekly' },
{ value: '0 9 1 * *', label: 'Monthly' },
]
const PERMISSION_OPTIONS = [
{ value: '', label: 'Default' },
{ value: 'ask', label: 'Ask permissions' },
{ value: 'auto-accept', label: 'Auto accept edits' },
{ value: 'plan', label: 'Plan mode' },
{ value: 'bypass', label: 'Bypass permissions' },
]
export function NewTaskModal({ open, onClose }: Props) {
const { createTask } = useTaskStore()
const availableModels = useSettingsStore((s) => s.availableModels)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [prompt, setPrompt] = useState('')
const [cron, setCron] = useState('0 9 * * *')
const [model, setModel] = useState('')
const [permissionMode, setPermissionMode] = useState('')
const [folderPath, setFolderPath] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const canSubmit = name.trim() && prompt.trim()
@ -38,12 +53,19 @@ export function NewTaskModal({ open, onClose }: Props) {
prompt: prompt.trim(),
enabled: true,
recurring: true,
model: model || undefined,
permissionMode: permissionMode || undefined,
folderPath: folderPath.trim() || undefined,
})
// Reset form
setName('')
setDescription('')
setPrompt('')
setCron('0 9 * * *')
setModel('')
setPermissionMode('')
setFolderPath('')
setShowAdvanced(false)
onClose()
} catch (err) {
console.error('Failed to create task:', err)
@ -52,6 +74,8 @@ export function NewTaskModal({ open, onClose }: Props) {
}
}
const selectClass = 'h-10 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]'
return (
<Modal
open={open}
@ -99,17 +123,62 @@ export function NewTaskModal({ open, onClose }: Props) {
{/* Frequency */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">Frequency</label>
<select
value={cron}
onChange={(e) => setCron(e.target.value)}
className="h-10 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
>
<select value={cron} onChange={(e) => setCron(e.target.value)} className={selectClass}>
{FREQUENCY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Advanced toggle */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors self-start"
>
<span
className="material-symbols-outlined text-[16px] transition-transform"
style={{ transform: showAdvanced ? 'rotate(90deg)' : 'rotate(0deg)' }}
>
chevron_right
</span>
Advanced options
</button>
{showAdvanced && (
<div className="flex flex-col gap-4 pl-2 border-l-2 border-[var(--color-border)]">
{/* Model */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">Model</label>
<select value={model} onChange={(e) => setModel(e.target.value)} className={selectClass}>
<option value="">Default (current model)</option>
{availableModels.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
<p className="text-[10px] text-[var(--color-text-tertiary)]">Override the model used for this task.</p>
</div>
{/* Permission Mode */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">Permission mode</label>
<select value={permissionMode} onChange={(e) => setPermissionMode(e.target.value)} className={selectClass}>
{PERMISSION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Folder Path */}
<Input
label="Working directory"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="/path/to/project"
/>
</div>
)}
<p className="text-xs text-[var(--color-text-tertiary)]">
Scheduled tasks use a randomized delay of up to 5 minutes to avoid rate limits.
</p>

View File

@ -1,13 +1,17 @@
import { useState, useEffect } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useProviderStore } from '../stores/providerStore'
import { useUIStore } from '../stores/uiStore'
import { settingsApi } from '../api/settings'
import { Modal } from '../components/shared/Modal'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import type { PermissionMode, EffortLevel, ModelInfo } from '../types/settings'
import type { Provider, ProviderModel, UpdateProviderInput, ProviderTestResult } from '../types/provider'
type SettingsTab = 'model' | 'permissions' | 'general'
type SettingsTab = 'providers' | 'model' | 'permissions' | 'general'
export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('model')
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
const setActiveView = useUIStore((s) => s.setActiveView)
return (
@ -26,28 +30,15 @@ export function Settings() {
<div className="flex-1 flex overflow-hidden">
{/* Tab navigation */}
<div className="w-48 border-r border-[var(--color-border)] py-3 flex-shrink-0">
<TabButton
icon="auto_awesome"
label="Model"
active={activeTab === 'model'}
onClick={() => setActiveTab('model')}
/>
<TabButton
icon="shield"
label="Permissions"
active={activeTab === 'permissions'}
onClick={() => setActiveTab('permissions')}
/>
<TabButton
icon="tune"
label="General"
active={activeTab === 'general'}
onClick={() => setActiveTab('general')}
/>
<TabButton icon="dns" label="Providers" active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
<TabButton icon="auto_awesome" label="Model" active={activeTab === 'model'} onClick={() => setActiveTab('model')} />
<TabButton icon="shield" label="Permissions" active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
<TabButton icon="tune" label="General" active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto px-8 py-6">
{activeTab === 'providers' && <ProviderSettings />}
{activeTab === 'model' && <ModelSettings />}
{activeTab === 'permissions' && <PermissionSettings />}
{activeTab === 'general' && <GeneralSettings />}
@ -73,10 +64,336 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri
)
}
// ─── Model Settings ──────────────────────────────────────────
// ─── Provider Settings ──────────────────────────────────────
function ProviderSettings() {
const { providers, isLoading, fetchProviders, deleteProvider, activateProvider, testProvider } = useProviderStore()
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const [editingProvider, setEditingProvider] = useState<Provider | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [activatingProvider, setActivatingProvider] = useState<Provider | null>(null)
const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
useEffect(() => { fetchProviders() }, [fetchProviders])
const handleDelete = async (provider: Provider) => {
if (provider.isActive) return
if (!window.confirm(`Delete provider "${provider.name}"? This cannot be undone.`)) return
try {
await deleteProvider(provider.id)
} catch (err) {
console.error('Failed to delete provider:', err)
}
}
const handleTest = async (provider: Provider) => {
setTestResults((r) => ({ ...r, [provider.id]: { loading: true } }))
try {
const result = await testProvider(provider.id)
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result } }))
} catch {
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { success: false, latencyMs: 0, error: 'Request failed' } } }))
}
}
const handleActivate = async (providerId: string, modelId: string) => {
await activateProvider(providerId, modelId)
await fetchSettings()
setActivatingProvider(null)
}
return (
<div className="max-w-2xl">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">Providers</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">Manage API providers for model access.</p>
</div>
<Button size="sm" onClick={() => setShowCreateModal(true)}>
<span className="material-symbols-outlined text-[16px]">add</span>
Add Provider
</Button>
</div>
{isLoading && providers.length === 0 ? (
<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>
) : providers.length === 0 ? (
<div className="text-center py-12 border border-dashed border-[var(--color-border)] rounded-xl">
<span className="material-symbols-outlined text-[36px] text-[var(--color-text-tertiary)] mb-2 block">dns</span>
<p className="text-sm text-[var(--color-text-secondary)] mb-3">No providers configured</p>
<Button size="sm" onClick={() => setShowCreateModal(true)}>Add your first provider</Button>
</div>
) : (
<div className="flex flex-col gap-2">
{providers.map((provider) => {
const test = testResults[provider.id]
return (
<div
key={provider.id}
className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all group ${
provider.isActive
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
}`}
>
{/* Status dot */}
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${provider.isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{provider.name}</span>
{provider.isActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">ACTIVE</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
{provider.baseUrl} &middot; {provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
</div>
{/* Test result inline */}
{test && !test.loading && test.result && (
<div className={`text-xs mt-1 ${test.result.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
{test.result.success ? `Connected (${test.result.latencyMs}ms)` : `Failed: ${test.result.error}`}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{!provider.isActive && (
<Button variant="ghost" size="sm" onClick={() => setActivatingProvider(provider)}>Activate</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>
Test
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>Edit</Button>
{!provider.isActive && (
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">
Delete
</Button>
)}
</div>
</div>
)
})}
</div>
)}
{/* Create Modal */}
<ProviderFormModal
open={showCreateModal}
onClose={() => setShowCreateModal(false)}
mode="create"
/>
{/* Edit Modal */}
{editingProvider && (
<ProviderFormModal
key={editingProvider.id}
open={true}
onClose={() => setEditingProvider(null)}
mode="edit"
provider={editingProvider}
/>
)}
{/* Activate — model picker */}
{activatingProvider && (
<Modal open={true} onClose={() => setActivatingProvider(null)} title={`Activate ${activatingProvider.name}`} width={420}>
<p className="text-sm text-[var(--color-text-secondary)] mb-3">Select a model to use with this provider:</p>
<div className="flex flex-col gap-2">
{activatingProvider.models.map((m) => (
<button
key={m.id}
onClick={() => handleActivate(activatingProvider.id, m.id)}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] text-left transition-colors"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">smart_toy</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">{m.name}</div>
{m.description && <div className="text-xs text-[var(--color-text-tertiary)]">{m.description}{m.context ? ` · ${m.context}` : ''}</div>}
</div>
</button>
))}
</div>
</Modal>
)}
</div>
)
}
// ─── Provider Form Modal ──────────────────────────────────────
type ProviderFormProps = {
open: boolean
onClose: () => void
mode: 'create' | 'edit'
provider?: Provider
}
function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) {
const { createProvider, updateProvider, testConfig } = useProviderStore()
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const [name, setName] = useState(provider?.name ?? '')
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? '')
const [apiKey, setApiKey] = useState('')
const [notes, setNotes] = useState(provider?.notes ?? '')
const [models, setModels] = useState<ProviderModel[]>(provider?.models ?? [{ id: '', name: '', description: '', context: '' }])
const [isSubmitting, setIsSubmitting] = useState(false)
const [testResult, setTestResult] = useState<ProviderTestResult | null>(null)
const [isTesting, setIsTesting] = useState(false)
const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.some((m) => m.id.trim() && m.name.trim())
const handleSubmit = async () => {
if (!canSubmit) return
setIsSubmitting(true)
try {
const validModels = models.filter((m) => m.id.trim() && m.name.trim())
if (mode === 'create') {
await createProvider({
name: name.trim(),
baseUrl: baseUrl.trim(),
apiKey: apiKey.trim(),
models: validModels,
notes: notes.trim() || undefined,
})
} else if (provider) {
const input: UpdateProviderInput = {
name: name.trim(),
baseUrl: baseUrl.trim(),
models: validModels,
notes: notes.trim() || undefined,
}
if (apiKey.trim()) input.apiKey = apiKey.trim()
await updateProvider(provider.id, input)
if (provider.isActive) await fetchSettings()
}
onClose()
} catch (err) {
console.error('Failed to save provider:', err)
} finally {
setIsSubmitting(false)
}
}
const handleTest = async () => {
const firstModel = models.find((m) => m.id.trim())
if (!baseUrl.trim() || !apiKey.trim() || !firstModel) return
setIsTesting(true)
setTestResult(null)
try {
const result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: firstModel.id.trim() })
setTestResult(result)
} catch {
setTestResult({ success: false, latencyMs: 0, error: 'Request failed' })
} finally {
setIsTesting(false)
}
}
const addModel = () => setModels([...models, { id: '', name: '', description: '', context: '' }])
const removeModel = (index: number) => setModels(models.filter((_, i) => i !== index))
const updateModel = (index: number, field: keyof ProviderModel, value: string) => {
setModels(models.map((m, i) => i === index ? { ...m, [field]: value } : m))
}
return (
<Modal
open={open}
onClose={onClose}
title={mode === 'create' ? 'Add Provider' : 'Edit Provider'}
width={600}
footer={
<>
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
{mode === 'create' ? 'Add' : 'Save'}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. OpenRouter, Anthropic Official" />
<Input label="Base URL" required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.anthropic.com" />
<Input
label={mode === 'edit' ? 'API Key (leave blank to keep current)' : 'API Key'}
required={mode === 'create'}
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={mode === 'edit' ? '****' : 'sk-ant-...'}
/>
<Input label="Notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional notes..." />
{/* Models */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-[var(--color-text-primary)]">
Models <span className="text-[var(--color-error)]">*</span>
</label>
<button onClick={addModel} className="text-xs text-[var(--color-brand)] hover:underline">+ Add model</button>
</div>
{models.map((m, i) => (
<div key={i} className="flex gap-2 items-start">
<div className="flex-1 grid grid-cols-2 gap-2">
<input
value={m.id}
onChange={(e) => updateModel(i, 'id', e.target.value)}
placeholder="Model ID *"
className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
/>
<input
value={m.name}
onChange={(e) => updateModel(i, 'name', e.target.value)}
placeholder="Display name *"
className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
/>
<input
value={m.description ?? ''}
onChange={(e) => updateModel(i, 'description', e.target.value)}
placeholder="Description"
className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
/>
<input
value={m.context ?? ''}
onChange={(e) => updateModel(i, 'context', e.target.value)}
placeholder="Context (e.g. 200k)"
className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
/>
</div>
{models.length > 1 && (
<button onClick={() => removeModel(i)} className="mt-1 p-1 text-[var(--color-text-tertiary)] hover:text-[var(--color-error)] transition-colors">
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
)}
</div>
))}
</div>
{/* Test connection */}
<div className="flex items-center gap-3">
<Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !apiKey.trim() || !models.some((m) => m.id.trim())}>
Test Connection
</Button>
{testResult && (
<span className={`text-xs ${testResult.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
{testResult.success ? `Connected (${testResult.latencyMs}ms)` : `Failed: ${testResult.error}`}
</span>
)}
</div>
</div>
</Modal>
)
}
// ─── Model Settings ──────────────────────────────────────
function ModelSettings() {
const { availableModels, currentModel, effortLevel, setModel, setEffort, fetchAll } = useSettingsStore()
const { availableModels, currentModel, effortLevel, activeProviderName, setModel, setEffort, fetchAll } = useSettingsStore()
const [customModelId, setCustomModelId] = useState('')
const [showCustom, setShowCustom] = useState(false)
@ -117,7 +434,14 @@ function ModelSettings() {
return (
<div className="max-w-xl">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">Model</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">Choose the AI model for new conversations.</p>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
Choose the AI model for new conversations.
{activeProviderName && (
<span className="ml-1 text-[var(--color-text-secondary)]">
(via <span className="font-medium">{activeProviderName}</span>)
</span>
)}
</p>
<div className="flex flex-col gap-2 mb-6">
{availableModels.map((model) => {
@ -247,51 +571,15 @@ function PermissionSettings() {
)
}
// ─── General Settings ──────────────────────────────────────────
// ─── General Settings ──────────────────────────────────────
function GeneralSettings() {
const [apiBase, setApiBase] = useState('')
const [saved, setSaved] = useState(false)
useEffect(() => {
settingsApi.getUser().then((s) => {
setApiBase((s as any).apiBaseUrl || '')
}).catch(() => {})
}, [])
const handleSave = async () => {
const updates: Record<string, unknown> = {}
if (apiBase) updates.apiBaseUrl = apiBase
await settingsApi.updateUser(updates)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
return (
<div className="max-w-xl">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">General</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">Advanced configuration settings.</p>
<div className="flex flex-col gap-4">
<div>
<label className="block text-xs font-semibold text-[var(--color-text-secondary)] mb-1.5">API Base URL</label>
<input
type="text"
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder="https://api.anthropic.com"
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
/>
<p className="text-[10px] text-[var(--color-text-tertiary)] mt-1">Override the Anthropic API endpoint. Leave empty for default.</p>
</div>
<button
onClick={handleSave}
className="self-start px-4 py-2 text-xs font-semibold text-white bg-[var(--color-brand)] rounded-lg hover:opacity-90 transition-opacity"
>
{saved ? 'Saved!' : 'Save Changes'}
</button>
</div>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
API configuration is now managed through the <strong>Providers</strong> tab.
</p>
</div>
)
}

239
desktop/src/pages/Tasks.tsx Normal file
View File

@ -0,0 +1,239 @@
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

@ -0,0 +1,45 @@
import { create } from 'zustand'
import { cliTasksApi } from '../api/cliTasks'
import type { CLITask, TaskListSummary } from '../types/cliTask'
type CLITaskStore = {
taskLists: TaskListSummary[]
selectedListId: string | null
tasks: CLITask[]
isLoading: boolean
fetchTaskLists: () => Promise<void>
selectTaskList: (id: string) => Promise<void>
clearSelection: () => void
}
export const useCLITaskStore = create<CLITaskStore>((set) => ({
taskLists: [],
selectedListId: null,
tasks: [],
isLoading: false,
fetchTaskLists: async () => {
set({ isLoading: true })
try {
const { lists } = await cliTasksApi.listTaskLists()
set({ taskLists: lists, isLoading: false })
} catch {
set({ isLoading: false })
}
},
selectTaskList: async (id) => {
set({ selectedListId: id, isLoading: true })
try {
const { tasks } = await cliTasksApi.getTasksForList(id)
set({ tasks, isLoading: false })
} catch {
set({ tasks: [], isLoading: false })
}
},
clearSelection: () => {
set({ selectedListId: null, tasks: [] })
},
}))

View File

@ -0,0 +1,69 @@
import { create } from 'zustand'
import { providersApi } from '../api/providers'
import type {
Provider,
CreateProviderInput,
UpdateProviderInput,
TestProviderConfigInput,
ProviderTestResult,
} from '../types/provider'
type ProviderStore = {
providers: Provider[]
isLoading: boolean
fetchProviders: () => Promise<void>
createProvider: (input: CreateProviderInput) => Promise<Provider>
updateProvider: (id: string, input: UpdateProviderInput) => Promise<Provider>
deleteProvider: (id: string) => Promise<void>
activateProvider: (id: string, modelId: string) => Promise<void>
testProvider: (id: string) => Promise<ProviderTestResult>
testConfig: (input: TestProviderConfigInput) => Promise<ProviderTestResult>
}
export const useProviderStore = create<ProviderStore>((set, get) => ({
providers: [],
isLoading: false,
fetchProviders: async () => {
set({ isLoading: true })
try {
const { providers } = await providersApi.list()
set({ providers, isLoading: false })
} catch {
set({ isLoading: false })
}
},
createProvider: async (input) => {
const { provider } = await providersApi.create(input)
await get().fetchProviders()
return provider
},
updateProvider: async (id, input) => {
const { provider } = await providersApi.update(id, input)
await get().fetchProviders()
return provider
},
deleteProvider: async (id) => {
await providersApi.delete(id)
await get().fetchProviders()
},
activateProvider: async (id, modelId) => {
await providersApi.activate(id, modelId)
await get().fetchProviders()
},
testProvider: async (id) => {
const { result } = await providersApi.test(id)
return result
},
testConfig: async (input) => {
const { result } = await providersApi.testConfig(input)
return result
},
}))

View File

@ -8,6 +8,7 @@ type SettingsStore = {
currentModel: ModelInfo | null
effortLevel: EffortLevel
availableModels: ModelInfo[]
activeProviderName: string | null
isLoading: boolean
fetchAll: () => Promise<void>
@ -21,18 +22,26 @@ export const useSettingsStore = create<SettingsStore>((set) => ({
currentModel: null,
effortLevel: 'high',
availableModels: [],
activeProviderName: null,
isLoading: false,
fetchAll: async () => {
set({ isLoading: true })
try {
const [{ mode }, { models }, { model }, { level }] = await Promise.all([
const [{ mode }, modelsRes, { model }, { level }] = await Promise.all([
settingsApi.getPermissionMode(),
modelsApi.list(),
modelsApi.getCurrent(),
modelsApi.getEffort(),
])
set({ permissionMode: mode, availableModels: models, currentModel: model, effortLevel: level, isLoading: false })
set({
permissionMode: mode,
availableModels: modelsRes.models,
activeProviderName: modelsRes.provider?.name ?? null,
currentModel: model,
effortLevel: level,
isLoading: false,
})
} catch {
set({ isLoading: false })
}

View File

@ -7,7 +7,7 @@ export type Toast = {
duration?: number
}
type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings'
type ActiveView = 'code' | 'scheduled' | 'tasks' | 'terminal' | 'history' | 'settings'
type UIStore = {
theme: 'light' | 'dark'

View File

@ -0,0 +1,24 @@
// Source: src/server/services/taskService.ts (CLI V2 Tasks)
export type TaskStatus = 'pending' | 'in_progress' | 'completed'
export type CLITask = {
id: string
subject: string
description: string
activeForm?: string
owner?: string
status: TaskStatus
blocks: string[]
blockedBy: string[]
metadata?: Record<string, unknown>
taskListId: string
}
export type TaskListSummary = {
id: string
taskCount: number
completedCount: number
inProgressCount: number
pendingCount: number
}

View File

@ -0,0 +1,50 @@
// Source: src/server/types/provider.ts
export type ProviderModel = {
id: string
name: string
description?: string
context?: string
}
export type Provider = {
id: string
name: string
baseUrl: string
apiKey: string // masked from server: "sk-a****xyz"
models: ProviderModel[]
isActive: boolean
createdAt: number
updatedAt: number
notes?: string
}
export type CreateProviderInput = {
name: string
baseUrl: string
apiKey: string
models: ProviderModel[]
notes?: string
}
export type UpdateProviderInput = {
name?: string
baseUrl?: string
apiKey?: string
models?: ProviderModel[]
notes?: string
}
export type TestProviderConfigInput = {
baseUrl: string
apiKey: string
modelId: string
}
export type ProviderTestResult = {
success: boolean
latencyMs: number
error?: string
modelUsed?: string
httpStatus?: number
}

View File

@ -8,7 +8,7 @@ export type ModelInfo = {
id: string
name: string
description: string
context: number
context: string
}
export type UserSettings = {

View File

@ -98,6 +98,11 @@ async function handleAgents(
}
// ─── Tasks API ─────────────────────────────────────────────────────────────
//
// GET /api/tasks → list all tasks (across all task lists)
// GET /api/tasks/lists → list all task lists with summaries
// GET /api/tasks/lists/:taskListId → get all tasks for a specific task list
// GET /api/tasks/lists/:taskListId/:id → get a single task
async function handleTasksApi(
req: Request,
@ -111,18 +116,32 @@ async function handleTasksApi(
)
}
const taskId = segments[2]
const sub = segments[2] // 'lists' or undefined
if (taskId) {
// GET /api/tasks/:id
const task = await taskService.getTask(taskId)
if (!task) {
throw ApiError.notFound(`Task not found: ${taskId}`)
// GET /api/tasks/lists — list all task lists
if (sub === 'lists') {
const taskListId = segments[3]
const taskId = segments[4]
if (taskListId && taskId) {
// GET /api/tasks/lists/:taskListId/:taskId
const task = await taskService.getTask(taskListId, taskId)
if (!task) throw ApiError.notFound(`Task not found: ${taskListId}/${taskId}`)
return Response.json({ task })
}
return Response.json({ task })
if (taskListId) {
// GET /api/tasks/lists/:taskListId
const tasks = await taskService.getTasksForList(taskListId)
return Response.json({ tasks })
}
// GET /api/tasks/lists
const lists = await taskService.listTaskLists()
return Response.json({ lists })
}
// GET /api/tasks
// GET /api/tasks — list all tasks
const tasks = await taskService.listTasks()
return Response.json({ tasks })
}

View File

@ -1,25 +1,35 @@
/**
* TaskService
* TaskService CLI Task V2
*
* ~/.claude/tasks/ JSON
* team/project
* ~/.claude/tasks/<task_list_id>/ JSON
* Task list ID session IDteam name
*/
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
export type TaskStatus = 'pending' | 'in_progress' | 'completed'
export type TaskInfo = {
id: string
type: 'local_shell' | 'local_agent' | 'in_process_teammate' | 'remote_agent' | 'workflow'
status: 'running' | 'completed' | 'failed' | 'pending'
name?: string
description?: string
createdAt?: number
completedAt?: number
teamName?: string
agentName?: string
subject: string
description: string
activeForm?: string
owner?: string
status: TaskStatus
blocks: string[]
blockedBy: string[]
metadata?: Record<string, unknown>
taskListId: string
}
export type TaskListSummary = {
id: string
taskCount: number
completedCount: number
inProgressCount: number
pendingCount: number
}
export class TaskService {
@ -31,68 +41,105 @@ export class TaskService {
return path.join(this.getConfigDir(), 'tasks')
}
/** 列出所有持久化任务 */
async listTasks(): Promise<TaskInfo[]> {
/** 列出所有 task list (目录) */
async listTaskLists(): Promise<TaskListSummary[]> {
const tasksDir = this.getTasksDir()
try {
const result: TaskInfo[] = []
await this.scanTasksRecursive(tasksDir, result)
return result.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
const entries = await fs.readdir(tasksDir, { withFileTypes: true })
const results: TaskListSummary[] = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
const tasks = await this.getTasksForList(entry.name)
if (tasks.length === 0) continue
results.push({
id: entry.name,
taskCount: tasks.length,
completedCount: tasks.filter((t) => t.status === 'completed').length,
inProgressCount: tasks.filter((t) => t.status === 'in_progress').length,
pendingCount: tasks.filter((t) => t.status === 'pending').length,
})
}
return results
} catch (err: any) {
if (err.code === 'ENOENT') return []
throw err
}
}
/** 递归扫描任务目录 */
private async scanTasksRecursive(dir: string, result: TaskInfo[]): Promise<void> {
let entries
/** 获取指定 task list 的所有任务 */
async getTasksForList(taskListId: string): Promise<TaskInfo[]> {
const listDir = path.join(this.getTasksDir(), taskListId)
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return
}
const entries = await fs.readdir(listDir)
const tasks: TaskInfo[] = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await this.scanTasksRecursive(fullPath, result)
} else if (entry.name.endsWith('.json')) {
for (const filename of entries) {
if (!filename.endsWith('.json')) continue
try {
const raw = await fs.readFile(fullPath, 'utf-8')
const raw = await fs.readFile(path.join(listDir, filename), 'utf-8')
const data = JSON.parse(raw)
const task = this.parseTaskFile(entry.name, data)
if (task) result.push(task)
const task = this.parseTaskFile(data, taskListId)
if (task) tasks.push(task)
} catch {
// 跳过无法解析的文件
// skip unparseable files
}
}
// Sort by numeric ID
return tasks.sort((a, b) => {
const numA = parseInt(a.id, 10)
const numB = parseInt(b.id, 10)
if (!isNaN(numA) && !isNaN(numB)) return numA - numB
return a.id.localeCompare(b.id)
})
} catch (err: any) {
if (err.code === 'ENOENT') return []
throw err
}
}
/** 解析单个任务文件 */
private parseTaskFile(filename: string, data: any): TaskInfo | null {
if (!data || typeof data !== 'object') return null
const id = filename.replace('.json', '')
return {
id: data.id || id,
type: data.type || 'local_agent',
status: data.status || 'completed',
name: data.name || data.title,
description: data.description || data.prompt,
createdAt: data.createdAt,
completedAt: data.completedAt,
teamName: data.teamName,
agentName: data.agentName || data.name,
metadata: data.metadata,
/** 列出所有任务(跨所有 task list */
async listTasks(): Promise<TaskInfo[]> {
const taskLists = await this.listTaskLists()
const allTasks: TaskInfo[] = []
for (const list of taskLists) {
const tasks = await this.getTasksForList(list.id)
allTasks.push(...tasks)
}
return allTasks
}
/** 获取单个任务详情 */
async getTask(taskId: string): Promise<TaskInfo | null> {
const tasks = await this.listTasks()
return tasks.find(t => t.id === taskId) || null
async getTask(taskListId: string, taskId: string): Promise<TaskInfo | null> {
const tasks = await this.getTasksForList(taskListId)
return tasks.find((t) => t.id === taskId) || null
}
/** 解析单个任务文件 — 匹配 CLI V2 Task 格式 */
private parseTaskFile(data: any, taskListId: string): TaskInfo | null {
if (!data || typeof data !== 'object') return null
if (!data.id || !data.subject) return null
// Skip internal tasks
if (data.metadata?._internal) return null
return {
id: String(data.id),
subject: data.subject || '',
description: data.description || '',
activeForm: data.activeForm,
owner: data.owner,
status: (['pending', 'in_progress', 'completed'].includes(data.status)
? data.status
: 'pending') as TaskStatus,
blocks: Array.isArray(data.blocks) ? data.blocks : [],
blockedBy: Array.isArray(data.blockedBy) ? data.blockedBy : [],
metadata: data.metadata,
taskListId,
}
}
}