diff --git a/AGENTS.md b/AGENTS.md index 4c58e34c..f2f149c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -Guidance for coding agents (and humans) working on DreamCoder. Setup, build order and the contribution flow are in [docs/CONTRIBUTING_en.md](docs/CONTRIBUTING_en.md); this file covers what is easy to get wrong. +Guidance for coding agents (and humans) working on DreamCoder. Setup, build order and the contribution flow are in [docs/general/CONTRIBUTING_en.md](docs/general/CONTRIBUTING_en.md); this file covers what is easy to get wrong. ## Project layout @@ -8,7 +8,8 @@ Guidance for coding agents (and humans) working on DreamCoder. Setup, build orde - `desktop/electron`: the Electron main process and preload. The page reaches it only through `desktop/src/lib/shell`. - `src/server`: the Bun server that runs as the app's sidecar. It starts one CLI process per session. - `src`: the CLI, derived from Claude Code. The app drives it in `--print` stream-json mode. -- Session state lives on the server. The desktop app and the H5 page mirror it. New session state belongs in the server state store, not only in a client store. See [docs/session-state-tech-design.md](docs/session-state-tech-design.md). +- `docs`: project-wide documents in `docs/general`, and one folder per feature in `docs/features/` for its technical design and related documents. A new feature's design goes in its own folder there. See [docs/README.md](docs/README.md). +- Session state lives on the server. The desktop app and the H5 page mirror it. New session state belongs in the server state store, not only in a client store. See [docs/features/session-state/tech-design.md](docs/features/session-state/tech-design.md). ## Checks diff --git a/CHANGELOG.md b/CHANGELOG.md index e7185bb7..2a1388df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,20 @@ All notable changes to DreamCoder will be documented in this file. ## [Unreleased] +### Added + +- **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). + +### Changed + +- **A message you send while the agent is working waits for the next turn.** It used to be slipped into the running turn. Queued messages are listed above the message box, where you can withdraw one or send the queue now, which stops the current turn and starts the next one; Stop does the same while messages are queued. Queued messages survive a model or permission change that restarts the agent at the end of the turn (#239). + ### Fixed - Confirmation dialogs keep long workspace paths inside their bounds, so Cancel and Enable remain visible when enabling bypass permissions (#226). Thanks @imshah-id. - Images attached from uploads, paste or the built-in browser are kept unchanged within the client limits and resized or compressed when they exceed 2000×2000 pixels or 3.75 MB; a re-encoded image is named after its new type. An image the app cannot decode is attached as it is when it is within 3.75 MB. A file that cannot be attached is named in a message, and the other files picked or dropped with it are still attached. Sent images now use compact thumbnails that still open in the gallery (#218). Thanks @henricop. +- Messages that reach a session in the middle of a turn are saved with the conversation. They used to disappear when the history was reloaded, and the agent forgot them when the session was resumed (#239). - A lead session that is resumed after a restart keeps using its team's task list (#216). The lead looked for tasks in a list named after its session, so `TaskList` returned nothing and new tasks went to a different list than the one the teammates read. The lead now goes back to the team it created when the session is resumed. Thanks @WhitePepperLambSoup. ## [0.8.0] — 2026-11-01 diff --git a/README.md b/README.md index 938581f8..f45e9a94 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Also hosted on AtomGit: [atomgit.com/GoDiao/DreamCoder](https://atomgit.com/GoDi > 🎉 **500 stars!** Thank you to everyone who starred, tried, reported issues, and contributed. DreamCoder is only five months old, and it has grown step by step because of you. Next, we'll keep pace with the best practices of frontier coding agents and try everything that can make the experience better, along with brand-new features. Our goal will always be to **reach for the stars**. -> 🌱 **Contributions welcome!** Browse [good first issues](https://github.com/GoDiao/dreamcoder/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) and [help wanted](https://github.com/GoDiao/dreamcoder/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22), and read the [contributing guide](docs/CONTRIBUTING_en.md) before starting. +> 🌱 **Contributions welcome!** Browse [good first issues](https://github.com/GoDiao/dreamcoder/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) and [help wanted](https://github.com/GoDiao/dreamcoder/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22), and read the [contributing guide](docs/general/CONTRIBUTING_en.md) before starting. --- @@ -185,7 +185,7 @@ To learn how a coding agent works under the hood, read the [Coding Agent Impleme > The maintainer now develops primarily on **macOS (Apple Silicon)**, running from source (`bun run electron:dev`); it ships as a `.dmg` from v0.5.0. > **Windows x64** was the main platform before and has been tested extensively, but the maintainer no longer develops on it day to day, so feedback on how new changes behave on Windows is especially welcome. > **Linux** is not built or packaged; running the desktop app from source may work but is not part of the maintainer's day-to-day workflow, so Linux support is still best-effort. -> For the validated Ubuntu browser workflow, exFAT installation workaround, and local Ollama results, see the [Linux validation report](docs/LINUX_BROWSER_VALIDATION.md). For startup, shutdown, and termination by PID, see the [browser usage guide](docs/WEB_USAGE.md). +> For the validated Ubuntu browser workflow, exFAT installation workaround, and local Ollama results, see the [Linux validation report](docs/general/LINUX_BROWSER_VALIDATION.md). For startup, shutdown, and termination by PID, see the [browser usage guide](docs/general/WEB_USAGE.md). > If you're running DreamCoder on Windows or Linux, issues and PRs are especially valuable; > for the current Linux memory investigation, see [#25](https://github.com/GoDiao/dreamcoder/issues/25). > Check [GitHub Releases](https://github.com/GoDiao/dreamcoder/releases) for the actual assets available with each release. @@ -201,7 +201,7 @@ To learn how a coding agent works under the hood, read the [Coding Agent Impleme - [ ] **Phase 4**: Release Automation + Auto-update - [ ] **Phase 5**: Remote Agent Backend (run the agent on a remote host over SSH / WebSocket) -See [ROADMAP](docs/ROADMAP_en.md) +See [ROADMAP](docs/general/ROADMAP_en.md) --- @@ -255,11 +255,11 @@ On first launch, DreamCoder detects your system locale: Chinese systems (`zh-*`) ## 💝 Sponsorship -DreamCoder is maintained by one person in their spare time. Companies and individuals are welcome to sponsor its development. See the [sponsorship policy](docs/SPONSORS_en.md) for tiers and rules, and email diaoshengjia@gmail.com to get in touch. +DreamCoder is maintained by one person in their spare time. Companies and individuals are welcome to sponsor its development. See the [sponsorship policy](docs/general/SPONSORS_en.md) for tiers and rules, and email diaoshengjia@gmail.com to get in touch. ## 🤝 Contributing -Issues and pull requests are welcome. If you'd like to improve DreamCoder, start with the [Contributing Guide](docs/CONTRIBUTING_en.md) to get familiar with the workflow and collaboration style. +Issues and pull requests are welcome. If you'd like to improve DreamCoder, start with the [Contributing Guide](docs/general/CONTRIBUTING_en.md) to get familiar with the workflow and collaboration style. ## 📝 Changelog diff --git a/README_zh.md b/README_zh.md index 152a678f..1d0a127a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -32,7 +32,7 @@ > 🎉 **500 Star 了!** 感谢每一位点 Star、试用、提 issue 和贡献代码的朋友。DreamCoder 才五个月大,是你们让它一点点长起来的。我们接下来会更进一步跟进前沿 coding agent 的最佳实践,完成一切可以提升用户体验的尝试和崭新的功能,我们的目标一直会是**星辰大海**。 -> 🌱 **欢迎贡献!** 可以从 [good first issue](https://github.com/GoDiao/dreamcoder/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) 和 [help wanted](https://github.com/GoDiao/dreamcoder/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) 开始;动手前请阅读 [贡献指南](docs/CONTRIBUTING_zh.md)。 +> 🌱 **欢迎贡献!** 可以从 [good first issue](https://github.com/GoDiao/dreamcoder/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) 和 [help wanted](https://github.com/GoDiao/dreamcoder/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) 开始;动手前请阅读 [贡献指南](docs/general/CONTRIBUTING_zh.md)。 --- @@ -188,7 +188,7 @@ Provider 配置和 API Key 保存在本机文件中。使用云端模型时, > 维护者目前主要在 **macOS(Apple Silicon)** 上开发,从源码运行(`bun run electron:dev`),自 v0.5.0 起提供 `.dmg` 安装包。 > **Windows x64** 是此前的主力平台,经过长期实测;维护者现在不在 Windows 上日常开发,新改动在 Windows 上的表现尤其欢迎反馈。 > **Linux** 暂不构建安装包;从源码运行桌面端或许可行,但维护者并不日常使用,仍属于尽力支持的状态。 -> Ubuntu 浏览器版的验证范围、exFAT 依赖安装问题及本地 Ollama 调用结果见[Linux 验证报告](docs/LINUX_BROWSER_VALIDATION_zh.md);启动、关闭和按 PID 终止进程的方法见[浏览器版使用指南](docs/WEB_USAGE_zh.md)。 +> Ubuntu 浏览器版的验证范围、exFAT 依赖安装问题及本地 Ollama 调用结果见[Linux 验证报告](docs/general/LINUX_BROWSER_VALIDATION_zh.md);启动、关闭和按 PID 终止进程的方法见[浏览器版使用指南](docs/general/WEB_USAGE_zh.md)。 > 如果你正在使用 Windows 或 Linux,欢迎通过 issue 或 PR 一起把这部分体验补完整; > Linux 内存问题可关注 [#25](https://github.com/GoDiao/dreamcoder/issues/25)。 > 安装包供应情况以 [GitHub Releases](https://github.com/GoDiao/dreamcoder/releases) 页面中的实际附件为准。 @@ -204,7 +204,7 @@ Provider 配置和 API Key 保存在本机文件中。使用云端模型时, - [ ] **Phase 4**: Release 自动化 + 自动更新 - [ ] **Phase 5**: 远程 Agent 后端 (通过 SSH / WebSocket 在远程主机运行 Agent) -详见 [ROADMAP](docs/ROADMAP_zh.md) +详见 [ROADMAP](docs/general/ROADMAP_zh.md) --- @@ -258,11 +258,11 @@ bun run electron:dev ## 💝 赞助与合作 -DreamCoder 由个人利用业余时间维护。欢迎企业和个人赞助,支持项目持续开发。档位和规则见 [赞助政策](docs/SPONSORS_zh.md),合作请发邮件到 diaoshengjia@gmail.com。 +DreamCoder 由个人利用业余时间维护。欢迎企业和个人赞助,支持项目持续开发。档位和规则见 [赞助政策](docs/general/SPONSORS_zh.md),合作请发邮件到 diaoshengjia@gmail.com。 ## 🤝 贡献指南 -欢迎提交 Issue 和 PR。若你准备参与改进 DreamCoder,建议先阅读 [贡献指南](docs/CONTRIBUTING_zh.md),可以更快熟悉开发流程与协作方式。 +欢迎提交 Issue 和 PR。若你准备参与改进 DreamCoder,建议先阅读 [贡献指南](docs/general/CONTRIBUTING_zh.md),可以更快熟悉开发流程与协作方式。 ## 📝 更新日志 diff --git a/desktop/README.md b/desktop/README.md index dbd9e441..4aa2eaef 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -52,7 +52,7 @@ bun run electron:pack ## 许可证 -[MIT License](LICENSE) +[MIT License](../LICENSE) ## 致谢 diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index baf0af2c..107036c1 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -117,6 +117,8 @@ function runtimeMirror(selection: RuntimeSelection): SessionState { activeGoal: null, backgroundTasks: [], uiPages: [], + pendingReports: [], + queuedMessages: [], team: null, clientCount: 1, } satisfies SessionState diff --git a/desktop/src/components/CommandPalette/CommandPalette.tsx b/desktop/src/components/CommandPalette/CommandPalette.tsx index 1822fd94..2444cfa0 100644 --- a/desktop/src/components/CommandPalette/CommandPalette.tsx +++ b/desktop/src/components/CommandPalette/CommandPalette.tsx @@ -2,8 +2,7 @@ import { useRef, useState } from 'react' import { useTranslation } from '../../i18n' import { useUIStore } from '../../stores/uiStore' import { useSessionStore } from '../../stores/sessionStore' -import { useTabStore } from '../../stores/tabStore' -import { useChatStore } from '../../stores/chatStore' +import { openSessionTab } from '../../lib/sessionNavigation' import type { SessionListItem } from '../../types/session' import { useOverlayReturnFocus } from '../../hooks/useOverlayReturnFocus' import { Dialog, DialogContent, DialogTitle } from '../shared/ui/dialog' @@ -32,8 +31,7 @@ function CommandPaletteContent() { ? state.sessions.map((item) => item.id === session.id ? { ...item, ...session } : item) : [...state.sessions, session], })) - useTabStore.getState().openTab(session.id, session.title) - useChatStore.getState().connectToSession(session.id) + openSessionTab(session.id, session.title) close() } diff --git a/desktop/src/components/README.md b/desktop/src/components/README.md index 9601b55f..28ea6d87 100644 --- a/desktop/src/components/README.md +++ b/desktop/src/components/README.md @@ -149,7 +149,7 @@ Generate through the CLI, then review imports, icons, dependencies, tokens and accessibility against the local adaptations above. Do not overwrite local behavior with unreviewed registry output. -See [CONTRIBUTING](../../../docs/CONTRIBUTING_en.md#development-environment) for +See [CONTRIBUTING](../../../docs/general/CONTRIBUTING_en.md#development-environment) for setup. Run `bun run lint` and `bun run test -- --run` from `desktop/`, plus `bun test` from the repository root before a PR. Automated checks do not replace seven-theme/bilingual desktop and H5 visual acceptance, diff --git a/desktop/src/components/chat/BrowserGrantRequests.test.tsx b/desktop/src/components/chat/BrowserGrantRequests.test.tsx index 5f198da9..d55d4ba6 100644 --- a/desktop/src/components/chat/BrowserGrantRequests.test.tsx +++ b/desktop/src/components/chat/BrowserGrantRequests.test.tsx @@ -46,6 +46,8 @@ function setGrants(grants: PendingBrowserGrant[], sessionId = SESSION_ID) { activeGoal: null, backgroundTasks: [], uiPages: [], + pendingReports: [], + queuedMessages: [], team: null, clientCount: 1, } diff --git a/desktop/src/components/chat/ChatTraceBar.tsx b/desktop/src/components/chat/ChatTraceBar.tsx new file mode 100644 index 00000000..4dd24957 --- /dev/null +++ b/desktop/src/components/chat/ChatTraceBar.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react' +import { Trace } from '../shared/ui/trace' + +export function ChatTraceBar({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 06e1fc86..f4e26330 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -7,11 +7,13 @@ import { useChatStore, useSessionField } from '../../stores/chatStore' import { useSessionStore } from '../../stores/sessionStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { useTabStore } from '../../stores/tabStore' +import { openSessionTab } from '../../lib/sessionNavigation' import { useTeamStore } from '../../stores/teamStore' 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 { AssistantMessage } from './AssistantMessage' import { StreamingReply } from './StreamingReply' import { ThinkingBlock } from './ThinkingBlock' @@ -1833,8 +1835,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { try { const result = await branchSession(resolvedSessionId, target.transcriptMessageId) const title = result.title.trim() || t('sidebar.newSession') - useTabStore.getState().openTab(result.sessionId, title) - useChatStore.getState().connectToSession(result.sessionId) + openSessionTab(result.sessionId, title) addToast({ type: 'success', message: t('chat.branchSuccess', { title }), @@ -2044,6 +2045,9 @@ export const MessageBlock = memo(function MessageBlock({ switch (message.type) { case 'user_text': + if (message.origin?.kind === 'session') { + return + } return ( { + useChatStore.setState({ + sessions: { [SESSION_ID]: { ...useChatStore.getState().getSession(SESSION_ID), mirror } }, + }) + }) +} + +const report = (id: string, summary: string, createdAt = Date.now() - 5 * 60000): Reports[number] => ({ + id, + from: { sessionId: `child-${id}`, name: `worker-${id}` }, + summary, + createdAt, +}) + +describe('PendingReportsBar', () => { + const connectToSession = vi.fn() + + beforeEach(() => { + connectToSession.mockReset() + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [] }) + useChatStore.setState({ sessions: {}, connectToSession } as Partial>) + }) + + afterEach(() => cleanup()) + + it('renders nothing without queued reports', () => { + setReports([]) + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('shows a singular and a plural count', () => { + setReports([report('1', 'tests green')]) + render() + expect(screen.getByText('1 progress report waiting')).toBeInTheDocument() + + setReports([report('1', 'tests green'), report('2', 'docs done')]) + expect(screen.getByText('2 progress reports waiting')).toBeInTheDocument() + }) + + it('lists sender, summary, age and the delivery hint when expanded', () => { + setReports([report('1', 'tests green'), report('2', 'docs done')]) + render() + + fireEvent.click(screen.getByRole('button', { name: '2 progress reports waiting' })) + + expect(screen.getByText('tests green')).toBeInTheDocument() + expect(screen.getByText('docs done')).toBeInTheDocument() + expect(screen.getAllByText('5m ago')).toHaveLength(2) + expect(screen.getByText('They will be delivered with your next message.')).toBeInTheDocument() + }) + + it('opens the sender session from its title', () => { + setReports([report('1', 'tests green')]) + render() + fireEvent.click(screen.getByRole('button', { name: '1 progress report waiting' })) + + fireEvent.click(screen.getByRole('button', { name: 'Open session worker-1' })) + + expect(useTabStore.getState().activeTabId).toBe('child-1') + expect(connectToSession).toHaveBeenCalledWith('child-1') + }) +}) diff --git a/desktop/src/components/chat/PendingReportsBar.tsx b/desktop/src/components/chat/PendingReportsBar.tsx new file mode 100644 index 00000000..8731e283 --- /dev/null +++ b/desktop/src/components/chat/PendingReportsBar.tsx @@ -0,0 +1,48 @@ +import { Inbox } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { relativeTimeLabel } from '../../lib/relativeTime' +import { useChatStore } from '../../stores/chatStore' +import { SessionLink } from './SessionLink' +import type { SessionState } from '../../types/chat' +import { TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from '../shared/ui/trace' +import { ChatTraceBar } from './ChatTraceBar' + +function PendingReportRow({ report }: { report: SessionState['pendingReports'][number] }) { + const t = useTranslation() + return ( +
  • + + {report.summary} + {relativeTimeLabel(t, Date.now() - report.createdAt)} +
  • + ) +} + +export function PendingReportsBar({ sessionId }: { sessionId: string | null | undefined }) { + const t = useTranslation() + const reports = useChatStore((state) => (sessionId ? state.sessions[sessionId]?.mirror?.pendingReports : undefined)) + if (!reports?.length) return null + + const title = reports.length === 1 + ? t('chat.pendingReports.one') + : t('chat.pendingReports.other', { count: reports.length }) + + return ( + + + + + + {title} + + + +

    {t('chat.pendingReports.hint')}

    +
      + {reports.map((report) => )} +
    +
    +
    +
    + ) +} diff --git a/desktop/src/components/chat/QueuedMessagesBar.test.tsx b/desktop/src/components/chat/QueuedMessagesBar.test.tsx new file mode 100644 index 00000000..9234cfdf --- /dev/null +++ b/desktop/src/components/chat/QueuedMessagesBar.test.tsx @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { QueuedMessagesBar } from './QueuedMessagesBar' +import { useChatStore } from '../../stores/chatStore' +import type { SessionState } from '../../types/chat' + +const SESSION_ID = 'session-1' + +function setQueued(queuedMessages: SessionState['queuedMessages']) { + const mirror: SessionState = { + id: SESSION_ID, + version: 1, + permission: { desired: 'default', effective: 'default' }, + runtime: { desired: null, effective: null }, + title: { text: null, source: null }, + workspace: { workDir: '/repo', projectRoot: '/repo', workDirExists: true }, + process: { phase: 'running', generation: 1 }, + turn: { phase: 'idle' }, + pendingPermissions: [], + pendingBrowserGrants: [], + browserActivity: null, + slashCommands: [], + activeGoal: null, + backgroundTasks: [], + uiPages: [], + pendingReports: [], + queuedMessages, + team: null, + clientCount: 1, + } + act(() => { + useChatStore.setState({ + sessions: { [SESSION_ID]: { ...useChatStore.getState().getSession(SESSION_ID), mirror } }, + }) + }) +} + +describe('QueuedMessagesBar', () => { + const cancelQueuedMessage = vi.fn() + const sendQueuedNow = vi.fn() + + beforeEach(() => { + cancelQueuedMessage.mockReset() + sendQueuedNow.mockReset() + useChatStore.setState({ sessions: {}, cancelQueuedMessage, sendQueuedNow } as Partial>) + }) + + afterEach(() => cleanup()) + + it('renders nothing without queued messages', () => { + setQueued([]) + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('lists each message with its attachment count', () => { + setQueued([ + { id: 'a', content: 'first follow-up', attachmentCount: 0, createdAt: 1 }, + { id: 'b', content: 'second follow-up', attachmentCount: 2, createdAt: 2 }, + ]) + render() + + expect(screen.getByText('Queued - sends when this turn ends')).toBeInTheDocument() + expect(screen.getByText('first follow-up')).toBeInTheDocument() + expect(screen.getByText('second follow-up')).toBeInTheDocument() + expect(screen.getAllByText(/^Attachments:/)).toHaveLength(1) + expect(screen.getByText('Attachments: 2')).toBeInTheDocument() + }) + + it('withdraws one message by id', () => { + setQueued([ + { id: 'a', content: 'first follow-up', attachmentCount: 0, createdAt: 1 }, + { id: 'b', content: 'second follow-up', attachmentCount: 0, createdAt: 2 }, + ]) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Withdraw queued message: second follow-up' })) + + expect(cancelQueuedMessage).toHaveBeenCalledWith(SESSION_ID, 'b') + }) + + it('sends the queue now', () => { + setQueued([{ id: 'a', content: 'first follow-up', attachmentCount: 0, createdAt: 1 }]) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Send now' })) + + expect(sendQueuedNow).toHaveBeenCalledWith(SESSION_ID) + }) +}) diff --git a/desktop/src/components/chat/QueuedMessagesBar.tsx b/desktop/src/components/chat/QueuedMessagesBar.tsx new file mode 100644 index 00000000..b74121c1 --- /dev/null +++ b/desktop/src/components/chat/QueuedMessagesBar.tsx @@ -0,0 +1,58 @@ +import { Clock } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { isDesktopRuntime } from '../../lib/desktopRuntime' +import { useMobileViewport } from '../../hooks/useMobileViewport' +import { cn } from '@/lib/utils' +import { useChatStore } from '../../stores/chatStore' +import { Button } from '../shared/ui/button' +import { TraceDescription, TraceIcon, TraceLine, TraceTitle } from '../shared/ui/trace' +import { ChatTraceBar } from './ChatTraceBar' + +export function QueuedMessagesBar({ sessionId }: { sessionId: string | null | undefined }) { + const t = useTranslation() + const isMobile = useMobileViewport() && !isDesktopRuntime() + const queued = useChatStore((state) => (sessionId ? state.sessions[sessionId]?.mirror?.queuedMessages : undefined)) + const cancelQueuedMessage = useChatStore((state) => state.cancelQueuedMessage) + const sendQueuedNow = useChatStore((state) => state.sendQueuedNow) + if (!sessionId || !queued?.length) return null + + return ( + +
    + + + {t('chat.queued.title')} + + +
    +
      + {queued.map((message) => ( +
    • + {message.content} + {message.attachmentCount > 0 && ( + {t('chat.queued.attachments', { count: message.attachmentCount })} + )} + +
    • + ))} +
    +
    + ) +} diff --git a/desktop/src/components/chat/SessionLink.tsx b/desktop/src/components/chat/SessionLink.tsx new file mode 100644 index 00000000..fbbfc9dc --- /dev/null +++ b/desktop/src/components/chat/SessionLink.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from '../../i18n' +import { useSessionLink } from '../../lib/sessionNavigation' +import { Button } from '../shared/ui/button' +import { cn } from '@/lib/utils' + +type Props = { + sessionId: string + sentTitle?: string + className?: string +} + +export function SessionLink({ sessionId, sentTitle, className }: Props) { + const t = useTranslation() + const link = useSessionLink({ sessionId, sentTitle }) + return ( + + ) +} diff --git a/desktop/src/components/chat/SessionMessageCard.test.tsx b/desktop/src/components/chat/SessionMessageCard.test.tsx new file mode 100644 index 00000000..2a87a543 --- /dev/null +++ b/desktop/src/components/chat/SessionMessageCard.test.tsx @@ -0,0 +1,138 @@ +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 { 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' + +const origin: SessionOrigin = { kind: 'session', sessionId: 'child-1', name: 'docs-reorg', purpose: 'report' } + +describe('SessionMessageCard', () => { + const connectToSession = vi.fn() + + beforeEach(() => { + connectToSession.mockReset() + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [] }) + useChatStore.setState({ sessions: {}, connectToSession } as Partial>) + }) + + afterEach(() => cleanup()) + + it('is collapsed by default and shows the summary as the preview', () => { + render() + + expect(screen.getByText('docs-reorg')).toBeInTheDocument() + expect(screen.getByText('Report')).toBeInTheDocument() + expect(screen.getByText('Docs moved, links fixed')).toBeInTheDocument() + expect(screen.queryByText('all green')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Copy prompt' })).not.toBeInTheDocument() + }) + + it('expands to the body as markdown, with the copy action', () => { + render() + + fireEvent.click(screen.getByText('Report')) + + expect(screen.getByText('all green').tagName).toBe('STRONG') + expect(screen.getByRole('button', { name: 'Copy prompt' })).toBeInTheDocument() + }) + + it.each([ + ['message', 'Message'], + ['brief', 'Brief'], + ['report', 'Report'], + ] as const)('labels the %s purpose', (purpose, label) => { + render() + expect(screen.getByText(label)).toBeInTheDocument() + }) + + it('shows the current session title instead of the title at send time', () => { + useSessionStore.setState({ + sessions: [{ + id: 'child-1', title: 'Project manager', createdAt: '', modifiedAt: '', messageCount: 1, + projectPath: '/p', workDir: '/p', workDirExists: true, + }], + }) + render() + + expect(screen.getByRole('button', { name: 'Open session Project manager' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Open session docs-reorg' })).not.toBeInTheDocument() + }) + + it('falls back to the known session title when the origin has no title', () => { + useSessionStore.setState({ + sessions: [{ + id: 'child-1', title: 'Reorganise docs', createdAt: '', modifiedAt: '', messageCount: 1, + projectPath: '/p', workDir: '/p', workDirExists: true, + }], + }) + render() + + expect(screen.getByRole('button', { name: 'Open session Reorganise docs' })).toBeInTheDocument() + }) + + it('opens the source session when the title is clicked', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Open session docs-reorg' })) + + expect(useTabStore.getState().activeTabId).toBe('child-1') + expect(connectToSession).toHaveBeenCalledWith('child-1') + expect(screen.queryByRole('button', { name: 'Copy prompt' })).not.toBeInTheDocument() + }) + + it('keeps the open-session control outside the collapsible trigger as a native button', () => { + render() + + const open = screen.getByRole('button', { name: 'Open session docs-reorg' }) + expect(open.tagName).toBe('BUTTON') + expect(open.closest('[data-slot="trace-collapsible-trigger"]')).toBeNull() + expect(screen.getByText('Report').closest('[data-slot="trace-collapsible-trigger"]')).not.toBeNull() + }) + + it('offers copy but no fork action', () => { + render() + fireEvent.click(screen.getByText('Report')) + + expect(screen.getByRole('button', { name: 'Copy prompt' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Fork a new conversation' })).not.toBeInTheDocument() + }) +}) + +describe('MessageBlock user messages', () => { + const base = { id: 'u-1', type: 'user_text', content: 'hello there', timestamp: 1 } as const + + function renderMessage(message: UIMessage) { + return render( + {} }} + />, + ) + } + + afterEach(() => cleanup()) + + it('renders a message with a session origin as a card, without the fork action', () => { + const { container } = renderMessage({ ...base, origin }) + + expect(container.querySelector('[data-message-shell="session"]')).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) + + expect(container.querySelector('[data-message-shell="user"]')).not.toBeNull() + expect(container.querySelector('[data-message-shell="session"]')).toBeNull() + expect(screen.getByRole('button', { name: 'Fork a new conversation' })).toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/chat/SessionMessageCard.tsx b/desktop/src/components/chat/SessionMessageCard.tsx new file mode 100644 index 00000000..efc74d84 --- /dev/null +++ b/desktop/src/components/chat/SessionMessageCard.tsx @@ -0,0 +1,59 @@ +import { memo, useState } from 'react' +import { MessageSquareShare } from 'lucide-react' +import { useTranslation, type TranslationKey } from '../../i18n' +import type { SessionOrigin } 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' + +const PURPOSE_LABEL_KEYS: Record = { + message: 'chat.sessionMessage.purpose.message', + brief: 'chat.sessionMessage.purpose.brief', + report: 'chat.sessionMessage.purpose.report', +} + +type Props = { + content: string + origin: SessionOrigin + timestamp?: number +} + +export const SessionMessageCard = memo(function SessionMessageCard({ content, origin, timestamp }: Props) { + const t = useTranslation() + const [expanded, setExpanded] = useState(false) + const preview = origin.summary + + return ( +
    + + +
    + + + {t('chat.sessionMessage.from')} + + + + + {t(PURPOSE_LABEL_KEYS[origin.purpose])} + {!expanded && preview && {preview}} + + +
    + +
    + +
    + +
    +
    +
    +
    + ) +}) diff --git a/desktop/src/components/chat/SessionTaskBar.tsx b/desktop/src/components/chat/SessionTaskBar.tsx index 37a90fcb..e53966c2 100644 --- a/desktop/src/components/chat/SessionTaskBar.tsx +++ b/desktop/src/components/chat/SessionTaskBar.tsx @@ -2,7 +2,8 @@ import { ListTodo, X } from 'lucide-react' import { useCLITaskStore } from '../../stores/cliTaskStore' import { useTranslation } from '../../i18n' import { Button } from '../shared/ui/button' -import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from '../shared/ui/trace' +import { TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle } from '../shared/ui/trace' +import { ChatTraceBar } from './ChatTraceBar' import { TaskTraceItems } from './TaskTraceItems' export function SessionTaskBar() { @@ -18,24 +19,22 @@ export function SessionTaskBar() { if (allCompleted && completedAndDismissed) return null return ( -
    - - -
    - - task.status === 'in_progress') ? 'running' : undefined}> - - {t('tasks.title')} - {t('tasks.progress', { done: completedCount, total: tasks.length })} - - - {allCompleted && } -
    - - - -
    -
    -
    + + +
    + + task.status === 'in_progress') ? 'running' : undefined}> + + {t('tasks.title')} + {t('tasks.progress', { done: completedCount, total: tasks.length })} + + + {allCompleted && } +
    + + + +
    +
    ) } diff --git a/desktop/src/components/chat/StreamingIndicator.test.tsx b/desktop/src/components/chat/StreamingIndicator.test.tsx index e1819edc..50f0caa5 100644 --- a/desktop/src/components/chat/StreamingIndicator.test.tsx +++ b/desktop/src/components/chat/StreamingIndicator.test.tsx @@ -51,6 +51,8 @@ function sessionWithTurn(sessionId: string, turn: SessionState['turn']): PerSess activeGoal: null, backgroundTasks: [], uiPages: [], + pendingReports: [], + queuedMessages: [], team: null, clientCount: 1, }, diff --git a/desktop/src/components/chat/streamingRenderScenario.ts b/desktop/src/components/chat/streamingRenderScenario.ts index d95c5d56..4e093b07 100644 --- a/desktop/src/components/chat/streamingRenderScenario.ts +++ b/desktop/src/components/chat/streamingRenderScenario.ts @@ -24,6 +24,8 @@ function mirror(turn: SessionState['turn']): SessionState { activeGoal: null, backgroundTasks: [], uiPages: [], + pendingReports: [], + queuedMessages: [], team: null, clientCount: 1, } diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 331f775c..f2ae7e54 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -43,6 +43,8 @@ function mirrorRuntime(sessionId: string, selection: RuntimeSelection) { activeGoal: null, backgroundTasks: [], uiPages: [], + pendingReports: [], + queuedMessages: [], team: null, clientCount: 1, }, diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 7416d9a2..e9353fd6 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -1,5 +1,6 @@ import { MenuIcon, XIcon } from 'lucide-react' import { usePanelWidthPersistence } from '../../hooks/usePanelWidthPersistence' +import { relativeTimeLabel } from '../../lib/relativeTime' import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../shared/ui/resizable' import { useEffect, useRef, useState, type HTMLAttributes } from 'react' import { onMenuNavigate } from '../../lib/shell' @@ -77,10 +78,7 @@ export function AppShell() { const mobileSessionUpdated = (() => { if (!activeSession?.modifiedAt) return '' const diff = Date.now() - new Date(activeSession.modifiedAt).getTime() - if (diff < 60000) return t('session.timeJustNow') - if (diff < 3600000) return t('session.timeMinutes', { n: Math.floor(diff / 60000) }) - if (diff < 86400000) return t('session.timeHours', { n: Math.floor(diff / 3600000) }) - return t('session.timeDays', { n: Math.floor(diff / 86400000) }) + return relativeTimeLabel(t, diff) })() const settingsOpen = useUIStore((s) => s.settingsOpen) const { panelRef: sidebarPanelRef, onLayoutChanged } = usePanelWidthPersistence(setSidebarWidth, effectiveSidebarOpen && !isMobileShell && !settingsOpen) diff --git a/desktop/src/components/layout/Sidebar.sessionTree.test.tsx b/desktop/src/components/layout/Sidebar.sessionTree.test.tsx new file mode 100644 index 00000000..7ba140fe --- /dev/null +++ b/desktop/src/components/layout/Sidebar.sessionTree.test.tsx @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import '@testing-library/jest-dom' + +const desktopUiPreferencesApiMock = vi.hoisted(() => ({ + getPreferences: vi.fn(), + updateSidebarPreferences: vi.fn(), +})) + +vi.mock('../../api/desktopUiPreferences', () => ({ + desktopUiPreferencesApi: desktopUiPreferencesApiMock, +})) + +vi.mock('../../i18n', () => ({ + useTranslation: () => (key: string, params?: Record) => + params ? `${key} ${Object.values(params).join(' ')}` : key, +})) + +import { Sidebar } from './Sidebar' +import { useChatStore } from '../../stores/chatStore' +import { useSessionStore } from '../../stores/sessionStore' +import { useTabStore } from '../../stores/tabStore' +import { useUIStore } from '../../stores/uiStore' +import type { SessionListItem } from '../../types/session' + +const COLLAPSED_KEY = 'dreamcoder-sidebar-collapsed-sessions' + +function makeSession( + id: string, + title: string, + modifiedAt: string, + extra: Partial = {}, + projectRoot = '/work/alpha', +): SessionListItem { + return { + id, + title, + createdAt: modifiedAt, + modifiedAt, + messageCount: 1, + projectPath: projectRoot, + projectRoot, + workDir: projectRoot, + workDirExists: true, + ...extra, + } +} + +function alphaSessions(): SessionListItem[] { + return [ + makeSession('p', 'Parent', '2026-11-04T10:00:00Z'), + makeSession('c1', 'Child one', '2026-11-04T09:00:00Z', { parent: { sessionId: 'p', kind: 'spawned' } }), + makeSession('c2', 'Child two', '2026-11-04T09:30:00Z', { parent: { sessionId: 'p', kind: 'forked' } }), + makeSession('g', 'Grandchild', '2026-11-04T08:00:00Z', { parent: { sessionId: 'c1', kind: 'spawned' } }), + makeSession('o', 'Orphan', '2026-11-04T07:00:00Z', { parent: { sessionId: 'gone', kind: 'forked' } }), + ] +} + +function rowIds(): string[] { + return screen.getAllByTestId(/^sidebar-session-row-/).map((row) => row.dataset.testid!.replace('sidebar-session-row-', '')) +} + +function row(id: string): HTMLElement { + return screen.getByTestId(`sidebar-session-row-${id}`) +} + +function setSessions(sessions: SessionListItem[]) { + useSessionStore.setState({ sessions }) +} + +describe('Sidebar session tree', () => { + const connectToSession = vi.fn() + + beforeEach(() => { + connectToSession.mockReset() + window.localStorage.removeItem(COLLAPSED_KEY) + desktopUiPreferencesApiMock.getPreferences.mockRejectedValue(new Error('server unavailable')) + desktopUiPreferencesApiMock.updateSidebarPreferences.mockResolvedValue({ ok: true }) + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ + sessions: [], + isLoading: false, + error: null, + isBatchMode: false, + selectedSessionIds: new Set(), + fetchSessions: vi.fn().mockResolvedValue(undefined), + }) + useChatStore.setState({ sessions: {}, connectToSession } as Partial>) + useUIStore.setState({ sidebarOpen: true } as Partial>) + }) + + afterEach(() => { + cleanup() + window.localStorage.removeItem(COLLAPSED_KEY) + }) + + it('nests children under their parent recursively, newest child first, and keeps an orphan top-level', () => { + setSessions(alphaSessions()) + render() + + expect(rowIds()).toEqual(['p', 'c2', 'c1', 'g', 'o']) + expect(within(row('p').parentElement!).queryAllByTestId('sidebar-session-connector')).toHaveLength(0) + expect(within(row('c1').parentElement!).getAllByTestId('sidebar-session-connector')).toHaveLength(1) + expect(within(row('g').parentElement!).getAllByTestId('sidebar-session-connector')).toHaveLength(2) + expect(within(row('o').parentElement!).queryAllByTestId('sidebar-session-connector')).toHaveLength(0) + expect(row('p').parentElement!.style.paddingLeft).toBe('') + expect(row('c1').parentElement!.style.paddingLeft).toBe('14px') + expect(row('g').parentElement!.style.paddingLeft).toBe('28px') + }) + + it('shows a child whose parent is in another project as a top-level row of its own group', () => { + setSessions([ + makeSession('p', 'Parent', '2026-11-04T10:00:00Z'), + makeSession('x', 'Elsewhere', '2026-11-04T09:00:00Z', { parent: { sessionId: 'p', kind: 'spawned' } }, '/work/beta'), + ]) + render() + + const betaGroup = screen.getByTestId('sidebar-project-group-work-beta') + expect(within(betaGroup).getByTestId('sidebar-session-row-x')).toBeInTheDocument() + expect(within(row('x').parentElement!).queryAllByTestId('sidebar-session-connector')).toHaveLength(0) + expect(screen.queryByRole('button', { name: /sidebar\.(collapse|expand)Children/ })).not.toBeInTheDocument() + }) + + it('marks forked and spawned children differently', () => { + setSessions(alphaSessions()) + render() + + expect(within(row('c1')).getByText('sidebar.sessionSpawned')).toBeInTheDocument() + expect(within(row('c2')).getByText('sidebar.sessionForked')).toBeInTheDocument() + expect(within(row('p')).queryByText('sidebar.sessionSpawned')).not.toBeInTheDocument() + expect(within(row('p')).queryByText('sidebar.sessionForked')).not.toBeInTheDocument() + }) + + it('puts the child toggle after the session title with the child count', () => { + setSessions(alphaSessions()) + render() + + const toggle = within(row('p')).getByTestId('sidebar-session-children-toggle') + const titleButton = within(row('p')).getByRole('button', { name: 'Parent' }) + expect(toggle).toHaveTextContent('2') + expect(titleButton.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('gives the child toggle a 44px touch target on mobile only', () => { + setSessions(alphaSessions()) + const { unmount } = render() + const desktopToggle = within(row('p')).getByTestId('sidebar-session-children-toggle') + expect(desktopToggle.className).not.toContain('min-h-[44px]') + unmount() + + render( undefined} />) + const mobileToggle = within(row('p')).getByTestId('sidebar-session-children-toggle') + expect(mobileToggle.className).toContain('min-h-[44px]') + expect(mobileToggle.className).toContain('min-w-[44px]') + }) + + it('collapses and expands a parent and remembers the collapsed state', () => { + setSessions(alphaSessions()) + const { unmount } = render() + + fireEvent.click(screen.getByRole('button', { name: 'sidebar.collapseChildren Parent' })) + expect(rowIds()).toEqual(['p', 'o']) + expect(JSON.parse(window.localStorage.getItem(COLLAPSED_KEY)!)).toEqual(['p']) + + unmount() + render() + expect(rowIds()).toEqual(['p', 'o']) + + fireEvent.click(screen.getByRole('button', { name: 'sidebar.expandChildren Parent' })) + expect(rowIds()).toEqual(['p', 'c2', 'c1', 'g', 'o']) + expect(JSON.parse(window.localStorage.getItem(COLLAPSED_KEY)!)).toEqual([]) + }) + + it('hides only the collapsed subtree', () => { + setSessions(alphaSessions()) + render() + + fireEvent.click(screen.getByRole('button', { name: 'sidebar.collapseChildren Child one' })) + expect(rowIds()).toEqual(['p', 'c2', 'c1', 'o']) + }) + + it('shows matching children flat when their parent does not match, and ignores collapsed state while searching', () => { + setSessions(alphaSessions()) + window.localStorage.setItem(COLLAPSED_KEY, JSON.stringify(['p'])) + render() + expect(rowIds()).toEqual(['p', 'o']) + + fireEvent.change(screen.getByPlaceholderText('sidebar.searchPlaceholder'), { target: { value: 'child' } }) + expect(rowIds()).toEqual(['c2', 'c1', 'g']) + expect(within(row('c2').parentElement!).queryAllByTestId('sidebar-session-connector')).toHaveLength(0) + expect(within(row('g').parentElement!).getAllByTestId('sidebar-session-connector')).toHaveLength(1) + + fireEvent.change(screen.getByPlaceholderText('sidebar.searchPlaceholder'), { target: { value: 'parent' } }) + expect(rowIds()).toEqual(['p']) + }) + + it('shows children of a collapsed parent while the search matches both', () => { + setSessions([ + makeSession('p', 'Alpha plan', '2026-11-04T10:00:00Z'), + makeSession('c', 'Alpha step', '2026-11-04T09:00:00Z', { parent: { sessionId: 'p', kind: 'spawned' } }), + ]) + window.localStorage.setItem(COLLAPSED_KEY, JSON.stringify(['p'])) + render() + expect(rowIds()).toEqual(['p']) + + fireEvent.change(screen.getByPlaceholderText('sidebar.searchPlaceholder'), { target: { value: 'alpha' } }) + expect(rowIds()).toEqual(['p', 'c']) + }) + + it('opens a session from its row through the tab store', () => { + setSessions(alphaSessions()) + render() + + fireEvent.click(within(row('g')).getByRole('button', { name: 'Grandchild' })) + expect(useTabStore.getState().activeTabId).toBe('g') + expect(connectToSession).toHaveBeenCalledWith('g') + }) +}) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 5474b5b8..383c1211 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { CheckIcon, ChevronDown, ChevronRight, FolderOpen, Layers, FolderPlus, GitBranch, ListChecks, LoaderCircle, Pencil, Pin, PinOff, Plus, SquarePen, Trash2, X } from 'lucide-react' +import { CheckIcon, ChevronDown, ChevronRight, FolderOpen, Layers, FolderPlus, Bot, GitBranch, GitFork, ListChecks, LoaderCircle, Pencil, Pin, PinOff, Plus, SquarePen, Trash2, X } from 'lucide-react' import { Tooltip as HintTooltip } from '../shared/Tooltip' import { Button } from '../shared/ui/button' import { useEffect, useState, useCallback, useMemo, useRef } from 'react' @@ -11,7 +11,7 @@ import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation, type TranslationKey } from '../../i18n' import { ConfirmDialog } from '../shared/ConfirmDialog' -import type { SessionListItem } from '../../types/session' +import type { SessionListItem, SessionParent } from '../../types/session' import { useTabStore, ARTIFACTS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' import { useOpenTargetStore } from '../../stores/openTargetStore' @@ -22,6 +22,8 @@ import { ProjectManagementDialog, type SidebarProjectOrganization, type SidebarP import { ShortcutsDialog } from './ShortcutsDialog' import { ReportProblemDialog } from './ReportProblemDialog' import { SearchField } from '../shared/SearchField' +import { openSessionTab } from '../../lib/sessionNavigation' +import { buildSessionTreeRows, unfoldSessionTreeRows, type SessionTreeRow } from './sessionTree' const isDesktop = isDesktopRuntime() const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform) @@ -32,6 +34,9 @@ const PROJECT_PINNED_STORAGE_KEY = 'dreamcoder-sidebar-pinned-projects' const PROJECT_HIDDEN_STORAGE_KEY = 'dreamcoder-sidebar-hidden-projects' const PROJECT_ORGANIZATION_STORAGE_KEY = 'dreamcoder-sidebar-project-organization' const PROJECT_SORT_STORAGE_KEY = 'dreamcoder-sidebar-project-sort' +const SESSION_COLLAPSED_STORAGE_KEY = 'dreamcoder-sidebar-collapsed-sessions' +const SESSION_INDENT_PX = 14 +const SESSION_MAX_INDENT_DEPTH = 4 const PROJECT_GROUP_VISIBLE_COUNT = 6 const PROJECT_GROUP_SCROLL_COUNT = 12 @@ -42,6 +47,7 @@ type ProjectGroup = { subtitle: string | null workDir: string | undefined sessions: SessionListItem[] + rows: SessionTreeRow[] } type SidebarProps = { @@ -91,9 +97,10 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const [lastSelectedSessionId, setLastSelectedSessionId] = useState(null) const [expandedProjectKeys, setExpandedProjectKeys] = useState>(new Set()) const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()) - const [projectOrder, setProjectOrder] = useState(() => readStoredProjectOrder()) - const [pinnedProjectKeys, setPinnedProjectKeys] = useState>(() => readStoredProjectPins()) - const [hiddenProjectKeys, setHiddenProjectKeys] = useState>(() => readStoredProjectHidden()) + const [collapsedSessionIds, setCollapsedSessionIds] = useState>(() => new Set(readStoredStrings(SESSION_COLLAPSED_STORAGE_KEY))) + const [projectOrder, setProjectOrder] = useState(() => readStoredStrings(PROJECT_ORDER_STORAGE_KEY)) + const [pinnedProjectKeys, setPinnedProjectKeys] = useState>(() => new Set(readStoredStrings(PROJECT_PINNED_STORAGE_KEY))) + const [hiddenProjectKeys, setHiddenProjectKeys] = useState>(() => new Set(readStoredStrings(PROJECT_HIDDEN_STORAGE_KEY))) const [projectOrganization, setProjectOrganizationState] = useState(() => readStoredProjectOrganization()) const [projectSortBy, setProjectSortByState] = useState(() => readStoredProjectSortBy()) const [draggingProjectKey, setDraggingProjectKey] = useState(null) @@ -495,6 +502,19 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { } }, [deselectSessions, selectSessions, selectedSessionIds]) + const toggleSessionCollapsed = useCallback((sessionId: string) => { + setCollapsedSessionIds((current) => { + const next = new Set(current) + if (next.has(sessionId)) { + next.delete(sessionId) + } else { + next.add(sessionId) + } + writeStoredStrings(SESSION_COLLAPSED_STORAGE_KEY, next) + return next + }) + }, []) + const toggleProjectCollapsed = useCallback((projectKey: string) => { if (suppressProjectClickRef.current === projectKey) { suppressProjectClickRef.current = null @@ -762,11 +782,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { {visibleProjectGroups.map((project) => { const projectCollapsed = collapsedProjectKeys.has(project.key) const sessionsExpanded = expandedProjectKeys.has(project.key) - const visibleItems = projectCollapsed ? [] : getVisibleProjectSessions(project.sessions, sessionsExpanded, activeTabId) - const hiddenCount = project.sessions.length - visibleItems.length + const unfoldedRows = unfoldSessionTreeRows(project.rows, searchQuery ? EMPTY_ID_SET : collapsedSessionIds) + const visibleItems = projectCollapsed ? [] : getVisibleProjectRows(unfoldedRows, sessionsExpanded, activeTabId) + const hiddenCount = unfoldedRows.length - visibleItems.length const groupIds = project.sessions.map((session) => session.id) const groupSelectedCount = groupIds.filter((id) => selectedSessionIds.has(id)).length - const hasInternalScroll = sessionsExpanded && project.sessions.length > PROJECT_GROUP_SCROLL_COUNT + const hasInternalScroll = sessionsExpanded && unfoldedRows.length > PROJECT_GROUP_SCROLL_COUNT const isProjectDragging = draggingProjectKey === project.key const isProjectPinned = pinnedProjectKeys.has(project.key) const dropBefore = projectDropTarget?.key === project.key && projectDropTarget.position === 'before' @@ -857,7 +878,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { className={hasInternalScroll ? 'max-h-[420px] overflow-y-auto pr-1' : undefined} data-testid={`sidebar-project-session-list-${domSafeProjectKey(project.key)}`} > - {visibleItems.map((session) => { + {visibleItems.map(({ session, depth, childCount }) => { + const rowCollapsed = !searchQuery && collapsedSessionIds.has(session.id) const isSelected = selectedSessionIds.has(session.id) const isCurrent = session.id === activeTabId const title = session.title || t('session.untitled') @@ -868,7 +890,19 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { { id: 'delete', label: t('common.delete'), icon: , danger: true, separatorBefore: true, afterClose: true, onSelect: () => handleDelete(session.id) }, ]}> {(menuTrigger, menuOpen) => ( -
    +
    0 ? { paddingLeft: Math.min(depth, SESSION_MAX_INDENT_DEPTH) * SESSION_INDENT_PX } : undefined} + > + {depth > 0 && Array.from({ length: Math.min(depth, SESSION_MAX_INDENT_DEPTH) }, (_, level) => ( +
    )} @@ -1170,6 +1219,7 @@ function groupByProject(sessions: SessionListItem[], sortBy: SidebarProjectSortB subtitle: projectSubtitle(projectRoot, key), workDir: projectRoot || newest?.workDir || undefined, sessions: sortedSessions, + rows: buildSessionTreeRows(sortedSessions), } }) @@ -1220,57 +1270,25 @@ function getProjectDropPosition(event: React.DragEvent): 'before' | return event.clientY <= rect.top + rect.height / 2 ? 'before' : 'after' } -function readStoredProjectOrder(): string[] { +const EMPTY_ID_SET: ReadonlySet = new Set() + +function readStoredStrings(key: string): string[] { if (typeof localStorage === 'undefined') return [] try { - const parsed = JSON.parse(localStorage.getItem(PROJECT_ORDER_STORAGE_KEY) ?? '[]') + const parsed = JSON.parse(localStorage.getItem(key) ?? '[]') return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [] - } catch { + } catch (error) { + console.warn(`Could not read ${key} from storage`, error) return [] } } -function writeStoredProjectOrder(projectOrder: string[]): void { +function writeStoredStrings(key: string, values: Iterable): void { if (typeof localStorage === 'undefined') return try { - localStorage.setItem(PROJECT_ORDER_STORAGE_KEY, JSON.stringify(projectOrder)) - } catch { - } -} - -function readStoredProjectPins(): Set { - if (typeof localStorage === 'undefined') return new Set() - try { - const parsed = JSON.parse(localStorage.getItem(PROJECT_PINNED_STORAGE_KEY) ?? '[]') - return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []) - } catch { - return new Set() - } -} - -function writeStoredProjectPins(projectKeys: Set): void { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(PROJECT_PINNED_STORAGE_KEY, JSON.stringify([...projectKeys])) - } catch { - } -} - -function readStoredProjectHidden(): Set { - if (typeof localStorage === 'undefined') return new Set() - try { - const parsed = JSON.parse(localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]') - return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []) - } catch { - return new Set() - } -} - -function writeStoredProjectHidden(projectKeys: Set): void { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify([...projectKeys])) - } catch { + localStorage.setItem(key, JSON.stringify([...values])) + } catch (error) { + console.warn(`Could not save ${key} to storage`, error) } } @@ -1318,9 +1336,9 @@ function buildSidebarProjectPreferences( function readCachedSidebarProjectPreferences(): SidebarProjectPreferences { return { - projectOrder: readStoredProjectOrder(), - pinnedProjects: [...readStoredProjectPins()], - hiddenProjects: [...readStoredProjectHidden()], + projectOrder: readStoredStrings(PROJECT_ORDER_STORAGE_KEY), + pinnedProjects: readStoredStrings(PROJECT_PINNED_STORAGE_KEY), + hiddenProjects: readStoredStrings(PROJECT_HIDDEN_STORAGE_KEY), projectOrganization: readStoredProjectOrganization(), projectSortBy: readStoredProjectSortBy(), } @@ -1328,9 +1346,9 @@ function readCachedSidebarProjectPreferences(): SidebarProjectPreferences { function writeCachedSidebarProjectPreferences(preferences: SidebarProjectPreferences): void { const normalized = normalizeSidebarProjectPreferences(preferences) - writeStoredProjectOrder(normalized.projectOrder) - writeStoredProjectPins(new Set(normalized.pinnedProjects)) - writeStoredProjectHidden(new Set(normalized.hiddenProjects)) + writeStoredStrings(PROJECT_ORDER_STORAGE_KEY, normalized.projectOrder) + writeStoredStrings(PROJECT_PINNED_STORAGE_KEY, normalized.pinnedProjects) + writeStoredStrings(PROJECT_HIDDEN_STORAGE_KEY, normalized.hiddenProjects) writeStoredProjectOrganization(normalized.projectOrganization) writeStoredProjectSortBy(normalized.projectSortBy) } @@ -1393,18 +1411,18 @@ function normalizeProjectSortBy(value: unknown): SidebarProjectSortBy { return value === 'createdAt' || value === 'updatedAt' ? value : 'updatedAt' } -function getVisibleProjectSessions( - sessions: SessionListItem[], +function getVisibleProjectRows( + rows: SessionTreeRow[], expanded: boolean, activeSessionId: string | null, -): SessionListItem[] { - if (expanded || sessions.length <= PROJECT_GROUP_VISIBLE_COUNT) return sessions +): SessionTreeRow[] { + if (expanded || rows.length <= PROJECT_GROUP_VISIBLE_COUNT) return rows - const visible = sessions.slice(0, PROJECT_GROUP_VISIBLE_COUNT) - if (!activeSessionId || visible.some((session) => session.id === activeSessionId)) return visible + const visible = rows.slice(0, PROJECT_GROUP_VISIBLE_COUNT) + if (!activeSessionId || visible.some((row) => row.session.id === activeSessionId)) return visible - const activeSession = sessions.find((session) => session.id === activeSessionId) - return activeSession ? [...visible, activeSession] : visible + const activeRow = rows.find((row) => row.session.id === activeSessionId) + return activeRow ? [...visible, activeRow] : visible } function getSessionProjectKey(session: SessionListItem): string { @@ -1470,20 +1488,30 @@ function domSafeProjectKey(projectKey: string): string { function SessionRowMeta({ isRunning, isWorktree, + relation, t, }: { isRunning: boolean isWorktree: boolean + relation: SessionParent['kind'] | undefined t: (key: TranslationKey, params?: Record) => string }) { return ( -
    @@ -371,7 +371,7 @@ export default function App() {