diff --git a/CHANGELOG.md b/CHANGELOG.md index e00d7e1c..5ffc9df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to DreamCoder will be documented in this file. - **Sessions can start other sessions and message each other.** The agent can start a new session with `StartSession`, either fresh with a brief or as a fork of its own conversation, optionally in a new git worktree; you approve every new session, whatever the permission mode. Any session can message any other with `SendMessage`, across projects: a message wakes the receiver, and a progress report from a child to its parent waits for the parent's next turn instead of interrupting it (a parent that is already working sees it right away). Reports waiting for the parent are listed above its message box. The server stamps who sent each message, so a session cannot pass itself off as you or as another session. `ListSessions` lists sessions, and `RenameSession` lets the agent rename its own session (#239). - **Sessions are nested in the sidebar.** Sessions the agent started, and conversations you forked, appear under the session they came from, with an icon for each kind and a count that collapses or expands them. Messages from another session appear in the conversation as a one-line row with the sender, which expands to the full text (#239). +- **Project board.** Each project gets a persistent board of tasks and sub-tasks that you and your sessions share; every worktree of the project uses the same board. Open it from the project in the sidebar. A task records its owner, reviewer and creator, acceptance criteria, priority, dependencies, progress, and the owner's and reviewer's reports. Sessions read and update the board with four tools (`KanbanList`, `KanbanGet`, `KanbanCreate`, `KanbanUpdate`): they split work into sub-tasks, record progress, block a task with a question, submit their work and review it. The board notifies the sessions involved when they are assigned, when work is submitted, blocked, sent back, reset or cancelled, and when you comment. `StartSession` can start a session as a task's owner or reviewer. The board page has a board view and a hierarchy view, opens a task in the side panel, and lists what waits on you under "Needs me": blocked tasks, deliveries to accept and tasks whose owner session is gone. You approve every time a session creates the board, starts a session on a task, cancels a task, changes acceptance criteria it did not set, or makes itself a reviewer; only a task's creator or you choose its reviewer (#242). ### Changed @@ -15,6 +16,8 @@ All notable changes to DreamCoder will be documented in this file. ### Fixed +- Permission prompts that ask every time, such as starting a session or editing files under `.git/`, no longer offer "Allow for this session", which could not take effect (#242). +- Tools sent to providers through the OpenAI Responses API are no longer in strict mode, so their optional parameters stay optional (#242). - Confirmation dialogs keep long workspace paths inside their bounds, so Cancel and Enable remain visible when enabling bypass permissions (#226). Thanks @imshah-id. - Reading environment files, credential files, and SSH private keys now requires explicit approval through both the Read tool and read-only Bash commands. Recursive directory reads, Grep tool glob filters, and files read from Git history are not covered yet (#235). Thanks @AIR-Xie. - The exact shell no-op `:` is recognized as read-only and no longer requires approval (#235). Thanks @AIR-Xie. diff --git a/README.md b/README.md index f45e9a94..c991c152 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ DreamCoder runs its desktop app and session service locally, bringing model conf * **Follow the work**: Review sessions, file changes, terminal activity, and tool calls on your desktop. * **See it, not just read it**: The agent can answer with interactive pages (charts, diagrams, step-by-step walkthroughs) shown right in the conversation, and a built-in browser shows your dev server next to the chat. * **Design and clone**: Start from 60+ templates and 156+ design systems with `/design`: the agent interviews you first, then builds a web page or app prototype. It can also clone a website. Results are kept in your project's `dc_artifacts/`, ready to export, keep editing, or compare side by side on a canvas. +* **Plan work on a board**: A project board keeps tasks, owners, reviewers and progress for you and your sessions, across sessions and worktrees. * **Continue on your phone**: Enable H5 Access to reach desktop sessions from a phone browser on the same LAN. Access across networks requires a reverse proxy you configure yourself. * **Learn how a coding agent works**: A seven-part source code walkthrough follows one code change through the execution loop, tools, permissions, sessions, and desktop integration. See the [Coding Agent Implementation Guide](docs/tutorial/README.en.md). @@ -121,7 +122,30 @@ Provider settings and API keys are stored in local files. When you use a cloud m The templates and most of the design systems are converted from [Open Design](https://github.com/nexu-io/open-design) (Apache-2.0); see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). -### 4. Multi-Provider Configuration +### 4. Project Board +* **One board for you and your sessions**: Each project gets a persistent board of tasks and sub-tasks. You create and assign tasks there; sessions read the board, split their work into sub-tasks, record progress and submit a report when they finish. The board outlives the sessions, and every worktree of the project shares it. +* **Owner, reviewer and creator**: Each task records who does it, who reviews it and who created it. The reviewer checks the delivery against the acceptance criteria and accepts it or sends it back; without a reviewer, you accept it. The hierarchy view shows the whole task tree, and Needs me collects what waits on you: a blocked task with the session's question, or a delivery to accept. +* **You stay in charge**: The creator sets a task's acceptance criteria. When another session wants to change them, it asks you every time, and the card shows which criteria are added and removed. Sessions also ask before they create the board, start a session on a task, cancel a task or make themselves a reviewer. + + + + The project board with a task open in the side panel: status, owner, reviewer with review instructions, and the delivery report + + + + A session asks to change a task's acceptance criteria; the card lists the added and removed criteria + + + + + The hierarchy view: tasks and sub-tasks with status, assignee and dependencies + + + + Needs me: a blocked task with the session's question and a delivery waiting for acceptance + + +### 5. Multi-Provider Configuration * **Presets and custom endpoints**: Set the API key, endpoint, and model mapping for your chosen provider. * **Connection check**: Test the availability and latency of configured endpoints in Settings. @@ -130,7 +154,7 @@ The templates and most of the design systems are converted from [Open Design](ht Adding a provider from a preset -### 5. Claude Code Workflows +### 6. Claude Code Workflows * **Computer Use**: operates desktop apps and browsers in the background through UI elements, powered by [cua-driver](https://github.com/trycua/cua) (MIT). DreamCoder downloads it when you turn Computer Use on; no Python needed. Verified on macOS; Windows needs community testing, Linux is a preview. * **Transparent tool execution**: File edits and terminal commands are surfaced clearly, so it's easy to understand and review what the agent is doing. * **MCP extensibility**: Expand context and tooling through the Model Context Protocol. @@ -140,11 +164,11 @@ The templates and most of the design systems are converted from [Open Design](ht Computer Use settings -### 6. Continue on Your Phone over LAN +### 7. Continue on Your Phone over LAN * **H5 Access**: Enable access in Settings, manage the token, and use the QR code to connect a phone on the same LAN. * **Access scope**: Keep the desktop app running. Cross-network access requires your own reverse proxy; the deployment guide is still in progress. -### 7. MCP Extensions +### 8. MCP Extensions * **Visual configuration**: Manage MCP servers in the UI instead of editing JSON by hand. diff --git a/README_zh.md b/README_zh.md index 1d0a127a..b73c02e9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -44,6 +44,7 @@ DreamCoder 在本机运行桌面应用与会话服务,把模型配置、编程 * **看清编程过程**:在桌面查看会话、文件变更、终端和工具调用。 * **不只是文字**:AI 可以直接在对话里给出可交互的页面,如图表、示意图、分步演示;内置浏览器能在对话旁边打开你的开发服务器。 * **设计与复刻**:用 `/design` 从 60+ 个模板和 156+ 套设计系统出发,Agent 先访谈再动手,做出网页或应用原型;也能复刻网站。成果保存在项目的 `dc_artifacts/` 里,可导出、可继续改、可在画布上并排比较。 +* **在看板上安排工作**:项目看板为你和会话记录任务、负责人、审核人和进展,跨会话、跨 worktree 保留。 * **从手机接续**:启用 H5 接入后,可在同一局域网内通过手机浏览器访问桌面会话;跨网络访问需要自行配置反向代理。 * **读懂编程智能体**:附带七篇源码导读,沿一次代码修改讲解执行循环、工具、权限、会话与桌面集成,见[《编程智能体实现指南》](docs/tutorial/README.md)。 @@ -121,7 +122,30 @@ Provider 配置和 API Key 保存在本机文件中。使用云端模型时, 模板和大部分设计系统转换自 [Open Design](https://github.com/nexu-io/open-design)(Apache-2.0),详见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 -### 4. 多模型配置 +### 4. 项目看板 +* **你和会话共用一块看板**:每个项目一块持久的看板,记录任务和子任务。你在看板上建任务、分配给会话;会话读看板,把工作拆成子任务,记录进展,做完提交交付报告。会话结束了看板还在,同一项目的各个 worktree 共用同一块看板。 +* **负责人、审核人和创建者**:每个任务记着谁来做、谁来审、谁建的。审核人对照验收要点检查交付,接受或退回;没有审核人时由你验收。层级视图展示整棵任务树,"待我处理" 汇总等你处理的事:带着会话提问的阻塞任务,和等你验收的交付。 +* **由你把关**:验收要点由任务的创建者定。其他会话要改时每次都要你确认,确认卡片列出新增和删除的要点。会话建看板、开新会话做任务、取消任务、把自己设为审核人之前,也都会先问你。 + + + + 打开任务详情的项目看板:状态、负责人、带审核说明的审核人和交付报告 + + + + 会话请求修改任务的验收要点,确认卡片列出新增和删除的要点 + + + + + 层级视图:任务和子任务,带状态、负责人和依赖 + + + + 待我处理:一条带会话提问的阻塞任务,一条等待验收的交付 + + +### 5. 多模型配置 * **Provider 预设与自定义端点**:按所选服务商配置 API Key、地址与模型映射。 * **连接检查**:在设置界面测试已配置端点的可用性与延迟。 @@ -130,7 +154,7 @@ Provider 配置和 API Key 保存在本机文件中。使用云端模型时, 从预设添加 Provider -### 5. Claude Code 工作流 +### 6. Claude Code 工作流 * **Computer Use**:在后台按界面元素操作桌面应用和浏览器,由 [cua-driver](https://github.com/trycua/cua)(MIT)驱动。开启时 DreamCoder 自动下载,不需要 Python。macOS 已验证;Windows 有待社区实测,Linux 为预览。 * **工具调用全程可见**:AI 读写文件、执行终端命令的过程透明呈现,便于理解与审查。 * **MCP 扩展能力**:通过 Model Context Protocol 持续扩展 AI 的上下文与工具能力。 @@ -140,13 +164,13 @@ Provider 配置和 API Key 保存在本机文件中。使用云端模型时, Computer Use 设置 -### 6. 局域网内手机接续 +### 7. 局域网内手机接续 * **H5 接入**:在设置中开启访问、管理 Token,并使用二维码从同一局域网内的手机连接。 * **访问范围**:桌面端需保持运行;跨网络访问需要自行配置反向代理,相关部署指南仍在编写。 --- -### 7. MCP 扩展 +### 8. MCP 扩展 * **支持 MCP**:通过 Model Context Protocol 接入外部工具。 * **配置过程图形化**:不再手写 JSON,通过界面管理 MCP 服务器。 diff --git a/assets/dark/kanban-approval.png b/assets/dark/kanban-approval.png new file mode 100644 index 00000000..f20fa59b Binary files /dev/null and b/assets/dark/kanban-approval.png differ diff --git a/assets/dark/kanban-inbox.png b/assets/dark/kanban-inbox.png new file mode 100644 index 00000000..519e2af7 Binary files /dev/null and b/assets/dark/kanban-inbox.png differ diff --git a/assets/dark/kanban-tree.png b/assets/dark/kanban-tree.png new file mode 100644 index 00000000..742a8d23 Binary files /dev/null and b/assets/dark/kanban-tree.png differ diff --git a/assets/dark/kanban.png b/assets/dark/kanban.png new file mode 100644 index 00000000..68623750 Binary files /dev/null and b/assets/dark/kanban.png differ diff --git a/assets/kanban-approval.png b/assets/kanban-approval.png new file mode 100644 index 00000000..d6c3e58c Binary files /dev/null and b/assets/kanban-approval.png differ diff --git a/assets/kanban-inbox.png b/assets/kanban-inbox.png new file mode 100644 index 00000000..e846268a Binary files /dev/null and b/assets/kanban-inbox.png differ diff --git a/assets/kanban-tree.png b/assets/kanban-tree.png new file mode 100644 index 00000000..96997c8a Binary files /dev/null and b/assets/kanban-tree.png differ diff --git a/assets/kanban.png b/assets/kanban.png new file mode 100644 index 00000000..b2294804 Binary files /dev/null and b/assets/kanban.png differ diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts index 1ec3f4e0..824cee3d 100644 --- a/desktop/src/api/client.ts +++ b/desktop/src/api/client.ts @@ -69,6 +69,13 @@ export class ApiError extends Error { } } +export function apiErrorCode(error: unknown): string | null { + if (!(error instanceof ApiError)) return null + const body = error.body + if (!body || typeof body !== 'object' || !('error' in body)) return null + return typeof body.error === 'string' ? body.error : null +} + async function request(method: string, path: string, body?: unknown, options?: { timeout?: number }): Promise { const url = `${baseUrl}${path}` const headers = buildHeaders() diff --git a/desktop/src/api/kanban.ts b/desktop/src/api/kanban.ts new file mode 100644 index 00000000..a3dc8e94 --- /dev/null +++ b/desktop/src/api/kanban.ts @@ -0,0 +1,32 @@ +import type { BoardListing, BoardView, KanbanCommand, KanbanEvent } from '@protocol' +import { api } from './client' + +export type BoardPoll = { changed: true; view: BoardView } | { changed: false; etag: string } + +export type CommandOutcome = { view: BoardView; warnings: string[]; card?: number } + +function boardPath(key: string) { + return `/api/kanban/boards/${encodeURIComponent(key)}` +} + +export const kanbanApi = { + listBoards() { + return api.get<{ boards: BoardListing[] }>('/api/kanban/boards') + }, + + createBoard(project: string) { + return api.post<{ view: BoardView }>('/api/kanban/boards', { project }) + }, + + view(key: string, since?: string) { + return api.get(since ? `${boardPath(key)}?since=${encodeURIComponent(since)}` : boardPath(key)) + }, + + command(key: string, command: KanbanCommand) { + return api.post(`${boardPath(key)}/commands`, { command }) + }, + + history(key: string, card: number) { + return api.get<{ events: KanbanEvent[] }>(`${boardPath(key)}/cards/${card}/history`) + }, +} diff --git a/desktop/src/components/README.md b/desktop/src/components/README.md index 28ea6d87..960527d8 100644 --- a/desktop/src/components/README.md +++ b/desktop/src/components/README.md @@ -63,6 +63,7 @@ that only renames props or passes them through does not belong here. | `FormSelect` | Select with labelled triggers and empty application values encoded as nonempty Radix item values | | `Modal` | Dialog, localized close action and existing modal props | | `ConfirmDialog` | Alert Dialog with application confirmation callbacks | +| `TreeConnector` | Elbow lines between nested rows, shared by the sidebar session tree and the board's hierarchy view | | `MobileBottomSheet` | Drawer with localized title and close control | | `Tooltip` | The tooltip entry point: a Tooltip and content wrapper that keeps the trigger mounted when the content is empty; the single `TooltipProvider` (400 ms delay) is mounted in `App`. `ActivitySettings` and `TerminalSettings` use `ui/tooltip` directly because they need an instant delay, a controlled open state or custom content | | `ActionMenu` | Shared Dropdown Menu and Context Menu actions/focus handling; the dropdown also takes single-choice submenus (`MenuChoiceGroup`) | diff --git a/desktop/src/components/chat/DesignTemplateChoice.test.tsx b/desktop/src/components/chat/DesignTemplateChoice.test.tsx index 018f3cf8..81354820 100644 --- a/desktop/src/components/chat/DesignTemplateChoice.test.tsx +++ b/desktop/src/components/chat/DesignTemplateChoice.test.tsx @@ -163,7 +163,7 @@ describe('DesignTemplateChoice', () => { '/app/design-templates/saas/index.html', '/app/design-templates/saas', )) - expect(useWorkspacePanelStore.getState().panelBySession[SESSION]?.tabs[0]).toMatchObject({ kind: 'browser', template: { id: 'saas' } }) + expect(useWorkspacePanelStore.getState().panelByTab[SESSION]?.tabs[0]).toMatchObject({ kind: 'browser', template: { id: 'saas' } }) }) it('tells the user when the preview could not be opened', async () => { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index f4e26330..8d949ef1 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -13,7 +13,7 @@ import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n/locales/en' import { UserMessage } from './UserMessage' -import { SessionMessageCard } from './SessionMessageCard' +import { OriginMessageCard } from './OriginMessageCard' import { AssistantMessage } from './AssistantMessage' import { StreamingReply } from './StreamingReply' import { ThinkingBlock } from './ThinkingBlock' @@ -2045,8 +2045,8 @@ export const MessageBlock = memo(function MessageBlock({ switch (message.type) { case 'user_text': - if (message.origin?.kind === 'session') { - return + if (message.origin) { + return } return ( ) diff --git a/desktop/src/components/chat/SessionMessageCard.test.tsx b/desktop/src/components/chat/OriginMessageCard.test.tsx similarity index 56% rename from desktop/src/components/chat/SessionMessageCard.test.tsx rename to desktop/src/components/chat/OriginMessageCard.test.tsx index 2a87a543..f8d0b005 100644 --- a/desktop/src/components/chat/SessionMessageCard.test.tsx +++ b/desktop/src/components/chat/OriginMessageCard.test.tsx @@ -1,16 +1,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' -import { SessionMessageCard } from './SessionMessageCard' +import { OriginMessageCard } from './OriginMessageCard' import { MessageBlock } from './MessageList' import { useChatStore } from '../../stores/chatStore' import { useSessionStore } from '../../stores/sessionStore' import { useTabStore } from '../../stores/tabStore' -import type { SessionOrigin, UIMessage } from '../../types/chat' +import type { KanbanOrigin, SessionOrigin, UIMessage } from '../../types/chat' +import { useKanbanStore } from '../../stores/kanbanStore' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' + +const kanbanApiMock = vi.hoisted(() => ({ listBoards: vi.fn() })) +vi.mock('../../api/kanban', () => ({ kanbanApi: kanbanApiMock })) const origin: SessionOrigin = { kind: 'session', sessionId: 'child-1', name: 'docs-reorg', purpose: 'report' } +const kanbanOrigin: KanbanOrigin = { kind: 'kanban', project: '/repo', card: { id: 12, title: 'OAuth module' }, purpose: 'assignment' } -describe('SessionMessageCard', () => { +describe('OriginMessageCard', () => { const connectToSession = vi.fn() beforeEach(() => { @@ -23,7 +29,7 @@ describe('SessionMessageCard', () => { afterEach(() => cleanup()) it('is collapsed by default and shows the summary as the preview', () => { - render() + render() expect(screen.getByText('docs-reorg')).toBeInTheDocument() expect(screen.getByText('Report')).toBeInTheDocument() @@ -33,7 +39,7 @@ describe('SessionMessageCard', () => { }) it('expands to the body as markdown, with the copy action', () => { - render() + render() fireEvent.click(screen.getByText('Report')) @@ -46,7 +52,7 @@ describe('SessionMessageCard', () => { ['brief', 'Brief'], ['report', 'Report'], ] as const)('labels the %s purpose', (purpose, label) => { - render() + render() expect(screen.getByText(label)).toBeInTheDocument() }) @@ -57,7 +63,7 @@ describe('SessionMessageCard', () => { projectPath: '/p', workDir: '/p', workDirExists: true, }], }) - render() + render() expect(screen.getByRole('button', { name: 'Open session Project manager' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Open session docs-reorg' })).not.toBeInTheDocument() @@ -70,13 +76,13 @@ describe('SessionMessageCard', () => { projectPath: '/p', workDir: '/p', workDirExists: true, }], }) - render() + render() expect(screen.getByRole('button', { name: 'Open session Reorganise docs' })).toBeInTheDocument() }) it('opens the source session when the title is clicked', () => { - render() + render() fireEvent.click(screen.getByRole('button', { name: 'Open session docs-reorg' })) @@ -86,7 +92,7 @@ describe('SessionMessageCard', () => { }) it('keeps the open-session control outside the collapsible trigger as a native button', () => { - render() + render() const open = screen.getByRole('button', { name: 'Open session docs-reorg' }) expect(open.tagName).toBe('BUTTON') @@ -95,7 +101,7 @@ describe('SessionMessageCard', () => { }) it('offers copy but no fork action', () => { - render() + render() fireEvent.click(screen.getByText('Report')) expect(screen.getByRole('button', { name: 'Copy prompt' })).toBeInTheDocument() @@ -103,6 +109,57 @@ describe('SessionMessageCard', () => { }) }) +describe('OriginMessageCard for the project board', () => { + afterEach(() => cleanup()) + + beforeEach(() => { + useKanbanStore.setState({ boards: null, views: {} }) + useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) + useTabStore.setState({ tabs: [{ sessionId: 'worker', title: 'Worker', type: 'session', status: 'idle' }], activeTabId: 'worker' }) + kanbanApiMock.listBoards.mockResolvedValue({ boards: [{ key: 'repo', project: '/repo' }] }) + }) + + it('opens the card as a task tab in the current session panel when its number is clicked', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Open task #12' })) + + await vi.waitFor(() => expect(useWorkspacePanelStore.getState().getActiveTab('worker')).toMatchObject({ kind: 'task', board: 'repo', card: 12 })) + expect(useWorkspacePanelStore.getState().isPanelOpen('worker')).toBe(true) + expect(useTabStore.getState().activeTabId).toBe('worker') + expect(screen.queryByText('Card #12 is yours')).not.toBeInTheDocument() + }) + + it('shows the board, the card and the purpose, collapsed', () => { + render() + + expect(screen.getByText('Project board')).toBeInTheDocument() + expect(screen.getByText('#12 OAuth module')).toBeInTheDocument() + expect(screen.getByText('Assignment')).toBeInTheDocument() + expect(screen.queryByText('Card #12 is yours')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Open session/ })).not.toBeInTheDocument() + }) + + it('expands to the notification body', () => { + render() + + fireEvent.click(screen.getByText('Assignment')) + + expect(screen.getByText('Card #12 is yours')).toBeInTheDocument() + }) + + it.each([ + ['assignment', 'Assignment'], + ['revocation', 'Unassigned'], + ['comment', 'Comment'], + ['review', 'Ready for review'], + ['blocked', 'Blocked'], + ] as const)('labels the %s purpose', (purpose, label) => { + render() + expect(screen.getByText(label)).toBeInTheDocument() + }) +}) + describe('MessageBlock user messages', () => { const base = { id: 'u-1', type: 'user_text', content: 'hello there', timestamp: 1 } as const @@ -128,6 +185,14 @@ describe('MessageBlock user messages', () => { expect(screen.queryByRole('button', { name: 'Fork a new conversation' })).not.toBeInTheDocument() }) + it('renders a message with a project board origin as a card, without the fork action', () => { + const { container } = renderMessage({ ...base, origin: kanbanOrigin }) + + expect(container.querySelector('[data-message-shell="kanban"]')).not.toBeNull() + expect(container.querySelector('[data-message-shell="user"]')).toBeNull() + expect(screen.queryByRole('button', { name: 'Fork a new conversation' })).not.toBeInTheDocument() + }) + it('renders a message without origin as the user bubble with its fork action', () => { const { container } = renderMessage(base) diff --git a/desktop/src/components/chat/OriginMessageCard.tsx b/desktop/src/components/chat/OriginMessageCard.tsx new file mode 100644 index 00000000..ec1602be --- /dev/null +++ b/desktop/src/components/chat/OriginMessageCard.tsx @@ -0,0 +1,144 @@ +import { memo, useState, type ReactNode } from 'react' +import { MessageSquareShare, SquareKanban } from 'lucide-react' +import { useTranslation, type TranslationKey } from '../../i18n' +import type { KanbanOrigin, SessionOrigin, UserMessageOrigin } from '../../types/chat' +import { MarkdownRenderer } from '../markdown/MarkdownRenderer' +import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle } from '../shared/ui/trace' +import { MessageActionBar } from './MessageActionBar' +import { SessionLink } from './SessionLink' +import { toast } from 'sonner' +import { Button } from '../shared/ui/button' +import { boardForProject, useKanbanStore } from '../../stores/kanbanStore' +import { openBoardTab, useTabStore } from '../../stores/tabStore' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' +import { useMobileViewport } from '../../hooks/useMobileViewport' +import { isDesktopRuntime } from '../../lib/desktopRuntime' +import { kanbanErrorMessage, type Translate } from '../kanban/board' + +const SESSION_PURPOSE_LABEL_KEYS: Record = { + message: 'chat.sessionMessage.purpose.message', + brief: 'chat.sessionMessage.purpose.brief', + report: 'chat.sessionMessage.purpose.report', +} + +const KANBAN_PURPOSE_LABEL_KEYS: Record = { + assignment: 'chat.kanbanMessage.purpose.assignment', + revocation: 'chat.kanbanMessage.purpose.revocation', + comment: 'chat.kanbanMessage.purpose.comment', + review: 'chat.kanbanMessage.purpose.review', + blocked: 'chat.kanbanMessage.purpose.blocked', + changes_requested: 'chat.kanbanMessage.purpose.changes_requested', + accepted: 'chat.kanbanMessage.purpose.accepted', + unblocked: 'chat.kanbanMessage.purpose.unblocked', + reset: 'chat.kanbanMessage.purpose.reset', + cancelled: 'chat.kanbanMessage.purpose.cancelled', + reopened: 'chat.kanbanMessage.purpose.reopened', +} + +type Header = { + icon: ReactNode + from: TranslationKey + source: ReactNode + purpose: TranslationKey + preview?: string +} + +async function openKanbanCard(t: Translate, origin: KanbanOrigin, card: number, inPanel: boolean) { + try { + const store = useKanbanStore.getState() + const board = boardForProject(store.boards ?? await store.loadBoards(), origin.project) + if (!board) { + toast.error(t('kanban.error.NOT_FOUND')) + return + } + const sessionId = useTabStore.getState().activeTabId + if (inPanel && sessionId) { + useWorkspacePanelStore.getState().openTab(sessionId, { kind: 'task', board: board.key, card }) + return + } + const tabId = openBoardTab(origin.project, t('kanban.title')) + useWorkspacePanelStore.getState().openTab(tabId, { kind: 'task', board: board.key, card }) + } catch (error) { + toast.error(kanbanErrorMessage(t, error)) + } +} + +function KanbanCardLink({ origin }: { origin: KanbanOrigin }) { + const t = useTranslation() + const { card } = origin + const inPanel = !(useMobileViewport() && !isDesktopRuntime()) + return ( + + ) +} + +function headerFor(origin: UserMessageOrigin): Header { + if (origin.kind === 'kanban') { + return { + icon: , + from: 'chat.kanbanMessage.from', + source: , + purpose: KANBAN_PURPOSE_LABEL_KEYS[origin.purpose], + } + } + return { + icon: , + from: 'chat.sessionMessage.from', + source: , + purpose: SESSION_PURPOSE_LABEL_KEYS[origin.purpose], + preview: origin.summary, + } +} + +type Props = { + content: string + origin: UserMessageOrigin + timestamp?: number +} + +export const OriginMessageCard = memo(function OriginMessageCard({ content, origin, timestamp }: Props) { + const t = useTranslation() + const [expanded, setExpanded] = useState(false) + const header = headerFor(origin) + + return ( +
+ + +
+ + {header.icon} + {t(header.from)} + + {header.source} + + + {t(header.purpose)} + {!expanded && header.preview && {header.preview}} + + +
+ +
+ +
+ +
+
+
+
+ ) +}) diff --git a/desktop/src/components/chat/PermissionDialog.test.tsx b/desktop/src/components/chat/PermissionDialog.test.tsx new file mode 100644 index 00000000..f61f69f3 --- /dev/null +++ b/desktop/src/components/chat/PermissionDialog.test.tsx @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { PermissionDialog } from './PermissionDialog' +import { useChatStore } from '../../stores/chatStore' +import { useKanbanStore } from '../../stores/kanbanStore' +import { useSessionStore } from '../../stores/sessionStore' +import { fixtureCard, fixtureView } from '../kanban/kanbanFixture' +import { useSettingsStore } from '../../stores/settingsStore' +import { useTabStore } from '../../stores/tabStore' + +describe('PermissionDialog for the project board', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + useTabStore.setState({ activeTabId: 'pm', tabs: [{ sessionId: 'pm', title: 'PM', type: 'session' as const, status: 'idle' }] }) + useChatStore.setState({ sessions: {} }) + const session = (id: string, title: string) => ({ id, title, createdAt: '', modifiedAt: '', messageCount: 1, projectPath: '/repo', projectRoot: '/repo', workDir: '/repo', workDirExists: true }) + useSessionStore.setState({ sessions: [session('pm', 'PM'), session('worker', 'Token worker')] }) + useKanbanStore.setState({ boards: [{ key: 'repo', project: '/repo' }], views: { repo: fixtureView([fixtureCard(3, { title: 'Rotate keys' })], { key: 'repo', project: '/repo' }) } }) + }) + + it('names who gets which role on which task, and what changes', () => { + const { unmount } = render() + expect(screen.getByText('Make this session the reviewer of #3?')).toBeInTheDocument() + expect(screen.getByText('#3 Rotate keys')).toBeInTheDocument() + expect(screen.getByText(/reviews what this task delivers/)).toBeInTheDocument() + unmount() + + render() + expect(screen.getByText('Make Token worker the owner of #3?')).toBeInTheDocument() + expect(screen.getByText(/may split it into sub-tasks/)).toBeInTheDocument() + }) + + it('says which task a new session will own or review', () => { + const { unmount } = render() + expect(screen.getByText('Start a new session to own #3?')).toBeInTheDocument() + expect(screen.getByText('#3 Rotate keys')).toBeInTheDocument() + unmount() + + const review = render() + expect(screen.getByText('Start a new session to review #3?')).toBeInTheDocument() + review.unmount() + + render() + expect(screen.getByText('Allow Claude to use StartSession?')).toBeInTheDocument() + }) + + it('names the task by its number when the board is not loaded in this window', () => { + useKanbanStore.setState({ boards: null, views: {} }) + render() + expect(screen.getByText('#9')).toBeInTheDocument() + }) + + it('names the task a session wants to cancel and its reason', () => { + render() + expect(screen.getByText('Cancel #3?')).toBeInTheDocument() + expect(screen.getByText('#3 Rotate keys')).toBeInTheDocument() + expect(screen.getByText('Covered by #5')).toBeInTheDocument() + }) + + it('shows which acceptance criteria an owner wants to add and remove', () => { + useChatStore.setState({ sessions: { pm: { pendingPermission: { requestId: 'perm-acceptance' } } } as never }) + useKanbanStore.setState({ views: { repo: fixtureView([fixtureCard(3, { title: 'Rotate keys', acceptance: ['Old keys stop working', 'Runs nightly'] })], { key: 'repo', project: '/repo' }) } }) + render() + expect(screen.getByText('Change the acceptance criteria of #3?')).toBeInTheDocument() + expect(screen.getByText('#3 Rotate keys')).toBeInTheDocument() + expect(screen.getByText('Runs weekly').previousElementSibling).toHaveTextContent('Added') + expect(screen.getByText('Runs nightly').previousElementSibling).toHaveTextContent('Removed') + expect(screen.getByText('Old keys stop working').previousElementSibling).not.toHaveTextContent(/Added|Removed/) + expect(screen.getByText(/Allow only a change you agreed on/)).toBeInTheDocument() + }) + + it('shows an answered acceptance change as the list it asked for, not a diff against what the card became later', () => { + useKanbanStore.setState({ views: { repo: fixtureView([fixtureCard(3, { title: 'Rotate keys', acceptance: ['Old keys stop working', 'Runs weekly', 'Logs each rotation'] })], { key: 'repo', project: '/repo' }) } }) + render() + expect(screen.getByText('Runs weekly')).toBeInTheDocument() + expect(screen.queryByText('Logs each rotation')).not.toBeInTheDocument() + expect(screen.queryByText('Added')).not.toBeInTheDocument() + expect(screen.queryByText('Removed')).not.toBeInTheDocument() + }) + + it('shows the suggested task, and says when it also creates the board', () => { + const { unmount } = render() + expect(screen.getByText('Add this task to the project board?')).toBeInTheDocument() + expect(screen.getByText('Rotate keys')).toBeInTheDocument() + expect(screen.getByText('Found while testing')).toBeInTheDocument() + unmount() + + render() + expect(screen.getByText('Create the project board with this task?')).toBeInTheDocument() + }) + + it('shows no empty input for a tool that takes none', () => { + render() + expect(screen.queryByText('{}')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Show full input' })).not.toBeInTheDocument() + }) + + it('offers to allow for the session only when the answer can be remembered', () => { + const pending = (requestId: string) => useChatStore.setState({ sessions: { pm: { pendingPermission: { requestId } } } } as never) + pending('perm-board') + const { unmount } = render() + expect(screen.queryByRole('button', { name: /Allow for session/ })).not.toBeInTheDocument() + unmount() + + pending('perm-card') + render() + expect(screen.getByRole('button', { name: /Allow for session/ })).toBeInTheDocument() + }) +}) + diff --git a/desktop/src/components/chat/PermissionDialog.tsx b/desktop/src/components/chat/PermissionDialog.tsx index 57c92aa5..2f24888d 100644 --- a/desktop/src/components/chat/PermissionDialog.tsx +++ b/desktop/src/components/chat/PermissionDialog.tsx @@ -3,8 +3,10 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } import { CodeBlock, CodeBlockContent, CodeBlockLine } from '../shared/ui/code-block' import { CodeViewer } from './CodeViewer' import { getToolIcon } from './toolIcons' -import { useState } from 'react' +import { useState, type ReactNode } from 'react' import { useChatStore } from '../../stores/chatStore' +import { boardForProject, useKanbanStore } from '../../stores/kanbanStore' +import { useSessionStore } from '../../stores/sessionStore' import { useTabStore } from '../../stores/tabStore' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n' @@ -20,11 +22,101 @@ type Props = { toolName: string input: unknown description?: string + alwaysAsks?: boolean resolution?: PermissionResolution } -function extractToolDetails(toolName: string, input: unknown, t: (key: TranslationKey, params?: Record) => string): { primary: string; secondary?: string } { - const obj = (input && typeof input === 'object') ? input as Record : {} +type Translate = (key: TranslationKey, params?: Record) => string + +type SpecialDisplay = { title: string; primary: string; secondary?: string; preview?: ReactNode } + +type KanbanRole = 'owner' | 'reviewer' + +type KanbanAssign = { + card: number + role: KanbanRole + target: { kind: 'self' } | { kind: 'new' } | { kind: 'session'; sessionId: string } +} + +const KANBAN_ASSIGN_TITLE: Record = { + owner: 'permission.allowKanbanAssign.owner', + reviewer: 'permission.allowKanbanAssign.reviewer', +} + +const KANBAN_START_TITLE: Record = { + owner: 'permission.allowStartSessionForCard.owner', + reviewer: 'permission.allowStartSessionForCard.reviewer', +} + +const KANBAN_ROLE_NOTE: Record = { + owner: 'permission.kanbanRole.owner', + reviewer: 'permission.kanbanRole.reviewer', +} + +function record(value: unknown): Record { + return value && typeof value === 'object' ? value as Record : {} +} + +function kanbanRole(value: unknown): KanbanRole | null { + return value === 'owner' || value === 'reviewer' ? value : null +} + +function kanbanAssign(toolName: string, input: unknown): KanbanAssign | null { + const obj = record(input) + if (toolName === 'StartSession') return typeof obj.card === 'number' ? { card: obj.card, role: kanbanRole(obj.role) ?? 'owner', target: { kind: 'new' } } : null + if (toolName !== 'KanbanUpdate') return null + const command = record(obj.command) + const role = kanbanRole(command.role) + if (command.type !== 'assign' || typeof command.card !== 'number' || !role) return null + if (command.target === 'self') return { card: command.card, role, target: { kind: 'self' } } + const sessionId = record(command.target).sessionId + return typeof sessionId === 'string' ? { card: command.card, role, target: { kind: 'session', sessionId } } : null +} + +function kanbanCancel(toolName: string, input: unknown): { card: number; reason: string | null } | null { + if (toolName !== 'KanbanUpdate') return null + const command = record(record(input).command) + if (command.type !== 'cancel' || typeof command.card !== 'number') return null + return { card: command.card, reason: typeof command.reason === 'string' && command.reason.trim() ? command.reason : null } +} + +function kanbanAcceptance(toolName: string, input: unknown): { card: number; items: string[] } | null { + if (toolName !== 'KanbanUpdate') return null + const command = record(record(input).command) + if (command.type !== 'edit_card' || typeof command.card !== 'number' || !Array.isArray(command.acceptance)) return null + return { card: command.card, items: command.acceptance.filter((item): item is string => typeof item === 'string') } +} + +function AcceptanceChange({ before, after, t }: { before: readonly string[] | null; after: readonly string[]; t: Translate }) { + const removed = before?.filter((item) => !after.includes(item)) ?? [] + const tag = (key: TranslationKey) => {t(key)} + return ( +
    + {after.map((item) => ( +
  • + {before && !before.includes(item) ? tag('permission.kanbanAcceptance.added') :
  • + ))} + {removed.map((item) => ( +
  • + {tag('permission.kanbanAcceptance.removed')} + {item} +
  • + ))} +
+ ) +} + +function kanbanAssignTitle(assign: KanbanAssign, targetTitle: string | null, t: Translate): string { + const { target } = assign + if (target.kind === 'new') return t(KANBAN_START_TITLE[assign.role], { card: assign.card }) + const who = target.kind === 'self' ? t('permission.kanbanAssign.self') : targetTitle || target.sessionId + return t(KANBAN_ASSIGN_TITLE[assign.role], { card: assign.card, target: who }) +} + +function extractToolDetails(toolName: string, input: unknown, t: Translate): { primary: string; secondary?: string } { + const obj = record(input) switch (toolName) { case 'Bash': { @@ -54,13 +146,17 @@ function extractToolDetails(toolName: string, input: unknown, t: (key: Translati return { primary: typeof obj.query === 'string' ? obj.query : '' } case 'WebFetch': return { primary: typeof obj.url === 'string' ? obj.url : '' } - default: - return { primary: typeof input === 'string' ? input : JSON.stringify(input, null, 2) } + case 'KanbanCreate': + return { primary: typeof obj.title === 'string' ? obj.title : '', secondary: typeof obj.description === 'string' && obj.description ? obj.description : undefined } + default: { + const raw = typeof input === 'string' ? input : JSON.stringify(input, null, 2) + return { primary: raw === '{}' ? '' : raw } + } } } -function getPermissionTitle(toolName: string, input: unknown, t: (key: TranslationKey, params?: Record) => string) { - const obj = (input && typeof input === 'object') ? input as Record : {} +function getPermissionTitle(toolName: string, input: unknown, alwaysAsks: boolean, t: Translate) { + const obj = record(input) const filePath = typeof obj.file_path === 'string' ? obj.file_path : '' const fileName = filePath ? filePath.split('/').pop() || filePath : '' @@ -70,6 +166,8 @@ function getPermissionTitle(toolName: string, input: unknown, t: (key: Translati return fileName ? t('permission.allowEditFile', { toolName, fileName }) : t('permission.allowEditFileGeneric', { toolName: toolName.toLowerCase() }) case 'Bash': return t('permission.allowBash') + case 'KanbanCreate': + return t(alwaysAsks ? 'permission.allowKanbanBoard' : 'permission.allowKanbanCard') default: return t('permission.allowTool', { toolName }) } @@ -104,7 +202,7 @@ function resolutionLabelKey(resolution: PermissionResolution | undefined): Trans return resolution.outcome === 'allowed' ? 'permission.allowedElsewhere' : 'permission.deniedElsewhere' } -export function PermissionDialog({ sessionId, requestId, toolName, input, description, resolution }: Props) { +export function PermissionDialog({ sessionId, requestId, toolName, input, description, alwaysAsks, resolution }: Props) { const respondToPermission = useChatStore((s) => s.respondToPermission) const activeTabId = useTabStore((s) => s.activeTabId) const targetSessionId = sessionId ?? activeTabId @@ -113,17 +211,47 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri ) const t = useTranslation() const [showRaw, setShowRaw] = useState(false) + const projectRoot = useSessionStore((s) => s.sessions.find((session) => session.id === targetSessionId)?.projectRoot) + const assign = kanbanAssign(toolName, input) + const cancel = kanbanCancel(toolName, input) + const acceptance = kanbanAcceptance(toolName, input) + const boardCard = assign?.card ?? cancel?.card ?? acceptance?.card ?? null + const loadedCard = useKanbanStore((s) => { + const board = boardCard === null ? null : boardForProject(s.boards, projectRoot) + return board ? s.views[board.key]?.cards.find((card) => card.id === boardCard) ?? null : null + }) + const boardCardTitle = loadedCard?.title ?? null + const boardCardLabel = boardCard === null ? null : boardCardTitle ? `#${boardCard} ${boardCardTitle}` : `#${boardCard}` + const assignTarget = useSessionStore((s) => { + const target = assign?.target + return target?.kind === 'session' ? s.sessions.find((session) => session.id === target.sessionId)?.title ?? null : null + }) const cua = getCuaToolDisplay(toolName, input, t) const Icon = getToolIcon(toolName, cua?.icon) - const details = cua ? { primary: cua.summary } : extractToolDetails(toolName, input, t) + const card = boardCardLabel ?? '' + const special: SpecialDisplay | null = cua + ? { title: cua.permissionTitle, primary: cua.summary } + : assign + ? { title: kanbanAssignTitle(assign, assignTarget, t), primary: card, secondary: t(KANBAN_ROLE_NOTE[assign.role]) } + : cancel + ? { title: t('permission.allowKanbanCancel', { card: cancel.card }), primary: card, secondary: cancel.reason ?? t('permission.kanbanCancel.note') } + : acceptance + ? { + title: t('permission.allowKanbanAcceptance', { card: acceptance.card }), + primary: card, + secondary: t('permission.kanbanAcceptance.note'), + preview: , + } + : null + const details = special ?? extractToolDetails(toolName, input, t) const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2) - const preview = cua ? null : renderPermissionPreview(toolName, input) - const title = cua ? cua.permissionTitle : getPermissionTitle(toolName, input, t) + const preview = special ? special.preview ?? null : renderPermissionPreview(toolName, input) + const title = special?.title ?? getPermissionTitle(toolName, input, alwaysAsks === true, t) // CLI-supplied descriptions for MCP tools quote the raw `mcp__cua__…` name; hide those. const shownDescription = cua && description && /mcp__|\bMCP\b/i.test(description) ? undefined : description const DetailIcon = cua ? Icon : toolName === 'Glob' || toolName === 'Grep' ? Search : FolderOpen - const allowRawToggle = !preview + const allowRawToggle = !preview && rawInput !== '{}' return (