From d059a8b487c95dcf77bc3f5fd1d3ce19eef3bc2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 10:05:50 +0800 Subject: [PATCH] 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) --- desktop/src/api/cliTasks.ts | 28 ++ desktop/src/api/models.ts | 2 +- desktop/src/api/providers.ts | 46 ++ .../src/components/layout/ContentRouter.tsx | 5 + desktop/src/components/layout/Sidebar.tsx | 16 + desktop/src/components/tasks/NewTaskModal.tsx | 79 +++- desktop/src/pages/Settings.tsx | 416 +++++++++++++++--- desktop/src/pages/Tasks.tsx | 239 ++++++++++ desktop/src/stores/cliTaskStore.ts | 45 ++ desktop/src/stores/providerStore.ts | 69 +++ desktop/src/stores/settingsStore.ts | 13 +- desktop/src/stores/uiStore.ts | 2 +- desktop/src/types/cliTask.ts | 24 + desktop/src/types/provider.ts | 50 +++ desktop/src/types/settings.ts | 2 +- src/server/api/agents.ts | 35 +- src/server/services/taskService.ts | 149 ++++--- 17 files changed, 1087 insertions(+), 133 deletions(-) create mode 100644 desktop/src/api/cliTasks.ts create mode 100644 desktop/src/api/providers.ts create mode 100644 desktop/src/pages/Tasks.tsx create mode 100644 desktop/src/stores/cliTaskStore.ts create mode 100644 desktop/src/stores/providerStore.ts create mode 100644 desktop/src/types/cliTask.ts create mode 100644 desktop/src/types/provider.ts diff --git a/desktop/src/api/cliTasks.ts b/desktop/src/api/cliTasks.ts new file mode 100644 index 00000000..d659e91a --- /dev/null +++ b/desktop/src/api/cliTasks.ts @@ -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('/api/tasks/lists') + }, + + /** Get all tasks for a specific task list */ + getTasksForList(taskListId: string) { + return api.get(`/api/tasks/lists/${encodeURIComponent(taskListId)}`) + }, + + /** Get a single task */ + getTask(taskListId: string, taskId: string) { + return api.get(`/api/tasks/lists/${encodeURIComponent(taskListId)}/${taskId}`) + }, + + /** List all tasks across all task lists */ + listAll() { + return api.get('/api/tasks') + }, +} diff --git a/desktop/src/api/models.ts b/desktop/src/api/models.ts index 56d80668..c3fa92b0 100644 --- a/desktop/src/api/models.ts +++ b/desktop/src/api/models.ts @@ -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[] } diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts new file mode 100644 index 00000000..c47828c9 --- /dev/null +++ b/desktop/src/api/providers.ts @@ -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('/api/providers') + }, + + get(id: string) { + return api.get(`/api/providers/${id}`) + }, + + create(input: CreateProviderInput) { + return api.post('/api/providers', input) + }, + + update(id: string, input: UpdateProviderInput) { + return api.put(`/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(`/api/providers/${id}/test`) + }, + + testConfig(input: TestProviderConfigInput) { + return api.post('/api/providers/test', input) + }, +} diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 3282d476..7e68c8a3 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -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 } + 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 37d3fdab..a402370b 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -86,6 +86,13 @@ export function Sidebar() { > Scheduled + setActiveView('tasks')} + icon={} + > + Tasks + {/* Project filter */} @@ -280,3 +287,12 @@ function ClockIcon() { ) } + +function TaskIcon() { + return ( + + + + + ) +} diff --git a/desktop/src/components/tasks/NewTaskModal.tsx b/desktop/src/components/tasks/NewTaskModal.tsx index a0fb58c9..f149a569 100644 --- a/desktop/src/components/tasks/NewTaskModal.tsx +++ b/desktop/src/components/tasks/NewTaskModal.tsx @@ -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 ( - setCron(e.target.value)} className={selectClass}> {FREQUENCY_OPTIONS.map((opt) => ( ))} + {/* Advanced toggle */} + + + {showAdvanced && ( +
+ {/* Model */} +
+ + +

Override the model used for this task.

+
+ + {/* Permission Mode */} +
+ + +
+ + {/* Folder Path */} + setFolderPath(e.target.value)} + placeholder="/path/to/project" + /> +
+ )} +

Scheduled tasks use a randomized delay of up to 5 minutes to avoid rate limits.

diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 501262c5..ae307324 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -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('model') + const [activeTab, setActiveTab] = useState('providers') const setActiveView = useUIStore((s) => s.setActiveView) return ( @@ -26,28 +30,15 @@ export function Settings() {
{/* Tab navigation */}
- setActiveTab('model')} - /> - setActiveTab('permissions')} - /> - setActiveTab('general')} - /> + setActiveTab('providers')} /> + setActiveTab('model')} /> + setActiveTab('permissions')} /> + setActiveTab('general')} />
{/* Tab content */}
+ {activeTab === 'providers' && } {activeTab === 'model' && } {activeTab === 'permissions' && } {activeTab === 'general' && } @@ -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(null) + const [showCreateModal, setShowCreateModal] = useState(false) + const [activatingProvider, setActivatingProvider] = useState(null) + const [testResults, setTestResults] = useState>({}) + + 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 ( +
+
+
+

Providers

+

Manage API providers for model access.

+
+ +
+ + {isLoading && providers.length === 0 ? ( +
+
+
+ ) : providers.length === 0 ? ( +
+ dns +

No providers configured

+ +
+ ) : ( +
+ {providers.map((provider) => { + const test = testResults[provider.id] + return ( +
+ {/* Status dot */} + + + {/* Info */} +
+
+ {provider.name} + {provider.isActive && ( + ACTIVE + )} +
+
+ {provider.baseUrl} · {provider.models.length} model{provider.models.length !== 1 ? 's' : ''} +
+ {/* Test result inline */} + {test && !test.loading && test.result && ( +
+ {test.result.success ? `Connected (${test.result.latencyMs}ms)` : `Failed: ${test.result.error}`} +
+ )} +
+ + {/* Actions */} +
+ {!provider.isActive && ( + + )} + + + {!provider.isActive && ( + + )} +
+
+ ) + })} +
+ )} + + {/* Create Modal */} + setShowCreateModal(false)} + mode="create" + /> + + {/* Edit Modal */} + {editingProvider && ( + setEditingProvider(null)} + mode="edit" + provider={editingProvider} + /> + )} + + {/* Activate — model picker */} + {activatingProvider && ( + setActivatingProvider(null)} title={`Activate ${activatingProvider.name}`} width={420}> +

Select a model to use with this provider:

+
+ {activatingProvider.models.map((m) => ( + + ))} +
+
+ )} +
+ ) +} + +// ─── 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(provider?.models ?? [{ id: '', name: '', description: '', context: '' }]) + const [isSubmitting, setIsSubmitting] = useState(false) + const [testResult, setTestResult] = useState(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 ( + + + + + } + > +
+ setName(e.target.value)} placeholder="e.g. OpenRouter, Anthropic Official" /> + setBaseUrl(e.target.value)} placeholder="https://api.anthropic.com" /> + setApiKey(e.target.value)} + placeholder={mode === 'edit' ? '****' : 'sk-ant-...'} + /> + setNotes(e.target.value)} placeholder="Optional notes..." /> + + {/* Models */} +
+
+ + +
+ {models.map((m, i) => ( +
+
+ 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)]" + /> + 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)]" + /> + 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)]" + /> + 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)]" + /> +
+ {models.length > 1 && ( + + )} +
+ ))} +
+ + {/* Test connection */} +
+ + {testResult && ( + + {testResult.success ? `Connected (${testResult.latencyMs}ms)` : `Failed: ${testResult.error}`} + + )} +
+
+
+ ) +} + +// ─── 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 (

Model

-

Choose the AI model for new conversations.

+

+ Choose the AI model for new conversations. + {activeProviderName && ( + + (via {activeProviderName}) + + )} +

{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 = {} - if (apiBase) updates.apiBaseUrl = apiBase - await settingsApi.updateUser(updates) - setSaved(true) - setTimeout(() => setSaved(false), 2000) - } - return (

General

-

Advanced configuration settings.

- -
-
- - 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)]" - /> -

Override the Anthropic API endpoint. Leave empty for default.

-
- - -
+

+ API configuration is now managed through the Providers tab. +

) } diff --git a/desktop/src/pages/Tasks.tsx b/desktop/src/pages/Tasks.tsx new file mode 100644 index 00000000..9dd19cc8 --- /dev/null +++ b/desktop/src/pages/Tasks.tsx @@ -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 ( +
+ {/* 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/cliTaskStore.ts b/desktop/src/stores/cliTaskStore.ts new file mode 100644 index 00000000..1b67fb4b --- /dev/null +++ b/desktop/src/stores/cliTaskStore.ts @@ -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 + selectTaskList: (id: string) => Promise + clearSelection: () => void +} + +export const useCLITaskStore = create((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: [] }) + }, +})) diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts new file mode 100644 index 00000000..2adc5fe7 --- /dev/null +++ b/desktop/src/stores/providerStore.ts @@ -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 + createProvider: (input: CreateProviderInput) => Promise + updateProvider: (id: string, input: UpdateProviderInput) => Promise + deleteProvider: (id: string) => Promise + activateProvider: (id: string, modelId: string) => Promise + testProvider: (id: string) => Promise + testConfig: (input: TestProviderConfigInput) => Promise +} + +export const useProviderStore = create((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 + }, +})) diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 3c902da8..436a2264 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -8,6 +8,7 @@ type SettingsStore = { currentModel: ModelInfo | null effortLevel: EffortLevel availableModels: ModelInfo[] + activeProviderName: string | null isLoading: boolean fetchAll: () => Promise @@ -21,18 +22,26 @@ export const useSettingsStore = create((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 }) } diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts index bfd43982..a25820ee 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' | 'terminal' | 'history' | 'settings' +type ActiveView = 'code' | 'scheduled' | 'tasks' | 'terminal' | 'history' | 'settings' type UIStore = { theme: 'light' | 'dark' diff --git a/desktop/src/types/cliTask.ts b/desktop/src/types/cliTask.ts new file mode 100644 index 00000000..44b3c160 --- /dev/null +++ b/desktop/src/types/cliTask.ts @@ -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 + taskListId: string +} + +export type TaskListSummary = { + id: string + taskCount: number + completedCount: number + inProgressCount: number + pendingCount: number +} diff --git a/desktop/src/types/provider.ts b/desktop/src/types/provider.ts new file mode 100644 index 00000000..9929b958 --- /dev/null +++ b/desktop/src/types/provider.ts @@ -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 +} diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 89907f53..24f741fc 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -8,7 +8,7 @@ export type ModelInfo = { id: string name: string description: string - context: number + context: string } export type UserSettings = { diff --git a/src/server/api/agents.ts b/src/server/api/agents.ts index 1916f1b2..fc8142fe 100644 --- a/src/server/api/agents.ts +++ b/src/server/api/agents.ts @@ -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 }) } diff --git a/src/server/services/taskService.ts b/src/server/services/taskService.ts index ccfbd696..064f7a16 100644 --- a/src/server/services/taskService.ts +++ b/src/server/services/taskService.ts @@ -1,25 +1,35 @@ /** - * TaskService — 后台任务的读取与查询 + * TaskService — CLI Task V2 的读取与查询 * - * 任务信息存储在 ~/.claude/tasks/ 目录下,每个任务一个 JSON 文件。 - * 支持嵌套子目录(例如按 team/project 分组)。 + * 任务信息存储在 ~/.claude/tasks// 目录下,每个任务一个 JSON 文件。 + * Task list ID 可以是 session ID、team 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 + 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 { + /** 列出所有 task list (目录) */ + async listTaskLists(): Promise { 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 { - let entries + /** 获取指定 task list 的所有任务 */ + async getTasksForList(taskListId: string): Promise { + 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 { + 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 { - const tasks = await this.listTasks() - return tasks.find(t => t.id === taskId) || null + async getTask(taskListId: string, taskId: string): Promise { + 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, + } } }