diff --git a/app/src/components/settings/panels/AgentAccessPanel.tsx b/app/src/components/settings/panels/AgentAccessPanel.tsx index f7a85bcecc..ace9608a9a 100644 --- a/app/src/components/settings/panels/AgentAccessPanel.tsx +++ b/app/src/components/settings/panels/AgentAccessPanel.tsx @@ -50,6 +50,7 @@ const AgentAccessPanel = () => { const [level, setLevel] = useState('supervised'); const [workspaceOnly, setWorkspaceOnly] = useState(false); + const [requireTaskPlanApproval, setRequireTaskPlanApproval] = useState(true); const [trustedRoots, setTrustedRoots] = useState([]); // "Always allow" allowlist — populated by the in-chat "Always allow" button; // shown here read-only with a Remove action (the re-protect path). @@ -78,6 +79,7 @@ const AgentAccessPanel = () => { if (cancelled) return; setLevel(resp.result.level); setWorkspaceOnly(resp.result.workspace_only); + setRequireTaskPlanApproval(resp.result.require_task_plan_approval ?? true); setTrustedRoots(resp.result.trusted_roots ?? []); setAutoApprove(resp.result.auto_approve ?? []); } catch (e) { @@ -100,6 +102,7 @@ const AgentAccessPanel = () => { const persist = async (next: { level: AutonomyLevel; workspaceOnly: boolean; + requireTaskPlanApproval: boolean; trustedRoots: TrustedRoot[]; // Only sent when the allowlist itself is being changed. Omitting it leaves // the server's `auto_approve` untouched (partial patch) — important so a @@ -118,6 +121,7 @@ const AgentAccessPanel = () => { workspace_only: next.workspaceOnly, trusted_roots: next.trustedRoots, allow_tool_install: ALLOW_TOOL_INSTALL, + require_task_plan_approval: next.requireTaskPlanApproval, ...(next.autoApprove !== undefined ? { auto_approve: next.autoApprove } : {}), }); // Only the most recent persist may write UI state back. @@ -137,12 +141,17 @@ const AgentAccessPanel = () => { const selectTier = (next: AutonomyLevel) => { setLevel(next); - void persist({ level: next, workspaceOnly, trustedRoots }); + void persist({ level: next, workspaceOnly, requireTaskPlanApproval, trustedRoots }); }; const toggleWorkspaceOnly = (next: boolean) => { setWorkspaceOnly(next); - void persist({ level, workspaceOnly: next, trustedRoots }); + void persist({ level, workspaceOnly: next, requireTaskPlanApproval, trustedRoots }); + }; + + const toggleTaskPlanApproval = (next: boolean) => { + setRequireTaskPlanApproval(next); + void persist({ level, workspaceOnly, requireTaskPlanApproval: next, trustedRoots }); }; const addRoot = () => { @@ -156,19 +165,25 @@ const AgentAccessPanel = () => { setTrustedRoots(nextRoots); setNewRootPath(''); setNewRootAccess('read'); - void persist({ level, workspaceOnly, trustedRoots: nextRoots }); + void persist({ level, workspaceOnly, requireTaskPlanApproval, trustedRoots: nextRoots }); }; const removeRoot = (path: string) => { const nextRoots = trustedRoots.filter(r => r.path !== path); setTrustedRoots(nextRoots); - void persist({ level, workspaceOnly, trustedRoots: nextRoots }); + void persist({ level, workspaceOnly, requireTaskPlanApproval, trustedRoots: nextRoots }); }; const removeAutoApprove = (tool: string) => { const nextList = autoApprove.filter(name => name !== tool); setAutoApprove(nextList); - void persist({ level, workspaceOnly, trustedRoots, autoApprove: nextList }); + void persist({ + level, + workspaceOnly, + requireTaskPlanApproval, + trustedRoots, + autoApprove: nextList, + }); }; return ( @@ -248,6 +263,25 @@ const AgentAccessPanel = () => { +
+ +
+ {/* Granted folders (trusted roots) — extra read/write reach. */}

diff --git a/app/src/components/settings/panels/__tests__/AgentAccessPanel.test.tsx b/app/src/components/settings/panels/__tests__/AgentAccessPanel.test.tsx index 1d6960ee6b..572f9003be 100644 --- a/app/src/components/settings/panels/__tests__/AgentAccessPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AgentAccessPanel.test.tsx @@ -74,12 +74,23 @@ describe('AgentAccessPanel', () => { it('toggling "confine to workspace" persists workspace_only', async () => { renderWithProviders(); await screen.findByText('Read-only'); - fireEvent.click(screen.getByRole('checkbox')); + fireEvent.click(screen.getByRole('checkbox', { name: /confine to workspace/i })); await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ workspace_only: true })) ); }); + it('toggling task plan approval persists require_task_plan_approval', async () => { + renderWithProviders(); + await screen.findByText('Read-only'); + fireEvent.click(screen.getByRole('checkbox', { name: /require task plan approval/i })); + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ require_task_plan_approval: false }) + ) + ); + }); + it('adding then removing a granted folder persists the updated list', async () => { renderWithProviders(); await screen.findByText('Granted folders'); @@ -111,7 +122,9 @@ describe('AgentAccessPanel', () => { }); renderWithProviders(); expect(await screen.findByText('/home/u/notes')).toBeInTheDocument(); - expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(true); + expect( + (screen.getByRole('checkbox', { name: /confine to workspace/i }) as HTMLInputElement).checked + ).toBe(true); }); it('shows the empty "always-allow" state when no tools are allow-listed', async () => { diff --git a/app/src/lib/i18n/chunks/ar-4.ts b/app/src/lib/i18n/chunks/ar-4.ts index e0e59f7f4e..f059656bc2 100644 --- a/app/src/lib/i18n/chunks/ar-4.ts +++ b/app/src/lib/i18n/chunks/ar-4.ts @@ -428,6 +428,28 @@ const ar4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'التوجيه والمشغلات وسجل عمليات التكامل المدعومة بواسطة Composio.', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', }; export default ar4; diff --git a/app/src/lib/i18n/chunks/ar-5.ts b/app/src/lib/i18n/chunks/ar-5.ts index e9feac8611..3a0eb73930 100644 --- a/app/src/lib/i18n/chunks/ar-5.ts +++ b/app/src/lib/i18n/chunks/ar-5.ts @@ -946,6 +946,9 @@ const ar5: TranslationMap = { 'skills.new.title': 'Create a skill', 'skills.new.placeholderBody': 'Authoring form arrives soon. For now, use the “New skill” button on the runner page.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', }; export default ar5; diff --git a/app/src/lib/i18n/chunks/bn-4.ts b/app/src/lib/i18n/chunks/bn-4.ts index 9e31dbd830..6d25561793 100644 --- a/app/src/lib/i18n/chunks/bn-4.ts +++ b/app/src/lib/i18n/chunks/bn-4.ts @@ -431,6 +431,28 @@ const bn4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Composio দ্বারা চালিত ইন্টিগ্রেশনের জন্য রাউটিং, ট্রিগার এবং ইতিহাস।', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', }; export default bn4; diff --git a/app/src/lib/i18n/chunks/bn-5.ts b/app/src/lib/i18n/chunks/bn-5.ts index 552f2a32ba..1c00d88390 100644 --- a/app/src/lib/i18n/chunks/bn-5.ts +++ b/app/src/lib/i18n/chunks/bn-5.ts @@ -959,6 +959,9 @@ const bn5: TranslationMap = { 'skills.new.title': 'Create a skill', 'skills.new.placeholderBody': 'Authoring form arrives soon. For now, use the “New skill” button on the runner page.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', }; export default bn5; diff --git a/app/src/lib/i18n/chunks/de-4.ts b/app/src/lib/i18n/chunks/de-4.ts index 041645c57e..ecbe96ab08 100644 --- a/app/src/lib/i18n/chunks/de-4.ts +++ b/app/src/lib/i18n/chunks/de-4.ts @@ -437,6 +437,28 @@ const de4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Routing, Trigger und Verlauf für Integrationen, die von Composio unterstützt werden.', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', }; export default de4; diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index 42584cce6e..ce9344fefa 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -987,6 +987,9 @@ const de5: TranslationMap = { 'skills.new.title': 'Create a skill', 'skills.new.placeholderBody': 'Authoring form arrives soon. For now, use the “New skill” button on the runner page.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', }; export default de5; diff --git a/app/src/lib/i18n/chunks/en-4.ts b/app/src/lib/i18n/chunks/en-4.ts index 70c8303d72..dcfbbf702c 100644 --- a/app/src/lib/i18n/chunks/en-4.ts +++ b/app/src/lib/i18n/chunks/en-4.ts @@ -76,6 +76,28 @@ const en4: TranslationMap = { 'conversations.taskKanban.moveLeft': 'Move left', 'conversations.taskKanban.moveRight': 'Move right', 'conversations.taskKanban.title': 'Tasks', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', 'conversations.toolTimeline.turn': 'turn', 'conversations.toolTimeline.workerThread': 'worker thread', 'daemon.serviceBlockingGate.body': 'Body', diff --git a/app/src/lib/i18n/chunks/en-5.ts b/app/src/lib/i18n/chunks/en-5.ts index d30151b1ff..02c46d112b 100644 --- a/app/src/lib/i18n/chunks/en-5.ts +++ b/app/src/lib/i18n/chunks/en-5.ts @@ -313,6 +313,9 @@ const en5: TranslationMap = { 'settings.agentAccess.confine.label': 'Confine to workspace', 'settings.agentAccess.confine.desc': 'Restrict the agent to the workspace directory (plus any granted folders), whichever access mode is selected. When off, it can reach anywhere your user can — except the always-blocked credential and system directories.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', 'settings.agentAccess.grantedFolders': 'Granted folders', 'settings.agentAccess.alwaysAllow': 'Always-allowed tools', 'settings.agentAccess.alwaysAllowDesc': diff --git a/app/src/lib/i18n/chunks/es-4.ts b/app/src/lib/i18n/chunks/es-4.ts index a764f5cf01..26012f2a20 100644 --- a/app/src/lib/i18n/chunks/es-4.ts +++ b/app/src/lib/i18n/chunks/es-4.ts @@ -435,6 +435,28 @@ const es4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Enrutamiento, activadores e historial para integraciones impulsadas por Composio.', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', }; export default es4; diff --git a/app/src/lib/i18n/chunks/es-5.ts b/app/src/lib/i18n/chunks/es-5.ts index da296192b6..751c6dcb07 100644 --- a/app/src/lib/i18n/chunks/es-5.ts +++ b/app/src/lib/i18n/chunks/es-5.ts @@ -973,6 +973,9 @@ const es5: TranslationMap = { 'skills.new.title': 'Create a skill', 'skills.new.placeholderBody': 'Authoring form arrives soon. For now, use the “New skill” button on the runner page.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', }; export default es5; diff --git a/app/src/lib/i18n/chunks/fr-4.ts b/app/src/lib/i18n/chunks/fr-4.ts index 23950bd5de..5cb4e645ff 100644 --- a/app/src/lib/i18n/chunks/fr-4.ts +++ b/app/src/lib/i18n/chunks/fr-4.ts @@ -434,6 +434,28 @@ const fr4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Routage, déclencheurs et historique pour les intégrations optimisées par Composio.', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', }; export default fr4; diff --git a/app/src/lib/i18n/chunks/fr-5.ts b/app/src/lib/i18n/chunks/fr-5.ts index 44cbf461ae..1c22596bab 100644 --- a/app/src/lib/i18n/chunks/fr-5.ts +++ b/app/src/lib/i18n/chunks/fr-5.ts @@ -977,6 +977,9 @@ const fr5: TranslationMap = { 'skills.new.title': 'Create a skill', 'skills.new.placeholderBody': 'Authoring form arrives soon. For now, use the “New skill” button on the runner page.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', }; export default fr5; diff --git a/app/src/lib/i18n/chunks/hi-4.ts b/app/src/lib/i18n/chunks/hi-4.ts index 317e489137..e308c6e09d 100644 --- a/app/src/lib/i18n/chunks/hi-4.ts +++ b/app/src/lib/i18n/chunks/hi-4.ts @@ -432,6 +432,28 @@ const hi4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Composio द्वारा संचालित एकीकरण के लिए रूटिंग, ट्रिगर और इतिहास।', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', }; export default hi4; diff --git a/app/src/lib/i18n/chunks/hi-5.ts b/app/src/lib/i18n/chunks/hi-5.ts index 0a0c9393d7..5a5afdcea6 100644 --- a/app/src/lib/i18n/chunks/hi-5.ts +++ b/app/src/lib/i18n/chunks/hi-5.ts @@ -960,6 +960,9 @@ const hi5: TranslationMap = { 'skills.new.title': 'Create a skill', 'skills.new.placeholderBody': 'Authoring form arrives soon. For now, use the “New skill” button on the runner page.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', }; export default hi5; diff --git a/app/src/lib/i18n/chunks/id-4.ts b/app/src/lib/i18n/chunks/id-4.ts index d678b4ccdf..56b7838591 100644 --- a/app/src/lib/i18n/chunks/id-4.ts +++ b/app/src/lib/i18n/chunks/id-4.ts @@ -433,6 +433,28 @@ const id4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Perutean, pemicu, dan riwayat untuk integrasi yang didukung oleh Composio.', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', }; export default id4; diff --git a/app/src/lib/i18n/chunks/id-5.ts b/app/src/lib/i18n/chunks/id-5.ts index d190fe7386..e592ea4d91 100644 --- a/app/src/lib/i18n/chunks/id-5.ts +++ b/app/src/lib/i18n/chunks/id-5.ts @@ -960,6 +960,9 @@ const id5: TranslationMap = { 'skills.new.title': 'Create a skill', 'skills.new.placeholderBody': 'Authoring form arrives soon. For now, use the “New skill” button on the runner page.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', }; export default id5; diff --git a/app/src/lib/i18n/chunks/it-4.ts b/app/src/lib/i18n/chunks/it-4.ts index a1e85aa563..200c0bd1b8 100644 --- a/app/src/lib/i18n/chunks/it-4.ts +++ b/app/src/lib/i18n/chunks/it-4.ts @@ -436,6 +436,28 @@ const it4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Routing, trigger e cronologia per le integrazioni fornite da Composio.', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', }; export default it4; diff --git a/app/src/lib/i18n/chunks/it-5.ts b/app/src/lib/i18n/chunks/it-5.ts index 58d965e4ca..d16d4faf72 100644 --- a/app/src/lib/i18n/chunks/it-5.ts +++ b/app/src/lib/i18n/chunks/it-5.ts @@ -971,6 +971,9 @@ const it5: TranslationMap = { 'skills.new.title': 'Create a skill', 'skills.new.placeholderBody': 'Authoring form arrives soon. For now, use the “New skill” button on the runner page.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', }; export default it5; diff --git a/app/src/lib/i18n/chunks/ko-4.ts b/app/src/lib/i18n/chunks/ko-4.ts index f04f05b16e..740eded01f 100644 --- a/app/src/lib/i18n/chunks/ko-4.ts +++ b/app/src/lib/i18n/chunks/ko-4.ts @@ -434,6 +434,28 @@ const ko4: TranslationMap = { 'settings.ai.openAiCompat.rotateKey': '키 회전', 'settings.ai.openAiCompat.setKey': '키 설정', 'settings.ai.openAiCompat.title': 'OpenAI 호환 엔드포인트', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', }; export default ko4; diff --git a/app/src/lib/i18n/chunks/ko-5.ts b/app/src/lib/i18n/chunks/ko-5.ts index 6ac1b084b9..599b9ec8f8 100644 --- a/app/src/lib/i18n/chunks/ko-5.ts +++ b/app/src/lib/i18n/chunks/ko-5.ts @@ -949,6 +949,9 @@ const ko5: TranslationMap = { 'skills.new.title': 'Create a skill', 'skills.new.placeholderBody': 'Authoring form arrives soon. For now, use the “New skill” button on the runner page.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', }; export default ko5; diff --git a/app/src/lib/i18n/chunks/pt-4.ts b/app/src/lib/i18n/chunks/pt-4.ts index baee2e9867..8cc0d814da 100644 --- a/app/src/lib/i18n/chunks/pt-4.ts +++ b/app/src/lib/i18n/chunks/pt-4.ts @@ -434,6 +434,28 @@ const pt4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Roteamento, gatilhos e histórico para integrações desenvolvidas por Composio.', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', }; export default pt4; diff --git a/app/src/lib/i18n/chunks/pt-5.ts b/app/src/lib/i18n/chunks/pt-5.ts index 57dbfeb88d..54d53e33e2 100644 --- a/app/src/lib/i18n/chunks/pt-5.ts +++ b/app/src/lib/i18n/chunks/pt-5.ts @@ -970,6 +970,9 @@ const pt5: TranslationMap = { 'skills.new.title': 'Create a skill', 'skills.new.placeholderBody': 'Authoring form arrives soon. For now, use the “New skill” button on the runner page.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', }; export default pt5; diff --git a/app/src/lib/i18n/chunks/ru-4.ts b/app/src/lib/i18n/chunks/ru-4.ts index 779e1cd68e..562f42fa2b 100644 --- a/app/src/lib/i18n/chunks/ru-4.ts +++ b/app/src/lib/i18n/chunks/ru-4.ts @@ -431,6 +431,28 @@ const ru4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Маршрутизация, триггеры и история интеграций на базе Composio.', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', }; export default ru4; diff --git a/app/src/lib/i18n/chunks/ru-5.ts b/app/src/lib/i18n/chunks/ru-5.ts index edd6707c54..75dcaf7dd6 100644 --- a/app/src/lib/i18n/chunks/ru-5.ts +++ b/app/src/lib/i18n/chunks/ru-5.ts @@ -966,6 +966,9 @@ const ru5: TranslationMap = { 'skills.new.title': 'Create a skill', 'skills.new.placeholderBody': 'Authoring form arrives soon. For now, use the “New skill” button on the runner page.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', }; export default ru5; diff --git a/app/src/lib/i18n/chunks/zh-CN-4.ts b/app/src/lib/i18n/chunks/zh-CN-4.ts index 5ea934d779..2e8476e093 100644 --- a/app/src/lib/i18n/chunks/zh-CN-4.ts +++ b/app/src/lib/i18n/chunks/zh-CN-4.ts @@ -422,6 +422,28 @@ const zhCN4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': '由 Composio 提供支持的集成的路由、触发器和历史记录。', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', }; export default zhCN4; diff --git a/app/src/lib/i18n/chunks/zh-CN-5.ts b/app/src/lib/i18n/chunks/zh-CN-5.ts index 866cd04396..e6e5c0923d 100644 --- a/app/src/lib/i18n/chunks/zh-CN-5.ts +++ b/app/src/lib/i18n/chunks/zh-CN-5.ts @@ -920,6 +920,9 @@ const zhCN5: TranslationMap = { 'skills.new.title': 'Create a skill', 'skills.new.placeholderBody': 'Authoring form arrives soon. For now, use the “New skill” button on the runner page.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', }; export default zhCN5; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 8417f4deb2..fa7f26867c 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2447,6 +2447,28 @@ const en: TranslationMap = { 'conversations.taskKanban.moveLeft': 'Move left', 'conversations.taskKanban.moveRight': 'Move right', 'conversations.taskKanban.title': 'Tasks', + 'conversations.taskKanban.approval.default': 'Default', + 'conversations.taskKanban.approval.notRequired': 'Not required', + 'conversations.taskKanban.approval.notRequiredBadge': 'no approval', + 'conversations.taskKanban.approval.required': 'Required', + 'conversations.taskKanban.approval.requiredBadge': 'approval', + 'conversations.taskKanban.approval.requiredBeforeExecution': 'Required before execution', + 'conversations.taskKanban.briefButton': 'Task brief', + 'conversations.taskKanban.briefTitle': 'Task brief', + 'conversations.taskKanban.closeBrief': 'Close task brief', + 'conversations.taskKanban.field.acceptanceCriteria': 'Acceptance criteria', + 'conversations.taskKanban.field.allowedTools': 'Allowed tools', + 'conversations.taskKanban.field.approval': 'Approval', + 'conversations.taskKanban.field.assignedAgent': 'Assigned agent', + 'conversations.taskKanban.field.blocker': 'Blocker', + 'conversations.taskKanban.field.evidence': 'Evidence', + 'conversations.taskKanban.field.notes': 'Notes', + 'conversations.taskKanban.field.objective': 'Objective', + 'conversations.taskKanban.field.plan': 'Plan', + 'conversations.taskKanban.field.status': 'Status', + 'conversations.taskKanban.field.title': 'Title', + 'conversations.taskKanban.saveChanges': 'Save changes', + 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', 'conversations.toolTimeline.turn': 'turn', 'conversations.toolTimeline.workerThread': 'worker thread', 'daemon.serviceBlockingGate.body': @@ -3471,6 +3493,9 @@ const en: TranslationMap = { 'settings.agentAccess.confine.label': 'Confine to workspace', 'settings.agentAccess.confine.desc': 'Restrict the agent to the workspace directory (plus any granted folders), whichever access mode is selected. When off, it can reach anywhere your user can — except the always-blocked credential and system directories.', + 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', + 'settings.agentAccess.requireTaskPlanApproval.desc': + 'Pause before an assigned agent executes an agent-authored task brief.', 'settings.agentAccess.grantedFolders': 'Granted folders', 'settings.agentAccess.alwaysAllow': 'Always-allowed tools', 'settings.agentAccess.alwaysAllowDesc': diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 7ff61b9c20..00a2609e65 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -1115,7 +1115,34 @@ const Conversations = ({ dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: saved })); } catch (error) { debug('putTaskBoard failed: %o', error); - setSendAdvisory('Could not move task; changes were not saved.'); + setSendAdvisory(t('conversations.taskKanban.updateFailed')); + dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: selectedTaskBoard })); + } + }; + + const handleUpdateTaskCard = async ( + card: TaskBoardCard, + nextCard: TaskBoardCard + ): Promise => { + if (!selectedThreadId || !selectedTaskBoard) return; + const now = new Date().toISOString(); + const nextBoard = { + ...selectedTaskBoard, + cards: selectedTaskBoard.cards.map(existing => + existing.id === card.id ? { ...nextCard, updatedAt: now } : existing + ), + updatedAt: now, + }; + dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: nextBoard })); + try { + const saved = await threadApi.putTaskBoard(selectedThreadId, nextBoard.cards); + if (!saved) { + throw new Error('Task board update returned no board'); + } + dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: saved })); + } catch (error) { + debug('putTaskBoard failed: %o', error); + setSendAdvisory(t('conversations.taskKanban.updateFailed')); dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: selectedTaskBoard })); } }; @@ -1554,6 +1581,9 @@ const Conversations = ({ onMove={(card, status) => { void handleMoveTaskCard(card, status); }} + onUpdateCard={(card, nextCard) => { + void handleUpdateTaskCard(card, nextCard); + }} /> )} {visibleMessages.map(msg => ( diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index c362761bd4..772e7631cc 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -933,7 +933,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { fireEvent.click(screen.getByLabelText('Move right')); await waitFor(() => { - expect(screen.getByText('Could not move task; changes were not saved.')).toBeInTheDocument(); + expect( + screen.getByText('Could not update task; changes were not saved.') + ).toBeInTheDocument(); }); expect(threadApi.putTaskBoard).toHaveBeenCalledWith( 'board-thread', @@ -941,6 +943,65 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { ); }); + it('rolls back and shows feedback when task board edit persistence fails', async () => { + const thread = makeThread({ id: 'edit-board-thread', title: 'Edit Board Thread' }); + const board = { + threadId: 'edit-board-thread', + updatedAt: '2026-05-04T10:00:00Z', + cards: [ + { + id: 'task-1', + title: 'Plan rollout', + status: 'todo' as const, + objective: 'Draft the launch task brief', + assignedAgent: 'planner', + approvalMode: 'required' as const, + plan: ['Read docs'], + allowedTools: ['todo'], + acceptanceCriteria: ['Saved board round-trips'], + evidence: [], + order: 0, + updatedAt: '2026-05-04T10:00:00Z', + }, + ], + }; + mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 }); + mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 }); + vi.mocked(threadApi.getTaskBoard).mockResolvedValueOnce(board); + vi.mocked(threadApi.putTaskBoard).mockRejectedValueOnce(new Error('write failed')); + + await act(async () => { + await renderConversations({ + thread: selectedThreadState(thread), + socket: socketState('connected'), + }); + }); + + expect(await screen.findByText('Plan rollout')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Task brief')); + fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Updated rollout' } }); + fireEvent.change(screen.getByLabelText('Assigned agent'), { + target: { value: 'code_executor' }, + }); + fireEvent.click(screen.getByText('Save changes')); + + await waitFor(() => { + expect( + screen.getByText('Could not update task; changes were not saved.') + ).toBeInTheDocument(); + }); + expect(threadApi.putTaskBoard).toHaveBeenCalledWith( + 'edit-board-thread', + expect.arrayContaining([ + expect.objectContaining({ + id: 'task-1', + title: 'Updated rollout', + assignedAgent: 'code_executor', + }), + ]) + ); + }); + it('sends with Enter when the composer is not composing text', async () => { const { textarea, thread } = await renderSelectedConversation(); diff --git a/app/src/pages/conversations/components/TaskKanbanBoard.tsx b/app/src/pages/conversations/components/TaskKanbanBoard.tsx index a96a3a10e3..afb6c7af63 100644 --- a/app/src/pages/conversations/components/TaskKanbanBoard.tsx +++ b/app/src/pages/conversations/components/TaskKanbanBoard.tsx @@ -1,4 +1,14 @@ -import { LuArrowLeft, LuArrowRight } from 'react-icons/lu'; +import { useMemo, useState } from 'react'; +import { + LuArrowLeft, + LuArrowRight, + LuBot, + LuCircleCheck, + LuClipboardList, + LuShieldCheck, + LuWrench, + LuX, +} from 'react-icons/lu'; import { useT } from '../../../lib/i18n/I18nContext'; import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../../types/turnState'; @@ -18,10 +28,21 @@ interface TaskKanbanBoardProps { board: TaskBoard; disabled?: boolean; onMove?: (card: TaskBoardCard, status: TaskBoardCardStatus) => void; + onUpdateCard?: (card: TaskBoardCard, nextCard: TaskBoardCard) => void; } -export function TaskKanbanBoard({ board, disabled = false, onMove }: TaskKanbanBoardProps) { +export function TaskKanbanBoard({ + board, + disabled = false, + onMove, + onUpdateCard, +}: TaskKanbanBoardProps) { const { t } = useT(); + const [selectedCardId, setSelectedCardId] = useState(null); + const selectedCard = useMemo( + () => board.cards.find(card => card.id === selectedCardId) ?? null, + [board.cards, selectedCardId] + ); if (board.cards.length === 0) return null; @@ -99,6 +120,39 @@ export function TaskKanbanBoard({ board, disabled = false, onMove }: TaskKanbanB )} +
+ {card.assignedAgent && ( + + + {card.assignedAgent} + + )} + {card.allowedTools && card.allowedTools.length > 0 && ( + + + {card.allowedTools.length} + + )} + {card.approvalMode && ( + + + {card.approvalMode === 'required' + ? t('conversations.taskKanban.approval.requiredBadge') + : t('conversations.taskKanban.approval.notRequiredBadge')} + + )} + {card.acceptanceCriteria && card.acceptanceCriteria.length > 0 && ( + + + {card.acceptanceCriteria.length} + + )} +
+ {card.objective && ( +

+ {card.objective} +

+ )} {card.notes && (

{card.notes} @@ -109,12 +163,376 @@ export function TaskKanbanBoard({ board, disabled = false, onMove }: TaskKanbanB {card.blocker}

)} + {(onUpdateCard || + card.plan?.length || + card.allowedTools?.length || + card.acceptanceCriteria?.length || + card.evidence?.length || + card.objective || + card.assignedAgent || + card.approvalMode) && ( + + )} ))}

))} + {selectedCard && ( + setSelectedCardId(null)} + onUpdate={onUpdateCard} + /> + )} + + ); +} + +function TaskBriefDialog({ + card, + disabled, + onClose, + onUpdate, +}: { + card: TaskBoardCard; + disabled: boolean; + onClose: () => void; + onUpdate?: (card: TaskBoardCard, nextCard: TaskBoardCard) => void; +}) { + const { t } = useT(); + const editable = Boolean(onUpdate) && !disabled; + const [title, setTitle] = useState(card.title); + const [status, setStatus] = useState(card.status); + const [objective, setObjective] = useState(card.objective ?? ''); + const [assignedAgent, setAssignedAgent] = useState(card.assignedAgent ?? ''); + const [approvalMode, setApprovalMode] = useState(card.approvalMode ?? ''); + const [plan, setPlan] = useState(joinLines(card.plan)); + const [allowedTools, setAllowedTools] = useState(joinLines(card.allowedTools)); + const [acceptanceCriteria, setAcceptanceCriteria] = useState(joinLines(card.acceptanceCriteria)); + const [evidence, setEvidence] = useState(joinLines(card.evidence)); + const [notes, setNotes] = useState(card.notes ?? ''); + const [blocker, setBlocker] = useState(card.blocker ?? ''); + + const save = () => { + if (!editable) return; + const trimmedTitle = title.trim(); + if (!trimmedTitle) return; + onUpdate?.(card, { + ...card, + title: trimmedTitle, + status, + objective: emptyToNull(objective), + assignedAgent: emptyToNull(assignedAgent), + approvalMode: + approvalMode === 'required' || approvalMode === 'not_required' ? approvalMode : null, + plan: splitLines(plan), + allowedTools: splitLines(allowedTools), + acceptanceCriteria: splitLines(acceptanceCriteria), + evidence: splitLines(evidence), + notes: emptyToNull(notes), + blocker: emptyToNull(blocker), + }); + onClose(); + }; + + return ( +
+
+
+
+

+ {t('conversations.taskKanban.briefTitle')} +

+

+ {card.title} +

+
+ +
+ + {editable ? ( +
+ +
+ + + +
+ + + + + + + +
+ + +
+
+ ) : ( +
+ + + + + + + + + +
+ )} +
+
+ ); +} + +function BriefInput({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function BriefTextarea({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (value: string) => void; +}) { + return ( +