From 779ec912b301d09e0ba4602bd9e7ef5ac105de68 Mon Sep 17 00:00:00 2001 From: idevlab Date: Fri, 15 May 2026 00:38:51 +0800 Subject: [PATCH] Add task-first board workflow --- README.md | 12 +- electron/main/conversation-engine.ts | 27 + electron/main/index.ts | 8 +- electron/main/workspace-store.ts | 14 +- electron/preload/index.ts | 3 + src/renderer/App.tsx | 221 ++++--- src/renderer/features/task-workbench.test.tsx | 147 +++++ src/renderer/features/task-workbench.tsx | 183 ++++++ .../features/workspace-board.test.tsx | 210 +++++++ src/renderer/features/workspace-board.tsx | 586 ++++++++++++++++++ .../features/workspace-resources.test.tsx | 68 +- src/renderer/features/workspace-resources.tsx | 168 +++-- src/renderer/lib/api.ts | 4 + src/renderer/vite-env.d.ts | 2 + src/shared/schemas.ts | 12 + src/shared/types.ts | 17 +- src/shared/workspace-store.test.ts | 109 ++++ 17 files changed, 1627 insertions(+), 164 deletions(-) create mode 100644 src/renderer/features/task-workbench.test.tsx create mode 100644 src/renderer/features/task-workbench.tsx create mode 100644 src/renderer/features/workspace-board.test.tsx create mode 100644 src/renderer/features/workspace-board.tsx diff --git a/README.md b/README.md index bb8c60a..bbecd30 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # F5 -F5 is a local AI workspace for talking with coding agents, tracking their work, and keeping every conversation as Markdown files on disk. +F5 is a local AI workspace for assigning tasks, tracking AI and human work, and keeping chats and docs as Markdown files on disk. ![F5 workspace](docs/assets/f5-workspace.png) ## What It Does -- Multi-conversation workspace with a searchable conversation list. -- Markdown-backed conversation storage under the local workspace folder. -- Workspace-level TODO lists backed by local Markdown files, with task Agent assignment. -- Workspace-level Markdown documents with automatic save, edit, preview, selected-text comments, Agent handoff, and preview highlights. +- Task-first workspace with a Board for AI and human assignments. +- Markdown-backed task, chat, and document storage under the local workspace folder. +- Workspace-level TODO lists backed by local Markdown files, with AI and human assignment. +- Task-bound Markdown documents with automatic save, edit, preview, selected-text comments, Agent handoff, and preview highlights. - Real Codex CLI agent integration with queued prompts and visible agent progress. - Agent side panel for plan steps, tool activity, session details, and raw logs. - Conversation actions for star, rename, archive, delete, export, and showing file location. -- Profile, agent, workspace overview, theme switching, and macOS menu support. +- Profile, agent, Board, Docs, theme switching, and macOS menu support. ## Stack diff --git a/electron/main/conversation-engine.ts b/electron/main/conversation-engine.ts index 44c7290..6927113 100644 --- a/electron/main/conversation-engine.ts +++ b/electron/main/conversation-engine.ts @@ -10,6 +10,7 @@ import { createTaskListInputSchema, createDocumentInputSchema, createConversationInputSchema, + createTaskConversationInputSchema, createTaskInputSchema, deleteDocumentCommentInputSchema, deleteTaskListInputSchema, @@ -33,6 +34,7 @@ import type { CreateDocumentCommentInput, CreateConversationInput, CreateDocumentInput, + CreateTaskConversationInput, CreateTaskListInput, CreateTaskInput, DeleteDocumentCommentInput, @@ -53,6 +55,7 @@ import type { UpdateTaskInput, WorkspaceSnapshot, } from '../../src/shared/types'; +import { HUMAN_ASSIGNEE_ID } from '../../src/shared/types'; import { AcpStdioClient } from './acp-client'; import { makeLocalId, nowIso, WorkspaceStore } from './workspace-store'; @@ -97,6 +100,30 @@ export class ConversationEngine { return this.emitSnapshot(conversation.conversation.id); } + async createTaskConversation(input: CreateTaskConversationInput): Promise { + const parsed = createTaskConversationInputSchema.parse(input); + const task = await this.store.createTask({ + title: parsed.title, + body: parsed.body, + agentId: parsed.agentId, + taskListId: parsed.taskListId, + }); + const defaultAgent = await this.store.getDefaultAgent(); + const agentId = task.agentId === HUMAN_ASSIGNEE_ID ? defaultAgent.id : task.agentId; + const conversation = await this.store.createConversation({ + title: task.title, + agentId, + taskId: task.id, + }); + if (parsed.firstPrompt?.trim()) { + await this.sendMessage({ + conversationId: conversation.conversation.id, + content: parsed.firstPrompt, + }); + } + return this.emitSnapshot(conversation.conversation.id); + } + async openConversation(conversationId: string): Promise { return this.store.openConversation(conversationId); } diff --git a/electron/main/index.ts b/electron/main/index.ts index 493826d..5e02ec8 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -279,13 +279,13 @@ function buildHelpDataUrl(iconDataUrl: string): string { ${iconDataUrl ? `${APP_DISPLAY_NAME}` : ''}

${APP_DISPLAY_NAME} Help

-

Local AI workspace with Markdown conversations.

+

Local AI workspace with task-bound chats and Markdown docs.

Common Actions

-
New conversation
Use the plus button in the top bar or conversation list.
+
New task
Use the plus button in the top bar or chat history.
Conversation files
Use “Show file location” from the conversation menu.
Workspace folder
Use Help > Show Workspace Folder.
@@ -316,6 +316,10 @@ ipcMain.handle('conversation:create', async (_event, input) => { return engine.createConversation(input); }); +ipcMain.handle('task-conversation:create', async (_event, input) => { + return engine.createTaskConversation(input); +}); + ipcMain.handle('conversation:open', async (_event, conversationId: string) => { return engine.openConversation(conversationId); }); diff --git a/electron/main/workspace-store.ts b/electron/main/workspace-store.ts index 208a433..e6d8dad 100644 --- a/electron/main/workspace-store.ts +++ b/electron/main/workspace-store.ts @@ -372,12 +372,13 @@ export class WorkspaceStore { const list = input.taskListId ? await this.readTaskList(input.taskListId) : await this.ensureDefaultTaskList(); - const agent = input.agentId ? await this.getAgent(input.agentId) : await this.getDefaultAgent(); + const defaultAgent = await this.getDefaultAgent(); + const assigneeId = input.agentId?.trim() || defaultAgent.id; const task: TaskRecord = { schema: 'f5.task.v1', id: makeLocalId('task'), listId: list.id, - agentId: agent.id, + agentId: assigneeId, title: input.title.trim(), status: 'todo', createdAt: timestamp, @@ -396,10 +397,10 @@ export class WorkspaceStore { const current = await this.readTask(input.taskId); const timestamp = nowIso(); const completedAt = input.status === 'done' ? current.completedAt || timestamp : ''; - const agent = input.agentId ? await this.getAgent(input.agentId) : undefined; + const assigneeId = input.agentId?.trim() || current.agentId; const next: TaskRecord = { ...current, - agentId: agent?.id ?? current.agentId, + agentId: assigneeId, title: input.title.trim(), body: input.body.trimEnd(), status: input.status, @@ -661,6 +662,7 @@ export class WorkspaceStore { const document: DocumentRecord = { schema: 'f5.document.v1', id: makeLocalId('doc'), + taskId: input.taskId ?? '', title: input.title?.trim() || 'Untitled document', createdAt: timestamp, updatedAt: timestamp, @@ -856,6 +858,7 @@ export class WorkspaceStore { return { schema: document.schema, id: document.id, + taskId: document.taskId, title: document.title, createdAt: document.createdAt, updatedAt: document.updatedAt, @@ -866,6 +869,7 @@ export class WorkspaceStore { return { schema: 'f5.document.v1', id: documentIdSchema.parse(id), + taskId: '', title: basename(id), createdAt: timestamp, updatedAt: timestamp, @@ -883,6 +887,7 @@ export class WorkspaceStore { const meta: ConversationMeta = { schema: 'f5.conversation.v1', id, + taskId: input.taskId ?? '', title, agentId: agent.id, status: 'active', @@ -994,6 +999,7 @@ export class WorkspaceStore { return { schema: 'f5.conversation.v1' as const, id, + taskId: '', title: basename(id), agentId: defaultAgent.id, status: 'needs_repair' as const, diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 6aaea39..c56b90e 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -5,6 +5,7 @@ import type { CreateConversationInput, CreateDocumentCommentInput, CreateDocumentInput, + CreateTaskConversationInput, CreateTaskListInput, CreateTaskInput, DeleteConversationInput, @@ -31,6 +32,8 @@ contextBridge.exposeInMainWorld('f5', { ipcRenderer.invoke('workspace:initialize', activeConversationId), createConversation: (input: CreateConversationInput): Promise => ipcRenderer.invoke('conversation:create', input), + createTaskConversation: (input: CreateTaskConversationInput): Promise => + ipcRenderer.invoke('task-conversation:create', input), openConversation: (conversationId: string) => ipcRenderer.invoke('conversation:open', conversationId), sendMessage: (input: SendMessageInput): Promise => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 11daf74..27e47bc 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -71,9 +71,12 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import f5LogoDarkUrl from '../../resources/icon-dark.png'; import f5LogoUrl from '../../resources/icon.png'; import { fallbackSnapshot } from '@/data/fallback'; +import { TaskWorkbenchPage } from '@/features/task-workbench'; +import { WorkspaceBoardPage } from '@/features/workspace-board'; import { DocumentsPage, TasksPage } from '@/features/workspace-resources'; import { f5Api } from '@/lib/api'; import { cn } from '@/lib/utils'; +import { HUMAN_ASSIGNEE_ID } from '../shared/types'; import type { AgentConfig, AgentConnectionTestResult, @@ -81,6 +84,7 @@ import type { ConversationListItem, CreateDocumentCommentInput, CreateDocumentInput, + CreateTaskConversationInput, CreateTaskListInput, CreateTaskInput, DeleteDocumentCommentInput, @@ -122,6 +126,7 @@ function WorkspaceApp(): React.JSX.Element { const [query, setQuery] = useState(''); const [draft, setDraft] = useState(''); const [view, setView] = useState('workspace'); + const [activeTaskId, setActiveTaskId] = useState(''); const [newOpen, setNewOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); @@ -230,12 +235,14 @@ function WorkspaceApp(): React.JSX.Element { if (!normalized) return snapshot.tasks; return snapshot.tasks.filter((task) => `${task.title} ${task.body} ${ - snapshot.agents.find((agent) => agent.id === task.agentId)?.name ?? task.agentId + task.agentId === HUMAN_ASSIGNEE_ID + ? snapshot.profile.displayName + : (snapshot.agents.find((agent) => agent.id === task.agentId)?.name ?? task.agentId) }` .toLowerCase() .includes(normalized), ); - }, [query, snapshot.agents, snapshot.tasks]); + }, [query, snapshot.agents, snapshot.profile.displayName, snapshot.tasks]); const filteredDocuments = useMemo(() => { const normalized = query.trim().toLowerCase(); if (!normalized) return snapshot.documents; @@ -349,6 +356,50 @@ function WorkspaceApp(): React.JSX.Element { await updateSnapshot(f5Api.deleteDocumentComment(input)); } + async function openConversation(conversationId: string): Promise { + const next = await updateSnapshot(f5Api.initializeWorkspace(conversationId)); + if (next) setView('workspace'); + } + + function openTask(taskId: string): void { + setActiveTaskId(taskId); + setView('task-workbench'); + } + + async function createTaskConversation(input: CreateTaskConversationInput): Promise { + const next = await updateSnapshot(f5Api.createTaskConversation(input)); + const taskId = next?.activeConversation?.conversation.taskId; + if (taskId) setActiveTaskId(taskId); + if (next) setView('workspace'); + } + + async function startTaskChat(taskId: string): Promise { + const task = snapshot.tasks.find((item) => item.id === taskId); + if (!task) return; + const agentId = + task.agentId === HUMAN_ASSIGNEE_ID ? snapshot.profile.defaultAgentId : task.agentId; + const next = await updateSnapshot( + f5Api.createConversation({ + title: task.title, + agentId, + taskId: task.id, + }), + ); + if (next) setView('workspace'); + } + + async function createTaskDocument(taskId: string): Promise { + const task = snapshot.tasks.find((item) => item.id === taskId); + if (!task) return; + await createDocument({ + title: task.title, + body: `# ${task.title}\n`, + taskId: task.id, + }); + setActiveTaskId(task.id); + setView('task-workbench'); + } + async function sendPrompt(): Promise { if (!active || !draft.trim()) return; const content = draft.trim(); @@ -385,7 +436,7 @@ function WorkspaceApp(): React.JSX.Element { persistThemePreference(resolvedTheme === 'dark' ? 'light' : 'dark') } onBack={() => setView('workspace')} - onForward={() => setView(active ? 'agent-profile' : 'overview')} + onForward={() => setView(active ? 'agent-profile' : 'agents')} /> {error ? ( - void updateSnapshot(f5Api.initializeWorkspace(conversationId)) - } + onOpen={(conversationId) => void openConversation(conversationId)} onNew={() => setNewOpen(true)} agents={snapshot.agents} defaultAgentId={snapshot.profile.defaultAgentId} @@ -421,7 +470,7 @@ function WorkspaceApp(): React.JSX.Element { ).length } onToggleArchived={() => setShowArchived((value) => !value)} - onQuickCreate={(input) => void updateSnapshot(f5Api.createConversation(input))} + onQuickCreate={(input) => void createTaskConversation(input)} /> ) : null} @@ -429,6 +478,7 @@ function WorkspaceApp(): React.JSX.Element { view={view} snapshot={snapshot} active={active} + activeTaskId={activeTaskId} taskLists={snapshot.taskLists} tasks={filteredTasks} documents={filteredDocuments} @@ -477,13 +527,18 @@ function WorkspaceApp(): React.JSX.Element { active && void updateSnapshot(f5Api.cancelActive(active.conversation.id)) } onAgentProfile={() => setView('agent-profile')} + onOpenTasks={() => setView('tasks')} + onOpenTask={openTask} onUserProfile={() => setView('user-profile')} - onBack={() => setView('workspace')} + onBack={() => setView(view === 'task-workbench' ? 'board' : 'workspace')} onTogglePanel={() => setPanelOpen((value) => !value)} onProfileSave={(input) => void updateSnapshot(f5Api.updateProfile(input))} onThemePreview={setThemePreference} onIconThemePreview={setIconThemePreference} iconPreviewUrl={currentLogoUrl} + onOpenTaskConversation={(conversationId) => void openConversation(conversationId)} + onStartTaskChat={(taskId) => void startTaskChat(taskId)} + onCreateTaskDocument={(taskId) => void createTaskDocument(taskId)} onCreateTask={createTask} onUpdateTask={updateTask} onDeleteTask={deleteTask} @@ -511,9 +566,7 @@ function WorkspaceApp(): React.JSX.Element { agents={snapshot.agents} defaultAgentId={snapshot.profile.defaultAgentId} onOpenChange={setNewOpen} - onCreate={(input) => - void updateSnapshot(f5Api.createConversation(input)).then(() => setNewOpen(false)) - } + onCreate={(input) => void createTaskConversation(input).then(() => setNewOpen(false))} /> - void updateSnapshot(f5Api.initializeWorkspace(conversationId)) - } + onOpen={(conversationId) => void openConversation(conversationId)} onNew={() => setNewOpen(true)} agents={snapshot.agents} defaultAgentId={snapshot.profile.defaultAgentId} @@ -548,7 +599,7 @@ function WorkspaceApp(): React.JSX.Element { .length } onToggleArchived={() => setShowArchived((value) => !value)} - onQuickCreate={(input) => void updateSnapshot(f5Api.createConversation(input))} + onQuickCreate={(input) => void createTaskConversation(input)} /> @@ -614,7 +665,7 @@ function TopChrome(props: { className="liquid-glass-control h-9 rounded-lg pl-9" /> - + @@ -622,6 +673,8 @@ function TopChrome(props: { } function searchPlaceholder(view: AppView): string { + if (view === 'board') return 'Search board'; + if (view === 'task-workbench') return 'Search task'; if (view === 'tasks') return 'Search TODO'; if (view === 'documents') return 'Search docs'; return 'Search conversations'; @@ -639,9 +692,9 @@ function NavigationRail(props: { }): React.JSX.Element { const items = [ { label: 'Chat', icon: MessageCircle, view: 'workspace' as const }, + { label: 'Board', icon: Grid2X2, view: 'board' as const }, { label: 'TODO', icon: Check, view: 'tasks' as const }, { label: 'Docs', icon: FileText, view: 'documents' as const }, - { label: 'Workspace overview', icon: Grid2X2, view: 'overview' as const }, { label: 'Agents', icon: Bot, view: 'agents' as const }, ]; return ( @@ -730,7 +783,7 @@ function ConversationPane(props: { showArchived: boolean; archivedCount: number; onToggleArchived: () => void; - onQuickCreate: (input: { title?: string; agentId: string; firstPrompt?: string }) => void; + onQuickCreate: (input: CreateTaskConversationInput) => void; }): React.JSX.Element { const today = props.conversations.slice(0, 3); const older = props.conversations.slice(3); @@ -797,7 +850,7 @@ function NewConversationButton(props: { agents: AgentConfig[]; primaryAgentId: string; onNew: () => void; - onQuickCreate: (input: { title?: string; agentId: string; firstPrompt?: string }) => void; + onQuickCreate: (input: CreateTaskConversationInput) => void; }): React.JSX.Element { const primaryAgent = props.agents.find((agent) => agent.id === props.primaryAgentId); return ( @@ -808,14 +861,14 @@ function NewConversationButton(props: { onClick={props.onNew} > - New conversation + New task @@ -824,19 +877,19 @@ function NewConversationButton(props: { props.onQuickCreate({ - title: 'New conversation', + title: 'New task', agentId: primaryAgent?.id ?? props.primaryAgentId, }) } > - Start with {primaryAgent?.name ?? 'default agent'} + Task with {primaryAgent?.name ?? 'default agent'} {props.agents.slice(0, 4).map((agent) => ( - props.onQuickCreate({ title: `${agent.name} conversation`, agentId: agent.id }) + props.onQuickCreate({ title: `${agent.name} task`, agentId: agent.id }) } > Recent agent: {agent.name} @@ -924,6 +977,7 @@ function WorkspaceSurface(props: { view: AppView; snapshot: WorkspaceSnapshot; active?: OpenConversation; + activeTaskId: string; taskLists: WorkspaceSnapshot['taskLists']; tasks: WorkspaceSnapshot['tasks']; documents: WorkspaceSnapshot['documents']; @@ -945,6 +999,8 @@ function WorkspaceSurface(props: { onCancelQueued: (messageId: string) => void; onCancelActive: () => void; onAgentProfile: () => void; + onOpenTasks: () => void; + onOpenTask: (taskId: string) => void; onUserProfile: () => void; onBack: () => void; onTogglePanel: () => void; @@ -967,8 +1023,14 @@ function WorkspaceSurface(props: { onDeleteDocumentComment: (input: DeleteDocumentCommentInput) => Promise; onSendToAgent: (content: string) => Promise; onRevealDocument: (documentId: string) => Promise; + onOpenTaskConversation: (conversationId: string) => void; + onStartTaskChat: (taskId: string) => void; + onCreateTaskDocument: (taskId: string) => void; }): React.JSX.Element { const active = props.active; + const activeTask = active?.conversation.taskId + ? props.snapshot.tasks.find((task) => task.id === active.conversation.taskId) + : undefined; if (props.view === 'user-profile') { return ( ); } + if (props.view === 'board') { + return ( + + ); + } + if (props.view === 'task-workbench') { + return ( + + ); + } if (props.view === 'documents') { return ( ); } - if (props.view === 'overview') { - return ; - } if (props.view === 'agents') { return ( void; onStar: () => void; onArchive: () => void; @@ -1116,6 +1202,12 @@ function ChatHeader(props: { {props.active.agent.name} {connection.label} + {props.taskTitle ? ( + <> + + Task: {props.taskTitle} + + ) : null}
@@ -1667,45 +1759,6 @@ function connectionState(agent: AgentConfig): { label: string; dotClass: string return { label: 'Not connected', dotClass: 'bg-destructive' }; } -function WorkspaceOverviewPage({ - snapshot, - onBack, -}: { - snapshot: WorkspaceSnapshot; - onBack: () => void; -}): React.JSX.Element { - const activeCount = snapshot.conversations.filter( - (conversation) => conversation.status === 'active', - ).length; - const archivedCount = snapshot.conversations.filter( - (conversation) => conversation.status === 'archived', - ).length; - const messageCount = snapshot.conversations.reduce( - (total, conversation) => total + conversation.messageCount, - 0, - ); - const todoCount = snapshot.tasks.filter((task) => task.status === 'todo').length; - return ( - - - - - - - -
-
Workspace path
-
{snapshot.workspacePath}
- -
-
-
- ); -} - // Agents page lists every configured local agent with command, availability, and profile access. function AgentsPage({ agents, @@ -1747,15 +1800,6 @@ function AgentsPage({ ); } -function MetricCard({ label, value }: { label: string; value: string }): React.JSX.Element { - return ( -
-
{value}
-
{label}
-
- ); -} - // User profile page keeps editable local settings aligned with the JSON profile stored in the workspace. function UserProfilePage({ snapshot, @@ -1956,13 +2000,13 @@ function ProfileRow({ label, value }: { label: string; value: string }): React.J ); } -// New conversation flow owns its draft fields and creates files only after the user confirms the dialog. +// New task flow owns its draft fields and creates files only after the user confirms the dialog. function NewConversationFlow(props: { open: boolean; agents: AgentConfig[]; defaultAgentId: string; onOpenChange: (open: boolean) => void; - onCreate: (input: { title?: string; agentId: string; firstPrompt?: string }) => void; + onCreate: (input: CreateTaskConversationInput) => void; }): React.JSX.Element { const [title, setTitle] = useState(''); const [prompt, setPrompt] = useState(''); @@ -1971,16 +2015,16 @@ function NewConversationFlow(props: { - New conversation + New task - Choose an agent and optionally start with a first prompt. + Choose an assignee and optionally start a bound chat with a first prompt.
setTitle(event.target.value)} - placeholder="Optional title" + placeholder="Task title" /> onQueryChange?.(event.target.value)} + placeholder="Search board" + className="liquid-glass-control h-9 rounded-lg pl-9" + /> +
+ + +
+ {model.actors.map((actor) => ( + + ))} +
+
+
+ +
+
+
+

Board

+
+ {model.openCount} open tasks + + {model.actors.length} people and agents +
+
+ +
+
+
+ {(['inProgress', 'waiting', 'notStarted'] as BoardColumnKey[]).map((column) => ( + + ))} +
+
+
+
+ ); +} + +function BoardMetrics({ + openCount, + runningCount, + waitingCount, + doneCount, +}: { + openCount: number; + runningCount: number; + waitingCount: number; + doneCount: number; +}): React.JSX.Element { + const metrics = [ + { label: 'Open', value: openCount }, + { label: 'Running', value: runningCount }, + { label: 'Waiting', value: waitingCount }, + { label: 'Done', value: doneCount }, + ]; + return ( +
+ {metrics.map((metric) => ( +
+
{metric.value}
+
{metric.label}
+
+ ))} +
+ ); +} + +function ActorSummaryRow({ actor }: { actor: ActorSummary }): React.JSX.Element { + return ( +
+
+ +
+
+ {actor.name} + +
+
{actor.currentWork}
+
{actor.taskCount} assigned open
+
+
+
+ ); +} + +function BoardColumn({ + columnKey, + cards, + onOpenTask, +}: { + columnKey: BoardColumnKey; + cards: BoardCard[]; + onOpenTask: (taskId: string) => void; +}): React.JSX.Element { + const copy = columnCopy[columnKey]; + return ( +
+
+ +
+
+

{copy.title}

+ + {cards.length} + +
+
{copy.description}
+
+
+ +
+ {cards.map((card) => ( + + ))} + {cards.length === 0 ? ( +
+ Nothing here. +
+ ) : null} +
+
+
+ ); +} + +/** + * BoardTaskCard is a full-card button so board navigation stays fast on desktop and keyboard. + */ +function BoardTaskCard({ + card, + onOpenTask, +}: { + card: BoardCard; + onOpenTask: (taskId: string) => void; +}): React.JSX.Element { + return ( + + ); +} + +function ColumnIcon({ columnKey }: { columnKey: BoardColumnKey }): React.JSX.Element { + if (columnKey === 'inProgress') + return ; + if (columnKey === 'waiting') + return ; + return ; +} + +function StatusPill({ status, label }: { status: ActorStatus; label: string }): React.JSX.Element { + return ( + + + {label} + + ); +} + +function AssigneeMark({ + name, + kind, + className, +}: { + name: string; + kind: ActorKind; + className?: string; +}): React.JSX.Element { + return ( + + {kind === 'human' ? : } + {name} + + ); +} + +/** + * buildBoardModel derives board columns from real task assignments and the active conversation state. + */ +function buildBoardModel( + snapshot: WorkspaceSnapshot, + query: string, +): { + actors: ActorSummary[]; + columns: Record; + openCount: number; + doneCount: number; +} { + const normalizedQuery = query.trim().toLowerCase(); + const agentsById = new Map(snapshot.agents.map((agent) => [agent.id, agent])); + const listsById = new Map(snapshot.taskLists.map((list) => [list.id, list])); + const openTasks = snapshot.tasks.filter((task) => task.status === 'todo'); + const doneCount = snapshot.tasks.filter((task) => task.status === 'done').length; + const active = snapshot.activeConversation; + const agentHasActiveWork = hasActiveAgentWork(active); + const conversationsByTaskId = linkedConversationsByTaskId(snapshot); + const columns: Record = { + inProgress: [], + waiting: [], + notStarted: [], + }; + + for (const task of openTasks) { + const linkedConversation = conversationsByTaskId.get(task.id); + const taskIsActive = active?.conversation.taskId === task.id; + const card = taskToCard( + task, + agentsById, + listsById, + snapshot.profile.displayName, + taskIsActive ? active : undefined, + linkedConversation, + ); + if (taskIsActive && agentHasActiveWork) { + columns.inProgress.push(card); + } else if (task.agentId === HUMAN_ASSIGNEE_ID || linkedConversation) { + columns.waiting.push(card); + } else { + columns.notStarted.push(card); + } + } + + const filteredColumns = mapColumns(columns, (cards) => + cards.filter((card) => matchesBoardQuery(card, normalizedQuery)), + ); + return { + actors: buildActors(snapshot, openTasks, agentHasActiveWork), + columns: filteredColumns, + openCount: openTasks.length, + doneCount, + }; +} + +function buildActors( + snapshot: WorkspaceSnapshot, + openTasks: TaskListItem[], + agentHasActiveWork: boolean, +): ActorSummary[] { + const active = snapshot.activeConversation; + const humanTaskCount = openTasks.filter((task) => task.agentId === HUMAN_ASSIGNEE_ID).length; + const humanWaiting = Boolean( + active && + active.conversation.taskId && + !agentHasActiveWork && + active.conversation.status === 'active', + ); + const human: ActorSummary = { + id: HUMAN_ASSIGNEE_ID, + name: snapshot.profile.displayName, + kind: 'human', + status: humanWaiting ? 'waiting' : humanTaskCount > 0 ? 'assigned' : 'idle', + statusLabel: humanWaiting ? 'Input needed' : humanTaskCount > 0 ? 'Assigned' : 'Idle', + taskCount: humanTaskCount, + currentWork: humanWaiting + ? `Reply in ${active?.conversation.title ?? 'conversation'}` + : 'No live input needed', + }; + return [ + human, + ...snapshot.agents.map((agent) => agentSummary(agent, openTasks, active, agentHasActiveWork)), + ]; +} + +function agentSummary( + agent: AgentConfig, + openTasks: TaskListItem[], + active: OpenConversation | undefined, + agentHasActiveWork: boolean, +): ActorSummary { + const taskCount = openTasks.filter((task) => task.agentId === agent.id).length; + const isActiveAgent = active?.agent.id === agent.id; + const status = agentStatus(agent, isActiveAgent, agentHasActiveWork, taskCount); + return { + id: agent.id, + name: agent.name, + kind: 'agent', + status, + statusLabel: actorStatusLabel(status), + taskCount, + currentWork: currentAgentWork(agent, active, isActiveAgent, agentHasActiveWork, taskCount), + }; +} + +function agentStatus( + agent: AgentConfig, + isActiveAgent: boolean, + agentHasActiveWork: boolean, + taskCount: number, +): ActorStatus { + if (!agent.enabled || agent.availability === 'disabled') return 'disabled'; + if (isActiveAgent && agentHasActiveWork) return 'running'; + if (taskCount > 0) return 'assigned'; + return 'idle'; +} + +function currentAgentWork( + agent: AgentConfig, + active: OpenConversation | undefined, + isActiveAgent: boolean, + agentHasActiveWork: boolean, + taskCount: number, +): string { + if (isActiveAgent && agentHasActiveWork && active) + return `Working on ${active.conversation.title}`; + if (!agent.enabled || agent.availability === 'disabled') return 'Disabled in this workspace'; + if (taskCount > 0) return `${taskCount} tasks assigned`; + return 'No assigned work'; +} + +function taskToCard( + task: TaskListItem, + agentsById: Map, + listsById: Map, + displayName: string, + active: OpenConversation | undefined, + linkedConversation: ConversationListItem | undefined, +): BoardCard { + const agent = agentsById.get(task.agentId); + const isHuman = task.agentId === HUMAN_ASSIGNEE_ID; + const running = Boolean(active && hasActiveAgentWork(active)); + const details = [ + activePlanTitle(active?.state.plan ?? []), + ...(active?.state.tools ?? []) + .filter((tool) => tool.status === 'running') + .map((tool) => `Tool running: ${tool.name}`), + linkedConversation ? `Chat: ${linkedConversation.title}` : '', + task.repairStatus === 'needs_repair' ? 'Needs repair' : '', + ].filter((detail): detail is string => Boolean(detail)); + return { + id: task.id, + taskId: task.id, + title: task.title, + description: task.body, + assigneeId: task.agentId, + assigneeName: isHuman ? displayName : (agent?.name ?? task.agentId), + assigneeKind: isHuman ? 'human' : 'agent', + sourceLabel: listsById.get(task.listId)?.title ?? 'Task list', + statusLabel: running + ? 'Running' + : isHuman + ? 'Assigned to human' + : linkedConversation + ? 'Input needed' + : 'Assigned to AI', + updatedAt: linkedConversation?.updatedAt ?? task.updatedAt, + details, + }; +} + +function linkedConversationsByTaskId( + snapshot: WorkspaceSnapshot, +): Map { + const byTaskId = new Map(); + for (const conversation of snapshot.conversations) { + if (conversation.taskId && !byTaskId.has(conversation.taskId)) { + byTaskId.set(conversation.taskId, conversation); + } + } + const active = snapshot.activeConversation?.conversation; + if (active?.taskId) { + byTaskId.set(active.taskId, { + ...active, + agentName: + snapshot.agents.find((agent) => agent.id === active.agentId)?.name ?? active.agentId, + agentStatus: + snapshot.agents.find((agent) => agent.id === active.agentId)?.availability ?? 'available', + preview: snapshot.activeConversation?.messages.at(-1)?.body ?? '', + }); + } + return byTaskId; +} + +function hasActiveAgentWork(active: OpenConversation | undefined): boolean { + if (!active) return false; + return Boolean( + active.state.activeTurnId || + active.state.tools.some((tool) => tool.status === 'running') || + active.messages.some((message) => ['active', 'streaming'].includes(message.meta.status)), + ); +} + +function activePlanTitle(steps: PlanStep[]): string { + const active = steps.find((step) => step.status === 'active'); + if (active) return `Plan: ${active.title}`; + const pending = steps.find((step) => step.status === 'pending'); + return pending ? `Next: ${pending.title}` : ''; +} + +function matchesBoardQuery(card: BoardCard, normalizedQuery: string): boolean { + if (!normalizedQuery) return true; + return `${card.title} ${card.description} ${card.assigneeName} ${card.sourceLabel} ${card.details.join(' ')}` + .toLowerCase() + .includes(normalizedQuery); +} + +function mapColumns( + columns: Record, + mapper: (cards: BoardCard[]) => BoardCard[], +): Record { + return { + inProgress: mapper(columns.inProgress), + waiting: mapper(columns.waiting), + notStarted: mapper(columns.notStarted), + }; +} + +function actorStatusLabel(status: ActorStatus): string { + if (status === 'running') return 'Running'; + if (status === 'waiting') return 'Waiting'; + if (status === 'assigned') return 'Assigned'; + if (status === 'disabled') return 'Disabled'; + return 'Idle'; +} + +function statusDotClass(status: ActorStatus): string { + if (status === 'running') return 'bg-[color:var(--status-active)]'; + if (status === 'waiting') return 'bg-[color:var(--status-queued)]'; + if (status === 'assigned') return 'bg-[color:var(--status-connected)]'; + if (status === 'disabled') return 'bg-muted-foreground/40'; + return 'bg-muted-foreground/60'; +} + +function formatShortDate(value: string): string { + return value ? value.slice(0, 10) : 'unknown'; +} + +export { WorkspaceBoardPage }; diff --git a/src/renderer/features/workspace-resources.test.tsx b/src/renderer/features/workspace-resources.test.tsx index d5f8740..9149e1e 100644 --- a/src/renderer/features/workspace-resources.test.tsx +++ b/src/renderer/features/workspace-resources.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { DocumentsPage, MarkdownPreview, TasksPage } from './workspace-resources'; +import { HUMAN_ASSIGNEE_ID } from '../../shared/types'; import type { AgentConfig, DocumentCommentListItem, @@ -75,6 +76,7 @@ function documentListItem(overrides: Partial = {}): DocumentLi return { schema: 'f5.document.v1', id: 'doc_aaaaaaaaaaaaaaaaaaaaaaaa', + taskId: '', title: 'Project doc', createdAt, updatedAt, @@ -87,6 +89,7 @@ function documentRecord(overrides: Partial = {}): DocumentRecord return { schema: 'f5.document.v1', id: 'doc_aaaaaaaaaaaaaaaaaaaaaaaa', + taskId: '', title: 'Project doc', createdAt, updatedAt, @@ -172,6 +175,40 @@ describe('workspace resources UI', () => { expect(screen.getByText('Finished task')).toBeInTheDocument(); }); + it('creates TODO items assigned to the human profile', async () => { + const user = userEvent.setup(); + const createTask = vi.fn(async () => undefined); + render( + undefined)} + onUpdateTaskList={vi.fn(async () => undefined)} + onDeleteTaskList={vi.fn(async () => undefined)} + onCreateTask={createTask} + onUpdateTask={vi.fn(async () => undefined)} + onDeleteTask={vi.fn(async () => undefined)} + />, + ); + + await user.click(screen.getByLabelText('Task assignee')); + await user.click(screen.getByRole('option', { name: 'idevlab' })); + await user.type(screen.getByLabelText('Task title'), 'Human review'); + await user.click(screen.getByRole('button', { name: 'Add task' })); + + expect(createTask).toHaveBeenCalledWith({ + taskListId: defaultTaskListId, + title: 'Human review', + body: '', + agentId: HUMAN_ASSIGNEE_ID, + }); + }); + it('edits, cancels, and deletes TODO items', async () => { const user = userEvent.setup(); const updateTask = vi.fn(async () => undefined); @@ -342,9 +379,9 @@ describe('workspace resources UI', () => { />, ); - expect(screen.getByText('Agent: Codex')).toBeInTheDocument(); + expect(screen.getByText('Assignee: Codex')).toBeInTheDocument(); - await user.click(screen.getByLabelText('Task agent')); + await user.click(screen.getByLabelText('Task assignee')); await user.click(await screen.findByRole('option', { name: 'Claude Code' })); await user.type(screen.getByLabelText('Task title'), 'Agent task'); await user.click(screen.getByRole('button', { name: 'Add task' })); @@ -356,7 +393,7 @@ describe('workspace resources UI', () => { }); await user.click(screen.getByLabelText('Edit task')); - await user.click(screen.getByLabelText('Edit task agent')); + await user.click(screen.getByLabelText('Edit task assignee')); await user.click(await screen.findByRole('option', { name: 'Claude Code' })); await user.click(screen.getByRole('button', { name: 'Save' })); expect(updateTask).toHaveBeenCalledWith({ @@ -410,6 +447,31 @@ describe('workspace resources UI', () => { ); }); + it('shows document task source markers', () => { + render( + documentRecord())} + onOpenDocument={vi.fn(async () => documentRecord())} + onUpdateDocument={vi.fn(async () => documentRecord())} + onDeleteDocument={vi.fn(async () => undefined)} + onRevealDocument={vi.fn(async () => undefined)} + />, + ); + + expect(screen.getByText('Open task')).toBeInTheDocument(); + expect(screen.getByText('Unlinked')).toBeInTheDocument(); + }); + it('autosaves Markdown document drafts after typing pauses', async () => { const user = userEvent.setup(); const opened = documentRecord(); diff --git a/src/renderer/features/workspace-resources.tsx b/src/renderer/features/workspace-resources.tsx index 4d37aaa..c84f756 100644 --- a/src/renderer/features/workspace-resources.tsx +++ b/src/renderer/features/workspace-resources.tsx @@ -40,6 +40,7 @@ import { } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; +import { HUMAN_ASSIGNEE_ID } from '../../shared/types'; import type { AgentConfig, CreateDocumentCommentInput, @@ -252,6 +253,7 @@ function TasksPage({ tasks, agents = [], defaultAgentId = '', + profileDisplayName = 'You', query, onQueryChange, onBack, @@ -266,6 +268,7 @@ function TasksPage({ tasks: TaskListItem[]; agents?: AgentConfig[]; defaultAgentId?: string; + profileDisplayName?: string; query: string; onQueryChange?: (value: string) => void; onBack: () => void; @@ -278,7 +281,7 @@ function TasksPage({ }): React.JSX.Element { const [title, setTitle] = useState(''); const [body, setBody] = useState(''); - const [agentId, setAgentId] = useState(defaultAgentId || agents[0]?.id || ''); + const [agentId, setAgentId] = useState(defaultAgentId || agents[0]?.id || HUMAN_ASSIGNEE_ID); const [filter, setFilter] = useState('all'); const [activeListId, setActiveListId] = useState(taskLists[0]?.id ?? ''); const [deleteTarget, setDeleteTarget] = useState(null); @@ -468,28 +471,27 @@ function TasksPage({ New task
- {agents.length > 0 ? ( - - ) : null} + + + + + {profileDisplayName} + {agents.map((agent) => ( + + {agent.name} + + ))} + +
{(['all', 'todo', 'done'] as TaskFilter[]).map((value) => ( + document={document} + taskLabel={taskNames.get(document.taskId) ?? 'Unlinked'} + selected={selected?.id === document.id} + onOpen={() => void openDocument(document.id)} + /> ))} {documents.length === 0 ? (
@@ -1057,6 +1051,10 @@ function DocumentsPage({
Markdown document + + {selected.taskId ? (taskNames.get(selected.taskId) ?? 'Task') : 'Unlinked'} + + {selectedComments.length}{' '} {selectedComments.length === 1 ? 'comment' : 'comments'} @@ -1473,6 +1471,44 @@ function DocumentCommentRow({ ); } +function DocumentListButton({ + document, + taskLabel, + selected, + onOpen, +}: { + document: DocumentListItem; + taskLabel: string; + selected: boolean; + onOpen: () => void; +}): React.JSX.Element { + return ( + + ); +} + function ResourceShell({ sidebar, children, diff --git a/src/renderer/lib/api.ts b/src/renderer/lib/api.ts index 0d0b2c0..850000d 100644 --- a/src/renderer/lib/api.ts +++ b/src/renderer/lib/api.ts @@ -4,6 +4,7 @@ import type { CreateConversationInput, CreateDocumentCommentInput, CreateDocumentInput, + CreateTaskConversationInput, CreateTaskListInput, CreateTaskInput, DeleteConversationInput, @@ -29,6 +30,9 @@ export const f5Api = { createConversation(input: CreateConversationInput) { return window.f5.createConversation(input); }, + createTaskConversation(input: CreateTaskConversationInput) { + return window.f5.createTaskConversation(input); + }, sendMessage(input: SendMessageInput) { return window.f5.sendMessage(input); }, diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts index 8ad481a..7eb1bb2 100644 --- a/src/renderer/vite-env.d.ts +++ b/src/renderer/vite-env.d.ts @@ -6,6 +6,7 @@ import type { CreateConversationInput, CreateDocumentCommentInput, CreateDocumentInput, + CreateTaskConversationInput, CreateTaskListInput, CreateTaskInput, DeleteConversationInput, @@ -32,6 +33,7 @@ declare global { platform: NodeJS.Platform; initializeWorkspace: (activeConversationId?: string) => Promise; createConversation: (input: CreateConversationInput) => Promise; + createTaskConversation: (input: CreateTaskConversationInput) => Promise; openConversation: ( conversationId: string, ) => Promise; diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index a8e81c5..081707f 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -12,6 +12,7 @@ export const appearancePreferenceSchema = z.enum(['light', 'dark', 'system']); export const conversationMetaSchema = z.object({ schema: z.literal('f5.conversation.v1'), id: conversationIdSchema, + taskId: z.union([z.literal(''), taskIdSchema]).default(''), title: z.string().min(1), agentId: z.string().min(1), status: z.enum(['active', 'archived', 'needs_repair']), @@ -155,6 +156,7 @@ export const taskListIndexSchema = z.object({ export const documentRecordSchema = z.object({ schema: z.literal('f5.document.v1'), id: documentIdSchema, + taskId: z.union([z.literal(''), taskIdSchema]).default(''), title: z.string().trim().min(1), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), @@ -198,6 +200,15 @@ export const documentCommentIndexSchema = z.object({ export const createConversationInputSchema = z.object({ title: z.string().trim().optional(), agentId: z.string().optional(), + taskId: taskIdSchema.optional(), + firstPrompt: z.string().trim().optional(), +}); + +export const createTaskConversationInputSchema = z.object({ + title: z.string().trim().min(1), + body: z.string().optional().default(''), + agentId: z.string().trim().min(1).optional(), + taskListId: taskListIdSchema.optional(), firstPrompt: z.string().trim().optional(), }); @@ -260,6 +271,7 @@ export const deleteTaskListInputSchema = z.object({ export const createDocumentInputSchema = z.object({ title: z.string().trim().optional(), body: z.string().optional().default(''), + taskId: taskIdSchema.optional(), }); export const updateDocumentInputSchema = z.object({ diff --git a/src/shared/types.ts b/src/shared/types.ts index 4ca8662..34659d5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -26,19 +26,22 @@ export type ToolStatus = 'running' | 'queued' | 'completed' | 'failed'; export type TaskStatus = 'todo' | 'done'; export type DocumentCommentStatus = 'open' | 'resolved'; export type RepairStatus = 'ok' | 'needs_repair'; +export const HUMAN_ASSIGNEE_ID = 'human-user'; export type AppView = | 'workspace' + | 'task-workbench' + | 'board' | 'tasks' | 'documents' | 'user-profile' | 'agent-profile' - | 'overview' | 'agents'; export type AppearancePreference = 'light' | 'dark' | 'system'; export interface ConversationMeta { schema: 'f5.conversation.v1'; id: string; + taskId: string; title: string; agentId: string; status: ConversationStatus; @@ -179,6 +182,7 @@ export interface TaskListIndex { export interface DocumentRecord { schema: 'f5.document.v1'; id: string; + taskId: string; title: string; createdAt: string; updatedAt: string; @@ -188,6 +192,7 @@ export interface DocumentRecord { export interface DocumentListItem { schema: 'f5.document.v1'; id: string; + taskId: string; title: string; createdAt: string; updatedAt: string; @@ -246,6 +251,15 @@ export interface OpenConversation { export interface CreateConversationInput { title?: string; agentId?: string; + taskId?: string; + firstPrompt?: string; +} + +export interface CreateTaskConversationInput { + title: string; + body?: string; + agentId?: string; + taskListId?: string; firstPrompt?: string; } @@ -308,6 +322,7 @@ export interface DeleteTaskListInput { export interface CreateDocumentInput { title?: string; body?: string; + taskId?: string; } export interface UpdateDocumentInput { diff --git a/src/shared/workspace-store.test.ts b/src/shared/workspace-store.test.ts index c01cd39..79a0170 100644 --- a/src/shared/workspace-store.test.ts +++ b/src/shared/workspace-store.test.ts @@ -28,6 +28,7 @@ import { taskListRecordSchema, taskRecordSchema, } from './schemas'; +import { HUMAN_ASSIGNEE_ID } from './types'; describe('WorkspaceStore', () => { it('creates, persists, indexes, and reloads markdown conversations', async () => { @@ -80,6 +81,41 @@ describe('WorkspaceStore', () => { expect(index.conversations[0].messageCount).toBe(2); }); + it('persists task links on conversations and keeps older conversation files readable', async () => { + const workspacePath = await mkdtemp(join(tmpdir(), 'f5-conversation-task-link-test-')); + const store = new WorkspaceStore(workspacePath); + const task = await store.createTask({ title: 'Task-linked conversation' }); + const conversation = await store.createConversation({ + title: 'Task chat', + taskId: task.id, + }); + + const conversationPath = join( + workspacePath, + 'conversations', + conversation.conversation.id, + 'conversation.md', + ); + const rawConversation = matter(await readFile(conversationPath, 'utf8')); + expect(rawConversation.data.taskId).toBe(task.id); + expect((await store.openConversation(conversation.conversation.id)).conversation.taskId).toBe( + task.id, + ); + expect( + (await store.listConversations()).find((item) => item.id === conversation.conversation.id), + ).toMatchObject({ taskId: task.id }); + + delete rawConversation.data.taskId; + await writeFile( + conversationPath, + matter.stringify(rawConversation.content, rawConversation.data), + 'utf8', + ); + + const oldConversation = await store.openConversation(conversation.conversation.id); + expect(oldConversation.conversation.taskId).toBe(''); + }); + it('rejects path-like local ids before filesystem operations', async () => { expect(() => sendMessageInputSchema.parse({ @@ -195,6 +231,29 @@ describe('WorkspaceStore', () => { expect(await restarted.listTasks()).toHaveLength(0); }); + it('persists human task assignments without rewriting them to the default agent', async () => { + const workspacePath = await mkdtemp(join(tmpdir(), 'f5-human-task-test-')); + const store = new WorkspaceStore(workspacePath); + const task = await store.createTask({ + title: 'Review agent output', + agentId: HUMAN_ASSIGNEE_ID, + }); + expect(task.agentId).toBe(HUMAN_ASSIGNEE_ID); + + const updated = await store.updateTask({ + taskId: task.id, + title: 'Review agent output', + body: 'Check the plan before running it.', + status: 'todo', + agentId: HUMAN_ASSIGNEE_ID, + }); + expect(updated.agentId).toBe(HUMAN_ASSIGNEE_ID); + + const restarted = new WorkspaceStore(workspacePath); + await restarted.ensureWorkspace(); + expect((await restarted.readTask(task.id)).agentId).toBe(HUMAN_ASSIGNEE_ID); + }); + it('creates multiple TODO lists, indexes counts, and persists task membership', async () => { const workspacePath = await mkdtemp(join(tmpdir(), 'f5-task-list-test-')); const store = new WorkspaceStore(workspacePath); @@ -355,6 +414,30 @@ describe('WorkspaceStore', () => { ).toBe(false); }); + it('persists task links on documents and keeps older document files readable', async () => { + const workspacePath = await mkdtemp(join(tmpdir(), 'f5-document-task-link-test-')); + const store = new WorkspaceStore(workspacePath); + const task = await store.createTask({ title: 'Task-linked document' }); + const document = await store.createDocument({ + title: 'Task doc', + body: '# Task doc\n', + taskId: task.id, + }); + + const documentPath = join(workspacePath, 'documents', `${document.id}.md`); + const rawDocument = matter(await readFile(documentPath, 'utf8')); + expect(rawDocument.data.taskId).toBe(task.id); + expect((await store.readDocument(document.id)).taskId).toBe(task.id); + expect((await store.listDocuments()).find((item) => item.id === document.id)).toMatchObject({ + taskId: task.id, + }); + + delete rawDocument.data.taskId; + await writeFile(documentPath, matter.stringify(rawDocument.content, rawDocument.data), 'utf8'); + + expect((await store.readDocument(document.id)).taskId).toBe(''); + }); + it('marks invalid TODO and document frontmatter as needing repair', async () => { const workspacePath = await mkdtemp(join(tmpdir(), 'f5-resource-repair-test-')); const store = new WorkspaceStore(workspacePath); @@ -566,6 +649,32 @@ describe('WorkspaceStore', () => { expect(idle.state.queue).toHaveLength(0); }); + it('creates a task before opening its bound conversation', async () => { + const workspacePath = await mkdtemp(join(tmpdir(), 'f5-task-conversation-test-')); + const store = new WorkspaceStore(workspacePath); + const engine = new ConversationEngine(store); + + const snapshot = await engine.createTaskConversation({ + title: 'Human-owned task', + body: 'Needs a draft', + agentId: HUMAN_ASSIGNEE_ID, + }); + const task = snapshot.tasks.find((item) => item.title === 'Human-owned task'); + + expect(task).toMatchObject({ + agentId: HUMAN_ASSIGNEE_ID, + body: 'Needs a draft', + }); + expect(snapshot.activeConversation?.conversation).toMatchObject({ + title: 'Human-owned task', + taskId: task?.id, + agentId: defaultAgentsFile.defaultAgentId, + }); + expect(snapshot.conversations.find((item) => item.taskId === task?.id)?.id).toBe( + snapshot.activeConversation?.conversation.id, + ); + }); + it('emits snapshots for engine-managed workspace TODO and document operations', async () => { const workspacePath = await mkdtemp(join(tmpdir(), 'f5-engine-resource-test-')); const store = new WorkspaceStore(workspacePath);