diff --git a/docs/README.md b/docs/README.md index 59a2f4789d..181250508f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ | --- | --- | | [ARCHITECTURE.md](./ARCHITECTURE.md) | main 进程模块、所有权、生命周期和依赖方向 | | [FLOWS.md](./FLOWS.md) | 启动、Session、Agent、Tool、Remote、Scheduler、Sync 和退出流程 | +| [design-system.md](./design-system.md) | 当前产品、界面与视觉设计风格基线 | | [architecture/agent-system.md](./architecture/agent-system.md) | DeepChat / ACP backend、Run、权限和 Subagent 合同 | | [architecture/session-management.md](./architecture/session-management.md) | Session 数据、binding、恢复、删除和 transfer | | [architecture/tool-system.md](./architecture/tool-system.md) | Tool、MCP、Skill、Plugin 和权限边界 | diff --git a/docs/architecture/baselines/renderer-application-boundaries-baseline.json b/docs/architecture/baselines/renderer-application-boundaries-baseline.json index d3f3a0d353..eaf16df94e 100644 --- a/docs/architecture/baselines/renderer-application-boundaries-baseline.json +++ b/docs/architecture/baselines/renderer-application-boundaries-baseline.json @@ -50,6 +50,10 @@ "file": "src/renderer/settings/App.vue", "specifier": "../src/composables/useFontManager" }, + { + "file": "src/renderer/settings/App.vue", + "specifier": "../src/foundation/appearance/documentAppearance" + }, { "file": "src/renderer/settings/App.vue", "specifier": "../src/lib/iconLoader" @@ -723,5 +727,5 @@ "specifier": "@/i18n/bootstrap" } ], - "settingsToChatAppImportCount": 170 + "settingsToChatAppImportCount": 171 } diff --git a/docs/architecture/chat-markstream-rendering-pipeline/plan.md b/docs/architecture/chat-markstream-rendering-pipeline/plan.md new file mode 100644 index 0000000000..2c622a145a --- /dev/null +++ b/docs/architecture/chat-markstream-rendering-pipeline/plan.md @@ -0,0 +1,134 @@ +# 实施计划 + +## 1. 建立单一流式节拍 owner + +`MarkdownRenderer` 保留非流式 fast/slow debounce,但为 chat streaming 增加同步旁路: + +```text +content update + -> streaming now or streaming just ended? + yes -> invalidate older debounce revision and commit immediately + no -> keep existing 32/96 ms static debounce + -> NodeRenderer + -> Markstream smooth stream / parse scheduler +``` + +监听 source 同时包含 `content` 与 `isStreaming`。完成态转换必须根据 previous streaming state +同步提交,以覆盖父组件同一次 patch 中 content/status 一起改变的情况。旧 timer 继续用 revision +guard no-op,不新增 timer cleanup 或第二份派生状态。 + +## 2. 解耦 node virtualization 与重节点优先级 + +保留两类派生值: + +- `shouldVirtualizeNodes = virtualizeNodes && !isStreaming`:只决定 Markdown node DOM window; +- `shouldUseViewportPriority = virtualizeNodes`:继续决定 heavy node 是否接近视口后才启动。 + +保持 `codeRenderer="monaco"` 贯穿同一个 `NodeRenderer`,不要以 phase key 重建它。当前 Markstream 将 +该兼容名称映射到由 `stream-diffs` 支持的 `CodeBlockNode`:流式期间它保留自身的 `
` fallback,代码块 +完成且可见后再把同一宿主原子升级为单一 File/FileDiff surface。`deferNodesUntilVisible` 与 +`viewportPriority` 继续使用第二个值,保留既有 Mermaid/KaTeX 等重节点的调度策略。搜索/截图传入 +`virtualizeNodes=false` 时,所有内容仍立即可用。 + +`MessageBlockContent` 只在搜索、截图等完整 DOM 消费者出现时传 `virtualizeNodes=false`。streaming 的 +node window 仍由 `MarkdownRenderer` 内部的 `virtualizeNodes && !isStreaming` 负责关闭。 + +chat streaming 的 typewriter 使用 `simple` 模式。它保留尾部 CSS 光标和 smooth streaming 的 auto +eligibility,但不使用 precise 模式每次提交的 Range/getClientRects 光标定位。 + +## 3. 使用 Markstream 内建 code renderer + +- 增加 `stream-diffs` direct dependency,使 Markstream 的动态 runtime import 可解析; +- live chat 与 completed/static 均显式传 `codeRenderer="monaco"`;live 传 `codeBlockStream=true`,完成时传 false 并同步更新 `final=true`。不重建 NodeRenderer,也不直接调用 `stream-diffs`; +- 删除 generic `code_block` custom mapping,恢复 `CodeBlockNode` 的内建 `` fallback 与增强 surface handoff; +- 通过 `codeBlockProps` 传 themes 和工具栏选项,通过 `codeBlockMonacoOptions` 传字体/换行,通过 `@handle-artifact-click` 接收预览; +- 保留 Mermaid strict props、`break-words` prose root 和可收缩的 flex host,但不覆盖 `stream-diffs` gutter/content 内部几何。 + +## 4. 消息顺序与流式窗口 + +后续 `renderer-state-ownership-hardening` 已移除 stable/tail layout 快路径。`useDisplayMessages` +始终按 `messageIds` 产生完整顺序的 display list,未变化记录通过转换缓存复用;`useMessageWindow` +据此建立 geometry,虚拟窗口限制实际挂载的行数。本地 optimistic user 和首次 stream placeholder 的 +`orderSeq` 使用当前缓存最大有限值加一,而不是 `messageIds.length + 1`。这样只加载长会话尾页时, +新消息仍保持真实尾序,不会因一次全量排序被移到历史前方。该扫描只发生在本地消息首次插入,不在 +token snapshot 热路径。 + +## 5. 缩短 full snapshot 的 renderer 热路径 + +- stream store 使用 shallow ref,因为每个 snapshot 都是整数组替换,内部没有嵌套 mutation contract; +- `applyStreamingBlocksToMessage` 在写入 JSON record 的同时,用同一份已校验 blocks 预填 parsed cache; +- stable block identity 继续通过 `reuseStableAssistantBlocks` 复用,metadata cache 在内容更新时保留。 + +main 的 JSON normalization + Zod clone 继续保留,因为真实 `extra` payload 含有 undefined 值,直接 +schema parse 会改变兼容语义甚至拒绝 snapshot。preload 与 renderer event contract 校验也继续保留。 +full snapshot transport 仍会随累计长度重复校验和跨进程复制;delta protocol 需要独立设计和 +profile,不在本次变更中暗改。 + +## 6. 移除 custom registry 热路径 + +MarkdownRenderer 改用 NodeRenderer 内建 link/reference/Mermaid 组件: + +- `mermaidProps.isStrict=true` 保持安全模式; +- 根级 click 委托识别 anchor,继续调用 `navigateLink`; +- 根级 mouse/click 委托识别 `.reference-node`,继续加载搜索结果与显示引用浮层; +- code preview 通过公开 `@handle-artifact-click` event 处理。 + +因此不再调用 `setCustomComponents/removeCustomComponents`,同消息多个 text part 不会互相覆盖, +Markstream 也可以启用 append-only parse 和 stable top-level node reuse。`customId` 仍包含实例 token, +用于隔离 NodeRenderer 自身的虚拟化/测量 identity。 + +## 7. 与 PR #2000 的边界 + +不改变 `useMessageWindow` 或 `useMessageVirtualization` 的 contract。本次补齐 +`useDisplayMessages` 对真实尾部 inline stream 的分类,并保证 tail 到 Markstream 的交接不再重复 +pacing、重复 JSON parse 或因 registry 关闭增量节点复用。 + +完成态继续依赖现有 message id / renderKey handoff: + +```text +pending placeholder -> real stream message -> persisted final message + same outer row identity same MarkdownRenderer instance +``` + +## 8. 测试策略 + +- MarkdownRenderer component test: + - 后续 streaming snapshot 同步转交,不只覆盖首 chunk; + - final transition 同步带上最终 content,且旧 timer 不能回写; + - streaming 到 final 始终选择同一个 enhanced renderer,并把 `codeBlockStream` 从 true 切到 false; + - explicit `virtualizeNodes=false` 同时关闭三类延迟; + - `stream-diffs` handoff、无 generic override、preview 和 Mermaid strict 保持。 +- useDisplayMessages: + - 尾部 inline stream 返回 segments,连续 snapshot 保持 stable identity; + - 中间位置 inline stream 返回 null,完整消息顺序不变。 +- message/echo: + - schema parse 保持深拷贝; + - snapshot blocks 预填 parsed cache,并保持 settled block identity。 +- 复跑 MessageBlockContent、MessageList、MessageItemAssistant、useDisplayMessages、 + useMessageWindow、useMessageVirtualization 和 ChatPage 相关 suite。 +- 执行 typecheck 与 production renderer build,验证 Markstream optional peer 的真实解析路径。 + +## 9. 验证命令 + +```bash +pnpm exec vitest --config vitest.config.renderer.ts test/renderer/components/MarkdownRenderer.test.ts test/renderer/components/message/MessageBlockContent.test.ts +pnpm exec vitest --config vitest.config.renderer.ts test/renderer/components/MessageList.test.ts test/renderer/components/message/MessageItemAssistant.test.ts test/renderer/composables/useMessageWindow.test.ts test/renderer/composables/useMessageVirtualization.test.ts +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck +pnpm run test:renderer +pnpm run build +``` + +## 风险与缓解 + +| 风险 | 缓解 | +| --- | --- | +| final 与 content 同 patch 时顺序不确定 | 同时 watch 两个 source,并用 previous streaming 状态同步提交 | +| streaming heavy node 延迟后高度变化影响滚动 | 使用 Markstream 自带 lifecycle/ResizeObserver,外层继续按 PR #2000 批量测量 | +| 搜索或截图拿不到离屏 DOM | `virtualizeNodes=false` 同时关闭 node window 与 viewport deferral | +| stream-diffs optional peer 缺失 | direct dependency + Markstream 内建 pre fallback + production build 验证 | +| 旧静态 debounce 晚到覆盖 stream | 每次同步旁路递增同一个 revision,使旧 callback no-op | +| 事件委托误拦截普通节点 | 只处理 closest anchor / `.reference-node`,其他 click/mouse event 原样忽略 | +| 中间 stream 被错误追加到尾部 | 仅以排序后的 `messageIds` 最后一个 id 精确匹配,其他情况返回 null | diff --git a/docs/architecture/chat-markstream-rendering-pipeline/spec.md b/docs/architecture/chat-markstream-rendering-pipeline/spec.md new file mode 100644 index 0000000000..fed48178fb --- /dev/null +++ b/docs/architecture/chat-markstream-rendering-pipeline/spec.md @@ -0,0 +1,120 @@ +# Chat 与 Markstream 流式渲染管线 + +## 背景 + +PR #2000 已把高频流式消息从稳定历史列表中拆出,并为消息窗口增加 append-only tail +快路径、批量测量和显式行级 streaming 状态。与此同时,聊天正文仍经过 DeepChat 自己的 +内容防抖,再交给当前 `markstream-vue@1.0.7-beta.4` 的 smooth streaming、解析合并、节点批量挂载和重节点延迟机制。增强代码块使用其 `stream-diffs@0.0.2` peer。 + +当前端到端链路如下: + +```text +provider token events + -> main accumulator + -> echo renderer throttle (120 ms, full block snapshot) + -> chat.stream.updated typed event + -> messageIpc request/session ordering gate + -> stream store + folded message record + -> stable display history + active streaming tail + -> outer message window / scroll arbitration + -> MessageBlockContent ant-tag projection + -> MarkdownRenderer stream handoff + -> Markstream smooth streaming (up to 20 commits/s) + -> incremental Markdown parse + node batching + -> Monaco / Mermaid / KaTeX viewport deferral + -> DOM measurement -> outer message window / scroll follow +``` + +主进程的 120 ms 合帧控制跨进程吞吐,Markstream 的 smooth streaming 控制用户实际看到的 +文本节奏,PR #2000 的消息窗口控制历史行重算和滚动。集成层不应再成为第三个流式节拍 owner。 + +## 问题 + +1. 聊天流内容在进入 Markstream 前再经过 32 ms 或 96 ms 防抖。它不能减少 main snapshot + 数量,却增加首段之外每个 snapshot 的延迟,并可能让 `final` 先于最新防抖内容生效。 +2. 旧集成曾把 `codeRenderer="monaco"` 误当作流式期间即时创建 Monaco 的开关,并因此尝试在 + 应用层把 renderer 从 `pre` 重建为 `monaco`。当前 Markstream 的该兼容名称实际选择由 + `stream-diffs` 驱动的增强 `CodeBlockNode`:它在 block streaming 时保留内建 ``,仅在 + block 完成且可见后才升级同一宿主。因此外部 remount 会破坏受支持的 handoff,并可能造成完成闪烁或瞬时几何。 +3. generic `code_block` 自定义映射会绕过 Markstream 内建的 renderer selection、异步 fallback 和 + viewport-deferred `stream-diffs` 路径。仅安装 optional peer 或直接导入其 controller 都不能替代该路径。 +4. 该阶段曾为尾部 inline stream 引入 stable/tail layout segments;后续 + `renderer-state-ownership-hardening` 已移除这条私有路径,统一以完整 display-list 建立消息 geometry。 +5. 每个累计 snapshot 在 main 中先 JSON round-trip 再 Zod clone,renderer 写入消息后又立即把 + 同一份 blocks JSON.parse 回来;长回复会放大全量 snapshot 协议本身的 O(L^2) 累计成本。 +6. 每个 MarkdownRenderer 通过全局 custom component registry 注册纯渲染 wrapper。同一消息被 + artifact 分段时 registry id 冲突,且任意非空 custom map 都会让 Markstream 禁用稳定顶层节点复用。 +7. `codeBlockStream=false` 在 loading code node 上显示 skeleton,而不是实时代码;它不负责选择 + Monaco。对于聊天流,轻量 `` 比 skeleton 更连续,enhanced runtime 仍应等节点闭合且可见。 +8. 外层消息窗口与 Markstream 内层节点窗口职责相邻。两者必须各自只处理自己的粒度:外层按 + message 行裁剪,内层按 Markdown node 裁剪,搜索/截图需要完整 DOM 时同时显式退出延迟行为。 + +## 目标 + +1. 让 main 进程负责 snapshot 合帧,Markstream 负责文本 pacing;聊天集成层同步转交流式内容。 +2. 从 streaming 到 final 的同一消息、MarkdownRenderer、NodeRenderer 与内建 CodeBlockNode 都保持身份稳定;最终内容不会被旧的防抖回调覆盖。 +3. 整个生命周期保持 Markstream `codeRenderer="monaco"`,并仅以 `codeBlockStream` / `final` 表达流式状态;内建 `CodeBlockNode` 在 streaming 时呈现 ``,完成且可见后才挂载 `stream-diffs` 表面。内层 node virtualization 继续在 streaming 阶段关闭,避免 typewriter 尾部被节点窗口裁掉。 +4. completed 历史消息继续使用 Markstream node virtualization 及可见性延迟;聊天搜索、截图和其他要求 + 完整 DOM 的路径仍可通过 `virtualizeNodes=false` 同时关闭虚拟化与视口延迟。 +5. ordinary fenced code 使用 Markstream 内建 `stream-diffs` 路径;streaming 时先显示轻量 fallback, + final 且接近视口后再加载 enhanced File/FileDiff surface。Mermaid 保持 strict 处理。 +6. 保留非流式、可高频编辑的 docs/artifact surface 的现有内容防抖,避免把聊天优化扩散到不同 + 交互语义的页面。 +7. inline stream 与其他消息统一由完整 display-list 驱动;未变化记录仍复用 display-message + 转换缓存,虚拟窗口负责限制已挂载的行数。 +8. 复用已通过 IPC schema 校验的 blocks,减少 renderer 内部重复 JSON parse,同时不削弱 + main/preload/renderer 边界校验,也不改变持久化 JSON 格式。 +9. 使用 Markstream 内建 link/reference/Mermaid 节点和 renderer 级事件委托,避免全局 registry + 写入及其 parser reuse 失效;链接导航、搜索引用预览和 strict Mermaid 行为保持不变。 +10. 高频 chat stream 使用 Markstream 的 simple CSS typewriter cursor,避免 precise cursor 每次可见 + 提交都通过 Range/getClientRects 触发布局读取。 + +## 非目标 + +- 不修改 `chat.stream.updated` snapshot contract、main 120 ms renderer 合帧或 600 ms DB 合帧。 +- 不改 Markstream、stream-diffs 或 DeepChat 独立 editor surfaces 所用 stream-monaco 的上游实现,不维护本地 fork。 +- 不重写 `useArtifacts` 的 ant-tag parser、消息 JSON 持久化格式或 message store 的折叠协议。 +- 不在本次工作中把 full snapshot IPC 改为 delta transport;它仍是超长输出的剩余架构瓶颈。 +- 不替换 PR #2000 的 outer message window、scroll controller、search highlight 或 capture owner。 +- 不在本次工作中改变 markdown worker 的全局生命周期或引入新的用户设置。 + +## 验收标准 + +1. streaming 状态下,首个及后续 `content` snapshot 都在同一 Vue 更新周期传给 NodeRenderer, + 不等待 DeepChat 的 32/96 ms timer。 +2. streaming -> final 时,NodeRenderer 同步收到最终 content 和 `final=true`;任何较早的静态防抖 + 任务都不能回写旧内容。 +3. 非流式内容更新继续走已有 fast/slow debounce;现有 docs/artifact 行为不变。 +4. 正常 streaming chat 配置为 `nodeVirtual=false`、`maxLiveNodes=0`、`codeRenderer="monaco"`、`codeBlockStream=true`;完成态在同一 NodeRenderer 上设为 `final=true` 与 `codeBlockStream=false`。Markstream 在流式阶段展示内建 ``,完成且可见后才升级 `stream-diffs` surface。`viewportPriority` 与 `deferNodesUntilVisible` 仍仅由 `virtualizeNodes` 控制。 +5. `virtualizeNodes=false` 时,node virtualization、viewport priority 和 visible deferral 均关闭, + 保证搜索、截图和完整 DOM 消费者可用。 +6. NodeRenderer 在 live chat 和 completed/static 内容均保持 `codeRenderer="monaco"`,只通过 `codeBlockStream` 与 `final` 交给 Markstream 管理单一代码块 handoff;preview event 与 strict Mermaid 仍可用。 +7. MarkdownRenderer 不写入 Markstream 全局 custom component registry;内建 link/reference 事件经 + 根级委托保持 DeepChat 导航和引用交互,同消息多个 text part 互不覆盖。 +8. inline stream 在完整 display-list 中保持 `messageIds` 顺序;未变化记录的转换缓存、单行 + streaming 状态和 completion node reuse 回归测试继续通过。 +9. renderer 已校验 blocks 直接预填 parsed cache,不在同一个 snapshot 写入后立即 JSON.parse。 +12. format、i18n、lint、typecheck、targeted renderer tests 和 renderer production build 通过。 + +## 性能与交互预算 + +- 跨进程 snapshot 频率仍由 main 限制为最多约 8.3 次/秒。 +- 屏幕文本提交频率由 Markstream 限制为最多 20 次/秒;集成层不再叠加聊天内容 timer。 +- 单个 stream snapshot 的 display-message 转换复用未变化记录;外层 geometry 仍以完整 display-list + 计算,并由虚拟窗口限制实际挂载的行数。 +- 已校验 blocks 不得在 renderer 同步热路径再次 JSON.parse。 +- 重节点在接近视口前不得启动 Monaco/Mermaid/KaTeX 重工作;完成态的 fallback 到 enhanced + 切换不得替换外层 message row。 +- 沿用 chat scroll ownership 的每帧最多一次 scroll write、1 px anchor 误差和无新增 >50 ms + long task 预算;jsdom 测试覆盖可自动化的调度和 handoff 回归。 + +## 兼容性与回滚 + +变化位于 main snapshot clone 与 renderer 集成层,不改变持久化数据、IPC schema 或用户设置。 +若需回滚,可以分别恢复内容路由、parsed cache 预填、事件委托和内建 code renderer 选择; +`renderer-state-ownership-hardening` 的完整 display-list 消息窗口仍可独立工作。 + +## GitHub Issue + +未请求或创建 GitHub issue。当前工作区的 `docs/issues/markstream-code-block-rendering/spec.md` +记录 ordinary code block 的具体回归,本架构目标覆盖其集成层根因。 diff --git a/docs/architecture/chat-markstream-rendering-pipeline/tasks.md b/docs/architecture/chat-markstream-rendering-pipeline/tasks.md new file mode 100644 index 0000000000..fb858c2ce9 --- /dev/null +++ b/docs/architecture/chat-markstream-rendering-pipeline/tasks.md @@ -0,0 +1,20 @@ +# 任务清单 + +- [x] 审计 PR #2000 的 stable history、streaming tail、outer message window 与 scroll 链路。 +- [x] 审计 main 120 ms snapshot 合帧、renderer message store,以及当前 Markstream + stream-diffs 源码与 AI workflow。 +- [x] 明确 main、renderer integration、Markstream 和 outer window 的 owner 边界。 +- [x] 写入 spec、plan 和验证预算,无 `[NEEDS CLARIFICATION]`。 +- [x] 让 streaming snapshot 和 final handoff 绕过 DeepChat 静态内容 debounce。 +- [x] 建立稳定的 stream-diffs handoff:streaming 保持 Markstream `` fallback,completed/visible 由同一 CodeBlockNode 升级 File/FileDiff surface。 +- [x] 完成 Markstream 内建 enhanced code renderer 路径与公开 preview event 兼容处理。 +- [x] 补同步 stream handoff、final、viewport 和 enhanced code renderer 行为测试。 +- [x] 曾让正常尾部 inline stream 命中 stable/tail layout contract;后续 `renderer-state-ownership-hardening` 已改为单一完整 display-list 合同。 +- [x] 修正高 orderSeq 分页窗口中的 optimistic/stream 本地尾序。 +- [x] 使用 shallow stream state,并预填 renderer parsed cache;保留 main JSON normalization 兼容语义。 +- [x] 用内建 link/reference/Mermaid + 事件委托移除 MarkdownRenderer 全局 custom registry 热路径。 +- [x] 恢复 Markstream live code `` fallback,并覆盖 static/completed handoff 行为。 +- [x] 将高频 chat typewriter 切换为 simple CSS cursor。 +- [x] 更新 `markstream-code-block-rendering` issue spec 的版本、handoff 与验证状态。 +- [x] 运行 targeted renderer tests 并复审 failure。 +- [x] 运行 format、i18n、lint、typecheck、完整 renderer tests 与 production build。(full renderer 并行运行受宿主容量影响,出现 32 个无关 10 秒超时;Markstream 相关 suites、类型检查和 production bundling 均通过。) +- [x] 复审最终 diff、性能职责和 PR #2000 描述是否仍准确。 diff --git a/docs/architecture/renderer-scope-optimization/plan.md b/docs/architecture/renderer-scope-optimization/plan.md new file mode 100644 index 0000000000..82857704ee --- /dev/null +++ b/docs/architecture/renderer-scope-optimization/plan.md @@ -0,0 +1,89 @@ +# 实施计划 + +## Slice 1:会话与 deeplink 的并发边界 + +### 新会话提交 + +`NewThreadPage` 将持有单个 `isSubmitting` ref,并从 `onSubmit`、`onCommandSubmit` 进入统一 guarded submit 路径。锁从文件准备前开始,到 `submitText` 成功且页面状态清理完成后释放;异常仅记录并释放锁,不清除用户草稿。ACP 既有会话的 `sendMessage` 与普通 `createSession` 使用同一互斥语义。 + +发送 UI 同时接收锁状态,避免按钮表现为可点击但 handler 静默返回。 + +### Session 更新 + +- `refreshSessionsByIds` 为每个 refresh invocation 分配递增 revision,并将其登记到每个请求的 session ID。旧请求只会丢弃已被更新请求覆盖的 ID;不同 ID 的并发结果可各自合并。全量 `sessionListEpoch` 仍使旧定向响应整体失效,过期行不能更新 sessions、active shell 或 error。 +- 首屏请求记录发起时的定向提交 revision;若定向行在首屏等待期间先提交,首屏替换列表时保留这些具有明确因果新鲜度的行,其他旧列表行仍按首屏快照替换。 +- 定向刷新仅在它仍拥有至少一个 session ID 时清除或写入它自己标记的 refresh error;不覆盖首屏或分页错误。 +- `mergeSessions` 保留已知 session 的较新 `updatedAt`;相同 timestamp 缺少跨窗口因果顺序,也不得由延迟响应覆盖已渲染状态。 +- `sessionIpc` 按目标 webContents id 缓存 identity 未就绪时的最新定向 activation/deactivation 事件;`session` store 在取得本窗口 id 后仅处理本窗口事件,其他窗口事件不能覆盖它。非定向 list/update 不受影响。 + +### Start deeplink + +`applyStartDeeplink` 接收并守卫 payload token。在 `await currentDraftDefaultsTask`、`nextTick`、`ensureEnabledModelsReady` 之后确认 draft store 当前 token 仍相同;仅在仍当前时写 message/system prompt/model,且只清除相同 token 的 pending payload。ChatMain 的 start deeplink 激活流程也在每个 async 边界后确认 token,旧 payload 不得再路由、选择 agent 或关闭 session。当前 main 固定关闭的 `autoSend` 保持不消费。 + +## Slice 2:流式列表和搜索 + +### 行级流式状态 + +ChatPage 计算当前 stream message id,MessageList 按 `item.id` 生成 `isStreamingMessage`。MessageListRow / MessageItemAssistant 接收该行状态,保留 prop 默认兼容。会话是否仍在生成的全局状态继续保留给页面 shell、composer/stop action 等真正需要的消费者。 + +### 搜索 + +使用 VueUse 防抖的 canonical query 作为 expensive match collection / highlight 的输入。关闭时立即清空 search result 和 highlight。结果索引使用与 display model 相同的可渲染 block 投影,避免计数包含永远不会挂载的内容;DOM 高亮器刻意忽略的按钮文本和默认 `aria-hidden` 的 tool-call 详情不进入索引。结果导航使用已提交的查询,必要时在等待中禁用导航或保持最后稳定结果,避免旧结果与新 query 混用。MutationObserver 只聚合受影响行并按 rAF 刷新,明确抑制自身的高亮 DOM mutation。 + +不在本 slice 更改 markdown virtualization 语义,除非 review 证明它能以小范围改动保留现有匹配和跳转正确性。 + +### 历史加载状态 + +将 message store 的历史加载结果升级为受判别状态的返回值或 state,调用方据此区分 loaded/exhausted/error。失败状态在 ChatPage 顶部使用已有 shadcn button 和 i18n 文案提供 retry;成功及 exhausted 不造成额外打扰。 + +## Slice 3:多窗口 appearance foundation + +在 `src/renderer/src/foundation/appearance/` 放置只依赖浏览器/Vue 基础能力的纯 helper 或 composable:应用 theme class、font class、document lang/dir,并对短暂主题切换禁用 transition。数据读取和 renderer-specific listener 仍留在各 app,由 app 将解析后的 appearance state 传入 foundation helper。 + +chat main 迁移现有 `syncAppearanceClasses`;settings/floating 仅复用适用部分,保持各自 preload API 和 listener 生命周期。settings 的 persisted language 异步读取用 watcher cleanup 防止已失效的读取覆盖新语言事件。不能令 foundation import Pinia、feature、chat store 或 API client。 + +`ChatTabView` 已迁入 `apps/chat-main/`,router 保持按需加载该 route host,启动职责与路由契约不变。 + +## Slice 4:相邻 renderer 状态机收敛 + +### 项目环境重排 + +`projectStore.reorderEnvironments` 使用 renderer 内递增 revision 保护乐观排序,并同时记录共享 environment snapshot revision。并发的旧请求仍向它的调用方报错,但不得在后到 reorder、archive、restore、remove 或 IPC refresh 已经开始或完成后回滚 environments,避免拖拽和其他环境操作相互覆盖。 + +### 流式虚拟窗口 + +后续 `renderer-state-ownership-hardening` 切片将移除稳定前缀 + append-only tail contract。虚拟化始终由完整 display list 建立 geometry 并截取可见窗口;保留 display-message 转换缓存、pending/renderKey handoff、测量 cache 与逻辑锚定,确保公共的无闪烁和长历史 windowing 合同不变。tail 替换同时回收已离开 tail 的估高 cache。 + +### 可见 user text 与测试收尾 + +`features/chat-page/model/displayUserMessageText.ts` 与 `displayMessage.ts` 同属 chat display model:它将 rich content、mention label 和无 rich content 时按 offset 插入的 inline skill/file 投影为渲染 body 实际显示的 blocks/text。`MessageItemUser`、`MessageContent`、折叠度量与 `chatSearch` 只使用此投影;rich content 覆盖 raw text 和 inline metadata。standalone file/skill metadata 保持在 body 外,并显式标记为不可被 DOM 高亮器索引;高亮器在真实 message row 中也只遍历 `[data-message-content]`,避免 message chrome 增加不可导航匹配。 + +该阶段曾以稳定前缀和 streaming tail identity 覆盖 fast path;后续 `renderer-state-ownership-hardening` 已删除这条私有路径和 profile,改以 pending/renderKey handoff、完整 display-list、虚拟窗口和阅读锚定等公共行为测试覆盖。 + +MarkdownRenderer mock 测试删除 Markstream tuning profile 和 app-level stream/final handoff 的快照细节,只保留 DeepChat 的 worker、artifact、语言、link/reference/unmount 边界与 `final=false`/`codeBlockStream=true` streaming smoke。 + +## Slice 5:循环 review 与可证据化收尾 + +每完成一个 slice: + +1. 审查变更 diff、依赖方向、用户路径与异步状态机。 +2. 修复 review 明确发现的问题。 +3. 再次搜索 renderer 中相邻 owner 是否存在同类可安全收敛点。 +4. 仅当没有高/中风险、低回归风险的剩余优化,进入最终验证。 + +最终统一执行: + +```bash +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck +pnpm run test:renderer +pnpm run architecture:renderer-baseline:check +``` + +根据改动覆盖补充或运行直接 renderer suite。确认 git diff、测试与质量门禁后,创建 `--base dev` 的 PR。 + +## 回滚 + +所有改动均是 renderer 内部状态边界和展示优化:不涉及 IPC 或数据迁移。可以按 slice 反向恢复,且用户已有 session、配置和消息不需要迁移。 diff --git a/docs/architecture/renderer-scope-optimization/spec.md b/docs/architecture/renderer-scope-optimization/spec.md new file mode 100644 index 0000000000..862b19e249 --- /dev/null +++ b/docs/architecture/renderer-scope-optimization/spec.md @@ -0,0 +1,78 @@ +# Renderer Scope 收敛、可靠性与性能优化 + +## 背景 + +Renderer 已有独立的 main、settings、floating、splash 和 browser-overlay entry,且 chat main 的 composition root 已迁入 `apps/chat-main`。不过主聊天会话创建、会话列表刷新和 deeplink 仍存在并发窗口;流式消息在状态切换时会向全部可见行广播会话级状态;多窗口外观初始化也存在重复实现。 + +本工作在不改变 IPC、持久化数据、用户可见功能或 renderer entry 的前提下,继续收敛 renderer scope、消除已确认的异步竞态,并降低长会话流式与搜索时的无效计算。 + +## 目标 + +1. 让新建会话提交具备 renderer 侧互斥,避免同一草稿的快速重复触发创建多个会话。 +2. 防止过期的 session refresh 结果覆盖较新的本地或 IPC 更新,并保证 runtime webContents identity 尚未就绪时不会错误丢弃定向状态事件。 +3. 让 start deeplink 的异步默认值、模型解析、路由跳转和会话关闭只为仍是当前 token 的 payload 提交,不能由旧 deeplink 覆盖后到 deeplink 的导航意图。 +4. 将流式会话状态收敛到正在流式输出的消息行,避免历史 assistant 行在流开始和结束时无意义重算。 +5. 降低聊天搜索输入期间的同步全量扫描与 DOM 高亮频率,并保证结果计数、可见 DOM 高亮和键盘导航使用同一可渲染内容语义。 +6. 让历史消息加载区分 exhausted 与失败,失败时提供可访问的重试路径。 +7. 在不共享运行时 Pinia/Vue 实例的前提下,提取 renderer-only 外观初始化能力,减少 chat main、settings、floating 的主题/字体/语言初始化漂移。 +8. 保持既有 `features/chat-page/model/displayMessage.ts` 的 chat feature model 所有权;不为纯目录移动制造跨 feature “共享层”。 +9. 对 user message 的富文本、mention、inline skill/file 的可见文字建立单一纯函数投影,使渲染、折叠度量与搜索结果计数共享语义,且搜索结果只索引可以由 DOM 高亮器激活的文字。 +10. 后续的 `renderer-state-ownership-hardening` 架构切片移除 stable history + streaming tail 的 append-only 私有快路径:保留 message 转换缓存、pending/renderKey handoff、虚拟窗口与阅读锚定等公共合同,删除基于引用复用和手工 microbenchmark 的非用户可见合同。 +11. MarkdownRenderer mock 测试只锁定 DeepChat 委托边界和必要的 streaming smoke contract,不把 Markstream 的可调优 profile 数值当作本应用稳定 API。 +12. 每个实现切片完成后执行独立 review;发现问题先修复,再继续寻找明确且低风险的 renderer 优化点。 + +## 约束 + +- 代码改动范围限于 `src/renderer/`、对应 renderer 测试和本 SDD 文档;不变更 main、preload、shared IPC contract、数据库 schema 或用户设置格式。 +- 不改变现有发送、deeplink、会话选择、流式消息、搜索、历史加载的正常用户路径语义。 +- renderer app 维持独立 bootstrap、Pinia、i18n 和生命周期,不能共享运行中实例。 +- 新的用户可见文案必须使用既有 i18n key;若既有 key 无法表达状态,才添加全 locale key。 +- 不新增 renderer API facade;`renderer/api/*Client` 保持唯一 IPC adapter。 +- 性能优化以减少可验证的无效重算为目标,不预设未测量的复杂 cache/worker 方案。 +- 按用户要求,开发中不运行 lint、typecheck 或测试;所有质量门禁仅在 PR 创建前统一执行。 + +## 非目标 + +- 不移动或重命名 Vite HTML entry。 +- 不改 main 进程 stream 合帧、会话幂等、session 持久化协议或当前固定关闭的 start deeplink 自动发送产品语义。 +- 不重写成熟的 scroll controller、message window 锚定机制或 stream tombstone/generation gate。 +- 不把所有 `components/message` 移入 chat feature;它们可读取 chat 的纯 display model contract,详见 `chat-display-model-boundary`。 +- 不以 bundle 分包替换为主要优化手段,除非 production profiling 证明常规文本会话的首屏解析是明确瓶颈。 + +## 验收标准 + +1. 连续触发同一条新会话提交时,在首个提交完成前不会发出第二次 create/send;失败后仍可重新提交,且草稿只在成功时清除。 +2. 定向 session refresh 以 session ID 为粒度处理重叠:同一 ID 只有最新请求可以提交结果,不同 ID 的并发结果都可提交;全量列表 epoch 仍会使旧定向结果整体失效,首屏请求发起后已提交的定向行不会再被该首屏的旧快照覆盖,且合并不会用更旧 `updatedAt` 覆盖较新 session 数据。 +3. webContents identity 异步就绪期间收到的定向 activation/deactivation 被保留,并在 identity 就绪后按已有 session activation 逻辑处理。 +4. 被较新 start deeplink 取代的异步任务不写入 message/draft/model,也不清除较新的 pending payload。 +5. `MessageListRow` 只为当前流式 assistant message 接收生成中状态;历史行的活动分组结果不因会话级状态切换而变化。 +6. 聊天搜索的文本扫描和 DOM 高亮经过防抖;关闭、结果导航、可见行高亮行为保持可用;被 DOM 高亮器忽略的交互控件及默认隐藏的工具详情不产生无法激活的结果。 +7. 历史加载在 failure 与 exhausted 情况有不同 UI 状态;failure 可重试且不误报已到底。 +8. 共享外观能力不导入 chat feature/store;每个 app 仍按自己的数据源初始化并在 cleanup 时解绑。 +9. rich user content 优先于 raw text 时,MessageItemUser、MessageContent、折叠判定和 search result 计数使用相同的文字投影;mention 的 prompts/context label 及 inline skill/file 标签与实际 DOM 一致,且不会因 raw text 或非可见 metadata 产生额外结果。 +10. 该阶段曾保留 stable/tail profile 与引用 identity 测试;后续 `renderer-state-ownership-hardening` 已用单一 display-list、虚拟窗口和阅读锚定等公共合同取代它们,不将数组 microbenchmark 作为性能依据。 +11. MarkdownRenderer 测试继续覆盖 worker 初始化、artifact ID、语言规范化、link/reference 行为、失败重试和 unmount guard;仅保留 live streaming `final=false`、`codeBlockStream=true` 的 smoke 断言,而不锁定 profile tuning 数值或本地 debounce handoff 细节。 +12. 每项实现后完成独立只读 review,最终 review 未发现需要在本 scope 中继续处理的高/中风险问题。 +13. 最终执行 format、i18n、lint、typecheck、针对性与 renderer tests,并在通过后创建以 `dev` 为 base 的 PR。 + +## 风险与兼容性 + +- 提交互斥必须覆盖文件准备等异步步骤;否则第二次触发仍可能穿透。 +- session `updatedAt` 只可作为 renderer 防倒灌保护,不能替代 main 持久化的线性化保证。 +- 深链 token 必须在每个异步边界后复核,且清除动作必须与 token 绑定。 +- 搜索防抖只延后计算,不能使 Escape、关闭、箭头导航使用旧索引。 +- 外观 bootstrap 的初始读取和订阅顺序需兼容各 renderer 的 preload API 差异。 + +## 最终复审补充 + +最终 renderer 复审确认并收敛了三个与本目标一致的状态边界: + +- start deeplink 仅保留异步边界后的 token 校验,移除无法引入交错的同步重复 guard; +- floating button 在读取初始 snapshot 前订阅跨窗口 IPC 更新,且本地写失败只回滚到实际的前值; +- DeepChat agent 默认配置异步返回时,不得覆盖用户在等待期间手动选择的新项目。 + +同时修复了 language store 对显式 `ltr` direction 的丢失,以及 project snapshot 过期失败错误可能覆盖较新本地 mutation 的情况。 + +## GitHub Issue + +本工作由用户直接要求提交 PR;未请求创建或同步 GitHub Issue。 diff --git a/docs/architecture/renderer-scope-optimization/tasks.md b/docs/architecture/renderer-scope-optimization/tasks.md new file mode 100644 index 0000000000..32996db8b7 --- /dev/null +++ b/docs/architecture/renderer-scope-optimization/tasks.md @@ -0,0 +1,27 @@ +# 任务清单 + +- [x] 建立 renderer scope 优化设计、约束和验证计划。 +- [x] 审查并修复 NewThreadPage 提交互斥与 deeplink token 竞态(含 ChatMain 激活抢占)。 +- [x] 审查并修复 session refresh 的陈旧结果及 runtime identity 早到 IPC 事件。 +- [x] 添加对应 renderer 回归测试(最终统一运行)。 +- [x] 把流式生成状态收敛到单条 assistant 行,并审查渲染影响。 +- [x] 对聊天搜索加入低风险防抖/高亮调度优化,并审查导航正确性。 +- [x] 区分历史消息 exhausted/error 并添加重试反馈。 +- [x] 提取不依赖 chat 的 renderer appearance foundation,并迁移适用 app,保持 language direction 与初始化事件一致。 +- [x] 核验并清理无引用的 `views/SettingsTabView.vue` 遗留设置视图。 +- [x] 将 `ChatTabView` 路由宿主迁入 `apps/chat-main/` 并保持 lazy route。 +- [x] 保护项目环境重排的并发乐观回滚,并收敛 append-only 流式虚拟窗口数据路径。 +- [x] 修复 review 发现的旧首屏覆盖定向更新、tool-call 搜索死结果和跨环境操作回滚。 +- [x] 更新依赖 session IPC binding 的测试 mock,并同步 renderer architecture baseline。 +- [x] 对每个切片执行独立 review,修复发现的问题并继续寻找剩余优化。 +- [x] 执行最终 format、i18n、lint、typecheck、renderer tests 与 architecture baseline check。 +- [x] 审阅最终 diff 并提交 follow-up commit;已有 base 为 `dev` 的 PR #2000。 +- [x] 最终复审收敛 start deeplink 的冗余同步 token 守卫,保留所有异步边界后的因果校验。 +- [x] 修复浮窗按钮 snapshot/IPC 初始化竞态及写入失败时的精确回滚,并补充 scope cleanup 回归测试。 +- [x] 防止异步 DeepChat agent 默认配置覆盖请求期间用户手动选择的项目。 +- [x] 补齐显式 LTR 语言方向与过期项目快照错误抑制的回归覆盖。 +- [x] 再次执行 format、i18n、lint、typecheck、定向 renderer tests 与 architecture baseline check,并更新 PR #2000。 +- [x] 复跑完整 renderer suite:默认并发下出现广泛、非确定性超时;双 worker 串行化后仅 `ChatPage` 单测偶发超时,该文件单独复跑通过。保留结果供 CI 验证,不将其误报为通过。 +- [x] 将 user message 可见文字投影收敛到 chat display model,并让渲染、折叠和搜索结果计数共享其 mention / inline content 语义。 +- [x] 收敛 MarkdownRenderer mock 测试到跨层独有合同,新增非 CI 的手工 tail fast-path profile。 +- [x] 在后续 `renderer-state-ownership-hardening` 中移除 stable/tail 私有 display/layout 快路径和 profile,改以单一 display-list、虚拟窗口和锚定公共合同验证。 diff --git a/docs/issues/chat-interrupt-actions-stall/spec.md b/docs/issues/chat-interrupt-actions-stall/spec.md new file mode 100644 index 0000000000..a0302394f7 --- /dev/null +++ b/docs/issues/chat-interrupt-actions-stall/spec.md @@ -0,0 +1,101 @@ +# Chat Interrupt Actions Can Stall Silently + +## Issue + +The chat composer can show Stop or Steer as available while clicking the control produces no +visible result. The symptom is intermittent because it depends on session-restore timing, transient +composer gates, or a failed main-process action. + +## Impact + +- A stale `working` session snapshot can leave Stop/Steer visible after the active turn has ended. +- Composer Steer looks enabled while session preparation, a missing ACP workdir, or a pending tool + interaction causes the submit path to return without feedback. +- Stop ignores the main-process `{ stopped: false }` result, and composer Steer does not catch a + rejected request, so users cannot distinguish a pending action from a failed action. +- Repeated clicks can dispatch duplicate operations because neither action exposes an in-flight + state. + +## Root Cause + +1. `sessions.status.changed` includes a version, but `sessionIpc` drops it before applying status to + the session store. +2. Session restore reads the session snapshot before reading messages. A status event can arrive + between those reads, after which the older restore snapshot unconditionally replaces + `activeSessionSummary.status`. +3. `disableQueueSteerAction` is wired to queued-item Steer but not to the composer toolbar Steer + button. The handler repeats those gates as silent early returns. +4. Stop discards the `stopped` response, while Stop and composer Steer only log failures (or leave an + unhandled rejection) and have no pending state. + +## Fix Plan + +- Track the newest observed status event per session, including its version and mapped UI status. +- Reject older status events and merge the newest observed status into asynchronous list, hydrate, + and restore snapshots so stale reads cannot roll the UI backward. +- Pass the existing Steer availability state into `ChatInputToolbar`; disable the button and expose + the existing unavailable tooltip when the action cannot run. +- Add session-scoped pending state for composer Steer and Stop, render the existing shadcn spinner, + prevent duplicate dispatch, retain the draft on failure, and surface destructive toasts. +- Treat `{ stopped: false }` as a user-visible failure and only rebaseline the plan after composer + Steer is accepted. + +## Constraints + +- Do not change the Stop/Steer IPC route schemas or queue ordering semantics. +- Do not optimistically mark a session idle; runtime status events remain authoritative. +- Do not clear a draft when Steer fails or when its completion belongs to a superseded session view. +- Preserve the existing automatic queue drain after Stop/Steer. +- Use existing i18n keys and shadcn primitives; no new custom loading control. + +## UI Change + +BEFORE + +```text +Generating + draft: [Steer (always enabled)] [Queue] +Generating, no draft: [Stop] +Request in flight: no visible pending state or failure feedback +``` + +AFTER + +```text +Generating + draft: [Steer disabled | spinner] [Queue disabled while steering] +Generating, no draft: [Stop spinner] +Request failure: destructive toast; Steer draft remains available +``` + +## Task Checklist + +- [x] Preserve monotonic status events across asynchronous session snapshots. +- [x] Wire composer Steer availability and pending state through the toolbar. +- [x] Surface Stop/Steer failures and prevent duplicate requests. +- [x] Add renderer store and component regression tests. +- [x] Run format, i18n, lint, typecheck, and focused tests. +- [x] Commit and push the fix to PR #2000. + +## Validation + +- An `idle` status event cannot be overwritten by an older `generating` restore snapshot, and the + inverse transition is equally protected. +- Status events older than the last applied version are ignored. +- Composer Steer is visibly disabled whenever its handler would reject the action. +- Pending Stop/Steer renders progress and accepts only one click. +- Rejected Steer retains the draft and shows an error toast. +- Stop returning `{ stopped: false }` shows an error toast. +- Existing queue/steer runtime tests remain green. + +### Results + +- `pnpm exec vitest run` for the affected session store, toolbar, ChatPage, and composer suites: + 169 tests passed. +- `pnpm exec vitest run test/renderer/components/MemorySettings.test.ts`: 11 tests passed + when rerunning the unrelated full-suite timeout in isolation. +- `pnpm run typecheck`, `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`: passed on + Node 24.14.1. + +## GitHub + +- Target PR: https://github.com/ThinkInAIXYZ/deepchat/pull/2000 +- No separate GitHub issue was requested or created. diff --git a/docs/issues/markstream-code-block-rendering/spec.md b/docs/issues/markstream-code-block-rendering/spec.md new file mode 100644 index 0000000000..1a3366a533 --- /dev/null +++ b/docs/issues/markstream-code-block-rendering/spec.md @@ -0,0 +1,79 @@ +# Markstream Code Block Rendering Regression + +## Issue + +DeepChat's Markstream-backed code blocks can show gutter/content overlap or an incorrectly measured +code surface after an assistant message changes from streaming to final. The integration must use the +current enhanced code-block runtime: `markstream-vue@1.0.7-beta.4` with its `stream-diffs@0.0.2` +peer. + +## Impact + +- Language-labeled and unlabeled fences must keep their source readable while generation is active. +- Completed, visible fences must upgrade to the enhanced `stream-diffs` File/FileDiff surface without + replacing the outer message or reusing transient streaming geometry. +- The application must not depend on Markstream internals, direct `stream-diffs` controllers, or CSS + overrides of editor/gutter geometry. + +## Root Cause + +The prior diagnosis inspected a `markstream-vue@1.0.5 + stream-monaco` runtime after the dependency +had been pinned away from the current release line. That runtime is not the one requested for this +integration, and its application-managed continuous Monaco lifecycle is not the documented behavior +of the current `stream-diffs` adapter. + +For current Markstream, `codeRenderer="monaco"` remains the compatibility name for the enhanced +`CodeBlockNode`, but it does **not** create a live Monaco instance for every streaming code update. +The component owns a stable handoff: + +```text +MarkdownRenderer + -> NodeRenderer (content grows, final=false, codeRenderer="monaco", codeBlockStream=true) + -> CodeBlockNode renders its built-in PreCodeNode while the fence is streaming + +same NodeRenderer / same CodeBlockNode + -> final=true, codeBlockStream=false, completed and visible + -> CodeBlockNode dynamically imports stream-diffs and mounts one File/FileDiff surface +``` + +Replacing the renderer at the application boundary (`pre` → `monaco`) or calling private runtime APIs +would bypass that lifecycle and reintroduce a completion flash or geometry race. The app-level prose +root also applies `break-all`; enhanced code hosts must opt out of that inherited text-breaking rule, +and their flex parent must be allowed to shrink, without targeting runtime gutter internals. + +## Fix Plan + +1. Restore current direct dependencies: `markstream-vue@1.0.7-beta.4` and `stream-diffs@0.0.2`. + Keep `stream-monaco` because DeepChat's independent artifact/editor surfaces still import it. +2. Keep one stable `NodeRenderer` with `codeRenderer="monaco"` for both streaming and final states. + Pass `codeBlockStream=true` only while the message is live and set `final=true` with the last + content snapshot when it completes. +3. Do not set `renderCodeBlocksAsPre`, register a generic `code_block` override, remount the renderer, + import `stream-diffs` directly from DeepChat, or call private adapter methods. +4. Continue using Markstream's documented `handle-artifact-click` event and supported + `codeBlockProps` / `codeBlockMonacoOptions` props. +5. Replace DeepChat-only Markdown fence labels such as `desktop-local-file` with `plaintext` before + passing content to Markstream. `stream-diffs` delegates final highlighting to Shiki and rejects + unsupported identifiers; valid standard fence languages remain unchanged. +6. Use `break-words` instead of the prose root's `break-all`, and retain `min-w-0` on the assistant + content flex item. Neither change overrides runtime gutter/content geometry. +7. Update DOM-contract tests to cover both unlabeled and TypeScript fallback/final handoff. + +## Validation + +- [x] Package lock resolves `markstream-vue@1.0.7-beta.4` with `stream-diffs@0.0.2`. +- [x] Focused MarkdownRenderer tests confirm one enhanced renderer configuration through streaming and + final completion, with the final snapshot committed synchronously. +- [x] Markstream DOM-contract test proves the built-in streaming `` fallback and post-completion + enhanced `stream-diffs` handoff for unlabeled and TypeScript fences. +- [x] Message layout test verifies the code host's flex ancestor can shrink. +- [x] Focused renderer tests (54), format, i18n, lint, typecheck, and direct `electron-vite build` + passed. The full renderer suite remains a host-capacity follow-up: its parallel run produced 32 + unrelated 10-second test timeouts, while all Markstream-related suites passed. The standard + `pnpm run build` command also needs a `pnpm` Corepack shim in this host's child-script PATH; its + prebuild refresh completed and direct production bundling passed. + + +## GitHub Issue + +No GitHub issue has been requested or created. diff --git a/package.json b/package.json index a66ffb7c1a..d93898b334 100644 --- a/package.json +++ b/package.json @@ -197,7 +197,7 @@ "electron-vite": "5.0.0", "jsdom": "^26.1.0", "katex": "^0.16.47", - "markstream-vue": "^1.0.6", + "markstream-vue": "1.0.7-beta.4", "mermaid": "^11.15.0", "minimatch": "^10.2.5", "monaco-editor": "^0.55.1", @@ -206,6 +206,7 @@ "picocolors": "^1.1.1", "pinia": "^3.0.4", "reka-ui": "^2.10.1", + "stream-diffs": "0.0.2", "stream-monaco": "^0.0.49", "tailwind-merge": "^3.6.0", "tailwind-scrollbar-hide": "^4.0.0", diff --git a/resources/acp-registry/registry.json b/resources/acp-registry/registry.json index bc291798c0..ad62f32840 100644 --- a/resources/acp-registry/registry.json +++ b/resources/acp-registry/registry.json @@ -103,7 +103,7 @@ { "id": "claude-acp", "name": "Claude Agent", - "version": "0.59.0", + "version": "0.60.0", "description": "ACP wrapper for Anthropic's Claude", "repository": "https://github.com/agentclientprotocol/claude-agent-acp", "authors": [ @@ -114,7 +114,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@agentclientprotocol/claude-agent-acp@0.59.0" + "package": "@agentclientprotocol/claude-agent-acp@0.60.0" } }, "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg" @@ -331,7 +331,7 @@ { "id": "cursor", "name": "Cursor", - "version": "2026.07.09", + "version": "2026.07.16", "description": "Cursor's coding agent", "website": "https://cursor.com/docs/cli/acp", "authors": [ @@ -341,42 +341,42 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/darwin/arm64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.07.16-899851b/darwin/arm64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "darwin-x86_64": { - "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/darwin/x64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.07.16-899851b/darwin/x64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "linux-aarch64": { - "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/linux/arm64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.07.16-899851b/linux/arm64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "linux-x86_64": { - "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/linux/x64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.07.16-899851b/linux/x64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "windows-aarch64": { - "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/windows/arm64/agent-cli-package.zip", + "archive": "https://downloads.cursor.com/lab/2026.07.16-899851b/windows/arm64/agent-cli-package.zip", "cmd": "./dist-package\\cursor-agent.cmd", "args": [ "acp" ] }, "windows-x86_64": { - "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/windows/x64/agent-cli-package.zip", + "archive": "https://downloads.cursor.com/lab/2026.07.16-899851b/windows/x64/agent-cli-package.zip", "cmd": "./dist-package\\cursor-agent.cmd", "args": [ "acp" @@ -467,7 +467,7 @@ { "id": "dimcode", "name": "DimCode", - "version": "0.2.31", + "version": "0.2.35", "description": "A coding agent that puts leading models at your command.", "website": "https://dimcode.dev/docs/acp.html", "authors": [ @@ -476,7 +476,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "dimcode@0.2.31", + "package": "dimcode@0.2.35", "args": [ "acp" ] @@ -508,7 +508,7 @@ { "id": "factory-droid", "name": "Factory Droid", - "version": "0.175.1", + "version": "0.176.0", "description": "Factory Droid - AI coding agent powered by Factory AI", "website": "https://factory.ai/product/cli", "authors": [ @@ -517,7 +517,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "droid@0.175.1", + "package": "droid@0.176.0", "args": [ "exec", "--output-format", @@ -534,7 +534,7 @@ { "id": "fast-agent", "name": "fast-agent", - "version": "0.9.17", + "version": "0.9.18", "description": "Code and build agents with comprehensive multi-provider support", "repository": "https://github.com/evalstate/fast-agent", "website": "https://fast-agent.ai", @@ -544,7 +544,7 @@ "license": "Apache 2.0", "distribution": { "uvx": { - "package": "fast-agent-acp==0.9.17", + "package": "fast-agent-acp==0.9.18", "args": [ "-x" ] @@ -576,7 +576,7 @@ { "id": "github-copilot-cli", "name": "GitHub Copilot", - "version": "1.0.71", + "version": "1.0.73", "description": "GitHub's AI pair programmer", "repository": "https://github.com/github/copilot-cli", "website": "https://github.com/features/copilot/cli/", @@ -586,7 +586,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@github/copilot@1.0.71", + "package": "@github/copilot@1.0.73", "args": [ "--acp" ] @@ -597,7 +597,7 @@ { "id": "glm-acp-agent", "name": "GLM Agent", - "version": "1.2.0", + "version": "1.3.0", "description": "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", "repository": "https://github.com/stefandevo/glm-acp-agent", "authors": [ @@ -607,7 +607,7 @@ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/glm-acp-agent.svg", "distribution": { "npx": { - "package": "glm-acp-agent@1.2.0" + "package": "glm-acp-agent@1.3.0" } } }, @@ -671,7 +671,7 @@ { "id": "grok-build", "name": "Grok Build", - "version": "0.2.106", + "version": "0.2.108", "description": "xAI's coding agent and CLI", "website": "https://x.ai/cli", "authors": [ @@ -680,7 +680,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@xai-official/grok@0.2.106", + "package": "@xai-official/grok@0.2.108", "args": [ "agent", "stdio" @@ -754,7 +754,7 @@ { "id": "junie", "name": "Junie", - "version": "2144.9.0", + "version": "2144.11.0", "description": "AI Coding Agent by JetBrains", "repository": "https://github.com/JetBrains/junie", "website": "https://junie.jetbrains.com", @@ -765,35 +765,35 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-macos-aarch64.zip", + "archive": "https://github.com/JetBrains/junie/releases/download/2144.11/junie-release-2144.11-macos-aarch64.zip", "cmd": "./Applications/junie.app/Contents/MacOS/junie", "args": [ "--acp=true" ] }, "darwin-x86_64": { - "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-macos-amd64.zip", + "archive": "https://github.com/JetBrains/junie/releases/download/2144.11/junie-release-2144.11-macos-amd64.zip", "cmd": "./Applications/junie.app/Contents/MacOS/junie", "args": [ "--acp=true" ] }, "linux-aarch64": { - "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-linux-aarch64.zip", + "archive": "https://github.com/JetBrains/junie/releases/download/2144.11/junie-release-2144.11-linux-aarch64.zip", "cmd": "./junie-app/bin/junie", "args": [ "--acp=true" ] }, "linux-x86_64": { - "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-linux-amd64.zip", + "archive": "https://github.com/JetBrains/junie/releases/download/2144.11/junie-release-2144.11-linux-amd64.zip", "cmd": "./junie-app/bin/junie", "args": [ "--acp=true" ] }, "windows-x86_64": { - "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-windows-amd64.zip", + "archive": "https://github.com/JetBrains/junie/releases/download/2144.11/junie-release-2144.11-windows-amd64.zip", "cmd": "./junie/junie.exe", "args": [ "--acp=true" @@ -988,7 +988,7 @@ { "id": "nova", "name": "Nova", - "version": "1.1.28", + "version": "1.1.29", "description": "Nova by Compass AI - a fully-fledged software engineer at your command", "repository": "https://github.com/Compass-Agentic-Platform/nova", "website": "https://www.compassap.ai/portfolio/nova.html", @@ -999,7 +999,7 @@ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/nova.svg", "distribution": { "npx": { - "package": "@compass-ai/nova@1.1.28", + "package": "@compass-ai/nova@1.1.29", "args": [ "acp" ] @@ -1009,7 +1009,7 @@ { "id": "opencode", "name": "OpenCode", - "version": "1.18.3", + "version": "1.18.4", "description": "The open source coding agent", "repository": "https://github.com/anomalyco/opencode", "website": "https://opencode.ai", @@ -1021,52 +1021,52 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-darwin-arm64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-darwin-arm64.zip", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "946f62b155638b911144b7bef520ee4a6442f696297907873463bca3524e40ef" + "sha256": "04fb881b632b323c712dfda6dcbbc6fce736394f07ba76176e52d6665925d4e6" }, "darwin-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-darwin-x64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-darwin-x64.zip", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "4ea147867ba19e4ec03559df557811f1674f40788aea4d10326dc563b7667c6d" + "sha256": "e177c532654572079981db1dce464a78adbaed9654a142848b2e81beb8c9f5c6" }, "linux-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-linux-arm64.tar.gz", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-linux-arm64.tar.gz", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "da0a631174eba380b2a1d51f9d364fa3812da433e72743c72471d4b5da59c69d" + "sha256": "eba87efba3976d533a24cca0316f8ef375b5f8e797c0a95c25ee919700b7ba35" }, "linux-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-linux-x64.tar.gz", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-linux-x64.tar.gz", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "60f27b2679f00a511b6539f97e02448afaf58d9c66e2448285ea0c517ca84583" + "sha256": "bab463c3fb3224d388bb7cfad63f38703df9cf0be2cfd2ce8cb49d886b53a174" }, "windows-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-windows-arm64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-windows-arm64.zip", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "a549fb2e9041db9438bcd9b77bfa0a4b2476caf2d550f37479aabfec1b079bfb" + "sha256": "4b4d2b48afdf1432a697bccabe230c3d614cf8ed34f5bc0acf9ffd89bb9cfb25" }, "windows-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-windows-x64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-windows-x64.zip", "cmd": "./opencode.exe", "args": [ "acp" ], - "sha256": "68bc62930f6cb5755e0409aa9de0bb270a66ed2b8c9cf0c029e9f2287ed5486e" + "sha256": "814dae5724dfa396a43b6408703d0929625483e2fac135623f10f0fa8db04a96" } } } @@ -1091,7 +1091,7 @@ { "id": "poolside", "name": "Poolside", - "version": "1.0.11", + "version": "1.0.13", "description": "Poolside's coding agent", "repository": "https://github.com/poolsideai/pool", "website": "https://poolside.ai", @@ -1102,46 +1102,52 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-darwin-arm64.tar.gz", + "archive": "https://downloads.poolside.ai/pool/v1.0.13/pool-darwin-arm64.tar.gz", "cmd": "./pool-darwin-arm64", "args": [ "acp" - ] + ], + "sha256": "19634a50986899eb139bc62d77d1917743563edaab365637f6579167bde0d8d5" }, "darwin-x86_64": { - "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-darwin-amd64.tar.gz", + "archive": "https://downloads.poolside.ai/pool/v1.0.13/pool-darwin-amd64.tar.gz", "cmd": "./pool-darwin-amd64", "args": [ "acp" - ] + ], + "sha256": "1efc0f27173522c37c300ce5563df264270ea916676865f4e7d00e42cde48046" }, "linux-aarch64": { - "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-linux-arm64.tar.gz", + "archive": "https://downloads.poolside.ai/pool/v1.0.13/pool-linux-arm64.tar.gz", "cmd": "./pool-linux-arm64", "args": [ "acp" - ] + ], + "sha256": "8c41ff475a9e0a2209ffee0c3c706d52ae1fd765ffe13d6857ab88b804d5a954" }, "linux-x86_64": { - "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-linux-amd64.tar.gz", + "archive": "https://downloads.poolside.ai/pool/v1.0.13/pool-linux-amd64.tar.gz", "cmd": "./pool-linux-amd64", "args": [ "acp" - ] + ], + "sha256": "01fe682e3d8ae982418e8d0614b740024aeb91ba145cf8d9be4d1ec195c0b7d0" }, "windows-aarch64": { - "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-windows-arm64.tar.gz", + "archive": "https://downloads.poolside.ai/pool/v1.0.13/pool-windows-arm64.tar.gz", "cmd": "./pool-windows-arm64.exe", "args": [ "acp" - ] + ], + "sha256": "88072012c18ade11c944d8463a35044e50f7586ba2db4daa3878a00a38118ca9" }, "windows-x86_64": { - "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-windows-amd64.tar.gz", + "archive": "https://downloads.poolside.ai/pool/v1.0.13/pool-windows-amd64.tar.gz", "cmd": "./pool-windows-amd64.exe", "args": [ "acp" - ] + ], + "sha256": "68e57bcf4b5ee4737065b8f77c1fed25ed5f8993bb90357be7e24af1440cb595" } } }, diff --git a/resources/model-db/providers.json b/resources/model-db/providers.json index 3f41575edc..4883cac051 100644 --- a/resources/model-db/providers.json +++ b/resources/model-db/providers.json @@ -104492,6 +104492,53 @@ }, "type": "imageGeneration" }, + { + "id": "qwen3.8-max-preview", + "name": "Qwen3.8 Max Preview", + "display_name": "Qwen3.8 Max Preview", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-07-19", + "last_updated": "2026-07-19", + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "type": "chat" + }, { "id": "kimi-k2.6", "name": "Kimi K2.6", @@ -136793,6 +136840,53 @@ }, "type": "imageGeneration" }, + { + "id": "qwen3.8-max-preview", + "name": "Qwen3.8 Max Preview", + "display_name": "Qwen3.8 Max Preview", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-07-19", + "last_updated": "2026-07-19", + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "type": "chat" + }, { "id": "kimi-k2.6", "name": "Kimi K2.6", @@ -174967,7 +175061,7 @@ "cost": { "input": 2, "output": 6, - "cache_read": 0.5 + "cache_read": 0.3 }, "type": "chat" }, @@ -190173,6 +190267,87 @@ }, "type": "chat" }, + { + "id": "moonshotai/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "display_name": "Kimi K2.7 Code", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "cost": { + "input": 0.95, + "output": 4, + "cache_read": 0.19 + }, + "type": "chat" + }, + { + "id": "moonshotai/kimi-k3", + "name": "Kimi K3", + "display_name": "Kimi K3", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 1048576 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-07-16", + "last_updated": "2026-07-16", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3 + }, + "type": "chat" + }, { "id": "moonshotai/kimi-k2-thinking", "name": "Kimi K2 Thinking", @@ -226750,7 +226925,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226775,7 +226950,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226800,7 +226975,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226824,7 +226999,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226848,7 +227023,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226872,7 +227047,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226897,7 +227072,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226922,7 +227097,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "embedding" }, { @@ -226947,7 +227122,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "embedding" }, { @@ -226973,7 +227148,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226999,7 +227174,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227025,7 +227200,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227051,7 +227226,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227077,7 +227252,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227103,7 +227278,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227129,7 +227304,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227155,7 +227330,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227181,7 +227356,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227207,7 +227382,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227233,7 +227408,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227259,7 +227434,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227285,7 +227460,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227311,7 +227486,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227335,7 +227510,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227361,7 +227536,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227387,7 +227562,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227413,7 +227588,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227437,7 +227612,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227458,7 +227633,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z" + "last_updated": "2026-07-20T09:28:20Z" }, { "id": "doubao-seedance-1-0-pro-fast-251015", @@ -227478,7 +227653,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z" + "last_updated": "2026-07-20T09:28:20Z" }, { "id": "doubao-seedance-1-5-pro-251215", @@ -227499,7 +227674,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z" + "last_updated": "2026-07-20T09:28:20Z" }, { "id": "doubao-seedance-2-0-260128", @@ -227521,7 +227696,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z" + "last_updated": "2026-07-20T09:28:20Z" }, { "id": "doubao-seedance-2-0-fast-260128", @@ -227543,7 +227718,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z" + "last_updated": "2026-07-20T09:28:20Z" }, { "id": "doubao-seedance-2-0-mini-260615", @@ -227565,7 +227740,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z" + "last_updated": "2026-07-20T09:28:20Z" }, { "id": "doubao-seedream-4-0-250828", @@ -227585,7 +227760,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "imageGeneration" }, { @@ -227606,7 +227781,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "imageGeneration" }, { @@ -227627,7 +227802,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "imageGeneration" }, { @@ -227648,7 +227823,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "imageGeneration" }, { @@ -227673,7 +227848,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227698,7 +227873,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" } ] @@ -236122,6 +236297,95 @@ }, "type": "chat" }, + { + "id": "hy3-preview", + "name": "hy3-preview", + "display_name": "hy3-preview", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.17, + "output": 0.566661, + "cache_read": 0.051 + }, + "type": "chat" + }, + { + "id": "minimax-m3", + "name": "minimax-m3", + "display_name": "minimax-m3", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 204800 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.288, + "output": 1.152 + }, + "type": "chat" + }, + { + "id": "qwen3.7-plus", + "name": "qwen3.7-plus", + "display_name": "qwen3.7-plus", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 991000, + "output": 991000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.282, + "output": 1.128, + "cache_read": 0.0564 + }, + "type": "chat" + }, { "id": "step-3.7-flash", "name": "step-3.7-flash", @@ -236147,95 +236411,6 @@ }, "type": "chat" }, - { - "id": "hy3-preview", - "name": "hy3-preview", - "display_name": "hy3-preview", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 256000, - "output": 256000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.17, - "output": 0.566661, - "cache_read": 0.051 - }, - "type": "chat" - }, - { - "id": "minimax-m3", - "name": "minimax-m3", - "display_name": "minimax-m3", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 204800, - "output": 204800 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.288, - "output": 1.152 - }, - "type": "chat" - }, - { - "id": "qwen3.7-plus", - "name": "qwen3.7-plus", - "display_name": "qwen3.7-plus", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 991000, - "output": 991000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.282, - "output": 1.128, - "cache_read": 0.0564 - }, - "type": "chat" - }, { "id": "claude-opus-4-8-think", "name": "claude-opus-4-8-think", @@ -236439,6 +236614,35 @@ }, "type": "chat" }, + { + "id": "coding-glm-5.2", + "name": "coding-glm-5.2", + "display_name": "coding-glm-5.2", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.06, + "output": 0.22 + }, + "type": "chat" + }, { "id": "grok-4.3", "name": "grok-4.3", @@ -236562,64 +236766,6 @@ }, "type": "chat" }, - { - "id": "coding-glm-5.2", - "name": "coding-glm-5.2", - "display_name": "coding-glm-5.2", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.06, - "output": 0.22 - }, - "type": "chat" - }, - { - "id": "coding-minimax-m3-free", - "name": "coding-minimax-m3-free", - "display_name": "coding-minimax-m3-free", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 204800, - "output": 204800 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0, - "output": 0 - }, - "type": "chat" - }, { "id": "gpt-5.5", "name": "gpt-5.5", @@ -236698,6 +236844,35 @@ }, "type": "chat" }, + { + "id": "coding-minimax-m3-free", + "name": "coding-minimax-m3-free", + "display_name": "coding-minimax-m3-free", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 204800 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, { "id": "deepseek-v4-flash", "name": "deepseek-v4-flash", @@ -236828,36 +237003,6 @@ }, "type": "chat" }, - { - "id": "command-a-plus-05-2026", - "name": "command-a-plus-05-2026", - "display_name": "command-a-plus-05-2026", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 2.5, - "output": 10 - }, - "type": "chat" - }, { "id": "kimi-k2.6", "name": "kimi-k2.6", @@ -236977,6 +237122,36 @@ }, "type": "chat" }, + { + "id": "command-a-plus-05-2026", + "name": "command-a-plus-05-2026", + "display_name": "command-a-plus-05-2026", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 128000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 2.5, + "output": 10 + }, + "type": "chat" + }, { "id": "claude-opus-4-7", "name": "claude-opus-4-7", @@ -237081,37 +237256,6 @@ }, "type": "chat" }, - { - "id": "gpt-chat-latest", - "name": "gpt-chat-latest", - "display_name": "gpt-chat-latest", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 1050000, - "output": 1050000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 5, - "output": 30, - "cache_read": 0.5 - }, - "type": "chat" - }, { "id": "qwen3.6-27b", "name": "qwen3.6-27b", @@ -237225,6 +237369,99 @@ }, "type": "chat" }, + { + "id": "gpt-chat-latest", + "name": "gpt-chat-latest", + "display_name": "gpt-chat-latest", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 1050000, + "output": 1050000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 5, + "output": 30, + "cache_read": 0.5 + }, + "type": "chat" + }, + { + "id": "grok-4-20-non-reasoning", + "name": "grok-4-20-non-reasoning", + "display_name": "grok-4-20-non-reasoning", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 2000000, + "output": 2000000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 2, + "output": 6, + "cache_read": 0.2 + }, + "type": "chat" + }, + { + "id": "grok-4-20-reasoning", + "name": "grok-4-20-reasoning", + "display_name": "grok-4-20-reasoning", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 2000000, + "output": 2000000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 2, + "output": 6, + "cache_read": 0.2 + }, + "type": "chat" + }, { "id": "mai-image-2e", "name": "mai-image-2e", @@ -237350,49 +237587,19 @@ "type": "rerank" }, { - "id": "grok-4-20-non-reasoning", - "name": "grok-4-20-non-reasoning", - "display_name": "grok-4-20-non-reasoning", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 2000000, - "output": 2000000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 2, - "output": 6, - "cache_read": 0.2 - }, - "type": "chat" - }, - { - "id": "grok-4-20-reasoning", - "name": "grok-4-20-reasoning", - "display_name": "grok-4-20-reasoning", + "id": "qwen3.6-plus", + "name": "qwen3.6-plus", + "display_name": "qwen3.6-plus", "modalities": { "input": [ "text", - "image" + "image", + "video" ] }, "limit": { - "context": 2000000, - "output": 2000000 + "context": 991000, + "output": 991000 }, "tool_call": true, "reasoning": { @@ -237401,13 +237608,19 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, "cost": { - "input": 2, - "output": 6, - "cache_read": 0.2 + "input": 0.282, + "output": 1.692, + "cache_read": 0.0282 }, "type": "chat" }, @@ -237537,44 +237750,6 @@ }, "type": "imageGeneration" }, - { - "id": "qwen3.6-plus", - "name": "qwen3.6-plus", - "display_name": "qwen3.6-plus", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 991000, - "output": 991000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "cost": { - "input": 0.282, - "output": 1.692, - "cache_read": 0.0282 - }, - "type": "chat" - }, { "id": "wan2.7-videoedit", "name": "wan2.7-videoedit", @@ -237720,6 +237895,25 @@ }, "type": "imageGeneration" }, + { + "id": "cc-k2.6-code-preview", + "name": "cc-k2.6-code-preview", + "display_name": "cc-k2.6-code-preview", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.2, + "cache_read": 0.02 + }, + "type": "chat" + }, { "id": "gemma-4-26b-a4b-it", "name": "gemma-4-26b-a4b-it", @@ -237806,177 +238000,6 @@ }, "type": "chat" }, - { - "id": "cc-k2.6-code-preview", - "name": "cc-k2.6-code-preview", - "display_name": "cc-k2.6-code-preview", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.2, - "output": 0.2, - "cache_read": 0.02 - }, - "type": "chat" - }, - { - "id": "claude-sonnet-4-6", - "name": "claude-sonnet-4-6", - "display_name": "claude-sonnet-4-6", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 1000000, - "output": 1000000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": false - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": false, - "mode": "mixed", - "budget": { - "min": 1024, - "unit": "tokens" - }, - "effort": "high", - "effort_options": [ - "low", - "medium", - "high", - "max" - ], - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ], - "notes": [ - "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", - "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." - ] - } - }, - "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3 - }, - "type": "chat" - }, - { - "id": "coding-xiaomi-mimo-v2.5", - "name": "coding-xiaomi-mimo-v2.5", - "display_name": "coding-xiaomi-mimo-v2.5", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.08, - "output": 0.16, - "cache_read": 0.0016 - }, - "type": "chat" - }, - { - "id": "coding-xiaomi-mimo-v2.5-pro", - "name": "coding-xiaomi-mimo-v2.5-pro", - "display_name": "coding-xiaomi-mimo-v2.5-pro", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.2, - "output": 0.4, - "cache_read": 0.0016 - }, - "type": "chat" - }, - { - "id": "qwen3.5-plus", - "name": "qwen3.5-plus", - "display_name": "qwen3.5-plus", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 991000, - "output": 991000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "cost": { - "input": 0.1096, - "output": 0.6576, - "cache_read": 0.01096 - }, - "type": "chat" - }, { "id": "doubao-seed-2-0-lite-260428", "name": "doubao-seed-2-0-lite-260428", @@ -238369,34 +238392,47 @@ "type": "chat" }, { - "id": "gpt-5.3-chat-latest", - "name": "gpt-5.3-chat-latest", - "display_name": "gpt-5.3-chat-latest", + "id": "qwen3.5-plus", + "name": "qwen3.5-plus", + "display_name": "qwen3.5-plus", "modalities": { "input": [ "text", - "image" + "image", + "video" ] }, "limit": { - "context": 128000, - "output": 128000 + "context": 991000, + "output": 991000 }, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 1.75, - "output": 14, - "cache_read": 0.175 + "input": 0.1096, + "output": 0.6576, + "cache_read": 0.01096 }, "type": "chat" }, { - "id": "gpt-5.3-codex", - "name": "gpt-5.3-codex", - "display_name": "gpt-5.3-codex", + "id": "claude-sonnet-4-6", + "name": "claude-sonnet-4-6", + "display_name": "claude-sonnet-4-6", "modalities": { "input": [ "text", @@ -238404,66 +238440,108 @@ ] }, "limit": { - "context": 400000, - "output": 400000 + "context": 1000000, + "output": 1000000 }, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", + "default_enabled": false, + "mode": "mixed", + "budget": { + "min": 1024, + "unit": "tokens" + }, + "effort": "high", "effort_options": [ "low", "medium", "high", - "xhigh" + "max" ], - "verbosity": "medium", - "verbosity_options": [ - "low", - "medium", - "high" + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" ], - "visibility": "hidden" + "notes": [ + "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", + "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." + ] } }, "cost": { - "input": 1.75, - "output": 14, - "cache_read": 0.175 + "input": 3, + "output": 15, + "cache_read": 0.3 }, "type": "chat" }, { - "id": "gpt-image-2-free", - "name": "gpt-image-2-free", - "display_name": "gpt-image-2-free", + "id": "coding-xiaomi-mimo-v2.5", + "name": "coding-xiaomi-mimo-v2.5", + "display_name": "coding-xiaomi-mimo-v2.5", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { "context": 8192, "output": 8192 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "cost": { - "input": 0, - "output": 0, - "cache_read": 0 + "input": 0.08, + "output": 0.16, + "cache_read": 0.0016 }, - "type": "imageGeneration" + "type": "chat" + }, + { + "id": "coding-xiaomi-mimo-v2.5-pro", + "name": "coding-xiaomi-mimo-v2.5-pro", + "display_name": "coding-xiaomi-mimo-v2.5-pro", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.2, + "output": 0.4, + "cache_read": 0.0016 + }, + "type": "chat" }, { "id": "claude-sonnet-4-6-think", @@ -238579,6 +238657,103 @@ }, "type": "chat" }, + { + "id": "gpt-5.3-chat-latest", + "name": "gpt-5.3-chat-latest", + "display_name": "gpt-5.3-chat-latest", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 128000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "cost": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "type": "chat" + }, + { + "id": "gpt-5.3-codex", + "name": "gpt-5.3-codex", + "display_name": "gpt-5.3-codex", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 400000, + "output": 400000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "cost": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "type": "chat" + }, + { + "id": "gpt-image-2-free", + "name": "gpt-image-2-free", + "display_name": "gpt-image-2-free", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "imageGeneration" + }, { "id": "qwen3.5-122b-a10b", "name": "qwen3.5-122b-a10b", @@ -238793,6 +238968,143 @@ }, "type": "chat" }, + { + "id": "coding-glm-5.1", + "name": "coding-glm-5.1", + "display_name": "coding-glm-5.1", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.06, + "output": 0.22 + }, + "type": "chat" + }, + { + "id": "xiaomi-mimo-v2.5-pro-free", + "name": "xiaomi-mimo-v2.5-pro-free", + "display_name": "xiaomi-mimo-v2.5-pro-free", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "xiaomi-mimo-v2.5-free", + "name": "xiaomi-mimo-v2.5-free", + "display_name": "xiaomi-mimo-v2.5-free", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "xiaomi-mimo-v2-pro-free", + "name": "xiaomi-mimo-v2-pro-free", + "display_name": "xiaomi-mimo-v2-pro-free", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "xiaomi-mimo-v2-omni-free", + "name": "xiaomi-mimo-v2-omni-free", + "display_name": "xiaomi-mimo-v2-omni-free", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, { "id": "doubao-seed-2-0-pro", "name": "doubao-seed-2-0-pro", @@ -238968,139 +239280,64 @@ "type": "chat" }, { - "id": "xiaomi-mimo-v2.5-pro-free", - "name": "xiaomi-mimo-v2.5-pro-free", - "display_name": "xiaomi-mimo-v2.5-pro-free", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio" - ] - }, - "limit": { - "context": 256000, - "output": 256000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0, - "output": 0, - "cache_read": 0 - }, - "type": "chat" - }, - { - "id": "xiaomi-mimo-v2.5-free", - "name": "xiaomi-mimo-v2.5-free", - "display_name": "xiaomi-mimo-v2.5-free", + "id": "glm-5", + "name": "glm-5", + "display_name": "glm-5", "modalities": { "input": [ - "text", - "image", - "video", - "audio" + "text" ] }, "limit": { - "context": 256000, - "output": 256000 + "context": 202752, + "output": 202752 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false - }, - "cost": { - "input": 0, - "output": 0, - "cache_read": 0 - }, - "type": "chat" - }, - { - "id": "xiaomi-mimo-v2-pro-free", - "name": "xiaomi-mimo-v2-pro-free", - "display_name": "xiaomi-mimo-v2-pro-free", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio" - ] - }, - "limit": { - "context": 256000, - "output": 256000 + "supported": true, + "default": true }, - "tool_call": false, - "reasoning": { - "supported": false + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 0, - "output": 0, - "cache_read": 0 + "input": 0.88, + "output": 2.816, + "cache_read": 0.176 }, "type": "chat" }, { - "id": "xiaomi-mimo-v2-omni-free", - "name": "xiaomi-mimo-v2-omni-free", - "display_name": "xiaomi-mimo-v2-omni-free", + "id": "glm-5v-turbo", + "name": "glm-5v-turbo", + "display_name": "glm-5v-turbo", "modalities": { "input": [ "text", "image", - "video", - "audio" + "video" ] }, "limit": { - "context": 256000, - "output": 256000 + "context": 200000, + "output": 200000 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 0, - "output": 0, - "cache_read": 0 - }, - "type": "chat" - }, - { - "id": "coding-glm-5.1", - "name": "coding-glm-5.1", - "display_name": "coding-glm-5.1", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.06, - "output": 0.22 + "input": 0.7042, + "output": 3.09848, + "cache_read": 0.169008 }, "type": "chat" }, @@ -239218,17 +239455,17 @@ "type": "chat" }, { - "id": "glm-5", - "name": "glm-5", - "display_name": "glm-5", + "id": "minimax-m2.7", + "name": "minimax-m2.7", + "display_name": "minimax-m2.7", "modalities": { "input": [ "text" ] }, "limit": { - "context": 202752, - "output": 202752 + "context": 200000, + "output": 200000 }, "tool_call": true, "reasoning": { @@ -239247,50 +239484,78 @@ } }, "cost": { - "input": 0.88, - "output": 2.816, - "cache_read": 0.176 + "input": 0.2958, + "output": 1.1832, + "cache_read": 0.05916 }, "type": "chat" }, { - "id": "glm-5v-turbo", - "name": "glm-5v-turbo", - "display_name": "glm-5v-turbo", + "id": "claude-opus-4-6-think", + "name": "claude-opus-4-6-think", + "display_name": "claude-opus-4-6-think", "modalities": { "input": [ - "text", "image", - "video" + "text" ] }, "limit": { "context": 200000, "output": 200000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "mixed", + "budget": { + "min": 1024, + "unit": "tokens" + }, + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", + "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." + ] + } }, "cost": { - "input": 0.7042, - "output": 3.09848, - "cache_read": 0.169008 + "input": 5, + "output": 25, + "cache_read": 0.5 }, "type": "chat" }, { - "id": "minimax-m2.7", - "name": "minimax-m2.7", - "display_name": "minimax-m2.7", + "id": "coding-glm-5-free", + "name": "coding-glm-5-free", + "display_name": "coding-glm-5-free", "modalities": { "input": [ "text" ] }, "limit": { - "context": 200000, - "output": 200000 + "context": 8192, + "output": 8192 }, "tool_call": true, "reasoning": { @@ -239299,19 +239564,72 @@ }, "extra_capabilities": { "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] + "supported": true } }, "cost": { - "input": 0.2958, - "output": 1.1832, - "cache_read": 0.05916 + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "coding-glm-5-turbo-free", + "name": "coding-glm-5-turbo-free", + "display_name": "coding-glm-5-turbo-free", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "coding-minimax-m2.5-free", + "name": "coding-minimax-m2.5-free", + "display_name": "coding-minimax-m2.5-free", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 204800 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0, + "output": 0 }, "type": "chat" }, @@ -239534,106 +239852,66 @@ "type": "chat" }, { - "id": "claude-opus-4-6-think", - "name": "claude-opus-4-6-think", - "display_name": "claude-opus-4-6-think", + "id": "embed-v-4-0", + "name": "embed-v-4-0", + "display_name": "embed-v-4-0", "modalities": { "input": [ - "image", - "text" + "text", + "image" ] }, "limit": { - "context": 200000, - "output": 200000 + "context": 128000, + "output": 128000 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "mixed", - "budget": { - "min": 1024, - "unit": "tokens" - }, - "effort": "high", - "effort_options": [ - "low", - "medium", - "high", - "max" - ], - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ], - "notes": [ - "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", - "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." - ] - } + "supported": false }, "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5 + "input": 0.12, + "output": 0, + "cache_read": 0.12 }, - "type": "chat" + "type": "embedding" }, { - "id": "coding-glm-5-free", - "name": "coding-glm-5-free", - "display_name": "coding-glm-5-free", - "modalities": { - "input": [ - "text" - ] - }, + "id": "ernie-image-turbo", + "name": "ernie-image-turbo", + "display_name": "ernie-image-turbo", "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 0, + "input": 2, "output": 0, "cache_read": 0 }, - "type": "chat" + "type": "imageGeneration" }, { - "id": "coding-glm-5-turbo-free", - "name": "coding-glm-5-turbo-free", - "display_name": "coding-glm-5-turbo-free", + "id": "gemini-3.1-flash-image-preview-free", + "name": "gemini-3.1-flash-image-preview-free", + "display_name": "gemini-3.1-flash-image-preview-free", "modalities": { "input": [ - "text" + "text", + "image" ] }, "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true + "supported": true }, "extra_capabilities": { "reasoning": { @@ -239645,36 +239923,7 @@ "output": 0, "cache_read": 0 }, - "type": "chat" - }, - { - "id": "coding-minimax-m2.5-free", - "name": "coding-minimax-m2.5-free", - "display_name": "coding-minimax-m2.5-free", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 204800, - "output": 204800 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0, - "output": 0 - }, - "type": "chat" + "type": "imageGeneration" }, { "id": "cc-glm-5.1", @@ -239811,80 +240060,6 @@ }, "type": "chat" }, - { - "id": "embed-v-4-0", - "name": "embed-v-4-0", - "display_name": "embed-v-4-0", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.12, - "output": 0, - "cache_read": 0.12 - }, - "type": "embedding" - }, - { - "id": "ernie-image-turbo", - "name": "ernie-image-turbo", - "display_name": "ernie-image-turbo", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2, - "output": 0, - "cache_read": 0 - }, - "type": "imageGeneration" - }, - { - "id": "gemini-3.1-flash-image-preview-free", - "name": "gemini-3.1-flash-image-preview-free", - "display_name": "gemini-3.1-flash-image-preview-free", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0, - "output": 0, - "cache_read": 0 - }, - "type": "imageGeneration" - }, { "id": "mimo-v2-omni", "name": "mimo-v2-omni", @@ -242878,30 +243053,6 @@ }, "type": "chat" }, - { - "id": "step-3.5-flash", - "name": "step-3.5-flash", - "display_name": "step-3.5-flash", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 256000, - "output": 256000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.11, - "output": 0.33 - }, - "type": "chat" - }, { "id": "wan2.5-t2v-preview", "name": "wan2.5-t2v-preview", @@ -243091,6 +243242,30 @@ }, "type": "chat" }, + { + "id": "step-3.5-flash", + "name": "step-3.5-flash", + "display_name": "step-3.5-flash", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.11, + "output": 0.33 + }, + "type": "chat" + }, { "id": "flux-2-flex", "name": "flux-2-flex", @@ -244063,30 +244238,6 @@ }, "type": "imageGeneration" }, - { - "id": "imagen-4.0-fast-generate-preview-06-06", - "name": "imagen-4.0-fast-generate-preview-06-06", - "display_name": "imagen-4.0-fast-generate-preview-06-06", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2, - "output": 2 - }, - "type": "imageGeneration" - }, { "id": "imagen-4.0-generate-001", "name": "imagen-4.0-generate-001", @@ -244523,9 +244674,9 @@ "type": "chat" }, { - "id": "veo-3.1-fast-generate-preview", - "name": "veo-3.1-fast-generate-preview", - "display_name": "veo-3.1-fast-generate-preview", + "id": "veo-3.0-generate-preview", + "name": "veo-3.0-generate-preview", + "display_name": "veo-3.0-generate-preview", "modalities": { "input": [ "text", @@ -244543,14 +244694,15 @@ }, "cost": { "input": 2, - "output": 0 + "output": 2, + "cache_read": 0 }, "type": "chat" }, { - "id": "veo-3.0-generate-preview", - "name": "veo-3.0-generate-preview", - "display_name": "veo-3.0-generate-preview", + "id": "veo-3.1-fast-generate-preview", + "name": "veo-3.1-fast-generate-preview", + "display_name": "veo-3.1-fast-generate-preview", "modalities": { "input": [ "text", @@ -244568,8 +244720,7 @@ }, "cost": { "input": 2, - "output": 2, - "cache_read": 0 + "output": 0 }, "type": "chat" }, @@ -245334,31 +245485,6 @@ }, "type": "chat" }, - { - "id": "imagen-4.0-ultra-generate-exp-05-20", - "name": "imagen-4.0-ultra-generate-exp-05-20", - "display_name": "imagen-4.0-ultra-generate-exp-05-20", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2, - "output": 2, - "cache_read": 0 - }, - "type": "imageGeneration" - }, { "id": "jina-embeddings-v5-text-nano", "name": "jina-embeddings-v5-text-nano", @@ -246195,6 +246321,24 @@ }, "type": "rerank" }, + { + "id": "tao-8k", + "name": "tao-8k", + "display_name": "tao-8k", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.068, + "output": 0.068 + }, + "type": "embedding" + }, { "id": "doubao-seedream-4-0", "name": "doubao-seedream-4-0", @@ -246365,24 +246509,6 @@ }, "type": "chat" }, - { - "id": "tao-8k", - "name": "tao-8k", - "display_name": "tao-8k", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.068, - "output": 0.068 - }, - "type": "embedding" - }, { "id": "jina-clip-v2", "name": "jina-clip-v2", @@ -246454,31 +246580,6 @@ }, "type": "embedding" }, - { - "id": "gpt-4o-search-preview", - "name": "gpt-4o-search-preview", - "display_name": "gpt-4o-search-preview", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2.5, - "output": 10, - "cache_read": 1.25 - }, - "type": "chat" - }, { "id": "DeepSeek-R1", "name": "DeepSeek-R1", @@ -246513,6 +246614,31 @@ }, "type": "chat" }, + { + "id": "gpt-4o-search-preview", + "name": "gpt-4o-search-preview", + "display_name": "gpt-4o-search-preview", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 128000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "cost": { + "input": 2.5, + "output": 10, + "cache_read": 1.25 + }, + "type": "chat" + }, { "id": "gpt-4o-mini-search-preview", "name": "gpt-4o-mini-search-preview", @@ -246937,75 +247063,6 @@ }, "type": "chat" }, - { - "id": "Qwen2-VL-72B-Instruct", - "name": "Qwen2-VL-72B-Instruct", - "display_name": "Qwen2-VL-72B-Instruct", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2.18, - "output": 6.54 - }, - "type": "chat" - }, - { - "id": "Qwen2-VL-7B-Instruct", - "name": "Qwen2-VL-7B-Instruct", - "display_name": "Qwen2-VL-7B-Instruct", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.28, - "output": 0.7 - }, - "type": "chat" - }, - { - "id": "cc-kimi-for-coding", - "name": "cc-kimi-for-coding", - "display_name": "cc-kimi-for-coding", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.2, - "output": 0.2, - "cache_read": 0.02 - }, - "type": "chat" - }, { "id": "gemini-embedding-001", "name": "gemini-embedding-001", @@ -247058,6 +247115,75 @@ }, "type": "chat" }, + { + "id": "Qwen2-VL-72B-Instruct", + "name": "Qwen2-VL-72B-Instruct", + "display_name": "Qwen2-VL-72B-Instruct", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 2.18, + "output": 6.54 + }, + "type": "chat" + }, + { + "id": "Qwen2-VL-7B-Instruct", + "name": "Qwen2-VL-7B-Instruct", + "display_name": "Qwen2-VL-7B-Instruct", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.28, + "output": 0.7 + }, + "type": "chat" + }, + { + "id": "cc-kimi-for-coding", + "name": "cc-kimi-for-coding", + "display_name": "cc-kimi-for-coding", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.2, + "cache_read": 0.02 + }, + "type": "chat" + }, { "id": "Qwen/Qwen3-30B-A3B", "name": "Qwen/Qwen3-30B-A3B", @@ -248064,6 +248190,29 @@ }, "type": "chat" }, + { + "id": "ernie-x1.1-preview", + "name": "ernie-x1.1-preview", + "display_name": "ernie-x1.1-preview", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.136, + "output": 0.544 + }, + "type": "chat" + }, { "id": "minimax-m2", "name": "minimax-m2", @@ -248094,88 +248243,96 @@ "type": "chat" }, { - "id": "ernie-x1.1-preview", - "name": "ernie-x1.1-preview", - "display_name": "ernie-x1.1-preview", + "id": "kat-dev", + "name": "kat-dev", + "display_name": "kat-dev", + "modalities": { + "input": [ + "text" + ] + }, "limit": { - "context": 8192, - "output": 8192 + "context": 128000, + "output": 128000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 0.136, - "output": 0.544 + "input": 0.137, + "output": 0.548 }, "type": "chat" }, { - "id": "ernie-4.5-0.3b", - "name": "ernie-4.5-0.3b", - "display_name": "ernie-4.5-0.3b", - "modalities": { - "input": [ - "text", - "image" - ] + "id": "llama-3.3-70b", + "name": "llama-3.3-70b", + "display_name": "llama-3.3-70b", + "limit": { + "context": 65536, + "output": 65536 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.6, + "output": 0.6 }, + "type": "chat" + }, + { + "id": "moonshotai/Kimi-Dev-72B", + "name": "moonshotai/Kimi-Dev-72B", + "display_name": "moonshotai/Kimi-Dev-72B", "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 0.0136, - "output": 0.0544 + "input": 0.32, + "output": 1.28, + "cache_read": 0 }, "type": "chat" }, { - "id": "ernie-4.5-turbo-128k-preview", - "name": "ernie-4.5-turbo-128k-preview", - "display_name": "ernie-4.5-turbo-128k-preview", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "moonshotai/Moonlight-16B-A3B-Instruct", + "name": "moonshotai/Moonlight-16B-A3B-Instruct", + "display_name": "moonshotai/Moonlight-16B-A3B-Instruct", "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 0.108, - "output": 0.432 + "input": 0.2, + "output": 0.2, + "cache_read": 0 }, "type": "chat" }, { - "id": "ernie-x1-turbo", - "name": "ernie-x1-turbo", - "display_name": "ernie-x1-turbo", + "id": "nvidia-nemotron-3-super-120b-a12b", + "name": "nvidia-nemotron-3-super-120b-a12b", + "display_name": "nvidia-nemotron-3-super-120b-a12b", "modalities": { "input": [ "text" ] }, "limit": { - "context": 50500, - "output": 50500 + "context": 1000000, + "output": 1000000 }, "tool_call": true, "reasoning": { @@ -248188,8 +248345,85 @@ } }, "cost": { - "input": 0.136, - "output": 0.544 + "input": 0.11, + "output": 0.55, + "cache_read": 0.0275 + }, + "type": "chat" + }, + { + "id": "o1-global", + "name": "o1-global", + "display_name": "o1-global", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "cost": { + "input": 15, + "output": 60, + "cache_read": 7.5 + }, + "type": "chat" + }, + { + "id": "qianfan-qi-vl", + "name": "qianfan-qi-vl", + "display_name": "qianfan-qi-vl", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.6 + }, + "type": "chat" + }, + { + "id": "qwen2.5-vl-72b-instruct", + "name": "qwen2.5-vl-72b-instruct", + "display_name": "qwen2.5-vl-72b-instruct", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 2.4, + "output": 7.2 }, "type": "chat" }, @@ -248365,96 +248599,65 @@ "type": "chat" }, { - "id": "kat-dev", - "name": "kat-dev", - "display_name": "kat-dev", + "id": "ernie-4.5-0.3b", + "name": "ernie-4.5-0.3b", + "display_name": "ernie-4.5-0.3b", "modalities": { "input": [ - "text" + "text", + "image" ] }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.137, - "output": 0.548 - }, - "type": "chat" - }, - { - "id": "llama-3.3-70b", - "name": "llama-3.3-70b", - "display_name": "llama-3.3-70b", - "limit": { - "context": 65536, - "output": 65536 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.6, - "output": 0.6 - }, - "type": "chat" - }, - { - "id": "moonshotai/Kimi-Dev-72B", - "name": "moonshotai/Kimi-Dev-72B", - "display_name": "moonshotai/Kimi-Dev-72B", "limit": { "context": 8192, "output": 8192 }, - "tool_call": false, + "tool_call": true, "reasoning": { "supported": false }, "cost": { - "input": 0.32, - "output": 1.28, - "cache_read": 0 + "input": 0.0136, + "output": 0.0544 }, "type": "chat" }, { - "id": "moonshotai/Moonlight-16B-A3B-Instruct", - "name": "moonshotai/Moonlight-16B-A3B-Instruct", - "display_name": "moonshotai/Moonlight-16B-A3B-Instruct", + "id": "ernie-4.5-turbo-128k-preview", + "name": "ernie-4.5-turbo-128k-preview", + "display_name": "ernie-4.5-turbo-128k-preview", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 }, - "tool_call": false, + "tool_call": true, "reasoning": { "supported": false }, "cost": { - "input": 0.2, - "output": 0.2, - "cache_read": 0 + "input": 0.108, + "output": 0.432 }, "type": "chat" }, { - "id": "nvidia-nemotron-3-super-120b-a12b", - "name": "nvidia-nemotron-3-super-120b-a12b", - "display_name": "nvidia-nemotron-3-super-120b-a12b", + "id": "ernie-x1-turbo", + "name": "ernie-x1-turbo", + "display_name": "ernie-x1-turbo", "modalities": { "input": [ "text" ] }, "limit": { - "context": 1000000, - "output": 1000000 + "context": 50500, + "output": 50500 }, "tool_call": true, "reasoning": { @@ -248467,50 +248670,15 @@ } }, "cost": { - "input": 0.11, - "output": 0.55, - "cache_read": 0.0275 - }, - "type": "chat" - }, - { - "id": "o1-global", - "name": "o1-global", - "display_name": "o1-global", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "cost": { - "input": 15, - "output": 60, - "cache_read": 7.5 + "input": 0.136, + "output": 0.544 }, "type": "chat" }, { - "id": "qianfan-qi-vl", - "name": "qianfan-qi-vl", - "display_name": "qianfan-qi-vl", + "id": "gemini-exp-1206", + "name": "gemini-exp-1206", + "display_name": "gemini-exp-1206", "limit": { "context": 8192, "output": 8192 @@ -248520,15 +248688,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.6 + "input": 1.25, + "output": 5 }, "type": "chat" }, { - "id": "qwen2.5-vl-72b-instruct", - "name": "qwen2.5-vl-72b-instruct", - "display_name": "qwen2.5-vl-72b-instruct", + "id": "gpt-4o-zh", + "name": "gpt-4o-zh", + "display_name": "gpt-4o-zh", "modalities": { "input": [ "text", @@ -248544,8 +248712,8 @@ "supported": false }, "cost": { - "input": 2.4, - "output": 7.2 + "input": 2.5, + "output": 10 }, "type": "chat" }, @@ -248567,48 +248735,6 @@ }, "type": "chat" }, - { - "id": "gemini-exp-1206", - "name": "gemini-exp-1206", - "display_name": "gemini-exp-1206", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 1.25, - "output": 5 - }, - "type": "chat" - }, - { - "id": "gpt-4o-zh", - "name": "gpt-4o-zh", - "display_name": "gpt-4o-zh", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2.5, - "output": 10 - }, - "type": "chat" - }, { "id": "unsloth/gemma-3-12b-it", "name": "unsloth/gemma-3-12b-it", @@ -248646,24 +248772,6 @@ }, "type": "chat" }, - { - "id": "tencent/Hunyuan-MT-7B", - "name": "tencent/Hunyuan-MT-7B", - "display_name": "tencent/Hunyuan-MT-7B", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.2, - "output": 0.2 - }, - "type": "chat" - }, { "id": "BAAI/bge-large-en-v1.5", "name": "BAAI/bge-large-en-v1.5", @@ -248736,6 +248844,24 @@ }, "type": "rerank" }, + { + "id": "tencent/Hunyuan-MT-7B", + "name": "tencent/Hunyuan-MT-7B", + "display_name": "tencent/Hunyuan-MT-7B", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.2 + }, + "type": "chat" + }, { "id": "V3", "name": "V3", @@ -248971,47 +249097,6 @@ }, "type": "chat" }, - { - "id": "stepfun-ai/step3", - "name": "stepfun-ai/step3", - "display_name": "stepfun-ai/step3", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 1.1, - "output": 2.75 - }, - "type": "chat" - }, - { - "id": "text-embedding-v4", - "name": "text-embedding-v4", - "display_name": "text-embedding-v4", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.08, - "output": 0.08 - }, - "type": "embedding" - }, { "id": "qwen-plus-2025-07-28", "name": "qwen-plus-2025-07-28", @@ -249091,35 +249176,46 @@ "type": "chat" }, { - "id": "qwen-turbo-latest", - "name": "qwen-turbo-latest", - "display_name": "qwen-turbo-latest", + "id": "stepfun-ai/step3", + "name": "stepfun-ai/step3", + "display_name": "stepfun-ai/step3", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.046, - "output": 0.092, - "cache_read": 0.0092 + "input": 1.1, + "output": 2.75 }, "type": "chat" }, + { + "id": "text-embedding-v4", + "name": "text-embedding-v4", + "display_name": "text-embedding-v4", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.08, + "output": 0.08 + }, + "type": "embedding" + }, { "id": "AiHubmix-Phi-4-mini-reasoning", "name": "AiHubmix-Phi-4-mini-reasoning", @@ -249143,6 +249239,36 @@ }, "type": "chat" }, + { + "id": "qwen-turbo-latest", + "name": "qwen-turbo-latest", + "display_name": "qwen-turbo-latest", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "cost": { + "input": 0.046, + "output": 0.092, + "cache_read": 0.0092 + }, + "type": "chat" + }, { "id": "aihub-Phi-4-multimodal-instruct", "name": "aihub-Phi-4-multimodal-instruct", @@ -249198,29 +249324,6 @@ }, "type": "chat" }, - { - "id": "aihub-Phi-4-mini-instruct", - "name": "aihub-Phi-4-mini-instruct", - "display_name": "aihub-Phi-4-mini-instruct", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.12, - "output": 0.48 - }, - "type": "chat" - }, { "id": "grok-3", "name": "grok-3", @@ -249240,44 +249343,25 @@ "type": "chat" }, { - "id": "doubao-embedding-text-240715", - "name": "doubao-embedding-text-240715", - "display_name": "doubao-embedding-text-240715", + "id": "aihub-Phi-4-mini-instruct", + "name": "aihub-Phi-4-mini-instruct", + "display_name": "aihub-Phi-4-mini-instruct", "modalities": { "input": [ "text" ] }, "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.7, - "output": 0.7 - }, - "type": "embedding" - }, - { - "id": "grok-3-beta", - "name": "grok-3-beta", - "display_name": "grok-3-beta", - "limit": { - "context": 8192, - "output": 8192 + "context": 128000, + "output": 128000 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 3, - "output": 15, - "cache_read": 0 + "input": 0.12, + "output": 0.48 }, "type": "chat" }, @@ -249377,39 +249461,32 @@ "type": "chat" }, { - "id": "qwen3-8b", - "name": "qwen3-8b", - "display_name": "qwen3-8b", + "id": "doubao-embedding-text-240715", + "name": "doubao-embedding-text-240715", + "display_name": "doubao-embedding-text-240715", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.08, - "output": 0.8, - "cache_read": 0 + "input": 0.7, + "output": 0.7 }, - "type": "chat" + "type": "embedding" }, { - "id": "grok-3-fast", - "name": "grok-3-fast", - "display_name": "grok-3-fast", + "id": "grok-3-beta", + "name": "grok-3-beta", + "display_name": "grok-3-beta", "limit": { "context": 8192, "output": 8192 @@ -249419,16 +249496,16 @@ "supported": false }, "cost": { - "input": 5.5, - "output": 27.5, + "input": 3, + "output": 15, "cache_read": 0 }, "type": "chat" }, { - "id": "grok-3-fast-beta", - "name": "grok-3-fast-beta", - "display_name": "grok-3-fast-beta", + "id": "grok-3-fast", + "name": "grok-3-fast", + "display_name": "grok-3-fast", "limit": { "context": 8192, "output": 8192 @@ -249445,20 +249522,31 @@ "type": "chat" }, { - "id": "grok-3-mini", - "name": "grok-3-mini", - "display_name": "grok-3-mini", + "id": "qwen3-8b", + "name": "qwen3-8b", + "display_name": "qwen3-8b", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 0.3, - "output": 0.501, + "input": 0.08, + "output": 0.8, "cache_read": 0 }, "type": "chat" @@ -249512,31 +249600,39 @@ "type": "chat" }, { - "id": "qwen3-1.7b", - "name": "qwen3-1.7b", - "display_name": "qwen3-1.7b", + "id": "grok-3-fast-beta", + "name": "grok-3-fast-beta", + "display_name": "grok-3-fast-beta", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true + "supported": false }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "cost": { + "input": 5.5, + "output": 27.5, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "grok-3-mini", + "name": "grok-3-mini", + "display_name": "grok-3-mini", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false }, "cost": { - "input": 0.046, - "output": 0.46, + "input": 0.3, + "output": 0.501, "cache_read": 0 }, "type": "chat" @@ -249560,6 +249656,36 @@ }, "type": "chat" }, + { + "id": "qwen3-1.7b", + "name": "qwen3-1.7b", + "display_name": "qwen3-1.7b", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "cost": { + "input": 0.046, + "output": 0.46, + "cache_read": 0 + }, + "type": "chat" + }, { "id": "qwen3-0.6b", "name": "qwen3-0.6b", @@ -249911,6 +250037,24 @@ }, "type": "chat" }, + { + "id": "qwen-3-235b-a22b-instruct-2507", + "name": "qwen-3-235b-a22b-instruct-2507", + "display_name": "qwen-3-235b-a22b-instruct-2507", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.28, + "output": 1.4 + }, + "type": "chat" + }, { "id": "deepseek-ai/Janus-Pro-7B", "name": "deepseek-ai/Janus-Pro-7B", @@ -249930,9 +250074,14 @@ "type": "chat" }, { - "id": "qwen-3-235b-a22b-instruct-2507", - "name": "qwen-3-235b-a22b-instruct-2507", - "display_name": "qwen-3-235b-a22b-instruct-2507", + "id": "coding-glm-4.5-air", + "name": "coding-glm-4.5-air", + "display_name": "coding-glm-4.5-air", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -249942,8 +250091,67 @@ "supported": false }, "cost": { - "input": 0.28, - "output": 1.4 + "input": 0.014, + "output": 0.084 + }, + "type": "chat" + }, + { + "id": "deepinfra-nvidia-nemotron-3-nano-30b-a3b2", + "name": "deepinfra-nvidia-nemotron-3-nano-30b-a3b2", + "display_name": "deepinfra-nvidia-nemotron-3-nano-30b-a3b2", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.066, + "output": 0.264 + }, + "type": "chat" + }, + { + "id": "glm-4.5-air", + "name": "glm-4.5-air", + "display_name": "glm-4.5-air", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 131072 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.14, + "output": 0.84 + }, + "type": "chat" + }, + { + "id": "gpt-4-32k", + "name": "gpt-4-32k", + "display_name": "gpt-4-32k", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 60, + "output": 120 }, "type": "chat" }, @@ -250071,88 +250279,6 @@ }, "type": "chat" }, - { - "id": "coding-glm-4.5-air", - "name": "coding-glm-4.5-air", - "display_name": "coding-glm-4.5-air", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.014, - "output": 0.084 - }, - "type": "chat" - }, - { - "id": "deepinfra-nvidia-nemotron-3-nano-30b-a3b2", - "name": "deepinfra-nvidia-nemotron-3-nano-30b-a3b2", - "display_name": "deepinfra-nvidia-nemotron-3-nano-30b-a3b2", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.066, - "output": 0.264 - }, - "type": "chat" - }, - { - "id": "glm-4.5-air", - "name": "glm-4.5-air", - "display_name": "glm-4.5-air", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 131072 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.14, - "output": 0.84 - }, - "type": "chat" - }, - { - "id": "gpt-4-32k", - "name": "gpt-4-32k", - "display_name": "gpt-4-32k", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 60, - "output": 120 - }, - "type": "chat" - }, { "id": "Qwen/QVQ-72B-Preview", "name": "Qwen/QVQ-72B-Preview", @@ -250290,6 +250416,30 @@ }, "type": "chat" }, + { + "id": "wan2.6-t2i", + "name": "wan2.6-t2i", + "display_name": "wan2.6-t2i", + "modalities": { + "input": [ + "image", + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 2, + "output": 0 + }, + "type": "imageGeneration" + }, { "id": "grok-2-1212", "name": "grok-2-1212", @@ -250309,15 +250459,9 @@ "type": "chat" }, { - "id": "wan2.6-t2i", - "name": "wan2.6-t2i", - "display_name": "wan2.6-t2i", - "modalities": { - "input": [ - "image", - "text" - ] - }, + "id": "gpt-image-test", + "name": "gpt-image-test", + "display_name": "gpt-image-test", "limit": { "context": 8192, "output": 8192 @@ -250326,16 +250470,48 @@ "reasoning": { "supported": false }, + "cost": { + "input": 5, + "output": 40, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "grok-4.20-beta-0309-non-reasoning", + "name": "grok-4.20-beta-0309-non-reasoning", + "display_name": "grok-4.20-beta-0309-non-reasoning", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 2000000, + "output": 2000000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, "cost": { "input": 2, - "output": 0 + "output": 6, + "cache_read": 0.2 }, - "type": "imageGeneration" + "type": "chat" }, { - "id": "grok-4.20-multi-agent-beta-0309", - "name": "grok-4.20-multi-agent-beta-0309", - "display_name": "grok-4.20-multi-agent-beta-0309", + "id": "grok-4.20-beta-0309-reasoning", + "name": "grok-4.20-beta-0309-reasoning", + "display_name": "grok-4.20-beta-0309-reasoning", "modalities": { "input": [ "text", @@ -250364,9 +250540,9 @@ "type": "chat" }, { - "id": "imagen-3.0-generate-002", - "name": "imagen-3.0-generate-002", - "display_name": "imagen-3.0-generate-002", + "id": "grok-4.20-multi-agent-beta-0309", + "name": "grok-4.20-multi-agent-beta-0309", + "display_name": "grok-4.20-multi-agent-beta-0309", "modalities": { "input": [ "text", @@ -250374,19 +250550,25 @@ ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 2000000, + "output": 2000000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "cost": { "input": 2, - "output": 2, - "cache_read": 0 + "output": 6, + "cache_read": 0.2 }, - "type": "imageGeneration" + "type": "chat" }, { "id": "jina-reader", @@ -250759,9 +250941,9 @@ "type": "chat" }, { - "id": "gpt-image-test", - "name": "gpt-image-test", - "display_name": "gpt-image-test", + "id": "Baichuan3-Turbo", + "name": "Baichuan3-Turbo", + "display_name": "Baichuan3-Turbo", "limit": { "context": 8192, "output": 8192 @@ -250771,78 +250953,51 @@ "supported": false }, "cost": { - "input": 5, - "output": 40, - "cache_read": 0 + "input": 1.9, + "output": 1.9 }, "type": "chat" }, { - "id": "grok-4.20-beta-0309-non-reasoning", - "name": "grok-4.20-beta-0309-non-reasoning", - "display_name": "grok-4.20-beta-0309-non-reasoning", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "Baichuan3-Turbo-128k", + "name": "Baichuan3-Turbo-128k", + "display_name": "Baichuan3-Turbo-128k", "limit": { - "context": 2000000, - "output": 2000000 + "context": 8192, + "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 2, - "output": 6, - "cache_read": 0.2 + "input": 3.8, + "output": 3.8 }, "type": "chat" }, { - "id": "grok-4.20-beta-0309-reasoning", - "name": "grok-4.20-beta-0309-reasoning", - "display_name": "grok-4.20-beta-0309-reasoning", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "Baichuan4", + "name": "Baichuan4", + "display_name": "Baichuan4", "limit": { - "context": 2000000, - "output": 2000000 + "context": 8192, + "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 2, - "output": 6, - "cache_read": 0.2 + "input": 16, + "output": 16 }, "type": "chat" }, { - "id": "deepseek-ai/deepseek-llm-67b-chat", - "name": "deepseek-ai/deepseek-llm-67b-chat", - "display_name": "deepseek-ai/deepseek-llm-67b-chat", + "id": "Baichuan4-Air", + "name": "Baichuan4-Air", + "display_name": "Baichuan4-Air", "limit": { "context": 8192, "output": 8192 @@ -250858,9 +251013,9 @@ "type": "chat" }, { - "id": "deepseek-ai/deepseek-vl2", - "name": "deepseek-ai/deepseek-vl2", - "display_name": "deepseek-ai/deepseek-vl2", + "id": "Baichuan4-Turbo", + "name": "Baichuan4-Turbo", + "display_name": "Baichuan4-Turbo", "limit": { "context": 8192, "output": 8192 @@ -250870,15 +251025,15 @@ "supported": false }, "cost": { - "input": 0.16, - "output": 0.16 + "input": 2.4, + "output": 2.4 }, "type": "chat" }, { - "id": "deepseek-v3", - "name": "deepseek-v3", - "display_name": "deepseek-v3", + "id": "DeepSeek-v3", + "name": "DeepSeek-v3", + "display_name": "DeepSeek-v3", "limit": { "context": 8192, "output": 8192 @@ -250889,20 +251044,14 @@ }, "cost": { "input": 0.272, - "output": 1.088, - "cache_read": 0 + "output": 1.088 }, "type": "chat" }, { - "id": "distil-whisper-large-v3-en", - "name": "distil-whisper-large-v3-en", - "display_name": "distil-whisper-large-v3-en", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "Doubao-1.5-lite-32k", + "name": "Doubao-1.5-lite-32k", + "display_name": "Doubao-1.5-lite-32k", "limit": { "context": 8192, "output": 8192 @@ -250912,15 +251061,16 @@ "supported": false }, "cost": { - "input": 5.556, - "output": 5.556 + "input": 0.05, + "output": 0.1, + "cache_read": 0.01 }, "type": "chat" }, { - "id": "doubao-1-5-thinking-vision-pro-250428", - "name": "doubao-1-5-thinking-vision-pro-250428", - "display_name": "doubao-1-5-thinking-vision-pro-250428", + "id": "Doubao-1.5-pro-256k", + "name": "Doubao-1.5-pro-256k", + "display_name": "Doubao-1.5-pro-256k", "limit": { "context": 8192, "output": 8192 @@ -250930,16 +251080,16 @@ "supported": false }, "cost": { - "input": 2, - "output": 2, - "cache_read": 2 + "input": 0.8, + "output": 1.44, + "cache_read": 0.8 }, "type": "chat" }, { - "id": "fx-flux-2-pro", - "name": "fx-flux-2-pro", - "display_name": "fx-flux-2-pro", + "id": "Doubao-1.5-pro-32k", + "name": "Doubao-1.5-pro-32k", + "display_name": "Doubao-1.5-pro-32k", "limit": { "context": 8192, "output": 8192 @@ -250949,68 +251099,34 @@ "supported": false }, "cost": { - "input": 2, - "output": 0, - "cache_read": 0 + "input": 0.134, + "output": 0.335, + "cache_read": 0.0268 }, "type": "chat" }, { - "id": "gemini-2.5-pro-exp-03-25", - "name": "gemini-2.5-pro-exp-03-25", - "display_name": "gemini-2.5-pro-exp-03-25", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ] - }, + "id": "Doubao-1.5-vision-pro-32k", + "name": "Doubao-1.5-vision-pro-32k", + "display_name": "Doubao-1.5-vision-pro-32k", "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "budget", - "budget": { - "default": -1, - "min": 128, - "max": 32768, - "auto": -1, - "unit": "tokens" - }, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thought_signatures" - ] - } + "supported": false }, "cost": { - "input": 1.25, - "output": 5, - "cache_read": 0.125 + "input": 0.46, + "output": 1.38 }, "type": "chat" }, { - "id": "gemini-embedding-exp-03-07", - "name": "gemini-embedding-exp-03-07", - "display_name": "gemini-embedding-exp-03-07", - "modalities": { - "input": [ - "text" - ] - }, + "id": "Doubao-lite-128k", + "name": "Doubao-lite-128k", + "display_name": "Doubao-lite-128k", "limit": { "context": 8192, "output": 8192 @@ -251020,15 +251136,16 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 0.14, + "output": 0.28, + "cache_read": 0.14 }, - "type": "embedding" + "type": "chat" }, { - "id": "gemini-exp-1114", - "name": "gemini-exp-1114", - "display_name": "gemini-exp-1114", + "id": "Doubao-lite-32k", + "name": "Doubao-lite-32k", + "display_name": "Doubao-lite-32k", "limit": { "context": 8192, "output": 8192 @@ -251038,15 +251155,16 @@ "supported": false }, "cost": { - "input": 1.25, - "output": 5 + "input": 0.06, + "output": 0.12, + "cache_read": 0.012 }, "type": "chat" }, { - "id": "gemini-exp-1121", - "name": "gemini-exp-1121", - "display_name": "gemini-exp-1121", + "id": "Doubao-lite-4k", + "name": "Doubao-lite-4k", + "display_name": "Doubao-lite-4k", "limit": { "context": 8192, "output": 8192 @@ -251056,15 +251174,16 @@ "supported": false }, "cost": { - "input": 1.25, - "output": 5 + "input": 0.06, + "output": 0.12, + "cache_read": 0.06 }, "type": "chat" }, { - "id": "gemini-pro", - "name": "gemini-pro", - "display_name": "gemini-pro", + "id": "Doubao-pro-128k", + "name": "Doubao-pro-128k", + "display_name": "Doubao-pro-128k", "limit": { "context": 8192, "output": 8192 @@ -251074,15 +251193,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.6 + "input": 0.8, + "output": 1.44 }, "type": "chat" }, { - "id": "gemini-pro-vision", - "name": "gemini-pro-vision", - "display_name": "gemini-pro-vision", + "id": "Doubao-pro-256k", + "name": "Doubao-pro-256k", + "display_name": "Doubao-pro-256k", "limit": { "context": 8192, "output": 8192 @@ -251092,15 +251211,16 @@ "supported": false }, "cost": { - "input": 1, - "output": 1 + "input": 0.8, + "output": 1.44, + "cache_read": 0.8 }, "type": "chat" }, { - "id": "gemma-7b-it", - "name": "gemma-7b-it", - "display_name": "gemma-7b-it", + "id": "Doubao-pro-32k", + "name": "Doubao-pro-32k", + "display_name": "Doubao-pro-32k", "limit": { "context": 8192, "output": 8192 @@ -251110,15 +251230,16 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 0.14, + "output": 0.35, + "cache_read": 0.028 }, "type": "chat" }, { - "id": "glm-3-turbo", - "name": "glm-3-turbo", - "display_name": "glm-3-turbo", + "id": "Doubao-pro-4k", + "name": "Doubao-pro-4k", + "display_name": "Doubao-pro-4k", "limit": { "context": 8192, "output": 8192 @@ -251128,33 +251249,38 @@ "supported": false }, "cost": { - "input": 0.71, - "output": 0.71 + "input": 0.14, + "output": 0.35 }, "type": "chat" }, { - "id": "glm-4", - "name": "glm-4", - "display_name": "glm-4", + "id": "GPT-OSS-20B", + "name": "GPT-OSS-20B", + "display_name": "GPT-OSS-20B", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "cost": { - "input": 14.2, - "output": 14.2 + "input": 0.11, + "output": 0.55 }, "type": "chat" }, { - "id": "glm-4-flash", - "name": "glm-4-flash", - "display_name": "glm-4-flash", + "id": "Gryphe/MythoMax-L2-13b", + "name": "Gryphe/MythoMax-L2-13b", + "display_name": "Gryphe/MythoMax-L2-13b", "limit": { "context": 8192, "output": 8192 @@ -251164,15 +251290,20 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 0.4, + "output": 0.4 }, "type": "chat" }, { - "id": "glm-4-plus", - "name": "glm-4-plus", - "display_name": "glm-4-plus", + "id": "MiniMax-Text-01", + "name": "MiniMax-Text-01", + "display_name": "MiniMax-Text-01", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -251182,20 +251313,15 @@ "supported": false }, "cost": { - "input": 8, - "output": 8 + "input": 0.14, + "output": 1.12 }, "type": "chat" }, { - "id": "glm-4.5-airx", - "name": "glm-4.5-airx", - "display_name": "glm-4.5-airx", - "modalities": { - "input": [ - "text" - ] - }, + "id": "Mistral-large-2407", + "name": "Mistral-large-2407", + "display_name": "Mistral-large-2407", "limit": { "context": 8192, "output": 8192 @@ -251205,16 +251331,15 @@ "supported": false }, "cost": { - "input": 1.1, - "output": 4.51, - "cache_read": 0.22 + "input": 3, + "output": 9 }, "type": "chat" }, { - "id": "glm-4v", - "name": "glm-4v", - "display_name": "glm-4v", + "id": "Qwen/Qwen2-1.5B-Instruct", + "name": "Qwen/Qwen2-1.5B-Instruct", + "display_name": "Qwen/Qwen2-1.5B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251224,15 +251349,15 @@ "supported": false }, "cost": { - "input": 14.2, - "output": 14.2 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "glm-4v-plus", - "name": "glm-4v-plus", - "display_name": "glm-4v-plus", + "id": "Qwen/Qwen2-57B-A14B-Instruct", + "name": "Qwen/Qwen2-57B-A14B-Instruct", + "display_name": "Qwen/Qwen2-57B-A14B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251242,15 +251367,15 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 0.24, + "output": 0.24 }, "type": "chat" }, { - "id": "google-gemma-3-12b-it", - "name": "google-gemma-3-12b-it", - "display_name": "google-gemma-3-12b-it", + "id": "Qwen/Qwen2-72B-Instruct", + "name": "Qwen/Qwen2-72B-Instruct", + "display_name": "Qwen/Qwen2-72B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251260,15 +251385,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 0.8, + "output": 0.8 }, "type": "chat" }, { - "id": "google-gemma-3-27b-it", - "name": "google-gemma-3-27b-it", - "display_name": "google-gemma-3-27b-it", + "id": "Qwen/Qwen2-7B-Instruct", + "name": "Qwen/Qwen2-7B-Instruct", + "display_name": "Qwen/Qwen2-7B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251278,16 +251403,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2, - "cache_read": 0 + "input": 0.08, + "output": 0.08 }, "type": "chat" }, { - "id": "google-gemma-3-4b-it", - "name": "google-gemma-3-4b-it", - "display_name": "google-gemma-3-4b-it", + "id": "Qwen/Qwen2.5-32B-Instruct", + "name": "Qwen/Qwen2.5-32B-Instruct", + "display_name": "Qwen/Qwen2.5-32B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251297,16 +251421,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2, - "cache_read": 0 + "input": 0.6, + "output": 0.6 }, "type": "chat" }, { - "id": "google/gemini-exp-1114", - "name": "google/gemini-exp-1114", - "display_name": "google/gemini-exp-1114", + "id": "Qwen/Qwen2.5-72B-Instruct", + "name": "Qwen/Qwen2.5-72B-Instruct", + "display_name": "Qwen/Qwen2.5-72B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251316,15 +251439,15 @@ "supported": false }, "cost": { - "input": 1.25, - "output": 5 + "input": 0.8, + "output": 0.8 }, "type": "chat" }, { - "id": "google/gemma-2-27b-it", - "name": "google/gemma-2-27b-it", - "display_name": "google/gemma-2-27b-it", + "id": "Qwen/Qwen2.5-72B-Instruct-128K", + "name": "Qwen/Qwen2.5-72B-Instruct-128K", + "display_name": "Qwen/Qwen2.5-72B-Instruct-128K", "limit": { "context": 8192, "output": 8192 @@ -251340,9 +251463,9 @@ "type": "chat" }, { - "id": "google/gemma-2-9b-it:free", - "name": "google/gemma-2-9b-it:free", - "display_name": "google/gemma-2-9b-it:free", + "id": "Qwen/Qwen2.5-7B-Instruct", + "name": "Qwen/Qwen2.5-7B-Instruct", + "display_name": "Qwen/Qwen2.5-7B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251352,15 +251475,15 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 0.4, + "output": 0.4 }, "type": "chat" }, { - "id": "gpt-3.5-turbo", - "name": "gpt-3.5-turbo", - "display_name": "gpt-3.5-turbo", + "id": "Qwen/Qwen2.5-Coder-32B-Instruct", + "name": "Qwen/Qwen2.5-Coder-32B-Instruct", + "display_name": "Qwen/Qwen2.5-Coder-32B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251370,33 +251493,50 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 1.5 + "input": 0.16, + "output": 0.16 }, "type": "chat" }, { - "id": "gpt-3.5-turbo-0301", - "name": "gpt-3.5-turbo-0301", - "display_name": "gpt-3.5-turbo-0301", + "id": "Qwen3-235B-A22B-Thinking-2507", + "name": "Qwen3-235B-A22B-Thinking-2507", + "display_name": "Qwen3-235B-A22B-Thinking-2507", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 1.5, - "output": 1.5 + "input": 0.28, + "output": 2.8 }, "type": "chat" }, { - "id": "gpt-3.5-turbo-0613", - "name": "gpt-3.5-turbo-0613", - "display_name": "gpt-3.5-turbo-0613", + "id": "Stable-Diffusion-3-5-Large", + "name": "Stable-Diffusion-3-5-Large", + "display_name": "Stable-Diffusion-3-5-Large", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -251406,15 +251546,16 @@ "supported": false }, "cost": { - "input": 1.5, - "output": 2 + "input": 4, + "output": 4, + "cache_read": 0 }, - "type": "chat" + "type": "imageGeneration" }, { - "id": "gpt-3.5-turbo-1106", - "name": "gpt-3.5-turbo-1106", - "display_name": "gpt-3.5-turbo-1106", + "id": "WizardLM/WizardCoder-Python-34B-V1.0", + "name": "WizardLM/WizardCoder-Python-34B-V1.0", + "display_name": "WizardLM/WizardCoder-Python-34B-V1.0", "limit": { "context": 8192, "output": 8192 @@ -251424,15 +251565,15 @@ "supported": false }, "cost": { - "input": 1, - "output": 2 + "input": 0.9, + "output": 0.9 }, "type": "chat" }, { - "id": "gpt-3.5-turbo-16k", - "name": "gpt-3.5-turbo-16k", - "display_name": "gpt-3.5-turbo-16k", + "id": "ahm-Phi-3-5-MoE-instruct", + "name": "ahm-Phi-3-5-MoE-instruct", + "display_name": "ahm-Phi-3-5-MoE-instruct", "limit": { "context": 8192, "output": 8192 @@ -251442,15 +251583,15 @@ "supported": false }, "cost": { - "input": 3, - "output": 4 + "input": 0.4, + "output": 1.6 }, "type": "chat" }, { - "id": "gpt-3.5-turbo-16k-0613", - "name": "gpt-3.5-turbo-16k-0613", - "display_name": "gpt-3.5-turbo-16k-0613", + "id": "ahm-Phi-3-5-mini-instruct", + "name": "ahm-Phi-3-5-mini-instruct", + "display_name": "ahm-Phi-3-5-mini-instruct", "limit": { "context": 8192, "output": 8192 @@ -251460,15 +251601,21 @@ "supported": false }, "cost": { - "input": 3, - "output": 4 + "input": 1, + "output": 3 }, "type": "chat" }, { - "id": "gpt-3.5-turbo-instruct", - "name": "gpt-3.5-turbo-instruct", - "display_name": "gpt-3.5-turbo-instruct", + "id": "ahm-Phi-3-5-vision-instruct", + "name": "ahm-Phi-3-5-vision-instruct", + "display_name": "ahm-Phi-3-5-vision-instruct", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -251478,15 +251625,15 @@ "supported": false }, "cost": { - "input": 1.5, - "output": 2 + "input": 0.4, + "output": 1.6 }, "type": "chat" }, { - "id": "gpt-4", - "name": "gpt-4", - "display_name": "gpt-4", + "id": "ahm-Phi-3-medium-128k", + "name": "ahm-Phi-3-medium-128k", + "display_name": "ahm-Phi-3-medium-128k", "limit": { "context": 8192, "output": 8192 @@ -251496,15 +251643,15 @@ "supported": false }, "cost": { - "input": 30, - "output": 60 + "input": 6, + "output": 18 }, "type": "chat" }, { - "id": "gpt-4-0125-preview", - "name": "gpt-4-0125-preview", - "display_name": "gpt-4-0125-preview", + "id": "ahm-Phi-3-medium-4k", + "name": "ahm-Phi-3-medium-4k", + "display_name": "ahm-Phi-3-medium-4k", "limit": { "context": 8192, "output": 8192 @@ -251514,15 +251661,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 30 + "input": 1, + "output": 3 }, "type": "chat" }, { - "id": "gpt-4-0314", - "name": "gpt-4-0314", - "display_name": "gpt-4-0314", + "id": "ahm-Phi-3-small-128k", + "name": "ahm-Phi-3-small-128k", + "display_name": "ahm-Phi-3-small-128k", "limit": { "context": 8192, "output": 8192 @@ -251532,15 +251679,15 @@ "supported": false }, "cost": { - "input": 30, - "output": 60 + "input": 1, + "output": 3 }, "type": "chat" }, { - "id": "gpt-4-0613", - "name": "gpt-4-0613", - "display_name": "gpt-4-0613", + "id": "aihubmix-Codestral-2501", + "name": "aihubmix-Codestral-2501", + "display_name": "aihubmix-Codestral-2501", "limit": { "context": 8192, "output": 8192 @@ -251550,15 +251697,20 @@ "supported": false }, "cost": { - "input": 30, - "output": 60 + "input": 0.4, + "output": 1.2 }, "type": "chat" }, { - "id": "gpt-4-1106-preview", - "name": "gpt-4-1106-preview", - "display_name": "gpt-4-1106-preview", + "id": "aihubmix-Cohere-command-r", + "name": "aihubmix-Cohere-command-r", + "display_name": "aihubmix-Cohere-command-r", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -251568,15 +251720,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 30 + "input": 0.64, + "output": 1.92 }, "type": "chat" }, { - "id": "gpt-4-32k-0314", - "name": "gpt-4-32k-0314", - "display_name": "gpt-4-32k-0314", + "id": "aihubmix-Jamba-1-5-Large", + "name": "aihubmix-Jamba-1-5-Large", + "display_name": "aihubmix-Jamba-1-5-Large", "limit": { "context": 8192, "output": 8192 @@ -251586,15 +251738,15 @@ "supported": false }, "cost": { - "input": 60, - "output": 120 + "input": 2.2, + "output": 8.8 }, "type": "chat" }, { - "id": "gpt-4-32k-0613", - "name": "gpt-4-32k-0613", - "display_name": "gpt-4-32k-0613", + "id": "aihubmix-Llama-3-1-405B-Instruct", + "name": "aihubmix-Llama-3-1-405B-Instruct", + "display_name": "aihubmix-Llama-3-1-405B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251604,15 +251756,15 @@ "supported": false }, "cost": { - "input": 60, - "output": 120 + "input": 5, + "output": 15 }, "type": "chat" }, { - "id": "gpt-4-turbo", - "name": "gpt-4-turbo", - "display_name": "gpt-4-turbo", + "id": "aihubmix-Llama-3-1-70B-Instruct", + "name": "aihubmix-Llama-3-1-70B-Instruct", + "display_name": "aihubmix-Llama-3-1-70B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251622,15 +251774,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 30 + "input": 0.6, + "output": 0.78 }, "type": "chat" }, { - "id": "gpt-4-turbo-2024-04-09", - "name": "gpt-4-turbo-2024-04-09", - "display_name": "gpt-4-turbo-2024-04-09", + "id": "aihubmix-Llama-3-1-8B-Instruct", + "name": "aihubmix-Llama-3-1-8B-Instruct", + "display_name": "aihubmix-Llama-3-1-8B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251640,15 +251792,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 30 + "input": 0.3, + "output": 0.6 }, "type": "chat" }, { - "id": "gpt-4-turbo-preview", - "name": "gpt-4-turbo-preview", - "display_name": "gpt-4-turbo-preview", + "id": "aihubmix-Llama-3-2-11B-Vision", + "name": "aihubmix-Llama-3-2-11B-Vision", + "display_name": "aihubmix-Llama-3-2-11B-Vision", "limit": { "context": 8192, "output": 8192 @@ -251658,15 +251810,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 30 + "input": 0.4, + "output": 0.4 }, "type": "chat" }, { - "id": "gpt-4-vision-preview", - "name": "gpt-4-vision-preview", - "display_name": "gpt-4-vision-preview", + "id": "aihubmix-Llama-3-2-90B-Vision", + "name": "aihubmix-Llama-3-2-90B-Vision", + "display_name": "aihubmix-Llama-3-2-90B-Vision", "limit": { "context": 8192, "output": 8192 @@ -251676,40 +251828,33 @@ "supported": false }, "cost": { - "input": 10, - "output": 30 + "input": 2.4, + "output": 2.4 }, "type": "chat" }, { - "id": "gpt-4o-2024-05-13", - "name": "gpt-4o-2024-05-13", - "display_name": "gpt-4o-2024-05-13", + "id": "aihubmix-Llama-3-70B-Instruct", + "name": "aihubmix-Llama-3-70B-Instruct", + "display_name": "aihubmix-Llama-3-70B-Instruct", "limit": { - "context": 128000, - "output": 128000 + "context": 8192, + "output": 8192 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 5, - "output": 15, - "cache_read": 5 + "input": 0.7, + "output": 0.7 }, "type": "chat" }, { - "id": "gpt-4o-mini-2024-07-18", - "name": "gpt-4o-mini-2024-07-18", - "display_name": "gpt-4o-mini-2024-07-18", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "aihubmix-Mistral-large", + "name": "aihubmix-Mistral-large", + "display_name": "aihubmix-Mistral-large", "limit": { "context": 8192, "output": 8192 @@ -251719,49 +251864,41 @@ "supported": false }, "cost": { - "input": 0.15, - "output": 0.6, - "cache_read": 0.075 + "input": 4, + "output": 12 }, "type": "chat" }, { - "id": "gpt-oss-20b", - "name": "gpt-oss-20b", - "display_name": "gpt-oss-20b", + "id": "aihubmix-command-r-08-2024", + "name": "aihubmix-command-r-08-2024", + "display_name": "aihubmix-command-r-08-2024", "modalities": { "input": [ "text" ] }, "limit": { - "context": 128000, - "output": 128000 + "context": 8192, + "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 0.11, - "output": 0.55 + "input": 0.2, + "output": 0.8 }, "type": "chat" }, { - "id": "grok-2-vision-1212", - "name": "grok-2-vision-1212", - "display_name": "grok-2-vision-1212", + "id": "aihubmix-command-r-plus", + "name": "aihubmix-command-r-plus", + "display_name": "aihubmix-command-r-plus", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { @@ -251773,19 +251910,18 @@ "supported": false }, "cost": { - "input": 1.8, - "output": 9 + "input": 3.84, + "output": 19.2 }, "type": "chat" }, { - "id": "grok-vision-beta", - "name": "grok-vision-beta", - "display_name": "grok-vision-beta", + "id": "aihubmix-command-r-plus-08-2024", + "name": "aihubmix-command-r-plus-08-2024", + "display_name": "aihubmix-command-r-plus-08-2024", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { @@ -251797,15 +251933,15 @@ "supported": false }, "cost": { - "input": 5.6, - "output": 16.8 + "input": 2.8, + "output": 11.2 }, "type": "chat" }, { - "id": "groq-llama-3.1-8b-instant", - "name": "groq-llama-3.1-8b-instant", - "display_name": "groq-llama-3.1-8b-instant", + "id": "alicloud-deepseek-v3.2", + "name": "alicloud-deepseek-v3.2", + "display_name": "alicloud-deepseek-v3.2", "limit": { "context": 8192, "output": 8192 @@ -251815,15 +251951,16 @@ "supported": false }, "cost": { - "input": 0.055, - "output": 0.088 + "input": 0.274, + "output": 0.411, + "cache_read": 0.0548 }, "type": "chat" }, { - "id": "groq-llama-3.3-70b-versatile", - "name": "groq-llama-3.3-70b-versatile", - "display_name": "groq-llama-3.3-70b-versatile", + "id": "alicloud-glm-4.7", + "name": "alicloud-glm-4.7", + "display_name": "alicloud-glm-4.7", "limit": { "context": 8192, "output": 8192 @@ -251833,15 +251970,16 @@ "supported": false }, "cost": { - "input": 0.649, - "output": 0.869011 + "input": 0.41096, + "output": 1.917786, + "cache_read": 0.41096 }, "type": "chat" }, { - "id": "groq-llama-4-maverick-17b-128e-instruct", - "name": "groq-llama-4-maverick-17b-128e-instruct", - "display_name": "groq-llama-4-maverick-17b-128e-instruct", + "id": "alicloud-kimi-k2-thinking", + "name": "alicloud-kimi-k2-thinking", + "display_name": "alicloud-kimi-k2-thinking", "limit": { "context": 8192, "output": 8192 @@ -251851,39 +251989,34 @@ "supported": false }, "cost": { - "input": 0.22, - "output": 0.66 + "input": 0.548, + "output": 2.192 }, "type": "chat" }, { - "id": "groq-llama-4-scout-17b-16e-instruct", - "name": "groq-llama-4-scout-17b-16e-instruct", - "display_name": "groq-llama-4-scout-17b-16e-instruct", + "id": "alicloud-kimi-k2.5", + "name": "alicloud-kimi-k2.5", + "display_name": "alicloud-kimi-k2.5", "limit": { - "context": 8192, - "output": 8192 + "context": 256000, + "output": 256000 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 0.122, - "output": 0.366 + "input": 0.548, + "output": 2.877, + "cache_read": 0.0959 }, "type": "chat" }, { - "id": "imagen-4.0-generate-preview-05-20", - "name": "imagen-4.0-generate-preview-05-20", - "display_name": "imagen-4.0-generate-preview-05-20", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "alicloud-minimax-m2.5", + "name": "alicloud-minimax-m2.5", + "display_name": "alicloud-minimax-m2.5", "limit": { "context": 8192, "output": 8192 @@ -251893,39 +252026,47 @@ "supported": false }, "cost": { - "input": 2, - "output": 2, - "cache_read": 0 + "input": 0.2876, + "output": 1.1504, + "cache_read": 0.05752 }, - "type": "imageGeneration" + "type": "chat" }, { - "id": "jina-embeddings-v2-base-code", - "name": "jina-embeddings-v2-base-code", - "display_name": "jina-embeddings-v2-base-code", + "id": "anthropic-opus-4-6", + "name": "anthropic-opus-4-6", + "display_name": "anthropic-opus-4-6", "modalities": { "input": [ - "text" + "text", + "image" ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 200000, + "output": 200000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "cost": { - "input": 0.05, - "output": 0.05 + "input": 5, + "output": 25, + "cache_read": 0.5 }, - "type": "embedding" + "type": "chat" }, { - "id": "learnlm-1.5-pro-experimental", - "name": "learnlm-1.5-pro-experimental", - "display_name": "learnlm-1.5-pro-experimental", + "id": "azure-deepseek-v3.2", + "name": "azure-deepseek-v3.2", + "display_name": "azure-deepseek-v3.2", "limit": { "context": 8192, "output": 8192 @@ -251935,15 +252076,15 @@ "supported": false }, "cost": { - "input": 1.25, - "output": 5 + "input": 0.58, + "output": 1.680028 }, "type": "chat" }, { - "id": "llama-3.1-405b-instruct", - "name": "llama-3.1-405b-instruct", - "display_name": "llama-3.1-405b-instruct", + "id": "azure-deepseek-v3.2-speciale", + "name": "azure-deepseek-v3.2-speciale", + "display_name": "azure-deepseek-v3.2-speciale", "limit": { "context": 8192, "output": 8192 @@ -251953,33 +252094,33 @@ "supported": false }, "cost": { - "input": 4, - "output": 4 + "input": 0.58, + "output": 1.680028 }, "type": "chat" }, { - "id": "llama-3.1-405b-reasoning", - "name": "llama-3.1-405b-reasoning", - "display_name": "llama-3.1-405b-reasoning", + "id": "azure-kimi-k2.5", + "name": "azure-kimi-k2.5", + "display_name": "azure-kimi-k2.5", "limit": { - "context": 8192, - "output": 8192 + "context": 256000, + "output": 256000 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 4, - "output": 4 + "input": 0.6, + "output": 3 }, "type": "chat" }, { - "id": "llama-3.1-70b-versatile", - "name": "llama-3.1-70b-versatile", - "display_name": "llama-3.1-70b-versatile", + "id": "cbs-glm-4.7", + "name": "cbs-glm-4.7", + "display_name": "cbs-glm-4.7", "limit": { "context": 8192, "output": 8192 @@ -251989,15 +252130,15 @@ "supported": false }, "cost": { - "input": 0.6, - "output": 0.6 + "input": 2.25, + "output": 2.749995 }, "type": "chat" }, { - "id": "llama-3.1-8b-instant", - "name": "llama-3.1-8b-instant", - "display_name": "llama-3.1-8b-instant", + "id": "cerebras-llama-3.3-70b", + "name": "cerebras-llama-3.3-70b", + "display_name": "cerebras-llama-3.3-70b", "limit": { "context": 8192, "output": 8192 @@ -252007,15 +252148,15 @@ "supported": false }, "cost": { - "input": 0.3, + "input": 0.6, "output": 0.6 }, "type": "chat" }, { - "id": "llama-3.1-sonar-small-128k-online", - "name": "llama-3.1-sonar-small-128k-online", - "display_name": "llama-3.1-sonar-small-128k-online", + "id": "chatglm_lite", + "name": "chatglm_lite", + "display_name": "chatglm_lite", "limit": { "context": 8192, "output": 8192 @@ -252025,15 +252166,15 @@ "supported": false }, "cost": { - "input": 0.3, - "output": 0.3 + "input": 0.2858, + "output": 0.2858 }, "type": "chat" }, { - "id": "llama-3.2-11b-vision-preview", - "name": "llama-3.2-11b-vision-preview", - "display_name": "llama-3.2-11b-vision-preview", + "id": "chatglm_pro", + "name": "chatglm_pro", + "display_name": "chatglm_pro", "limit": { "context": 8192, "output": 8192 @@ -252043,15 +252184,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 1.4286, + "output": 1.4286 }, "type": "chat" }, { - "id": "llama-3.2-1b-preview", - "name": "llama-3.2-1b-preview", - "display_name": "llama-3.2-1b-preview", + "id": "chatglm_std", + "name": "chatglm_std", + "display_name": "chatglm_std", "limit": { "context": 8192, "output": 8192 @@ -252061,15 +252202,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 0.7144, + "output": 0.7144 }, "type": "chat" }, { - "id": "llama-3.2-3b-preview", - "name": "llama-3.2-3b-preview", - "display_name": "llama-3.2-3b-preview", + "id": "chatglm_turbo", + "name": "chatglm_turbo", + "display_name": "chatglm_turbo", "limit": { "context": 8192, "output": 8192 @@ -252079,15 +252220,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 0.7144, + "output": 0.7144 }, "type": "chat" }, { - "id": "llama-3.2-90b-vision-preview", - "name": "llama-3.2-90b-vision-preview", - "display_name": "llama-3.2-90b-vision-preview", + "id": "claude-2", + "name": "claude-2", + "display_name": "claude-2", "limit": { "context": 8192, "output": 8192 @@ -252097,15 +252238,15 @@ "supported": false }, "cost": { - "input": 2.4, - "output": 2.4 + "input": 8.8, + "output": 8.8 }, "type": "chat" }, { - "id": "llama2-70b-4096", - "name": "llama2-70b-4096", - "display_name": "llama2-70b-4096", + "id": "claude-2.0", + "name": "claude-2.0", + "display_name": "claude-2.0", "limit": { "context": 8192, "output": 8192 @@ -252115,15 +252256,15 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 0.5 + "input": 8.8, + "output": 39.6 }, "type": "chat" }, { - "id": "llama2-70b-40960", - "name": "llama2-70b-40960", - "display_name": "llama2-70b-40960", + "id": "claude-2.1", + "name": "claude-2.1", + "display_name": "claude-2.1", "limit": { "context": 8192, "output": 8192 @@ -252133,15 +252274,21 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 0.5 + "input": 8.8, + "output": 39.6 }, "type": "chat" }, { - "id": "llama2-7b-2048", - "name": "llama2-7b-2048", - "display_name": "llama2-7b-2048", + "id": "claude-3-haiku-20240229", + "name": "claude-3-haiku-20240229", + "display_name": "claude-3-haiku-20240229", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252151,15 +252298,21 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 0.275, + "output": 0.275 }, "type": "chat" }, { - "id": "llama3-70b-8192", - "name": "llama3-70b-8192", - "display_name": "llama3-70b-8192", + "id": "claude-3-haiku-20240307", + "name": "claude-3-haiku-20240307", + "display_name": "claude-3-haiku-20240307", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252169,15 +252322,21 @@ "supported": false }, "cost": { - "input": 0.7, - "output": 0.937288 + "input": 0.275, + "output": 1.375 }, "type": "chat" }, { - "id": "llama3-8b-8192", - "name": "llama3-8b-8192", - "display_name": "llama3-8b-8192", + "id": "claude-3-sonnet-20240229", + "name": "claude-3-sonnet-20240229", + "display_name": "claude-3-sonnet-20240229", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252187,15 +252346,15 @@ "supported": false }, "cost": { - "input": 0.06, - "output": 0.12 + "input": 3.3, + "output": 16.5 }, "type": "chat" }, { - "id": "llama3-groq-70b-8192-tool-use-preview", - "name": "llama3-groq-70b-8192-tool-use-preview", - "display_name": "llama3-groq-70b-8192-tool-use-preview", + "id": "claude-instant-1", + "name": "claude-instant-1", + "display_name": "claude-instant-1", "limit": { "context": 8192, "output": 8192 @@ -252205,15 +252364,15 @@ "supported": false }, "cost": { - "input": 0.00089, - "output": 0.00089 + "input": 1.793, + "output": 1.793 }, "type": "chat" }, { - "id": "llama3-groq-8b-8192-tool-use-preview", - "name": "llama3-groq-8b-8192-tool-use-preview", - "display_name": "llama3-groq-8b-8192-tool-use-preview", + "id": "claude-instant-1.2", + "name": "claude-instant-1.2", + "display_name": "claude-instant-1.2", "limit": { "context": 8192, "output": 8192 @@ -252223,15 +252382,15 @@ "supported": false }, "cost": { - "input": 0.00019, - "output": 0.00019 + "input": 0.88, + "output": 3.96 }, "type": "chat" }, { - "id": "mai-image-2", - "name": "mai-image-2", - "display_name": "mai-image-2", + "id": "code-davinci-edit-001", + "name": "code-davinci-edit-001", + "display_name": "code-davinci-edit-001", "limit": { "context": 8192, "output": 8192 @@ -252241,16 +252400,15 @@ "supported": false }, "cost": { - "input": 2, - "output": 2, - "cache_read": 0 + "input": 20, + "output": 20 }, - "type": "imageGeneration" + "type": "chat" }, { - "id": "meta-llama/Llama-3.2-90B-Vision-Instruct", - "name": "meta-llama/Llama-3.2-90B-Vision-Instruct", - "display_name": "meta-llama/Llama-3.2-90B-Vision-Instruct", + "id": "cogview-3", + "name": "cogview-3", + "display_name": "cogview-3", "limit": { "context": 8192, "output": 8192 @@ -252260,15 +252418,15 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 0.5 + "input": 35.5, + "output": 35.5 }, "type": "chat" }, { - "id": "meta-llama/llama-3.1-405b-instruct:free", - "name": "meta-llama/llama-3.1-405b-instruct:free", - "display_name": "meta-llama/llama-3.1-405b-instruct:free", + "id": "cogview-3-plus", + "name": "cogview-3-plus", + "display_name": "cogview-3-plus", "limit": { "context": 8192, "output": 8192 @@ -252278,15 +252436,20 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 10, + "output": 10 }, "type": "chat" }, { - "id": "meta-llama/llama-3.1-70b-instruct:free", - "name": "meta-llama/llama-3.1-70b-instruct:free", - "display_name": "meta-llama/llama-3.1-70b-instruct:free", + "id": "command", + "name": "command", + "display_name": "command", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252296,15 +252459,15 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 1, + "output": 2 }, "type": "chat" }, { - "id": "meta-llama/llama-3.1-8b-instruct:free", - "name": "meta-llama/llama-3.1-8b-instruct:free", - "display_name": "meta-llama/llama-3.1-8b-instruct:free", + "id": "command-light", + "name": "command-light", + "display_name": "command-light", "limit": { "context": 8192, "output": 8192 @@ -252314,15 +252477,15 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 1, + "output": 2 }, "type": "chat" }, { - "id": "meta-llama/llama-3.2-11b-vision-instruct:free", - "name": "meta-llama/llama-3.2-11b-vision-instruct:free", - "display_name": "meta-llama/llama-3.2-11b-vision-instruct:free", + "id": "command-light-nightly", + "name": "command-light-nightly", + "display_name": "command-light-nightly", "limit": { "context": 8192, "output": 8192 @@ -252332,15 +252495,15 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 1, + "output": 2 }, "type": "chat" }, { - "id": "meta-llama/llama-3.2-3b-instruct:free", - "name": "meta-llama/llama-3.2-3b-instruct:free", - "display_name": "meta-llama/llama-3.2-3b-instruct:free", + "id": "command-nightly", + "name": "command-nightly", + "display_name": "command-nightly", "limit": { "context": 8192, "output": 8192 @@ -252350,15 +252513,20 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 1, + "output": 2 }, "type": "chat" }, { - "id": "meta/llama-3.1-405b-instruct", - "name": "meta/llama-3.1-405b-instruct", - "display_name": "meta/llama-3.1-405b-instruct", + "id": "command-r", + "name": "command-r", + "display_name": "command-r", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252368,15 +252536,20 @@ "supported": false }, "cost": { - "input": 5, - "output": 5 + "input": 0.64, + "output": 1.92 }, "type": "chat" }, { - "id": "meta/llama3-8B-chat", - "name": "meta/llama3-8B-chat", - "display_name": "meta/llama3-8B-chat", + "id": "command-r-08-2024", + "name": "command-r-08-2024", + "display_name": "command-r-08-2024", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252386,15 +252559,20 @@ "supported": false }, "cost": { - "input": 0.3, - "output": 0.3 + "input": 0.2, + "output": 0.8 }, "type": "chat" }, { - "id": "mistralai/mistral-7b-instruct:free", - "name": "mistralai/mistral-7b-instruct:free", - "display_name": "mistralai/mistral-7b-instruct:free", + "id": "command-r-plus", + "name": "command-r-plus", + "display_name": "command-r-plus", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252404,15 +252582,20 @@ "supported": false }, "cost": { - "input": 0.002, - "output": 0.002 + "input": 3.84, + "output": 19.2 }, "type": "chat" }, { - "id": "mm-minimax-m3", - "name": "mm-minimax-m3", - "display_name": "mm-minimax-m3", + "id": "command-r-plus-08-2024", + "name": "command-r-plus-08-2024", + "display_name": "command-r-plus-08-2024", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252422,15 +252605,21 @@ "supported": false }, "cost": { - "input": 0.288, - "output": 1.152 + "input": 2.8, + "output": 11.2 }, "type": "chat" }, { - "id": "moonshot-kimi-k2.5", - "name": "moonshot-kimi-k2.5", - "display_name": "moonshot-kimi-k2.5", + "id": "dall-e-2", + "name": "dall-e-2", + "display_name": "dall-e-2", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252440,16 +252629,15 @@ "supported": false }, "cost": { - "input": 0.6, - "output": 3, - "cache_read": 0.105 + "input": 16, + "output": 16 }, - "type": "chat" + "type": "imageGeneration" }, { - "id": "moonshot-v1-128k", - "name": "moonshot-v1-128k", - "display_name": "moonshot-v1-128k", + "id": "davinci", + "name": "davinci", + "display_name": "davinci", "limit": { "context": 8192, "output": 8192 @@ -252459,15 +252647,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 10 + "input": 20, + "output": 20 }, "type": "chat" }, { - "id": "moonshot-v1-128k-vision-preview", - "name": "moonshot-v1-128k-vision-preview", - "display_name": "moonshot-v1-128k-vision-preview", + "id": "davinci-002", + "name": "davinci-002", + "display_name": "davinci-002", "limit": { "context": 8192, "output": 8192 @@ -252477,15 +252665,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 10 + "input": 2, + "output": 2 }, "type": "chat" }, { - "id": "moonshot-v1-32k", - "name": "moonshot-v1-32k", - "display_name": "moonshot-v1-32k", + "id": "deepinfra-llama-3.1-8b-instant", + "name": "deepinfra-llama-3.1-8b-instant", + "display_name": "deepinfra-llama-3.1-8b-instant", "limit": { "context": 8192, "output": 8192 @@ -252495,15 +252683,15 @@ "supported": false }, "cost": { - "input": 4, - "output": 4 + "input": 0.033, + "output": 0.054978 }, "type": "chat" }, { - "id": "moonshot-v1-32k-vision-preview", - "name": "moonshot-v1-32k-vision-preview", - "display_name": "moonshot-v1-32k-vision-preview", + "id": "deepinfra-llama-3.3-70b-instant-turbo", + "name": "deepinfra-llama-3.3-70b-instant-turbo", + "display_name": "deepinfra-llama-3.3-70b-instant-turbo", "limit": { "context": 8192, "output": 8192 @@ -252513,15 +252701,15 @@ "supported": false }, "cost": { - "input": 4, - "output": 4 + "input": 0.11, + "output": 0.352 }, "type": "chat" }, { - "id": "moonshot-v1-8k", - "name": "moonshot-v1-8k", - "display_name": "moonshot-v1-8k", + "id": "deepinfra-llama-4-maverick-17b-128e-instruct", + "name": "deepinfra-llama-4-maverick-17b-128e-instruct", + "display_name": "deepinfra-llama-4-maverick-17b-128e-instruct", "limit": { "context": 8192, "output": 8192 @@ -252531,15 +252719,15 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 0.33, + "output": 1.32 }, "type": "chat" }, { - "id": "moonshot-v1-8k-vision-preview", - "name": "moonshot-v1-8k-vision-preview", - "display_name": "moonshot-v1-8k-vision-preview", + "id": "deepinfra-llama-4-scout-17b-16e-instruct", + "name": "deepinfra-llama-4-scout-17b-16e-instruct", + "display_name": "deepinfra-llama-4-scout-17b-16e-instruct", "limit": { "context": 8192, "output": 8192 @@ -252549,15 +252737,16 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 0.088, + "output": 0.33, + "cache_read": 0 }, "type": "chat" }, { - "id": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", - "name": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", - "display_name": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", + "id": "deepseek-ai/DeepSeek-Coder-V2-Instruct", + "name": "deepseek-ai/DeepSeek-Coder-V2-Instruct", + "display_name": "deepseek-ai/DeepSeek-Coder-V2-Instruct", "limit": { "context": 8192, "output": 8192 @@ -252567,50 +252756,38 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 0.5, - "cache_read": 0 + "input": 0.16, + "output": 0.32 }, "type": "chat" }, { - "id": "o1-mini-2024-09-12", - "name": "o1-mini-2024-09-12", - "display_name": "o1-mini-2024-09-12", + "id": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true, - "default": true + "supported": true }, "extra_capabilities": { "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" + "supported": true } }, "cost": { - "input": 3, - "output": 12, - "cache_read": 1.5 + "input": 0.6, + "output": 0.6 }, "type": "chat" }, { - "id": "omni-moderation-latest", - "name": "omni-moderation-latest", - "display_name": "omni-moderation-latest", + "id": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", "limit": { "context": 8192, "output": 8192 @@ -252620,75 +252797,51 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 0.01, + "output": 0.01 }, "type": "chat" }, { - "id": "qwen-flash", - "name": "qwen-flash", - "display_name": "qwen-flash", + "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.02, - "output": 0.2, - "cache_read": 0.02 + "input": 0.01, + "output": 0.01 }, "type": "chat" }, { - "id": "qwen-flash-2025-07-28", - "name": "qwen-flash-2025-07-28", - "display_name": "qwen-flash-2025-07-28", + "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.02, - "output": 0.2, - "cache_read": 0.02 + "input": 0.1, + "output": 0.1 }, "type": "chat" }, { - "id": "qwen-long", - "name": "qwen-long", - "display_name": "qwen-long", + "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", "limit": { "context": 8192, "output": 8192 @@ -252698,15 +252851,15 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.4 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "qwen-max", - "name": "qwen-max", - "display_name": "qwen-max", + "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", "limit": { "context": 8192, "output": 8192 @@ -252716,15 +252869,15 @@ "supported": false }, "cost": { - "input": 0.38, - "output": 1.52 + "input": 0.01, + "output": 0.01 }, "type": "chat" }, { - "id": "qwen-max-longcontext", - "name": "qwen-max-longcontext", - "display_name": "qwen-max-longcontext", + "id": "deepseek-ai/DeepSeek-V2-Chat", + "name": "deepseek-ai/DeepSeek-V2-Chat", + "display_name": "deepseek-ai/DeepSeek-V2-Chat", "limit": { "context": 8192, "output": 8192 @@ -252734,85 +252887,51 @@ "supported": false }, "cost": { - "input": 7, - "output": 21 + "input": 0.16, + "output": 0.32 }, "type": "chat" }, { - "id": "qwen-plus", - "name": "qwen-plus", - "display_name": "qwen-plus", + "id": "deepseek-ai/DeepSeek-V2.5", + "name": "deepseek-ai/DeepSeek-V2.5", + "display_name": "deepseek-ai/DeepSeek-V2.5", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.1126, - "output": 1.126, - "cache_read": 0.02252 + "input": 0.16, + "output": 0.32 }, "type": "chat" }, { - "id": "qwen-turbo", - "name": "qwen-turbo", - "display_name": "qwen-turbo", - "modalities": { - "input": [ - "text" - ] - }, + "id": "deepseek-ai/deepseek-llm-67b-chat", + "name": "deepseek-ai/deepseek-llm-67b-chat", + "display_name": "deepseek-ai/deepseek-llm-67b-chat", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.046, - "output": 0.092, - "cache_read": 0.0092 + "input": 0.16, + "output": 0.16 }, "type": "chat" }, { - "id": "qwen-turbo-2024-11-01", - "name": "qwen-turbo-2024-11-01", - "display_name": "qwen-turbo-2024-11-01", - "modalities": { - "input": [ - "text" - ] - }, + "id": "deepseek-ai/deepseek-vl2", + "name": "deepseek-ai/deepseek-vl2", + "display_name": "deepseek-ai/deepseek-vl2", "limit": { "context": 8192, "output": 8192 @@ -252822,15 +252941,15 @@ "supported": false }, "cost": { - "input": 0.046, - "output": 0.092 + "input": 0.16, + "output": 0.16 }, "type": "chat" }, { - "id": "qwen2.5-14b-instruct", - "name": "qwen2.5-14b-instruct", - "display_name": "qwen2.5-14b-instruct", + "id": "deepseek-v3", + "name": "deepseek-v3", + "display_name": "deepseek-v3", "limit": { "context": 8192, "output": 8192 @@ -252840,15 +252959,21 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 1.2 + "input": 0.272, + "output": 1.088, + "cache_read": 0 }, "type": "chat" }, { - "id": "qwen2.5-32b-instruct", - "name": "qwen2.5-32b-instruct", - "display_name": "qwen2.5-32b-instruct", + "id": "distil-whisper-large-v3-en", + "name": "distil-whisper-large-v3-en", + "display_name": "distil-whisper-large-v3-en", + "modalities": { + "input": [ + "audio" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252858,15 +252983,15 @@ "supported": false }, "cost": { - "input": 0.6, - "output": 1.2 + "input": 5.556, + "output": 5.556 }, "type": "chat" }, { - "id": "qwen2.5-3b-instruct", - "name": "qwen2.5-3b-instruct", - "display_name": "qwen2.5-3b-instruct", + "id": "doubao-1-5-thinking-vision-pro-250428", + "name": "doubao-1-5-thinking-vision-pro-250428", + "display_name": "doubao-1-5-thinking-vision-pro-250428", "limit": { "context": 8192, "output": 8192 @@ -252876,15 +253001,16 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.8 + "input": 2, + "output": 2, + "cache_read": 2 }, "type": "chat" }, { - "id": "qwen2.5-72b-instruct", - "name": "qwen2.5-72b-instruct", - "display_name": "qwen2.5-72b-instruct", + "id": "fx-flux-2-pro", + "name": "fx-flux-2-pro", + "display_name": "fx-flux-2-pro", "limit": { "context": 8192, "output": 8192 @@ -252894,33 +253020,68 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 2.4 + "input": 2, + "output": 0, + "cache_read": 0 }, "type": "chat" }, { - "id": "qwen2.5-7b-instruct", - "name": "qwen2.5-7b-instruct", - "display_name": "qwen2.5-7b-instruct", + "id": "gemini-2.5-pro-exp-03-25", + "name": "gemini-2.5-pro-exp-03-25", + "display_name": "gemini-2.5-pro-exp-03-25", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ] + }, "limit": { "context": 8192, "output": 8192 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "budget", + "budget": { + "default": -1, + "min": 128, + "max": 32768, + "auto": -1, + "unit": "tokens" + }, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] + } }, "cost": { - "input": 0.4, - "output": 0.8 + "input": 1.25, + "output": 5, + "cache_read": 0.125 }, "type": "chat" }, { - "id": "qwen2.5-coder-1.5b-instruct", - "name": "qwen2.5-coder-1.5b-instruct", - "display_name": "qwen2.5-coder-1.5b-instruct", + "id": "gemini-embedding-exp-03-07", + "name": "gemini-embedding-exp-03-07", + "display_name": "gemini-embedding-exp-03-07", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252930,15 +253091,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.4 + "input": 0.02, + "output": 0.02 }, - "type": "chat" + "type": "embedding" }, { - "id": "qwen2.5-coder-7b-instruct", - "name": "qwen2.5-coder-7b-instruct", - "display_name": "qwen2.5-coder-7b-instruct", + "id": "gemini-exp-1114", + "name": "gemini-exp-1114", + "display_name": "gemini-exp-1114", "limit": { "context": 8192, "output": 8192 @@ -252948,15 +253109,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.4 + "input": 1.25, + "output": 5 }, "type": "chat" }, { - "id": "qwen2.5-math-1.5b-instruct", - "name": "qwen2.5-math-1.5b-instruct", - "display_name": "qwen2.5-math-1.5b-instruct", + "id": "gemini-exp-1121", + "name": "gemini-exp-1121", + "display_name": "gemini-exp-1121", "limit": { "context": 8192, "output": 8192 @@ -252966,15 +253127,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 1.25, + "output": 5 }, "type": "chat" }, { - "id": "qwen2.5-math-72b-instruct", - "name": "qwen2.5-math-72b-instruct", - "display_name": "qwen2.5-math-72b-instruct", + "id": "gemini-pro", + "name": "gemini-pro", + "display_name": "gemini-pro", "limit": { "context": 8192, "output": 8192 @@ -252984,15 +253145,15 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 2.4 + "input": 0.2, + "output": 0.6 }, "type": "chat" }, { - "id": "qwen2.5-math-7b-instruct", - "name": "qwen2.5-math-7b-instruct", - "display_name": "qwen2.5-math-7b-instruct", + "id": "gemini-pro-vision", + "name": "gemini-pro-vision", + "display_name": "gemini-pro-vision", "limit": { "context": 8192, "output": 8192 @@ -253002,15 +253163,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.4 + "input": 1, + "output": 1 }, "type": "chat" }, { - "id": "step-2-16k", - "name": "step-2-16k", - "display_name": "step-2-16k", + "id": "gemma-7b-it", + "name": "gemma-7b-it", + "display_name": "gemma-7b-it", "limit": { "context": 8192, "output": 8192 @@ -253020,20 +253181,15 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 0.1, + "output": 0.1 }, "type": "chat" }, { - "id": "text-embedding-v1", - "name": "text-embedding-v1", - "display_name": "text-embedding-v1", - "modalities": { - "input": [ - "text" - ] - }, + "id": "glm-3-turbo", + "name": "glm-3-turbo", + "display_name": "glm-3-turbo", "limit": { "context": 8192, "output": 8192 @@ -253043,20 +253199,15 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 0.71, + "output": 0.71 }, - "type": "embedding" + "type": "chat" }, { - "id": "tts-1-hd-1106", - "name": "tts-1-hd-1106", - "display_name": "tts-1-hd-1106", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "glm-4", + "name": "glm-4", + "display_name": "glm-4", "limit": { "context": 8192, "output": 8192 @@ -253066,19 +253217,15 @@ "supported": false }, "cost": { - "input": 30, - "output": 30 - } + "input": 14.2, + "output": 14.2 + }, + "type": "chat" }, { - "id": "text-embedding-ada-002", - "name": "text-embedding-ada-002", - "display_name": "text-embedding-ada-002", - "modalities": { - "input": [ - "text" - ] - }, + "id": "glm-4-flash", + "name": "glm-4-flash", + "display_name": "glm-4-flash", "limit": { "context": 8192, "output": 8192 @@ -253091,17 +253238,12 @@ "input": 0.1, "output": 0.1 }, - "type": "embedding" + "type": "chat" }, { - "id": "tts-1-hd", - "name": "tts-1-hd", - "display_name": "tts-1-hd", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "glm-4-plus", + "name": "glm-4-plus", + "display_name": "glm-4-plus", "limit": { "context": 8192, "output": 8192 @@ -253111,14 +253253,15 @@ "supported": false }, "cost": { - "input": 30, - "output": 30 - } + "input": 8, + "output": 8 + }, + "type": "chat" }, { - "id": "text-embedding-3-small", - "name": "text-embedding-3-small", - "display_name": "text-embedding-3-small", + "id": "glm-4.5-airx", + "name": "glm-4.5-airx", + "display_name": "glm-4.5-airx", "modalities": { "input": [ "text" @@ -253133,15 +253276,16 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 1.1, + "output": 4.51, + "cache_read": 0.22 }, - "type": "embedding" + "type": "chat" }, { - "id": "text-moderation-007", - "name": "text-moderation-007", - "display_name": "text-moderation-007", + "id": "glm-4v", + "name": "glm-4v", + "display_name": "glm-4v", "limit": { "context": 8192, "output": 8192 @@ -253151,20 +253295,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 14.2, + "output": 14.2 }, "type": "chat" }, { - "id": "tts-1-1106", - "name": "tts-1-1106", - "display_name": "tts-1-1106", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "glm-4v-plus", + "name": "glm-4v-plus", + "display_name": "glm-4v-plus", "limit": { "context": 8192, "output": 8192 @@ -253174,14 +253313,15 @@ "supported": false }, "cost": { - "input": 15, - "output": 15 - } + "input": 2, + "output": 2 + }, + "type": "chat" }, { - "id": "text-davinci-edit-001", - "name": "text-davinci-edit-001", - "display_name": "text-davinci-edit-001", + "id": "google-gemma-3-12b-it", + "name": "google-gemma-3-12b-it", + "display_name": "google-gemma-3-12b-it", "limit": { "context": 8192, "output": 8192 @@ -253191,15 +253331,15 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "text-davinci-003", - "name": "text-davinci-003", - "display_name": "text-davinci-003", + "id": "google-gemma-3-27b-it", + "name": "google-gemma-3-27b-it", + "display_name": "google-gemma-3-27b-it", "limit": { "context": 8192, "output": 8192 @@ -253209,15 +253349,16 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.2, + "output": 0.2, + "cache_read": 0 }, "type": "chat" }, { - "id": "text-davinci-002", - "name": "text-davinci-002", - "display_name": "text-davinci-002", + "id": "google-gemma-3-4b-it", + "name": "google-gemma-3-4b-it", + "display_name": "google-gemma-3-4b-it", "limit": { "context": 8192, "output": 8192 @@ -253227,15 +253368,16 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.2, + "output": 0.2, + "cache_read": 0 }, "type": "chat" }, { - "id": "text-curie-001", - "name": "text-curie-001", - "display_name": "text-curie-001", + "id": "google/gemini-exp-1114", + "name": "google/gemini-exp-1114", + "display_name": "google/gemini-exp-1114", "limit": { "context": 8192, "output": 8192 @@ -253245,20 +253387,15 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 1.25, + "output": 5 }, "type": "chat" }, { - "id": "whisper-1", - "name": "whisper-1", - "display_name": "whisper-1", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "google/gemma-2-27b-it", + "name": "google/gemma-2-27b-it", + "display_name": "google/gemma-2-27b-it", "limit": { "context": 8192, "output": 8192 @@ -253268,20 +253405,15 @@ "supported": false }, "cost": { - "input": 100, - "output": 100 + "input": 0.8, + "output": 0.8 }, "type": "chat" }, { - "id": "whisper-large-v3", - "name": "whisper-large-v3", - "display_name": "whisper-large-v3", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "google/gemma-2-9b-it:free", + "name": "google/gemma-2-9b-it:free", + "display_name": "google/gemma-2-9b-it:free", "limit": { "context": 8192, "output": 8192 @@ -253291,20 +253423,15 @@ "supported": false }, "cost": { - "input": 30.834, - "output": 30.834 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "whisper-large-v3-turbo", - "name": "whisper-large-v3-turbo", - "display_name": "whisper-large-v3-turbo", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "gpt-3.5-turbo", + "name": "gpt-3.5-turbo", + "display_name": "gpt-3.5-turbo", "limit": { "context": 8192, "output": 8192 @@ -253314,15 +253441,15 @@ "supported": false }, "cost": { - "input": 5.556, - "output": 5.556 + "input": 0.5, + "output": 1.5 }, "type": "chat" }, { - "id": "text-babbage-001", - "name": "text-babbage-001", - "display_name": "text-babbage-001", + "id": "gpt-3.5-turbo-0301", + "name": "gpt-3.5-turbo-0301", + "display_name": "gpt-3.5-turbo-0301", "limit": { "context": 8192, "output": 8192 @@ -253332,15 +253459,15 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 0.5 + "input": 1.5, + "output": 1.5 }, "type": "chat" }, { - "id": "text-ada-001", - "name": "text-ada-001", - "display_name": "text-ada-001", + "id": "gpt-3.5-turbo-0613", + "name": "gpt-3.5-turbo-0613", + "display_name": "gpt-3.5-turbo-0613", "limit": { "context": 8192, "output": 8192 @@ -253350,20 +253477,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.4 + "input": 1.5, + "output": 2 }, "type": "chat" }, { - "id": "tts-1", - "name": "tts-1", - "display_name": "tts-1", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "gpt-3.5-turbo-1106", + "name": "gpt-3.5-turbo-1106", + "display_name": "gpt-3.5-turbo-1106", "limit": { "context": 8192, "output": 8192 @@ -253373,14 +253495,15 @@ "supported": false }, "cost": { - "input": 15, - "output": 15 - } + "input": 1, + "output": 2 + }, + "type": "chat" }, { - "id": "text-search-ada-doc-001", - "name": "text-search-ada-doc-001", - "display_name": "text-search-ada-doc-001", + "id": "gpt-3.5-turbo-16k", + "name": "gpt-3.5-turbo-16k", + "display_name": "gpt-3.5-turbo-16k", "limit": { "context": 8192, "output": 8192 @@ -253390,15 +253513,15 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 3, + "output": 4 }, "type": "chat" }, { - "id": "text-moderation-stable", - "name": "text-moderation-stable", - "display_name": "text-moderation-stable", + "id": "gpt-3.5-turbo-16k-0613", + "name": "gpt-3.5-turbo-16k-0613", + "display_name": "gpt-3.5-turbo-16k-0613", "limit": { "context": 8192, "output": 8192 @@ -253408,15 +253531,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 3, + "output": 4 }, "type": "chat" }, { - "id": "text-moderation-latest", - "name": "text-moderation-latest", - "display_name": "text-moderation-latest", + "id": "gpt-3.5-turbo-instruct", + "name": "gpt-3.5-turbo-instruct", + "display_name": "gpt-3.5-turbo-instruct", "limit": { "context": 8192, "output": 8192 @@ -253426,15 +253549,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 1.5, + "output": 2 }, "type": "chat" }, { - "id": "yi-large", - "name": "yi-large", - "display_name": "yi-large", + "id": "gpt-4", + "name": "gpt-4", + "display_name": "gpt-4", "limit": { "context": 8192, "output": 8192 @@ -253444,15 +253567,15 @@ "supported": false }, "cost": { - "input": 3, - "output": 3 + "input": 30, + "output": 60 }, "type": "chat" }, { - "id": "yi-large-rag", - "name": "yi-large-rag", - "display_name": "yi-large-rag", + "id": "gpt-4-0125-preview", + "name": "gpt-4-0125-preview", + "display_name": "gpt-4-0125-preview", "limit": { "context": 8192, "output": 8192 @@ -253462,15 +253585,15 @@ "supported": false }, "cost": { - "input": 4, - "output": 4 + "input": 10, + "output": 30 }, "type": "chat" }, { - "id": "yi-large-turbo", - "name": "yi-large-turbo", - "display_name": "yi-large-turbo", + "id": "gpt-4-0314", + "name": "gpt-4-0314", + "display_name": "gpt-4-0314", "limit": { "context": 8192, "output": 8192 @@ -253480,15 +253603,15 @@ "supported": false }, "cost": { - "input": 1.8, - "output": 1.8 + "input": 30, + "output": 60 }, "type": "chat" }, { - "id": "yi-lightning", - "name": "yi-lightning", - "display_name": "yi-lightning", + "id": "gpt-4-0613", + "name": "gpt-4-0613", + "display_name": "gpt-4-0613", "limit": { "context": 8192, "output": 8192 @@ -253498,15 +253621,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 30, + "output": 60 }, "type": "chat" }, { - "id": "yi-medium", - "name": "yi-medium", - "display_name": "yi-medium", + "id": "gpt-4-1106-preview", + "name": "gpt-4-1106-preview", + "display_name": "gpt-4-1106-preview", "limit": { "context": 8192, "output": 8192 @@ -253516,15 +253639,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.4 + "input": 10, + "output": 30 }, "type": "chat" }, { - "id": "yi-vl-plus", - "name": "yi-vl-plus", - "display_name": "yi-vl-plus", + "id": "gpt-4-32k-0314", + "name": "gpt-4-32k-0314", + "display_name": "gpt-4-32k-0314", "limit": { "context": 8192, "output": 8192 @@ -253534,20 +253657,15 @@ "supported": false }, "cost": { - "input": 0.000852, - "output": 0.000852 + "input": 60, + "output": 120 }, "type": "chat" }, { - "id": "text-embedding-3-large", - "name": "text-embedding-3-large", - "display_name": "text-embedding-3-large", - "modalities": { - "input": [ - "text" - ] - }, + "id": "gpt-4-32k-0613", + "name": "gpt-4-32k-0613", + "display_name": "gpt-4-32k-0613", "limit": { "context": 8192, "output": 8192 @@ -253557,15 +253675,15 @@ "supported": false }, "cost": { - "input": 0.13, - "output": 0.13 + "input": 60, + "output": 120 }, - "type": "embedding" + "type": "chat" }, { - "id": "Baichuan3-Turbo", - "name": "Baichuan3-Turbo", - "display_name": "Baichuan3-Turbo", + "id": "gpt-4-turbo", + "name": "gpt-4-turbo", + "display_name": "gpt-4-turbo", "limit": { "context": 8192, "output": 8192 @@ -253575,15 +253693,15 @@ "supported": false }, "cost": { - "input": 1.9, - "output": 1.9 + "input": 10, + "output": 30 }, "type": "chat" }, { - "id": "Baichuan3-Turbo-128k", - "name": "Baichuan3-Turbo-128k", - "display_name": "Baichuan3-Turbo-128k", + "id": "gpt-4-turbo-2024-04-09", + "name": "gpt-4-turbo-2024-04-09", + "display_name": "gpt-4-turbo-2024-04-09", "limit": { "context": 8192, "output": 8192 @@ -253593,15 +253711,15 @@ "supported": false }, "cost": { - "input": 3.8, - "output": 3.8 + "input": 10, + "output": 30 }, "type": "chat" }, { - "id": "Baichuan4", - "name": "Baichuan4", - "display_name": "Baichuan4", + "id": "gpt-4-turbo-preview", + "name": "gpt-4-turbo-preview", + "display_name": "gpt-4-turbo-preview", "limit": { "context": 8192, "output": 8192 @@ -253611,15 +253729,15 @@ "supported": false }, "cost": { - "input": 16, - "output": 16 + "input": 10, + "output": 30 }, "type": "chat" }, { - "id": "Baichuan4-Air", - "name": "Baichuan4-Air", - "display_name": "Baichuan4-Air", + "id": "gpt-4-vision-preview", + "name": "gpt-4-vision-preview", + "display_name": "gpt-4-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -253629,33 +253747,40 @@ "supported": false }, "cost": { - "input": 0.16, - "output": 0.16 + "input": 10, + "output": 30 }, "type": "chat" }, { - "id": "Baichuan4-Turbo", - "name": "Baichuan4-Turbo", - "display_name": "Baichuan4-Turbo", + "id": "gpt-4o-2024-05-13", + "name": "gpt-4o-2024-05-13", + "display_name": "gpt-4o-2024-05-13", "limit": { - "context": 8192, - "output": 8192 + "context": 128000, + "output": 128000 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 2.4, - "output": 2.4 + "input": 5, + "output": 15, + "cache_read": 5 }, "type": "chat" }, { - "id": "DeepSeek-v3", - "name": "DeepSeek-v3", - "display_name": "DeepSeek-v3", + "id": "gpt-4o-mini-2024-07-18", + "name": "gpt-4o-mini-2024-07-18", + "display_name": "gpt-4o-mini-2024-07-18", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -253665,34 +253790,51 @@ "supported": false }, "cost": { - "input": 0.272, - "output": 1.088 + "input": 0.15, + "output": 0.6, + "cache_read": 0.075 }, "type": "chat" }, { - "id": "Doubao-1.5-lite-32k", - "name": "Doubao-1.5-lite-32k", - "display_name": "Doubao-1.5-lite-32k", + "id": "gpt-oss-20b", + "name": "gpt-oss-20b", + "display_name": "gpt-oss-20b", + "modalities": { + "input": [ + "text" + ] + }, "limit": { - "context": 8192, - "output": 8192 + "context": 128000, + "output": 128000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "cost": { - "input": 0.05, - "output": 0.1, - "cache_read": 0.01 + "input": 0.11, + "output": 0.55 }, "type": "chat" }, { - "id": "Doubao-1.5-pro-256k", - "name": "Doubao-1.5-pro-256k", - "display_name": "Doubao-1.5-pro-256k", + "id": "grok-2-vision-1212", + "name": "grok-2-vision-1212", + "display_name": "grok-2-vision-1212", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -253702,16 +253844,21 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 1.44, - "cache_read": 0.8 + "input": 1.8, + "output": 9 }, "type": "chat" }, { - "id": "Doubao-1.5-pro-32k", - "name": "Doubao-1.5-pro-32k", - "display_name": "Doubao-1.5-pro-32k", + "id": "grok-vision-beta", + "name": "grok-vision-beta", + "display_name": "grok-vision-beta", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -253721,16 +253868,15 @@ "supported": false }, "cost": { - "input": 0.134, - "output": 0.335, - "cache_read": 0.0268 + "input": 5.6, + "output": 16.8 }, "type": "chat" }, { - "id": "Doubao-1.5-vision-pro-32k", - "name": "Doubao-1.5-vision-pro-32k", - "display_name": "Doubao-1.5-vision-pro-32k", + "id": "groq-llama-3.1-8b-instant", + "name": "groq-llama-3.1-8b-instant", + "display_name": "groq-llama-3.1-8b-instant", "limit": { "context": 8192, "output": 8192 @@ -253740,15 +253886,15 @@ "supported": false }, "cost": { - "input": 0.46, - "output": 1.38 + "input": 0.055, + "output": 0.088 }, "type": "chat" }, { - "id": "Doubao-lite-128k", - "name": "Doubao-lite-128k", - "display_name": "Doubao-lite-128k", + "id": "groq-llama-3.3-70b-versatile", + "name": "groq-llama-3.3-70b-versatile", + "display_name": "groq-llama-3.3-70b-versatile", "limit": { "context": 8192, "output": 8192 @@ -253758,16 +253904,15 @@ "supported": false }, "cost": { - "input": 0.14, - "output": 0.28, - "cache_read": 0.14 + "input": 0.649, + "output": 0.869011 }, "type": "chat" }, { - "id": "Doubao-lite-32k", - "name": "Doubao-lite-32k", - "display_name": "Doubao-lite-32k", + "id": "groq-llama-4-maverick-17b-128e-instruct", + "name": "groq-llama-4-maverick-17b-128e-instruct", + "display_name": "groq-llama-4-maverick-17b-128e-instruct", "limit": { "context": 8192, "output": 8192 @@ -253777,16 +253922,15 @@ "supported": false }, "cost": { - "input": 0.06, - "output": 0.12, - "cache_read": 0.012 + "input": 0.22, + "output": 0.66 }, "type": "chat" }, { - "id": "Doubao-lite-4k", - "name": "Doubao-lite-4k", - "display_name": "Doubao-lite-4k", + "id": "groq-llama-4-scout-17b-16e-instruct", + "name": "groq-llama-4-scout-17b-16e-instruct", + "display_name": "groq-llama-4-scout-17b-16e-instruct", "limit": { "context": 8192, "output": 8192 @@ -253796,16 +253940,20 @@ "supported": false }, "cost": { - "input": 0.06, - "output": 0.12, - "cache_read": 0.06 + "input": 0.122, + "output": 0.366 }, "type": "chat" }, { - "id": "Doubao-pro-128k", - "name": "Doubao-pro-128k", - "display_name": "Doubao-pro-128k", + "id": "jina-embeddings-v2-base-code", + "name": "jina-embeddings-v2-base-code", + "display_name": "jina-embeddings-v2-base-code", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -253815,15 +253963,15 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 1.44 + "input": 0.05, + "output": 0.05 }, - "type": "chat" + "type": "embedding" }, { - "id": "Doubao-pro-256k", - "name": "Doubao-pro-256k", - "display_name": "Doubao-pro-256k", + "id": "learnlm-1.5-pro-experimental", + "name": "learnlm-1.5-pro-experimental", + "display_name": "learnlm-1.5-pro-experimental", "limit": { "context": 8192, "output": 8192 @@ -253833,16 +253981,15 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 1.44, - "cache_read": 0.8 + "input": 1.25, + "output": 5 }, "type": "chat" }, { - "id": "Doubao-pro-32k", - "name": "Doubao-pro-32k", - "display_name": "Doubao-pro-32k", + "id": "llama-3.1-405b-instruct", + "name": "llama-3.1-405b-instruct", + "display_name": "llama-3.1-405b-instruct", "limit": { "context": 8192, "output": 8192 @@ -253852,16 +253999,15 @@ "supported": false }, "cost": { - "input": 0.14, - "output": 0.35, - "cache_read": 0.028 + "input": 4, + "output": 4 }, "type": "chat" }, { - "id": "Doubao-pro-4k", - "name": "Doubao-pro-4k", - "display_name": "Doubao-pro-4k", + "id": "llama-3.1-405b-reasoning", + "name": "llama-3.1-405b-reasoning", + "display_name": "llama-3.1-405b-reasoning", "limit": { "context": 8192, "output": 8192 @@ -253871,38 +254017,33 @@ "supported": false }, "cost": { - "input": 0.14, - "output": 0.35 + "input": 4, + "output": 4 }, "type": "chat" }, { - "id": "GPT-OSS-20B", - "name": "GPT-OSS-20B", - "display_name": "GPT-OSS-20B", + "id": "llama-3.1-70b-versatile", + "name": "llama-3.1-70b-versatile", + "display_name": "llama-3.1-70b-versatile", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 0.11, - "output": 0.55 + "input": 0.6, + "output": 0.6 }, "type": "chat" }, { - "id": "Gryphe/MythoMax-L2-13b", - "name": "Gryphe/MythoMax-L2-13b", - "display_name": "Gryphe/MythoMax-L2-13b", + "id": "llama-3.1-8b-instant", + "name": "llama-3.1-8b-instant", + "display_name": "llama-3.1-8b-instant", "limit": { "context": 8192, "output": 8192 @@ -253912,20 +254053,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.4 + "input": 0.3, + "output": 0.6 }, "type": "chat" }, { - "id": "MiniMax-Text-01", - "name": "MiniMax-Text-01", - "display_name": "MiniMax-Text-01", - "modalities": { - "input": [ - "text" - ] - }, + "id": "llama-3.1-sonar-small-128k-online", + "name": "llama-3.1-sonar-small-128k-online", + "display_name": "llama-3.1-sonar-small-128k-online", "limit": { "context": 8192, "output": 8192 @@ -253935,15 +254071,15 @@ "supported": false }, "cost": { - "input": 0.14, - "output": 1.12 + "input": 0.3, + "output": 0.3 }, "type": "chat" }, { - "id": "Mistral-large-2407", - "name": "Mistral-large-2407", - "display_name": "Mistral-large-2407", + "id": "llama-3.2-11b-vision-preview", + "name": "llama-3.2-11b-vision-preview", + "display_name": "llama-3.2-11b-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -253953,15 +254089,15 @@ "supported": false }, "cost": { - "input": 3, - "output": 9 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "Qwen/Qwen2-1.5B-Instruct", - "name": "Qwen/Qwen2-1.5B-Instruct", - "display_name": "Qwen/Qwen2-1.5B-Instruct", + "id": "llama-3.2-1b-preview", + "name": "llama-3.2-1b-preview", + "display_name": "llama-3.2-1b-preview", "limit": { "context": 8192, "output": 8192 @@ -253977,9 +254113,9 @@ "type": "chat" }, { - "id": "Qwen/Qwen2-57B-A14B-Instruct", - "name": "Qwen/Qwen2-57B-A14B-Instruct", - "display_name": "Qwen/Qwen2-57B-A14B-Instruct", + "id": "llama-3.2-3b-preview", + "name": "llama-3.2-3b-preview", + "display_name": "llama-3.2-3b-preview", "limit": { "context": 8192, "output": 8192 @@ -253989,15 +254125,15 @@ "supported": false }, "cost": { - "input": 0.24, - "output": 0.24 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "Qwen/Qwen2-72B-Instruct", - "name": "Qwen/Qwen2-72B-Instruct", - "display_name": "Qwen/Qwen2-72B-Instruct", + "id": "llama-3.2-90b-vision-preview", + "name": "llama-3.2-90b-vision-preview", + "display_name": "llama-3.2-90b-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -254007,15 +254143,15 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 0.8 + "input": 2.4, + "output": 2.4 }, "type": "chat" }, { - "id": "Qwen/Qwen2-7B-Instruct", - "name": "Qwen/Qwen2-7B-Instruct", - "display_name": "Qwen/Qwen2-7B-Instruct", + "id": "llama2-70b-4096", + "name": "llama2-70b-4096", + "display_name": "llama2-70b-4096", "limit": { "context": 8192, "output": 8192 @@ -254025,15 +254161,15 @@ "supported": false }, "cost": { - "input": 0.08, - "output": 0.08 + "input": 0.5, + "output": 0.5 }, "type": "chat" }, { - "id": "Qwen/Qwen2.5-32B-Instruct", - "name": "Qwen/Qwen2.5-32B-Instruct", - "display_name": "Qwen/Qwen2.5-32B-Instruct", + "id": "llama2-70b-40960", + "name": "llama2-70b-40960", + "display_name": "llama2-70b-40960", "limit": { "context": 8192, "output": 8192 @@ -254043,15 +254179,15 @@ "supported": false }, "cost": { - "input": 0.6, - "output": 0.6 + "input": 0.5, + "output": 0.5 }, "type": "chat" }, { - "id": "Qwen/Qwen2.5-72B-Instruct", - "name": "Qwen/Qwen2.5-72B-Instruct", - "display_name": "Qwen/Qwen2.5-72B-Instruct", + "id": "llama2-7b-2048", + "name": "llama2-7b-2048", + "display_name": "llama2-7b-2048", "limit": { "context": 8192, "output": 8192 @@ -254061,15 +254197,15 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 0.8 + "input": 0.1, + "output": 0.1 }, "type": "chat" }, { - "id": "Qwen/Qwen2.5-72B-Instruct-128K", - "name": "Qwen/Qwen2.5-72B-Instruct-128K", - "display_name": "Qwen/Qwen2.5-72B-Instruct-128K", + "id": "llama3-70b-8192", + "name": "llama3-70b-8192", + "display_name": "llama3-70b-8192", "limit": { "context": 8192, "output": 8192 @@ -254079,15 +254215,15 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 0.8 + "input": 0.7, + "output": 0.937288 }, "type": "chat" }, { - "id": "Qwen/Qwen2.5-7B-Instruct", - "name": "Qwen/Qwen2.5-7B-Instruct", - "display_name": "Qwen/Qwen2.5-7B-Instruct", + "id": "llama3-8b-8192", + "name": "llama3-8b-8192", + "display_name": "llama3-8b-8192", "limit": { "context": 8192, "output": 8192 @@ -254097,15 +254233,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.4 + "input": 0.06, + "output": 0.12 }, "type": "chat" }, { - "id": "Qwen/Qwen2.5-Coder-32B-Instruct", - "name": "Qwen/Qwen2.5-Coder-32B-Instruct", - "display_name": "Qwen/Qwen2.5-Coder-32B-Instruct", + "id": "llama3-groq-70b-8192-tool-use-preview", + "name": "llama3-groq-70b-8192-tool-use-preview", + "display_name": "llama3-groq-70b-8192-tool-use-preview", "limit": { "context": 8192, "output": 8192 @@ -254115,50 +254251,33 @@ "supported": false }, "cost": { - "input": 0.16, - "output": 0.16 + "input": 0.00089, + "output": 0.00089 }, "type": "chat" }, { - "id": "Qwen3-235B-A22B-Thinking-2507", - "name": "Qwen3-235B-A22B-Thinking-2507", - "display_name": "Qwen3-235B-A22B-Thinking-2507", + "id": "llama3-groq-8b-8192-tool-use-preview", + "name": "llama3-groq-8b-8192-tool-use-preview", + "display_name": "llama3-groq-8b-8192-tool-use-preview", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.28, - "output": 2.8 + "input": 0.00019, + "output": 0.00019 }, "type": "chat" }, { - "id": "Stable-Diffusion-3-5-Large", - "name": "Stable-Diffusion-3-5-Large", - "display_name": "Stable-Diffusion-3-5-Large", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "mai-image-2", + "name": "mai-image-2", + "display_name": "mai-image-2", "limit": { "context": 8192, "output": 8192 @@ -254168,16 +254287,16 @@ "supported": false }, "cost": { - "input": 4, - "output": 4, + "input": 2, + "output": 2, "cache_read": 0 }, "type": "imageGeneration" }, { - "id": "WizardLM/WizardCoder-Python-34B-V1.0", - "name": "WizardLM/WizardCoder-Python-34B-V1.0", - "display_name": "WizardLM/WizardCoder-Python-34B-V1.0", + "id": "meta-llama/Llama-3.2-90B-Vision-Instruct", + "name": "meta-llama/Llama-3.2-90B-Vision-Instruct", + "display_name": "meta-llama/Llama-3.2-90B-Vision-Instruct", "limit": { "context": 8192, "output": 8192 @@ -254187,15 +254306,15 @@ "supported": false }, "cost": { - "input": 0.9, - "output": 0.9 + "input": 0.5, + "output": 0.5 }, "type": "chat" }, { - "id": "ahm-Phi-3-5-MoE-instruct", - "name": "ahm-Phi-3-5-MoE-instruct", - "display_name": "ahm-Phi-3-5-MoE-instruct", + "id": "meta-llama/llama-3.1-405b-instruct:free", + "name": "meta-llama/llama-3.1-405b-instruct:free", + "display_name": "meta-llama/llama-3.1-405b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -254205,15 +254324,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 1.6 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "ahm-Phi-3-5-mini-instruct", - "name": "ahm-Phi-3-5-mini-instruct", - "display_name": "ahm-Phi-3-5-mini-instruct", + "id": "meta-llama/llama-3.1-70b-instruct:free", + "name": "meta-llama/llama-3.1-70b-instruct:free", + "display_name": "meta-llama/llama-3.1-70b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -254223,21 +254342,15 @@ "supported": false }, "cost": { - "input": 1, - "output": 3 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "ahm-Phi-3-5-vision-instruct", - "name": "ahm-Phi-3-5-vision-instruct", - "display_name": "ahm-Phi-3-5-vision-instruct", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "meta-llama/llama-3.1-8b-instruct:free", + "name": "meta-llama/llama-3.1-8b-instruct:free", + "display_name": "meta-llama/llama-3.1-8b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -254247,15 +254360,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 1.6 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "ahm-Phi-3-medium-128k", - "name": "ahm-Phi-3-medium-128k", - "display_name": "ahm-Phi-3-medium-128k", + "id": "meta-llama/llama-3.2-11b-vision-instruct:free", + "name": "meta-llama/llama-3.2-11b-vision-instruct:free", + "display_name": "meta-llama/llama-3.2-11b-vision-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -254265,15 +254378,15 @@ "supported": false }, "cost": { - "input": 6, - "output": 18 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "ahm-Phi-3-medium-4k", - "name": "ahm-Phi-3-medium-4k", - "display_name": "ahm-Phi-3-medium-4k", + "id": "meta-llama/llama-3.2-3b-instruct:free", + "name": "meta-llama/llama-3.2-3b-instruct:free", + "display_name": "meta-llama/llama-3.2-3b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -254283,15 +254396,15 @@ "supported": false }, "cost": { - "input": 1, - "output": 3 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "ahm-Phi-3-small-128k", - "name": "ahm-Phi-3-small-128k", - "display_name": "ahm-Phi-3-small-128k", + "id": "meta/llama-3.1-405b-instruct", + "name": "meta/llama-3.1-405b-instruct", + "display_name": "meta/llama-3.1-405b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254301,15 +254414,15 @@ "supported": false }, "cost": { - "input": 1, - "output": 3 + "input": 5, + "output": 5 }, "type": "chat" }, { - "id": "aihubmix-Codestral-2501", - "name": "aihubmix-Codestral-2501", - "display_name": "aihubmix-Codestral-2501", + "id": "meta/llama3-8B-chat", + "name": "meta/llama3-8B-chat", + "display_name": "meta/llama3-8B-chat", "limit": { "context": 8192, "output": 8192 @@ -254319,20 +254432,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 1.2 + "input": 0.3, + "output": 0.3 }, "type": "chat" }, { - "id": "aihubmix-Cohere-command-r", - "name": "aihubmix-Cohere-command-r", - "display_name": "aihubmix-Cohere-command-r", - "modalities": { - "input": [ - "text" - ] - }, + "id": "mistralai/mistral-7b-instruct:free", + "name": "mistralai/mistral-7b-instruct:free", + "display_name": "mistralai/mistral-7b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -254342,15 +254450,15 @@ "supported": false }, "cost": { - "input": 0.64, - "output": 1.92 + "input": 0.002, + "output": 0.002 }, "type": "chat" }, { - "id": "aihubmix-Jamba-1-5-Large", - "name": "aihubmix-Jamba-1-5-Large", - "display_name": "aihubmix-Jamba-1-5-Large", + "id": "mm-minimax-m3", + "name": "mm-minimax-m3", + "display_name": "mm-minimax-m3", "limit": { "context": 8192, "output": 8192 @@ -254360,15 +254468,15 @@ "supported": false }, "cost": { - "input": 2.2, - "output": 8.8 + "input": 0.288, + "output": 1.152 }, "type": "chat" }, { - "id": "aihubmix-Llama-3-1-405B-Instruct", - "name": "aihubmix-Llama-3-1-405B-Instruct", - "display_name": "aihubmix-Llama-3-1-405B-Instruct", + "id": "moonshot-kimi-k2.5", + "name": "moonshot-kimi-k2.5", + "display_name": "moonshot-kimi-k2.5", "limit": { "context": 8192, "output": 8192 @@ -254378,15 +254486,16 @@ "supported": false }, "cost": { - "input": 5, - "output": 15 + "input": 0.6, + "output": 3, + "cache_read": 0.105 }, "type": "chat" }, { - "id": "aihubmix-Llama-3-1-70B-Instruct", - "name": "aihubmix-Llama-3-1-70B-Instruct", - "display_name": "aihubmix-Llama-3-1-70B-Instruct", + "id": "moonshot-v1-128k", + "name": "moonshot-v1-128k", + "display_name": "moonshot-v1-128k", "limit": { "context": 8192, "output": 8192 @@ -254396,15 +254505,15 @@ "supported": false }, "cost": { - "input": 0.6, - "output": 0.78 + "input": 10, + "output": 10 }, "type": "chat" }, { - "id": "aihubmix-Llama-3-1-8B-Instruct", - "name": "aihubmix-Llama-3-1-8B-Instruct", - "display_name": "aihubmix-Llama-3-1-8B-Instruct", + "id": "moonshot-v1-128k-vision-preview", + "name": "moonshot-v1-128k-vision-preview", + "display_name": "moonshot-v1-128k-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -254414,15 +254523,15 @@ "supported": false }, "cost": { - "input": 0.3, - "output": 0.6 + "input": 10, + "output": 10 }, "type": "chat" }, { - "id": "aihubmix-Llama-3-2-11B-Vision", - "name": "aihubmix-Llama-3-2-11B-Vision", - "display_name": "aihubmix-Llama-3-2-11B-Vision", + "id": "moonshot-v1-32k", + "name": "moonshot-v1-32k", + "display_name": "moonshot-v1-32k", "limit": { "context": 8192, "output": 8192 @@ -254432,15 +254541,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.4 + "input": 4, + "output": 4 }, "type": "chat" }, { - "id": "aihubmix-Llama-3-2-90B-Vision", - "name": "aihubmix-Llama-3-2-90B-Vision", - "display_name": "aihubmix-Llama-3-2-90B-Vision", + "id": "moonshot-v1-32k-vision-preview", + "name": "moonshot-v1-32k-vision-preview", + "display_name": "moonshot-v1-32k-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -254450,15 +254559,15 @@ "supported": false }, "cost": { - "input": 2.4, - "output": 2.4 + "input": 4, + "output": 4 }, "type": "chat" }, { - "id": "aihubmix-Llama-3-70B-Instruct", - "name": "aihubmix-Llama-3-70B-Instruct", - "display_name": "aihubmix-Llama-3-70B-Instruct", + "id": "moonshot-v1-8k", + "name": "moonshot-v1-8k", + "display_name": "moonshot-v1-8k", "limit": { "context": 8192, "output": 8192 @@ -254468,15 +254577,15 @@ "supported": false }, "cost": { - "input": 0.7, - "output": 0.7 + "input": 2, + "output": 2 }, "type": "chat" }, { - "id": "aihubmix-Mistral-large", - "name": "aihubmix-Mistral-large", - "display_name": "aihubmix-Mistral-large", + "id": "moonshot-v1-8k-vision-preview", + "name": "moonshot-v1-8k-vision-preview", + "display_name": "moonshot-v1-8k-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -254486,20 +254595,15 @@ "supported": false }, "cost": { - "input": 4, - "output": 12 + "input": 2, + "output": 2 }, "type": "chat" }, { - "id": "aihubmix-command-r-08-2024", - "name": "aihubmix-command-r-08-2024", - "display_name": "aihubmix-command-r-08-2024", - "modalities": { - "input": [ - "text" - ] - }, + "id": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", + "name": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", + "display_name": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", "limit": { "context": 8192, "output": 8192 @@ -254509,20 +254613,50 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.8 + "input": 0.5, + "output": 0.5, + "cache_read": 0 }, "type": "chat" }, { - "id": "aihubmix-command-r-plus", - "name": "aihubmix-command-r-plus", - "display_name": "aihubmix-command-r-plus", - "modalities": { - "input": [ - "text" - ] + "id": "o1-mini-2024-09-12", + "name": "o1-mini-2024-09-12", + "display_name": "o1-mini-2024-09-12", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "cost": { + "input": 3, + "output": 12, + "cache_read": 1.5 }, + "type": "chat" + }, + { + "id": "omni-moderation-latest", + "name": "omni-moderation-latest", + "display_name": "omni-moderation-latest", "limit": { "context": 8192, "output": 8192 @@ -254532,57 +254666,75 @@ "supported": false }, "cost": { - "input": 3.84, - "output": 19.2 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "aihubmix-command-r-plus-08-2024", - "name": "aihubmix-command-r-plus-08-2024", - "display_name": "aihubmix-command-r-plus-08-2024", - "modalities": { - "input": [ - "text" - ] - }, + "id": "qwen-flash", + "name": "qwen-flash", + "display_name": "qwen-flash", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 2.8, - "output": 11.2 + "input": 0.02, + "output": 0.2, + "cache_read": 0.02 }, "type": "chat" }, { - "id": "alicloud-deepseek-v3.2", - "name": "alicloud-deepseek-v3.2", - "display_name": "alicloud-deepseek-v3.2", + "id": "qwen-flash-2025-07-28", + "name": "qwen-flash-2025-07-28", + "display_name": "qwen-flash-2025-07-28", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 0.274, - "output": 0.411, - "cache_read": 0.0548 + "input": 0.02, + "output": 0.2, + "cache_read": 0.02 }, "type": "chat" }, { - "id": "alicloud-glm-4.7", - "name": "alicloud-glm-4.7", - "display_name": "alicloud-glm-4.7", + "id": "qwen-long", + "name": "qwen-long", + "display_name": "qwen-long", "limit": { "context": 8192, "output": 8192 @@ -254592,16 +254744,15 @@ "supported": false }, "cost": { - "input": 0.41096, - "output": 1.917786, - "cache_read": 0.41096 + "input": 0.1, + "output": 0.4 }, "type": "chat" }, { - "id": "alicloud-kimi-k2-thinking", - "name": "alicloud-kimi-k2-thinking", - "display_name": "alicloud-kimi-k2-thinking", + "id": "qwen-max", + "name": "qwen-max", + "display_name": "qwen-max", "limit": { "context": 8192, "output": 8192 @@ -254611,84 +254762,103 @@ "supported": false }, "cost": { - "input": 0.548, - "output": 2.192 + "input": 0.38, + "output": 1.52 }, "type": "chat" }, { - "id": "alicloud-kimi-k2.5", - "name": "alicloud-kimi-k2.5", - "display_name": "alicloud-kimi-k2.5", + "id": "qwen-max-longcontext", + "name": "qwen-max-longcontext", + "display_name": "qwen-max-longcontext", "limit": { - "context": 256000, - "output": 256000 + "context": 8192, + "output": 8192 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 0.548, - "output": 2.877, - "cache_read": 0.0959 + "input": 7, + "output": 21 }, "type": "chat" }, { - "id": "alicloud-minimax-m2.5", - "name": "alicloud-minimax-m2.5", - "display_name": "alicloud-minimax-m2.5", + "id": "qwen-plus", + "name": "qwen-plus", + "display_name": "qwen-plus", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 0.2876, - "output": 1.1504, - "cache_read": 0.05752 + "input": 0.1126, + "output": 1.126, + "cache_read": 0.02252 }, "type": "chat" }, { - "id": "anthropic-opus-4-6", - "name": "anthropic-opus-4-6", - "display_name": "anthropic-opus-4-6", + "id": "qwen-turbo", + "name": "qwen-turbo", + "display_name": "qwen-turbo", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { - "context": 200000, - "output": 200000 + "context": 8192, + "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true + "supported": true }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5 + "input": 0.046, + "output": 0.092, + "cache_read": 0.0092 }, "type": "chat" }, { - "id": "azure-deepseek-v3.2", - "name": "azure-deepseek-v3.2", - "display_name": "azure-deepseek-v3.2", + "id": "qwen-turbo-2024-11-01", + "name": "qwen-turbo-2024-11-01", + "display_name": "qwen-turbo-2024-11-01", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -254698,15 +254868,15 @@ "supported": false }, "cost": { - "input": 0.58, - "output": 1.680028 + "input": 0.046, + "output": 0.092 }, "type": "chat" }, { - "id": "azure-deepseek-v3.2-speciale", - "name": "azure-deepseek-v3.2-speciale", - "display_name": "azure-deepseek-v3.2-speciale", + "id": "qwen2.5-14b-instruct", + "name": "qwen2.5-14b-instruct", + "display_name": "qwen2.5-14b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254716,18 +254886,18 @@ "supported": false }, "cost": { - "input": 0.58, - "output": 1.680028 + "input": 0.4, + "output": 1.2 }, "type": "chat" }, { - "id": "azure-kimi-k2.5", - "name": "azure-kimi-k2.5", - "display_name": "azure-kimi-k2.5", + "id": "qwen2.5-32b-instruct", + "name": "qwen2.5-32b-instruct", + "display_name": "qwen2.5-32b-instruct", "limit": { - "context": 256000, - "output": 256000 + "context": 8192, + "output": 8192 }, "tool_call": false, "reasoning": { @@ -254735,14 +254905,14 @@ }, "cost": { "input": 0.6, - "output": 3 + "output": 1.2 }, "type": "chat" }, { - "id": "cbs-glm-4.7", - "name": "cbs-glm-4.7", - "display_name": "cbs-glm-4.7", + "id": "qwen2.5-3b-instruct", + "name": "qwen2.5-3b-instruct", + "display_name": "qwen2.5-3b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254752,15 +254922,15 @@ "supported": false }, "cost": { - "input": 2.25, - "output": 2.749995 + "input": 0.4, + "output": 0.8 }, "type": "chat" }, { - "id": "cerebras-llama-3.3-70b", - "name": "cerebras-llama-3.3-70b", - "display_name": "cerebras-llama-3.3-70b", + "id": "qwen2.5-72b-instruct", + "name": "qwen2.5-72b-instruct", + "display_name": "qwen2.5-72b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254770,15 +254940,15 @@ "supported": false }, "cost": { - "input": 0.6, - "output": 0.6 + "input": 0.8, + "output": 2.4 }, "type": "chat" }, { - "id": "chatglm_lite", - "name": "chatglm_lite", - "display_name": "chatglm_lite", + "id": "qwen2.5-7b-instruct", + "name": "qwen2.5-7b-instruct", + "display_name": "qwen2.5-7b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254788,15 +254958,15 @@ "supported": false }, "cost": { - "input": 0.2858, - "output": 0.2858 + "input": 0.4, + "output": 0.8 }, "type": "chat" }, { - "id": "chatglm_pro", - "name": "chatglm_pro", - "display_name": "chatglm_pro", + "id": "qwen2.5-coder-1.5b-instruct", + "name": "qwen2.5-coder-1.5b-instruct", + "display_name": "qwen2.5-coder-1.5b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254806,15 +254976,15 @@ "supported": false }, "cost": { - "input": 1.4286, - "output": 1.4286 + "input": 0.2, + "output": 0.4 }, "type": "chat" }, { - "id": "chatglm_std", - "name": "chatglm_std", - "display_name": "chatglm_std", + "id": "qwen2.5-coder-7b-instruct", + "name": "qwen2.5-coder-7b-instruct", + "display_name": "qwen2.5-coder-7b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254824,15 +254994,15 @@ "supported": false }, "cost": { - "input": 0.7144, - "output": 0.7144 + "input": 0.2, + "output": 0.4 }, "type": "chat" }, { - "id": "chatglm_turbo", - "name": "chatglm_turbo", - "display_name": "chatglm_turbo", + "id": "qwen2.5-math-1.5b-instruct", + "name": "qwen2.5-math-1.5b-instruct", + "display_name": "qwen2.5-math-1.5b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254842,15 +255012,15 @@ "supported": false }, "cost": { - "input": 0.7144, - "output": 0.7144 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "claude-2", - "name": "claude-2", - "display_name": "claude-2", + "id": "qwen2.5-math-72b-instruct", + "name": "qwen2.5-math-72b-instruct", + "display_name": "qwen2.5-math-72b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254860,15 +255030,15 @@ "supported": false }, "cost": { - "input": 8.8, - "output": 8.8 + "input": 0.8, + "output": 2.4 }, "type": "chat" }, { - "id": "claude-2.0", - "name": "claude-2.0", - "display_name": "claude-2.0", + "id": "qwen2.5-math-7b-instruct", + "name": "qwen2.5-math-7b-instruct", + "display_name": "qwen2.5-math-7b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254878,15 +255048,15 @@ "supported": false }, "cost": { - "input": 8.8, - "output": 39.6 + "input": 0.2, + "output": 0.4 }, "type": "chat" }, { - "id": "claude-2.1", - "name": "claude-2.1", - "display_name": "claude-2.1", + "id": "step-2-16k", + "name": "step-2-16k", + "display_name": "step-2-16k", "limit": { "context": 8192, "output": 8192 @@ -254896,19 +255066,18 @@ "supported": false }, "cost": { - "input": 8.8, - "output": 39.6 + "input": 2, + "output": 2 }, "type": "chat" }, { - "id": "claude-3-haiku-20240229", - "name": "claude-3-haiku-20240229", - "display_name": "claude-3-haiku-20240229", + "id": "tts-1-hd-1106", + "name": "tts-1-hd-1106", + "display_name": "tts-1-hd-1106", "modalities": { "input": [ - "text", - "image" + "audio" ] }, "limit": { @@ -254920,19 +255089,17 @@ "supported": false }, "cost": { - "input": 0.275, - "output": 0.275 - }, - "type": "chat" + "input": 30, + "output": 30 + } }, { - "id": "claude-3-haiku-20240307", - "name": "claude-3-haiku-20240307", - "display_name": "claude-3-haiku-20240307", + "id": "tts-1-hd", + "name": "tts-1-hd", + "display_name": "tts-1-hd", "modalities": { "input": [ - "text", - "image" + "audio" ] }, "limit": { @@ -254944,19 +255111,17 @@ "supported": false }, "cost": { - "input": 0.275, - "output": 1.375 - }, - "type": "chat" + "input": 30, + "output": 30 + } }, { - "id": "claude-3-sonnet-20240229", - "name": "claude-3-sonnet-20240229", - "display_name": "claude-3-sonnet-20240229", + "id": "tts-1-1106", + "name": "tts-1-1106", + "display_name": "tts-1-1106", "modalities": { "input": [ - "text", - "image" + "audio" ] }, "limit": { @@ -254968,15 +255133,19 @@ "supported": false }, "cost": { - "input": 3.3, - "output": 16.5 - }, - "type": "chat" + "input": 15, + "output": 15 + } }, { - "id": "claude-instant-1", - "name": "claude-instant-1", - "display_name": "claude-instant-1", + "id": "tts-1", + "name": "tts-1", + "display_name": "tts-1", + "modalities": { + "input": [ + "audio" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -254986,15 +255155,14 @@ "supported": false }, "cost": { - "input": 1.793, - "output": 1.793 - }, - "type": "chat" + "input": 15, + "output": 15 + } }, { - "id": "claude-instant-1.2", - "name": "claude-instant-1.2", - "display_name": "claude-instant-1.2", + "id": "text-search-ada-doc-001", + "name": "text-search-ada-doc-001", + "display_name": "text-search-ada-doc-001", "limit": { "context": 8192, "output": 8192 @@ -255004,15 +255172,15 @@ "supported": false }, "cost": { - "input": 0.88, - "output": 3.96 + "input": 20, + "output": 20 }, "type": "chat" }, { - "id": "code-davinci-edit-001", - "name": "code-davinci-edit-001", - "display_name": "code-davinci-edit-001", + "id": "text-moderation-007", + "name": "text-moderation-007", + "display_name": "text-moderation-007", "limit": { "context": 8192, "output": 8192 @@ -255022,15 +255190,20 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "cogview-3", - "name": "cogview-3", - "display_name": "cogview-3", + "id": "text-embedding-ada-002", + "name": "text-embedding-ada-002", + "display_name": "text-embedding-ada-002", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -255040,15 +255213,20 @@ "supported": false }, "cost": { - "input": 35.5, - "output": 35.5 + "input": 0.1, + "output": 0.1 }, - "type": "chat" + "type": "embedding" }, { - "id": "cogview-3-plus", - "name": "cogview-3-plus", - "display_name": "cogview-3-plus", + "id": "text-embedding-3-small", + "name": "text-embedding-3-small", + "display_name": "text-embedding-3-small", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -255058,15 +255236,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 10 + "input": 0.02, + "output": 0.02 }, - "type": "chat" + "type": "embedding" }, { - "id": "command", - "name": "command", - "display_name": "command", + "id": "text-embedding-3-large", + "name": "text-embedding-3-large", + "display_name": "text-embedding-3-large", "modalities": { "input": [ "text" @@ -255081,15 +255259,15 @@ "supported": false }, "cost": { - "input": 1, - "output": 2 + "input": 0.13, + "output": 0.13 }, - "type": "chat" + "type": "embedding" }, { - "id": "command-light", - "name": "command-light", - "display_name": "command-light", + "id": "text-moderation-stable", + "name": "text-moderation-stable", + "display_name": "text-moderation-stable", "limit": { "context": 8192, "output": 8192 @@ -255099,15 +255277,15 @@ "supported": false }, "cost": { - "input": 1, - "output": 2 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "command-light-nightly", - "name": "command-light-nightly", - "display_name": "command-light-nightly", + "id": "text-davinci-edit-001", + "name": "text-davinci-edit-001", + "display_name": "text-davinci-edit-001", "limit": { "context": 8192, "output": 8192 @@ -255117,15 +255295,20 @@ "supported": false }, "cost": { - "input": 1, - "output": 2 + "input": 20, + "output": 20 }, "type": "chat" }, { - "id": "command-nightly", - "name": "command-nightly", - "display_name": "command-nightly", + "id": "whisper-1", + "name": "whisper-1", + "display_name": "whisper-1", + "modalities": { + "input": [ + "audio" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -255135,18 +255318,18 @@ "supported": false }, "cost": { - "input": 1, - "output": 2 + "input": 100, + "output": 100 }, "type": "chat" }, { - "id": "command-r", - "name": "command-r", - "display_name": "command-r", + "id": "whisper-large-v3", + "name": "whisper-large-v3", + "display_name": "whisper-large-v3", "modalities": { "input": [ - "text" + "audio" ] }, "limit": { @@ -255158,18 +255341,18 @@ "supported": false }, "cost": { - "input": 0.64, - "output": 1.92 + "input": 30.834, + "output": 30.834 }, "type": "chat" }, { - "id": "command-r-08-2024", - "name": "command-r-08-2024", - "display_name": "command-r-08-2024", + "id": "whisper-large-v3-turbo", + "name": "whisper-large-v3-turbo", + "display_name": "whisper-large-v3-turbo", "modalities": { "input": [ - "text" + "audio" ] }, "limit": { @@ -255181,20 +255364,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.8 + "input": 5.556, + "output": 5.556 }, "type": "chat" }, { - "id": "command-r-plus", - "name": "command-r-plus", - "display_name": "command-r-plus", - "modalities": { - "input": [ - "text" - ] - }, + "id": "text-davinci-003", + "name": "text-davinci-003", + "display_name": "text-davinci-003", "limit": { "context": 8192, "output": 8192 @@ -255204,20 +255382,15 @@ "supported": false }, "cost": { - "input": 3.84, - "output": 19.2 + "input": 20, + "output": 20 }, "type": "chat" }, { - "id": "command-r-plus-08-2024", - "name": "command-r-plus-08-2024", - "display_name": "command-r-plus-08-2024", - "modalities": { - "input": [ - "text" - ] - }, + "id": "text-davinci-002", + "name": "text-davinci-002", + "display_name": "text-davinci-002", "limit": { "context": 8192, "output": 8192 @@ -255227,21 +255400,15 @@ "supported": false }, "cost": { - "input": 2.8, - "output": 11.2 + "input": 20, + "output": 20 }, "type": "chat" }, { - "id": "dall-e-2", - "name": "dall-e-2", - "display_name": "dall-e-2", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "text-curie-001", + "name": "text-curie-001", + "display_name": "text-curie-001", "limit": { "context": 8192, "output": 8192 @@ -255251,15 +255418,15 @@ "supported": false }, "cost": { - "input": 16, - "output": 16 + "input": 2, + "output": 2 }, - "type": "imageGeneration" + "type": "chat" }, { - "id": "davinci", - "name": "davinci", - "display_name": "davinci", + "id": "text-babbage-001", + "name": "text-babbage-001", + "display_name": "text-babbage-001", "limit": { "context": 8192, "output": 8192 @@ -255269,15 +255436,15 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.5, + "output": 0.5 }, "type": "chat" }, { - "id": "davinci-002", - "name": "davinci-002", - "display_name": "davinci-002", + "id": "text-ada-001", + "name": "text-ada-001", + "display_name": "text-ada-001", "limit": { "context": 8192, "output": 8192 @@ -255287,15 +255454,15 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 0.4, + "output": 0.4 }, "type": "chat" }, { - "id": "deepinfra-llama-3.1-8b-instant", - "name": "deepinfra-llama-3.1-8b-instant", - "display_name": "deepinfra-llama-3.1-8b-instant", + "id": "text-moderation-latest", + "name": "text-moderation-latest", + "display_name": "text-moderation-latest", "limit": { "context": 8192, "output": 8192 @@ -255305,15 +255472,15 @@ "supported": false }, "cost": { - "input": 0.033, - "output": 0.054978 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "deepinfra-llama-3.3-70b-instant-turbo", - "name": "deepinfra-llama-3.3-70b-instant-turbo", - "display_name": "deepinfra-llama-3.3-70b-instant-turbo", + "id": "yi-large", + "name": "yi-large", + "display_name": "yi-large", "limit": { "context": 8192, "output": 8192 @@ -255323,15 +255490,15 @@ "supported": false }, "cost": { - "input": 0.11, - "output": 0.352 + "input": 3, + "output": 3 }, "type": "chat" }, { - "id": "deepinfra-llama-4-maverick-17b-128e-instruct", - "name": "deepinfra-llama-4-maverick-17b-128e-instruct", - "display_name": "deepinfra-llama-4-maverick-17b-128e-instruct", + "id": "yi-large-rag", + "name": "yi-large-rag", + "display_name": "yi-large-rag", "limit": { "context": 8192, "output": 8192 @@ -255341,15 +255508,15 @@ "supported": false }, "cost": { - "input": 0.33, - "output": 1.32 + "input": 4, + "output": 4 }, "type": "chat" }, { - "id": "deepinfra-llama-4-scout-17b-16e-instruct", - "name": "deepinfra-llama-4-scout-17b-16e-instruct", - "display_name": "deepinfra-llama-4-scout-17b-16e-instruct", + "id": "yi-large-turbo", + "name": "yi-large-turbo", + "display_name": "yi-large-turbo", "limit": { "context": 8192, "output": 8192 @@ -255359,16 +255526,15 @@ "supported": false }, "cost": { - "input": 0.088, - "output": 0.33, - "cache_read": 0 + "input": 1.8, + "output": 1.8 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-Coder-V2-Instruct", - "name": "deepseek-ai/DeepSeek-Coder-V2-Instruct", - "display_name": "deepseek-ai/DeepSeek-Coder-V2-Instruct", + "id": "yi-lightning", + "name": "yi-lightning", + "display_name": "yi-lightning", "limit": { "context": 8192, "output": 8192 @@ -255378,38 +255544,33 @@ "supported": false }, "cost": { - "input": 0.16, - "output": 0.32 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + "id": "yi-medium", + "name": "yi-medium", + "display_name": "yi-medium", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 0.6, - "output": 0.6 + "input": 0.4, + "output": 0.4 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", + "id": "yi-vl-plus", + "name": "yi-vl-plus", + "display_name": "yi-vl-plus", "limit": { "context": 8192, "output": 8192 @@ -255419,15 +255580,20 @@ "supported": false }, "cost": { - "input": 0.01, - "output": 0.01 + "input": 0.000852, + "output": 0.000852 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", + "id": "text-embedding-v1", + "name": "text-embedding-v1", + "display_name": "text-embedding-v1", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -255437,15 +255603,15 @@ "supported": false }, "cost": { - "input": 0.01, - "output": 0.01 + "input": 0.1, + "output": 0.1 }, - "type": "chat" + "type": "embedding" }, { - "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", + "id": "meta-llama-3-70b", + "name": "meta-llama-3-70b", + "display_name": "meta-llama-3-70b", "limit": { "context": 8192, "output": 8192 @@ -255455,15 +255621,15 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 4.795, + "output": 4.795 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", + "id": "meta-llama-3-8b", + "name": "meta-llama-3-8b", + "display_name": "meta-llama-3-8b", "limit": { "context": 8192, "output": 8192 @@ -255473,33 +255639,116 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 0.548, + "output": 0.548 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", + "id": "o3-global", + "name": "o3-global", + "display_name": "o3-global", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } }, "cost": { - "input": 0.01, - "output": 0.01 + "input": 2, + "output": 8, + "cache_read": 0.5 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-V2-Chat", - "name": "deepseek-ai/DeepSeek-V2-Chat", - "display_name": "deepseek-ai/DeepSeek-V2-Chat", + "id": "o3-mini-global", + "name": "o3-mini-global", + "display_name": "o3-mini-global", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "cost": { + "input": 1.1, + "output": 4.4, + "cache_read": 0.55 + }, + "type": "chat" + }, + { + "id": "o3-pro-global", + "name": "o3-pro-global", + "display_name": "o3-pro-global", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "cost": { + "input": 20, + "output": 80 + }, + "type": "chat" + }, + { + "id": "qianfan-chinese-llama-2-13b", + "name": "qianfan-chinese-llama-2-13b", + "display_name": "qianfan-chinese-llama-2-13b", "limit": { "context": 8192, "output": 8192 @@ -255509,15 +255758,15 @@ "supported": false }, "cost": { - "input": 0.16, - "output": 0.32 + "input": 0.822, + "output": 0.822 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-V2.5", - "name": "deepseek-ai/DeepSeek-V2.5", - "display_name": "deepseek-ai/DeepSeek-V2.5", + "id": "qianfan-llama-vl-8b", + "name": "qianfan-llama-vl-8b", + "display_name": "qianfan-llama-vl-8b", "limit": { "context": 8192, "output": 8192 @@ -255527,8 +255776,8 @@ "supported": false }, "cost": { - "input": 0.16, - "output": 0.32 + "input": 0.274, + "output": 0.685 }, "type": "chat" }, @@ -255623,179 +255872,6 @@ "cache_read": 0.075 }, "type": "chat" - }, - { - "id": "meta-llama-3-70b", - "name": "meta-llama-3-70b", - "display_name": "meta-llama-3-70b", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 4.795, - "output": 4.795 - }, - "type": "chat" - }, - { - "id": "meta-llama-3-8b", - "name": "meta-llama-3-8b", - "display_name": "meta-llama-3-8b", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.548, - "output": 0.548 - }, - "type": "chat" - }, - { - "id": "o3-global", - "name": "o3-global", - "display_name": "o3-global", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "cost": { - "input": 2, - "output": 8, - "cache_read": 0.5 - }, - "type": "chat" - }, - { - "id": "o3-mini-global", - "name": "o3-mini-global", - "display_name": "o3-mini-global", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "cost": { - "input": 1.1, - "output": 4.4, - "cache_read": 0.55 - }, - "type": "chat" - }, - { - "id": "o3-pro-global", - "name": "o3-pro-global", - "display_name": "o3-pro-global", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "cost": { - "input": 20, - "output": 80 - }, - "type": "chat" - }, - { - "id": "qianfan-chinese-llama-2-13b", - "name": "qianfan-chinese-llama-2-13b", - "display_name": "qianfan-chinese-llama-2-13b", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.822, - "output": 0.822 - }, - "type": "chat" - }, - { - "id": "qianfan-llama-vl-8b", - "name": "qianfan-llama-vl-8b", - "display_name": "qianfan-llama-vl-8b", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.274, - "output": 0.685 - }, - "type": "chat" } ] }, @@ -258287,7 +258363,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 16384 }, "temperature": true, "tool_call": true, @@ -258660,6 +258736,34 @@ }, "type": "chat" }, + { + "id": "meituan/longcat-2.0", + "name": "Meituan: LongCat 2.0", + "display_name": "Meituan: LongCat 2.0", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048756, + "output": 262144 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, { "id": "meta-llama/llama-3.1-70b-instruct", "name": "Meta: Llama 3.1 70B Instruct", @@ -260066,7 +260170,7 @@ }, "limit": { "context": 262144, - "output": 16384 + "output": 262144 }, "temperature": true, "tool_call": true, @@ -263558,8 +263662,8 @@ ] }, "limit": { - "context": 262144, - "output": 262144 + "context": 131072, + "output": 32768 }, "tool_call": true, "reasoning": { @@ -264599,7 +264703,7 @@ }, "limit": { "context": 262144, - "output": 131072 + "output": 262144 }, "temperature": true, "tool_call": true, @@ -264643,30 +264747,6 @@ }, "type": "chat" }, - { - "id": "tencent/hy3:free", - "name": "Tencent: Hy3 (free)", - "display_name": "Tencent: Hy3 (free)", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 262144, - "output": 262144 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "type": "chat" - }, { "id": "thedrummer/cydonia-24b-v4.1", "name": "TheDrummer: Cydonia 24B V4.1", @@ -267924,29 +268004,6 @@ }, "type": "chat" }, - { - "id": "gpt-5-chat-latest", - "name": "gpt-5-chat-latest", - "display_name": "gpt-5-chat-latest", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 400000, - "output": 128000 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "gpt-5", "name": "gpt-5", @@ -268800,31 +268857,6 @@ }, "type": "chat" }, - { - "id": "gemini-2.0-flash-lite", - "name": "gemini-2.0-flash-lite", - "display_name": "gemini-2.0-flash-lite", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1048576, - "output": 8192 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "google/gemma-3-27b-it", "name": "Gemma 3 27B", diff --git a/src/main/agent/acp/runtime/acpSessionPersistence.ts b/src/main/agent/acp/runtime/acpSessionPersistence.ts index 4942afb079..85b0f3a412 100644 --- a/src/main/agent/acp/runtime/acpSessionPersistence.ts +++ b/src/main/agent/acp/runtime/acpSessionPersistence.ts @@ -42,7 +42,8 @@ export class AcpSessionPersistence { constructor( private readonly agentDatabase: AgentDatabase, private readonly sessionDatabase: SessionDatabase, - private readonly projectDatabase: ProjectDatabase + private readonly projectDatabase: ProjectDatabase, + private readonly notifyEnvironmentProjectionChanged: () => void = () => undefined ) {} async getSessionData(conversationId: string, agentId: string): Promise{ @@ -335,6 +336,7 @@ export class AcpSessionPersistence { for (const environmentPath of paths) { this.projectDatabase.newEnvironmentsTable.syncPath(environmentPath) } + this.notifyEnvironmentProjectionChanged() } async clearSession(conversationId: string, agentId: string): Promise { diff --git a/src/main/agent/data/database.ts b/src/main/agent/data/database.ts index 18d4483e33..d58fe05c4c 100644 --- a/src/main/agent/data/database.ts +++ b/src/main/agent/data/database.ts @@ -126,7 +126,9 @@ export class AgentDatabase { for (const [from, to] of entries) { if (hasNewSessions) { - db.prepare('UPDATE new_sessions SET agent_id = ? WHERE agent_id = ?').run(to, from) + db.prepare( + 'UPDATE new_sessions SET agent_id = ?, updated_at = ?, revision = revision + 1 WHERE agent_id = ?' + ).run(to, Date.now(), from) } if (hasAcpSessions) { db.prepare( diff --git a/src/main/agent/shared/appSessionService.ts b/src/main/agent/shared/appSessionService.ts index b7f803462f..6a0522fcbb 100644 --- a/src/main/agent/shared/appSessionService.ts +++ b/src/main/agent/shared/appSessionService.ts @@ -43,7 +43,8 @@ export interface AppSessionReadPort { export class AppSessionService implements AppSessionReadPort { constructor( private readonly sqlitePresenter: ProjectDatabase, - private readonly sessionDatabase: SessionDatabase + private readonly sessionDatabase: SessionDatabase, + private readonly notifyEnvironmentProjectionChanged: () => void = () => undefined ) {} create( @@ -79,6 +80,7 @@ export class AppSessionService implements AppSessionReadPort { updatedAt: Date.now() }) this.sqlitePresenter.newEnvironmentsTable.syncPath(projectDir) + this.notifyEnvironmentProjectionChanged() return toAppSessionId(id) } @@ -185,6 +187,7 @@ export class AppSessionService implements AppSessionReadPort { for (const path of affectedPaths) { this.sqlitePresenter.newEnvironmentsTable.syncPath(path) } + this.notifyEnvironmentProjectionChanged() } delete(id: string): void { @@ -195,6 +198,7 @@ export class AppSessionService implements AppSessionReadPort { for (const path of affectedPaths) { this.sqlitePresenter.newEnvironmentsTable.syncPath(path) } + this.notifyEnvironmentProjectionChanged() } getDisabledAgentTools(id: string): string[] { @@ -204,6 +208,7 @@ export class AppSessionService implements AppSessionReadPort { updateDisabledAgentTools(id: string, disabledAgentTools: string[]): void { this.sessionDatabase.newSessionsTable.updateDisabledAgentTools(id, disabledAgentTools) this.sqlitePresenter.newEnvironmentsTable.syncForSession(id) + this.notifyEnvironmentProjectionChanged() } updateAgentId(id: string, agentId: string): void { @@ -214,6 +219,7 @@ export class AppSessionService implements AppSessionReadPort { this.sessionDatabase.newSessionsTable.updateAgentId(id, agentId) this.sqlitePresenter.newEnvironmentsTable.syncForSession(id) + this.notifyEnvironmentProjectionChanged() } private mapRowToRecord(row: { @@ -228,6 +234,7 @@ export class AppSessionService implements AppSessionReadPort { subagent_meta_json: string | null created_at: number updated_at: number + revision: number }): SessionRecord { const metadata = this.sessionDatabase.deepchatSessionMetadataTable?.get(row.id) ?? null return { @@ -242,6 +249,7 @@ export class AppSessionService implements AppSessionReadPort { subagentMeta: parseSubagentMeta(row.subagent_meta_json), createdAt: row.created_at, updatedAt: row.updated_at, + revision: row.revision, ...(metadata ? { metadata } : {}) } } diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index cbd79f7690..71fa2aa583 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -352,7 +352,9 @@ export async function createMainProcessControl(dependencies: { version: Date.now() }) }) - appSessionService = new AppSessionService(projectDatabase, sessionData.database) + appSessionService = new AppSessionService(projectDatabase, sessionData.database, () => + projectService.notifyEnvironmentProjectionChanged() + ) sessionDataMigrationSQLite = { get appSettingsTable() { return settingsDatabase.appSettingsTable @@ -388,7 +390,9 @@ export async function createMainProcessControl(dependencies: { sessionData.database, projectDatabase, memoryDatabase, - sessionData.tapeStore + sessionData.tapeStore, + undefined, + () => projectService.notifyEnvironmentProjectionChanged() ) usageStatsService = new UsageStatsService( sessionData.database, @@ -413,7 +417,8 @@ export async function createMainProcessControl(dependencies: { const acpSessionPersistence = new AcpSessionPersistence( agentDatabase, sessionData.database, - projectDatabase + projectDatabase, + () => projectService.notifyEnvironmentProjectionChanged() ) let providerRuntime!: ProviderRuntime oauthService = new OAuthService( @@ -528,10 +533,16 @@ export async function createMainProcessControl(dependencies: { sessionData.database, deviceService, dependencies.settingsStore, - (projectPath) => + (projectPath, version) => publishDeepchatEvent('config.defaultProjectPath.changed', { path: projectPath, - version: Date.now() + version + }), + (action, environmentPath, version) => + publishDeepchatEvent(projectEnvironmentsChangedEvent.name, { + action, + path: environmentPath, + version }) ) exporter = new ConversationExporterService({ @@ -636,6 +647,7 @@ export async function createMainProcessControl(dependencies: { setPersistedNewSessionSkills: (conversationId, skills) => { sessionData.database.newSessionsTable.updateActiveSkills(conversationId, skills) projectDatabase.newEnvironmentsTable.syncForSession(conversationId) + projectService.notifyEnvironmentProjectionChanged() }, repairImportedLegacySessionSkills: async (conversationId) => { return await legacyChatImportService.repairImportedLegacySessionSkills(conversationId) @@ -1133,7 +1145,10 @@ export async function createMainProcessControl(dependencies: { projection: sessionQuery, deletion: sessionDeletion, environment: { - syncPath: (projectDir) => projectDatabase.newEnvironmentsTable.syncPath(projectDir) + syncPath: (projectDir) => { + projectDatabase.newEnvironmentsTable.syncPath(projectDir) + projectService.notifyEnvironmentProjectionChanged() + } }, acp: acpAsLlmProviderSessionControl }) @@ -1651,11 +1666,11 @@ export async function createMainProcessControl(dependencies: { const workspaceRoutes = createWorkspaceRoutes(workspaceService) const projectRoutes = createProjectRoutes({ projectService, - publishEnvironmentsChanged: (action, environmentPath) => { + publishEnvironmentsChanged: (action, environmentPath, version) => { publishDeepchatEvent(projectEnvironmentsChangedEvent.name, { action, path: environmentPath, - version: Date.now() + version }) } }) diff --git a/src/main/app/startupMigrations/legacyChatImportService.ts b/src/main/app/startupMigrations/legacyChatImportService.ts index acd72273f5..44410b0f20 100644 --- a/src/main/app/startupMigrations/legacyChatImportService.ts +++ b/src/main/app/startupMigrations/legacyChatImportService.ts @@ -37,6 +37,7 @@ export class LegacyChatImportService { private readonly memoryDatabase: MemoryDatabase private readonly messageStore: SessionTranscript private readonly sourceDbPath: string + private readonly notifyEnvironmentProjectionChanged: () => void private runningPromise: Promise | null = null private skillRepairPromise: Promise | null = null @@ -46,7 +47,8 @@ export class LegacyChatImportService { projectDatabase: ProjectDatabase, memoryDatabase: MemoryDatabase, tapeFacts: TapeMessageFactWriter, - sourceDbPath?: string + sourceDbPath?: string, + notifyEnvironmentProjectionChanged: () => void = () => undefined ) { this.appDatabase = appDatabase this.sessionDatabase = sessionDatabase @@ -54,6 +56,7 @@ export class LegacyChatImportService { this.memoryDatabase = memoryDatabase this.messageStore = new SessionTranscript(this.sessionDatabase, tapeFacts) this.sourceDbPath = sourceDbPath ?? path.join(app.getPath('userData'), 'app_db', 'chat.db') + this.notifyEnvironmentProjectionChanged = notifyEnvironmentProjectionChanged } startInBackground(force: boolean = false): void { @@ -608,6 +611,7 @@ export class LegacyChatImportService { try { // newEnvironmentsTable.rebuildFromSessions only refreshes derived environment metadata. this.projectDatabase.newEnvironmentsTable.rebuildFromSessions() + this.notifyEnvironmentProjectionChanged() } catch (error) { console.error('[LegacyChatImport] Failed to rebuild environments after import:', { error, diff --git a/src/main/app/startupMigrations/sessionDataMigrations.ts b/src/main/app/startupMigrations/sessionDataMigrations.ts index 21ab49cc66..d79f36d038 100644 --- a/src/main/app/startupMigrations/sessionDataMigrations.ts +++ b/src/main/app/startupMigrations/sessionDataMigrations.ts @@ -441,12 +441,16 @@ export async function runDisabledAgentToolCapabilityCleanupMigration( ORDER BY id ASC LIMIT ?` ) - const updateSessionDisabledTools = db.prepare<[string, string]>( - 'UPDATE new_sessions SET disabled_agent_tools = ? WHERE id = ?' + const updateSessionDisabledTools = db.prepare<[string, number, string]>( + 'UPDATE new_sessions SET disabled_agent_tools = ?, updated_at = ?, revision = revision + 1 WHERE id = ?' ) const persistSessionDisabledTools = db.transaction( (sessionId: string, disabledAgentTools: string[]): boolean => { - const result = updateSessionDisabledTools.run(JSON.stringify(disabledAgentTools), sessionId) + const result = updateSessionDisabledTools.run( + JSON.stringify(disabledAgentTools), + Date.now(), + sessionId + ) if (result.changes === 0) return false sqlitePresenter.newSessionDisabledAgentToolsTable.replaceForSession( sessionId, diff --git a/src/main/project/index.ts b/src/main/project/index.ts index 0c231359c7..63e57c76bf 100644 --- a/src/main/project/index.ts +++ b/src/main/project/index.ts @@ -12,6 +12,8 @@ import { import type { NewEnvironmentRow } from '@/project/data/tables/newEnvironments' import type { SettingsStore } from '@/config/settingsStore' +const PROJECT_SNAPSHOT_VERSION_SETTINGS_KEY = 'projectSnapshotVersion' + export class ProjectService { private sqlitePresenter: ProjectDatabase private sessionDatabase: SessionDatabase @@ -20,13 +22,22 @@ export class ProjectService { private readonly tempRoot: string private readonly userDataWorkspacesRoot: string private readonly appDataRoot: string + private snapshotVersion: number constructor( sqlitePresenter: ProjectDatabase, sessionDatabase: SessionDatabase, deviceService: DeviceServicePort, settings: SettingsStore, - private readonly publishDefaultProjectPathChanged: (path: string | null) => void + private readonly publishDefaultProjectPathChanged: ( + path: string | null, + version: number + ) => void, + private readonly publishEnvironmentsChanged: ( + action: 'reorder' | 'archive' | 'restore' | 'remove' | 'select', + path: string | null, + version: number + ) => void = () => undefined ) { this.sqlitePresenter = sqlitePresenter this.sessionDatabase = sessionDatabase @@ -35,6 +46,7 @@ export class ProjectService { this.tempRoot = path.resolve(app.getPath('temp')) this.userDataWorkspacesRoot = path.resolve(path.join(app.getPath('userData'), 'workspaces')) this.appDataRoot = path.resolve(app.getPath('appData')) + this.snapshotVersion = this.readSnapshotVersion() } async getProjects(): Promise { @@ -90,6 +102,58 @@ export class ProjectService { .sort((left, right) => this.compareEnvironmentSummaries(left, right, status)) } + async getSnapshot(limit: number = 20): Promise<{ + version: number + projects: Project[] + environments: EnvironmentSummary[] + archivedEnvironments: EnvironmentSummary[] + removedEnvironments: EnvironmentSummary[] + defaultProjectPath: string | null + }> { + // All reads are synchronous SQLite/settings reads in this process. Keep them in + // one uninterrupted turn and stamp the resulting projection with its version. + const version = this.snapshotVersion + const rows = this.sqlitePresenter.newProjectsTable + .getAll() + .filter((row) => !this.isRemovedEnvironment(row.path)) + .slice(0, limit) + const projects = rows.map((row) => ({ + path: row.path, + name: row.name, + icon: row.icon, + lastAccessedAt: row.last_accessed_at, + exists: fs.existsSync(row.path) + })) + const environmentRows = this.sqlitePresenter.newEnvironmentsTable.list() + const preferences = this.sqlitePresenter.newEnvironmentPreferencesTable.list() + const usageByPath = new Map(environmentRows.map((row) => [row.path, row])) + const preferenceByPath = new Map(preferences.map((row) => [row.path, row])) + const paths = new Set (environmentRows.map((row) => row.path)) + for (const preference of preferences) { + paths.add(preference.path) + } + const allEnvironments = Array.from(paths).map((environmentPath) => + this.createEnvironmentSummary( + environmentPath, + usageByPath.get(environmentPath), + preferenceByPath.get(environmentPath) + ) + ) + const byStatus = (status: EnvironmentStatus) => + allEnvironments + .filter((environment) => environment.status === status) + .sort((left, right) => this.compareEnvironmentSummaries(left, right, status)) + + return { + version, + projects, + environments: byStatus('active'), + archivedEnvironments: byStatus('archived'), + removedEnvironments: byStatus('removed'), + defaultProjectPath: this.getDefaultProjectPath() + } + } + async reorderEnvironments(paths: string[]): Promise { const activePathSet = new Set( (await this.getEnvironments({ status: 'active' })).map((environment) => environment.path) @@ -99,6 +163,7 @@ export class ProjectService { ) this.sqlitePresenter.newEnvironmentPreferencesTable.reorderActive(activePaths) + this.bumpSnapshotVersion() } async archiveEnvironment(environmentPath: string): Promise { @@ -110,7 +175,9 @@ export class ProjectService { this.sqlitePresenter.newEnvironmentPreferencesTable.markArchived(normalizedPath) if (this.getDefaultProjectPath() === normalizedPath) { this.setDefaultProjectPath(null) + return } + this.bumpSnapshotVersion() } async restoreEnvironment(environmentPath: string): Promise { @@ -120,6 +187,7 @@ export class ProjectService { } this.sqlitePresenter.newEnvironmentPreferencesTable.markActive(normalizedPath) + this.bumpSnapshotVersion() } async removeEnvironment(environmentPath: string): Promise<{ clearedSessionIds: string[] }> { @@ -138,8 +206,10 @@ export class ProjectService { if (this.getDefaultProjectPath() === normalizedPath) { this.setDefaultProjectPath(null) + return { clearedSessionIds } } + this.bumpSnapshotVersion() return { clearedSessionIds } } @@ -164,6 +234,16 @@ export class ProjectService { } } + getSnapshotVersion(): number { + return this.snapshotVersion + } + + notifyEnvironmentProjectionChanged(): number { + const version = this.bumpSnapshotVersion() + this.publishEnvironmentsChanged('select', null, version) + return version + } + async selectDirectory(): Promise { const result = await this.deviceService.selectDirectory() if (result.canceled || result.filePaths.length === 0) return null @@ -173,6 +253,7 @@ export class ProjectService { this.sqlitePresenter.newProjectsTable.upsert(dirPath, dirName) this.sqlitePresenter.newEnvironmentPreferencesTable.markActive(dirPath) + this.bumpSnapshotVersion() return dirPath } @@ -203,6 +284,8 @@ export class ProjectService { if (currentDefault !== defaultPath) { this.setDefaultProjectPath(defaultPath) + } else { + this.bumpSnapshotVersion() } return defaultPath @@ -213,10 +296,23 @@ export class ProjectService { return projectPath?.trim() || null } - setDefaultProjectPath(projectPath: string | null): void { + setDefaultProjectPath(projectPath: string | null): number { const normalized = projectPath?.trim() || null this.settings.set('defaultProjectPath', normalized) - this.publishDefaultProjectPathChanged(normalized) + const version = this.bumpSnapshotVersion() + this.publishDefaultProjectPathChanged(normalized, version) + return version + } + + private bumpSnapshotVersion(): number { + this.snapshotVersion += 1 + this.settings.set(PROJECT_SNAPSHOT_VERSION_SETTINGS_KEY, this.snapshotVersion) + return this.snapshotVersion + } + + private readSnapshotVersion(): number { + const value = this.settings.get (PROJECT_SNAPSHOT_VERSION_SETTINGS_KEY) + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0 } private createEnvironmentSummary( diff --git a/src/main/project/routes.ts b/src/main/project/routes.ts index 6142bc5e21..b372f813a4 100644 --- a/src/main/project/routes.ts +++ b/src/main/project/routes.ts @@ -2,6 +2,7 @@ import { configGetDefaultProjectPathRoute, configSetDefaultProjectPathRoute, projectArchiveEnvironmentRoute, + projectGetSnapshotRoute, projectListEnvironmentsRoute, projectListRecentRoute, projectOpenDirectoryRoute, @@ -17,6 +18,8 @@ import type { ProjectService } from './index' type ProjectRouteService = Pick< ProjectService, | 'getRecentProjects' + | 'getSnapshot' + | 'getSnapshotVersion' | 'getEnvironments' | 'reorderEnvironments' | 'archiveEnvironment' @@ -32,8 +35,9 @@ type ProjectRouteService = Pick< export function createProjectRoutes(deps: { projectService: ProjectRouteService publishEnvironmentsChanged( - action: 'reorder' | 'archive' | 'restore' | 'remove', - path: string | null + action: 'reorder' | 'archive' | 'restore' | 'remove' | 'select', + path: string | null, + version: number ): void }): DeepchatRouteMap { const { projectService, publishEnvironmentsChanged } = deps @@ -57,6 +61,13 @@ export function createProjectRoutes(deps: { }) } ], + [ + projectGetSnapshotRoute.name, + async (rawInput) => { + projectGetSnapshotRoute.input.parse(rawInput) + return projectGetSnapshotRoute.output.parse(await projectService.getSnapshot(20)) + } + ], [ projectListRecentRoute.name, async (rawInput) => { @@ -80,7 +91,7 @@ export function createProjectRoutes(deps: { async (rawInput) => { const input = projectReorderEnvironmentsRoute.input.parse(rawInput) await projectService.reorderEnvironments(input.paths) - publishEnvironmentsChanged('reorder', null) + publishEnvironmentsChanged('reorder', null, projectService.getSnapshotVersion()) return projectReorderEnvironmentsRoute.output.parse({ updated: true }) } ], @@ -89,7 +100,7 @@ export function createProjectRoutes(deps: { async (rawInput) => { const input = projectArchiveEnvironmentRoute.input.parse(rawInput) await projectService.archiveEnvironment(input.path) - publishEnvironmentsChanged('archive', input.path) + publishEnvironmentsChanged('archive', input.path, projectService.getSnapshotVersion()) return projectArchiveEnvironmentRoute.output.parse({ updated: true }) } ], @@ -98,7 +109,7 @@ export function createProjectRoutes(deps: { async (rawInput) => { const input = projectRestoreEnvironmentRoute.input.parse(rawInput) await projectService.restoreEnvironment(input.path) - publishEnvironmentsChanged('restore', input.path) + publishEnvironmentsChanged('restore', input.path, projectService.getSnapshotVersion()) return projectRestoreEnvironmentRoute.output.parse({ updated: true }) } ], @@ -107,8 +118,10 @@ export function createProjectRoutes(deps: { async (rawInput) => { const input = projectRemoveEnvironmentRoute.input.parse(rawInput) const result = await projectService.removeEnvironment(input.path) - publishEnvironmentsChanged('remove', input.path) - return projectRemoveEnvironmentRoute.output.parse(result) + publishEnvironmentsChanged('remove', input.path, projectService.getSnapshotVersion()) + return projectRemoveEnvironmentRoute.output.parse({ + clearedSessionIds: result.clearedSessionIds + }) } ], [ @@ -132,9 +145,11 @@ export function createProjectRoutes(deps: { projectSelectDirectoryRoute.name, async (rawInput) => { projectSelectDirectoryRoute.input.parse(rawInput) - return projectSelectDirectoryRoute.output.parse({ - path: await projectService.selectDirectory() - }) + const path = await projectService.selectDirectory() + if (path) { + publishEnvironmentsChanged('select', path, projectService.getSnapshotVersion()) + } + return projectSelectDirectoryRoute.output.parse({ path }) } ] ]) diff --git a/src/main/session/data/tables/newSessions.ts b/src/main/session/data/tables/newSessions.ts index 18dc661034..57f5b8ed0f 100644 --- a/src/main/session/data/tables/newSessions.ts +++ b/src/main/session/data/tables/newSessions.ts @@ -16,6 +16,7 @@ export interface NewSessionRow { subagent_meta_json: string | null created_at: number updated_at: number + revision: number } export type SessionListPageCursor = { @@ -63,6 +64,9 @@ export class NewSessionsTable extends BaseTable { 'subagent_meta_json TEXT' ) } + if (version >= 21) { + columns.push('revision INTEGER NOT NULL DEFAULT 0') + } columns.push('created_at INTEGER NOT NULL', 'updated_at INTEGER NOT NULL') @@ -93,11 +97,14 @@ export class NewSessionsTable extends BaseTable { ALTER TABLE new_sessions ADD COLUMN subagent_meta_json TEXT; ` } + if (version === 21) { + return 'ALTER TABLE new_sessions ADD COLUMN revision INTEGER NOT NULL DEFAULT 0;' + } return null } getLatestVersion(): number { - return 20 + return 21 } create( @@ -318,7 +325,7 @@ export class NewSessionsTable extends BaseTable { if (setClauses.length === 0) return - setClauses.push('updated_at = ?') + setClauses.push('updated_at = ?', 'revision = revision + 1') params.push(Date.now()) params.push(id) @@ -387,7 +394,9 @@ export class NewSessionsTable extends BaseTable { updateAgentId(id: string, agentId: string): void { this.db - .prepare('UPDATE new_sessions SET agent_id = ?, updated_at = ? WHERE id = ?') + .prepare( + 'UPDATE new_sessions SET agent_id = ?, updated_at = ?, revision = revision + 1 WHERE id = ?' + ) .run(agentId, Date.now(), id) } @@ -413,18 +422,22 @@ export class NewSessionsTable extends BaseTable { this.db .prepare( `UPDATE new_sessions - SET project_dir = NULL + SET project_dir = NULL, + updated_at = ?, + revision = revision + 1 WHERE project_dir = ? AND session_kind = 'regular'` ) - .run(normalizedProjectDir) + .run(Date.now(), normalizedProjectDir) return rows.map((row) => row.id) } reassignAgentId(fromAgentId: string, toAgentId: string): void { this.db - .prepare('UPDATE new_sessions SET agent_id = ?, updated_at = ? WHERE agent_id = ?') + .prepare( + 'UPDATE new_sessions SET agent_id = ?, updated_at = ?, revision = revision + 1 WHERE agent_id = ?' + ) .run(toAgentId, Date.now(), fromAgentId) } diff --git a/src/main/session/query.ts b/src/main/session/query.ts index 7e1ea8624e..e40b61a215 100644 --- a/src/main/session/query.ts +++ b/src/main/session/query.ts @@ -290,19 +290,23 @@ export class SessionQuery implements SessionProjectionReadPort, SessionProjectio return await this.dependencies.transcript.getMessage(messageId) } - async renameSession(sessionId: string, title: string): Promise { + async renameSession(sessionId: string, title: string): Promise { this.requireSession(sessionId) const normalized = title.trim() if (!normalized) throw new Error('Session title cannot be empty.') this.dependencies.sessions.update(sessionId, { title: normalized }) + const session = await this.materializeRequired(sessionId) this.notify({ sessionIds: [sessionId], reason: 'updated' }) + return session } - async toggleSessionPinned(sessionId: string, pinned: boolean): Promise { + async toggleSessionPinned(sessionId: string, pinned: boolean): Promise { this.requireSession(sessionId) this.dependencies.sessions.update(sessionId, { isPinned: pinned }) + const session = await this.materializeRequired(sessionId) this.notify({ sessionIds: [sessionId], reason: 'updated' }) + return session } notify(options: SessionProjectionUpdate = {}): void { diff --git a/src/main/session/routes.ts b/src/main/session/routes.ts index 9dbe50b32e..95dc63ca2c 100644 --- a/src/main/session/routes.ts +++ b/src/main/session/routes.ts @@ -394,16 +394,18 @@ export function createSessionRoutes(deps: { sessionsRenameRoute.name, async (rawInput) => { const input = sessionsRenameRoute.input.parse(rawInput) - await deps.projection.renameSession(input.sessionId, input.title) - return sessionsRenameRoute.output.parse({ updated: true }) + return sessionsRenameRoute.output.parse({ + session: await deps.projection.renameSession(input.sessionId, input.title) + }) } ], [ sessionsTogglePinnedRoute.name, async (rawInput) => { const input = sessionsTogglePinnedRoute.input.parse(rawInput) - await deps.projection.toggleSessionPinned(input.sessionId, input.pinned) - return sessionsTogglePinnedRoute.output.parse({ updated: true }) + return sessionsTogglePinnedRoute.output.parse({ + session: await deps.projection.toggleSessionPinned(input.sessionId, input.pinned) + }) } ], [ diff --git a/src/renderer/api/ProjectClient.ts b/src/renderer/api/ProjectClient.ts index 4d5cf92aee..1bfb901cc1 100644 --- a/src/renderer/api/ProjectClient.ts +++ b/src/renderer/api/ProjectClient.ts @@ -2,6 +2,7 @@ import type { DeepchatBridge } from '@shared/contracts/bridge' import { projectEnvironmentsChangedEvent } from '@shared/contracts/events' import { projectArchiveEnvironmentRoute, + projectGetSnapshotRoute, projectListEnvironmentsRoute, projectListRecentRoute, projectOpenDirectoryRoute, @@ -15,6 +16,10 @@ import type { EnvironmentStatus } from '@shared/types/agent-interface' import { getDeepchatBridge } from './core' export function createProjectClient(bridge: DeepchatBridge = getDeepchatBridge()) { + async function getSnapshot() { + return await bridge.invoke(projectGetSnapshotRoute.name, {}) + } + async function listRecent(limit: number = 20) { const result = await bridge.invoke(projectListRecentRoute.name, { limit }) return result.projects @@ -57,7 +62,7 @@ export function createProjectClient(bridge: DeepchatBridge = getDeepchatBridge() function onEnvironmentsChanged( listener: (payload: { - action: 'reorder' | 'archive' | 'restore' | 'remove' + action: 'reorder' | 'archive' | 'restore' | 'remove' | 'select' path: string | null version: number }) => void @@ -66,6 +71,7 @@ export function createProjectClient(bridge: DeepchatBridge = getDeepchatBridge() } return { + getSnapshot, listRecent, listEnvironments, reorderEnvironments, diff --git a/src/renderer/api/SessionClient.ts b/src/renderer/api/SessionClient.ts index a6186d9b9e..20f24038d4 100644 --- a/src/renderer/api/SessionClient.ts +++ b/src/renderer/api/SessionClient.ts @@ -307,11 +307,13 @@ export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge() } async function renameSession(sessionId: string, title: string) { - await bridge.invoke(sessionsRenameRoute.name, { sessionId, title }) + const result = await bridge.invoke(sessionsRenameRoute.name, { sessionId, title }) + return result.session } async function toggleSessionPinned(sessionId: string, pinned: boolean) { - await bridge.invoke(sessionsTogglePinnedRoute.name, { sessionId, pinned }) + const result = await bridge.invoke(sessionsTogglePinnedRoute.name, { sessionId, pinned }) + return result.session } async function clearSessionMessages(sessionId: string) { diff --git a/src/renderer/floating/main.ts b/src/renderer/floating/main.ts index 3ce9ca78de..757b1360a4 100644 --- a/src/renderer/floating/main.ts +++ b/src/renderer/floating/main.ts @@ -4,16 +4,15 @@ import { createApp, defineComponent, h, ref } from 'vue' import FloatingButton from './FloatingButton.vue' import { createRendererI18n } from '../src/i18n/bootstrap' import { loadLocaleMessages, resolveSupportedLocale } from '../src/i18n' +import { + applyDocumentAppearance, + resolveDocumentDirection +} from '../src/foundation/appearance/documentAppearance' -const RTL_LANGUAGES = new Set(['fa-IR', 'he-IL']) const floatingTheme = ref<'dark' | 'light'>('dark') const applyTheme = (nextTheme: 'dark' | 'light') => { - document.documentElement.dataset.theme = nextTheme - document.documentElement.classList.remove('dark', 'light') - document.body.classList.remove('dark', 'light') - document.documentElement.classList.add(nextTheme) - document.body.classList.add(nextTheme) + applyDocumentAppearance({ theme: nextTheme, themeDataset: true }) floatingTheme.value = nextTheme } @@ -31,7 +30,7 @@ async function bootstrap() { return { requestedLanguage: locale, locale, - direction: RTL_LANGUAGES.has(locale) ? 'rtl' : 'ltr' + direction: resolveDocumentDirection(locale) } }, onError: (message, error) => { @@ -40,8 +39,10 @@ async function bootstrap() { }) const initialLocale = resolveSupportedLocale(languageState.locale) - document.documentElement.lang = initialLocale - document.documentElement.dir = RTL_LANGUAGES.has(initialLocale) ? 'rtl' : 'ltr' + applyDocumentAppearance({ + language: initialLocale, + direction: resolveDocumentDirection(initialLocale) + }) const app = createApp(Root) app.use(i18n) @@ -58,8 +59,10 @@ async function bootstrap() { i18n.global.setLocaleMessage(locale, messages) i18n.global.locale.value = locale - document.documentElement.lang = locale - document.documentElement.dir = RTL_LANGUAGES.has(locale) ? 'rtl' : 'ltr' + applyDocumentAppearance({ + language: locale, + direction: resolveDocumentDirection(locale) + }) } catch (error) { if (revision === languageRevision) { console.warn(`Failed to load floating widget locale ${locale}:`, error) diff --git a/src/renderer/settings/App.vue b/src/renderer/settings/App.vue index 690c9c9d1f..631e85a04b 100644 --- a/src/renderer/settings/App.vue +++ b/src/renderer/settings/App.vue @@ -94,7 +94,6 @@ import { useRouter, useRoute, RouterView } from 'vue-router' import { onMounted, onBeforeUnmount, Ref, ref, watch, computed, nextTick, unref } from 'vue' import { useI18n } from 'vue-i18n' import { useEventListener, useTitle } from '@vueuse/core' -import { createConfigClient } from '@api/ConfigClient' import { createDeviceClient } from '@api/DeviceClient' import { createWindowClient } from '@api/WindowClient' import { getRuntimeArch, getRuntimePlatform } from '@api/runtime' @@ -118,6 +117,7 @@ import { useProviderDeeplinkImportStore } from '@/stores/providerDeeplinkImport' import { useMcpInstallDeeplinkHandler } from '../src/lib/storeInitializer' import { ensureIconsLoaded } from '../src/lib/iconLoader' import { useFontManager } from '../src/composables/useFontManager' +import { applyDocumentAppearance } from '../src/foundation/appearance/documentAppearance' import { markStartupInteractive } from '../src/lib/startupDeferred' import type { DatabaseRepairSuggestedPayload } from '@shared/types/databaseSchema' import type { LLM_PROVIDER } from '@shared/types/provider' @@ -141,7 +141,6 @@ type SettingsWindowState = Window & { __deepchatSettingsPendingSection?: string | null } -const configClient = createConfigClient() const deviceClient = createDeviceClient() const windowClient = createWindowClient() const settingsEventCleanups: Array<() => void> = [] @@ -180,7 +179,7 @@ const toasterTheme = computed(() => // Detect platform to apply proper styling const { isMacOS, isWinMacOS } = useDeviceVersion() -const { t } = useI18n() +const { t, locale } = useI18n() const router = useRouter() const route = useRoute() const title = useTitle() @@ -576,22 +575,37 @@ const SETTINGS_TAB_TEST_IDS: Record = { const getSettingsTabTestId = (name: string) => SETTINGS_TAB_TEST_IDS[name] ?? `settings-tab-${name.replace(/^settings-/, '')}` -// Keep the document direction aligned with the language store. watch( - () => languageStore.dir, - (direction) => { - document.documentElement.dir = direction + [() => themeStore.themeMode, () => themeStore.isDark, () => uiSettingsStore.fontSizeClass], + ([themeMode, isDark, fontSizeClass], previous) => { + const theme = themeMode === 'system' ? (isDark ? 'dark' : 'light') : themeMode + const previousTheme = previous + ? previous[0] === 'system' + ? previous[1] + ? 'dark' + : 'light' + : previous[0] + : null + + applyDocumentAppearance({ + theme, + fontSizeClass, + disableThemeTransition: previousTheme === null || previousTheme !== theme + }) }, { immediate: true } ) -// Watch font size changes and update classes +// The language store owns the sole initial IPC snapshot and its change listener. +// Settings only projects that resolved state onto this window's document. +void languageStore.initLanguage?.() + watch( - () => uiSettingsStore.fontSizeClass, - (newClass, oldClass) => { - if (oldClass) document.documentElement.classList.remove(oldClass) - document.documentElement.classList.add(newClass) - } + [() => locale.value, () => languageStore.dir], + ([language, direction]) => { + applyDocumentAppearance({ language, direction }) + }, + { immediate: true } ) const handleErrorClosed = () => { diff --git a/src/renderer/src/apps/chat-main/ChatMainApp.vue b/src/renderer/src/apps/chat-main/ChatMainApp.vue index eee16723d3..721919d59a 100644 --- a/src/renderer/src/apps/chat-main/ChatMainApp.vue +++ b/src/renderer/src/apps/chat-main/ChatMainApp.vue @@ -13,7 +13,7 @@ import { usePageRouterStore } from '@/stores/ui/pageRouter' import { Toaster } from '@shadcn/components/ui/sonner' import { useToast } from '@/components/use-toast' import { useUiSettingsStore } from '@/stores/uiSettingsStore' -import { useThemeStore, type ThemeMode } from '@/stores/theme' +import { useThemeStore } from '@/stores/theme' import { useLanguageStore } from '@/stores/language' import { useI18n } from 'vue-i18n' import TranslatePopup from '@/components/popup/TranslatePopup.vue' @@ -25,6 +25,7 @@ import { initAppStores, useMcpInstallDeeplinkHandler } from '@/lib/storeInitiali import { ensureIconsLoaded } from '@/lib/iconLoader' import 'vue-sonner/style.css' // vue-sonner v2 requires this import import { useFontManager } from '@/composables/useFontManager' +import { applyDocumentAppearance } from '@/foundation/appearance/documentAppearance' import AppBar from '@/components/AppBar.vue' import { useDeviceVersion } from '@/composables/useDeviceVersion' import WindowSideBar from '@/components/WindowSideBar.vue' @@ -84,7 +85,7 @@ watch( const themeStore = useThemeStore() const langStore = useLanguageStore() const modelCheckStore = useModelCheckStore() -const { t } = useI18n() +const { t, locale } = useI18n() const toasterTheme = computed(() => themeStore.themeMode === 'system' ? (themeStore.isDark ? 'dark' : 'light') : themeStore.themeMode ) @@ -95,39 +96,33 @@ let errorDisplayTimer: number | null = null const { setup: setupMcpDeeplink, cleanup: cleanupMcpDeeplink } = useMcpInstallDeeplinkHandler() -const resolveThemeName = (themeMode: ThemeMode, isDark: boolean) => { - return themeMode === 'system' ? (isDark ? 'dark' : 'light') : themeMode -} - -const syncAppearanceClasses = (themeName: string, fontSizeClass: string) => { - if (typeof document === 'undefined') { - return - } - - const root = document.documentElement - // 切换期间临时禁用过渡,让主题瞬时生效,避免大量元素同时跑颜色过渡造成的重绘卡顿 - root.classList.add('dc-theme-switching') - - for (const target of [root, document.body]) { - target.classList.remove('light', 'dark', 'system') - target.classList.add(themeName) - target.classList.remove('text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl') - target.classList.add(fontSizeClass) - } +watch( + [() => themeStore.themeMode, () => themeStore.isDark, () => uiSettingsStore.fontSizeClass], + ([themeMode, isDark, fontSizeClass], previous) => { + const theme = themeMode === 'system' ? (isDark ? 'dark' : 'light') : themeMode + const previousTheme = previous + ? previous[0] === 'system' + ? previous[1] + ? 'dark' + : 'light' + : previous[0] + : null + + applyDocumentAppearance({ + theme, + fontSizeClass, + disableThemeTransition: previousTheme === null || previousTheme !== theme + }) + }, + { immediate: true } +) - // 强制同步重算,使本次类切换在「过渡已禁用」的状态下完成 - void root.offsetWidth - // 下一帧恢复过渡,不影响日常 hover 等交互动画 - requestAnimationFrame(() => { - root.classList.remove('dc-theme-switching') - }) -} +void langStore.initLanguage?.() watch( - [() => themeStore.themeMode, () => themeStore.isDark, () => uiSettingsStore.fontSizeClass], - ([themeMode, isDark, fontSizeClass]) => { - const nextThemeName = resolveThemeName(themeMode, isDark) - syncAppearanceClasses(nextThemeName, fontSizeClass) + [() => locale.value, () => langStore.dir], + ([language, direction]) => { + applyDocumentAppearance({ language, direction }) }, { immediate: true } ) @@ -315,22 +310,35 @@ const activatePendingStartDeeplink = async () => { return } + const isCurrentPendingStartDeeplink = () => draftStore.pendingStartDeeplink?.token === token processingStartDeeplinkToken.value = token try { const initComplete = Boolean(await configClient.getSetting('init_complete')) - if (!initComplete) { + if (!initComplete || !isCurrentPendingStartDeeplink()) { return } await router.isReady() + if (!isCurrentPendingStartDeeplink()) { + return + } + if (router.currentRoute.value.name !== 'chat') { await router.push({ name: 'chat' }) + if (!isCurrentPendingStartDeeplink()) { + return + } } agentStore.setSelectedAgent('deepchat') + if (sessionStore.hasActiveSession) { await sessionStore.closeSession() + if (!isCurrentPendingStartDeeplink()) { + return + } + processedStartDeeplinkToken.value = token return } @@ -353,8 +361,7 @@ const handleStartDeeplink = (_event: unknown, payload?: Omit +@@ -532,6 +590,7 @@ import { TooltipTrigger } from '@shadcn/components/ui/tooltip' import { Button } from '@shadcn/components/ui/button' +import { Input } from '@shadcn/components/ui/input' import { Empty, EmptyDescription, @@ -809,7 +868,6 @@ const isChatSession = (session: UISession) => { (defaultChatWorkspacePath.value.length > 0 && projectPath === defaultChatWorkspacePath.value) ) } -const isWorkspaceSession = (session: UISession) => !isChatSession(session) const isChatProjectGroup = (group: SessionGroup) => group.id === NO_PROJECT_GROUP_ID || (defaultChatWorkspacePath.value.length > 0 && @@ -848,7 +906,7 @@ const compareProjectGroups = (left: SessionGroup, right: SessionGroup) => { return 0 } -const filteredGroups = computed(() => { +const orderedFilteredGroups = computed(() => { const groups = baseFilteredGroups.value if (sessionStore.groupMode !== 'project') { return groups @@ -870,20 +928,61 @@ const compareSidebarSessions = (left: UISession, right: UISession) => { return left.title.localeCompare(right.title) || left.id.localeCompare(right.id) } -const sortSidebarSessions = (sessions: UISession[]) => [...sessions].sort(compareSidebarSessions) -const chatSessions = computed(() => - sortSidebarSessions( - baseFilteredGroups.value.flatMap((group) => { - if (sessionStore.groupMode === 'project') { - return isChatProjectGroup(group) ? group.sessions : [] +const ensureSortedSessions = ( + sessions: UISession[], + compare: (left: UISession, right: UISession) => number +) => { + for (let index = 1; index < sessions.length; index += 1) { + if (compare(sessions[index - 1], sessions[index]) > 0) { + return [...sessions].sort(compare) + } + } + + return sessions +} +const sessionSections = computed(() => { + if (sessionStore.groupMode === 'project') { + const chatSessions = ensureSortedSessions( + orderedFilteredGroups.value.filter(isChatProjectGroup).flatMap((group) => group.sessions), + compareSidebarSessions + ) + + return { + chatSessions, + workspaceGroups: orderedFilteredGroups.value.filter(isProjectDirectoryGroup).map((group) => { + const sessions = ensureSortedSessions(group.sessions, compareSidebarSessions) + return sessions === group.sessions ? group : { ...group, sessions } + }) + } + } + + const chatSessions: UISession[] = [] + const workspaceGroups: SessionGroup[] = [] + for (const group of orderedFilteredGroups.value) { + const workspaceSessions: UISession[] = [] + for (const session of group.sessions) { + if (isChatSession(session)) { + chatSessions.push(session) + } else { + workspaceSessions.push(session) } + } - return group.sessions.filter(isChatSession) - }) - ) -) + if (workspaceSessions.length > 0) { + workspaceGroups.push({ + ...group, + sessions: ensureSortedSessions(workspaceSessions, compareSidebarSessions) + }) + } + } + + return { + chatSessions: ensureSortedSessions(chatSessions, compareSidebarSessions), + workspaceGroups + } +}) const chatSectionGroup = computed+++ + + (() => { - const sessions = chatSessions.value + const sessions = sessionSections.value.chatSessions if (sessions.length === 0) { return null } @@ -895,18 +994,7 @@ const chatSectionGroup = computed (() => { sessions } }) -const workspaceGroups = computed(() => { - if (sessionStore.groupMode === 'project') { - return filteredGroups.value.filter(isProjectDirectoryGroup) - } - - return baseFilteredGroups.value - .map((group) => ({ - ...group, - sessions: sortSidebarSessions(group.sessions.filter(isWorkspaceSession)) - })) - .filter((group) => group.sessions.length > 0) -}) +const workspaceGroups = computed(() => sessionSections.value.workspaceGroups) const visibleGroups = computed(() => [ ...(chatSectionGroup.value ? [chatSectionGroup.value] : []), ...workspaceGroups.value @@ -946,6 +1034,13 @@ const getGroupIcon = (group: SessionGroup) => const isGroupCollapsed = (group: SessionGroup) => collapsedGroupIds.value.has(getGroupIdentifier(group)) +const canAutoFillSessionList = computed( + () => + normalizedSessionSearchQuery.value.length === 0 && + !isPinnedSectionCollapsed.value && + !visibleGroups.value.some(isGroupCollapsed) +) + const visibleShortcutSessions = computed (() => { if (collapsed.value) { return [] @@ -1585,7 +1680,12 @@ const handleSessionListScroll = () => { // 这里在加载/过滤变化后主动检测视口是否被填满,未满且仍有更多数据时继续加载。 let isFillingSessionList = false const ensureSessionListFilled = async () => { - if (isFillingSessionList || isProjectGroupDragging.value || collapsed.value) { + if ( + isFillingSessionList || + isProjectGroupDragging.value || + collapsed.value || + !canAutoFillSessionList.value + ) { return } isFillingSessionList = true @@ -1599,6 +1699,7 @@ const ensureSessionListFilled = async () => { !listElement || isProjectGroupDragging.value || collapsed.value || + !canAutoFillSessionList.value || !sessionStore.hasMore || sessionStore.loadingMore || sessionStore.loading @@ -1636,8 +1737,9 @@ const visibleSessionFingerprint = computed(() => ].join('|') ) -// 会话列表内容、过滤、分组折叠或容器高度变化后,若视口未被填满则继续加载, -// 保证「滚动加载更多」在首屏内容过少或可见内容被过滤/折叠后也能启动(issue #1762)。 +// 会话列表内容或容器高度变化后,若视口仍未填满则继续加载,保证「滚动加载更多」 +// 在首屏内容过少时也能启动(issue #1762)。搜索仅过滤已加载会话,仍要求所有 +// 分组展开,避免因为筛选或隐藏行扫完剩余分页。 watch( [ () => sessionStore.sessions.length, diff --git a/src/renderer/src/components/chat/ChatInputToolbar.vue b/src/renderer/src/components/chat/ChatInputToolbar.vue index 87e0d5b291..53da6865a3 100644 --- a/src/renderer/src/components/chat/ChatInputToolbar.vue +++ b/src/renderer/src/components/chat/ChatInputToolbar.vue @@ -109,14 +109,23 @@ variant="outline" size="sm" class="h-7 gap-1.5 rounded-lg px-2.5 text-foreground" + :disabled="steerDisabled || isSteering" + :aria-busy="isSteering || undefined" @click="emit('steer')" > - + + {{ t('chat.input.steer') }} - @@ -136,11 +145,18 @@ size="icon" class="h-7 w-7 rounded-full" :disabled=" - buttonMode === 'send' ? sendDisabled : buttonMode === 'queue' ? queueDisabled : false + buttonMode === 'send' + ? sendDisabled + : buttonMode === 'queue' + ? queueDisabled + : isStopping " + :aria-busy="buttonMode === 'stop' && isStopping ? true : undefined" @click="handlePrimaryAction" > +{{ t('chat.input.steer') }}
++ {{ + steerDisabled && !isSteering + ? t('chat.pendingInput.steerUnavailable') + : t('chat.input.steer') + }} +
string | undefined beforeSpacerHeight?: number afterSpacerHeight?: number disableMarkdownVirtualization?: boolean @@ -73,9 +75,9 @@ const props = withDefaults( ephemeralRateLimitBlock: null, ephemeralRateLimitMessageId: null, isGenerating: false, + streamingMessageId: null, traceMessageIds: () => [], isReadOnly: false, - allMessagesForCapture: () => [], beforeSpacerHeight: 0, afterSpacerHeight: 0, disableMarkdownVirtualization: false @@ -99,9 +101,6 @@ const shouldDisableMarkdownVirtualization = computed( () => props.disableMarkdownVirtualization || isCapturingValue.value ) const allRenderedMessages = computed(() => props.messages) -const captureSearchMessages = computed(() => - props.allMessagesForCapture.length > 0 ? props.allMessagesForCapture : props.messages -) const onRetry = (messageId: string) => emit('retry', messageId) const onDelete = (messageId: string) => emit('delete', messageId) @@ -112,16 +111,18 @@ const onTrace = (messageId: string) => emit('trace', messageId) const onEditSave = (payload: { messageId: string; text: string }) => emit('editSave', payload) const onMeasure = (payload: { messageId: string; height: number }) => emit('measure', payload) -const resolveCaptureParentId = (messageId: string, parentId?: string): string | undefined => { - const messageItems = captureSearchMessages.value +const resolveVisibleCaptureParentId = ( + messageId: string, + parentId?: string +): string | undefined => { if (parentId) { - const parentMessage = messageItems.find((msg) => msg.id === parentId) + const parentMessage = props.messages.find((msg) => msg.id === parentId) if (parentMessage?.role === 'user') return parentId } - const messageIndex = messageItems.findIndex((msg) => msg.id === messageId) + const messageIndex = props.messages.findIndex((msg) => msg.id === messageId) if (messageIndex <= 0) return undefined for (let index = messageIndex - 1; index >= 0; index -= 1) { - const candidate = messageItems[index] as DisplayMessage + const candidate = props.messages[index] as DisplayMessage if (candidate.role === 'user') return candidate.id } return undefined @@ -133,7 +134,9 @@ const handleCopyImage = async ( fromTop: boolean, modelInfo: { model_name: string; model_provider: string } ) => { - const resolvedParentId = resolveCaptureParentId(messageId, parentId) + const resolvedParentId = props.resolveCaptureParentId + ? props.resolveCaptureParentId(messageId, parentId) + : resolveVisibleCaptureParentId(messageId, parentId) await captureMessage({ messageId, parentId: resolvedParentId, fromTop, modelInfo }) } diff --git a/src/renderer/src/components/chat/MessageListRow.vue b/src/renderer/src/components/chat/MessageListRow.vue index 49b854fa40..e7212c6df4 100644 --- a/src/renderer/src/components/chat/MessageListRow.vue +++ b/src/renderer/src/components/chat/MessageListRow.vue @@ -35,6 +35,7 @@ :message="item as DisplayAssistantMessage" :use-legacy-actions="false" :is-in-generating-thread="isGenerating" + :is-streaming-message="isStreamingMessage" :show-trace="showTrace" :is-capturing-image="isCapturing" :is-read-only="isReadOnly" @@ -65,6 +66,7 @@ const props = withDefaults( defineProps<{ item: MessageListItem isGenerating?: boolean + isStreamingMessage?: boolean showTrace?: boolean isCapturing?: boolean isReadOnly?: boolean @@ -72,6 +74,7 @@ const props = withDefaults( }>(), { isGenerating: false, + isStreamingMessage: false, showTrace: false, isCapturing: false, isReadOnly: false, diff --git a/src/renderer/src/components/markdown/MarkdownRenderer.vue b/src/renderer/src/components/markdown/MarkdownRenderer.vue index 6c8eab77e8..1c9ee3646a 100644 --- a/src/renderer/src/components/markdown/MarkdownRenderer.vue +++ b/src/renderer/src/components/markdown/MarkdownRenderer.vue @@ -1,16 +1,20 @@ @@ -39,17 +47,10 @@ import { useArtifactStore } from '@/stores/artifact' import { useReferenceStore } from '@/stores/reference' import { nanoid } from 'nanoid' import { useDebounceFn } from '@vueuse/core' -import { computed, h, onBeforeUnmount, onMounted, ref, watch } from 'vue' -import NodeRenderer, { - CodeBlockNode, - ReferenceNode, - removeCustomComponents, - setCustomComponents, - MermaidBlockNode -} from 'markstream-vue' +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import NodeRenderer, { type CodeBlockPreviewPayload } from 'markstream-vue' import { useThemeStore } from '@/stores/theme' import { useUiSettingsStore } from '@/stores/uiSettingsStore' -import LinkNode from './LinkNode.vue' import { useMarkdownLinkNavigation } from './useMarkdownLinkNavigation' import type { MarkdownLinkContext } from './linkTypes' import { ensureMarkdownWorkers } from '@/lib/markdownWorkerLifecycle' @@ -77,16 +78,26 @@ const props = withDefaults( ) const themeStore = useThemeStore() const uiSettingsStore = useUiSettingsStore() -// 组件映射表 const artifactStore = useArtifactStore() -// 生成唯一的 message ID 和 thread ID,用于 MarkdownRenderer +const UNSUPPORTED_CODE_FENCE_LANGUAGES = new Set(['desktop-local-file']) const fallbackMessageId = `artifact-msg-${nanoid()}` const fallbackThreadId = `artifact-thread-${nanoid()}` const referenceStore = useReferenceStore() const sessionClient = createSessionClient() -const referenceNode = ref(null) -const debouncedContent = ref(props.content) +const normalizeCodeFenceLanguages = (content: string): string => + content.replace(/(^|\n)(`{3,}|~{3,})([^\r\n]*)/g, (fence, lineStart, delimiter, info) => { + const [language, ...meta] = info.trim().split(/\s+/) + if (!UNSUPPORTED_CODE_FENCE_LANGUAGES.has(language?.toLowerCase())) { + return fence + } + + return `${lineStart}${delimiter}plaintext${meta.length ? ` ${meta.join(' ')}` : ''}` + }) + +const renderContent = ref(normalizeCodeFenceLanguages(props.content)) let searchResultsPromise: ReturnType | null = null +let activeReferenceElement: HTMLElement | null = null +let rendererContextRevision = 0 const effectiveMessageId = computed(() => props.messageId ?? fallbackMessageId) const effectiveThreadId = computed(() => props.threadId ?? fallbackThreadId) const effectiveLinkContext = computed (() => { @@ -107,7 +118,8 @@ const customRendererId = computed(() => effectiveMessageId.value, effectiveLinkContext.value.source, effectiveLinkContext.value.sessionId ?? '', - effectiveLinkContext.value.sourceFilePath ?? '' + effectiveLinkContext.value.sourceFilePath ?? '', + fallbackMessageId ].join('::') ) const codeBlockThemes = ['vitesse-dark', 'vitesse-light'] as const @@ -117,6 +129,10 @@ const codeBlockMonacoOption = computed(() => ({ fontFamily: uiSettingsStore.formattedCodeFontFamily, wordWrap: 'on' as const })) +const codeBlockProps = computed(() => ({ + themes: [...codeBlockThemes] +})) +const mermaidProps = { isStrict: true } as const const isStreaming = computed( () => props.final === false || (props.streaming && props.final !== true) ) @@ -128,6 +144,10 @@ const resolvedSmoothStreaming = computed(() => { return 'auto' as const }) +const resolvedTypewriter = computed(() => (isStreaming.value ? ('simple' as const) : false)) +// In current Markstream, `monaco` is the compatibility name for the enhanced stream-diffs +// CodeBlockNode. Keep it stable: Markstream owns the streaming -> final surface handoff. +const codeRenderer = 'monaco' as const const STREAM_INITIAL_RENDER_BATCH_SIZE = 10 const STREAM_RENDER_BATCH_SIZE = 14 const STREAM_RENDER_BATCH_DELAY_MS = 8 @@ -144,7 +164,7 @@ const STATIC_MAX_LIVE_NODES = 260 const STATIC_LIVE_NODE_BUFFER = 80 const shouldVirtualizeNodes = computed(() => props.virtualizeNodes && !isStreaming.value) -const shouldDeferNodesUntilVisible = computed(() => shouldVirtualizeNodes.value) +const shouldUseViewportPriority = computed(() => props.virtualizeNodes) const resolvedNodeVirtual = computed(() => shouldVirtualizeNodes.value ? ('auto' as const) : false ) @@ -173,10 +193,110 @@ const { navigateLink } = useMarkdownLinkNavigation({ }) const getSearchResults = () => { - searchResultsPromise ??= sessionClient.getSearchResults(effectiveMessageId.value) + if (!searchResultsPromise) { + const request = sessionClient.getSearchResults(effectiveMessageId.value) + searchResultsPromise = request + void request.catch(() => { + if (searchResultsPromise === request) { + searchResultsPromise = null + } + }) + } + return searchResultsPromise } +function closestEventElement(event: Event, selector: string): HTMLElement | null { + const target = event.target + return target instanceof Element ? (target.closest(selector) as HTMLElement | null) : null +} + +function getReferenceIndex(element: HTMLElement): number { + return Number.parseInt(element.textContent?.trim() ?? '', 10) - 1 +} + +function isEventInsideElement(event: MouseEvent, element: HTMLElement): boolean { + return event.relatedTarget instanceof Node && element.contains(event.relatedTarget) +} + +function handleArtifactClick(v: CodeBlockPreviewPayload): void { + artifactStore.showArtifact( + { + id: v.id, + type: v.artifactType, + title: v.artifactTitle, + language: v.node.language, + content: v.node.code, + status: 'loaded' + }, + effectiveMessageId.value, + effectiveThreadId.value, + { force: true } + ) +} + +function handleRendererClick(event: MouseEvent): void { + const referenceElement = closestEventElement(event, '.reference-node') + if (referenceElement) { + const index = getReferenceIndex(referenceElement) + const contextRevision = rendererContextRevision + if (index >= 0) { + getSearchResults().then( + (results) => { + if (contextRevision === rendererContextRevision && index < results.length) { + void navigateLink(results[index].url, event) + } + }, + () => undefined + ) + } + return + } + + const anchor = closestEventElement(event, 'a.link-node[href]') + if (anchor) { + void navigateLink(anchor.getAttribute('href') ?? '', event) + } +} + +function handleRendererKeydown(event: KeyboardEvent): void { + if (event.key !== 'Enter' && event.key !== ' ') return + const referenceElement = closestEventElement(event, '.reference-node') + if (!referenceElement) return + + event.preventDefault() + referenceElement.click() +} + +function handleRendererMouseover(event: MouseEvent): void { + const referenceElement = closestEventElement(event, '.reference-node') + if (!referenceElement || isEventInsideElement(event, referenceElement)) return + + activeReferenceElement = referenceElement + referenceStore.hideReference() + const index = getReferenceIndex(referenceElement) + if (index < 0) return + + getSearchResults().then( + (results) => { + if (activeReferenceElement === referenceElement && index < results.length) { + referenceStore.showReference(results[index], referenceElement.getBoundingClientRect()) + } + }, + () => undefined + ) +} + +function handleRendererMouseout(event: MouseEvent): void { + const referenceElement = closestEventElement(event, '.reference-node') + if (!referenceElement || isEventInsideElement(event, referenceElement)) return + + if (activeReferenceElement === referenceElement) { + activeReferenceElement = null + } + referenceStore.hideReference() +} + // Shared revision guard so an older slow-path update can never land after a // newer fast-path update (or vice versa) when the routing condition flips, // which would repaint stale markdown and reintroduce the completion flash. @@ -185,7 +305,7 @@ let contentRevision = 0 const updateContentFast = useDebounceFn( (revision: number, value: string) => { if (revision === contentRevision) { - debouncedContent.value = value + renderContent.value = value } }, 32, @@ -194,126 +314,45 @@ const updateContentFast = useDebounceFn( const updateContentSlow = useDebounceFn( (revision: number, value: string) => { if (revision === contentRevision) { - debouncedContent.value = value + renderContent.value = value } }, 96, { maxWait: 180 } ) -const updateContent = (value: string) => { +const updateContent = (value: string, commitImmediately: boolean) => { const revision = ++contentRevision + const normalizedValue = normalizeCodeFenceLanguages(value) - if (isStreaming.value && debouncedContent.value.length === 0 && value.length > 0) { - debouncedContent.value = value + // Main already coalesces renderer snapshots and Markstream owns visible pacing. + // Stream updates, including the final handoff, must not pass through a third timer. + if (commitImmediately) { + renderContent.value = normalizedValue return } if (props.smoothStreaming && value.length > 12_000) { - updateContentSlow(revision, value) + updateContentSlow(revision, normalizedValue) return } - updateContentFast(revision, value) + updateContentFast(revision, normalizedValue) } -watch( - () => props.content, - (value) => { - updateContent(value) - } -) - -watch(effectiveMessageId, () => { - searchResultsPromise = null +watch([() => props.content, isStreaming], ([value, streaming], [, wasStreaming]) => { + updateContent(value, streaming || wasStreaming === true) }) -watch( - customRendererId, - (nextCustomRendererId, previousCustomRendererId) => { - if (previousCustomRendererId && previousCustomRendererId !== nextCustomRendererId) { - removeCustomComponents(previousCustomRendererId) - } - - setCustomComponents(nextCustomRendererId, { - link: (_props) => - h(LinkNode, { - ..._props, - linkContext: effectiveLinkContext.value - }), - reference: (_props) => - h(ReferenceNode, { - ..._props, - messageId: effectiveMessageId.value, - threadId: effectiveThreadId.value, - onClick(event?: MouseEvent) { - getSearchResults().then((results) => { - const index = parseInt(_props.node.id, 10) - 1 - if (index >= 0 && index < results.length) { - void navigateLink(results[index].url, event) - } - }) - }, - onMouseEnter() { - referenceStore.hideReference() - getSearchResults().then((results) => { - const index = parseInt(_props.node.id, 10) - 1 - if (index >= 0 && index < results.length && referenceNode.value) { - referenceStore.showReference( - results[index], - referenceNode.value.getBoundingClientRect() - ) - } - }) - }, - onMouseLeave() { - referenceStore.hideReference() - } - }), - mermaid: (_props) => { - return h(MermaidBlockNode, { - ..._props, - isStrict: true - }) - }, - code_block: (_props) => { - const isMermaid = _props.node.language === 'mermaid' - if (isMermaid) { - return h(MermaidBlockNode, { - ..._props, - isStrict: true - }) - } - return h(CodeBlockNode, { - ..._props, - isDark: themeStore.isDark, - darkTheme: codeBlockDarkTheme, - lightTheme: codeBlockLightTheme, - themes: [...codeBlockThemes], - monacoOptions: codeBlockMonacoOption.value, - onPreviewCode(v) { - artifactStore.showArtifact( - { - id: v.id, - type: v.artifactType, - title: v.artifactTitle, - language: v.language, - content: v.node.code, - status: 'loaded' - }, - effectiveMessageId.value, - effectiveThreadId.value, - { force: true } - ) - } - }) - } - }) - }, - { - immediate: true +watch(customRendererId, () => { + rendererContextRevision += 1 + searchResultsPromise = null + const ownedReferencePreview = activeReferenceElement !== null + activeReferenceElement = null + if (ownedReferencePreview) { + referenceStore.hideReference() } -) +}) onMounted(() => { ensureMarkdownWorkers().catch((error) => { @@ -322,7 +361,12 @@ onMounted(() => { }) onBeforeUnmount(() => { - removeCustomComponents(customRendererId.value) + rendererContextRevision += 1 + const ownedReferencePreview = activeReferenceElement !== null + activeReferenceElement = null + if (ownedReferencePreview) { + referenceStore.hideReference() + } }) defineEmits(['copy']) @@ -395,14 +439,6 @@ defineEmits(['copy']) contain: layout style paint; } - .markstream-vue [data-markstream-code-block='1'], - .markstream-vue [data-markstream-code-block='1'] .code-editor-container, - .markstream-vue [data-markstream-code-block='1'] .code-pre-fallback, - .markstream-vue pre[class^='language-'], - .markstream-vue pre[class*=' language-'] { - scrollbar-gutter: stable; - } - table { @apply py-0 my-0; border-collapse: collapse; diff --git a/src/renderer/src/components/message/MessageBlockContent.vue b/src/renderer/src/components/message/MessageBlockContent.vue index 4180c3761b..5b73dc5bba 100644 --- a/src/renderer/src/components/message/MessageBlockContent.vue +++ b/src/renderer/src/components/message/MessageBlockContent.vue @@ -64,11 +64,8 @@ const shouldSmoothStream = computed( () => props.block.status === 'pending' || props.block.status === 'loading' ) const isStreamingPart = (part: ProcessedPart) => shouldSmoothStream.value || Boolean(part.loading) -const hasStreamingContent = computed(() => - processedContent.value.some((part) => part.type === 'text' && isStreamingPart(part)) -) const shouldVirtualizeNodes = computed( - () => !props.disableMarkdownVirtualization && !props.isSearchResult && !hasStreamingContent.value + () => !props.disableMarkdownVirtualization && !props.isSearchResult ) const artifactSnapshot = computed(() => diff --git a/src/renderer/src/components/message/MessageContent.vue b/src/renderer/src/components/message/MessageContent.vue index 7e526dd223..46f1acf305 100644 --- a/src/renderer/src/components/message/MessageContent.vue +++ b/src/renderer/src/components/message/MessageContent.vue @@ -12,7 +12,7 @@ @click="handleMentionClick(block)" >- {{ getMentionLabel(block) }} + {{ getVisibleMentionLabel(block) }} @@ -56,6 +56,7 @@ import type { DisplayUserMessageSkillBlock, DisplayUserMessageTextBlock } from '@/features/chat-page/model/displayMessage' +import { getVisibleMentionLabel } from '@/features/chat-page/model/displayUserMessageText' import { useLanguageStore } from '@/stores/language' import { getMimeTypeIcon } from '@/lib/utils' @@ -103,16 +104,6 @@ const getMentionIcon = (category: string) => { return MENTION_ICON_MAP[category] || MENTION_ICON_MAP.default } -const getMentionLabel = (block: DisplayUserMessageMentionBlock) => { - if (block.category === 'prompts') { - return block.id || block.content - } - if (block.category === 'context') { - return block.id || block.category - } - return block.content -} - const getMentionTitle = (block: DisplayUserMessageMentionBlock) => { if (block.category === 'context') { return block.id || '' diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue index 38be3ca188..c3156af14e 100644 --- a/src/renderer/src/components/message/MessageItemAssistant.vue +++ b/src/renderer/src/components/message/MessageItemAssistant.vue @@ -27,7 +27,7 @@ /> - +{ }) const shouldGroupActivity = computed(() => { - if (resolvedIsInGeneratingThread.value) return false + // Row-level: only the actively streaming row stays ungrouped so its activity + // renders live; thread-level generating must not ungroup settled history. + if (props.isStreamingMessage) return false return currentMessage.value.status !== 'pending' }) diff --git a/src/renderer/src/components/message/MessageItemUser.vue b/src/renderer/src/components/message/MessageItemUser.vue index e0b468d65e..b074426a55 100644 --- a/src/renderer/src/components/message/MessageItemUser.vue +++ b/src/renderer/src/components/message/MessageItemUser.vue @@ -21,6 +21,7 @@ -+- @@ -120,11 +120,13 @@ diff --git a/src/renderer/src/features/chat-page/composables/useChatSearch.ts b/src/renderer/src/features/chat-page/composables/useChatSearch.ts index 69da4a9a47..72bf08b815 100644 --- a/src/renderer/src/features/chat-page/composables/useChatSearch.ts +++ b/src/renderer/src/features/chat-page/composables/useChatSearch.ts @@ -1,4 +1,5 @@ import { computed, nextTick, ref, watch, type ComputedRef, type Ref } from 'vue' +import { refDebounced } from '@vueuse/core' import { applyChatSearchHighlights, collectChatSearchResults, @@ -41,6 +42,10 @@ export function useChatSearch(options: UseChatSearchOptions) { const isChatSearchOpen = ref(false) const chatSearchQuery = ref('') const activeChatSearchIndex = ref(0) + // Keep the input untouched, but debounce and compare one canonical query everywhere else. + // This avoids a whitespace-only edit briefly mixing the old and new search state. + const canonicalChatSearchQuery = computed(() => chatSearchQuery.value.trim()) + const debouncedChatSearchQuery = refDebounced(canonicalChatSearchQuery, 150) const chatSearchBarRef = ref<{ focusInput: () => void selectInput: () => void @@ -48,23 +53,42 @@ export function useChatSearch(options: UseChatSearchOptions) { let chatSearchRefreshFrame: number | null = null let pendingChatSearchReveal = false + let chatSearchOperationId = 0 - const chatSearchResults = computed(() => - collectChatSearchResults(displayMessages.value, chatSearchQuery.value) + const isSearchQuerySettled = computed( + () => canonicalChatSearchQuery.value === debouncedChatSearchQuery.value ) + const chatSearchResults = computed(() => { + if (!isChatSearchOpen.value || !isSearchQuerySettled.value) { + return [] + } - async function refreshChatSearchHighlights(revealActive: boolean) { - if (!isChatSearchOpen.value) { + return collectChatSearchResults(displayMessages.value, debouncedChatSearchQuery.value) + }) + + const resolvedChatSearchQuery = () => debouncedChatSearchQuery.value + const isCurrentSearchOperation = (operationId: number, query: string) => + operationId === chatSearchOperationId && + isChatSearchOpen.value && + isSearchQuerySettled.value && + resolvedChatSearchQuery() === query + + async function refreshChatSearchHighlights( + revealActive: boolean, + operationId = chatSearchOperationId + ) { + if (!isChatSearchOpen.value || !isSearchQuerySettled.value) { return } + const query = resolvedChatSearchQuery() await nextTick() - if (!isChatSearchOpen.value) { + if (!isCurrentSearchOperation(operationId, query)) { return } const root = messageSearchRoot.value - applyChatSearchHighlights(root, chatSearchQuery.value) + applyChatSearchHighlights(root, query) if (chatSearchResults.value.length === 0) { activeChatSearchIndex.value = 0 @@ -75,21 +99,24 @@ export function useChatSearch(options: UseChatSearchOptions) { activeChatSearchIndex.value = nextIndex const activeResult = chatSearchResults.value[nextIndex] if (revealActive) { - await revealChatSearchResult(activeResult, 'auto') - } else { + await revealChatSearchResult(activeResult, 'auto', operationId, query) + } else if (isCurrentSearchOperation(operationId, query)) { setActiveChatSearchResult(root, activeResult, { scroll: false }) } } async function revealChatSearchResult( result: ChatSearchResult | undefined, - behavior: ScrollBehavior = 'auto' + behavior: ScrollBehavior = 'auto', + operationId = chatSearchOperationId, + query = resolvedChatSearchQuery() ) { - if (!result) return + if (!result || !isCurrentSearchOperation(operationId, query)) return await nextTick() + if (!isCurrentSearchOperation(operationId, query)) return const root = messageSearchRoot.value - applyChatSearchHighlights(root, chatSearchQuery.value) + applyChatSearchHighlights(root, query) if (!hasWindowEntry(result.messageId)) return const requestId = requestChatScroll('search-navigation', { @@ -99,8 +126,11 @@ export function useChatSearch(options: UseChatSearchOptions) { }) if (requestId === null) return await waitForNextAnimationFrame() + if (!isCurrentSearchOperation(operationId, query)) return await nextTick() - applyChatSearchHighlights(root, chatSearchQuery.value) + if (!isCurrentSearchOperation(operationId, query)) return + applyChatSearchHighlights(root, query) + if (!isCurrentSearchOperation(operationId, query)) return setActiveChatSearchResult(root, result, { behavior, scroll: false }) } @@ -114,6 +144,12 @@ export function useChatSearch(options: UseChatSearchOptions) { chatSearchRefreshFrame = null } + function disposeChatSearch() { + chatSearchOperationId += 1 + cancelScheduledChatSearchRefresh() + clearChatSearchHighlights(messageSearchRoot.value) + } + function scheduleChatSearchHighlights(revealActive = false) { if (!isChatSearchOpen.value) { return @@ -125,7 +161,7 @@ export function useChatSearch(options: UseChatSearchOptions) { chatSearchRefreshFrame = null const shouldReveal = pendingChatSearchReveal pendingChatSearchReveal = false - void refreshChatSearchHighlights(shouldReveal) + void refreshChatSearchHighlights(shouldReveal, chatSearchOperationId) }) } @@ -136,6 +172,7 @@ export function useChatSearch(options: UseChatSearchOptions) { } function clearChatSearchState() { + chatSearchOperationId += 1 cancelScheduledChatSearchRefresh() clearChatSearchHighlights(messageSearchRoot.value) chatSearchQuery.value = '' @@ -146,7 +183,9 @@ export function useChatSearch(options: UseChatSearchOptions) { function openChatSearch() { isChatSearchOpen.value = true focusChatSearchInput() - void refreshChatSearchHighlights(true) + if (isSearchQuerySettled.value) { + void refreshChatSearchHighlights(true) + } } function closeChatSearch() { @@ -164,6 +203,7 @@ export function useChatSearch(options: UseChatSearchOptions) { chatSearchResults.value.length activeChatSearchIndex.value = normalizedIndex + chatSearchOperationId += 1 void revealChatSearchResult(chatSearchResults.value[normalizedIndex], behavior) } @@ -218,15 +258,30 @@ export function useChatSearch(options: UseChatSearchOptions) { return false } - watch(chatSearchQuery, () => { - activeChatSearchIndex.value = 0 + watch( + canonicalChatSearchQuery, + (query) => { + chatSearchOperationId += 1 + activeChatSearchIndex.value = 0 + if (query !== debouncedChatSearchQuery.value) { + clearChatSearchHighlights(messageSearchRoot.value) + } + }, + { flush: 'sync' } + ) + + watch(debouncedChatSearchQuery, () => { + if (!isChatSearchOpen.value || !isSearchQuerySettled.value) { + return + } + scheduleChatSearchHighlights(true) }) watch( [visibleDisplayMessages, chatSearchResults], () => { - if (!isChatSearchOpen.value) { + if (!isChatSearchOpen.value || !isSearchQuerySettled.value) { return } @@ -247,6 +302,7 @@ export function useChatSearch(options: UseChatSearchOptions) { goToNextChatSearchMatch, goToPreviousChatSearchMatch, handleSearchKeydown, - cancelScheduledChatSearchRefresh + cancelScheduledChatSearchRefresh, + disposeChatSearch } } diff --git a/src/renderer/src/features/chat-page/composables/useComposerSubmit.ts b/src/renderer/src/features/chat-page/composables/useComposerSubmit.ts index a4cc7dc081..56835e11be 100644 --- a/src/renderer/src/features/chat-page/composables/useComposerSubmit.ts +++ b/src/renderer/src/features/chat-page/composables/useComposerSubmit.ts @@ -18,7 +18,7 @@ type PendingInputStore = ReturnType type ChatClientLike = { sendMessage: (sessionId: string, payload: SendMessageInput) => Promise - steerActiveTurn: (sessionId: string, payload: SendMessageInput) => Promise + steerActiveTurn: (sessionId: string, payload: SendMessageInput) => Promise<{ accepted: boolean }> } type SessionClientLike = { @@ -101,13 +101,16 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { const message = ref('') const attachedFiles = ref ([]) + const steeringSessionIds = ref >(new Set()) let attachmentFilterToken = 0 const hasInputText = computed(() => Boolean(message.value.trim())) const hasAttachments = computed(() => attachedFiles.value.length > 0) const hasDraftInput = computed(() => hasInputText.value || hasAttachments.value) + const isSteering = computed(() => steeringSessionIds.value.has(options.sessionId())) const isQueueSubmitDisabled = computed( () => + isSteering.value || isSessionViewPreparing.value || isAcpWorkdirMissing.value || !hasDraftInput.value || @@ -116,6 +119,7 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { ) const isInputSubmitDisabled = computed( () => + isSteering.value || isSessionViewPreparing.value || isAcpWorkdirMissing.value || options.hasBlockingInteraction() || @@ -127,9 +131,20 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { isSessionViewPreparing.value || !isGenerating.value || isAcpWorkdirMissing.value || - options.hasBlockingInteraction() + options.hasBlockingInteraction() || + isSteering.value ) + function setSteering(sessionId: string, pending: boolean): void { + const next = new Set(steeringSessionIds.value) + if (pending) { + next.add(sessionId) + } else { + next.delete(sessionId) + } + steeringSessionIds.value = next + } + function notifyUnsupportedAudioAttachments( selection: { providerId: string; modelId: string }, rejectedAudioFiles: MessageFile[] @@ -200,6 +215,7 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { function canSubmitNow(): boolean { if (isReadOnlySession.value) return false + if (isSteering.value) return false if (isSessionViewPreparing.value) return false if (isAcpWorkdirMissing.value) return false if (options.hasBlockingInteraction()) return false @@ -360,24 +376,47 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { } async function onSteer() { - if (!canSubmitNow()) return const sessionId = options.sessionId() + if (!canSubmitNow() || disableQueueSteerAction.value) return + if (steeringSessionIds.value.has(sessionId)) return const restoreRequestId = options.currentRestoreRequestId() const text = message.value.trim() - const files = (await prepareFilesForCurrentModel([...attachedFiles.value])).map((f) => toRaw(f)) - if (!options.canWriteSessionView(sessionId, restoreRequestId)) return - if (!text && files.length === 0) return - const handledCompaction = await handleManualCompactionCommand(text, sessionId, restoreRequestId) - if (!options.canWriteSessionView(sessionId, restoreRequestId)) return - if (handledCompaction) { - return + const selectedFiles = [...attachedFiles.value] + if (!text && selectedFiles.length === 0) return + + setSteering(sessionId, true) + try { + const files = (await prepareFilesForCurrentModel(selectedFiles)).map((file) => toRaw(file)) + if (!options.canWriteSessionView(sessionId, restoreRequestId)) return + if (!text && files.length === 0) return + const handledCompaction = await handleManualCompactionCommand( + text, + sessionId, + restoreRequestId + ) + if (!options.canWriteSessionView(sessionId, restoreRequestId)) return + if (handledCompaction) return + + const result = await chatClient.steerActiveTurn(sessionId, withMessageSkills(text, files)) + if (!result.accepted) { + throw new Error('Steer request was not accepted') + } + options.beginPlanTurn(sessionId) + if (!options.canWriteSessionView(sessionId, restoreRequestId)) return + message.value = '' + attachedFiles.value = [] + clearComposerSkills() + } catch (error) { + console.error('[ChatPage] steer active turn failed:', error) + if (options.canWriteSessionView(sessionId, restoreRequestId)) { + toast({ + title: t('chat.pendingInput.steerFailed'), + variant: 'destructive' + }) + } + } finally { + setSteering(sessionId, false) } - options.beginPlanTurn(sessionId) - await chatClient.steerActiveTurn(sessionId, withMessageSkills(text, files)) - if (!options.canWriteSessionView(sessionId, restoreRequestId)) return - message.value = '' - attachedFiles.value = [] - clearComposerSkills() } async function onFilesChange(files: MessageFile[]) { @@ -399,6 +438,7 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { message, attachedFiles, hasDraftInput, + isSteering, isQueueSubmitDisabled, isInputSubmitDisabled, disableQueueSteerAction, diff --git a/src/renderer/src/features/chat-page/composables/useDisplayMessages.ts b/src/renderer/src/features/chat-page/composables/useDisplayMessages.ts index d0506a875b..987a3dc32f 100644 --- a/src/renderer/src/features/chat-page/composables/useDisplayMessages.ts +++ b/src/renderer/src/features/chat-page/composables/useDisplayMessages.ts @@ -35,9 +35,8 @@ type UseDisplayMessagesOptions = { * * - `toDisplayMessage` conversion with an identity cache so token updates don't * rebuild settled rows. - * - Stable history list (reads only `lastPersistedRevision`) vs. the high-frequency - * streaming tail (reads `streamRevision`) — kept separate so long sessions don't - * rebuild every row on each token. + * - A single ordered display list driven by persisted and streaming revisions, with + * cached conversion for unchanged message records. * - The pending-assistant placeholder state machine and its renderKey handoff, which * hands the placeholder's identity to the real streaming row so completion patches * the same DOM node instead of unmount/remount. @@ -360,118 +359,81 @@ export function useDisplayMessages(options: UseDisplayMessagesOptions) { ) /** - * Stable history list: intentionally does NOT read streamRevision / streamingBlocks, - * so token-level updates do not rebuild every display message in long sessions. - * The in-flight assistant row is owned by streamingDisplayTail instead. + * Builds the ordered list from the current persisted and streaming revisions. The + * conversion cache keeps settled display-message objects stable, while one clear + * list contract keeps display, layout, and virtualization in sync. */ - const stableDisplayMessages = computed(() => { + const displayMessages = computed(() => { void messageStore.lastPersistedRevision + void messageStore.streamRevision if (!isSessionViewCommitted.value) { return [] } - const streamId = - isCurrentSessionStreaming.value && hasInlineStreamingTarget.value - ? messageStore.currentStreamMessageId - : null - const msgs: DisplayMessage[] = [] + const messages: DisplayMessage[] = [] const activeMessageIds = new Set () const cache = messageStore.messageCache for (const id of messageStore.messageIds) { - if (streamId && id === streamId) { - continue - } const message = cache.get(id) if (!message || message.sessionId !== currentSessionId()) continue + activeMessageIds.add(message.id) const displayMessage = toDisplayMessage(message) if (shouldRenderDisplayMessage(displayMessage)) { - msgs.push(displayMessage) + messages.push(displayMessage) + } + } + + const streamMessageId = messageStore.currentStreamMessageId + if ( + hasInlineStreamingTarget.value && + streamMessageId && + !activeMessageIds.has(streamMessageId) + ) { + // The stream record can reach messageCache one reactive update before its id + // joins messageIds. Keep rendering the authoritative record through that + // handoff instead of temporarily dropping the active assistant row. + const streamMessage = cache.get(streamMessageId) + if (streamMessage?.sessionId === currentSessionId()) { + activeMessageIds.add(streamMessage.id) + const displayMessage = toDisplayMessage(streamMessage) + if (shouldRenderDisplayMessage(displayMessage)) { + messages.push(displayMessage) + } } } for (const cachedId of displayMessageCache.keys()) { - if (!activeMessageIds.has(cachedId) && cachedId !== streamId) { + if (!activeMessageIds.has(cachedId)) { displayMessageCache.delete(cachedId) } } - return msgs - }) - - /** High-frequency streaming row + pending placeholder only. */ - const streamingDisplayTail = computed(() => { - void messageStore.streamRevision - if (!isSessionViewCommitted.value) { - return [] - } - const msgs: DisplayMessage[] = [] - - // Single-track: stream blocks are folded into the message record in place, so the - // generating message is the same id/node through completion (no flash). - if (isCurrentSessionStreaming.value && hasInlineStreamingTarget.value) { - const streamId = messageStore.currentStreamMessageId - const record = streamId ? messageStore.messageCache.get(streamId) : undefined - if (record) { - const displayMessage = toDisplayMessage(record) - if (shouldRenderDisplayMessage(displayMessage)) { - msgs.push(displayMessage) - } - } - } else if ( + // When the backend has not assigned a persisted message yet, render its live + // blocks after the ordered records. Once it is persisted, the matching id uses + // the same render-key handoff and Vue patches the existing row in place. + if ( isCurrentSessionStreaming.value && messageStore.streamingBlocks.length > 0 && !hasInlineStreamingTarget.value && !ephemeralRateLimitBlock.value ) { - msgs.push( + messages.push( toStreamingMessage( messageStore.streamingBlocks as DisplayAssistantMessageBlock[], - messageStore.currentStreamMessageId + streamMessageId ) ) } if (shouldShowPendingAssistantPlaceholder.value && pendingAssistantPlaceholder.value) { - msgs.push(toStreamingMessage([], pendingAssistantPlaceholder.value.id)) + messages.push(toStreamingMessage([], pendingAssistantPlaceholder.value.id)) } else if (shouldShowGeneratingAssistantPlaceholder.value) { - msgs.push(toStreamingMessage([], `__pending_assistant_generating_${currentSessionId()}`)) + messages.push(toStreamingMessage([], `__pending_assistant_generating_${currentSessionId()}`)) } - return msgs - }) - - const displayMessages = computed(() => { - const stable = stableDisplayMessages.value - const tail = streamingDisplayTail.value - if (tail.length === 0) { - return stable - } - - const streamId = messageStore.currentStreamMessageId - // Common path: virtual/fallback streaming rows and pending placeholders append at end. - if (!(streamId && hasInlineStreamingTarget.value && tail[0]?.id === streamId)) { - return stable.concat(tail) - } - - // Re-insert the in-flight row at its messageIds order while reusing stable objects. - const stableById = new Map(stable.map((message) => [message.id, message])) - const ordered: DisplayMessage[] = [] - for (const id of messageStore.messageIds) { - if (id === streamId) { - ordered.push(tail[0]) - continue - } - const item = stableById.get(id) - if (item) { - ordered.push(item) - } - } - for (let i = 1; i < tail.length; i += 1) { - ordered.push(tail[i]) - } - return ordered + return messages }) function resetForSessionChange(): void { diff --git a/src/renderer/src/features/chat-page/composables/useMessageActions.ts b/src/renderer/src/features/chat-page/composables/useMessageActions.ts index c3d075ea39..c0dcd1bf46 100644 --- a/src/renderer/src/features/chat-page/composables/useMessageActions.ts +++ b/src/renderer/src/features/chat-page/composables/useMessageActions.ts @@ -23,7 +23,8 @@ type UseMessageActionsOptions = { clearPlanSnapshotForDeletedMessage: (sessionId: string, messageId: string) => void loadMessagesForSession: (sessionId: string) => Promise applyRestoredSessionSummary: (session: unknown) => void - isCurrentSession: (sessionId: string) => boolean + currentRestoreRequestId: () => number + canWriteSessionView: (sessionId: string, requestId: number) => boolean } /** @@ -35,8 +36,10 @@ export function useMessageActions(options: UseMessageActionsOptions) { const pendingDeleteMessageId = ref (null) const showDeleteMessageDialog = computed(() => Boolean(pendingDeleteMessageId.value)) - async function refreshAfterRetryFailure(sessionId: string) { - options.applyRestoredSessionSummary(await options.loadMessagesForSession(sessionId)) + async function refreshAfterRetryFailure(sessionId: string, requestId: number) { + const restoredSession = await options.loadMessagesForSession(sessionId) + if (!options.canWriteSessionView(sessionId, requestId)) return + options.applyRestoredSessionSummary(restoredSession) } async function retryMessage( @@ -48,13 +51,15 @@ export function useMessageActions(options: UseMessageActionsOptions) { if (options.isReadOnlySession.value || !messageId) return if (blocksInteraction && options.hasBlockingInteraction()) return + const requestId = options.currentRestoreRequestId() try { options.beginPlanTurn(sessionId) options.messageStore.clearStreamingState() await options.sessionClient.retryMessage(sessionId, messageId) } catch (error) { console.error(errorMessage, error) - await refreshAfterRetryFailure(sessionId) + if (!options.canWriteSessionView(sessionId, requestId)) return + await refreshAfterRetryFailure(sessionId, requestId) } } @@ -72,14 +77,18 @@ export function useMessageActions(options: UseMessageActionsOptions) { if (!messageId || options.isReadOnlySession.value) return const sessionId = options.sessionId() + const requestId = options.currentRestoreRequestId() pendingDeleteMessageId.value = null try { options.messageStore.clearStreamingState() await options.sessionClient.deleteMessage(sessionId, messageId) + // Plan snapshots are stored per session id, not per view; clean up even + // when the user switched sessions while the confirm dialog was open. options.clearPlanSnapshotForDeletedMessage(sessionId, messageId) - if (options.isCurrentSession(sessionId)) { - options.applyRestoredSessionSummary(await options.loadMessagesForSession(sessionId)) - } + if (!options.canWriteSessionView(sessionId, requestId)) return + const restoredSession = await options.loadMessagesForSession(sessionId) + if (!options.canWriteSessionView(sessionId, requestId)) return + options.applyRestoredSessionSummary(restoredSession) } catch (error) { console.error('[ChatPage] delete message failed:', error) } diff --git a/src/renderer/src/features/chat-page/composables/useMessageVirtualization.ts b/src/renderer/src/features/chat-page/composables/useMessageVirtualization.ts index ed2b8c141e..99da79408f 100644 --- a/src/renderer/src/features/chat-page/composables/useMessageVirtualization.ts +++ b/src/renderer/src/features/chat-page/composables/useMessageVirtualization.ts @@ -117,9 +117,10 @@ export function useMessageVirtualization(options: UseMessageVirtualizationOption } }) - const visibleDisplayMessages = computed(() => - displayMessages.value.slice(messageWindowRange.value.start, messageWindowRange.value.end) - ) + const visibleDisplayMessages = computed(() => { + const { start, end } = messageWindowRange.value + return displayMessages.value.slice(start, end) + }) const messageWindowBeforeHeight = computed(() => messageWindowRange.value.before) const messageWindowAfterHeight = computed(() => messageWindowRange.value.after) diff --git a/src/renderer/src/features/chat-page/composables/useToolInteraction.ts b/src/renderer/src/features/chat-page/composables/useToolInteraction.ts index 9d8cd5fae0..4e297cd131 100644 --- a/src/renderer/src/features/chat-page/composables/useToolInteraction.ts +++ b/src/renderer/src/features/chat-page/composables/useToolInteraction.ts @@ -43,6 +43,8 @@ type UseToolInteractionOptions = { chatClient: ChatClientLike loadMessagesForSession: (sessionId: string) => Promise applyRestoredSessionSummary: (session: unknown) => void + currentRestoreRequestId: () => number + canWriteSessionView: (sessionId: string, requestId: number) => boolean } function parseSubagentProgress(value: unknown): SubagentProgressPayload | null { @@ -144,6 +146,8 @@ export function useToolInteraction(options: UseToolInteractionOptions) { return } + const sessionId = options.sessionId() + const requestId = options.currentRestoreRequestId() isHandlingInteraction.value = true try { const result = await options.chatClient.respondToolInteraction({ @@ -152,7 +156,10 @@ export function useToolInteraction(options: UseToolInteractionOptions) { toolCallId: interaction.toolCallId, response }) - options.applyRestoredSessionSummary(await options.loadMessagesForSession(options.sessionId())) + if (!options.canWriteSessionView(sessionId, requestId)) return + const restoredSession = await options.loadMessagesForSession(sessionId) + if (!options.canWriteSessionView(sessionId, requestId)) return + options.applyRestoredSessionSummary(restoredSession) if (result.handledInline) { return } diff --git a/src/renderer/src/features/chat-page/model/displayUserMessageText.ts b/src/renderer/src/features/chat-page/model/displayUserMessageText.ts new file mode 100644 index 0000000000..c3dbb1796e --- /dev/null +++ b/src/renderer/src/features/chat-page/model/displayUserMessageText.ts @@ -0,0 +1,94 @@ +import type { + DisplayUserMessageContent, + DisplayUserMessageInlineBlock, + DisplayUserMessageMentionBlock +} from './displayMessage' + +export function getVisibleMentionLabel(block: DisplayUserMessageMentionBlock): string { + if (block.category === 'prompts') { + return block.id || block.content + } + + if (block.category === 'context') { + return block.id || block.category + } + + return block.content +} + +/** + * Returns the structured blocks shown in a user message body. Rich persisted blocks + * replace raw text and inline items; otherwise valid inline items are inserted into + * the raw text at their recorded offsets. + */ +export function getVisibleUserContentBlocks( + content: DisplayUserMessageContent +): DisplayUserMessageInlineBlock[] { + const richBlocks = content.content + if (richBlocks && richBlocks.length > 0) { + return richBlocks + } + + const text = content.text || '' + const inlineItems = (content.inlineItems ?? []) + .map((item, index) => ({ item, index })) + .filter( + ({ item }) => Number.isInteger(item.offset) && item.offset >= 0 && item.offset <= text.length + ) + .sort((left, right) => left.item.offset - right.item.offset || left.index - right.index) + + if (inlineItems.length === 0) { + return [] + } + + const blocks: DisplayUserMessageInlineBlock[] = [] + let cursor = 0 + + for (const { item } of inlineItems) { + if (item.offset > cursor) { + blocks.push({ type: 'text', content: text.slice(cursor, item.offset) }) + } + + if (item.type === 'skill') { + blocks.push({ type: 'skill', skillName: item.skillName }) + } else { + blocks.push({ + type: 'file', + fileName: item.fileName, + filePath: item.filePath, + mimeType: item.mimeType + }) + } + + cursor = item.offset + } + + if (cursor < text.length) { + blocks.push({ type: 'text', content: text.slice(cursor) }) + } + + return blocks +} + +/** Returns the text rendered in a user message body for collapse measurement and search indexing. */ +export function collectVisibleUserMessageText(content: DisplayUserMessageContent): string { + const blocks = getVisibleUserContentBlocks(content) + if (blocks.length === 0) { + return content.text || '' + } + + return blocks + .map((block) => { + if (block.type === 'mention') { + return getVisibleMentionLabel(block) + } + if (block.type === 'skill') { + return block.skillName + } + if (block.type === 'file') { + return block.fileName + } + return block.content + }) + .join('') +} diff --git a/src/renderer/src/foundation/appearance/documentAppearance.ts b/src/renderer/src/foundation/appearance/documentAppearance.ts new file mode 100644 index 0000000000..3c9a5fdbda --- /dev/null +++ b/src/renderer/src/foundation/appearance/documentAppearance.ts @@ -0,0 +1,95 @@ +export type ResolvedTheme = 'dark' | 'light' +export type DocumentDirection = 'auto' | 'ltr' | 'rtl' + +type ApplyDocumentAppearanceOptions = { + theme?: ResolvedTheme + fontSizeClass?: string + language?: string + direction?: DocumentDirection + disableThemeTransition?: boolean + themeDataset?: boolean +} + +const FONT_SIZE_CLASSES = ['text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl'] +const RTL_LANGUAGES = new Set(['fa-IR', 'he-IL']) + +export const resolveDocumentDirection = (language: string): DocumentDirection => + RTL_LANGUAGES.has(language) ? 'rtl' : 'auto' + +const getDocumentTargets = (): [HTMLElement, HTMLElement] | null => { + if (typeof document === 'undefined' || !document.body) { + return null + } + + return [document.documentElement, document.body] +} + +/** + * Projects already-resolved renderer appearance state onto the current document. + * Apps retain ownership of settings, stores, and IPC listeners; this module only + * applies values to DOM state and deliberately has no Vue or renderer dependencies. + */ +export const applyDocumentAppearance = ({ + theme, + fontSizeClass, + language, + direction, + disableThemeTransition = false, + themeDataset = false +}: ApplyDocumentAppearanceOptions): void => { + const targets = getDocumentTargets() + if (!targets) { + return + } + + const [root] = targets + const currentTheme = root.classList.contains('dark') + ? 'dark' + : root.classList.contains('light') + ? 'light' + : null + const shouldDisableThemeTransition = + disableThemeTransition && Boolean(theme) && currentTheme !== theme + + if (shouldDisableThemeTransition) { + root.classList.add('dc-theme-switching') + } + + if (theme) { + for (const target of targets) { + target.classList.remove('light', 'dark', 'system') + target.classList.add(theme) + } + + if (themeDataset) { + root.dataset.theme = theme + } + } + + if (fontSizeClass) { + for (const target of targets) { + target.classList.remove(...FONT_SIZE_CLASSES) + target.classList.add(fontSizeClass) + } + } + + if (language) { + root.lang = language + } + + if (direction) { + root.dir = direction + } + + if (shouldDisableThemeTransition) { + if (typeof requestAnimationFrame === 'undefined') { + root.classList.remove('dc-theme-switching') + return + } + + void root.offsetWidth + requestAnimationFrame(() => { + root.classList.remove('dc-theme-switching') + }) + } +} diff --git a/src/renderer/src/i18n/da-DK/chat.json b/src/renderer/src/i18n/da-DK/chat.json index c00c5142b8..3504e2b69a 100644 --- a/src/renderer/src/i18n/da-DK/chat.json +++ b/src/renderer/src/i18n/da-DK/chat.json @@ -348,7 +348,8 @@ "moveProjectGroupBottom": "Flyt til bunden", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "Viser resultater fra indlæste samtaler. Rul for at indlæse mere historik." }, "floatingWidget": { "title": "Opgaveoversigt", diff --git a/src/renderer/src/i18n/de-DE/chat.json b/src/renderer/src/i18n/de-DE/chat.json index b3b99936d9..d5827976e9 100644 --- a/src/renderer/src/i18n/de-DE/chat.json +++ b/src/renderer/src/i18n/de-DE/chat.json @@ -364,7 +364,8 @@ "moveProjectGroupBottom": "Ganz nach unten verschieben", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "Es werden Treffer aus geladenen Unterhaltungen angezeigt. Scrolle, um weiteren Verlauf zu laden." }, "spotlight": { "placeholder": "Chats, Agent, Einstellungen und Aktionen suchen…", diff --git a/src/renderer/src/i18n/en-US/chat.json b/src/renderer/src/i18n/en-US/chat.json index 8b6eccad0b..c67e831dc6 100644 --- a/src/renderer/src/i18n/en-US/chat.json +++ b/src/renderer/src/i18n/en-US/chat.json @@ -383,7 +383,8 @@ "searchPlaceholder": "Search chats", "searchAriaLabel": "Search chats", "searchEmptyTitle": "No matching conversations", - "searchEmptyDescription": "Try a different title keyword" + "searchEmptyDescription": "Try a different title keyword", + "searchLoadedRangeDescription": "Showing matches from loaded conversations. Scroll to load more history." }, "spotlight": { "placeholder": "Search chats, agents, settings, actions…", diff --git a/src/renderer/src/i18n/es-ES/chat.json b/src/renderer/src/i18n/es-ES/chat.json index 7594e24ff0..a6708dddbe 100644 --- a/src/renderer/src/i18n/es-ES/chat.json +++ b/src/renderer/src/i18n/es-ES/chat.json @@ -364,7 +364,8 @@ "moveProjectGroupBottom": "Mover al final", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "Mostrando coincidencias de conversaciones cargadas. Desplázate para cargar más historial." }, "spotlight": { "placeholder": "Buscar chats, Agents, ajustes y acciones…", diff --git a/src/renderer/src/i18n/fa-IR/chat.json b/src/renderer/src/i18n/fa-IR/chat.json index 31706e06ba..98fdf3f0a2 100644 --- a/src/renderer/src/i18n/fa-IR/chat.json +++ b/src/renderer/src/i18n/fa-IR/chat.json @@ -348,7 +348,8 @@ "moveProjectGroupBottom": "انتقال به پایینترین جایگاه", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "نتایج گفتوگوهای بارگذاریشده نمایش داده میشوند. برای بارگذاری تاریخچه بیشتر پیمایش کنید." }, "floatingWidget": { "title": "نمای کلی کارها", diff --git a/src/renderer/src/i18n/fr-FR/chat.json b/src/renderer/src/i18n/fr-FR/chat.json index 9ebbe81bff..9835d124f9 100644 --- a/src/renderer/src/i18n/fr-FR/chat.json +++ b/src/renderer/src/i18n/fr-FR/chat.json @@ -348,7 +348,8 @@ "moveProjectGroupBottom": "Déplacer tout en bas", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "Affichage des résultats des conversations chargées. Faites défiler pour charger plus d’historique." }, "floatingWidget": { "title": "Aperçu des tâches", diff --git a/src/renderer/src/i18n/he-IL/chat.json b/src/renderer/src/i18n/he-IL/chat.json index 78c31dfc60..09d5609edc 100644 --- a/src/renderer/src/i18n/he-IL/chat.json +++ b/src/renderer/src/i18n/he-IL/chat.json @@ -348,7 +348,8 @@ "moveProjectGroupBottom": "העבר לתחתית הרשימה", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "מוצגות התאמות משיחות שנטענו. גלול כדי לטעון היסטוריה נוספת." }, "floatingWidget": { "title": "סקירת משימות", diff --git a/src/renderer/src/i18n/id-ID/chat.json b/src/renderer/src/i18n/id-ID/chat.json index d173939c11..c776177cb3 100644 --- a/src/renderer/src/i18n/id-ID/chat.json +++ b/src/renderer/src/i18n/id-ID/chat.json @@ -364,7 +364,8 @@ "moveProjectGroupBottom": "Pindahkan ke paling bawah", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "Menampilkan kecocokan dari percakapan yang dimuat. Gulir untuk memuat riwayat lainnya." }, "spotlight": { "placeholder": "Sesi pencarian, Agent, pengaturan dan tindakan…", diff --git a/src/renderer/src/i18n/it-IT/chat.json b/src/renderer/src/i18n/it-IT/chat.json index 6b74b24cc0..e85b396ed2 100644 --- a/src/renderer/src/i18n/it-IT/chat.json +++ b/src/renderer/src/i18n/it-IT/chat.json @@ -364,7 +364,8 @@ "moveProjectGroupBottom": "Sposta in fondo", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "Visualizzazione delle corrispondenze dalle conversazioni caricate. Scorri per caricare altra cronologia." }, "spotlight": { "placeholder": "Cerca conversazioni, Agent, impostazioni e azioni…", diff --git a/src/renderer/src/i18n/ja-JP/chat.json b/src/renderer/src/i18n/ja-JP/chat.json index d010c8b8f0..52597be4c1 100644 --- a/src/renderer/src/i18n/ja-JP/chat.json +++ b/src/renderer/src/i18n/ja-JP/chat.json @@ -348,7 +348,8 @@ "moveProjectGroupBottom": "最下部へ移動", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "読み込み済みの会話から一致する項目を表示しています。スクロールして履歴をさらに読み込めます。" }, "floatingWidget": { "title": "タスク概要", diff --git a/src/renderer/src/i18n/ko-KR/chat.json b/src/renderer/src/i18n/ko-KR/chat.json index 63f1090cdf..1cd1afa40b 100644 --- a/src/renderer/src/i18n/ko-KR/chat.json +++ b/src/renderer/src/i18n/ko-KR/chat.json @@ -348,7 +348,8 @@ "moveProjectGroupBottom": "맨 아래로 이동", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "불러온 대화의 일치 항목을 표시하고 있습니다. 스크롤하여 이전 기록을 더 불러오세요." }, "floatingWidget": { "title": "작업 개요", diff --git a/src/renderer/src/i18n/ms-MY/chat.json b/src/renderer/src/i18n/ms-MY/chat.json index d33121382f..d4b5d504dd 100644 --- a/src/renderer/src/i18n/ms-MY/chat.json +++ b/src/renderer/src/i18n/ms-MY/chat.json @@ -364,7 +364,8 @@ "moveProjectGroupBottom": "Alih ke paling bawah", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "Memaparkan padanan daripada perbualan yang dimuatkan. Tatal untuk memuatkan lebih banyak sejarah." }, "spotlight": { "placeholder": "Sesi carian, Agent, tetapan dan tindakan…", diff --git a/src/renderer/src/i18n/pl-PL/chat.json b/src/renderer/src/i18n/pl-PL/chat.json index 8bd30a1779..533beea68c 100644 --- a/src/renderer/src/i18n/pl-PL/chat.json +++ b/src/renderer/src/i18n/pl-PL/chat.json @@ -364,7 +364,8 @@ "moveProjectGroupBottom": "Przenieś na dół", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "Wyświetlanie wyników z załadowanych rozmów. Przewiń, aby załadować więcej historii." }, "spotlight": { "placeholder": "Przeszukuj czaty, agentów, ustawienia, działania…", diff --git a/src/renderer/src/i18n/pt-BR/chat.json b/src/renderer/src/i18n/pt-BR/chat.json index f552a2e728..0cc8564da9 100644 --- a/src/renderer/src/i18n/pt-BR/chat.json +++ b/src/renderer/src/i18n/pt-BR/chat.json @@ -348,7 +348,8 @@ "moveProjectGroupBottom": "Mover para o fim", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "Mostrando resultados das conversas carregadas. Role para carregar mais histórico." }, "floatingWidget": { "title": "Visão geral das tarefas", diff --git a/src/renderer/src/i18n/ru-RU/chat.json b/src/renderer/src/i18n/ru-RU/chat.json index 96451a5e2e..a93c4a37d2 100644 --- a/src/renderer/src/i18n/ru-RU/chat.json +++ b/src/renderer/src/i18n/ru-RU/chat.json @@ -348,7 +348,8 @@ "moveProjectGroupBottom": "Переместить в конец", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "Показаны совпадения из загруженных бесед. Прокрутите, чтобы загрузить больше истории." }, "floatingWidget": { "title": "Обзор задач", diff --git a/src/renderer/src/i18n/tr-TR/chat.json b/src/renderer/src/i18n/tr-TR/chat.json index cb8f48479f..8bab294742 100644 --- a/src/renderer/src/i18n/tr-TR/chat.json +++ b/src/renderer/src/i18n/tr-TR/chat.json @@ -364,7 +364,8 @@ "moveProjectGroupBottom": "En alta taşı", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "Yüklenen konuşmalardaki eşleşmeler gösteriliyor. Daha fazla geçmiş yüklemek için kaydırın." }, "spotlight": { "placeholder": "Sohbetlerde, agents, ayarlarda, işlemlerde arama yapın…", diff --git a/src/renderer/src/i18n/vi-VN/chat.json b/src/renderer/src/i18n/vi-VN/chat.json index 891f91c2ba..2268c3ae11 100644 --- a/src/renderer/src/i18n/vi-VN/chat.json +++ b/src/renderer/src/i18n/vi-VN/chat.json @@ -364,7 +364,8 @@ "moveProjectGroupBottom": "Di chuyển xuống cuối", "searchCommand": "Search", "chatSection": "Chat", - "workspace": "Workspace" + "workspace": "Workspace", + "searchLoadedRangeDescription": "Đang hiển thị kết quả khớp từ các cuộc trò chuyện đã tải. Cuộn để tải thêm lịch sử." }, "spotlight": { "placeholder": "Tìm kiếm cuộc trò chuyện, đại lý, cài đặt, hành động…", diff --git a/src/renderer/src/i18n/zh-CN/chat.json b/src/renderer/src/i18n/zh-CN/chat.json index 011a262da8..e3b445639d 100644 --- a/src/renderer/src/i18n/zh-CN/chat.json +++ b/src/renderer/src/i18n/zh-CN/chat.json @@ -383,7 +383,8 @@ "searchPlaceholder": "搜索会话标题", "searchAriaLabel": "搜索会话标题", "searchEmptyTitle": "没有找到匹配的会话", - "searchEmptyDescription": "试试换一个标题关键词" + "searchEmptyDescription": "试试换一个标题关键词", + "searchLoadedRangeDescription": "正在显示已加载会话中的匹配项。向下滚动以加载更多历史记录。" }, "spotlight": { "placeholder": "搜索会话、Agent、设置与动作…", diff --git a/src/renderer/src/i18n/zh-HK/chat.json b/src/renderer/src/i18n/zh-HK/chat.json index f839d762f5..a69baa553a 100644 --- a/src/renderer/src/i18n/zh-HK/chat.json +++ b/src/renderer/src/i18n/zh-HK/chat.json @@ -348,7 +348,8 @@ "moveProjectGroupBottom": "移至最下方", "searchCommand": "搜尋", "chatSection": "Chat", - "workspace": "工作區" + "workspace": "工作區", + "searchLoadedRangeDescription": "正在顯示已載入對話中的相符結果。向下捲動以載入更多記錄。" }, "floatingWidget": { "title": "任務總覽", diff --git a/src/renderer/src/i18n/zh-TW/chat.json b/src/renderer/src/i18n/zh-TW/chat.json index 9e950b8da3..4b55707a1a 100644 --- a/src/renderer/src/i18n/zh-TW/chat.json +++ b/src/renderer/src/i18n/zh-TW/chat.json @@ -348,7 +348,8 @@ "moveProjectGroupBottom": "移至最下方", "searchCommand": "搜尋", "chatSection": "Chat", - "workspace": "工作區" + "workspace": "工作區", + "searchLoadedRangeDescription": "正在顯示已載入對話中的相符結果。向下捲動以載入更多歷史記錄。" }, "floatingWidget": { "title": "任務總覽", diff --git a/src/renderer/src/lib/chatSearch.ts b/src/renderer/src/lib/chatSearch.ts index aacc981208..f1708ccb3c 100644 --- a/src/renderer/src/lib/chatSearch.ts +++ b/src/renderer/src/lib/chatSearch.ts @@ -1,5 +1,21 @@ +import type { DisplayUserMessageContent } from '@/features/chat-page/model/displayMessage' +import { + getVisibleMentionLabel, + getVisibleUserContentBlocks +} from '@/features/chat-page/model/displayUserMessageText' + const HIGHLIGHT_SELECTOR = '[data-chat-search-match]' const ACTIVE_HIGHLIGHT_SELECTOR = '[data-chat-search-active]' +const HIGHLIGHTED_QUERY_ATTRIBUTE = 'data-chat-search-highlighted-query' +const MESSAGE_ROW_SELECTOR = '[data-message-id]' +type HighlightObserverState = { + observer: MutationObserver + frame: number | null + rowsToRefresh: Set + isApplyingHighlights: boolean +} + +const highlightedRowObservers = new WeakMap () export type ChatSearchMatch = HTMLElement export type ChatSearchResult = { @@ -10,7 +26,7 @@ export type ChatSearchResult = { const isIgnoredElement = (element: HTMLElement | null): boolean => Boolean( element?.closest( - 'input, textarea, select, button, [contenteditable="true"], [data-chat-search-match]' + 'input, textarea, select, button, [contenteditable="true"], [data-chat-search-exclude], [data-chat-search-match]' ) ) @@ -39,6 +55,21 @@ const isElementVisible = (element: HTMLElement | null): boolean => { return true } +const isInsideSearchableMessageContent = (element: HTMLElement): boolean => { + const row = element.closest (MESSAGE_ROW_SELECTOR) + if (!row) { + return true + } + + // Components in older/unit-test rows may omit the body marker. In real message + // rows, it excludes author/timestamp chrome so data indexing and DOM marks stay + // in the same order. + return ( + !row.querySelector('[data-message-content]') || + Boolean(element.closest('[data-message-content]')) + ) +} + const collectSearchableTextNodes = (root: ParentNode): Text[] => { if (typeof document === 'undefined') { return [] @@ -55,7 +86,12 @@ const collectSearchableTextNodes = (root: ParentNode): Text[] => { } const parentElement = node.parentElement - if (!parentElement || isIgnoredElement(parentElement) || !isElementVisible(parentElement)) { + if ( + !parentElement || + isIgnoredElement(parentElement) || + !isElementVisible(parentElement) || + !isInsideSearchableMessageContent(parentElement) + ) { return NodeFilter.FILTER_REJECT } @@ -75,6 +111,192 @@ const collectSearchableTextNodes = (root: ParentNode): Text[] => { return nodes } +const getRowsToHighlight = ( + root: ParentNode, + query: string, + isSameQuery: boolean +): ParentNode[] => { + if (!isSameQuery) { + return [root] + } + + if (!(root instanceof Element || root instanceof Document || root instanceof DocumentFragment)) { + return [] + } + + const rows = Array.from(root.querySelectorAll (MESSAGE_ROW_SELECTOR)) + if (rows.length === 0) { + return [root] + } + + return rows.filter((row) => row.getAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE) !== query) +} + +const markRowsHighlighted = (roots: ParentNode[], query: string): void => { + roots.forEach((root) => { + if (root instanceof HTMLElement && root.matches(MESSAGE_ROW_SELECTOR)) { + root.setAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE, query) + return + } + + if ( + !(root instanceof Element || root instanceof Document || root instanceof DocumentFragment) + ) { + return + } + + root.querySelectorAll (MESSAGE_ROW_SELECTOR).forEach((row) => { + row.setAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE, query) + }) + }) +} + +const clearHighlightedRowMarkers = (root: ParentNode): void => { + if (!(root instanceof Element || root instanceof Document || root instanceof DocumentFragment)) { + return + } + + root.querySelectorAll (`[${HIGHLIGHTED_QUERY_ATTRIBUTE}]`).forEach((row) => { + row.removeAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE) + }) +} + +const stopHighlightObserver = (root: ParentNode): void => { + const state = highlightedRowObservers.get(root) + if (!state) return + + state.observer.disconnect() + if (state.frame !== null) { + window.cancelAnimationFrame(state.frame) + } + highlightedRowObservers.delete(root) +} + +const getMessageRow = (node: Node): HTMLElement | null => { + const element = node instanceof HTMLElement ? node : node.parentElement + return element?.closest (MESSAGE_ROW_SELECTOR) ?? null +} + +const clearHighlightsInRoot = (root: ParentNode): void => { + root.querySelectorAll (HIGHLIGHT_SELECTOR).forEach((highlight) => { + const parent = highlight.parentNode + if (!parent) { + return + } + + parent.replaceChild(document.createTextNode(highlight.textContent ?? ''), highlight) + parent.normalize() + }) +} + +// A same-query re-apply tears the observer down before its pending refresh runs, +// yet those rows still carry the highlighted-query marker and would be skipped +// by every future same-query scan. Strip their marks and markers first so the +// caller's rescan rebuilds them. +const drainHighlightObserver = (root: ParentNode): void => { + const state = highlightedRowObservers.get(root) + if (!state) return + + const pendingRows = new Set(state.rowsToRefresh) + state.observer.takeRecords().forEach((record) => { + const row = getMessageRow(record.target) + if (row) { + pendingRows.add(row) + } + }) + stopHighlightObserver(root) + pendingRows.forEach((row) => { + clearHighlightsInRoot(row) + row.removeAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE) + }) +} + +const highlightRow = (row: HTMLElement, query: string): void => { + clearHighlightsInRoot(row) + row.removeAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE) + + collectSearchableTextNodes(row).forEach((node) => { + const fragment = buildHighlightedFragment(node.nodeValue ?? '', query) + if (fragment && node.parentNode) { + node.parentNode.replaceChild(fragment, node) + } + }) + + row.setAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE, query) +} + +const observeHighlightedRows = (root: ParentNode, query: string): void => { + if (!(root instanceof HTMLElement) || typeof MutationObserver === 'undefined') { + return + } + + stopHighlightObserver(root) + let state: HighlightObserverState + const scheduleRefresh = () => { + if (state.frame !== null) return + + state.frame = window.requestAnimationFrame(() => { + state.frame = null + if (state.isApplyingHighlights || getAppliedSearchQuery(root) !== query) { + state.rowsToRefresh.clear() + return + } + + const rows = Array.from(state.rowsToRefresh) + state.rowsToRefresh.clear() + state.isApplyingHighlights = true + try { + rows.forEach((row) => highlightRow(row, query)) + // MutationObserver callbacks run after this task. Drain mutations made by our + // own mark replacement before dropping the guard so they cannot enqueue work. + observer.takeRecords() + } finally { + state.isApplyingHighlights = false + } + }) + } + const observer = new MutationObserver((records) => { + if (state.isApplyingHighlights || getAppliedSearchQuery(root) !== query) { + return + } + + records.forEach((record) => { + const targetRow = getMessageRow(record.target) + if (targetRow) { + state.rowsToRefresh.add(targetRow) + } + + record.addedNodes.forEach((node) => { + if (!(node instanceof HTMLElement)) return + if (node.matches(MESSAGE_ROW_SELECTOR)) { + if (node.getAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE) !== query) { + state.rowsToRefresh.add(node) + } + return + } + node.querySelectorAll (MESSAGE_ROW_SELECTOR).forEach((row) => { + if (row.getAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE) !== query) { + state.rowsToRefresh.add(row) + } + }) + }) + }) + + if (state.rowsToRefresh.size > 0) { + scheduleRefresh() + } + }) + + state = { + observer, + frame: null, + rowsToRefresh: new Set(), + isApplyingHighlights: false + } + observer.observe(root, { childList: true, characterData: true, subtree: true }) + highlightedRowObservers.set(root, state) +} + const buildHighlightedFragment = (value: string, query: string): DocumentFragment | null => { const fragment = document.createDocumentFragment() const lowerValue = value.toLowerCase() @@ -157,6 +379,8 @@ export const clearChatSearchHighlights = (root: ParentNode | null | undefined): highlight.classList.remove('chat-search-highlight--active') }) + clearHighlightedRowMarkers(root) + stopHighlightObserver(root) setAppliedSearchQuery(root, null) } @@ -174,25 +398,34 @@ export const applyChatSearchHighlights = ( return [] } - // Same query: keep existing marks and only highlight newly mounted text nodes. - // Full clear+rebuild on every virtual-window change is the main flicker source. + // Same query: retain existing marks and walk only virtual-list rows that were + // mounted since their last highlight pass. This avoids scanning the entire + // visible message tree on every window shift. const appliedQuery = getAppliedSearchQuery(root) - if (appliedQuery !== null && appliedQuery !== normalizedQuery) { + const isSameQuery = appliedQuery === normalizedQuery + if (appliedQuery !== null && !isSameQuery) { clearChatSearchHighlights(root) + } else if (isSameQuery) { + drainHighlightObserver(root) } - const searchableNodes = collectSearchableTextNodes(root) - searchableNodes.forEach((node) => { - const value = node.nodeValue ?? '' - const fragment = buildHighlightedFragment(value, normalizedQuery) - if (!fragment || !node.parentNode) { - return - } + const rootsToHighlight = getRowsToHighlight(root, normalizedQuery, isSameQuery) + rootsToHighlight.forEach((highlightRoot) => { + const searchableNodes = collectSearchableTextNodes(highlightRoot) + searchableNodes.forEach((node) => { + const value = node.nodeValue ?? '' + const fragment = buildHighlightedFragment(value, normalizedQuery) + if (!fragment || !node.parentNode) { + return + } - node.parentNode.replaceChild(fragment, node) + node.parentNode.replaceChild(fragment, node) + }) }) + markRowsHighlighted(rootsToHighlight, normalizedQuery) setAppliedSearchQuery(root, normalizedQuery) + observeHighlightedRows(root, normalizedQuery) return Array.from(root.querySelectorAll (HIGHLIGHT_SELECTOR)) } @@ -210,30 +443,59 @@ const countOccurrences = (value: string, query: string): number => { return count } -const collectUnknownText = (value: unknown, output: string[]): void => { - if (typeof value === 'string') { - output.push(value) +const isDisplayUserMessageContent = (content: unknown): content is DisplayUserMessageContent => { + if (!content || typeof content !== 'object' || Array.isArray(content)) { + return false + } + + const value = content as Record + return typeof value.text === 'string' +} + +const appendDisplayContentText = (content: unknown, output: string[]): void => { + if (!content || typeof content !== 'object') return + + if (Array.isArray(content)) { + content.forEach((block) => { + if (!block || typeof block !== 'object') return + const record = block as Record + const extra = record.extra as Record | undefined + // Tool-call labels live in ignored buttons and their details are collapsed and + // aria-hidden by default, so indexing them would create non-activatable results. + if (record.type === 'plan' || record.type === 'tool_call' || extra?.internalTool === true) { + return + } + if (typeof record.content === 'string') { + output.push(record.content) + } + }) return } - if (!value || typeof value !== 'object') return - if (Array.isArray(value)) { - value.forEach((item) => collectUnknownText(item, output)) + + if (!isDisplayUserMessageContent(content)) { return } - const record = value as Record - for (const key of [ - 'text', - 'content', - 'name', - 'params', - 'response', - 'server_name', - 'server_description', - 'tool_call' - ]) { - collectUnknownText(record[key], output) + // Use the same block projection MessageItemUser renders. This deliberately + // excludes standalone attachment/skill metadata, which is outside the + // message body and marked as non-searchable for the DOM highlighter. + const visibleBlocks = getVisibleUserContentBlocks(content) + if (visibleBlocks.length === 0) { + output.push(content.text) + return } + + visibleBlocks.forEach((block) => { + if (block.type === 'mention') { + output.push(getVisibleMentionLabel(block)) + } else if (block.type === 'skill') { + output.push(block.skillName) + } else if (block.type === 'file') { + output.push(block.fileName) + } else { + output.push(block.content) + } + }) } export const collectChatSearchResults = ( @@ -246,7 +508,7 @@ export const collectChatSearchResults = ( const results: ChatSearchResult[] = [] for (const message of messages) { const chunks: string[] = [] - collectUnknownText(message.content, chunks) + appendDisplayContentText(message.content, chunks) let matchIndex = 0 for (const chunk of chunks) { const count = countOccurrences(chunk, normalizedQuery) diff --git a/src/renderer/src/pages/NewThreadPage.vue b/src/renderer/src/pages/NewThreadPage.vue index 960932497d..e9a60e7d77 100644 --- a/src/renderer/src/pages/NewThreadPage.vue +++ b/src/renderer/src/pages/NewThreadPage.vue @@ -105,7 +105,7 @@ :session-id="acpDraftSessionId" :workspace-path="projectStore.selectedProject?.path ?? null" :is-acp-session="isAcpSelectedAgent" - :submit-disabled="isAcpWorkdirUnavailable" + :submit-disabled="isAcpWorkdirUnavailable || isSubmitting" @update:files="onFilesChange" @pending-skills-change="onPendingSkillsChange" @command-submit="onCommandSubmit" @@ -117,7 +117,7 @@ :show-voice-input="isVoiceInputEnabled" :is-voice-input-listening="isVoiceInputListening" :is-voice-input-transcribing="isVoiceInputTranscribing" - :send-disabled="isAcpWorkdirUnavailable || !message.trim()" + :send-disabled="isAcpWorkdirUnavailable || isSubmitting || !message.trim()" @attach="onAttach" @voice-input="onToggleVoiceInput" @send="onSubmit" @@ -231,6 +231,7 @@ const modelGuideTargetRef = ref (null) const firstChatGuideHostRef = ref (null) const firstChatGuideTargetRef = ref (null) const isVoiceInputEnabled = ref(false) +const isSubmitting = ref(false) const chatInputRef = ref<{ triggerAttach: () => void insertRecognizedText?: (text: string) => void @@ -773,55 +774,63 @@ const resolveStartModelSelection = ( : null } +const isCurrentStartDeeplink = (token: number): boolean => + draftStore.pendingStartDeeplink?.token === token + const applyStartDeeplink = async (payload: StartDeeplinkPayload) => { const draftDefaultsTask = currentDraftDefaultsTask if (draftDefaultsTask) { await draftDefaultsTask + if (!isCurrentStartDeeplink(payload.token)) return } await nextTick() + if (!isCurrentStartDeeplink(payload.token)) return + message.value = buildStartMessage(payload) draftStore.systemPrompt = payload.systemPrompt const modelsReady = await ensureEnabledModelsReady() + if (!isCurrentStartDeeplink(payload.token)) return + const matchedModel = modelsReady ? resolveStartModelSelection(payload.modelId) : null if (matchedModel) { draftStore.providerId = matchedModel.providerId draftStore.modelId = matchedModel.modelId } - draftStore.clearPendingStartDeeplink() + if (isCurrentStartDeeplink(payload.token)) { + draftStore.clearPendingStartDeeplink() + } } -async function onSubmit() { - if (isAcpWorkdirUnavailable.value) return - - const text = message.value.trim() - if (!text) return - if (shouldIgnoreManualCompactionDraft(text)) return - const files = (await prepareFilesForCurrentModel([...attachedFiles.value])).map((f) => toRaw(f)) +async function submitDraft(text: string, clearMessage: boolean) { + if (isSubmitting.value || isAcpWorkdirUnavailable.value) return + if (!text || shouldIgnoreManualCompactionDraft(text)) return + isSubmitting.value = true try { + const files = (await prepareFilesForCurrentModel([...attachedFiles.value])).map((file) => + toRaw(file) + ) await submitText(text, files) - message.value = '' + if (clearMessage) { + message.value = '' + } attachedFiles.value = [] - } catch (e) { - console.error('[NewThreadPage] submit failed:', e) + } catch (error) { + console.error('[NewThreadPage] submit failed:', error) + } finally { + isSubmitting.value = false } } +async function onSubmit() { + await submitDraft(message.value.trim(), true) +} + async function onCommandSubmit(command: string) { - if (isAcpWorkdirUnavailable.value) return - const text = command.trim() - if (!text) return - if (shouldIgnoreManualCompactionDraft(text)) return - const files = (await prepareFilesForCurrentModel([...attachedFiles.value])).map((f) => toRaw(f)) - try { - await submitText(text, files) - attachedFiles.value = [] - } catch (e) { - console.error('[NewThreadPage] submit failed:', e) - } + await submitDraft(command.trim(), false) } function shouldIgnoreManualCompactionDraft(text: string): boolean { @@ -928,6 +937,7 @@ const applyDraftDefaultsForSelectedAgent = async (requestSeq: number): Promise import('@/views/ChatTabView.vue'), + component: () => import('@/apps/chat-main/ChatTabView.vue'), meta: { titleKey: 'routes.chat', icon: 'lucide:message-square' diff --git a/src/renderer/src/stores/floatingButton.ts b/src/renderer/src/stores/floatingButton.ts index f31379b6fb..c88d040598 100644 --- a/src/renderer/src/stores/floatingButton.ts +++ b/src/renderer/src/stores/floatingButton.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { ref, onMounted } from 'vue' +import { ref, onMounted, onScopeDispose } from 'vue' import { createConfigClient } from '../../api/ConfigClient' export const useFloatingButtonStore = defineStore('floatingButton', () => { @@ -8,6 +8,9 @@ export const useFloatingButtonStore = defineStore('floatingButton', () => { // 悬浮按钮是否启用的状态 const enabled = ref (false) let listenerRegistered = false + let stateRevision = 0 + let initialization: Promise | null = null + let removeFloatingButtonListener: (() => void) | null = null // 获取悬浮按钮启用状态 const getFloatingButtonEnabled = async (): Promise => { @@ -19,44 +22,68 @@ export const useFloatingButtonStore = defineStore('floatingButton', () => { } } + const setupFloatingButtonListener = () => { + if (listenerRegistered) { + return + } + + listenerRegistered = true + removeFloatingButtonListener = configClient.onFloatingButtonChanged((payload) => { + stateRevision += 1 + enabled.value = Boolean(payload.enabled) + }) + } + // 设置悬浮按钮启用状态 const setFloatingButtonEnabled = async (value: boolean) => { + const previousEnabled = enabled.value + const requestRevision = ++stateRevision + enabled.value = Boolean(value) try { - enabled.value = Boolean(value) await configClient.setFloatingButtonEnabled(value) } catch (error) { + if (requestRevision === stateRevision) { + enabled.value = previousEnabled + } console.error('Failed to set floating button enabled status:', error) - // 如果设置失败,回滚本地状态 - enabled.value = !value } } // 初始化状态 const initializeState = async () => { - try { - const currentEnabled = await getFloatingButtonEnabled() - enabled.value = currentEnabled - setupFloatingButtonListener() - } catch (error) { - console.error('Failed to initialize floating button state:', error) - enabled.value = false + if (initialization) { + return initialization } - } - const setupFloatingButtonListener = () => { - if (listenerRegistered) { - return - } + const task = (async () => { + try { + // Subscribe before reading the snapshot so an IPC update cannot be lost. + setupFloatingButtonListener() + const snapshotRevision = stateRevision + const currentEnabled = await getFloatingButtonEnabled() + if (snapshotRevision === stateRevision) { + enabled.value = currentEnabled + } + } catch (error) { + console.error('Failed to initialize floating button state:', error) + } + })() - listenerRegistered = true - configClient.onFloatingButtonChanged((payload) => { - enabled.value = Boolean(payload.enabled) - }) + initialization = task + return task } // 在组件挂载时初始化 - onMounted(async () => { - await initializeState() + onMounted(() => { + void initializeState() + }) + + onScopeDispose(() => { + removeFloatingButtonListener?.() + removeFloatingButtonListener = null + listenerRegistered = false + initialization = null + stateRevision += 1 }) return { diff --git a/src/renderer/src/stores/language.ts b/src/renderer/src/stores/language.ts index 6e95a5cb3d..b5c124c2a4 100644 --- a/src/renderer/src/stores/language.ts +++ b/src/renderer/src/stores/language.ts @@ -3,20 +3,20 @@ import { onMounted, onScopeDispose, shallowRef } from 'vue' import { useI18n } from 'vue-i18n' import { createConfigClient } from '@api/ConfigClient' +import { resolveDocumentDirection } from '@/foundation/appearance/documentAppearance' import { loadLocaleMessages, resolveSupportedLocale } from '@/i18n' import type { RendererLanguageState } from '@/i18n/bootstrap' -const RTL_LIST = ['fa-IR', 'he-IL'] - export const useLanguageStore = defineStore('language', () => { const { locale, setLocaleMessage } = useI18n({ useScope: 'global' }) const language = shallowRef ('system') const configClient = createConfigClient() const initialLocale = resolveSupportedLocale(locale.value) - const dir = shallowRef<'auto' | 'rtl'>(RTL_LIST.includes(initialLocale) ? 'rtl' : 'auto') + const dir = shallowRef<'auto' | 'rtl' | 'ltr'>(resolveDocumentDirection(initialLocale)) let transitionRevision = 0 let updateRequestRevision = 0 let removeLanguageListener: (() => void) | undefined + let languageInitialization: Promise | null = null const applyLanguageState = async (state: RendererLanguageState, revision: number) => { const resolvedLocale = resolveSupportedLocale(state.locale) @@ -28,7 +28,12 @@ export const useLanguageStore = defineStore('language', () => { setLocaleMessage(resolvedLocale, messages) locale.value = resolvedLocale language.value = state.requestedLanguage || 'system' - dir.value = state.direction === 'rtl' || RTL_LIST.includes(resolvedLocale) ? 'rtl' : 'auto' + dir.value = + state.direction === 'rtl' || resolveDocumentDirection(resolvedLocale) === 'rtl' + ? 'rtl' + : state.direction === 'ltr' + ? 'ltr' + : 'auto' return true } catch (error) { if (revision === transitionRevision) { @@ -48,15 +53,28 @@ export const useLanguageStore = defineStore('language', () => { } const initLanguage = async () => { + if (languageInitialization) { + return languageInitialization + } + ensureLanguageListener() const revision = ++transitionRevision - try { - const languageState = await configClient.getLanguageState() - await applyLanguageState(languageState, revision) - } catch (error) { - console.error('初始化语言失败:', error) - } + const initialization = (async () => { + try { + const languageState = await configClient.getLanguageState() + const applied = await applyLanguageState(languageState, revision) + if (!applied && revision === transitionRevision) { + languageInitialization = null + } + } catch (error) { + languageInitialization = null + console.error('初始化语言失败:', error) + } + })() + + languageInitialization = initialization + return initialization } const updateLanguage = async (newLanguage: string) => { @@ -76,6 +94,7 @@ export const useLanguageStore = defineStore('language', () => { onScopeDispose(() => { removeLanguageListener?.() removeLanguageListener = undefined + languageInitialization = null }) return { diff --git a/src/renderer/src/stores/theme.ts b/src/renderer/src/stores/theme.ts index a32aad29f4..f88aaa3ff8 100644 --- a/src/renderer/src/stores/theme.ts +++ b/src/renderer/src/stores/theme.ts @@ -1,6 +1,6 @@ import { useDark, useToggle } from '@vueuse/core' import { defineStore } from 'pinia' -import { ref } from 'vue' +import { onScopeDispose, ref } from 'vue' import { createConfigClient } from '../../api/ConfigClient' export type ThemeMode = 'dark' | 'light' | 'system' @@ -9,42 +9,27 @@ export const useThemeStore = defineStore('theme', () => { const isDark = useDark() const toggleDark = useToggle(isDark) const configClient = createConfigClient() - let listenersRegistered = false - - // 存储当前主题模式 const themeMode = ref ('system') + let listenersRegistered = false + let stateRevision = 0 + let themeInitialization: Promise | null = null + let removeThemeListeners: (() => void) | null = null - // 初始化主题 - const initTheme = async () => { - const currentTheme = (await configClient.getTheme()) as ThemeMode - themeMode.value = currentTheme - - // 获取当前实际的深色模式状态 - const isDarkMode = await configClient.getCurrentThemeIsDark() - console.log('initTheme - theme:', currentTheme, 'isDark:', isDarkMode) - toggleDark(isDarkMode) - setupThemeListeners() + const applyThemeState = (theme: ThemeMode, dark: boolean) => { + themeMode.value = theme + toggleDark(dark) } - initTheme() - const handleSystemThemeChange = (payload: { isDark: boolean }) => { - const isDarkMode = payload.isDark - console.log('handleSystemThemeChange', isDarkMode) - // 只有在系统模式下才跟随系统主题变化 + stateRevision += 1 if (themeMode.value === 'system') { - toggleDark(isDarkMode) + toggleDark(payload.isDark) } } - const handleUserThemeChange = (payload: { theme: ThemeMode }) => { - const theme = payload.theme - if (themeMode.value !== theme) { - configClient.getCurrentThemeIsDark().then((isDark) => { - console.log('handleUserThemeChange', theme, isDark) - themeMode.value = theme - toggleDark(isDark) - }) - } + + const handleUserThemeChange = (payload: { theme: ThemeMode; isDark: boolean }) => { + stateRevision += 1 + applyThemeState(payload.theme, payload.isDark) } const setupThemeListeners = () => { @@ -53,31 +38,72 @@ export const useThemeStore = defineStore('theme', () => { } listenersRegistered = true - configClient.onSystemThemeChanged(handleSystemThemeChange) - configClient.onThemeChanged(handleUserThemeChange) + const unsubscribeSystemTheme = configClient.onSystemThemeChanged(handleSystemThemeChange) + const unsubscribeTheme = configClient.onThemeChanged(handleUserThemeChange) + removeThemeListeners = () => { + unsubscribeSystemTheme() + unsubscribeTheme() + removeThemeListeners = null + listenersRegistered = false + stateRevision += 1 + } + } + + // Register listeners before reading the snapshot so updates from another window cannot be lost. + const initTheme = async () => { + if (themeInitialization) { + return themeInitialization + } + + const initialization = (async () => { + try { + setupThemeListeners() + const snapshotRevision = stateRevision + const themeState = await configClient.getThemeState() + if (snapshotRevision !== stateRevision) { + return + } + + applyThemeState(themeState.theme, themeState.isDark) + } catch (error) { + themeInitialization = null + console.error('初始化主题失败:', error) + } + })() + + themeInitialization = initialization + return initialization } - // 设置主题模式 const setThemeMode = async (mode: ThemeMode) => { + const requestRevision = ++stateRevision themeMode.value = mode const isDarkMode = await configClient.setTheme(mode) + if (requestRevision !== stateRevision) { + return + } - // 设置界面深色/浅色状态 toggleDark(isDarkMode) } // 循环切换主题:light -> dark -> system -> light const cycleTheme = async () => { - console.log('cycleTheme', themeMode.value) if (themeMode.value === 'light') await setThemeMode('dark') else if (themeMode.value === 'dark') await setThemeMode('system') else await setThemeMode('light') } + onScopeDispose(() => { + removeThemeListeners?.() + }) + + void initTheme() + return { isDark, toggleDark, themeMode, + initTheme, cycleTheme, setThemeMode } diff --git a/src/renderer/src/stores/ui/draft.ts b/src/renderer/src/stores/ui/draft.ts index e6a1af6377..6dc8d6e0b0 100644 --- a/src/renderer/src/stores/ui/draft.ts +++ b/src/renderer/src/stores/ui/draft.ts @@ -15,7 +15,6 @@ export interface StartDeeplinkPayload { modelId: string | null systemPrompt: string mentions: string[] - autoSend: boolean } // --- Store --- diff --git a/src/renderer/src/stores/ui/message.ts b/src/renderer/src/stores/ui/message.ts index 80010f49e5..80610d6bc9 100644 --- a/src/renderer/src/stores/ui/message.ts +++ b/src/renderer/src/stores/ui/message.ts @@ -69,6 +69,10 @@ export const useMessageStore = defineStore('message', () => { const nextCursor = ref (null) const hasMoreHistory = ref(false) const isLoadingHistory = ref(false) + // History pagination errors are intentionally kept separate from exhaustion. A + // failed request must not make the UI look like the conversation has reached + // its beginning, and the error is only exposed for the current committed view. + const historyLoadError = ref(false) const parsedMessageCache = new Map () const recentSessionViews = new RecentMessageViewCache() const messageMutationRevisions = new Map () @@ -96,6 +100,17 @@ export const useMessageStore = defineStore('message', () => { return left < right ? -1 : 1 } + function getNextLocalOrderSeq(): number { + let maxOrderSeq = 0 + for (const id of messageIds.value) { + const orderSeq = messageCache.value.get(id)?.orderSeq + if (typeof orderSeq === 'number' && Number.isFinite(orderSeq)) { + maxOrderSeq = Math.max(maxOrderSeq, orderSeq) + } + } + return maxOrderSeq + 1 + } + function sortMessageIdsByOrderSeq(): void { messageIds.value.sort((a, b) => { const aSeq = messageCache.value.get(a)?.orderSeq ?? Number.MAX_SAFE_INTEGER @@ -369,6 +384,32 @@ export const useMessageStore = defineStore('message', () => { ) } + function cacheStreamingAssistantBlocks( + record: ChatMessageRecord, + blocks: AssistantMessageBlock[] + ): void { + const cached = parsedMessageCache.get(record.id) + const previousBlocks = cached?.assistantBlocks ?? cached?.prevAssistantBlocks + const assistantBlocks = reuseStableAssistantBlocks( + blocks as DisplayAssistantMessageBlock[], + previousBlocks + ) + const entry: ParsedMessageCacheEntry = { + updatedAt: record.updatedAt, + content: record.content, + metadata: record.metadata, + assistantBlocks, + prevAssistantBlocks: assistantBlocks + } + + if (cached?.metadata === record.metadata && cached.parsedMetadata) { + entry.parsedMetadata = cached.parsedMetadata + } + + parsedMessageCache.delete(record.id) + setParsedEntry(record.id, entry) + } + function getAssistantMessageBlocks(record: ChatMessageRecord): DisplayAssistantMessageBlock[] { const entry = getParsedEntry(record) if (entry.assistantBlocks) { @@ -448,6 +489,7 @@ export const useMessageStore = defineStore('message', () => { latestHistoryRequestId += 1 latestLoadSessionId = null isLoadingHistory.value = false + historyLoadError.value = false currentSessionId.value = sessionId } @@ -526,6 +568,7 @@ export const useMessageStore = defineStore('message', () => { nextCursor.value = view.nextCursor hasMoreHistory.value = view.hasMoreHistory isLoadingHistory.value = false + historyLoadError.value = false lastPersistedRevision.value += 1 if (options.clearRecentViewDirty) { dirtyRecentSessionViews.delete(view.sessionId) @@ -562,6 +605,7 @@ export const useMessageStore = defineStore('message', () => { latestHistoryRequestId += 1 latestLoadSessionId = null isLoadingHistory.value = false + historyLoadError.value = false commitSessionView(cachedView) return true } @@ -638,6 +682,7 @@ export const useMessageStore = defineStore('message', () => { latestHistoryRequestId += 1 latestLoadSessionId = sessionId isLoadingHistory.value = false + historyLoadError.value = false const mutationRevisionAtStart = getMessageMutationRevision(sessionId) const cacheInvalidationRevisionAtStart = getRecentViewInvalidationRevision(sessionId) try { @@ -707,6 +752,7 @@ export const useMessageStore = defineStore('message', () => { const sessionId = committedSessionId.value const requestId = ++latestHistoryRequestId + historyLoadError.value = false isLoadingHistory.value = true try { const page = await sessionClient.listMessagesPage(sessionId, { @@ -739,6 +785,9 @@ export const useMessageStore = defineStore('message', () => { return incomingIds.length } catch (error) { console.error('Failed to load older messages:', error) + if (isCurrentHistoryRequest(requestId, sessionId)) { + historyLoadError.value = true + } return 0 } finally { if (isCurrentHistoryRequest(requestId, sessionId)) { @@ -774,7 +823,7 @@ export const useMessageStore = defineStore('message', () => { const record: ChatMessageRecord = { id, sessionId, - orderSeq: messageIds.value.length + 1, + orderSeq: getNextLocalOrderSeq(), role: 'user', content: JSON.stringify({ text: normalizedInput.text, @@ -825,6 +874,7 @@ export const useMessageStore = defineStore('message', () => { nextCursor.value = null hasMoreHistory.value = false isLoadingHistory.value = false + historyLoadError.value = false parsedMessageCache.clear() hydratingStreamMessageIds.clear() recentSessionViews.clear() @@ -876,35 +926,41 @@ export const useMessageStore = defineStore('message', () => { existing.status === 'pending' && existing.metadata === nextMetadata ) { + cacheStreamingAssistantBlocks(existing, blocks) return } markLiveMessageViewMutation(conversationId) - upsertMessageRecord({ + const nextRecord: ChatMessageRecord = { ...existing, content: serializedBlocks, metadata: nextMetadata, status: 'pending', updatedAt: Date.now() - }) + } + upsertMessageRecord(nextRecord) + cacheStreamingAssistantBlocks(nextRecord, blocks) return } if (hydratingStreamMessageIds.has(messageId)) return hydratingStreamMessageIds.add(messageId) markLiveMessageViewMutation(conversationId) - upsertMessageRecord({ + const now = Date.now() + const nextRecord: ChatMessageRecord = { id: messageId, sessionId: conversationId, - orderSeq: messageIds.value.length + 1, + orderSeq: getNextLocalOrderSeq(), role: 'assistant', content: serializedBlocks, status: 'pending', isContextEdge: 0, metadata: serializedMetadata, traceCount: 0, - createdAt: Date.now(), - updatedAt: Date.now() - }) + createdAt: now, + updatedAt: now + } + upsertMessageRecord(nextRecord) + cacheStreamingAssistantBlocks(nextRecord, blocks) hydratingStreamMessageIds.delete(messageId) } @@ -949,6 +1005,7 @@ export const useMessageStore = defineStore('message', () => { nextCursor, hasMoreHistory, isLoadingHistory, + historyLoadError, messages, getAssistantMessageBlocks, getUserMessageContent, diff --git a/src/renderer/src/stores/ui/project.ts b/src/renderer/src/stores/ui/project.ts index 2c80d0824d..49bf4f6754 100644 --- a/src/renderer/src/stores/ui/project.ts +++ b/src/renderer/src/stores/ui/project.ts @@ -1,11 +1,9 @@ +import { computed, ref } from 'vue' import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import { createConfigClient } from '../../../api/ConfigClient' import { createProjectClient } from '@api/ProjectClient' +import { createConfigClient } from '../../../api/ConfigClient' import type { EnvironmentSummary, Project } from '@shared/types/agent-interface' -// --- Type Definitions --- - export interface UIProject { name: string path: string @@ -16,13 +14,18 @@ export interface UIProject { type ProjectSelectionSource = 'none' | 'manual' | 'default' -// --- Store --- +type ProjectSnapshot = { + version: number + projects: Project[] + environments: EnvironmentSummary[] + archivedEnvironments: EnvironmentSummary[] + removedEnvironments: EnvironmentSummary[] + defaultProjectPath: string | null +} export const useProjectStore = defineStore('project', () => { - const configClient = createConfigClient() const projectClient = createProjectClient() - - // --- State --- + const configClient = createConfigClient() const projects = ref ([]) const environments = ref ([]) const archivedEnvironments = ref ([]) @@ -33,17 +36,15 @@ export const useProjectStore = defineStore('project', () => { const selectionSource = ref ('none') const error = ref (null) let listenersRegistered = false + let committedSnapshotVersion = -1 + let requestedSnapshotVersion = 0 + let refreshPromise: Promise | null = null - // --- Getters --- const selectedProject = computed(() => - projects.value.find((p) => p.path === selectedProjectPath.value) + projects.value.find((project) => project.path === selectedProjectPath.value) ) - const normalizePath = (path: string | null | undefined): string | null => { - const normalized = path?.trim() - return normalized ? normalized : null - } - + const normalizePath = (path: string | null | undefined): string | null => path?.trim() || null const createSyntheticProject = (projectPath: string): UIProject => ({ name: projectPath.split(/[/\\]/).pop() ?? projectPath, path: projectPath, @@ -52,10 +53,9 @@ export const useProjectStore = defineStore('project', () => { isSynthetic: true }) - const reconcileProjects = (baseProjects: UIProject[]): UIProject[] => { + function reconcileProjects(baseProjects: UIProject[]): UIProject[] { const nextProjects = baseProjects.filter((project) => !project.isSynthetic) const syntheticPaths: string[] = [] - if ( selectionSource.value === 'manual' && selectedProjectPath.value && @@ -63,7 +63,6 @@ export const useProjectStore = defineStore('project', () => { ) { syntheticPaths.push(selectedProjectPath.value) } - if ( defaultProjectPath.value && !nextProjects.some((project) => project.path === defaultProjectPath.value) && @@ -71,11 +70,10 @@ export const useProjectStore = defineStore('project', () => { ) { syntheticPaths.unshift(defaultProjectPath.value) } - return [...syntheticPaths.map(createSyntheticProject), ...nextProjects] } - const applyDefaultSelection = () => { + function applyDefaultSelection(): void { if (!defaultProjectPath.value) { if (selectionSource.value === 'default') { selectedProjectPath.value = null @@ -83,105 +81,93 @@ export const useProjectStore = defineStore('project', () => { } return } - if (selectionSource.value === 'none' || selectionSource.value === 'default') { selectedProjectPath.value = defaultProjectPath.value selectionSource.value = 'default' } } - const handleDefaultProjectPathChanged = ( - _event?: unknown, - payload?: string | { path?: string | null } - ) => { - defaultProjectPath.value = normalizePath( - typeof payload === 'string' ? payload : (payload?.path ?? null) + function applySnapshot(snapshot: ProjectSnapshot): boolean { + if (snapshot.version < committedSnapshotVersion) return false + committedSnapshotVersion = snapshot.version + defaultProjectPath.value = normalizePath(snapshot.defaultProjectPath) + projects.value = reconcileProjects( + snapshot.projects.map((project) => ({ + name: project.name, + path: project.path, + icon: project.icon, + exists: project.exists + })) ) - projects.value = reconcileProjects(projects.value) + environments.value = snapshot.environments + archivedEnvironments.value = snapshot.archivedEnvironments + removedEnvironments.value = snapshot.removedEnvironments + if ( + selectedProjectPath.value && + selectionSource.value !== 'manual' && + !projects.value.some((project) => project.path === selectedProjectPath.value) + ) { + selectedProjectPath.value = null + selectionSource.value = 'none' + } applyDefaultSelection() + error.value = null + return true } - const applyBootstrapDefaultProjectPath = ( - path: string | null | undefined, - chatWorkspacePath?: string | null - ) => { - defaultProjectPath.value = normalizePath(path) - defaultChatWorkspacePath.value = normalizePath(chatWorkspacePath) - projects.value = reconcileProjects(projects.value) - applyDefaultSelection() + async function refreshProjectSnapshot(minVersion = 0): Promise { + requestedSnapshotVersion = Math.max(requestedSnapshotVersion, minVersion) + if (refreshPromise) { + return refreshPromise + } + + refreshPromise = (async () => { + do { + const targetVersion = requestedSnapshotVersion + try { + const snapshot = (await projectClient.getSnapshot()) as ProjectSnapshot + if (snapshot.version >= targetVersion && snapshot.version >= committedSnapshotVersion) { + applySnapshot(snapshot) + } else { + // Events can become visible before their corresponding snapshot + // projection. Do not spin on a successful but incomplete read; + // leave the last committed state intact until a later refresh. + return + } + } catch (cause) { + // A versioned notification may arrive while the older read fails. In + // that case this single refresh owner must consume the newer target + // instead of publishing a stale failure that strands the store. + if (requestedSnapshotVersion > targetVersion) { + continue + } + error.value = `Failed to load project snapshot: ${cause}` + return + } + } while (committedSnapshotVersion < requestedSnapshotVersion) + })().finally(() => { + refreshPromise = null + }) + return refreshPromise } - const ensureListenersRegistered = () => { + function ensureListenersRegistered(): void { if (listenersRegistered) return - configClient.onDefaultProjectPathChanged(({ path }) => { - handleDefaultProjectPathChanged(undefined, { path }) + projectClient.onEnvironmentsChanged(({ version }) => { + if (version > committedSnapshotVersion) { + void refreshProjectSnapshot(version) + } }) - projectClient.onEnvironmentsChanged(({ action, path }) => { - if (action === 'remove' && path) { - projects.value = projects.value.filter((project) => project.path !== path) - if (selectedProjectPath.value === path) { - selectProject(null) - } + configClient.onDefaultProjectPathChanged(({ version }) => { + if (version > committedSnapshotVersion) { + void refreshProjectSnapshot(version) } - - void refreshProjectData() }) listenersRegistered = true } ensureListenersRegistered() - // --- Actions --- - - async function loadDefaultProjectPath(): Promise { - try { - applyBootstrapDefaultProjectPath(await configClient.getDefaultProjectPath()) - } catch (e) { - error.value = `Failed to load default project path: ${e}` - } - } - - async function fetchProjects(): Promise { - try { - const [result, nextDefaultProjectPath] = await Promise.all([ - projectClient.listRecent(20), - configClient.getDefaultProjectPath() - ]) - - defaultProjectPath.value = normalizePath(nextDefaultProjectPath) - projects.value = reconcileProjects( - (result as Project[]).map((p) => ({ - name: p.name, - path: p.path, - icon: p.icon, - exists: p.exists - })) - ) - applyDefaultSelection() - } catch (e) { - error.value = `Failed to load projects: ${e}` - } - } - - async function fetchEnvironments(): Promise { - try { - const [active, archived, removed] = await Promise.all([ - projectClient.listEnvironments('active'), - projectClient.listEnvironments('archived'), - projectClient.listEnvironments('removed') - ]) - environments.value = active - archivedEnvironments.value = archived - removedEnvironments.value = removed - } catch (e) { - error.value = `Failed to load environments: ${e}` - } - } - - async function refreshProjectData(): Promise { - await Promise.all([fetchProjects(), fetchEnvironments()]) - } - function selectProject( path: string | null, source: ProjectSelectionSource = normalizePath(path) ? 'manual' : 'none' @@ -191,128 +177,109 @@ export const useProjectStore = defineStore('project', () => { projects.value = reconcileProjects(projects.value) } - async function setDefaultProject(path: string | null): Promise { - const normalizedPath = normalizePath(path) - try { - await configClient.setDefaultProjectPath(normalizedPath) - handleDefaultProjectPathChanged(undefined, { path: normalizedPath }) - } catch (e) { - error.value = `Failed to update default project path: ${e}` - throw e + function applyBootstrapDefaultProjectPath( + path: string | null | undefined, + chatWorkspacePath?: string | null + ): void { + // The workspace hint is bootstrap-only, while defaultProjectPath belongs to + // the versioned project snapshot. A delayed bootstrap response therefore + // must not roll back an already committed snapshot. + defaultChatWorkspacePath.value = normalizePath(chatWorkspacePath) + if (committedSnapshotVersion >= 0) { + return } + + defaultProjectPath.value = normalizePath(path) + projects.value = reconcileProjects(projects.value) + applyDefaultSelection() } - async function clearDefaultProject(): Promise { - await setDefaultProject(null) + async function setDefaultProject(path: string | null): Promise { + try { + await configClient.setDefaultProjectPath(normalizePath(path)) + await refreshProjectSnapshot() + } catch (cause) { + error.value = `Failed to update default project path: ${cause}` + throw cause + } } async function reorderEnvironments(paths: string[]): Promise { const normalizedPaths = Array.from( - new Set(paths.map((environmentPath) => normalizePath(environmentPath)).filter(Boolean)) + new Set(paths.map(normalizePath).filter(Boolean)) ) as string[] - - if (normalizedPaths.length === 0) { - return - } - - const previousEnvironments = environments.value - const byPath = new Map( - previousEnvironments.map((environment) => [environment.path, environment]) - ) - const orderedPaths = normalizedPaths.filter((environmentPath) => byPath.has(environmentPath)) - - if (orderedPaths.length === 0) { - return - } - - const orderedPathSet = new Set(orderedPaths) - - environments.value = [ - ...orderedPaths.map((environmentPath, index) => ({ - ...byPath.get(environmentPath)!, - sortOrder: index - })), - ...previousEnvironments.filter((environment) => !orderedPathSet.has(environment.path)) - ] - + if (normalizedPaths.length === 0) return + const activePaths = new Set(environments.value.map((environment) => environment.path)) + const orderedPaths = normalizedPaths.filter((path) => activePaths.has(path)) + if (orderedPaths.length === 0) return try { await projectClient.reorderEnvironments(orderedPaths) - await fetchEnvironments() - } catch (e) { - environments.value = previousEnvironments - error.value = `Failed to reorder environments: ${e}` - throw e + await refreshProjectSnapshot() + } catch (cause) { + error.value = `Failed to reorder environments: ${cause}` + await refreshProjectSnapshot() + throw cause } } async function archiveEnvironment(path: string): Promise { try { await projectClient.archiveEnvironment(path) - await fetchEnvironments() - } catch (e) { - error.value = `Failed to archive environment: ${e}` - throw e + await refreshProjectSnapshot() + } catch (cause) { + error.value = `Failed to archive environment: ${cause}` + throw cause } } async function restoreEnvironment(path: string): Promise { try { await projectClient.restoreEnvironment(path) - await fetchEnvironments() - } catch (e) { - error.value = `Failed to restore environment: ${e}` - throw e + await refreshProjectSnapshot() + } catch (cause) { + error.value = `Failed to restore environment: ${cause}` + throw cause } } async function removeEnvironment(path: string): Promise<{ clearedSessionIds: string[] }> { try { const result = await projectClient.removeEnvironment(path) - projects.value = projects.value.filter((project) => project.path !== path) - if (selectedProjectPath.value === path) { - selectProject(null) - } - await Promise.all([loadDefaultProjectPath(), fetchEnvironments()]) + await refreshProjectSnapshot() return result - } catch (e) { - error.value = `Failed to remove environment: ${e}` - throw e + } catch (cause) { + error.value = `Failed to remove environment: ${cause}` + throw cause } } async function openDirectory(path: string): Promise { try { await projectClient.openDirectory(path) - } catch (e) { - error.value = `Failed to open directory: ${e}` - throw e + } catch (cause) { + error.value = `Failed to open directory: ${cause}` + throw cause } } - async function refreshEnvironmentData(): Promise { - await Promise.all([loadDefaultProjectPath(), fetchEnvironments()]) - } - async function openFolderPicker(): Promise { try { const selectedPath = await projectClient.selectDirectory() if (selectedPath) { - const name = selectedPath.split(/[/\\]/).pop() ?? selectedPath - const nextProjects = projects.value.filter((project) => project.path !== selectedPath) - nextProjects.unshift({ - name, - path: selectedPath, - icon: null, - exists: true - }) - projects.value = reconcileProjects(nextProjects) selectProject(selectedPath, 'manual') + await refreshProjectSnapshot() } - } catch (e) { - error.value = `Failed to open folder picker: ${e}` + } catch (cause) { + error.value = `Failed to open folder picker: ${cause}` } } + // Compatibility aliases keep callers on the single snapshot owner. + const fetchProjects = refreshProjectSnapshot + const fetchEnvironments = refreshProjectSnapshot + const loadDefaultProjectPath = refreshProjectSnapshot + const refreshEnvironmentData = refreshProjectSnapshot + return { projects, environments, @@ -329,9 +296,10 @@ export const useProjectStore = defineStore('project', () => { loadDefaultProjectPath, applyBootstrapDefaultProjectPath, refreshEnvironmentData, + refreshProjectSnapshot, selectProject, setDefaultProject, - clearDefaultProject, + clearDefaultProject: () => setDefaultProject(null), reorderEnvironments, archiveEnvironment, restoreEnvironment, diff --git a/src/renderer/src/stores/ui/session.ts b/src/renderer/src/stores/ui/session.ts index 1fef3c0b8d..1b32ed7058 100644 --- a/src/renderer/src/stores/ui/session.ts +++ b/src/renderer/src/stores/ui/session.ts @@ -43,6 +43,7 @@ export interface UISession { metadata?: SessionMetadata | null createdAt: number updatedAt: number + revision?: number } export interface UIActiveSessionSummary extends UISession { @@ -108,7 +109,8 @@ function mapToUISession(session: SessionListItem | SessionWithState): UISession subagentMeta: session.subagentMeta ?? null, ...(metadata ? { metadata } : {}), createdAt: session.createdAt, - updatedAt: session.updatedAt + updatedAt: session.updatedAt, + revision: session.revision } } @@ -247,11 +249,40 @@ function sortSessions(items: UISession[]): UISession[] { }) } +function isStaleOrSameSessionUpdate(existing: UISession, update: UISession): boolean { + const existingRevision = existing.revision + const updateRevision = update.revision + const hasExistingRevision = Number.isFinite(existingRevision) + const hasUpdateRevision = Number.isFinite(updateRevision) + + // A durable revision is authoritative. A legacy/unversioned read must never + // replace a row that has already been ordered by a revision, even if its wall + // clock happens to be later. + if (hasExistingRevision || hasUpdateRevision) { + if (!hasUpdateRevision) return true + if (!hasExistingRevision) return false + return updateRevision! <= existingRevision! + } + + const existingUpdatedAt = existing.updatedAt + const updateUpdatedAt = update.updatedAt + // During migration, snapshots without a durable revision retain the former + // timestamp guard until a revisioned row is observed. + return ( + Number.isFinite(existingUpdatedAt) && + Number.isFinite(updateUpdatedAt) && + updateUpdatedAt <= existingUpdatedAt + ) +} + function mergeSessions(current: UISession[], updates: UISession[]): UISession[] { const next = new Map(current.map((session) => [session.id, session])) for (const update of updates) { const existing = next.get(update.id) + if (existing && isStaleOrSameSessionUpdate(existing, update)) { + continue + } next.set(update.id, existing ? { ...existing, ...update } : update) } @@ -280,6 +311,18 @@ export const useSessionStore = defineStore('session', () => { let groupModeUpdateVersion = 0 let initialPageRequestId = 0 let nextPageRequestId = 0 + // A list epoch protects the first-page/pagination cursor chain. Targeted updates + // only merge individual rows, so they must not invalidate an in-flight next page. + let sessionListEpoch = 0 + let sessionByIdsRefreshRevision = 0 + const sessionByIdRefreshRevisions = new Map () + let targetedSessionCommitRevision = 0 + const targetedSessionCommitRevisions = new Map () + const observedSessionStatuses = new Map () + // Deleted sessions must stay absent while requests started before their deletion settle. + // IDs are stable database identifiers, so they are safe tombstones for this store lifetime. + const removedSessionIds = new Set () + let sessionByIdsErrorRevision: number | null = null let activationNavigationRequestId = 0 let newConversationProjectDirIntentId = 0 let sessionFetchPromise: Promise | null = null @@ -296,10 +339,12 @@ export const useSessionStore = defineStore('session', () => { const hasMore = ref(false) const nextCursor = ref<{ updatedAt: number; id: string } | null>(null) const error = ref (null) + let sessionIpcBinding: ReturnType | null = null void getCurrentWebContentsId() .then((webContentsId) => { myWebContentsId.value = webContentsId + sessionIpcBinding?.flushPendingTargetedUpdate() }) .catch((identityError) => { console.warn('[sessionStore] Failed to resolve runtime webContents id:', identityError) @@ -357,16 +402,96 @@ export const useSessionStore = defineStore('session', () => { activeSessionSummary.value = null } - const updateBootstrapActiveSession = (session: UISession | null) => { - bootstrapActiveSession.value = session + const mergeObservedSessionStatus = (session: T): T => { + const observed = observedSessionStatuses.get(session.id) + if (!observed || session.status === observed.status) { + return session + } + + return { + ...session, + status: observed.status + } + } + + const mapSessionSnapshot = (session: SessionListItem | SessionWithState): UISession => + mergeObservedSessionStatus(mapToUISession(session)) + + const mapActiveSessionSnapshot = (session: SessionWithState): UIActiveSessionSummary => + mergeObservedSessionStatus(mapToUIActiveSessionSummary(session)) + + const commitSessionSnapshot = (snapshot: SessionListItem | SessionWithState): boolean => { + const session = mapSessionSnapshot(snapshot) + if (removedSessionIds.has(session.id)) { + return false + } + + const existing = sessions.value.find((candidate) => candidate.id === session.id) + if (existing && isStaleOrSameSessionUpdate(existing, session)) { + return false + } + + sessions.value = mergeSessions(sessions.value, [session]) + if (bootstrapActiveSession.value?.id === session.id) { + bootstrapActiveSession.value = session + } + if (activeSessionSummary.value?.id === session.id) { + activeSessionSummary.value = + 'providerId' in snapshot + ? mapActiveSessionSnapshot(snapshot) + : { + ...session, + providerId: activeSessionSummary.value.providerId, + modelId: activeSessionSummary.value.modelId + } + } + return true } - const upsertSessions = (updates: UISession[]): void => { - sessions.value = mergeSessions(sessions.value, updates) + const commitSessionSnapshots = (snapshots: Array ): void => { + for (const snapshot of snapshots) { + commitSessionSnapshot(snapshot) + } + } + + const replaceSessionSnapshot = ( + snapshot: UISession[], + targetedCommitRevisionAtStart: number + ): UISession[] => { + const next = new Map(snapshot.map((session) => [session.id, session])) + + for (const session of sessions.value) { + const incoming = next.get(session.id) + if ( + (incoming && isStaleOrSameSessionUpdate(session, incoming)) || + (targetedSessionCommitRevisions.get(session.id) ?? 0) > targetedCommitRevisionAtStart + ) { + next.set(session.id, session) + } + } + + return sortSessions(Array.from(next.values())) } const removeSessions = (sessionIds: string[]): void => { const targetIds = new Set(sessionIds) + // A dropped in-flight first page must be re-requested, or the sidebar sits + // with no skeleton, no rows, and no error until an unrelated event fires. + const hadPendingInitialFetch = sessionFetchPromise !== null || loading.value + // Invalidate every in-flight list response and per-ID refresh for deleted rows. + // This also lets their finally blocks retire loading state without changing it. + sessionListEpoch += 1 + initialPageRequestId += 1 + nextPageRequestId += 1 + loading.value = false + loadingMore.value = false + // A deleted row should not keep a superseded first-page request deduplicated. + sessionFetchPromise = null + for (const sessionId of targetIds) { + removedSessionIds.add(sessionId) + observedSessionStatuses.delete(sessionId) + sessionByIdRefreshRevisions.set(sessionId, ++sessionByIdsRefreshRevision) + } sessions.value = sessions.value.filter((session) => !targetIds.has(session.id)) for (const sessionId of targetIds) { agentPlanStore.purge(sessionId) @@ -388,6 +513,10 @@ export const useSessionStore = defineStore('session', () => { setActiveSessionId(null) pageRouter.goToNewThread() } + + if (hadPendingInitialFetch && !hasLoadedInitialPage.value) { + void fetchSessions() + } } const activeSession: ComputedRef = computed(() => { @@ -450,7 +579,18 @@ export const useSessionStore = defineStore('session', () => { agentStore.setSelectedAgent(targetAgentId) } - const applySessionStatus = (sessionId: string, status: string): void => { + const applySessionStatus = (sessionId: string, status: string, version?: number): void => { + if (version !== undefined) { + const observed = observedSessionStatuses.get(sessionId) + if (observed && version < observed.version) { + return + } + observedSessionStatuses.set(sessionId, { + version, + status: mapSessionStatus(status) + }) + } + messageStore.invalidateRecentSessionView(sessionId) const nextStatus = mapSessionStatus(status) const index = sessions.value.findIndex((session) => session.id === sessionId) @@ -490,12 +630,32 @@ export const useSessionStore = defineStore('session', () => { return } - const lightweightSession = mapToUISession(session) - upsertSessions([lightweightSession]) + const committed = commitSessionSnapshot(session) if (activeSessionId.value !== session.id) return - activeSessionSummary.value = mapToUIActiveSessionSummary(session) - bootstrapActiveSession.value = lightweightSession + const canonical = sessions.value.find((candidate) => candidate.id === session.id) + const incoming = mapSessionSnapshot(session) + const isSameDurableSnapshot = + canonical && + ((Number.isFinite(canonical.revision) && canonical.revision === incoming.revision) || + (!Number.isFinite(canonical.revision) && canonical.updatedAt === incoming.updatedAt)) + if (!canonical || (!committed && !isSameDurableSnapshot)) { + return + } + + // A same-revision full hydrate may enrich only runtime provider/model data. Its + // durable fields always come from the canonical committed row, so a late read + // cannot split the active shell from the sidebar. + activeSessionSummary.value = { + ...canonical, + // Session execution state is runtime-only and can be refreshed without a + // durable row mutation. Preserve it (with the observed event guard) while + // keeping every durable projection on the canonical list row. + status: mapActiveSessionSnapshot(session).status, + providerId: session.providerId, + modelId: session.modelId + } + bootstrapActiveSession.value = canonical syncSelectedAgentToSession(session.id) } @@ -530,8 +690,23 @@ export const useSessionStore = defineStore('session', () => { } setActiveSessionId(nextActiveSessionId) - clearActiveSessionSummary() - updateBootstrapActiveSession(input.activeSession ? mapToUISession(input.activeSession) : null) + // A repeated bootstrap shell for the same selected session must not discard + // an already hydrated runtime summary before its durable revision guard runs. + if (activeSessionSummary.value?.id !== nextActiveSessionId) { + clearActiveSessionSummary() + } + + if (input.activeSession?.id === nextActiveSessionId) { + commitSessionSnapshot(input.activeSession) + // Startup shell data is only a hint. Prefer the canonical list row after + // its revision guard so a delayed bootstrap response cannot move any of + // the three active-session projections back in time. + bootstrapActiveSession.value = + sessions.value.find((session) => session.id === nextActiveSessionId) ?? null + } else { + bootstrapActiveSession.value = null + } + syncSelectedAgentToSession(nextActiveSessionId) } @@ -542,6 +717,9 @@ export const useSessionStore = defineStore('session', () => { }): Promise => { if (options.reset) { const requestId = ++initialPageRequestId + const listEpoch = ++sessionListEpoch + const targetedCommitRevisionAtStart = targetedSessionCommitRevision + loadingMore.value = false loading.value = true error.value = null @@ -556,20 +734,27 @@ export const useSessionStore = defineStore('session', () => { prioritizeSessionId: options.prioritizeSessionId ?? undefined }) - if (requestId !== initialPageRequestId) { + if (requestId !== initialPageRequestId || listEpoch !== sessionListEpoch) { return } - const nextSessions = result.items.map(mapToUISession) + // Commit incoming entity snapshots before the first-page replacement so an + // active shell receives the same authoritative revision as the sidebar. + commitSessionSnapshots(result.items) + const nextSessions = result.items + .map(mapSessionSnapshot) + .filter((session) => !removedSessionIds.has(session.id)) sessions.value = options.preserveExisting ? mergeSessions(sessions.value, nextSessions) - : sortSessions(nextSessions) + : replaceSessionSnapshot(nextSessions, targetedCommitRevisionAtStart) hasLoadedInitialPage.value = true hasMore.value = result.hasMore nextCursor.value = result.nextCursor syncSelectedAgentToSession(activeSessionId.value) } catch (loadError) { - error.value = `Failed to load sessions: ${loadError}` + if (requestId === initialPageRequestId && listEpoch === sessionListEpoch) { + error.value = `Failed to load sessions: ${loadError}` + } } finally { if (requestId === initialPageRequestId) { loading.value = false @@ -584,6 +769,7 @@ export const useSessionStore = defineStore('session', () => { } const requestId = ++nextPageRequestId + const listEpoch = sessionListEpoch loadingMore.value = true error.value = null @@ -595,20 +781,22 @@ export const useSessionStore = defineStore('session', () => { includeSubagents: false }) - if (requestId !== nextPageRequestId) { + if (requestId !== nextPageRequestId || listEpoch !== sessionListEpoch) { return } - upsertSessions(result.items.map(mapToUISession)) + commitSessionSnapshots(result.items) hasMore.value = result.hasMore nextCursor.value = result.nextCursor console.info( `[Startup][Renderer] startup.session.page.appended count=${result.items.length} total=${sessions.value.length}` ) } catch (loadError) { - error.value = `Failed to load more sessions: ${loadError}` + if (requestId === nextPageRequestId && listEpoch === sessionListEpoch) { + error.value = `Failed to load more sessions: ${loadError}` + } } finally { - if (requestId === nextPageRequestId) { + if (requestId === nextPageRequestId && listEpoch === sessionListEpoch) { loadingMore.value = false } } @@ -650,51 +838,104 @@ export const useSessionStore = defineStore('session', () => { return } + const refreshRevision = ++sessionByIdsRefreshRevision + const requestedRevisions = new Map () for (const sessionId of normalizedIds) { + sessionByIdRefreshRevisions.set(sessionId, refreshRevision) + requestedRevisions.set(sessionId, refreshRevision) messageStore.invalidateRecentSessionView(sessionId) } + const listEpoch = sessionListEpoch + const hasCurrentRequestedId = (): boolean => + listEpoch === sessionListEpoch && + normalizedIds.some( + (sessionId) => + sessionByIdRefreshRevisions.get(sessionId) === requestedRevisions.get(sessionId) + ) - error.value = null try { const items = await sessionClient.getLightweightByIds(normalizedIds) - upsertSessions(items.map(mapToUISession)) + if (listEpoch !== sessionListEpoch) { + return + } - const activeId = activeSessionId.value - if (activeId) { - const activeItem = items.find((item) => item.id === activeId) - if (activeItem) { - updateBootstrapActiveSession(mapToUISession(activeItem)) - syncSelectedAgentToSession(activeId) + // A newer request for the same ID wins, but disjoint IDs from concurrent + // targeted refreshes may safely merge independently. + const acceptedItems = items.filter( + (item) => + requestedRevisions.has(item.id) && + sessionByIdRefreshRevisions.get(item.id) === requestedRevisions.get(item.id) + ) + if (!hasCurrentRequestedId()) { + return + } + + const acceptedSessions = acceptedItems + .map(mapSessionSnapshot) + .filter((session) => !removedSessionIds.has(session.id)) + commitSessionSnapshots(acceptedItems) + if (acceptedSessions.length > 0) { + const commitRevision = ++targetedSessionCommitRevision + for (const session of acceptedSessions) { + targetedSessionCommitRevisions.set(session.id, commitRevision) } } + + // This background refresh may only clear an error it owns; first-page and + // pagination errors remain visible until their own request resolves. + if ( + sessionByIdsErrorRevision !== null && + sessionByIdsErrorRevision <= refreshRevision && + error.value?.startsWith('Failed to refresh sessions:') + ) { + error.value = null + sessionByIdsErrorRevision = null + } + + const activeId = activeSessionId.value + if (activeId && acceptedItems.some((item) => item.id === activeId)) { + syncSelectedAgentToSession(activeId) + } } catch (refreshError) { - error.value = `Failed to refresh sessions: ${refreshError}` + if (hasCurrentRequestedId()) { + const canReplaceError = + error.value === null || + (sessionByIdsErrorRevision !== null && + sessionByIdsErrorRevision <= refreshRevision && + error.value.startsWith('Failed to refresh sessions:')) + if (canReplaceError) { + error.value = `Failed to refresh sessions: ${refreshError}` + sessionByIdsErrorRevision = refreshRevision + } + } } } async function createSession(input: CreateSessionInput): Promise { error.value = null - createActivationNavigationRequest() + const requestId = createActivationNavigationRequest() try { const result = await sessionClient.create(input) const session = result.session const hasInitialTurn = input.message.trim().length > 0 || (input.files?.length ?? 0) > 0 - const lightweightSession = { - ...mapToUISession(session), - ...(hasInitialTurn ? { status: 'working' as const } : {}) + // Creation is durable even if the user has navigated elsewhere while it was pending. + commitSessionSnapshot(session) + if (activationNavigationRequestId !== requestId) { + return } - upsertSessions([lightweightSession]) + setActiveSessionId(session.id) - bootstrapActiveSession.value = lightweightSession - activeSessionSummary.value = { - ...mapToUIActiveSessionSummary(session), - ...(hasInitialTurn ? { status: 'working' as const } : {}) + applyRestoredSession(session) + if (hasInitialTurn) { + applySessionStatus(session.id, 'generating') } syncSelectedAgentToSession(session.id) pageRouter.goToChat(session.id) await completeOnboardingStep('first-chat') } catch (createError) { - error.value = `Failed to create session: ${createError}` + if (activationNavigationRequestId === requestId) { + error.value = `Failed to create session: ${createError}` + } throw createError } } @@ -719,21 +960,28 @@ export const useSessionStore = defineStore('session', () => { } pageRouter.goToChat(sessionId) } catch (selectError) { - error.value = `Failed to select session: ${selectError}` + if (activationNavigationRequestId === requestId) { + error.value = `Failed to select session: ${selectError}` + } } } async function closeSession(options: CloseSessionOptions = {}): Promise { error.value = null - createActivationNavigationRequest() + const requestId = createActivationNavigationRequest() try { messageStore.clearStreamingState() await sessionClient.deactivate() + if (activationNavigationRequestId !== requestId) { + return + } clearActiveSessionSummary() setActiveSessionId(null) pageRouter.goToNewThread(options.refresh ? { refresh: true } : {}) } catch (closeError) { - error.value = `Failed to close session: ${closeError}` + if (activationNavigationRequestId === requestId) { + error.value = `Failed to close session: ${closeError}` + } } } @@ -831,7 +1079,7 @@ export const useSessionStore = defineStore('session', () => { error.value = null try { const updated = await sessionClient.setSessionModel(sessionId, providerId, modelId) - upsertSessions([mapToUISession(updated)]) + commitSessionSnapshot(updated) if (activeSessionId.value === sessionId) { applyRestoredSession(updated) } @@ -861,7 +1109,7 @@ export const useSessionStore = defineStore('session', () => { error.value = null try { const updated = await sessionClient.setSessionProjectDir(sessionId, projectDir) - upsertSessions([mapToUISession(updated)]) + commitSessionSnapshot(updated) if (activeSessionId.value === sessionId) { applyRestoredSession(updated) } @@ -875,7 +1123,7 @@ export const useSessionStore = defineStore('session', () => { error.value = null try { const updated = await sessionClient.moveSessionToAgent(sessionId, toAgentId) - upsertSessions([mapToUISession(updated)]) + commitSessionSnapshot(updated) if (activeSessionId.value === sessionId) { applyRestoredSession(updated) syncSelectedAgentToSession(sessionId) @@ -893,23 +1141,8 @@ export const useSessionStore = defineStore('session', () => { if (!normalized) { return } - await sessionClient.renameSession(sessionId, normalized) - const target = sessions.value.find((session) => session.id === sessionId) - if (target) { - target.title = normalized - } - if (bootstrapActiveSession.value?.id === sessionId) { - bootstrapActiveSession.value = { - ...bootstrapActiveSession.value, - title: normalized - } - } - if (activeSessionSummary.value?.id === sessionId) { - activeSessionSummary.value = { - ...activeSessionSummary.value, - title: normalized - } - } + const updated = await sessionClient.renameSession(sessionId, normalized) + commitSessionSnapshot(updated) } catch (renameError) { error.value = `Failed to rename session: ${renameError}` throw renameError @@ -919,24 +1152,8 @@ export const useSessionStore = defineStore('session', () => { async function toggleSessionPinned(sessionId: string, pinned: boolean): Promise { error.value = null try { - await sessionClient.toggleSessionPinned(sessionId, pinned) - const target = sessions.value.find((session) => session.id === sessionId) - if (target) { - target.isPinned = pinned - } - if (bootstrapActiveSession.value?.id === sessionId) { - bootstrapActiveSession.value = { - ...bootstrapActiveSession.value, - isPinned: pinned - } - } - if (activeSessionSummary.value?.id === sessionId) { - activeSessionSummary.value = { - ...activeSessionSummary.value, - isPinned: pinned - } - } - sessions.value = sortSessions(sessions.value) + const updated = await sessionClient.toggleSessionPinned(sessionId, pinned) + commitSessionSnapshot(updated) } catch (pinError) { error.value = `Failed to toggle pinned state: ${pinError}` throw pinError @@ -999,39 +1216,34 @@ export const useSessionStore = defineStore('session', () => { } function getPinnedSessions(agentId: string | null): UISession[] { - const pinned = sortSessions( + return sortSessions( sessions.value.filter( - (session) => isRegularSession(session) && session.isPinned && !session.isDraft + (session) => + isRegularSession(session) && + session.isPinned && + !session.isDraft && + (agentId === null || session.agentId === agentId) ) ) - - if (agentId === null) return pinned - - return pinned.filter((session) => session.agentId === agentId) } function getFilteredGroups(agentId: string | null): SessionGroup[] { const visibleSessions = sortSessions( sessions.value.filter( - (session) => isRegularSession(session) && !session.isDraft && !session.isPinned + (session) => + isRegularSession(session) && + !session.isDraft && + !session.isPinned && + (agentId === null || session.agentId === agentId) ) ) - const grouped = - groupMode.value === 'time' ? groupByTime(visibleSessions) : groupByProject(visibleSessions) - - if (agentId === null) return grouped - - return grouped - .map((group) => ({ - id: group.id, - label: group.label, - labelKey: group.labelKey, - sessions: group.sessions.filter((session) => session.agentId === agentId) - })) - .filter((group) => group.sessions.length > 0) + + return groupMode.value === 'time' + ? groupByTime(visibleSessions) + : groupByProject(visibleSessions) } - const cleanupIpcBindings = bindSessionStoreIpc({ + sessionIpcBinding = bindSessionStoreIpc({ webContentsId: () => myWebContentsId.value, fetchSessions, refreshSessionsByIds, @@ -1059,11 +1271,11 @@ export const useSessionStore = defineStore('session', () => { setActiveSessionId(null) pageRouter.goToNewThread() }, - onStatusChanged: (sessionId, status) => { - applySessionStatus(sessionId, status) + onStatusChanged: (sessionId, status, version) => { + applySessionStatus(sessionId, status, version) } }) - registerStoreCleanup(cleanupIpcBindings) + registerStoreCleanup(sessionIpcBinding.cleanup) void ensureGroupModeLoaded() return { @@ -1075,6 +1287,7 @@ export const useSessionStore = defineStore('session', () => { loadingMore, hasLoadedInitialPage, hasMore, + nextCursor, error, activeSession, sessionGroups, diff --git a/src/renderer/src/stores/ui/sessionIpc.ts b/src/renderer/src/stores/ui/sessionIpc.ts index 428fc39878..c45eb31c12 100644 --- a/src/renderer/src/stores/ui/sessionIpc.ts +++ b/src/renderer/src/stores/ui/sessionIpc.ts @@ -7,25 +7,65 @@ interface BindSessionStoreIpcOptions { removeSessions: (sessionIds: string[]) => void onActivated: (sessionId: string) => void | Promise onDeactivated: () => void - onStatusChanged: (sessionId: string, status: string) => void + onStatusChanged: (sessionId: string, status: string, version: number) => void } -export function bindSessionStoreIpc(options: BindSessionStoreIpcOptions): () => void { +type TargetedSessionUpdate = { + reason: 'activated' | 'deactivated' + webContentsId: number + activeSessionId?: string | null +} + +export interface SessionStoreIpcBinding { + cleanup: () => void + flushPendingTargetedUpdate: () => void +} + +export function bindSessionStoreIpc(options: BindSessionStoreIpcOptions): SessionStoreIpcBinding { const sessionClient = createSessionClient() + const pendingTargetedUpdates = new Map () + + const applyTargetedUpdate = (payload: TargetedSessionUpdate): void => { + const webContentsId = options.webContentsId() + if (webContentsId === null) { + pendingTargetedUpdates.set(payload.webContentsId, payload) + return + } + + if (payload.webContentsId !== webContentsId) return + + if (payload.reason === 'activated' && payload.activeSessionId) { + void options.onActivated(payload.activeSessionId) + return + } + + if (payload.reason === 'deactivated') { + options.onDeactivated() + } + } + + const flushPendingTargetedUpdate = (): void => { + const webContentsId = options.webContentsId() + if (webContentsId === null) return + + const pendingUpdate = pendingTargetedUpdates.get(webContentsId) + pendingTargetedUpdates.clear() + if (pendingUpdate) { + applyTargetedUpdate(pendingUpdate) + } + } + const cleanups = [ sessionClient.onUpdated((payload) => { - const webContentsId = options.webContentsId() if ( - payload.reason === 'activated' && - payload.activeSessionId && - payload.webContentsId === webContentsId + (payload.reason === 'activated' || payload.reason === 'deactivated') && + typeof payload.webContentsId === 'number' ) { - void options.onActivated(payload.activeSessionId) - return - } - - if (payload.reason === 'deactivated' && payload.webContentsId === webContentsId) { - options.onDeactivated() + applyTargetedUpdate({ + reason: payload.reason, + webContentsId: payload.webContentsId, + activeSessionId: payload.activeSessionId + }) return } @@ -51,13 +91,17 @@ export function bindSessionStoreIpc(options: BindSessionStoreIpcOptions): () => } }), sessionClient.onStatusChanged((payload) => { - options.onStatusChanged(payload.sessionId, payload.status) + options.onStatusChanged(payload.sessionId, payload.status, payload.version) }) ] - return () => { - for (const cleanup of cleanups) { - cleanup() + return { + flushPendingTargetedUpdate, + cleanup: () => { + pendingTargetedUpdates.clear() + for (const cleanup of cleanups) { + cleanup() + } } } } diff --git a/src/renderer/src/stores/ui/stream.ts b/src/renderer/src/stores/ui/stream.ts index 7f5b315a43..9cc7704ec0 100644 --- a/src/renderer/src/stores/ui/stream.ts +++ b/src/renderer/src/stores/ui/stream.ts @@ -1,10 +1,12 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, shallowRef } from 'vue' import type { AssistantMessageBlock } from '@shared/types/agent-interface' export const useStreamStateStore = defineStore('streamState', () => { const isStreaming = ref(false) - const streamingBlocks = ref ([]) + // Stream snapshots are schema-validated whole-array replacements. Avoid recursively + // proxying an ever-growing payload that is never mutated in place. + const streamingBlocks = shallowRef ([]) const currentStreamSessionId = ref (null) const currentStreamRequestId = ref (null) const currentStreamMessageId = ref (null) diff --git a/src/renderer/src/views/SettingsTabView.vue b/src/renderer/src/views/SettingsTabView.vue deleted file mode 100644 index ff3b84769a..0000000000 --- a/src/renderer/src/views/SettingsTabView.vue +++ /dev/null @@ -1,96 +0,0 @@ - - -- - - - - diff --git a/src/shared/contracts/common.ts b/src/shared/contracts/common.ts index 4d5f99d8c2..68708d1ffc 100644 --- a/src/shared/contracts/common.ts +++ b/src/shared/contracts/common.ts @@ -27,6 +27,10 @@ export type JsonValue = export const EntityIdSchema = z.string().min(1) export const TimestampMsSchema = z.number().int().nonnegative() +// A monotonically increasing state token. Unlike TimestampMsSchema, this is not +// tied to wall-clock time and is safe for ordered snapshot/event application. +export const RevisionSchema = z.number().int().nonnegative() + export const ToolCallImagePreviewSchema = z.object({ id: z.string().min(1), data: z.string().min(1).nullable().optional(), @@ -236,6 +240,7 @@ export const SessionWithStateSchema = z.object({ subagentMeta: DeepChatSubagentMetaSchema.optional(), createdAt: TimestampMsSchema, updatedAt: TimestampMsSchema, + revision: RevisionSchema.optional(), metadata: z .object({ source: z.literal('cron_job'), diff --git a/src/shared/contracts/events/config.events.ts b/src/shared/contracts/events/config.events.ts index 079dc0e152..c2657e7b20 100644 --- a/src/shared/contracts/events/config.events.ts +++ b/src/shared/contracts/events/config.events.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { TimestampMsSchema, defineEventContract } from '../common' +import { RevisionSchema, defineEventContract } from '../common' import { AcpAgentConfigSchema, LanguageDirectionSchema, @@ -15,7 +15,7 @@ export const configLanguageChangedEvent = defineEventContract({ requestedLanguage: z.string(), locale: z.string(), direction: LanguageDirectionSchema, - version: TimestampMsSchema + version: RevisionSchema }) }) @@ -24,7 +24,7 @@ export const configThemeChangedEvent = defineEventContract({ payload: z.object({ theme: ThemeModeSchema, isDark: z.boolean(), - version: TimestampMsSchema + version: RevisionSchema }) }) @@ -32,7 +32,7 @@ export const configSystemThemeChangedEvent = defineEventContract({ name: 'config.systemTheme.changed', payload: z.object({ isDark: z.boolean(), - version: TimestampMsSchema + version: RevisionSchema }) }) @@ -40,7 +40,7 @@ export const configFloatingButtonChangedEvent = defineEventContract({ name: 'config.floatingButton.changed', payload: z.object({ enabled: z.boolean(), - version: TimestampMsSchema + version: RevisionSchema }) }) @@ -49,7 +49,7 @@ export const configSyncSettingsChangedEvent = defineEventContract({ payload: z.object({ enabled: z.boolean(), folderPath: z.string(), - version: TimestampMsSchema + version: RevisionSchema }) }) @@ -57,7 +57,7 @@ export const configDefaultProjectPathChangedEvent = defineEventContract({ name: 'config.defaultProjectPath.changed', payload: z.object({ path: z.string().nullable(), - version: TimestampMsSchema + version: RevisionSchema }) }) @@ -67,7 +67,7 @@ export const configAgentsChangedEvent = defineEventContract({ enabled: z.boolean(), agents: z.array(AcpAgentConfigSchema), agentIds: z.array(z.string()).optional(), - version: TimestampMsSchema + version: RevisionSchema }) }) @@ -75,7 +75,7 @@ export const configShortcutKeysChangedEvent = defineEventContract({ name: 'config.shortcutKeys.changed', payload: z.object({ shortcuts: ShortcutKeySettingSchema, - version: TimestampMsSchema + version: RevisionSchema }) }) @@ -85,7 +85,7 @@ export const configSystemPromptsChangedEvent = defineEventContract({ prompts: z.array(SystemPromptSchema), defaultPromptId: z.string(), prompt: z.string(), - version: TimestampMsSchema + version: RevisionSchema }) }) @@ -93,6 +93,6 @@ export const configCustomPromptsChangedEvent = defineEventContract({ name: 'config.customPrompts.changed', payload: z.object({ prompts: z.array(PromptSchema), - version: TimestampMsSchema + version: RevisionSchema }) }) diff --git a/src/shared/contracts/events/project.events.ts b/src/shared/contracts/events/project.events.ts index e206c4f7a8..a1b1fc44a1 100644 --- a/src/shared/contracts/events/project.events.ts +++ b/src/shared/contracts/events/project.events.ts @@ -1,11 +1,11 @@ import { z } from 'zod' -import { TimestampMsSchema, defineEventContract } from '../common' +import { RevisionSchema, defineEventContract } from '../common' export const projectEnvironmentsChangedEvent = defineEventContract({ name: 'project:environments-changed', payload: z.object({ - action: z.enum(['reorder', 'archive', 'restore', 'remove']), + action: z.enum(['reorder', 'archive', 'restore', 'remove', 'select']), path: z.string().nullable(), - version: TimestampMsSchema + version: RevisionSchema }) }) diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 064cd38db5..52ed6afa2c 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -317,6 +317,7 @@ import { } from './routes/providers.routes' import { projectArchiveEnvironmentRoute, + projectGetSnapshotRoute, projectListEnvironmentsRoute, projectListRecentRoute, projectOpenDirectoryRoute, @@ -571,6 +572,7 @@ const DEEPCHAT_ROUTE_CATALOG_PART_1 = { [deviceResetDataByTypeRoute.name]: deviceResetDataByTypeRoute, [deviceSanitizeSvgRoute.name]: deviceSanitizeSvgRoute, [projectListRecentRoute.name]: projectListRecentRoute, + [projectGetSnapshotRoute.name]: projectGetSnapshotRoute, [projectListEnvironmentsRoute.name]: projectListEnvironmentsRoute, [projectReorderEnvironmentsRoute.name]: projectReorderEnvironmentsRoute, [projectArchiveEnvironmentRoute.name]: projectArchiveEnvironmentRoute, diff --git a/src/shared/contracts/routes/project.routes.ts b/src/shared/contracts/routes/project.routes.ts index 377e7a79b9..045e039247 100644 --- a/src/shared/contracts/routes/project.routes.ts +++ b/src/shared/contracts/routes/project.routes.ts @@ -1,9 +1,24 @@ import { z } from 'zod' -import { defineRouteContract } from '../common' +import { RevisionSchema, defineRouteContract } from '../common' import { EnvironmentSummarySchema, ProjectSchema } from '../domainSchemas' export const EnvironmentStatusSchema = z.enum(['active', 'archived', 'removed']) +export const ProjectSnapshotSchema = z.object({ + version: RevisionSchema, + projects: z.array(ProjectSchema), + environments: z.array(EnvironmentSummarySchema), + archivedEnvironments: z.array(EnvironmentSummarySchema), + removedEnvironments: z.array(EnvironmentSummarySchema), + defaultProjectPath: z.string().nullable() +}) + +export const projectGetSnapshotRoute = defineRouteContract({ + name: 'project.getSnapshot', + input: z.object({}).default({}), + output: ProjectSnapshotSchema +}) + export const projectListRecentRoute = defineRouteContract({ name: 'project.listRecent', input: z.object({ diff --git a/src/shared/contracts/routes/sessions.routes.ts b/src/shared/contracts/routes/sessions.routes.ts index 513d6cb793..98a9d52c35 100644 --- a/src/shared/contracts/routes/sessions.routes.ts +++ b/src/shared/contracts/routes/sessions.routes.ts @@ -447,7 +447,7 @@ export const sessionsRenameRoute = defineRouteContract({ title: z.string().min(1) }), output: z.object({ - updated: z.literal(true) + session: SessionWithStateSchema }) }) @@ -458,7 +458,7 @@ export const sessionsTogglePinnedRoute = defineRouteContract({ pinned: z.boolean() }), output: z.object({ - updated: z.literal(true) + session: SessionWithStateSchema }) }) diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index c65f5e666e..fa16125a94 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -675,6 +675,8 @@ export interface SessionRecord { subagentMeta?: DeepChatSubagentMeta | null createdAt: number updatedAt: number + /** Monotonic durable revision for ordering snapshots of one session. */ + revision?: number metadata?: SessionMetadata | null } diff --git a/test/main/agent/acp/runtime/acpSessionPersistence.test.ts b/test/main/agent/acp/runtime/acpSessionPersistence.test.ts index c3b3ebf457..d9a1297dec 100644 --- a/test/main/agent/acp/runtime/acpSessionPersistence.test.ts +++ b/test/main/agent/acp/runtime/acpSessionPersistence.test.ts @@ -31,6 +31,26 @@ describe('AcpSessionPersistence remote session sync', () => { ) }) + it('notifies the project projection after synchronizing environment usage', async () => { + const projectDatabase = createProjectDatabase() + projectDatabase.newEnvironmentsTable.listPathsForSession.mockReturnValue(['/work/project']) + const notifyEnvironmentProjectionChanged = vi.fn() + const database = { + upsertAcpSession: vi.fn().mockResolvedValue(undefined) + } as MainDatabase + const persistence = new AcpSessionPersistence( + database as never, + database as never, + projectDatabase as never, + notifyEnvironmentProjectionChanged + ) + + await persistence.saveSessionData('conversation-1', 'agent-1', 'remote-1' as never, null, 'idle', null) + + expect(projectDatabase.newEnvironmentsTable.syncPath).toHaveBeenCalledWith('/work/project') + expect(notifyEnvironmentProjectionChanged).toHaveBeenCalledTimes(1) + }) + it('falls back from missing persisted workdirs to the default workdir', () => { const homeDir = process.cwd() const missingDir = path.join(homeDir, 'missing-workdir-for-acp-test') diff --git a/test/main/agent/deepchat/runtime/echo.test.ts b/test/main/agent/deepchat/runtime/echo.test.ts index c82b204a59..5630c6ffbf 100644 --- a/test/main/agent/deepchat/runtime/echo.test.ts +++ b/test/main/agent/deepchat/runtime/echo.test.ts @@ -203,4 +203,20 @@ describe('echo', () => { expect(cloned[0]?.extra?.nested).not.toBe(blocks[0]?.extra?.nested) expect(cloned[0]?.extra?.nested[0]).not.toBe(blocks[0]?.extra?.nested[0]) }) + + it('removes undefined JSON fields before validating renderer blocks', () => { + const blocks = [ + { + type: 'search' as const, + status: 'success' as const, + timestamp: 1, + extra: { + label: 'web_search', + engine: undefined + } + } + ] + + expect(cloneBlocksForRenderer(blocks)[0]?.extra).toEqual({ label: 'web_search' }) + }) }) diff --git a/test/main/agent/shared/appSessionService.test.ts b/test/main/agent/shared/appSessionService.test.ts index c6969290af..e518755688 100644 --- a/test/main/agent/shared/appSessionService.test.ts +++ b/test/main/agent/shared/appSessionService.test.ts @@ -34,11 +34,17 @@ function createMockSqlitePresenter() { describe('AppSessionService', () => { let sqlitePresenter: ReturnType----- {{ t(setting.title) }} - - + let notifyEnvironmentProjectionChanged: ReturnType let manager: AppSessionService beforeEach(() => { sqlitePresenter = createMockSqlitePresenter() - manager = new AppSessionService(sqlitePresenter, sqlitePresenter) + notifyEnvironmentProjectionChanged = vi.fn() + manager = new AppSessionService( + sqlitePresenter, + sqlitePresenter, + notifyEnvironmentProjectionChanged + ) }) describe('create', () => { @@ -68,6 +74,7 @@ describe('AppSessionService', () => { updatedAt: expect.any(Number) }) expect(sqlitePresenter.newEnvironmentsTable.syncPath).toHaveBeenCalledWith('/tmp/workspace') + expect(notifyEnvironmentProjectionChanged).toHaveBeenCalledTimes(1) }) it('stores session metadata when provided', () => { diff --git a/test/main/app/compositionBoundaries.test.ts b/test/main/app/compositionBoundaries.test.ts index 6222d0e172..aa08703689 100644 --- a/test/main/app/compositionBoundaries.test.ts +++ b/test/main/app/compositionBoundaries.test.ts @@ -13,12 +13,27 @@ describe('session boundary composition', () => { expect(compositionSource).toMatch( /new LegacyChatImportService\([^)]*memoryDatabase,\s*sessionData\.tapeStore/ ) + expect(compositionSource).toMatch( + /sessionData\.tapeStore,\s*undefined,\s*\(\) => projectService\.notifyEnvironmentProjectionChanged\(\)/ + ) expect(compositionSource).toContain( 'legacyChatImportService.repairImportedLegacySessionSkills(conversationId)' ) expect(compositionSource).toContain('legacyChatImportService.start(false)') }) + it('notifies the project snapshot owner after skill persistence refreshes environments', async () => { + const { readFileSync } = await vi.importActual ('node:fs') + const compositionSource = readFileSync( + path.resolve(process.cwd(), 'src/main/app/composition.ts'), + 'utf8' + ) + + expect(compositionSource).toContain( + 'projectDatabase.newEnvironmentsTable.syncForSession(conversationId)\n projectService.notifyEnvironmentProjectionChanged()' + ) + }) + it('keeps hooks notifications on one instance with lazy projection dependencies', async () => { const { readFileSync } = await vi.importActual ('node:fs') const compositionSource = readFileSync( diff --git a/test/main/app/startupMigrations/legacyChatImportService.test.ts b/test/main/app/startupMigrations/legacyChatImportService.test.ts index f2d083b8a1..199cb0349b 100644 --- a/test/main/app/startupMigrations/legacyChatImportService.test.ts +++ b/test/main/app/startupMigrations/legacyChatImportService.test.ts @@ -114,10 +114,12 @@ function createMockSqlitePresenter() { describe('LegacyChatImportService', () => { let sqlitePresenter: ReturnType let service: LegacyChatImportService + let notifyEnvironmentProjectionChanged: ReturnType beforeEach(() => { vi.clearAllMocks() sqlitePresenter = createMockSqlitePresenter() + notifyEnvironmentProjectionChanged = vi.fn() service = new LegacyChatImportService( sqlitePresenter as any, sqlitePresenter as any, @@ -128,7 +130,8 @@ describe('LegacyChatImportService', () => { appendMessageReplacement: vi.fn(() => 0), appendMessageRetraction: vi.fn(() => 0) }, - '/mock/legacy.db' + '/mock/legacy.db', + notifyEnvironmentProjectionChanged ) }) @@ -158,6 +161,7 @@ describe('LegacyChatImportService', () => { }) ) expect(sqlitePresenter.newEnvironmentsTable.rebuildFromSessions).toHaveBeenCalledTimes(1) + expect(notifyEnvironmentProjectionChanged).toHaveBeenCalledTimes(1) }) it('imports legacy conversation workdir as the project directory', async () => { @@ -277,6 +281,7 @@ describe('LegacyChatImportService', () => { importedMessages: 0, importedSearchResults: 0 }) + expect(notifyEnvironmentProjectionChanged).not.toHaveBeenCalled() expect(errorSpy).toHaveBeenCalledWith( '[LegacyChatImport] Failed to rebuild environments after import:', expect.objectContaining({ diff --git a/test/main/app/startupMigrations/sessionDataMigrations.test.ts b/test/main/app/startupMigrations/sessionDataMigrations.test.ts index 6e4a6f697d..40f4a7564a 100644 --- a/test/main/app/startupMigrations/sessionDataMigrations.test.ts +++ b/test/main/app/startupMigrations/sessionDataMigrations.test.ts @@ -16,10 +16,12 @@ function createFixture() { const sessionRows: Array<{ id: string }> = [] const sessionDisabledTools = new Map () const statements: string[] = [] - const updateSessionDisabledTools = vi.fn((serialized: string, sessionId: string) => ({ - changes: sessionRows.some((row) => row.id === sessionId) ? 1 : 0, - serialized - })) + const updateSessionDisabledTools = vi.fn( + (serialized: string, _updatedAt: number, sessionId: string) => ({ + changes: sessionRows.some((row) => row.id === sessionId) ? 1 : 0, + serialized + }) + ) const replaceSessionDisabledTools = vi.fn((sessionId: string, disabledAgentTools: string[]) => { sessionDisabledTools.set(sessionId, disabledAgentTools) }) @@ -232,6 +234,7 @@ describe('session data migrations', () => { ]) expect(fixture.updateSessionDisabledTools).toHaveBeenCalledWith( JSON.stringify(['cdp_send', 'custom_tool', 'exec']), + expect.any(Number), 'session-1' ) expect(fixture.providerSettings.updateDeepChatAgent).toHaveBeenCalledWith('deepchat', { @@ -256,8 +259,9 @@ describe('session data migrations', () => { const sessionUpdate = fixture.statements.find((sql) => sql.startsWith('UPDATE new_sessions SET disabled_agent_tools') ) - expect(sessionUpdate).toBe('UPDATE new_sessions SET disabled_agent_tools = ? WHERE id = ?') - expect(sessionUpdate).not.toContain('updated_at') + expect(sessionUpdate).toBe( + 'UPDATE new_sessions SET disabled_agent_tools = ?, updated_at = ?, revision = revision + 1 WHERE id = ?' + ) }) it('uses bounded session batches and yields between them', async () => { diff --git a/test/main/data/mainDatabase.test.ts b/test/main/data/mainDatabase.test.ts index de117a72b6..b398bdee18 100644 --- a/test/main/data/mainDatabase.test.ts +++ b/test/main/data/mainDatabase.test.ts @@ -207,35 +207,46 @@ describeIfSqlite('MainDatabase legacy schema bootstrap', () => { }) it('migrates ACP agent aliases without requiring legacy conversations tables', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepchat-sqlite-presenter-')) - tempDirs.push(tempDir) - - const dbPath = path.join(tempDir, 'agent.db') - const presenter = new MainDatabaseCtor(dbPath) + vi.useFakeTimers() + try { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepchat-sqlite-presenter-')) + tempDirs.push(tempDir) + + const dbPath = path.join(tempDir, 'agent.db') + const presenter = new MainDatabaseCtor(dbPath) + + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + presenter.newSessionsTable.create('session-1', 'kimi-cli', 'Recovered session', null) + presenter.deepchatSessionsTable.create('session-1', 'acp', 'kimi-cli', 'full_access') + await presenter.upsertAcpSession('conversation-1', 'kimi-cli', { + sessionId: 'acp-session-1', + status: 'active' + }) - presenter.newSessionsTable.create('session-1', 'kimi-cli', 'Recovered session', null) - presenter.deepchatSessionsTable.create('session-1', 'acp', 'kimi-cli', 'full_access') - await presenter.upsertAcpSession('conversation-1', 'kimi-cli', { - sessionId: 'acp-session-1', - status: 'active' - }) + vi.setSystemTime(new Date('2026-01-01T00:00:01.000Z')) + await expect( + presenter.migrateAcpAgentReferences({ + 'kimi-cli': 'kimi' + }) + ).resolves.toBeUndefined() - await expect( - presenter.migrateAcpAgentReferences({ - 'kimi-cli': 'kimi' + expect(presenter.newSessionsTable.get('session-1')).toMatchObject({ + agent_id: 'kimi', + revision: 1, + updated_at: Date.parse('2026-01-01T00:00:01.000Z') + }) + expect(presenter.deepchatSessionsTable.get('session-1')?.model_id).toBe('kimi') + expect(await presenter.getAcpSession('conversation-1', 'kimi-cli')).toBeNull() + expect(await presenter.getAcpSession('conversation-1', 'kimi')).toMatchObject({ + conversationId: 'conversation-1', + agentId: 'kimi', + sessionId: 'acp-session-1' }) - ).resolves.toBeUndefined() - - expect(presenter.newSessionsTable.get('session-1')?.agent_id).toBe('kimi') - expect(presenter.deepchatSessionsTable.get('session-1')?.model_id).toBe('kimi') - expect(await presenter.getAcpSession('conversation-1', 'kimi-cli')).toBeNull() - expect(await presenter.getAcpSession('conversation-1', 'kimi')).toMatchObject({ - conversationId: 'conversation-1', - agentId: 'kimi', - sessionId: 'acp-session-1' - }) - presenter.close() + presenter.close() + } finally { + vi.useRealTimers() + } }) it('recreates new_sessions with applied columns when schema version is already 16', async () => { diff --git a/test/main/project/projectService.test.ts b/test/main/project/projectService.test.ts index 47b5b005df..8ca60a9790 100644 --- a/test/main/project/projectService.test.ts +++ b/test/main/project/projectService.test.ts @@ -72,12 +72,10 @@ function createMockDeviceService() { } function createMockSettingsStore(defaultProjectPath: string | null = null) { - let currentDefaultProjectPath = defaultProjectPath + const values = new Map ([['defaultProjectPath', defaultProjectPath]]) return { - get: vi.fn(() => currentDefaultProjectPath), - set: vi.fn((key: string, projectPath: string | null) => { - if (key === 'defaultProjectPath') currentDefaultProjectPath = projectPath - }) + get: vi.fn((key: string) => values.get(key)), + set: vi.fn((key: string, value: unknown) => values.set(key, value)) } as any } @@ -101,6 +99,128 @@ describe('ProjectService', () => { ) }) + describe('versioned snapshots', () => { + it('returns projects, all environment states, and default path from one versioned snapshot', async () => { + const settingsStore = createMockSettingsStore('/work/default') + sqlitePresenter.newProjectsTable.getAll.mockReturnValue([ + { path: '/work/project', name: 'project', icon: null, last_accessed_at: 10 } + ]) + sqlitePresenter.newEnvironmentsTable.list.mockReturnValue([ + { path: '/work/project', session_count: 2, last_used_at: 20 } + ]) + sqlitePresenter.newEnvironmentPreferencesTable.list.mockReturnValue([ + { + path: '/work/archived', + status: 'archived', + sort_order: 2147483647, + archived_at: 30, + removed_at: null, + updated_at: 30 + }, + { + path: '/work/removed', + status: 'removed', + sort_order: 2147483647, + archived_at: null, + removed_at: 40, + updated_at: 40 + } + ]) + presenter = new ProjectService( + sqlitePresenter, + sqlitePresenter, + deviceService, + settingsStore, + vi.fn() + ) + + const initial = await presenter.getSnapshot() + await presenter.restoreEnvironment('/work/archived') + const updated = await presenter.getSnapshot() + + expect(initial.version).toBe(0) + expect(initial.projects.map((project) => project.path)).toEqual(['/work/project']) + expect(initial.archivedEnvironments.map((item) => item.path)).toEqual(['/work/archived']) + expect(initial.removedEnvironments.map((item) => item.path)).toEqual(['/work/removed']) + expect(initial.defaultProjectPath).toBe('/work/default') + expect(updated.version).toBe(1) + }) + + it('persists the snapshot version across ProjectService reconstruction', () => { + const settingsStore = createMockSettingsStore() + const first = new ProjectService( + sqlitePresenter, + sqlitePresenter, + deviceService, + settingsStore, + vi.fn() + ) + + first.notifyEnvironmentProjectionChanged() + first.notifyEnvironmentProjectionChanged() + + const reconstructed = new ProjectService( + sqlitePresenter, + sqlitePresenter, + deviceService, + settingsStore, + vi.fn() + ) + + expect(reconstructed.getSnapshotVersion()).toBe(2) + expect(reconstructed.notifyEnvironmentProjectionChanged()).toBe(3) + }) + + it('falls back to version zero when the persisted version is malformed', () => { + const settingsStore = createMockSettingsStore() + settingsStore.set('projectSnapshotVersion', 'invalid') + + const reconstructed = new ProjectService( + sqlitePresenter, + sqlitePresenter, + deviceService, + settingsStore, + vi.fn() + ) + + expect(reconstructed.getSnapshotVersion()).toBe(0) + }) + + it('publishes the same monotonic version with a default path change', () => { + const publishDefaultProjectPathChanged = vi.fn() + presenter = new ProjectService( + sqlitePresenter, + sqlitePresenter, + deviceService, + createMockSettingsStore(), + publishDefaultProjectPathChanged + ) + + presenter.setDefaultProjectPath('/work/default') + + expect(publishDefaultProjectPathChanged).toHaveBeenCalledWith('/work/default', 1) + expect(presenter.getSnapshotVersion()).toBe(1) + }) + + it('versions and publishes external environment projection changes', () => { + const publishEnvironmentsChanged = vi.fn() + presenter = new ProjectService( + sqlitePresenter, + sqlitePresenter, + deviceService, + createMockSettingsStore(), + vi.fn(), + publishEnvironmentsChanged + ) + + const version = presenter.notifyEnvironmentProjectionChanged() + + expect(version).toBe(1) + expect(presenter.getSnapshotVersion()).toBe(1) + expect(publishEnvironmentsChanged).toHaveBeenCalledWith('select', null, 1) + }) + }) + describe('ensureDefaultWorkspace', () => { it('creates and registers the Documents default workspace for first-run users', async () => { const settingsStore = createMockSettingsStore() @@ -145,7 +265,10 @@ describe('ProjectService', () => { '/mock/documents/DeepChat', 'DeepChat' ) - expect(settingsStore.set).not.toHaveBeenCalled() + expect(settingsStore.set).not.toHaveBeenCalledWith( + 'defaultProjectPath', + '/mock/documents/DeepChat' + ) }) it('does not migrate users with a custom default project path', async () => { diff --git a/test/main/routes/contracts.test.ts b/test/main/routes/contracts.test.ts index ecc2147c37..1adaefd5ff 100644 --- a/test/main/routes/contracts.test.ts +++ b/test/main/routes/contracts.test.ts @@ -8,7 +8,9 @@ import { contextMenuAskAiRequestedEvent, contextMenuTranslateRequestedEvent, settingsChangedEvent, - sessionsUpdatedEvent + sessionsUpdatedEvent, + projectEnvironmentsChangedEvent, + configLanguageChangedEvent } from '@shared/contracts/events' import { DEEPCHAT_ROUTE_CATALOG, @@ -277,6 +279,40 @@ describe('main kernel contracts', () => { expect(new Set(routeKeys).size).toBe(routeKeys.length) }) + it('uses revision schemas for versioned project and configuration payloads', () => { + expect( + projectEnvironmentsChangedEvent.payload.parse({ + action: 'select', + path: '/workspace', + version: 7 + }) + ).toEqual({ action: 'select', path: '/workspace', version: 7 }) + expect(() => + projectEnvironmentsChangedEvent.payload.parse({ + action: 'select', + path: '/workspace', + version: -1 + }) + ).toThrow() + + expect( + configLanguageChangedEvent.payload.parse({ + requestedLanguage: 'en-US', + locale: 'en-US', + direction: 'ltr', + version: 3 + }) + ).toMatchObject({ version: 3 }) + expect(() => + configLanguageChangedEvent.payload.parse({ + requestedLanguage: 'en-US', + locale: 'en-US', + direction: 'ltr', + version: -1 + }) + ).toThrow() + }) + it('trims and rejects blank project path route inputs', () => { expect( DEEPCHAT_ROUTE_CATALOG['project.reorderEnvironments'].input.parse({ diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index 5c79a6e5e2..272a370195 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -956,6 +956,15 @@ function createRuntime() { settings.defaultProjectPath = projectPath }), ensureDefaultWorkspace: vi.fn().mockResolvedValue('C:/Users/test/Documents/DeepChat'), + getSnapshotVersion: vi.fn(() => 1), + getSnapshot: vi.fn().mockResolvedValue({ + version: 1, + projects: [], + environments: [], + archivedEnvironments: [], + removedEnvironments: [], + defaultProjectPath: null + }), getRecentProjects: vi.fn().mockResolvedValue([ { path: 'C:/workspace', diff --git a/test/main/session/data/tables/newSessionsTable.test.ts b/test/main/session/data/tables/newSessionsTable.test.ts index 6ea4616bb0..69e7cb6b24 100644 --- a/test/main/session/data/tables/newSessionsTable.test.ts +++ b/test/main/session/data/tables/newSessionsTable.test.ts @@ -41,7 +41,7 @@ describeIfSqlite('NewSessionsTable', () => { db = null }) - it('clears regular project_dir without changing recency or subagent rows', () => { + it('clears regular project_dir, advances revision, and leaves subagent rows untouched', () => { db! .prepare( `INSERT INTO new_sessions ( @@ -50,11 +50,12 @@ describeIfSqlite('NewSessionsTable', () => { title, project_dir, session_kind, + revision, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ) - .run('regular-1', 'agent', 'Regular', '/work/app', 'regular', 100, 200) + .run('regular-1', 'agent', 'Regular', '/work/app', 'regular', 4, 100, 200) db! .prepare( `INSERT INTO new_sessions ( @@ -63,25 +64,31 @@ describeIfSqlite('NewSessionsTable', () => { title, project_dir, session_kind, + revision, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ) - .run('subagent-1', 'agent', 'Subagent', '/work/app', 'subagent', 300, 400) + .run('subagent-1', 'agent', 'Subagent', '/work/app', 'subagent', 8, 300, 400) expect(table.clearProjectDir('/work/app')).toEqual(['regular-1']) expect( - db!.prepare('SELECT project_dir, updated_at FROM new_sessions WHERE id = ?').get('regular-1') - ).toEqual({ + db! + .prepare('SELECT project_dir, updated_at, revision FROM new_sessions WHERE id = ?') + .get('regular-1') + ).toMatchObject({ project_dir: null, - updated_at: 200 + revision: 5 }) expect( - db!.prepare('SELECT project_dir, updated_at FROM new_sessions WHERE id = ?').get('subagent-1') + db! + .prepare('SELECT project_dir, updated_at, revision FROM new_sessions WHERE id = ?') + .get('subagent-1') ).toEqual({ project_dir: '/work/app', - updated_at: 400 + updated_at: 400, + revision: 8 }) }) @@ -110,6 +117,43 @@ describeIfSqlite('NewSessionsTable', () => { ).toEqual(['first', 'last']) }) + it('increments durable revision for session updates without relying on timestamps', () => { + table.create('session-1', 'deepchat', 'Session', null) + const created = table.get('session-1') + table.update('session-1', { title: 'Renamed' }) + table.updateAgentId('session-1', 'acp') + + expect(table.get('session-1')).toMatchObject({ + title: 'Renamed', + agent_id: 'acp', + revision: (created?.revision ?? 0) + 2 + }) + }) + + it('reassigns matching agent sessions and advances only their revisions', () => { + table.create('matching-1', 'legacy-agent', 'First matching session', null) + table.create('matching-2', 'legacy-agent', 'Second matching session', null) + table.create('unmatched', 'other-agent', 'Unmatched session', null) + + const matchingBefore = [table.get('matching-1'), table.get('matching-2')] + const unmatchedBefore = table.get('unmatched') + + table.reassignAgentId('legacy-agent', 'replacement-agent') + + expect(table.get('matching-1')).toMatchObject({ + agent_id: 'replacement-agent', + revision: (matchingBefore[0]?.revision ?? 0) + 1 + }) + expect(table.get('matching-2')).toMatchObject({ + agent_id: 'replacement-agent', + revision: (matchingBefore[1]?.revision ?? 0) + 1 + }) + expect(table.get('unmatched')).toMatchObject({ + agent_id: 'other-agent', + revision: unmatchedBefore?.revision + }) + }) + it('leaves the legacy Subagent policy column at its database-owned value', () => { table.create('session-1', 'deepchat', 'Session', null) expect(table.get('session-1')?.subagent_enabled).toBe(0) diff --git a/test/main/session/query.test.ts b/test/main/session/query.test.ts index 5cf893cb0c..769af82b1e 100644 --- a/test/main/session/query.test.ts +++ b/test/main/session/query.test.ts @@ -298,9 +298,11 @@ describe('SessionQuery', () => { it('owns rename, pin, normalized updates, and UI refresh', async () => { const harness = createHarness() - await harness.coordinator.renameSession('s1', ' Renamed ') - await harness.coordinator.toggleSessionPinned('s1', true) + const renamed = await harness.coordinator.renameSession('s1', ' Renamed ') + const pinned = await harness.coordinator.toggleSessionPinned('s1', true) expect(harness.records.get('s1')).toMatchObject({ title: 'Renamed', isPinned: true }) + expect(renamed).toMatchObject({ title: 'Renamed' }) + expect(pinned).toMatchObject({ isPinned: true }) harness.events.publish.mockClear() harness.ui.refreshSessionUi.mockClear() diff --git a/test/renderer/components/App.startup.test.ts b/test/renderer/components/App.startup.test.ts index 94b00d7ffa..76c842cfee 100644 --- a/test/renderer/components/App.startup.test.ts +++ b/test/renderer/components/App.startup.test.ts @@ -247,12 +247,13 @@ const mountApp = async (options?: { const agentStore = { setSelectedAgent: vi.fn() } + let nextStartDeeplinkToken = 0 const draftStore = reactive({ pendingStartDeeplink: null as null | Record , setPendingStartDeeplink: vi.fn((payload: Record ) => { draftStore.pendingStartDeeplink = { ...payload, - token: 1 + token: ++nextStartDeeplinkToken } }) }) @@ -337,7 +338,8 @@ const mountApp = async (options?: { vi.doMock('vue-i18n', () => ({ useI18n: () => ({ - t: (key: string) => key + t: (key: string) => key, + locale: ref('zh-CN') }) })) @@ -410,7 +412,7 @@ const mountApp = async (options?: { useModelStore: () => modelStore })) vi.doMock('@/lib/storeInitializer', () => ({ - initAppStores: vi.fn(), + initAppStores: vi.fn().mockResolvedValue(undefined), useMcpInstallDeeplinkHandler: () => ({ setup: setupMcpDeeplink, cleanup: cleanupMcpDeeplink @@ -797,7 +799,7 @@ describe('App startup welcome flow', () => { modelId: 'deepseek-chat', systemPrompt: 'Be concise', mentions: ['README.md'], - autoSend: false + autoSend: true }) await flushPromises() @@ -805,14 +807,49 @@ describe('App startup welcome flow', () => { msg: '你好,DeepChat', modelId: 'deepseek-chat', systemPrompt: 'Be concise', - mentions: ['README.md'], - autoSend: false + mentions: ['README.md'] }) expect(agentStore.setSelectedAgent).toHaveBeenCalledWith('deepchat') expect(sessionStore.closeSession).toHaveBeenCalledTimes(1) expect(pageRouterStore.goToNewThread).not.toHaveBeenCalled() }) + it('does not let a replaced start deeplink overwrite the newer navigation intent', async () => { + const { draftStore, pageRouterStore, sessionStore, ipcOn } = await mountApp({ + initComplete: true, + routeName: 'chat', + hasActiveSession: true + }) + let releaseFirstClose: (() => void) | undefined + const firstClose = new Promise ((resolve) => { + releaseFirstClose = resolve + }) + sessionStore.closeSession.mockImplementationOnce(() => firstClose) + + const startHandler = ipcOn.mock.calls.find( + ([eventName]: [string]) => eventName === 'appRuntime.startDeeplinkRequested' + )?.[1] + + expect(startHandler).toBeTypeOf('function') + + startHandler?.({ msg: 'A', autoSend: false }) + await flushPromises() + expect(sessionStore.closeSession).toHaveBeenCalledTimes(1) + + sessionStore.hasActiveSession = false + startHandler?.({ msg: 'B', autoSend: false }) + await flushPromises() + + expect(pageRouterStore.goToNewThread).toHaveBeenCalledTimes(1) + expect(draftStore.pendingStartDeeplink).toMatchObject({ msg: 'B', token: 2 }) + + releaseFirstClose?.() + await flushPromises() + + expect(pageRouterStore.goToNewThread).toHaveBeenCalledTimes(1) + expect(draftStore.pendingStartDeeplink).toMatchObject({ msg: 'B', token: 2 }) + }) + it('opens spotlight from the global shortcut event', async () => { const { ipcOn, spotlightStore } = await mountApp({ initComplete: true, diff --git a/test/renderer/components/ChatInputToolbar.test.ts b/test/renderer/components/ChatInputToolbar.test.ts index 5b48f706e3..63733dcbc3 100644 --- a/test/renderer/components/ChatInputToolbar.test.ts +++ b/test/renderer/components/ChatInputToolbar.test.ts @@ -110,6 +110,63 @@ describe('ChatInputToolbar', () => { expect(wrapper.emitted('steer')).toEqual([[]]) }) + it('disables steer when the active turn cannot be interrupted', async () => { + const ChatInputToolbar = (await import('@/components/chat/ChatInputToolbar.vue')).default + const wrapper = mount(ChatInputToolbar, { + props: { + isGenerating: true, + hasInput: true, + steerDisabled: true + } + }) + + const steerButton = wrapper.get('[data-testid="chat-steer-button"]') + expect(steerButton.attributes('disabled')).toBeDefined() + expect(wrapper.text()).toContain('chat.pendingInput.steerUnavailable') + + await steerButton.trigger('click') + expect(wrapper.emitted('steer')).toBeUndefined() + }) + + it('renders progress and blocks repeated steer clicks while pending', async () => { + const ChatInputToolbar = (await import('@/components/chat/ChatInputToolbar.vue')).default + const wrapper = mount(ChatInputToolbar, { + props: { + isGenerating: true, + hasInput: true, + isSteering: true + } + }) + + const steerButton = wrapper.get('[data-testid="chat-steer-button"]') + expect(steerButton.attributes('disabled')).toBeDefined() + expect(steerButton.attributes('aria-busy')).toBe('true') + expect(steerButton.find('[role="status"]').exists()).toBe(true) + expect(wrapper.find('[data-icon="lucide:compass"]').exists()).toBe(false) + + await steerButton.trigger('click') + expect(wrapper.emitted('steer')).toBeUndefined() + }) + + it('renders progress and blocks repeated stop clicks while pending', async () => { + const ChatInputToolbar = (await import('@/components/chat/ChatInputToolbar.vue')).default + const wrapper = mount(ChatInputToolbar, { + props: { + isGenerating: true, + hasInput: false, + isStopping: true + } + }) + + const stopButton = wrapper.get('[data-testid="chat-stop-button"]') + expect(stopButton.attributes('disabled')).toBeDefined() + expect(stopButton.attributes('aria-busy')).toBe('true') + expect(stopButton.find('[role="status"]').exists()).toBe(true) + + await stopButton.trigger('click') + expect(wrapper.emitted('stop')).toBeUndefined() + }) + it('emits voice-input and switches icon while listening', async () => { const ChatInputToolbar = (await import('@/components/chat/ChatInputToolbar.vue')).default const wrapper = mount(ChatInputToolbar, { diff --git a/test/renderer/components/ChatPage.test.ts b/test/renderer/components/ChatPage.test.ts index 5da5de18d5..cb5e564da5 100644 --- a/test/renderer/components/ChatPage.test.ts +++ b/test/renderer/components/ChatPage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { defineComponent, provide, reactive } from 'vue' +import { defineComponent, provide, reactive, ref } from 'vue' import { flushPromises, mount } from '@vue/test-utils' import { WORKSPACE_EVENTS } from '@/events' @@ -59,6 +59,7 @@ type SetupOptions = { deferStartupTasks?: boolean autoScrollEnabled?: boolean cachedMeasurements?: Readonly > + mockChatSearch?: boolean performanceReporter?: { recordChatSession: ReturnType } @@ -66,6 +67,7 @@ type SetupOptions = { const setup = async (options: SetupOptions = {}) => { vi.resetModules() + vi.doUnmock('@/features/chat-page/composables/useChatSearch') const activeStatus = String(options.activeSessionPatch?.status ?? 'idle') const sessionStore = reactive({ @@ -128,6 +130,7 @@ const setup = async (options: SetupOptions = {}) => { : options.currentStreamSessionId, hasMoreHistory: false, isLoadingHistory: false, + historyLoadError: false, messageIds: ( options.messages ?? [ buildAssistantMessage([ @@ -316,6 +319,9 @@ const setup = async (options: SetupOptions = {}) => { vi.doMock('@shadcn/components/ui/tooltip', () => ({ TooltipProvider: passthrough('TooltipProvider') })) + vi.doMock('@shadcn/components/ui/button', () => ({ + Button: clickStub('Button') + })) vi.doMock('@shadcn/components/ui/alert-dialog', () => ({ AlertDialog: defineComponent({ name: 'AlertDialog', @@ -380,9 +386,9 @@ const setup = async (options: SetupOptions = {}) => { type: Boolean, default: false }, - allMessagesForCapture: { - type: Array, - default: () => [] + resolveCaptureParentId: { + type: Function, + default: undefined }, beforeSpacerHeight: { type: Number, @@ -457,11 +463,23 @@ const setup = async (options: SetupOptions = {}) => { queueDisabled: { type: Boolean, default: false + }, + steerDisabled: { + type: Boolean, + default: false + }, + isSteering: { + type: Boolean, + default: false + }, + isStopping: { + type: Boolean, + default: false } }, emits: ['attach', 'queue', 'send', 'steer', 'stop'], template: - '' + '' }) })) vi.doMock('@/components/chat/AgentProgressFloat.vue', () => ({ @@ -524,6 +542,24 @@ const setup = async (options: SetupOptions = {}) => { ' ' }) })) + const disposeChatSearch = vi.fn() + if (options.mockChatSearch) { + vi.doMock('@/features/chat-page/composables/useChatSearch', () => ({ + useChatSearch: () => ({ + isChatSearchOpen: ref(false), + chatSearchQuery: ref(''), + activeChatSearchIndex: ref(0), + chatSearchBarRef: ref(null), + chatSearchResults: ref([]), + closeChatSearch: vi.fn(), + clearChatSearchState: vi.fn(), + goToNextChatSearchMatch: vi.fn(), + goToPreviousChatSearchMatch: vi.fn(), + handleSearchKeydown: vi.fn(), + disposeChatSearch + }) + })) + } vi.doMock('@/components/chat/ChatSearchBar.vue', () => ({ default: defineComponent({ name: 'ChatSearchBar', @@ -596,6 +632,7 @@ const setup = async (options: SetupOptions = {}) => { chatInputGetPendingSkillsSnapshot, chatInputClearPendingSkills, recentMessageMeasurementCache, + disposeChatSearch, emitPlanUpdated: (payload: any) => { planUpdatedListener?.(payload) }, @@ -692,6 +729,16 @@ describe('ChatPage', () => { expect(JSON.stringify(performanceReporter.recordChatSession.mock.calls)).not.toContain('s1') }) + it('disposes chat search resources when the page unmounts', async () => { + const { disposeChatSearch, wrapper } = await setup({ mockChatSearch: true }) + + expect(disposeChatSearch).not.toHaveBeenCalled() + + wrapper.unmount() + + expect(disposeChatSearch).toHaveBeenCalledTimes(1) + }) + it('isolates header, message viewport, and composer into independent shell rows', async () => { const { wrapper } = await setup() const shell = wrapper.get('[data-testid="chat-page-shell"]') @@ -728,7 +775,12 @@ describe('ChatPage', () => { const messageList = wrapper.findComponent({ name: 'MessageList' }) expect((messageList.props('messages') as unknown[]).length).toBeLessThanOrEqual(90) - expect((messageList.props('allMessagesForCapture') as unknown[]).length).toBe(300) + const resolveCaptureParentId = messageList.props('resolveCaptureParentId') as ( + messageId: string, + parentId?: string + ) => string | undefined + expect(resolveCaptureParentId).toBeTypeOf('function') + expect(resolveCaptureParentId('m150')).toBeUndefined() expect(messageList.props('beforeSpacerHeight')).toBeGreaterThan(0) }) @@ -1460,6 +1512,40 @@ describe('ChatPage', () => { wrapper.unmount() }) + it('retries failed history loading even before the initial restore threshold', async () => { + const messages = Array.from({ length: 20 }, (_, index) => ({ + ...buildAssistantMessage([ + { type: 'content', content: `message ${index}`, status: 'success', timestamp: index } + ]), + id: `history-${index}`, + orderSeq: index + 1 + })) + const { wrapper, messageStore } = await setup({ messages, deferStartupTasks: true }) + const chatPage = wrapper.get('[data-testid="chat-page"]').element as HTMLDivElement + let scrollTop = 0 + Object.defineProperty(chatPage, 'clientHeight', { configurable: true, get: () => 500 }) + Object.defineProperty(chatPage, 'scrollHeight', { configurable: true, get: () => 1200 }) + Object.defineProperty(chatPage, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value + } + }) + + messageStore.hasMoreHistory = true + messageStore.historyLoadError = true + await flushPromises() + + const error = wrapper.get('[data-testid="history-load-error"]') + expect(error.attributes('role')).toBe('alert') + await wrapper.get('[data-testid="history-load-retry"]').trigger('click') + await flushPromises() + + expect(messageStore.loadOlderMessages).toHaveBeenCalledOnce() + wrapper.unmount() + }) + it('does not rehydrate persisted plan blocks when switching sessions', async () => { const { wrapper, messageStore, agentPlanStore, flushStartupDeferredTasks } = await setup({ deferStartupTasks: true, @@ -2731,11 +2817,80 @@ describe('ChatPage', () => { expect(inputBox.props('queueSubmitDisabled')).toBe(true) expect(toolbar.props('sendDisabled')).toBe(true) expect(toolbar.props('queueDisabled')).toBe(true) - // Steer button is always available when generating with input + expect(toolbar.props('steerDisabled')).toBe(false) const steerButton = toolbar.find('[data-testid="chat-steer-button"]') expect(steerButton.exists()).toBe(true) }) + it('disables composer steer whenever its submit guard would reject it', async () => { + const { wrapper, chatClient } = await setup({ + isStreaming: true, + activeSessionPatch: { + projectDir: '' + } + }) + const inputBox = wrapper.findComponent({ name: 'ChatInputBox' }) + inputBox.vm.$emit('update:modelValue', 'tighten the answer') + await flushPromises() + + const toolbar = wrapper.findComponent({ name: 'ChatInputToolbar' }) + expect(toolbar.props('steerDisabled')).toBe(true) + + toolbar.vm.$emit('steer') + await flushPromises() + expect(chatClient.steerActiveTurn).not.toHaveBeenCalled() + }) + + it('blocks duplicate stop requests while cancellation is pending', async () => { + const stopping = createDeferred<{ stopped: boolean }>() + const { wrapper, chatClient, agentPlanStore } = await setup({ isStreaming: true }) + chatClient.stopStream.mockReturnValueOnce(stopping.promise) + const toolbar = wrapper.findComponent({ name: 'ChatInputToolbar' }) + + toolbar.vm.$emit('stop') + await flushPromises() + expect(toolbar.props('isStopping')).toBe(true) + + toolbar.vm.$emit('stop') + await flushPromises() + expect(chatClient.stopStream).toHaveBeenCalledTimes(1) + + stopping.resolve({ stopped: true }) + await flushPromises() + + expect(agentPlanStore.freezeActive).toHaveBeenCalledWith('s1') + expect(toolbar.props('isStopping')).toBe(false) + }) + + it('reports stop responses that did not cancel generation', async () => { + const { wrapper, chatClient, agentPlanStore, toast } = await setup({ isStreaming: true }) + chatClient.stopStream.mockResolvedValueOnce({ stopped: false }) + + wrapper.findComponent({ name: 'ChatInputToolbar' }).vm.$emit('stop') + await flushPromises() + + expect(agentPlanStore.freezeActive).not.toHaveBeenCalled() + expect(toast).toHaveBeenCalledWith({ + title: 'chat.input.stop', + description: 'common.error.requestFailed', + variant: 'destructive' + }) + }) + + it('reports rejected stop requests', async () => { + const { wrapper, chatClient, toast } = await setup({ isStreaming: true }) + chatClient.stopStream.mockRejectedValueOnce(new Error('boom')) + + wrapper.findComponent({ name: 'ChatInputToolbar' }).vm.$emit('stop') + await flushPromises() + + expect(toast).toHaveBeenCalledWith({ + title: 'chat.input.stop', + description: 'common.error.requestFailed', + variant: 'destructive' + }) + }) + it('queues drafts explicitly while a generation is running', async () => { const { wrapper, pendingInputStore, chatClient } = await setup({ isStreaming: true @@ -3317,6 +3472,10 @@ describe('ChatPage', () => { const searchBar = wrapper.findComponent({ name: 'ChatSearchBar' }) searchBar.vm.$emit('update:modelValue', 'needle') await flushPromises() + // Settle the 150ms query debounce (real timers in this test) before the + // highlight/jump frames run. + await new Promise((resolve) => setTimeout(resolve, 180)) + await flushPromises() await flushRaf() await flushRaf() diff --git a/test/renderer/components/ChatTabView.test.ts b/test/renderer/components/ChatTabView.test.ts index 6ba18baae5..e06557ee43 100644 --- a/test/renderer/components/ChatTabView.test.ts +++ b/test/renderer/components/ChatTabView.test.ts @@ -213,7 +213,7 @@ const setup = async (options: SetupOptions = {}) => { }) })) - const ChatTabView = (await import('@/views/ChatTabView.vue')).default + const ChatTabView = (await import('@/apps/chat-main/ChatTabView.vue')).default const { RENDERER_PERFORMANCE_REPORTER } = await import('@/platform/performance/rendererPerformance') const Host = defineComponent({ diff --git a/test/renderer/components/MarkdownRenderer.test.ts b/test/renderer/components/MarkdownRenderer.test.ts index 336aca4973..810f0ba312 100644 --- a/test/renderer/components/MarkdownRenderer.test.ts +++ b/test/renderer/components/MarkdownRenderer.test.ts @@ -1,8 +1,16 @@ import { flushPromises, mount } from '@vue/test-utils' import { defineComponent, h } from 'vue' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MarkdownLinkContext } from '@/components/markdown/linkTypes' +function createDeferred () { + let resolve!: (value: T) => void + const promise = new Promise ((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve } +} + const { showArtifactMock, getSearchResultsMock, @@ -25,6 +33,16 @@ const setup = async (props: Record = {}) => { vi.resetModules() let customComponents: Record any> = {} + const setCustomComponentsMock = vi.fn( + ( + customIdOrComponents: string | Record any>, + maybeComponents?: Record any> + ) => { + customComponents = + typeof customIdOrComponents === 'string' ? (maybeComponents ?? {}) : customIdOrComponents + } + ) + const removeCustomComponentsMock = vi.fn() vi.doMock('nanoid', () => ({ nanoid: nanoidMock @@ -78,7 +96,8 @@ const setup = async (props: Record = {}) => { artifactTitle: 'HTML Preview', language: 'html', node: { - code: ' Hello
' + code: 'Hello
', + language: 'html' } } @@ -89,72 +108,16 @@ const setup = async (props: Record= {}) => { type: Boolean, default: undefined }, - mode: { - type: String, - default: undefined - }, - htmlPolicy: { - type: String, - default: undefined - }, - smoothStreaming: { - type: [Boolean, String], - default: false - }, - typewriter: { - type: Boolean, - default: false - }, - batchRendering: { - type: Boolean, - default: false - }, - deferNodesUntilVisible: { - type: Boolean, - default: false - }, - viewportPriority: { - type: Boolean, - default: false - }, - nodeVirtual: { - type: [Boolean, String], - default: false - }, - maxLiveNodes: { - type: Number, - default: undefined - }, - liveNodeBuffer: { - type: Number, - default: undefined - }, codeBlockStream: { type: Boolean, default: false }, - initialRenderBatchSize: { - type: Number, - default: undefined - }, - renderBatchSize: { - type: Number, - default: undefined - }, - renderBatchDelay: { - type: Number, + mermaidProps: { + type: Object, default: undefined }, - renderBatchBudgetMs: { - type: Number, - default: undefined - }, - renderBatchIdleTimeoutMs: { - type: Number, - default: undefined - }, - parseCoalesceMs: { - type: Number, + customId: { + type: String, default: undefined }, content: { @@ -162,84 +125,69 @@ const setup = async (props: Record = {}) => { default: '' } }, - setup(props) { + emits: ['click', 'mouseover', 'mouseout', 'handleArtifactClick'], + setup(props, { emit }) { return () => h( 'div', { 'data-testid': 'node-renderer', 'data-final': String(props.final), - 'data-mode': props.mode, - 'data-html-policy': props.htmlPolicy, - 'data-smooth-streaming': String(props.smoothStreaming), - 'data-typewriter': String(props.typewriter), - 'data-batch-rendering': String(props.batchRendering), - 'data-defer-nodes-until-visible': String(props.deferNodesUntilVisible), - 'data-viewport-priority': String(props.viewportPriority), - 'data-node-virtual': String(props.nodeVirtual), - 'data-max-live-nodes': String(props.maxLiveNodes), - 'data-live-node-buffer': String(props.liveNodeBuffer), 'data-code-block-stream': String(props.codeBlockStream), - 'data-initial-render-batch-size': String(props.initialRenderBatchSize), - 'data-render-batch-size': String(props.renderBatchSize), - 'data-render-batch-delay': String(props.renderBatchDelay), - 'data-render-batch-budget-ms': String(props.renderBatchBudgetMs), - 'data-render-batch-idle-timeout-ms': String(props.renderBatchIdleTimeoutMs), - 'data-parse-coalesce-ms': String(props.parseCoalesceMs), + 'data-mermaid-strict': String(props.mermaidProps?.isStrict), + 'data-custom-id': props.customId, 'data-content': props.content }, [ - customComponents.code_block?.({ - node: { - language: 'html', - code: ' Hello
', - raw: 'Hello
' - } - }) ?? h('div') + h( + 'a', + { + href: 'https://example.com/link', + class: 'link-node', + 'data-testid': 'rendered-link', + onClick: (event: MouseEvent) => emit('click', event) + }, + 'link' + ), + h( + 'a', + { + href: '#unmarked-anchor', + 'data-testid': 'unmarked-anchor', + onClick: (event: MouseEvent) => emit('click', event) + }, + 'unmarked anchor' + ), + h( + 'button', + { + type: 'button', + 'data-testid': 'preview-code', + onClick: () => emit('handleArtifactClick', previewPayload) + }, + 'preview code' + ), + h( + 'span', + { + class: 'reference-node', + 'data-testid': 'reference-node', + onClick: (event: MouseEvent) => emit('click', event), + onMouseover: (event: MouseEvent) => emit('mouseover', event), + onMouseout: (event: MouseEvent) => emit('mouseout', event) + }, + '1' + ) ] ) } }) - const CodeBlockNode = defineComponent({ - name: 'CodeBlockNode', - emits: ['previewCode'], - mounted() { - this.$emit('previewCode', previewPayload) - }, - render() { - return h('div', { 'data-testid': 'code-block-node' }) - } - }) - - const ReferenceNode = defineComponent({ - name: 'ReferenceNode', - render() { - return h('div') - } - }) - - const MermaidBlockNode = defineComponent({ - name: 'MermaidBlockNode', - render() { - return h('div') - } - }) - return { default: NodeRenderer, NodeRenderer, - CodeBlockNode, - ReferenceNode, - MermaidBlockNode, - removeCustomComponents: vi.fn(), - setCustomComponents: ( - customIdOrComponents: string | Recordany>, - maybeComponents?: Record any> - ) => { - customComponents = - typeof customIdOrComponents === 'string' ? (maybeComponents ?? {}) : customIdOrComponents - } + removeCustomComponents: removeCustomComponentsMock, + setCustomComponents: setCustomComponentsMock } }) @@ -255,7 +203,9 @@ const setup = async (props: Record = {}) => { return { wrapper, - getCustomComponents: () => customComponents + getCustomComponents: () => customComponents, + setCustomComponentsMock, + removeCustomComponentsMock } } @@ -270,10 +220,17 @@ describe('MarkdownRenderer', () => { ensureMarkdownWorkersMock.mockReset() ensureMarkdownWorkersMock.mockResolvedValue(undefined) navigateLinkMock.mockReset() - navigateLinkMock.mockResolvedValue(true) + navigateLinkMock.mockImplementation(async (_href: string, event?: MouseEvent | null) => { + event?.preventDefault() + return true + }) nanoidMock.mockReturnValueOnce('fallback-message').mockReturnValueOnce('fallback-thread') }) + afterEach(() => { + vi.useRealTimers() + }) + it('initializes markdown workers lazily when mounted', async () => { await setup() @@ -281,11 +238,13 @@ describe('MarkdownRenderer', () => { }) it('uses the provided message and thread ids for HTML preview artifacts', async () => { - await setup({ + const { wrapper } = await setup({ messageId: 'message-1', threadId: 'thread-1' }) + await wrapper.get('[data-testid="preview-code"]').trigger('click') + expect(showArtifactMock).toHaveBeenCalledWith( { id: 'preview-artifact', @@ -302,7 +261,8 @@ describe('MarkdownRenderer', () => { }) it('falls back to local ids when no message or thread ids are provided', async () => { - await setup() + const { wrapper } = await setup() + await wrapper.get('[data-testid="preview-code"]').trigger('click') expect(showArtifactMock).toHaveBeenCalledWith( { @@ -319,61 +279,70 @@ describe('MarkdownRenderer', () => { ) }) - it('renders static markdown as final docs content by default', async () => { - const { wrapper } = await setup() - const nodeRenderer = wrapper.get('[data-testid="node-renderer"]') + it('normalizes unsupported code fence languages before they reach Markstream', async () => { + const { wrapper } = await setup({ + content: '```desktop-local-file\nconst answer = 42\n```' + }) - expect(nodeRenderer.attributes('data-mode')).toBe('docs') - expect(nodeRenderer.attributes('data-html-policy')).toBe('safe') - expect(nodeRenderer.attributes('data-final')).toBe('true') - expect(nodeRenderer.attributes('data-smooth-streaming')).toBe('false') - expect(nodeRenderer.attributes('data-typewriter')).toBe('false') - expect(nodeRenderer.attributes('data-batch-rendering')).toBe('true') - expect(nodeRenderer.attributes('data-defer-nodes-until-visible')).toBe('true') - expect(nodeRenderer.attributes('data-viewport-priority')).toBe('true') - expect(nodeRenderer.attributes('data-node-virtual')).toBe('auto') - expect(nodeRenderer.attributes('data-max-live-nodes')).toBe('260') - expect(nodeRenderer.attributes('data-live-node-buffer')).toBe('80') - expect(nodeRenderer.attributes('data-code-block-stream')).toBe('false') - expect(nodeRenderer.attributes('data-initial-render-batch-size')).toBe('96') - expect(nodeRenderer.attributes('data-render-batch-size')).toBe('80') - expect(nodeRenderer.attributes('data-render-batch-delay')).toBe('0') - expect(nodeRenderer.attributes('data-render-batch-budget-ms')).toBe('8') - expect(nodeRenderer.attributes('data-render-batch-idle-timeout-ms')).toBe('16') - expect(nodeRenderer.attributes('data-parse-coalesce-ms')).toBe('0') + expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-content')).toBe( + '```plaintext\nconst answer = 42\n```' + ) }) - it('marks the root for scoped code block scrollbar stabilization', async () => { + it('normalizes unsupported code fence languages during streaming updates', async () => { + const { wrapper } = await setup({ content: '', streaming: true, final: false }) + + await wrapper.setProps({ + content: '~~~DESKTOP-LOCAL-FILE path=src/example.ts\nconst answer = 42\n~~~' + }) + + expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-content')).toBe( + '~~~plaintext path=src/example.ts\nconst answer = 42\n~~~' + ) + }) + + it('leaves generic code blocks on Markstream’s built-in Monaco path', async () => { + const { getCustomComponents } = await setup({ mode: 'chat' }) + + expect(getCustomComponents().code_block).toBeUndefined() + }) + + it('uses the built-in strict Mermaid renderer without a global custom registry', async () => { + const { wrapper, getCustomComponents, setCustomComponentsMock, removeCustomComponentsMock } = + await setup() + + expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-mermaid-strict')).toBe( + 'true' + ) + expect(getCustomComponents()).toEqual({}) + expect(setCustomComponentsMock).not.toHaveBeenCalled() + + wrapper.unmount() + expect(removeCustomComponentsMock).not.toHaveBeenCalled() + }) + + it('keeps each NodeRenderer measurement identity instance-local', async () => { + const { wrapper } = await setup({ messageId: 'message-1', threadId: 'thread-1' }) + + expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-custom-id')).toContain( + 'artifact-msg-fallback-message' + ) + }) + + it('keeps prose wrapping from splitting code surfaces at every character', async () => { const { wrapper } = await setup() expect(wrapper.classes()).toContain('markdown-renderer-root') + expect(wrapper.classes()).toContain('break-words') + expect(wrapper.classes()).not.toContain('break-all') }) - it('passes the requested chat mode and streaming options to NodeRenderer for live content', async () => { - const { wrapper } = await setup({ - mode: 'chat', - streaming: true, - final: false, - smoothStreaming: true - }) + it('passes final and code block streaming flags for live content', async () => { + const { wrapper } = await setup({ streaming: true, final: false }) const nodeRenderer = wrapper.get('[data-testid="node-renderer"]') - expect(nodeRenderer.attributes('data-mode')).toBe('chat') expect(nodeRenderer.attributes('data-final')).toBe('false') - expect(nodeRenderer.attributes('data-smooth-streaming')).toBe('auto') - expect(nodeRenderer.attributes('data-typewriter')).toBe('true') - expect(nodeRenderer.attributes('data-node-virtual')).toBe('false') - expect(nodeRenderer.attributes('data-max-live-nodes')).toBe('0') - expect(nodeRenderer.attributes('data-live-node-buffer')).toBe('0') expect(nodeRenderer.attributes('data-code-block-stream')).toBe('true') - expect(nodeRenderer.attributes('data-defer-nodes-until-visible')).toBe('false') - expect(nodeRenderer.attributes('data-viewport-priority')).toBe('false') - expect(nodeRenderer.attributes('data-initial-render-batch-size')).toBe('10') - expect(nodeRenderer.attributes('data-render-batch-size')).toBe('14') - expect(nodeRenderer.attributes('data-render-batch-delay')).toBe('8') - expect(nodeRenderer.attributes('data-render-batch-budget-ms')).toBe('3') - expect(nodeRenderer.attributes('data-render-batch-idle-timeout-ms')).toBe('24') - expect(nodeRenderer.attributes('data-parse-coalesce-ms')).toBe('12') }) it('renders the first non-empty streaming update immediately', async () => { @@ -393,39 +362,37 @@ describe('MarkdownRenderer', () => { ) }) - it('disables smooth streaming when requested for live content', async () => { - const { wrapper } = await setup({ - streaming: true, - final: false, - smoothStreaming: false - }) + it('keeps coalescing updates for non-streaming surfaces', async () => { + vi.useFakeTimers() + const { wrapper } = await setup({ content: 'initial static content' }) + const nodeRenderer = () => wrapper.get('[data-testid="node-renderer"]') - expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-smooth-streaming')).toBe( - 'false' - ) - expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-final')).toBe('false') + await wrapper.setProps({ content: 'updated static content' }) + expect(nodeRenderer().attributes('data-content')).toBe('initial static content') + + vi.advanceTimersByTime(64) + await wrapper.vm.$nextTick() + expect(nodeRenderer().attributes('data-content')).toBe('updated static content') }) - it('marks completed chat markdown as final', async () => { - const { wrapper } = await setup({ - smoothStreaming: false - }) + it('retries reference interactions after a search-result request fails', async () => { + getSearchResultsMock + .mockRejectedValueOnce(new Error('transient search failure')) + .mockResolvedValueOnce([{ url: 'https://example.com/reference' }]) + const { wrapper } = await setup({ messageId: 'message-1' }) + const referenceElement = wrapper.get('[data-testid="reference-node"]').element - const nodeRenderer = wrapper.get('[data-testid="node-renderer"]') - expect(nodeRenderer.attributes('data-final')).toBe('true') - }) + referenceElement.dispatchEvent(new MouseEvent('click')) + await flushPromises() - it('allows callers to disable completed-node virtualization and deferral', async () => { - const { wrapper } = await setup({ - virtualizeNodes: false - }) - const nodeRenderer = wrapper.get('[data-testid="node-renderer"]') + expect(navigateLinkMock).not.toHaveBeenCalled() + expect(showReferenceMock).not.toHaveBeenCalled() + + referenceElement.dispatchEvent(new MouseEvent('mouseover')) + await flushPromises() - expect(nodeRenderer.attributes('data-node-virtual')).toBe('false') - expect(nodeRenderer.attributes('data-defer-nodes-until-visible')).toBe('false') - expect(nodeRenderer.attributes('data-viewport-priority')).toBe('false') - expect(nodeRenderer.attributes('data-max-live-nodes')).toBe('0') - expect(nodeRenderer.attributes('data-live-node-buffer')).toBe('0') + expect(getSearchResultsMock).toHaveBeenCalledTimes(2) + expect(showReferenceMock).toHaveBeenCalledOnce() }) it('routes reference clicks through the shared markdown link navigator', async () => { @@ -435,7 +402,7 @@ describe('MarkdownRenderer', () => { } ]) - const { getCustomComponents } = await setup({ + const { wrapper } = await setup({ messageId: 'message-1', threadId: 'thread-1', linkContext: { @@ -444,17 +411,77 @@ describe('MarkdownRenderer', () => { } satisfies MarkdownLinkContext }) - const referenceVNode = getCustomComponents().reference?.({ - node: { - id: '1' - } - }) const clickEvent = new MouseEvent('click', { altKey: true }) - await referenceVNode.props.onClick(clickEvent) + wrapper.get('[data-testid="reference-node"]').element.dispatchEvent(clickEvent) await flushPromises() expect(getSearchResultsMock).toHaveBeenCalledWith('message-1') expect(navigateLinkMock).toHaveBeenCalledWith('https://example.com/reference', clickEvent) }) + + it('ignores reference results that resolve after the renderer unmounts', async () => { + const searchResults = createDeferred >() + getSearchResultsMock.mockReturnValueOnce(searchResults.promise) + const { wrapper } = await setup({ messageId: 'message-1' }) + + wrapper.get('[data-testid="reference-node"]').element.dispatchEvent(new MouseEvent('click')) + wrapper.unmount() + searchResults.resolve([{ url: 'https://example.com/stale' }]) + await flushPromises() + + expect(navigateLinkMock).not.toHaveBeenCalled() + }) + + it('routes built-in link clicks through the shared markdown link navigator', async () => { + const { wrapper } = await setup() + const clickEvent = new MouseEvent('click', { cancelable: true }) + + wrapper.get('[data-testid="rendered-link"]').element.dispatchEvent(clickEvent) + await flushPromises() + + expect(navigateLinkMock).toHaveBeenCalledWith('https://example.com/link', clickEvent) + }) + + it('does not take over anchors without Markstream’s link marker', async () => { + const { wrapper } = await setup() + const clickEvent = new MouseEvent('click') + Object.defineProperty(clickEvent, 'target', { + value: wrapper.get('[data-testid="unmarked-anchor"]').element + }) + + wrapper.findComponent({ name: 'NodeRenderer' }).vm.$emit('click', clickEvent) + await flushPromises() + + expect(navigateLinkMock).not.toHaveBeenCalled() + }) + + it('supports keyboard activation for built-in reference nodes', async () => { + getSearchResultsMock.mockResolvedValueOnce([{ url: 'https://example.com/reference' }]) + const { wrapper } = await setup({ messageId: 'message-1' }) + const referenceElement = wrapper.get('[data-testid="reference-node"]').element + + referenceElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await flushPromises() + + expect(navigateLinkMock).toHaveBeenCalledWith( + 'https://example.com/reference', + expect.any(MouseEvent) + ) + }) + + it('anchors reference previews to the delegated reference element', async () => { + getSearchResultsMock.mockResolvedValueOnce([{ url: 'https://example.com/reference' }]) + const { wrapper } = await setup({ messageId: 'message-1' }) + const referenceElement = wrapper.get('[data-testid="reference-node"]').element as HTMLElement + const rect = referenceElement.getBoundingClientRect() + + referenceElement.dispatchEvent(new MouseEvent('mouseover')) + await flushPromises() + + expect(showReferenceMock).toHaveBeenCalledWith({ url: 'https://example.com/reference' }, rect) + + referenceElement.dispatchEvent(new MouseEvent('mouseout')) + expect(hideReferenceMock).toHaveBeenCalled() + }) }) diff --git a/test/renderer/components/MarkstreamDomContract.test.ts b/test/renderer/components/MarkstreamDomContract.test.ts new file mode 100644 index 0000000000..c3acba32ef --- /dev/null +++ b/test/renderer/components/MarkstreamDomContract.test.ts @@ -0,0 +1,145 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import NodeRenderer from 'markstream-vue' + +const originalCssStyleSheet = globalThis.CSSStyleSheet +const originalResizeObserver = globalThis.ResizeObserver + +beforeAll(() => { + class TestCssStyleSheet { + replaceSync() {} + } + + class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } + + Object.defineProperty(globalThis, 'CSSStyleSheet', { + configurable: true, + value: TestCssStyleSheet + }) + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: TestResizeObserver + }) +}) + +afterAll(() => { + Object.defineProperty(globalThis, 'CSSStyleSheet', { + configurable: true, + value: originalCssStyleSheet + }) + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: originalResizeObserver + }) +}) + +async function settleRenderer() { + await nextTick() + await flushPromises() + await nextTick() + await flushPromises() +} + +async function mountMarkstream(content: string) { + const wrapper = mount(NodeRenderer, { + props: { + content, + final: true, + smoothStreaming: false, + batchRendering: false, + deferNodesUntilVisible: false, + viewportPriority: false, + nodeVirtual: false, + codeRenderer: 'pre' + } + }) + await settleRenderer() + return wrapper +} + +describe('Markstream DOM contracts used by MarkdownRenderer delegation', () => { + it.each([ + ['unlabeled', '```\nconst answer = 42', '```\nconst answer = 42\n```'], + ['TypeScript', '```ts\nconst answer = 42', '```ts\nconst answer = 42\n```'] + ])( + 'keeps the %s fence readable through Markstream’s streaming-to-final handoff', + async (_label, liveContent, finalContent) => { + const wrapper = mount(NodeRenderer, { + props: { + content: liveContent, + final: false, + smoothStreaming: false, + batchRendering: false, + deferNodesUntilVisible: false, + viewportPriority: false, + nodeVirtual: false, + codeRenderer: 'monaco', + codeBlockStream: true + } + }) + + await settleRenderer() + + expect(wrapper.get('pre.code-pre-fallback').text()).toContain('const answer = 42') + + await wrapper.setProps({ + content: finalContent, + final: true, + codeBlockStream: false + }) + await settleRenderer() + + const codeBlock = wrapper.get('[data-markstream-code-block="1"]') + expect(codeBlock.attributes('data-markstream-code-block-state')).toBe('settled') + expect(codeBlock.text()).toContain('const answer = 42') + } + ) + + it('updates the fallback text during streaming without replacing the renderer', async () => { + const wrapper = mount(NodeRenderer, { + props: { + content: '```ts\nconst answer = 42', + final: false, + smoothStreaming: false, + batchRendering: false, + deferNodesUntilVisible: false, + viewportPriority: false, + nodeVirtual: false, + codeRenderer: 'monaco', + codeBlockStream: true + } + }) + + await settleRenderer() + const codeBlock = wrapper.get('[data-markstream-code-block="1"]').element + + await wrapper.setProps({ content: '```ts\nconst answer = 43' }) + await settleRenderer() + + expect(wrapper.get('[data-markstream-code-block="1"]').element).toBe(codeBlock) + expect(wrapper.get('pre.code-pre-fallback').text()).toContain('const answer = 43') + }) + + it('marks both Markdown and normalized safe HTML links for delegation', async () => { + const wrapper = await mountMarkstream( + '[DeepChat](https://deepchat.thinkinai.xyz)\n\nRaw' + ) + + expect(wrapper.get('a.link-node').attributes('href')).toBe('https://deepchat.thinkinai.xyz') + expect(wrapper.get('a[href="https://example.com/raw"]').classes()).toContain('link-node') + }) + + it('renders numeric citations with the reference-node delegation marker', async () => { + const wrapper = await mountMarkstream('Cite research [1] for details.') + const reference = wrapper.get('span.reference-node') + + expect(reference.text()).toBe('1') + await reference.trigger('click') + expect(wrapper.emitted('click')).toHaveLength(1) + }) +}) diff --git a/test/renderer/components/MessageList.test.ts b/test/renderer/components/MessageList.test.ts index 41720dad3d..8d1c1fd3c3 100644 --- a/test/renderer/components/MessageList.test.ts +++ b/test/renderer/components/MessageList.test.ts @@ -45,13 +45,21 @@ vi.mock('@/components/message/MessageItemAssistant.vue', () => ({ type: Boolean, default: false }, + isInGeneratingThread: { + type: Boolean, + default: false + }, + isStreamingMessage: { + type: Boolean, + default: false + }, disableMarkdownVirtualization: { type: Boolean, default: false } }, template: - ' {{ message.id }}' + '{{ message.id }}' }) })) @@ -68,7 +76,8 @@ vi.mock('@/components/message/MessageBlockAction.vue', () => ({ }) })) -const { isCapturingRef } = vi.hoisted(() => ({ +const { captureMessageMock, isCapturingRef } = vi.hoisted(() => ({ + captureMessageMock: vi.fn().mockResolvedValue(undefined), isCapturingRef: { current: undefined as undefined | import('vue').Ref} })) @@ -79,12 +88,13 @@ vi.mock('@/composables/message/useMessageCapture', async () => { return { useMessageCapture: () => ({ isCapturing, - captureMessage: vi.fn().mockResolvedValue(undefined) + captureMessage: captureMessageMock }) } }) import MessageList from '@/components/chat/MessageList.vue' +import MessageListRow from '@/components/chat/MessageListRow.vue' function createMessage(id: string, role: 'user' | 'assistant', orderSeq: number): DisplayMessage { return { @@ -142,6 +152,7 @@ function createCompactionMessage( describe('MessageList', () => { beforeEach(() => { + captureMessageMock.mockClear() if (isCapturingRef.current) { isCapturingRef.current.value = false } @@ -206,6 +217,39 @@ describe('MessageList', () => { expect(compactedWrapper.find('.compaction-divider__label--compacting').exists()).toBe(false) }) + it('marks only the streaming assistant message as streaming', () => { + const wrapper = mount(MessageList, { + props: { + messages: [ + createMessage('u1', 'user', 1), + createMessage('a1', 'assistant', 2), + createMessage('a2', 'assistant', 3) + ], + isGenerating: true, + streamingMessageId: 'a2' + } + }) + + const assistants = wrapper.findAll('.assistant-item') + expect(assistants[0].attributes('data-streaming')).toBe('false') + expect(assistants[1].attributes('data-streaming')).toBe('true') + // Thread-level generating still reaches every row for action gating. + expect(assistants[0].attributes('data-generating')).toBe('true') + expect(assistants[1].attributes('data-generating')).toBe('true') + }) + + it('does not mark assistant messages as streaming without a matching stream id', () => { + const wrapper = mount(MessageList, { + props: { + messages: [createMessage('a1', 'assistant', 1)], + streamingMessageId: null + } + }) + + expect(wrapper.find('.assistant-item').attributes('data-streaming')).toBe('false') + expect(wrapper.find('.assistant-item').attributes('data-generating')).toBe('false') + }) + it('passes read-only state down to message items', () => { const wrapper = mount(MessageList, { props: { @@ -238,6 +282,30 @@ describe('MessageList', () => { expect(wrapper.findAll('.assistant-item')).toHaveLength(0) }) + it('resolves screenshot parents lazily through the supplied resolver', async () => { + const resolveCaptureParentId = vi.fn(() => 'u1') + const wrapper = mount(MessageList, { + props: { + messages: [createMessage('a1', 'assistant', 2)], + resolveCaptureParentId + } + }) + + wrapper.findComponent(MessageListRow).vm.$emit('copyImage', 'a1', undefined, true, { + model_name: 'model-1', + model_provider: 'provider-1' + }) + await wrapper.vm.$nextTick() + + expect(resolveCaptureParentId).toHaveBeenCalledWith('a1', undefined) + expect(captureMessageMock).toHaveBeenCalledWith({ + messageId: 'a1', + parentId: 'u1', + fromTop: true, + modelInfo: { model_name: 'model-1', model_provider: 'provider-1' } + }) + }) + it('passes markdown virtualization disable state to assistant rows', () => { const wrapper = mount(MessageList, { props: { diff --git a/test/renderer/components/SettingsApp.test.ts b/test/renderer/components/SettingsApp.test.ts index 17b0052b7a..682f1342d6 100644 --- a/test/renderer/components/SettingsApp.test.ts +++ b/test/renderer/components/SettingsApp.test.ts @@ -1,34 +1,25 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' -import { defineComponent, reactive, ref } from 'vue' +import { defineComponent, nextTick, reactive, ref } from 'vue' import { SETTINGS_EVENTS } from '@/events' -const windowClientMock = vi.hoisted(() => ({ - closeSettings: vi.fn().mockResolvedValue(true), - focusMainWindow: vi.fn().mockResolvedValue(true), - notifySettingsReady: vi.fn().mockImplementation(async () => { - window.electron?.ipcRenderer?.send('settings:ready') - return true - }), - consumePendingSettingsProviderInstall: vi.fn().mockResolvedValue(null), - requeuePendingSettingsProviderInstall: vi.fn().mockResolvedValue(true), - startGuidedOnboarding: vi.fn().mockResolvedValue({ started: true, focused: true }), - onSettingsNavigate: vi.fn().mockImplementation((listener: (payload: unknown) => void) => { +const windowClientListenerImpls = vi.hoisted(() => ({ + onSettingsNavigate: (listener: (payload: unknown) => void) => { const wrapped = (_event: unknown, payload?: unknown) => listener(payload) window.electron?.ipcRenderer?.on('settings:navigate', wrapped) return () => window.electron?.ipcRenderer?.removeListener('settings:navigate', wrapped) - }), - onSettingsProviderInstall: vi.fn().mockImplementation((listener: () => void) => { + }, + onSettingsProviderInstall: (listener: () => void) => { const wrapped = () => listener() window.electron?.ipcRenderer?.on('settings:provider-install', wrapped) return () => window.electron?.ipcRenderer?.removeListener('settings:provider-install', wrapped) - }), - onNotificationError: vi.fn().mockImplementation((listener: (payload: unknown) => void) => { + }, + onNotificationError: (listener: (payload: unknown) => void) => { const wrapped = (_event: unknown, payload?: unknown) => listener(payload) window.electron?.ipcRenderer?.on('notification:show-error', wrapped) return () => window.electron?.ipcRenderer?.removeListener('notification:show-error', wrapped) - }), - onDatabaseRepairSuggested: vi.fn().mockImplementation((listener: (payload: unknown) => void) => { + }, + onDatabaseRepairSuggested: (listener: (payload: unknown) => void) => { const wrapped = (_event: unknown, payload?: unknown) => listener(payload) window.electron?.ipcRenderer?.on('notification:database-repair-suggested', wrapped) return () => @@ -36,7 +27,27 @@ const windowClientMock = vi.hoisted(() => ({ 'notification:database-repair-suggested', wrapped ) - }) + } +})) + +const windowClientMock = vi.hoisted(() => ({ + closeSettings: vi.fn().mockResolvedValue(true), + focusMainWindow: vi.fn().mockResolvedValue(true), + notifySettingsReady: vi.fn().mockImplementation(async () => { + window.electron?.ipcRenderer?.send('settings:ready') + return true + }), + consumePendingSettingsProviderInstall: vi.fn().mockResolvedValue(null), + requeuePendingSettingsProviderInstall: vi.fn().mockResolvedValue(true), + startGuidedOnboarding: vi.fn().mockResolvedValue({ started: true, focused: true }), + onSettingsNavigate: vi.fn().mockImplementation(windowClientListenerImpls.onSettingsNavigate), + onSettingsProviderInstall: vi + .fn() + .mockImplementation(windowClientListenerImpls.onSettingsProviderInstall), + onNotificationError: vi.fn().mockImplementation(windowClientListenerImpls.onNotificationError), + onDatabaseRepairSuggested: vi + .fn() + .mockImplementation(windowClientListenerImpls.onDatabaseRepairSuggested) })) const appRuntimeClientMock = vi.hoisted(() => ({ @@ -48,6 +59,10 @@ const appRuntimeClientMock = vi.hoisted(() => ({ }) })) +const configClientMock = vi.hoisted(() => ({ + getLanguage: vi.fn().mockResolvedValue('zh-CN') +})) + vi.mock('@api/DeviceClient', () => ({ createDeviceClient: () => ({ getDeviceInfo: vi.fn().mockResolvedValue({ platform: 'darwin' }) @@ -55,9 +70,7 @@ vi.mock('@api/DeviceClient', () => ({ })) vi.mock('@api/ConfigClient', () => ({ - createConfigClient: () => ({ - getLanguage: vi.fn().mockResolvedValue('zh-CN') - }) + createConfigClient: () => configClientMock })) vi.mock('@api/WindowClient', () => ({ @@ -68,6 +81,24 @@ vi.mock('@api/AppRuntimeClient', () => ({ createAppRuntimeClient: () => appRuntimeClientMock })) +// The global setup's afterEach runs vi.restoreAllMocks() after this file's +// hooks, stripping vi.fn() implementations. Rebuild the listener mocks before +// each test or unmount-time cleanups come back as undefined. +beforeEach(() => { + windowClientMock.onSettingsNavigate + .mockReset() + .mockImplementation(windowClientListenerImpls.onSettingsNavigate) + windowClientMock.onSettingsProviderInstall + .mockReset() + .mockImplementation(windowClientListenerImpls.onSettingsProviderInstall) + windowClientMock.onNotificationError + .mockReset() + .mockImplementation(windowClientListenerImpls.onNotificationError) + windowClientMock.onDatabaseRepairSuggested + .mockReset() + .mockImplementation(windowClientListenerImpls.onDatabaseRepairSuggested) +}) + afterEach(() => { vi.restoreAllMocks() windowClientMock.closeSettings.mockReset().mockResolvedValue(true) @@ -79,13 +110,12 @@ afterEach(() => { started: true, focused: true }) - windowClientMock.onSettingsNavigate.mockClear() - windowClientMock.onSettingsProviderInstall.mockClear() - windowClientMock.onNotificationError.mockClear() - windowClientMock.onDatabaseRepairSuggested.mockClear() appRuntimeClientMock.mcpInstallListener = undefined appRuntimeClientMock.cleanupMcpInstall.mockClear() appRuntimeClientMock.onMcpInstallRequested.mockClear() + configClientMock.getLanguage.mockReset().mockResolvedValue('zh-CN') + document.documentElement.lang = '' + document.documentElement.dir = '' }) describe('Settings App', () => { @@ -1338,4 +1368,127 @@ describe('Settings App', () => { expect(push).toHaveBeenCalledWith({ name: 'settings-mcp' }) expect(mcpStore.setMcpInstallCache).toHaveBeenCalledWith(serializedConfig) }) + + it('projects locale and direction updates when the requested language is unchanged', async () => { + vi.resetModules() + + const languageStore = reactive({ + language: 'system', + dir: 'auto' as 'auto' | 'rtl', + initLanguage: vi.fn().mockResolvedValue(undefined) + }) + const locale = ref('zh-CN') + + vi.doMock('vue-router', () => { + const currentRoute = ref({ name: 'settings-common', query: {}, params: {}, path: '/common' }) + const router = { + hasRoute: vi.fn(() => true), + isReady: vi.fn().mockResolvedValue(undefined), + push: vi.fn().mockResolvedValue(undefined), + replace: vi.fn().mockResolvedValue(undefined), + getRoutes: vi.fn(() => []), + currentRoute + } + + return { + useRouter: () => router, + useRoute: () => currentRoute.value, + RouterView: { name: 'RouterView', template: '' } + } + }) + vi.doMock('../../../src/renderer/src/stores/uiSettingsStore', () => ({ + useUiSettingsStore: () => ({ fontSizeClass: 'text-base', loadSettings: vi.fn() }) + })) + vi.doMock('../../../src/renderer/src/stores/language', () => ({ + useLanguageStore: () => languageStore + })) + vi.doMock('../../../src/renderer/src/stores/modelCheck', () => ({ + useModelCheckStore: () => ({ + isDialogOpen: false, + currentProviderId: null, + closeDialog: vi.fn() + }) + })) + vi.doMock('../../../src/renderer/src/stores/theme', () => ({ + useThemeStore: () => ({ themeMode: 'light', isDark: false }) + })) + vi.doMock('../../../src/renderer/src/stores/providerStore', () => ({ + useProviderStore: () => ({ providers: [], initialized: ref(false), initialize: vi.fn() }) + })) + vi.doMock('../../../src/renderer/src/stores/providerDeeplinkImport', () => ({ + useProviderDeeplinkImportStore: () => ({ + preview: null, + previewToken: 0, + openPreview: vi.fn(), + clearPreview: vi.fn() + }) + })) + vi.doMock('../../../src/renderer/src/stores/modelStore', () => ({ + useModelStore: () => ({ initialize: vi.fn() }) + })) + vi.doMock('../../../src/renderer/src/stores/ollamaStore', () => ({ + useOllamaStore: () => ({ initialize: vi.fn() }) + })) + vi.doMock('../../../src/renderer/src/stores/mcp', () => ({ + useMcpStore: () => ({ + mcpEnabled: false, + setMcpEnabled: vi.fn(), + setMcpInstallCache: vi.fn() + }) + })) + vi.doMock('../../../src/renderer/src/lib/storeInitializer', () => ({ + useMcpInstallDeeplinkHandler: () => ({ setup: vi.fn(), cleanup: vi.fn() }) + })) + vi.doMock('../../../src/renderer/src/composables/useFontManager', () => ({ + useFontManager: () => ({ setupFontListener: vi.fn() }) + })) + vi.doMock('../../../src/renderer/src/composables/useDeviceVersion', () => ({ + useDeviceVersion: () => ({ isMacOS: ref(false), isWinMacOS: true }) + })) + vi.doMock('@vueuse/core', () => ({ + useTitle: () => ref(''), + useEventListener: vi.fn() + })) + vi.doMock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key, locale }) + })) + vi.doMock('@iconify/vue', () => ({ Icon: { name: 'Icon', template: '' } })) + vi.doMock('@/components/use-toast', () => ({ + useToast: () => ({ toast: vi.fn(() => ({ dismiss: vi.fn() })) }) + })) + + const SettingsApp = (await import('../../../src/renderer/settings/App.vue')).default + const wrapper = mount(SettingsApp, { + global: { + stubs: { + Button: true, + RouterView: true, + CloseIcon: true, + ModelCheckDialog: true, + ProviderDeeplinkImportDialog: true, + Toaster: true, + Icon: true + } + } + }) + + expect(configClientMock.getLanguage).not.toHaveBeenCalled() + expect(languageStore.initLanguage).toHaveBeenCalledTimes(1) + + // The requested preference remains "system", but its resolved locale and + // direction can change when the system locale changes. + locale.value = 'fa-IR' + languageStore.dir = 'rtl' + await nextTick() + await flushPromises() + + expect(document.documentElement).toHaveProperty('lang', 'fa-IR') + expect(document.documentElement).toHaveProperty('dir', 'rtl') + expect(locale.value).toBe('fa-IR') + expect(configClientMock.getLanguage).not.toHaveBeenCalled() + + wrapper.unmount() + document.documentElement.lang = '' + document.documentElement.dir = '' + }) }) diff --git a/test/renderer/components/WindowSideBar.test.ts b/test/renderer/components/WindowSideBar.test.ts index 3168cbbdf6..19b4580ce2 100644 --- a/test/renderer/components/WindowSideBar.test.ts +++ b/test/renderer/components/WindowSideBar.test.ts @@ -16,6 +16,7 @@ type SetupOptions = { hasMore?: boolean loading?: boolean loadingMore?: boolean + error?: string | null nextPages?: Array<{ items: Array<{ id: string }>; hasMore: boolean }> pinnedSessions?: Array<{ id: string; title: string; status: string; isPinned?: boolean }> groups?: Array<{ @@ -172,6 +173,7 @@ const setup = async (options: SetupOptions = {}) => { hasMore: options.hasMore ?? false, loading: options.loading ?? false, loadingMore: options.loadingMore ?? false, + error: options.error ?? null, loadNextPage: vi.fn(async () => { const nextPage = (options.nextPages ?? []).shift() if (!nextPage) { @@ -1694,6 +1696,132 @@ describe('WindowSideBar agent switch', () => { TEST_TIMEOUT_MS ) + it( + 'sorts time workspace group sessions and chats by recency', + async () => { + const { wrapper } = await setup({ + groupMode: 'time', + groups: [ + { + id: 'common.time.today', + label: 'common.time.today', + labelKey: 'common.time.today', + sessions: [ + { + id: 'workspace-alpha', + title: 'Alpha workspace', + status: 'none', + projectDir: '/work/alpha', + updatedAt: 100 + }, + { + id: 'chat-older', + title: 'Older chat', + status: 'none', + projectDir: '', + updatedAt: 200 + }, + { + id: 'workspace-zulu', + title: 'Zulu workspace', + status: 'none', + projectDir: '/work/alpha', + updatedAt: 300 + } + ] + }, + { + id: 'common.time.yesterday', + label: 'common.time.yesterday', + labelKey: 'common.time.yesterday', + sessions: [ + { + id: 'chat-newer', + title: 'Newer chat', + status: 'none', + projectDir: '', + updatedAt: 400 + } + ] + } + ] + }) + + await wrapper.vm.$nextTick() + + expect( + wrapper + .findAll('[data-testid="sidebar-session-item"]') + .map((item) => item.attributes('data-session-id')) + ).toEqual(['chat-newer', 'chat-older', 'workspace-zulu', 'workspace-alpha']) + }, + TEST_TIMEOUT_MS + ) + + it( + 'keeps project sessions sorted by recency and project groups in environment order', + async () => { + const { wrapper } = await setup({ + groupMode: 'project', + projectEnvironments: [{ path: '/work/alpha' }, { path: '/work/beta' }], + groups: [ + { + id: '/work/beta', + label: 'beta', + sessions: [ + { + id: 'beta-older', + title: 'Beta older', + status: 'none', + projectDir: '/work/beta', + updatedAt: 100 + }, + { + id: 'beta-newer', + title: 'Beta newer', + status: 'none', + projectDir: '/work/beta', + updatedAt: 300 + } + ] + }, + { + id: '/work/alpha', + label: 'alpha', + sessions: [ + { + id: 'alpha-older', + title: 'Alpha older', + status: 'none', + projectDir: '/work/alpha', + updatedAt: 200 + }, + { + id: 'alpha-newer', + title: 'Alpha newer', + status: 'none', + projectDir: '/work/alpha', + updatedAt: 400 + } + ] + } + ] + }) + + await wrapper.vm.$nextTick() + + expect( + wrapper.findAll('button[data-group-id]').map((button) => button.attributes('data-group-id')) + ).toEqual(['/work/alpha', '/work/beta']) + expect( + wrapper + .findAll('[data-testid="sidebar-session-item"]') + .map((item) => item.attributes('data-session-id')) + ).toEqual(['alpha-newer', 'alpha-older', 'beta-newer', 'beta-older']) + }, + TEST_TIMEOUT_MS + ) + it('does not render the chats group when it has no sessions', async () => { const { wrapper } = await setup({ groupMode: 'project', @@ -1973,7 +2101,7 @@ describe('WindowSideBar viewport auto-fill', () => { ) it( - 'rechecks pagination after a group collapse makes the visible list too short', + 'does not auto-load after a group collapse makes the visible list too short', async () => { const { wrapper, sessionStore } = await setup({ hasMore: true, @@ -1999,15 +2127,211 @@ describe('WindowSideBar viewport auto-fill', () => { setSidebarListSize(wrapper, { scrollHeight: 80, clientHeight: 120 }) await flushSidebarFillFrame() + expect(sessionStore.loadNextPage).not.toHaveBeenCalled() + expect(sessionStore.sessions.map((session) => session.id)).toEqual(['session-1', 'session-2']) + + wrapper.unmount() + }, + TEST_TIMEOUT_MS + ) + + it( + 'does not auto-load after the pinned section is collapsed', + async () => { + const { wrapper, sessionStore } = await setup({ + hasMore: true, + sessions: [{ id: 'session-1' }], + pinnedSessions: [{ id: 'session-1', title: 'Pinned', status: 'none', isPinned: true }], + nextPages: [{ items: [{ id: 'session-2' }], hasMore: false }] + }) + setSidebarListSize(wrapper, { scrollHeight: 240, clientHeight: 120 }) + await flushSidebarFillFrame() + expect(sessionStore.loadNextPage).not.toHaveBeenCalled() + + await wrapper.get('[data-group-id="__pinned__"]').trigger('click') + setSidebarListSize(wrapper, { scrollHeight: 80, clientHeight: 120 }) + await flushSidebarFillFrame() + + expect(sessionStore.loadNextPage).not.toHaveBeenCalled() + + wrapper.unmount() + }, + TEST_TIMEOUT_MS + ) + + it( + 'cancels a mount-time auto-fill frame when a search starts before it runs', + async () => { + const { wrapper, sessionStore } = await setup({ + hasMore: true, + sessions: [{ id: 'session-1' }], + groups: [ + { + id: 'common.time.today', + label: 'common.time.today', + labelKey: 'common.time.today', + sessions: [{ id: 'session-1', title: 'Alpha', status: 'none' }] + } + ], + nextPages: [{ items: [{ id: 'session-2' }], hasMore: false }] + }) + setSidebarListSize(wrapper, { scrollHeight: 80, clientHeight: 120 }) + + await wrapper.get('[data-testid="sidebar-session-search-input"]').setValue('alpha') + await flushSidebarFillFrame() + + expect(sessionStore.loadNextPage).not.toHaveBeenCalled() + expect(wrapper.get('[data-testid="sidebar-session-search-loaded-range"]').exists()).toBe(true) + + wrapper.unmount() + }, + TEST_TIMEOUT_MS + ) + + it( + 'stops an in-flight auto-fill loop when a search starts', + async () => { + const { wrapper, sessionStore } = await setup({ + hasMore: true, + sessions: [{ id: 'session-1' }], + groups: [ + { + id: 'common.time.today', + label: 'common.time.today', + labelKey: 'common.time.today', + sessions: [{ id: 'session-1', title: 'Alpha', status: 'none' }] + } + ] + }) + setSidebarListSize(wrapper, { scrollHeight: 80, clientHeight: 120 }) + + let resolveFirstPage: (() => void) | undefined + sessionStore.loadNextPage.mockImplementation( + () => + new Promise ((resolve) => { + resolveFirstPage = () => { + sessionStore.sessions = [...sessionStore.sessions, { id: 'session-2' }] + resolve() + } + }) + ) + + await flushSidebarFillFrame() expect(sessionStore.loadNextPage).toHaveBeenCalledTimes(1) - expect(sessionStore.sessions.map((session) => session.id)).toEqual([ - 'session-1', - 'session-2', - 'session-3' - ]) + + await wrapper.get('[data-testid="sidebar-session-search-input"]').setValue('alpha') + resolveFirstPage?.() + await flushPromises() + + expect(sessionStore.loadNextPage).toHaveBeenCalledTimes(1) + expect(wrapper.get('[data-testid="sidebar-session-search-loaded-range"]').exists()).toBe(true) wrapper.unmount() }, TEST_TIMEOUT_MS ) + + it( + 'filters the loaded session range without auto-loading the remaining pages', + async () => { + const { wrapper, sessionStore } = await setup({ + hasMore: true, + sessions: [{ id: 'session-1' }], + groups: [ + { + id: 'common.time.today', + label: 'common.time.today', + labelKey: 'common.time.today', + sessions: [{ id: 'session-1', title: 'Alpha', status: 'none' }] + } + ], + nextPages: [{ items: [{ id: 'session-2' }], hasMore: false }] + }) + setSidebarListSize(wrapper, { scrollHeight: 80, clientHeight: 120 }) + + const input = wrapper.get('[data-testid="sidebar-session-search-input"]') + await input.setValue('alpha') + await flushSidebarFillFrame() + + expect(sessionStore.loadNextPage).not.toHaveBeenCalled() + expect(wrapper.get('[data-testid="sidebar-session-search-loaded-range"]').exists()).toBe(true) + + await input.trigger('keydown', { key: 'Escape' }) + expect((input.element as HTMLInputElement).value).toBe('') + + wrapper.unmount() + }, + TEST_TIMEOUT_MS + ) + + it('does not treat a whitespace-only query as an active search', async () => { + const { wrapper } = await setup({ + hasMore: true, + sessions: [{ id: 'session-1' }], + groups: [ + { + id: 'common.time.today', + label: 'common.time.today', + labelKey: 'common.time.today', + sessions: [{ id: 'session-1', title: 'Alpha', status: 'none' }] + } + ] + }) + + await wrapper.get('[data-testid="sidebar-session-search-input"]').setValue(' ') + + expect(wrapper.find('[data-testid="sidebar-session-search-loaded-range"]').exists()).toBe(false) + + wrapper.unmount() + }) + + it('loads the next page after a searched list reaches the bottom', async () => { + const { wrapper, sessionStore } = await setup({ + hasMore: true, + sessions: [{ id: 'session-1' }], + groups: [ + { + id: 'common.time.today', + label: 'common.time.today', + labelKey: 'common.time.today', + sessions: [{ id: 'session-1', title: 'Alpha', status: 'none' }] + } + ], + nextPages: [{ items: [{ id: 'session-2' }], hasMore: false }] + }) + const list = wrapper.get('.session-list') + setSidebarListSize(wrapper, { scrollHeight: 120, clientHeight: 120 }) + await wrapper.get('[data-testid="sidebar-session-search-input"]').setValue('alpha') + await list.trigger('scroll') + await flushSidebarFillFrame() + + expect(sessionStore.loadNextPage).toHaveBeenCalledTimes(1) + + wrapper.unmount() + }) + + it('keeps loaded results and offers retry after pagination fails', async () => { + const { wrapper, sessionStore } = await setup({ + hasMore: true, + error: 'Failed to load more sessions', + sessions: [{ id: 'session-1' }], + groups: [ + { + id: 'common.time.today', + label: 'common.time.today', + labelKey: 'common.time.today', + sessions: [{ id: 'session-1', title: 'Alpha', status: 'none' }] + } + ] + }) + + expect(wrapper.get('[data-testid="sidebar-session-pagination-error"]').text()).toContain( + 'Failed to load more sessions' + ) + await wrapper.get('[data-testid="sidebar-session-pagination-retry"]').trigger('click') + expect(sessionStore.loadNextPage).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('Alpha') + + wrapper.unmount() + }) }) diff --git a/test/renderer/components/message/MessageBlockContent.test.ts b/test/renderer/components/message/MessageBlockContent.test.ts index 21bee68cbc..8c74e5d5f0 100644 --- a/test/renderer/components/message/MessageBlockContent.test.ts +++ b/test/renderer/components/message/MessageBlockContent.test.ts @@ -231,10 +231,45 @@ describe('MessageBlockContent', () => { expect(markdown.attributes('data-smooth-streaming')).toBe('true') expect(markdown.attributes('data-streaming')).toBe('true') expect(markdown.attributes('data-final')).toBe('false') - expect(markdown.attributes('data-virtualize-nodes')).toBe('false') + // MarkdownRenderer disables its node window while live, but keeps this capability + // enabled so Markstream can still defer heavy offscreen nodes. + expect(markdown.attributes('data-virtualize-nodes')).toBe('true') } ) + it('hands a streaming text part to final state without replacing its renderer node', async () => { + const wrapper = mount(MessageBlockContent, { + props: { + block: createBlock({ + status: 'loading', + content: 'streaming markdown content' + }), + messageId: 'm-stream', + threadId: 's-stream' + } + }) + + await flushPromises() + + const liveNode = wrapper.get('.markdown-stub').element + expect(wrapper.get('.markdown-stub').attributes('data-final')).toBe('false') + + await wrapper.setProps({ + block: createBlock({ + status: 'success', + content: 'final markdown content' + }) + }) + await flushPromises() + + const finalMarkdown = wrapper.get('.markdown-stub') + expect(finalMarkdown.element).toBe(liveNode) + expect(finalMarkdown.attributes('data-streaming')).toBe('false') + expect(finalMarkdown.attributes('data-final')).toBe('true') + expect(finalMarkdown.attributes('data-virtualize-nodes')).toBe('true') + expect(finalMarkdown.text()).toContain('final markdown content') + }) + it('keeps all nodes mounted for searchable result messages', async () => { const wrapper = mount(MessageBlockContent, { props: { diff --git a/test/renderer/components/message/MessageItemAssistant.test.ts b/test/renderer/components/message/MessageItemAssistant.test.ts index be60c7bd51..75c74c63d3 100644 --- a/test/renderer/components/message/MessageItemAssistant.test.ts +++ b/test/renderer/components/message/MessageItemAssistant.test.ts @@ -237,6 +237,19 @@ describe('MessageItemAssistant', () => { } } + it('allows code block hosts to shrink inside the assistant row', () => { + const wrapper = mount(MessageItemAssistant, { + props: { + message: createMessage('pending', []), + isCapturingImage: false + }, + global + }) + + const contentElement = wrapper.get('[data-message-content="true"]').element + expect(contentElement.parentElement?.classList.contains('min-w-0')).toBe(true) + }) + it('does not render a spinner for empty non-pending assistant messages', () => { const wrapper = mount(MessageItemAssistant, { props: { @@ -421,7 +434,7 @@ describe('MessageItemAssistant', () => { expect(wrapper.findComponent({ name: 'MessageBlockToolCall' }).exists()).toBe(true) }) - it('does not group sent activity while the thread is still generating', () => { + it('groups sent activity even while the thread is still generating', () => { const wrapper = mount(MessageItemAssistant, { props: { message: createMessage('sent', [createThinkingBlock(), createToolCallBlock()]), @@ -431,6 +444,22 @@ describe('MessageItemAssistant', () => { global }) + expect(wrapper.find('[data-testid="activity-group"]').exists()).toBe(true) + expect(wrapper.findComponent({ name: 'MessageBlockThink' }).exists()).toBe(false) + expect(wrapper.findComponent({ name: 'MessageBlockToolCall' }).exists()).toBe(false) + }) + + it('does not group activity for the actively streaming row', () => { + const wrapper = mount(MessageItemAssistant, { + props: { + message: createMessage('sent', [createThinkingBlock(), createToolCallBlock()]), + isCapturingImage: false, + isInGeneratingThread: true, + isStreamingMessage: true + }, + global + }) + expect(wrapper.find('[data-testid="activity-group"]').exists()).toBe(false) expect(wrapper.findComponent({ name: 'MessageBlockThink' }).exists()).toBe(true) expect(wrapper.findComponent({ name: 'MessageBlockToolCall' }).exists()).toBe(true) diff --git a/test/renderer/components/message/MessageItemUser.test.ts b/test/renderer/components/message/MessageItemUser.test.ts index 35615d0580..37ec8cad18 100644 --- a/test/renderer/components/message/MessageItemUser.test.ts +++ b/test/renderer/components/message/MessageItemUser.test.ts @@ -5,21 +5,12 @@ import type { DisplayUserMessage, DisplayUserMessageMentionBlock } from '@/features/chat-page/model/displayMessage' +import { getVisibleMentionLabel } from '@/features/chat-page/model/displayUserMessageText' import type { MessageFile } from '@shared/types/agent-interface' import MessageItemUser from '@/components/message/MessageItemUser.vue' const originalApi = window.api -const getVisibleMentionLabel = (block: DisplayUserMessageMentionBlock) => { - if (block.category === 'prompts') { - return block.id || block.content - } - if (block.category === 'context') { - return block.id || block.category - } - return block.content -} - vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => { @@ -329,6 +320,31 @@ describe('MessageItemUser', () => { ) }) + it('uses persisted rich content instead of raw text and inline metadata', async () => { + const wrapper = mount(MessageItemUser, { + props: { + message: createMessage( + {}, + { + text: 'raw text must not render', + activeSkills: ['inline-skill'], + inlineItems: [{ type: 'skill', offset: 0, skillName: 'inline-skill' }], + content: [ + { type: 'text', content: 'visible ' }, + { type: 'mention', category: 'context', id: 'project-a', content: 'raw context' } + ] + } + ) + }, + ...globalMountOptions + }) + + const messageContent = wrapper.get('[data-message-content="true"]') + expect(messageContent.text()).toContain('visible project-a') + expect(messageContent.text()).not.toContain('raw text must not render') + expect(messageContent.text()).not.toContain('inline-skill') + }) + it('keeps attachments visible when long text collapses', async () => { const wrapper = mount(MessageItemUser, { props: { diff --git a/test/renderer/composables/chat/chatScrollState.test.ts b/test/renderer/composables/chat/chatScrollState.test.ts index fb55b42088..31ce105f4a 100644 --- a/test/renderer/composables/chat/chatScrollState.test.ts +++ b/test/renderer/composables/chat/chatScrollState.test.ts @@ -103,6 +103,21 @@ describe('chatScrollState', () => { expect(canAcceptChatScrollRequest(idleReading, 'measurement-anchor')).toBe(true) expect(canAcceptChatScrollRequest(idleReading, 'history-prepend')).toBe(true) }) + + it('rejects a late session restore once the user explicitly navigated', () => { + const navigating = reduceChatScrollState(createChatScrollState(1), { + type: 'explicit-navigation-start' + }) + expect(canAcceptChatScrollRequest(navigating, 'session-restore')).toBe(false) + + const navigated = reduceChatScrollState(navigating, { type: 'explicit-navigation-complete' }) + expect(navigated.mode).toBe('following') + expect(canAcceptChatScrollRequest(navigated, 'session-restore')).toBe(false) + expect(canAcceptChatScrollRequest(navigated, 'auto-follow')).toBe(true) + + const nextSession = reduceChatScrollState(navigated, { type: 'begin-session', sessionEpoch: 2 }) + expect(canAcceptChatScrollRequest(nextSession, 'session-restore')).toBe(true) + }) }) describe('ChatScrollRequestQueue', () => { diff --git a/test/renderer/composables/useMessageVirtualization.test.ts b/test/renderer/composables/useMessageVirtualization.test.ts new file mode 100644 index 0000000000..d74fb8fa20 --- /dev/null +++ b/test/renderer/composables/useMessageVirtualization.test.ts @@ -0,0 +1,119 @@ +import { computed, ref } from 'vue' +import { describe, expect, it } from 'vitest' +import { useMessageWindow } from '@/composables/message/useMessageWindow' +import { useMessageVirtualization } from '@/features/chat-page/composables/useMessageVirtualization' +import type { + DisplayMessageUsage, + MessageListItem +} from '@/features/chat-page/model/displayMessage' + +const usage: DisplayMessageUsage = { + context_usage: 0, + tokens_per_second: 0, + total_tokens: 0, + generation_time: 0, + first_token_time: 0, + reasoning_start_time: 0, + reasoning_end_time: 0, + input_tokens: 0, + output_tokens: 0 +} + +function createUserMessage(id: string, orderSeq: number): MessageListItem { + return { + id, + role: 'user', + timestamp: orderSeq, + updatedAt: orderSeq, + avatar: '', + name: 'You', + model_name: '', + model_id: '', + model_provider: '', + status: 'sent', + error: '', + usage, + conversationId: 'session-1', + is_variant: 0, + orderSeq, + content: { files: [], links: [], think: false, search: false, text: 'hello' } + } +} + +function createStreamingAssistant(content = 'streaming', updatedAt = 200): MessageListItem { + return { + id: 'assistant-streaming', + role: 'assistant', + timestamp: 200, + updatedAt, + avatar: '', + name: 'Assistant', + model_name: 'Assistant', + model_id: 'model-1', + model_provider: 'provider-1', + status: 'pending', + error: '', + usage, + conversationId: 'session-1', + is_variant: 0, + orderSeq: 200, + content: [{ type: 'content', content, status: 'loading', timestamp: updatedAt }] + } +} + +function createVirtualization(messages: ReturnType >) { + const displayMessages = computed(() => messages.value) + const messageWindow = useMessageWindow({ messages: displayMessages }) + const virtualization = useMessageVirtualization({ + viewport: ref(null), + displayMessages, + messageWindow, + windowingThreshold: 160, + initialWindowCount: 90, + overscanPx: 2400, + getWindowOriginTop: () => null, + isListScrolling: ref(false), + isBottomFollowingMode: () => false, + scrollToBottom: () => undefined, + requestAnchorScroll: () => undefined, + currentScrollMode: () => 'idle' + }) + + return { messageWindow, virtualization } +} + +describe('useMessageVirtualization', () => { + it('limits a long history to the initial window before viewport geometry is available', () => { + const messages = ref ( + Array.from({ length: 200 }, (_, index) => createUserMessage(`message-${index}`, index)) + ) + const { virtualization } = createVirtualization(messages) + + const visible = virtualization.visibleDisplayMessages.value + expect(visible).toHaveLength(90) + expect(visible[0]?.id).toBe('message-110') + expect(visible.at(-1)?.id).toBe('message-199') + }) + + it('updates a streaming row in the window without expanding the mounted history', () => { + const history = Array.from({ length: 200 }, (_, index) => + createUserMessage(`message-${index}`, index) + ) + const messages = ref ([...history, createStreamingAssistant()]) + const { messageWindow, virtualization } = createVirtualization(messages) + + expect(virtualization.visibleDisplayMessages.value).toHaveLength(90) + const heightBefore = messageWindow.totalHeight.value + + messages.value = [ + ...history, + createStreamingAssistant('longer streaming response '.repeat(100), 201) + ] + + const visible = virtualization.visibleDisplayMessages.value + expect(visible).toHaveLength(90) + expect(visible.at(-1)?.id).toBe('assistant-streaming') + expect(visible.at(-1)?.content[0]?.content).toContain('longer streaming response') + expect(messageWindow.totalHeight.value).toBeGreaterThan(heightBefore) + }) +}) diff --git a/test/renderer/composables/useMessageWindow.test.ts b/test/renderer/composables/useMessageWindow.test.ts index 14aa9e196c..9774cae3da 100644 --- a/test/renderer/composables/useMessageWindow.test.ts +++ b/test/renderer/composables/useMessageWindow.test.ts @@ -62,7 +62,7 @@ const createPendingAssistantPlaceholder = (): MessageListItem => ({ content: [] }) -const createStreamingAssistant = (): MessageListItem => ({ +const createStreamingAssistant = (): Extract => ({ id: 'assistant-real-1', renderKey: '__pending_assistant_1', role: 'assistant', @@ -201,19 +201,65 @@ describe('useMessageWindow', () => { expect(window.getEntry('message-0')?.bottom).toBe(initialEstimate) }) - it('keeps a 200-message layout bounded through a streaming row replacement', () => { - const messages = ref(createMessages(200)) - const start = performance.now() + it('falls back to a full layout without a layout contract', () => { + const history = createMessages(200) + const messages = ref ([...history, createStreamingAssistant()]) const window = useMessageWindow({ messages }) + const initialEntries = window.entries.value + const initialTailTop = initialEntries[200].top - expect(window.entries.value).toHaveLength(200) - expect(window.totalHeight.value).toBeGreaterThan(0) + messages.value = [ + createUserMessage('message-0', 0, 'long '.repeat(300)), + ...history.slice(1), + { + ...createStreamingAssistant(), + updatedAt: 3, + content: [ + { + type: 'content', + content: 'updated tail', + status: 'loading', + timestamp: 3 + } + ] + } + ] + const updatedEntries = window.entries.value + const tail = updatedEntries[200] + + expect(updatedEntries[0]).not.toBe(initialEntries[0]) + expect(tail.top).toBe(updatedEntries[199].bottom) + expect(tail.top).toBeGreaterThan(initialTailTop) + expect(window.totalHeight.value).toBe(tail.bottom) + }) - messages.value = [...messages.value.slice(0, 199), createStreamingAssistant()] + it('invalidates an estimate when a reused message receives new content', () => { + const original = createUserMessage('message-0', 0, 'short') + const messages = ref ([original]) + const window = useMessageWindow({ messages }) + const initialHeight = window.getEntry('message-0')?.estimatedHeight + + messages.value = [ + { + ...original, + updatedAt: 1, + content: { + ...original.content, + text: 'long '.repeat(300) + } + } + ] + + expect(window.getEntry('message-0')?.estimatedHeight).toBeGreaterThan(initialHeight ?? 0) + }) + + it('looks up entries by message id and render key through the current layout index', () => { + const messages = ref ([createStreamingAssistant()]) + const window = useMessageWindow({ messages }) - expect(window.entries.value).toHaveLength(200) - expect(window.getEntry('assistant-real-1')).toBeDefined() - expect(performance.now() - start).toBeLessThan(100) + expect(window.getEntry('assistant-real-1')).toBe(window.getEntry('__pending_assistant_1')) + expect(window.setMeasuredHeight('__pending_assistant_1', 200)).toBeGreaterThan(0) + expect(window.getEntry('assistant-real-1')).toMatchObject({ measuredHeight: 200, bottom: 200 }) }) it('captures and restores an immutable measurement snapshot', () => { diff --git a/test/renderer/features/chat-page/composables/useChatSearch.test.ts b/test/renderer/features/chat-page/composables/useChatSearch.test.ts new file mode 100644 index 0000000000..ba8d64d81a --- /dev/null +++ b/test/renderer/features/chat-page/composables/useChatSearch.test.ts @@ -0,0 +1,137 @@ +import { computed, nextTick, ref } from 'vue' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const chatSearchMocks = vi.hoisted(() => ({ + apply: vi.fn(), + clear: vi.fn(), + setActive: vi.fn() +})) + +vi.mock('@/lib/chatSearch', () => ({ + applyChatSearchHighlights: chatSearchMocks.apply, + clearChatSearchHighlights: chatSearchMocks.clear, + setActiveChatSearchResult: chatSearchMocks.setActive, + collectChatSearchResults: ( + messages: Array<{ id: string; content: { text: string } }>, + query: string + ) => + query.trim() && messages[0]?.content.text.includes(query.trim()) + ? [{ messageId: messages[0].id, matchIndex: 0 }] + : [] +})) + +import { useChatSearch } from '@/features/chat-page/composables/useChatSearch' +import type { DisplayMessage } from '@/features/chat-page/model/displayMessage' + +const createDeferred = () => { + let resolve!: () => void + const promise = new Promise ((done) => { + resolve = done + }) + return { promise, resolve } +} + +const createSearch = (waitForNextAnimationFrame: () => Promise = async () => {}) => { + const root = document.createElement('div') + root.innerHTML = ' ' + const messages = computed( + () => [{ id: 'm1', content: { text: 'alpha beta' } }] as unknown as DisplayMessage[] + ) + + return useChatSearch({ + messageSearchRoot: ref(root), + displayMessages: messages, + visibleDisplayMessages: messages, + hasWindowEntry: () => true, + requestChatScroll: () => 1, + waitForNextAnimationFrame + }) +} + +const settleQuery = async (search: ReturnTypealpha beta
, query: string) => { + search.chatSearchQuery.value = query + await nextTick() + await vi.advanceTimersByTimeAsync(150) + await nextTick() + // The debounced watcher schedules the highlight refresh on the next animation + // frame; advance the faked rAF timer so the refresh actually runs. + await vi.advanceTimersByTimeAsync(32) + await nextTick() +} + +describe('useChatSearch', () => { + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + }) + + it('waits for the query to settle before collecting results', async () => { + vi.useFakeTimers() + const search = createSearch() + + search.openChatSearch() + search.chatSearchQuery.value = 'alpha' + await nextTick() + + expect(search.chatSearchResults.value).toEqual([]) + + await vi.advanceTimersByTimeAsync(150) + await nextTick() + + expect(search.chatSearchResults.value).toEqual([{ messageId: 'm1', matchIndex: 0 }]) + }) + + it('uses a trimmed canonical query while preserving the input value', async () => { + vi.useFakeTimers() + const search = createSearch() + + search.openChatSearch() + await settleQuery(search, ' alpha ') + + expect(search.chatSearchQuery.value).toBe(' alpha ') + expect(search.chatSearchResults.value).toEqual([{ messageId: 'm1', matchIndex: 0 }]) + expect(chatSearchMocks.apply).toHaveBeenCalledWith(expect.anything(), 'alpha') + }) + + it('does not restore an active marker after closing while navigation waits for a frame', async () => { + vi.useFakeTimers() + const frame = createDeferred() + const search = createSearch(() => frame.promise) + + search.openChatSearch() + await settleQuery(search, 'alpha') + search.goToNextChatSearchMatch() + await nextTick() + + search.closeChatSearch() + frame.resolve() + await nextTick() + await nextTick() + + expect(chatSearchMocks.setActive).not.toHaveBeenCalled() + expect(chatSearchMocks.clear).toHaveBeenCalled() + }) + + it('does not apply the previous query after it changes while navigation waits for a frame', async () => { + vi.useFakeTimers() + const frame = createDeferred() + const search = createSearch(() => frame.promise) + + search.openChatSearch() + await settleQuery(search, 'alpha') + search.goToNextChatSearchMatch() + await nextTick() + + // The navigation chain is now suspended on the deferred frame with 'alpha' + // already applied. Only calls made after the query changes matter here. + chatSearchMocks.apply.mockClear() + search.chatSearchQuery.value = 'beta' + await nextTick() + frame.resolve() + await nextTick() + await nextTick() + + expect(chatSearchMocks.setActive).not.toHaveBeenCalled() + expect(chatSearchMocks.apply).not.toHaveBeenCalledWith(expect.anything(), 'alpha') + }) +}) diff --git a/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts b/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts new file mode 100644 index 0000000000..d6d9f7f73b --- /dev/null +++ b/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts @@ -0,0 +1,136 @@ +import { computed, ref } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import { useComposerSubmit } from '@/features/chat-page/composables/useComposerSubmit' +import { createDeferred } from '../../../utils/deferred' + +function setup() { + const sessionId = ref('s1') + const restoreRequestId = ref(1) + const isAcpWorkdirMissing = ref(false) + const steerActiveTurn = vi.fn(async () => ({ accepted: true })) + const beginPlanTurn = vi.fn() + const toast = vi.fn() + + const composer = useComposerSubmit({ + sessionId: () => sessionId.value, + currentRestoreRequestId: () => restoreRequestId.value, + canWriteSessionView: (targetSessionId, targetRequestId) => + targetSessionId === sessionId.value && targetRequestId === restoreRequestId.value, + messageStore: { + addOptimisticUserMessage: vi.fn(), + removeOptimisticMessage: vi.fn() + } as never, + sessionStore: { + activeSession: { providerId: 'openai' } + } as never, + modelStore: { + findChatSelectableModel: vi.fn() + } as never, + pendingInputStore: { + isAtCapacity: false, + queueInput: vi.fn() + } as never, + chatClient: { + sendMessage: vi.fn(), + steerActiveTurn + }, + sessionClient: { + compactSession: vi.fn(async () => ({ compacted: true })) + }, + modelClient: { + getCapabilities: vi.fn(async () => ({ supportsAudioInput: true })) + }, + chatInputRef: ref(null), + isReadOnlySession: computed(() => false), + isSessionViewPreparing: computed(() => false), + isAcpWorkdirMissing: computed(() => isAcpWorkdirMissing.value), + isGenerating: computed(() => true), + hasBlockingInteraction: () => false, + getActiveModelSelection: () => ({ providerId: 'openai', modelId: 'gpt-4' }), + createPendingAssistantPlaceholder: vi.fn(() => 'pending-assistant'), + clearPendingAssistantPlaceholder: vi.fn(), + beginPlanTurn, + schedulePostSubmitScrollToBottom: vi.fn(), + loadMessagesForSession: vi.fn(async () => null), + applyRestoredSessionSummary: vi.fn(), + toast, + t: (key) => key + }) + + return { + composer, + sessionId, + restoreRequestId, + isAcpWorkdirMissing, + steerActiveTurn, + beginPlanTurn, + toast + } +} + +describe('useComposerSubmit steer', () => { + it('blocks duplicates and clears the draft only after acceptance', async () => { + const steering = createDeferred<{ accepted: boolean }>() + const { composer, steerActiveTurn, beginPlanTurn } = setup() + steerActiveTurn.mockReturnValueOnce(steering.promise) + composer.message.value = 'tighten the answer' + + const request = composer.onSteer() + await vi.waitFor(() => expect(steerActiveTurn).toHaveBeenCalledTimes(1)) + + expect(composer.isSteering.value).toBe(true) + expect(composer.disableQueueSteerAction.value).toBe(true) + expect(composer.isQueueSubmitDisabled.value).toBe(true) + + await composer.onSteer() + expect(steerActiveTurn).toHaveBeenCalledTimes(1) + + steering.resolve({ accepted: true }) + await request + + expect(steerActiveTurn).toHaveBeenCalledWith('s1', { + text: 'tighten the answer', + files: [] + }) + expect(beginPlanTurn).toHaveBeenCalledWith('s1') + expect(composer.message.value).toBe('') + expect(composer.isSteering.value).toBe(false) + }) + + it('retains the draft and reports a failed request', async () => { + const { composer, steerActiveTurn, beginPlanTurn, toast } = setup() + steerActiveTurn.mockRejectedValueOnce(new Error('boom')) + composer.message.value = 'keep this draft' + + await composer.onSteer() + + expect(beginPlanTurn).not.toHaveBeenCalled() + expect(composer.message.value).toBe('keep this draft') + expect(toast).toHaveBeenCalledWith({ + title: 'chat.pendingInput.steerFailed', + variant: 'destructive' + }) + }) + + it('does not clear a new draft when an old A-B-A request resolves', async () => { + const steering = createDeferred<{ accepted: boolean }>() + const { composer, sessionId, restoreRequestId, steerActiveTurn, beginPlanTurn } = setup() + steerActiveTurn.mockReturnValueOnce(steering.promise) + composer.message.value = 'old draft' + + const request = composer.onSteer() + await vi.waitFor(() => expect(steerActiveTurn).toHaveBeenCalledTimes(1)) + + sessionId.value = 's2' + restoreRequestId.value += 1 + sessionId.value = 's1' + restoreRequestId.value += 1 + composer.message.value = 'new draft' + + steering.resolve({ accepted: true }) + await request + + expect(beginPlanTurn).toHaveBeenCalledWith('s1') + expect(composer.message.value).toBe('new draft') + }) +}) diff --git a/test/renderer/features/chat-page/composables/useDisplayMessages.test.ts b/test/renderer/features/chat-page/composables/useDisplayMessages.test.ts new file mode 100644 index 0000000000..998bc92062 --- /dev/null +++ b/test/renderer/features/chat-page/composables/useDisplayMessages.test.ts @@ -0,0 +1,194 @@ +import { computed, nextTick, reactive, ref } from 'vue' +import { describe, expect, it } from 'vitest' +import { useDisplayMessages } from '@/features/chat-page/composables/useDisplayMessages' +import type { AssistantMessageBlock, ChatMessageRecord } from '@shared/types/agent-interface' + +type DisplayMessageOptions = Parameters [0] + +function assistantRecord( + id: string, + orderSeq: number, + content: string, + status: ChatMessageRecord['status'] = 'sent', + updatedAt = orderSeq +): ChatMessageRecord { + return { + id, + sessionId: 's1', + orderSeq, + role: 'assistant', + content: JSON.stringify([ + { + type: 'content', + content, + status: status === 'pending' ? 'pending' : 'success', + timestamp: orderSeq + } + ]), + status, + isContextEdge: 0, + metadata: '{}', + traceCount: 0, + createdAt: orderSeq, + updatedAt + } +} + +function createHarness( + messageOrder: string[], + seededRecords: ChatMessageRecord[] = [ + assistantRecord('history', 1, 'settled'), + assistantRecord('stream', 2, 'first snapshot', 'pending') + ] +) { + const streaming = reactive({ active: true }) + const initialBlock: AssistantMessageBlock = { + type: 'content', + content: 'first snapshot', + status: 'pending', + timestamp: 2 + } + const records = reactive( + new Map (seededRecords.map((record) => [record.id, record])) + ) + const messageStore = reactive({ + lastPersistedRevision: 1, + streamRevision: 1, + currentStreamMessageId: 'stream' as string | null, + streamingBlocks: [initialBlock] as AssistantMessageBlock[], + messageIds: [...messageOrder], + messageCache: records, + get messages() { + return this.messageIds + .map((id) => this.messageCache.get(id)) + .filter((record): record is ChatMessageRecord => Boolean(record)) + }, + getAssistantMessageBlocks(record: ChatMessageRecord) { + return JSON.parse(record.content) + }, + getUserMessageContent() { + return { + text: '', + files: [], + links: [], + search: false, + think: false + } + }, + getMessageMetadata() { + return {} + } + }) + const sessionStore = reactive({ + activeSession: { + id: 's1', + modelId: 'model-1', + providerId: 'provider-1' + } + }) + const modelStore = { + findModelByIdOrName: () => ({ model: { name: 'Model 1' } }) + } + const display = useDisplayMessages({ + sessionId: () => 's1', + messageStore: messageStore as unknown as DisplayMessageOptions['messageStore'], + sessionStore: sessionStore as unknown as DisplayMessageOptions['sessionStore'], + modelStore: modelStore as unknown as DisplayMessageOptions['modelStore'], + isGenerating: ref(false), + isSessionViewCommitted: computed(() => true), + isCurrentSessionStreaming: computed(() => streaming.active) + }) + + return { display, messageStore, records, streaming } +} + +describe('useDisplayMessages', () => { + it('keeps ordered history and a folded streaming record in one display list', () => { + const history = Array.from({ length: 200 }, (_, index) => + assistantRecord(`history-${index}`, index + 1, `settled-${index}`) + ) + const stream = assistantRecord('stream', 201, 'first snapshot', 'pending', 201) + const { display, messageStore, records } = createHarness( + [...history.map((record) => record.id), stream.id], + [...history, stream] + ) + + expect(display.displayMessages.value).toHaveLength(201) + expect(display.displayMessages.value.map((message) => message.id)).toEqual([ + ...history.map((record) => record.id), + 'stream' + ]) + + const nextBlock: AssistantMessageBlock = { + type: 'content', + content: 'second snapshot', + status: 'pending', + timestamp: 202 + } + messageStore.streamingBlocks = [nextBlock] + records.set('stream', assistantRecord('stream', 201, 'second snapshot', 'pending', 202)) + messageStore.streamRevision += 1 + + const messages = display.displayMessages.value + expect(messages).toHaveLength(201) + expect(messages.at(-1)?.id).toBe('stream') + expect(messages.at(-1)?.content[0]?.content).toBe('second snapshot') + }) + + it('keeps an inline stream visible while its record reaches the cache before messageIds', () => { + const { display, messageStore, records } = createHarness( + ['history'], + [assistantRecord('history', 1, 'settled')] + ) + records.set('stream', assistantRecord('stream', 2, 'persisted response', 'pending', 3)) + messageStore.streamRevision += 1 + + expect(display.displayMessages.value.map((message) => message.id)).toEqual([ + 'history', + 'stream' + ]) + expect(display.displayMessages.value.at(-1)?.content[0]?.content).toBe('persisted response') + + messageStore.messageIds.push('stream') + messageStore.lastPersistedRevision += 1 + + expect(display.displayMessages.value.map((message) => message.id)).toEqual([ + 'history', + 'stream' + ]) + }) + + it('preserves a middle stream order and hands the pending row render key to it', async () => { + const user = { + ...assistantRecord('user', 1, 'prompt'), + role: 'user' as const + } + const middleStream = assistantRecord('stream', 2, 'first snapshot', 'pending') + const later = assistantRecord('later', 3, 'settled') + const { display, messageStore, records } = createHarness( + ['user', 'stream', 'later'], + [user, middleStream, later] + ) + messageStore.currentStreamMessageId = null + messageStore.streamingBlocks = [] + const placeholderId = display.createPendingAssistantPlaceholder('s1') + + await nextTick() + expect(display.pendingAssistantPlaceholder.value?.id).toBe(placeholderId) + + records.set('stream', assistantRecord('stream', 2, 'second snapshot', 'pending', 4)) + messageStore.currentStreamMessageId = 'stream' + messageStore.streamingBlocks = [ + { type: 'content', content: 'second snapshot', status: 'pending', timestamp: 4 } + ] + messageStore.streamRevision += 1 + await nextTick() + + expect(display.displayMessages.value.map((message) => message.id)).toEqual([ + 'user', + 'stream', + 'later' + ]) + expect(display.displayMessages.value[1]?.renderKey).toBe(placeholderId) + }) +}) diff --git a/test/renderer/features/chat-page/composables/useMessageActions.test.ts b/test/renderer/features/chat-page/composables/useMessageActions.test.ts index 30597742fb..05965d9596 100644 --- a/test/renderer/features/chat-page/composables/useMessageActions.test.ts +++ b/test/renderer/features/chat-page/composables/useMessageActions.test.ts @@ -1,6 +1,7 @@ import { computed, effectScope, ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useMessageActions } from '@/features/chat-page/composables/useMessageActions' +import { createDeferred } from '../../../utils/deferred' function createHarness() { const sessionId = ref('s1') @@ -18,7 +19,11 @@ function createHarness() { const clearPlanSnapshotForDeletedMessage = vi.fn() const loadMessagesForSession = vi.fn().mockResolvedValue({ id: 'loaded' }) const applyRestoredSessionSummary = vi.fn() - const isCurrentSession = vi.fn((id: string) => id === sessionId.value) + const restoreRequestId = ref(0) + const canWriteSessionView = vi.fn( + (id: string, requestId: number) => + id === sessionId.value && requestId === restoreRequestId.value + ) const scope = effectScope() let actions!: ReturnType @@ -34,7 +39,8 @@ function createHarness() { clearPlanSnapshotForDeletedMessage, loadMessagesForSession, applyRestoredSessionSummary, - isCurrentSession + currentRestoreRequestId: () => restoreRequestId.value, + canWriteSessionView }) }) @@ -50,7 +56,8 @@ function createHarness() { clearPlanSnapshotForDeletedMessage, loadMessagesForSession, applyRestoredSessionSummary, - isCurrentSession, + restoreRequestId, + canWriteSessionView, stop: () => scope.stop() } } @@ -86,6 +93,39 @@ describe('useMessageActions', () => { harness.stop() }) + it('does not restore a stale session after retry or delete completes', async () => { + const harness = createHarness() + const retry = createDeferred () + const deleteRequest = createDeferred () + const restore = createDeferred () + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + harness.sessionClient.retryMessage.mockReturnValueOnce(retry.promise) + harness.sessionClient.deleteMessage.mockReturnValueOnce(deleteRequest.promise) + harness.loadMessagesForSession.mockReturnValueOnce(restore.promise) + + const retryAction = harness.actions.onMessageRetry('message-1') + retry.reject?.(new Error('retry failed')) + await vi.waitFor(() => expect(harness.loadMessagesForSession).toHaveBeenCalledWith('s1')) + harness.sessionId.value = 's2' + harness.restoreRequestId.value += 1 + restore.resolve({ id: 'stale' }) + await retryAction + expect(harness.applyRestoredSessionSummary).not.toHaveBeenCalled() + + await harness.actions.onMessageDelete('message-2') + const deleteAction = harness.actions.confirmMessageDelete() + harness.sessionId.value = 's1' + harness.restoreRequestId.value += 1 + deleteRequest.resolve(undefined) + await deleteAction + expect(harness.loadMessagesForSession).toHaveBeenCalledTimes(1) + // Plan snapshots are keyed by session id, so the cleanup still runs for the + // deleted message even though the stale view restore is skipped. + expect(harness.clearPlanSnapshotForDeletedMessage).toHaveBeenCalledWith('s2', 'message-2') + consoleError.mockRestore() + harness.stop() + }) + it('preserves delete-confirmation, current-session refresh, and read-only behavior', async () => { const harness = createHarness() diff --git a/test/renderer/features/chat-page/composables/useToolInteraction.test.ts b/test/renderer/features/chat-page/composables/useToolInteraction.test.ts index 60327d74bc..ef656e9a9e 100644 --- a/test/renderer/features/chat-page/composables/useToolInteraction.test.ts +++ b/test/renderer/features/chat-page/composables/useToolInteraction.test.ts @@ -1,17 +1,7 @@ import { computed, effectScope, ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useToolInteraction } from '@/features/chat-page/composables/useToolInteraction' - -function deferred () { - let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise ((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - - return { promise, resolve, reject } -} +import { createDeferred } from '../../../utils/deferred' function createAssistantMessage(id: string, blocks: unknown[]) { return { @@ -33,6 +23,11 @@ function createHarness(messages: Array >) { } const loadMessagesForSession = vi.fn().mockResolvedValue({ id: 'restored' }) const applyRestoredSessionSummary = vi.fn() + const restoreRequestId = ref(0) + const canWriteSessionView = vi.fn( + (id: string, requestId: number) => + id === sessionId.value && requestId === restoreRequestId.value + ) const scope = effectScope() let toolInteraction!: ReturnType @@ -43,7 +38,9 @@ function createHarness(messages: Array >) { isReadOnlySession, chatClient, loadMessagesForSession, - applyRestoredSessionSummary + applyRestoredSessionSummary, + currentRestoreRequestId: () => restoreRequestId.value, + canWriteSessionView }) }) @@ -53,6 +50,8 @@ function createHarness(messages: Array >) { chatClient, loadMessagesForSession, applyRestoredSessionSummary, + restoreRequestId, + canWriteSessionView, stop: () => scope.stop() } } @@ -139,7 +138,7 @@ describe('useToolInteraction', () => { }) it('submits one response at a time and refreshes the current page session afterwards', async () => { - const response = deferred<{ handledInline?: boolean }>() + const response = createDeferred<{ handledInline?: boolean }>() const harness = createHarness([ createAssistantMessage('m1', [ { @@ -174,8 +173,8 @@ describe('useToolInteraction', () => { toolCallId: 'tool-1', response: { kind: 'permission', granted: true } }) - expect(harness.loadMessagesForSession).toHaveBeenCalledWith('s2') - expect(harness.applyRestoredSessionSummary).toHaveBeenCalledWith({ id: 'restored' }) + expect(harness.loadMessagesForSession).not.toHaveBeenCalled() + expect(harness.applyRestoredSessionSummary).not.toHaveBeenCalled() expect(harness.toolInteraction.isHandlingInteraction.value).toBe(false) harness.stop() }) diff --git a/test/renderer/features/chat-page/model/displayUserMessageText.test.ts b/test/renderer/features/chat-page/model/displayUserMessageText.test.ts new file mode 100644 index 0000000000..fca53877c6 --- /dev/null +++ b/test/renderer/features/chat-page/model/displayUserMessageText.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { + collectVisibleUserMessageText, + getVisibleMentionLabel, + getVisibleUserContentBlocks +} from '@/features/chat-page/model/displayUserMessageText' +import type { DisplayUserMessageContent } from '@/features/chat-page/model/displayMessage' + +const createContent = ( + overrides: Partial = {} +): DisplayUserMessageContent => ({ + text: '', + files: [], + links: [], + search: false, + think: false, + ...overrides +}) + +describe('displayUserMessageText', () => { + it('uses the labels rendered for prompt and context mentions', () => { + expect( + getVisibleMentionLabel({ + type: 'mention', + category: 'prompts', + id: 'prompt-a', + content: 'raw' + }) + ).toBe('prompt-a') + expect( + getVisibleMentionLabel({ type: 'mention', category: 'context', id: '', content: 'raw' }) + ).toBe('context') + }) + + it('uses rich blocks instead of raw text and inline items', () => { + const content = createContent({ + text: 'raw text', + inlineItems: [{ type: 'skill', offset: 0, skillName: 'inline-skill' }], + content: [ + { type: 'text', content: 'visible ' }, + { type: 'mention', category: 'context', id: 'project-a', content: 'raw context' }, + { type: 'code', content: 'const answer = 42', language: 'typescript' } + ] + }) + + expect(getVisibleUserContentBlocks(content)).toEqual(content.content) + expect(collectVisibleUserMessageText(content)).toBe('visible project-aconst answer = 42') + }) + + it('inserts valid inline labels at their text offsets', () => { + const content = createContent({ + text: 'before after', + inlineItems: [ + { type: 'file', offset: 7, fileName: 'notes.md', filePath: '/tmp/notes.md' }, + { type: 'skill', offset: 7, skillName: 'review' }, + { type: 'skill', offset: 99, skillName: 'ignored' } + ] + }) + + expect(getVisibleUserContentBlocks(content)).toEqual([ + { type: 'text', content: 'before ' }, + { type: 'file', fileName: 'notes.md', filePath: '/tmp/notes.md', mimeType: undefined }, + { type: 'skill', skillName: 'review' }, + { type: 'text', content: 'after' } + ]) + expect(collectVisibleUserMessageText(content)).toBe('before notes.mdreviewafter') + }) + + it('falls back to raw text without renderable inline items', () => { + const content = createContent({ + text: 'raw text', + inlineItems: [{ type: 'skill', offset: -1, skillName: 'ignored' }] + }) + + expect(getVisibleUserContentBlocks(content)).toEqual([]) + expect(collectVisibleUserMessageText(content)).toBe('raw text') + }) +}) diff --git a/test/renderer/foundation/appearance/documentAppearance.test.ts b/test/renderer/foundation/appearance/documentAppearance.test.ts new file mode 100644 index 0000000000..fa23a2059e --- /dev/null +++ b/test/renderer/foundation/appearance/documentAppearance.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + applyDocumentAppearance, + resolveDocumentDirection +} from '@/foundation/appearance/documentAppearance' + +describe('applyDocumentAppearance', () => { + afterEach(() => { + document.documentElement.className = '' + document.body.className = '' + document.documentElement.lang = '' + document.documentElement.dir = '' + delete document.documentElement.dataset.theme + vi.restoreAllMocks() + }) + + it('resolves direction from the persisted locale', () => { + expect(resolveDocumentDirection('he-IL')).toBe('rtl') + expect(resolveDocumentDirection('zh-CN')).toBe('auto') + }) + + it('projects resolved theme, font size, and locale state to the document', () => { + document.documentElement.classList.add('light', 'text-sm') + document.body.classList.add('light', 'text-sm') + + applyDocumentAppearance({ + theme: 'dark', + fontSizeClass: 'text-lg', + language: 'he-IL', + direction: 'rtl' + }) + + for (const target of [document.documentElement, document.body]) { + expect(target.classList.contains('dark')).toBe(true) + expect(target.classList.contains('light')).toBe(false) + expect(target.classList.contains('text-lg')).toBe(true) + expect(target.classList.contains('text-sm')).toBe(false) + } + expect(document.documentElement.lang).toBe('he-IL') + expect(document.documentElement.dir).toBe('rtl') + }) + + it('sets the optional theme dataset for renderers that need selector compatibility', () => { + applyDocumentAppearance({ theme: 'dark', themeDataset: true }) + + expect(document.documentElement.dataset.theme).toBe('dark') + }) + + it('does not suppress transitions when the resolved theme is unchanged', () => { + document.documentElement.classList.add('dark') + const offsetWidth = vi.spyOn(document.documentElement, 'offsetWidth', 'get') + + applyDocumentAppearance({ + theme: 'dark', + fontSizeClass: 'text-lg', + disableThemeTransition: true + }) + + expect(document.documentElement.classList.contains('dc-theme-switching')).toBe(false) + expect(offsetWidth).not.toHaveBeenCalled() + }) + + it('removes transition suppression on the next animation frame', () => { + let runFrame: FrameRequestCallback | null = null + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + runFrame = callback + return 1 + }) + + applyDocumentAppearance({ theme: 'dark', disableThemeTransition: true }) + + expect(document.documentElement.classList.contains('dc-theme-switching')).toBe(true) + expect(runFrame).not.toBeNull() + runFrame?.(0) + expect(document.documentElement.classList.contains('dc-theme-switching')).toBe(false) + }) +}) diff --git a/test/renderer/lib/chatSearch.test.ts b/test/renderer/lib/chatSearch.test.ts index 4de3b62fb3..04a28c01fd 100644 --- a/test/renderer/lib/chatSearch.test.ts +++ b/test/renderer/lib/chatSearch.test.ts @@ -80,6 +80,83 @@ describe('chatSearch', () => { expect(container.getAttribute('data-chat-search-query')).toBe('alpha') }) + it('coalesces observer updates and cancels scheduled work when clearing highlights', async () => { + vi.useFakeTimers() + const requestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame') + const container = document.createElement('div') + container.innerHTML = ' ' + + applyChatSearchHighlights(container, 'alpha') + const row = container.querySelectoralpha
('[data-message-id="m1"]') + if (!row) throw new Error('Expected message row') + + row.querySelector('p')!.textContent = 'alpha one' + row.querySelector('p')!.textContent = 'alpha two' + await Promise.resolve() + + expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + const cancelAnimationFrame = vi.spyOn(window, 'cancelAnimationFrame') + clearChatSearchHighlights(container) + expect(cancelAnimationFrame).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(20) + + expect(container.querySelectorAll('mark[data-chat-search-match="true"]')).toHaveLength(0) + expect(container.textContent).toBe('alpha two') + }) + + it('highlights newly mounted message rows without rescanning existing rows', async () => { + vi.useFakeTimers() + const container = document.createElement('div') + container.innerHTML = ` + +alpha
+ ` + + applyChatSearchHighlights(container, 'alpha') + const existingRow = container.querySelectoralpha
('[data-message-id="m1"]') + expect(existingRow?.getAttribute('data-chat-search-highlighted-query')).toBe('alpha') + + const existingText = existingRow?.querySelector('p')?.firstChild + if (!existingText) throw new Error('Expected existing row text') + existingText.textContent = 'alpha updated without remounting' + + const nextRow = document.createElement('div') + nextRow.dataset.messageId = 'm3' + nextRow.innerHTML = ' alpha newly mounted
' + container.appendChild(nextRow) + + applyChatSearchHighlights(container, 'alpha') + await vi.advanceTimersByTimeAsync(20) + + expect(existingRow?.querySelectorAll('mark')).toHaveLength(1) + expect(nextRow.querySelectorAll('mark')).toHaveLength(1) + }) + + it('refreshes a mounted row when its text changes without duplicating stale marks', async () => { + vi.useFakeTimers() + const container = document.createElement('div') + container.innerHTML = ` ++ ` + + applyChatSearchHighlights(container, 'alpha') + const row = container.querySelectoralpha
('[data-message-id="m1"]') + const paragraph = row?.querySelector('p') + if (!paragraph) throw new Error('Expected message content') + + // Vue commonly patches the existing message subtree instead of remounting its row. + paragraph.textContent = 'alpha updated with alpha' + await vi.advanceTimersByTimeAsync(20) + + const marks = row.querySelectorAll('mark[data-chat-search-match="true"]') + expect(marks).toHaveLength(2) + expect(Array.from(marks, (mark) => mark.textContent)).toEqual(['alpha', 'alpha']) + expect(row.textContent).toBe('alpha updated with alpha') + + await vi.advanceTimersByTimeAsync(20) + expect(row.querySelectorAll('mark[data-chat-search-match="true"]')).toHaveLength(2) + }) + it('rebuilds marks when the query changes', () => { const container = document.createElement('div') container.innerHTML = ` @@ -117,6 +194,31 @@ describe('chatSearch', () => { expect(container.querySelectorAll('mark[data-chat-search-match="true"]')).toHaveLength(2) }) + it('counts only content that the DOM highlighter can activate', () => { + const results = collectChatSearchResults( + [ + { + id: 'm1', + content: [ + { type: 'content', content: 'visible alpha' }, + { type: 'plan', content: 'hidden alpha' }, + { + type: 'tool_call', + tool_call: { name: 'search', params: '{"query":"alpha"}', response: 'alpha result' } + } + ] + }, + { id: 'm2', content: { text: 'user alpha', persistenceOnly: 'alpha' } } + ], + ' alpha ' + ) + + expect(results).toEqual([ + { messageId: 'm1', matchIndex: 0 }, + { messageId: 'm2', matchIndex: 0 } + ]) + }) + it('counts message matches from data and activates the matching rendered row only', () => { const results = collectChatSearchResults( [ @@ -144,9 +246,7 @@ describe('chatSearch', () => { expect(results).toEqual([ { messageId: 'm1', matchIndex: 0 }, { messageId: 'm1', matchIndex: 1 }, - { messageId: 'm2', matchIndex: 0 }, - { messageId: 'm3', matchIndex: 0 }, - { messageId: 'm3', matchIndex: 1 } + { messageId: 'm2', matchIndex: 0 } ]) const container = document.createElement('div') @@ -154,13 +254,126 @@ describe('chatSearch', () => { +gamma alpha
+` applyChatSearchHighlights(container, 'alpha') + expect(container.querySelectorAll('mark[data-chat-search-match="true"]')).toHaveLength(1) expect(setActiveChatSearchResult(container, results[0], { scroll: false })).toBeNull() const active = setActiveChatSearchResult(container, results[2], { scroll: false }) expect(active?.textContent).toBe('alpha') expect(active?.dataset.chatSearchActive).toBe('true') }) + + it('keeps rows with pending observer work highlighted across a same-query re-apply', async () => { + vi.useFakeTimers() + const container = document.createElement('div') + container.innerHTML = 'search alpha + +' + + applyChatSearchHighlights(container, 'alpha') + const row = container.querySelectoralpha
('[data-message-id="m1"]') + const paragraph = row?.querySelector('p') + if (!row || !paragraph) throw new Error('Expected message row') + + // In-place patch and same-query re-apply in the same task (e.g. streaming + // update plus a virtual-window shift): rebuilding the observer must not + // drop the row's still-undelivered mutation records. + paragraph.textContent = 'alpha updated with alpha' + applyChatSearchHighlights(container, 'alpha') + await vi.advanceTimersByTimeAsync(40) + + const marks = row.querySelectorAll('mark[data-chat-search-match="true"]') + expect(marks).toHaveLength(2) + expect(row.textContent).toBe('alpha updated with alpha') + }) + + it('indexes user message text exactly as the rendered body exposes it', () => { + const results = collectChatSearchResults( + [ + { + id: 'm1', + content: { + text: 'raw alpha', + files: [], + links: [], + search: false, + think: false, + activeSkills: ['standalone alpha'], + content: [ + { type: 'text', content: 'visible ' }, + { type: 'mention', category: 'prompts', id: 'alpha-prompt', content: 'raw mention' }, + { type: 'code', content: 'alpha code', language: 'text' } + ] + } + }, + { + id: 'm2', + content: { + text: 'before after', + files: [], + links: [], + search: false, + think: false, + inlineItems: [{ type: 'skill', offset: 7, skillName: 'alpha-skill' }] + } + } + ], + 'alpha' + ) + + expect(results).toEqual([ + { messageId: 'm1', matchIndex: 0 }, + { messageId: 'm1', matchIndex: 1 }, + { messageId: 'm2', matchIndex: 0 } + ]) + }) + + it('does not highlight text marked outside a message body as non-searchable', () => { + const container = document.createElement('div') + container.innerHTML = ` + + excluded alpha ++ ` + + const matches = applyChatSearchHighlights(container, 'alpha') + + expect(matches).toHaveLength(1) + expect(matches[0]?.parentElement?.textContent).toBe('visible alpha') + }) + + it('does not double count user messages carrying both text and rendered blocks', () => { + const results = collectChatSearchResults( + [ + { + id: 'm1', + content: { + text: 'user alpha', + content: [{ type: 'text', content: 'user alpha' }] + } + }, + { + id: 'm2', + content: { + text: 'irrelevant', + content: [ + { type: 'text', content: 'see ' }, + { type: 'mention', category: 'prompts', id: 'alpha-prompt', content: 'raw' } + ] + } + } + ], + 'alpha' + ) + + expect(results).toEqual([ + { messageId: 'm1', matchIndex: 0 }, + { messageId: 'm2', matchIndex: 0 } + ]) + }) }) diff --git a/test/renderer/pages/NewThreadPage.test.ts b/test/renderer/pages/NewThreadPage.test.ts index 3ecd23173e..43c5a5f99c 100644 --- a/test/renderer/pages/NewThreadPage.test.ts +++ b/test/renderer/pages/NewThreadPage.test.ts @@ -7,6 +7,17 @@ const setup = async ( options?: { projects?: Array<{ name: string; path: string; exists: boolean }> environments?: Array<{ path: string; exists: boolean }> + modelInitialized?: boolean + initializeModel?: () => Promisevisible alpha++ createSession?: () => Promise + resolveDeepChatAgentConfig?: () => Promise<{ + defaultModelPreset?: { providerId: string; modelId: string } + defaultProjectPath?: string + systemPrompt: string + permissionMode: 'default' | 'full_access' + disabledAgentTools: string[] + }> + awaitReady?: boolean } ) => { vi.resetModules() @@ -31,8 +42,7 @@ const setup = async ( msg: '帮我总结一下这周的迭代状态', modelId: pendingModelId, systemPrompt: 'You are a concise project assistant.', - mentions: ['README.md', 'docs/spec.md'], - autoSend: false + mentions: ['README.md', 'docs/spec.md'] }, toGenerationSettings: vi.fn(() => undefined), clearPendingStartDeeplink: vi.fn(() => { @@ -69,7 +79,7 @@ const setup = async ( const sessionStore = { selectSession: vi.fn(), sendMessage: vi.fn(), - createSession: vi.fn() + createSession: vi.fn(options?.createSession) } const agentStore = reactive({ selectedAgentId: 'deepchat', @@ -78,8 +88,11 @@ const setup = async ( }) const getChatSelectableModelGroups = () => modelStore.enabledModels const modelStore = reactive({ - initialized: true, + initialized: options?.modelInitialized ?? true, initialize: vi.fn().mockImplementation(async () => { + if (options?.initializeModel) { + await options.initializeModel() + } modelStore.initialized = true }), enabledModels: [ @@ -117,15 +130,19 @@ const setup = async ( }) const configClient = { getSetting: vi.fn().mockResolvedValue(undefined), - resolveDeepChatAgentConfig: vi.fn().mockResolvedValue({ - defaultModelPreset: { - providerId: 'openai', - modelId: 'gpt-4o-mini' - }, - systemPrompt: 'Default system prompt', - permissionMode: 'full_access', - disabledAgentTools: [] - }) + resolveDeepChatAgentConfig: vi.fn().mockImplementation( + options?.resolveDeepChatAgentConfig ?? + (() => + Promise.resolve({ + defaultModelPreset: { + providerId: 'openai', + modelId: 'gpt-4o-mini' + }, + systemPrompt: 'Default system prompt', + permissionMode: 'full_access' as const, + disabledAgentTools: [] + })) + ) } const sessionClient = { ensureAcpDraftSession: vi.fn() @@ -222,19 +239,25 @@ const setup = async ( ChatStatusBar: true, ChatInputBox: { name: 'ChatInputBox', - props: ['modelValue'], - template: ' {{ modelValue }}' + props: ['modelValue', 'submitDisabled'], + emits: ['submit', 'command-submit'], + template: + '{{ modelValue }}' } } } }) - await flushPromises() + if (options?.awaitReady !== false) { + await flushPromises() + } return { wrapper, draftStore, - projectStore + projectStore, + sessionStore, + modelStore } } @@ -258,6 +281,46 @@ describe('NewThreadPage start deeplink prefill', () => { expect(draftStore.modelId).toBe('deepseek-chat') }, 20000) + it('does not replace a project selected while agent defaults are loading', async () => { + let resolveAgentConfig!: (value: { + defaultModelPreset?: { providerId: string; modelId: string } + defaultProjectPath?: string + systemPrompt: string + permissionMode: 'default' | 'full_access' + disabledAgentTools: string[] + }) => void + const agentConfig = new Promise<{ + defaultModelPreset?: { providerId: string; modelId: string } + defaultProjectPath?: string + systemPrompt: string + permissionMode: 'default' | 'full_access' + disabledAgentTools: string[] + }>((resolve) => { + resolveAgentConfig = resolve + }) + const { projectStore } = await setup('deepseek-chat', { + resolveDeepChatAgentConfig: () => agentConfig, + awaitReady: false + }) + await vi.waitFor(() => expect(resolveAgentConfig).toBeTypeOf('function')) + + projectStore.selectProject('/workspace/user-choice', 'manual') + resolveAgentConfig({ + defaultModelPreset: { providerId: 'openai', modelId: 'gpt-4o-mini' }, + defaultProjectPath: '/workspace/agent-default', + systemPrompt: 'Default system prompt', + permissionMode: 'full_access', + disabledAgentTools: [] + }) + await flushPromises() + + expect(projectStore.selectedProject?.path).toBe('/workspace/user-choice') + expect(projectStore.selectProject).not.toHaveBeenCalledWith( + '/workspace/agent-default', + 'manual' + ) + }, 20000) + it('allows clearing the selected project from the new thread dropdown', async () => { const { wrapper, projectStore } = await setup('deepseek-chat') @@ -284,4 +347,68 @@ describe('NewThreadPage start deeplink prefill', () => { expect(wrapper.text()).not.toContain('/workspace/missing') expect(wrapper.text()).not.toContain('/workspace/stale') }, 20000) + + it('keeps only the newest deeplink when model initialization resolves out of order', async () => { + let resolveModelInitialization!: () => void + const modelInitialization = new Promise((resolve) => { + resolveModelInitialization = resolve + }) + const { draftStore, wrapper } = await setup('deepseek-chat', { + modelInitialized: false, + initializeModel: () => modelInitialization + }) + + draftStore.pendingStartDeeplink = { + token: 2, + msg: '只应用这条最新 deep link', + modelId: 'gpt-4o-mini', + systemPrompt: 'Latest prompt', + mentions: [] + } + await wrapper.vm.$nextTick() + + resolveModelInitialization() + await flushPromises() + + expect(wrapper.get('[data-testid="chat-input"]').text()).toContain('只应用这条最新 deep link') + expect(draftStore.systemPrompt).toBe('Latest prompt') + expect(draftStore.providerId).toBe('openai') + expect(draftStore.modelId).toBe('gpt-4o-mini') + expect(draftStore.clearPendingStartDeeplink).toHaveBeenCalledTimes(1) + }, 20000) + + it('prevents duplicate new-thread submissions until the current submission completes', async () => { + let resolveCreateSession!: () => void + const createSession = new Promise ((resolve) => { + resolveCreateSession = resolve + }) + const { sessionStore, wrapper } = await setup('deepseek-chat', { + createSession: () => createSession + }) + const input = wrapper.findComponent({ name: 'ChatInputBox' }) + + await input.vm.$emit('update:modelValue', '请创建一个新会话') + await wrapper.vm.$nextTick() + await input.vm.$emit('submit') + await input.vm.$emit('submit') + await flushPromises() + + expect(sessionStore.createSession).toHaveBeenCalledTimes(1) + expect(wrapper.get('[data-testid="chat-input"]').attributes('data-submit-disabled')).toBe( + 'true' + ) + + resolveCreateSession() + await flushPromises() + + expect(wrapper.get('[data-testid="chat-input"]').attributes('data-submit-disabled')).toBe( + 'false' + ) + + await input.vm.$emit('update:modelValue', '第二条会话') + await input.vm.$emit('submit') + await flushPromises() + + expect(sessionStore.createSession).toHaveBeenCalledTimes(2) + }, 20000) }) diff --git a/test/renderer/stores/floatingButtonStore.test.ts b/test/renderer/stores/floatingButtonStore.test.ts new file mode 100644 index 0000000000..2466ee759d --- /dev/null +++ b/test/renderer/stores/floatingButtonStore.test.ts @@ -0,0 +1,112 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { createPinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useFloatingButtonStore } from '@/stores/floatingButton' +import { createDeferred } from '../utils/deferred' + +vi.mock('pinia', async () => vi.importActual ('pinia')) + +const floatingButtonMocks = vi.hoisted(() => ({ + getFloatingButtonEnabled: vi.fn(), + setFloatingButtonEnabled: vi.fn(), + onFloatingButtonChanged: vi.fn(), + listener: undefined as ((payload: { enabled: boolean; version: number }) => void) | undefined, + removeListener: vi.fn() +})) + +vi.mock('@api/ConfigClient', () => ({ + createConfigClient: () => ({ + getFloatingButtonEnabled: floatingButtonMocks.getFloatingButtonEnabled, + setFloatingButtonEnabled: floatingButtonMocks.setFloatingButtonEnabled, + onFloatingButtonChanged: floatingButtonMocks.onFloatingButtonChanged + }) +})) + +function mountFloatingButtonStore() { + const pinia = createPinia() + let store!: ReturnType + + const Harness = defineComponent({ + setup() { + store = useFloatingButtonStore() + return () => h('div') + } + }) + + const wrapper = mount(Harness, { + global: { + plugins: [pinia] + } + }) + + mountedStores.push({ store, wrapper }) + return { store, wrapper } +} + +let mountedStores: Array<{ + store: ReturnType + wrapper: VueWrapper +}> = [] + +describe('floating button store', () => { + beforeEach(() => { + vi.clearAllMocks() + floatingButtonMocks.listener = undefined + floatingButtonMocks.getFloatingButtonEnabled.mockResolvedValue(false) + floatingButtonMocks.setFloatingButtonEnabled.mockResolvedValue(undefined) + floatingButtonMocks.onFloatingButtonChanged.mockImplementation( + (listener: (payload: { enabled: boolean; version: number }) => void) => { + floatingButtonMocks.listener = listener + return floatingButtonMocks.removeListener + } + ) + }) + + afterEach(() => { + for (const { store, wrapper } of mountedStores) { + wrapper.unmount() + store.$dispose() + } + mountedStores = [] + }) + + it('keeps a listener update that arrives before the initial snapshot resolves', async () => { + const snapshot = createDeferred () + floatingButtonMocks.getFloatingButtonEnabled.mockReturnValueOnce(snapshot.promise) + + const { store } = await mountFloatingButtonStore() + await vi.waitFor(() => + expect(floatingButtonMocks.onFloatingButtonChanged).toHaveBeenCalledOnce() + ) + + floatingButtonMocks.listener?.({ enabled: true, version: 1 }) + snapshot.resolve(false) + await flushPromises() + + expect(store.enabled).toBe(true) + }) + + it('restores the actual previous value when the newest local update fails', async () => { + const { store } = await mountFloatingButtonStore() + await flushPromises() + store.enabled = true + floatingButtonMocks.setFloatingButtonEnabled.mockRejectedValueOnce(new Error('write failed')) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await store.setFloatingButtonEnabled(false) + + expect(store.enabled).toBe(true) + consoleError.mockRestore() + }) + + it('cleans up the IPC listener with its owning scope', async () => { + const { store, wrapper } = await mountFloatingButtonStore() + await flushPromises() + + wrapper.unmount() + store.$dispose() + + expect(floatingButtonMocks.removeListener).toHaveBeenCalledOnce() + }) +}) diff --git a/test/renderer/stores/languageStore.test.ts b/test/renderer/stores/languageStore.test.ts index 9e2f9cdde4..7a840dee60 100644 --- a/test/renderer/stores/languageStore.test.ts +++ b/test/renderer/stores/languageStore.test.ts @@ -192,6 +192,36 @@ describe('language store', () => { expect(store.dir).toBe('rtl') }) + it('preserves an explicit ltr direction from the language state', async () => { + const { store } = mountLanguageStore() + await flushPromises() + + languageMocks.listener?.({ + requestedLanguage: 'en-US', + locale: 'en-US', + direction: 'ltr' + }) + await flushPromises() + + expect(store.dir).toBe('ltr') + }) + + it('retries initial language initialization after a locale chunk fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + languageMocks.loadLocaleMessages + .mockRejectedValueOnce(new Error('chunk unavailable')) + .mockResolvedValueOnce(createMessages('en-US')) + const { i18n, store } = mountLanguageStore() + await flushPromises() + + expect(languageMocks.loadLocaleMessages).toHaveBeenCalledTimes(1) + await store.initLanguage() + + expect(languageMocks.loadLocaleMessages).toHaveBeenCalledTimes(2) + expect(i18n.global.locale.value).toBe('en-US') + consoleError.mockRestore() + }) + it('keeps the active locale when a new locale chunk fails', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) const { i18n, store } = mountLanguageStore() diff --git a/test/renderer/stores/memoryActivityStore.test.ts b/test/renderer/stores/memoryActivityStore.test.ts index e51f7ca4ae..996c06ec10 100644 --- a/test/renderer/stores/memoryActivityStore.test.ts +++ b/test/renderer/stores/memoryActivityStore.test.ts @@ -158,7 +158,10 @@ async function setupStore() { getRuntimeWebContentsId: vi.fn(async () => null) })) vi.doMock('../../../src/renderer/src/stores/ui/sessionIpc', () => ({ - bindSessionStoreIpc: vi.fn(() => () => undefined) + bindSessionStoreIpc: vi.fn(() => ({ + cleanup: () => undefined, + flushPendingTargetedUpdate: () => undefined + })) })) vi.doMock('../../../src/renderer/src/stores/ui/messageIpc', () => ({ bindMessageStoreIpc: vi.fn(() => () => undefined) diff --git a/test/renderer/stores/messageStore.test.ts b/test/renderer/stores/messageStore.test.ts index bc1e42325c..f384481c87 100644 --- a/test/renderer/stores/messageStore.test.ts +++ b/test/renderer/stores/messageStore.test.ts @@ -699,6 +699,61 @@ describe('messageStore', () => { expect(store.messages.value[149]?.id).toBe('m150') }) + it('marks a failed history request as retryable without exhausting history', async () => { + const { store, sessionClient } = await setupStore() + // A full first page keeps loadMessages' fill loop from issuing the paged + // request itself, so the rejection below is consumed by loadOlderMessages. + const recentMessages = Array.from({ length: 100 }, (_, index) => + buildUserMessage(`m${index + 2}`, 's1', index + 2, 'recent') + ) + sessionClient.restore.mockResolvedValueOnce({ + session: { id: 's1' }, + messages: recentMessages, + nextCursor: { orderSeq: 2, id: 'm2' }, + hasMore: true + }) + sessionClient.listMessagesPage.mockRejectedValueOnce(new Error('offline')) + + await store.loadMessages('s1') + await store.loadOlderMessages() + + expect(store.historyLoadError.value).toBe(true) + expect(store.hasMoreHistory.value).toBe(true) + expect(store.isLoadingHistory.value).toBe(false) + + sessionClient.listMessagesPage.mockResolvedValueOnce({ + messages: [buildUserMessage('m1', 's1', 1, 'older')], + nextCursor: null, + hasMore: false + }) + + await store.loadOlderMessages() + + expect(store.historyLoadError.value).toBe(false) + expect(store.messageIds.value).toEqual(['m1', ...recentMessages.map((message) => message.id)]) + }) + + it('clears the history failure state when switching sessions', async () => { + const { store, sessionClient } = await setupStore() + sessionClient.restore.mockResolvedValueOnce({ + session: { id: 's1' }, + messages: Array.from({ length: 100 }, (_, index) => + buildUserMessage(`m${index + 2}`, 's1', index + 2, 'recent') + ), + nextCursor: { orderSeq: 2, id: 'm2' }, + hasMore: true + }) + sessionClient.listMessagesPage.mockRejectedValueOnce(new Error('offline')) + + await store.loadMessages('s1') + await store.loadOlderMessages() + expect(store.historyLoadError.value).toBe(true) + + store.setCurrentSessionId('s2') + + expect(store.historyLoadError.value).toBe(false) + }) + it('ignores stale older-history results after switching sessions', async () => { const { store, sessionClient } = await setupStore() const recentMessages = Array.from({ length: 100 }, (_, index) => @@ -1216,6 +1271,43 @@ describe('messageStore', () => { expect(store.messageIds.value).toEqual(['m1', 'm2', optimisticId]) }) + it('appends local messages after a high-order paginated window', async () => { + const { store, sessionClient, streamListeners } = await setupStore() + sessionClient.restore.mockResolvedValueOnce({ + session: { id: 's1' }, + nextCursor: { orderSeq: 501, id: 'm501' }, + hasMore: true, + messages: [ + buildUserMessage('m501', 's1', 501, 'one'), + buildUserMessage('m502', 's1', 502, 'two') + ] + }) + + await store.loadMessages('s1') + const optimisticId = store.addOptimisticUserMessage('s1', 'next')! + + expect(store.messageCache.value.get(optimisticId)?.orderSeq).toBe(503) + expect(store.messageIds.value.at(-1)).toBe(optimisticId) + + streamListeners.updated[0]({ + sessionId: 's1', + requestId: 'm504', + messageId: 'm504', + updatedAt: 504, + blocks: [ + { + type: 'content', + content: 'streaming', + status: 'pending', + timestamp: 504 + } + ] + }) + + expect(store.messageCache.value.get('m504')?.orderSeq).toBe(504) + expect(store.messageIds.value).toEqual(['m501', 'm502', optimisticId, 'm504']) + }) + it('falls back to full sort after older history creates an unsorted id window', async () => { const { store, sessionClient, streamListeners } = await setupStore() sessionClient.restore.mockResolvedValueOnce({ @@ -1252,7 +1344,7 @@ describe('messageStore', () => { expect(store.messageIds.value).toEqual(['m1', 'm2', 'm3', 'm4', 'm5']) }) - it('binary-inserts a newly hydrated streaming id when message ids are sorted', async () => { + it('appends a newly hydrated stream after the highest loaded order', async () => { const { store, sessionClient, streamListeners } = await setupStore() sessionClient.restore.mockResolvedValueOnce({ session: { id: 's1' }, @@ -1280,7 +1372,8 @@ describe('messageStore', () => { }) expect(sortSpy).not.toHaveBeenCalled() - expect(store.messageIds.value).toEqual(['m1', 'm2', 'm3']) + expect(store.messageCache.value.get('m2')?.orderSeq).toBe(4) + expect(store.messageIds.value).toEqual(['m1', 'm3', 'm2']) }) it('evicts the least recently used parsed message entry after 1024 records', async () => { @@ -1383,6 +1476,32 @@ describe('messageStore', () => { expect(updatedBlocks[0]).toBe(firstBlocks[0]) }) + it('reuses validated stream blocks without parsing the serialized record again', async () => { + const { store, streamListeners } = await setupStore() + await store.loadMessages('s1') + const blocks = [ + { + type: 'content' as const, + content: 'streaming', + status: 'pending' as const, + timestamp: 1 + } + ] + + streamListeners.updated[0]({ + sessionId: 's1', + requestId: 'm1', + messageId: 'm1', + updatedAt: 1, + blocks + }) + + const record = store.messageCache.value.get('m1')! + const parsedBlocks = store.getAssistantMessageBlocks(record) + expect(parsedBlocks[0]).toBe(blocks[0]) + expect(store.getAssistantMessageBlocks(record)).toBe(parsedBlocks) + }) + it('does not reuse stable assistant blocks when shallow payload fields change', async () => { const { store, sessionClient, streamListeners } = await setupStore() sessionClient.restore.mockResolvedValueOnce({ diff --git a/test/renderer/stores/projectStore.test.ts b/test/renderer/stores/projectStore.test.ts index 49dfec5ec3..4e0031071f 100644 --- a/test/renderer/stores/projectStore.test.ts +++ b/test/renderer/stores/projectStore.test.ts @@ -1,281 +1,230 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const setupStore = async (overrides?: { - recentProjects?: Array<{ path: string; name: string; icon: string | null; exists: boolean }> - defaultProjectPath?: string | null -}) => { - vi.resetModules() +const environment = (path: string, status: 'active' | 'archived' | 'removed' = 'active') => ({ + path, + name: path.split('/').pop() ?? path, + sessionCount: 1, + lastUsedAt: 100, + isTemp: false, + exists: true, + status, + sortOrder: 0, + archivedAt: status === 'archived' ? 100 : null, + removedAt: status === 'removed' ? 100 : null +}) - const defaultProjectPathListeners: Array< - (payload: { path: string | null; version: number }) => void - > = [] +const snapshot = (version: number, paths: string[] = ['/work/recent']) => ({ + version, + projects: paths.map((path) => ({ path, name: path.split('/').pop()!, icon: null, exists: true })), + environments: paths.map((path) => environment(path)), + archivedEnvironments: [], + removedEnvironments: [], + defaultProjectPath: null +}) + +const deferred = () => { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise ((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +async function setupStore() { + vi.resetModules() + const defaultListeners: Array<(payload: { path: string | null; version: number }) => void> = [] const environmentListeners: Array< (payload: { - action: 'reorder' | 'archive' | 'restore' | 'remove' + action: 'reorder' | 'archive' | 'restore' | 'remove' | 'select' path: string | null version: number }) => void > = [] - const projectPresenter = { - getRecentProjects: vi - .fn() - .mockResolvedValue( - overrides?.recentProjects ?? [ - { path: '/work/recent', name: 'recent', icon: null, exists: true } - ] - ), - getEnvironments: vi.fn().mockResolvedValue([]), + const projectClient = { + getSnapshot: vi.fn().mockResolvedValue(snapshot(1)), reorderEnvironments: vi.fn().mockResolvedValue({ updated: true }), archiveEnvironment: vi.fn().mockResolvedValue({ updated: true }), restoreEnvironment: vi.fn().mockResolvedValue({ updated: true }), removeEnvironment: vi.fn().mockResolvedValue({ clearedSessionIds: ['s1'] }), openDirectory: vi.fn().mockResolvedValue(undefined), - pathExists: vi.fn().mockResolvedValue(true), - selectDirectory: vi.fn().mockResolvedValue(null) + selectDirectory: vi.fn().mockResolvedValue(null), + onEnvironmentsChanged: vi.fn((listener) => { + environmentListeners.push(listener) + return () => undefined + }) } const configClient = { - getDefaultProjectPath: vi.fn().mockResolvedValue(overrides?.defaultProjectPath ?? null), - setDefaultProjectPath: vi.fn().mockResolvedValue(undefined), - onDefaultProjectPathChanged: vi.fn( - (listener: (payload: { path: string | null; version: number }) => void) => { - defaultProjectPathListeners.push(listener) - return () => undefined - } - ) + setDefaultProjectPath: vi.fn().mockResolvedValue({ path: null }), + onDefaultProjectPathChanged: vi.fn((listener) => { + defaultListeners.push(listener) + return () => undefined + }) } vi.doMock('pinia', async () => { const actual = await vi.importActual ('pinia') - return { - ...actual, - defineStore: (_id: string, setup: () => unknown) => setup - } + return { ...actual, defineStore: (_id: string, setup: () => unknown) => setup } }) vi.doMock('../../../src/renderer/api/ProjectClient', () => ({ - createProjectClient: vi.fn(() => ({ - listRecent: projectPresenter.getRecentProjects, - listEnvironments: projectPresenter.getEnvironments, - reorderEnvironments: projectPresenter.reorderEnvironments, - archiveEnvironment: projectPresenter.archiveEnvironment, - restoreEnvironment: projectPresenter.restoreEnvironment, - removeEnvironment: projectPresenter.removeEnvironment, - openDirectory: projectPresenter.openDirectory, - pathExists: projectPresenter.pathExists, - selectDirectory: projectPresenter.selectDirectory, - onEnvironmentsChanged: vi.fn( - ( - listener: (payload: { - action: 'reorder' | 'archive' | 'restore' | 'remove' - path: string | null - version: number - }) => void - ) => { - environmentListeners.push(listener) - return () => undefined - } - ) - })) + createProjectClient: vi.fn(() => projectClient) })) vi.doMock('../../../src/renderer/api/ConfigClient', () => ({ createConfigClient: vi.fn(() => configClient) })) const { useProjectStore } = await import('@/stores/ui/project') - const store = useProjectStore() - const emitDefaultProjectPathChanged = (path: string | null) => { - for (const listener of defaultProjectPathListeners) { - listener({ - path, - version: 1 - }) - } - } - const emitProjectEnvironmentsChanged = ( - payload: Parameters<(typeof environmentListeners)[number]>[0] - ) => { - for (const listener of environmentListeners) { - listener(payload) - } - } - return { - store, - projectPresenter, + store: useProjectStore(), + projectClient, configClient, - emitDefaultProjectPathChanged, - emitProjectEnvironmentsChanged + emitEnvironment: (version: number) => + environmentListeners.forEach((listener) => + listener({ action: 'archive', path: '/work/a', version }) + ), + emitDefault: (version: number) => + defaultListeners.forEach((listener) => listener({ path: '/work/a', version })) } } -describe('projectStore default project handling', () => { - beforeEach(() => { - vi.clearAllMocks() - }) +describe('projectStore snapshot ownership', () => { + beforeEach(() => vi.clearAllMocks()) - it('applies the default directory and injects a synthetic project when it is not recent', async () => { - const { store } = await setupStore({ - recentProjects: [{ path: '/work/recent', name: 'recent', icon: null, exists: true }], + it('commits projects, environments, and default path from one snapshot', async () => { + const { store, projectClient } = await setupStore() + projectClient.getSnapshot.mockResolvedValue({ + ...snapshot(2, ['/work/recent']), + environments: [environment('/work/active')], + archivedEnvironments: [environment('/work/archived', 'archived')], + removedEnvironments: [environment('/work/removed', 'removed')], defaultProjectPath: '/work/default' }) - await store.fetchProjects() + await store.refreshProjectSnapshot() expect(store.defaultProjectPath.value).toBe('/work/default') - expect(store.selectedProject.value?.path).toBe('/work/default') - expect(store.projects.value[0]).toMatchObject({ - path: '/work/default', - name: 'default', - isSynthetic: true - }) + expect(store.projects.value.map((project) => project.path)).toEqual([ + '/work/default', + '/work/recent' + ]) + expect(store.environments.value.map((item) => item.path)).toEqual(['/work/active']) + expect(store.archivedEnvironments.value.map((item) => item.path)).toEqual(['/work/archived']) + expect(store.removedEnvironments.value.map((item) => item.path)).toEqual(['/work/removed']) }) - it('keeps bootstrap chat workspace metadata when the default changes', async () => { - const { store, emitDefaultProjectPathChanged } = await setupStore({ - recentProjects: [], - defaultProjectPath: '/work/default' - }) - - store.applyBootstrapDefaultProjectPath('/work/default', '/work/default') + it('coalesces concurrent refresh requests into one snapshot read', async () => { + const { store, projectClient } = await setupStore() + const pending = deferred >() + projectClient.getSnapshot.mockReturnValueOnce(pending.promise) - expect(store.defaultChatWorkspacePath.value).toBe('/work/default') + const first = store.refreshProjectSnapshot() + const second = store.fetchProjects() + expect(projectClient.getSnapshot).toHaveBeenCalledTimes(1) - emitDefaultProjectPathChanged('/work/custom') - - expect(store.defaultProjectPath.value).toBe('/work/custom') - expect(store.defaultChatWorkspacePath.value).toBe('/work/default') + pending.resolve(snapshot(1)) + await Promise.all([first, second]) + expect(projectClient.getSnapshot).toHaveBeenCalledTimes(1) }) - it('keeps a manual project selection when the default project changes later', async () => { - const { store, emitDefaultProjectPathChanged } = await setupStore({ - recentProjects: [{ path: '/work/recent', name: 'recent', icon: null, exists: true }], - defaultProjectPath: '/work/default' - }) + it('queues a newer event version received while a snapshot is in flight', async () => { + const { store, projectClient, emitEnvironment } = await setupStore() + const first = deferred >() + projectClient.getSnapshot + .mockImplementationOnce(() => first.promise) + .mockResolvedValueOnce(snapshot(2)) - await store.fetchProjects() - store.selectProject('/work/manual') + const request = store.refreshProjectSnapshot() + emitEnvironment(2) + first.resolve(snapshot(1)) + await request - emitDefaultProjectPathChanged('/work/changed-default') + expect(projectClient.getSnapshot).toHaveBeenCalledTimes(2) + expect(store.projects.value.map((project) => project.path)).toEqual(['/work/recent']) + }) - expect(store.defaultProjectPath.value).toBe('/work/changed-default') - expect(store.selectedProject.value?.path).toBe('/work/manual') - expect(store.projects.value.map((project) => project.path)).toEqual([ - '/work/changed-default', - '/work/manual', - '/work/recent' - ]) + it('retries a newer requested snapshot when the older in-flight read fails', async () => { + const { store, projectClient, emitEnvironment } = await setupStore() + const first = deferred >() + projectClient.getSnapshot + .mockImplementationOnce(() => first.promise) + .mockResolvedValueOnce(snapshot(2, ['/work/after-retry'])) + + const request = store.refreshProjectSnapshot() + emitEnvironment(2) + first.reject(new Error('stale snapshot read failed')) + await request + + expect(projectClient.getSnapshot).toHaveBeenCalledTimes(2) + expect(store.projects.value.map((project) => project.path)).toEqual(['/work/after-retry']) + expect(store.error.value).toBeNull() }) - it('updates the selected project when the default selection source is still active', async () => { - const { store, emitDefaultProjectPathChanged } = await setupStore({ - recentProjects: [{ path: '/work/recent', name: 'recent', icon: null, exists: true }], - defaultProjectPath: '/work/default' - }) + it('ignores stale versioned events after a newer snapshot has committed', async () => { + const { store, projectClient, emitDefault } = await setupStore() + projectClient.getSnapshot.mockResolvedValue(snapshot(5)) + await store.refreshProjectSnapshot() + emitDefault(4) + await Promise.resolve() - await store.fetchProjects() + expect(projectClient.getSnapshot).toHaveBeenCalledTimes(1) + }) + + it('does not spin when an event arrives before its snapshot projection', async () => { + const { store, projectClient, emitEnvironment } = await setupStore() + await store.refreshProjectSnapshot() + projectClient.getSnapshot.mockResolvedValue(snapshot(2, ['/work/not-yet-projected'])) - emitDefaultProjectPathChanged('/work/changed-default') + emitEnvironment(3) + await store.refreshProjectSnapshot() - expect(store.selectedProject.value?.path).toBe('/work/changed-default') + expect(projectClient.getSnapshot).toHaveBeenCalledTimes(2) + expect(store.projects.value.map((project) => project.path)).toEqual(['/work/recent']) }) - it('keeps an explicit clear selection instead of reapplying the default directory', async () => { - const { store, emitDefaultProjectPathChanged } = await setupStore({ - recentProjects: [{ path: '/work/recent', name: 'recent', icon: null, exists: true }], - defaultProjectPath: '/work/default' + it('does not let a delayed bootstrap default project path overwrite a committed snapshot', async () => { + const { store, projectClient } = await setupStore() + projectClient.getSnapshot.mockResolvedValue({ + ...snapshot(2, ['/work/current']), + defaultProjectPath: '/work/current' }) - await store.fetchProjects() - store.selectProject(null, 'manual') + await store.refreshProjectSnapshot() + store.applyBootstrapDefaultProjectPath('/work/stale-bootstrap', '/work/bootstrap-workspace') - expect(store.selectedProjectPath.value).toBeNull() - expect(store.selectedProject.value).toBeUndefined() - - emitDefaultProjectPathChanged('/work/changed-default') - - expect(store.defaultProjectPath.value).toBe('/work/changed-default') - expect(store.selectedProjectPath.value).toBeNull() - expect(store.selectedProject.value).toBeUndefined() + expect(store.defaultProjectPath.value).toBe('/work/current') + expect(store.defaultChatWorkspacePath.value).toBe('/work/bootstrap-workspace') + expect(store.projects.value.map((project) => project.path)).toEqual(['/work/current']) }) - it('reorders active environments and removes deleted recent projects locally', async () => { - const { store, projectPresenter } = await setupStore({ - recentProjects: [ - { path: '/work/a', name: 'a', icon: null, exists: true }, - { path: '/work/b', name: 'b', icon: null, exists: true } - ] - }) - store.environments.value = [ - { - path: '/work/a', - name: 'a', - sessionCount: 1, - lastUsedAt: 100, - isTemp: false, - exists: true, - status: 'active', - sortOrder: 0, - archivedAt: null, - removedAt: null - }, - { - path: '/work/b', - name: 'b', - sessionCount: 1, - lastUsedAt: 200, - isTemp: false, - exists: true, - status: 'active', - sortOrder: 1, - archivedAt: null, - removedAt: null - } - ] - projectPresenter.getEnvironments.mockResolvedValueOnce(store.environments.value) - - await store.reorderEnvironments(['/work/b', '/work/missing', '/work/a']) - - expect(projectPresenter.reorderEnvironments).toHaveBeenCalledWith(['/work/b', '/work/a']) - - projectPresenter.reorderEnvironments.mockClear() - await store.reorderEnvironments(['/work/missing']) - expect(projectPresenter.reorderEnvironments).not.toHaveBeenCalled() - - await store.fetchProjects() - store.selectProject('/work/a') - - await expect(store.removeEnvironment('/work/a')).resolves.toEqual({ - clearedSessionIds: ['s1'] - }) - expect(projectPresenter.removeEnvironment).toHaveBeenCalledWith('/work/a') - expect(store.projects.value.some((project) => project.path === '/work/a')).toBe(false) - expect(store.selectedProjectPath.value).toBeNull() - }) + it('refreshes from the snapshot after a project mutation', async () => { + const { store, projectClient } = await setupStore() + projectClient.getSnapshot.mockResolvedValue(snapshot(1, ['/work/a', '/work/b'])) + await store.refreshProjectSnapshot() + projectClient.removeEnvironment.mockResolvedValue({ clearedSessionIds: ['s1'] }) + projectClient.getSnapshot.mockResolvedValue(snapshot(2, ['/work/b'])) - it('refreshes project data when environments change in another window', async () => { - const { store, projectPresenter, emitProjectEnvironmentsChanged } = await setupStore({ - recentProjects: [ - { path: '/work/a', name: 'a', icon: null, exists: true }, - { path: '/work/b', name: 'b', icon: null, exists: true } - ] - }) + await expect(store.removeEnvironment('/work/a')).resolves.toEqual({ clearedSessionIds: ['s1'] }) + expect(store.projects.value.map((project) => project.path)).toEqual(['/work/b']) + }) - await store.fetchProjects() - store.selectProject('/work/a') - projectPresenter.getRecentProjects.mockResolvedValueOnce([ - { path: '/work/b', name: 'b', icon: null, exists: true } - ]) + it('does not commit a mutation refresh ahead of a newer environment event', async () => { + const { store, projectClient, emitEnvironment } = await setupStore() + await store.refreshProjectSnapshot() - emitProjectEnvironmentsChanged({ - action: 'remove', - path: '/work/a', - version: 1 - }) + const mutationSnapshot = deferred >() + projectClient.removeEnvironment.mockResolvedValue({ clearedSessionIds: ['s1'] }) + projectClient.getSnapshot + .mockImplementationOnce(() => mutationSnapshot.promise) + .mockResolvedValueOnce(snapshot(3, ['/work/after-event'])) - await new Promise((resolve) => setTimeout(resolve, 0)) + const mutation = store.removeEnvironment('/work/a') + await Promise.resolve() + emitEnvironment(3) + mutationSnapshot.resolve(snapshot(2, ['/work/after-mutation'])) - expect(projectPresenter.getRecentProjects).toHaveBeenCalled() - expect(projectPresenter.getEnvironments).toHaveBeenCalled() - expect(store.projects.value.map((project) => project.path)).toEqual(['/work/b']) - expect(store.selectedProjectPath.value).toBeNull() + await expect(mutation).resolves.toEqual({ clearedSessionIds: ['s1'] }) + expect(projectClient.getSnapshot).toHaveBeenCalledTimes(3) + expect(store.projects.value.map((project) => project.path)).toEqual(['/work/after-event']) }) }) diff --git a/test/renderer/stores/sessionStore.test.ts b/test/renderer/stores/sessionStore.test.ts index 7b6e6814d5..b6f1de5c42 100644 --- a/test/renderer/stores/sessionStore.test.ts +++ b/test/renderer/stores/sessionStore.test.ts @@ -26,16 +26,19 @@ type SetupStoreOptions = { | 'skills' | 'plugins' | null + runtimeIdentity?: Promise<{ windowId: number; webContentsId: number }> } const SIDEBAR_GROUP_MODE_KEY = 'sidebar_group_mode' function createDeferred () { let resolve!: (value: T) => void - const promise = new Promise ((innerResolve) => { + let reject!: (reason?: unknown) => void + const promise = new Promise ((innerResolve, innerReject) => { resolve = innerResolve + reject = innerReject }) - return { promise, resolve } + return { promise, resolve, reject } } afterEach(() => { @@ -82,7 +85,16 @@ const setupStore = async (options: SetupStoreOptions = {}) => { .mockImplementation(async (_sessionId: string, providerId: string, modelId: string) => createSession({ providerId, modelId }) ), - toggleSessionPinned: vi.fn().mockResolvedValue(undefined), + renameSession: vi + .fn() + .mockImplementation(async (_sessionId: string, title: string) => + createSession({ title, revision: 2 }) + ), + toggleSessionPinned: vi + .fn() + .mockImplementation(async (_sessionId: string, pinned: boolean) => + createSession({ isPinned: pinned, revision: 2 }) + ), activate: vi.fn().mockResolvedValue({ activated: true }), deactivate: vi.fn().mockResolvedValue({ deactivated: true }), onUpdated: vi.fn((listener: (payload: any) => void) => { @@ -309,10 +321,12 @@ const setupStore = async (options: SetupStoreOptions = {}) => { ...((window as any).deepchat ?? {}), invoke: vi.fn(async (routeName: string) => { if (routeName === 'window.getRuntimeIdentity') { - return { - windowId: 1, - webContentsId: 1 - } + return ( + options.runtimeIdentity ?? { + windowId: 1, + webContentsId: 1 + } + ) } return {} @@ -576,13 +590,16 @@ describe('sessionStore.getFilteredGroups', () => { }) it('keeps pinned sessions alphabetically sorted after pinning', async () => { - const { store } = await setupStore() + const { store, sessionClient } = await setupStore() store.sessions.value = [ createSession({ id: 'bravo-pinned', title: 'Bravo', isPinned: true, updatedAt: 10 }), createSession({ id: 'target', title: 'Zulu', isPinned: false, updatedAt: 5 }), createSession({ id: 'grouped-alpha', title: 'Alpha', isPinned: false, updatedAt: 20 }) ] + sessionClient.toggleSessionPinned.mockResolvedValueOnce( + createSession({ id: 'target', title: 'Zulu', isPinned: true, revision: 2 }) + ) await store.toggleSessionPinned('target', true) @@ -593,7 +610,7 @@ describe('sessionStore.getFilteredGroups', () => { }) it('keeps grouped sessions alphabetically sorted after unpinning', async () => { - const { store } = await setupStore({ + const { store, sessionClient } = await setupStore({ initialSettings: { [SIDEBAR_GROUP_MODE_KEY]: 'time' } @@ -611,6 +628,9 @@ describe('sessionStore.getFilteredGroups', () => { }) ] + sessionClient.toggleSessionPinned.mockResolvedValueOnce( + createSession({ id: 'target', title: 'Zulu', isPinned: false, revision: 2 }) + ) await store.toggleSessionPinned('target', false) const groupedIds = store @@ -620,6 +640,96 @@ describe('sessionStore.getFilteredGroups', () => { ) expect(groupedIds).toEqual(['grouped-existing', 'target']) }) + + it('builds pinned and project groups from only the requested agent sessions', async () => { + const { store } = await setupStore() + const now = Date.now() + + await store.fetchSessions() + store.sessions.value = [ + createSession({ + id: 'agent-a-pinned', + title: 'Zulu pinned', + agentId: 'agent-a', + isPinned: true, + updatedAt: now + }), + createSession({ + id: 'agent-b-pinned', + title: 'Alpha pinned', + agentId: 'agent-b', + isPinned: true, + updatedAt: now + }), + createSession({ + id: 'agent-a-project', + title: 'Agent A project', + agentId: 'agent-a', + projectDir: '/projects/agent-a', + updatedAt: now + }), + createSession({ + id: 'agent-b-project', + title: 'Agent B project', + agentId: 'agent-b', + projectDir: '/projects/agent-b', + updatedAt: now + }) + ] + + expect(store.getPinnedSessions('agent-a').map((session: { id: string }) => session.id)).toEqual( + ['agent-a-pinned'] + ) + expect( + store.getFilteredGroups('agent-a').map((group: SessionListTestItem) => ({ + id: group.id, + sessionIds: group.sessions.map((session) => session.id) + })) + ).toEqual([{ id: '/projects/agent-a', sessionIds: ['agent-a-project'] }]) + }) + + it('builds time groups from only the requested agent sessions', async () => { + const { store } = await setupStore({ + initialSettings: { + [SIDEBAR_GROUP_MODE_KEY]: 'time' + } + }) + const now = Date.now() + + await store.fetchSessions() + store.sessions.value = [ + createSession({ + id: 'agent-a-today', + agentId: 'agent-a', + updatedAt: now + }), + createSession({ + id: 'agent-a-yesterday', + agentId: 'agent-a', + updatedAt: now - 86400000 + }), + createSession({ + id: 'agent-b-today', + agentId: 'agent-b', + updatedAt: now + }), + createSession({ + id: 'agent-b-older', + agentId: 'agent-b', + updatedAt: now - 14 * 86400000 + }) + ] + + expect( + store.getFilteredGroups('agent-a').map((group: SessionListTestItem) => ({ + id: group.id, + sessionIds: group.sessions.map((session) => session.id) + })) + ).toEqual([ + { id: 'common.time.today', sessionIds: ['agent-a-today'] }, + { id: 'common.time.yesterday', sessionIds: ['agent-a-yesterday'] } + ]) + }) }) describe('sessionStore group mode preferences', () => { @@ -981,6 +1091,99 @@ describe('sessionStore streaming cleanup', () => { expect(agentStore.setSelectedAgent).toHaveBeenCalledWith('acp-sync') }) + it('does not let a stale bootstrap shell overwrite a newer canonical session snapshot', async () => { + const { store } = await setupStore() + store.sessions.value = [ + createSession({ + id: 'session-sync-1', + title: 'Current title', + isPinned: true, + revision: 3, + updatedAt: 3 + }) + ] + + await store.applyBootstrapShell({ + activeSessionId: 'session-sync-1', + activeSession: createSession({ + id: 'session-sync-1', + title: 'Stale title', + isPinned: false, + revision: 2, + updatedAt: 4 + }) + }) + + expect(store.sessions.value).toEqual([ + expect.objectContaining({ id: 'session-sync-1', title: 'Current title', revision: 3 }) + ]) + expect(store.activeSession.value).toEqual( + expect.objectContaining({ id: 'session-sync-1', title: 'Current title', revision: 3 }) + ) + }) + + it('keeps canonical, hydrated, and bootstrap session projections on the newest revision', async () => { + const { store } = await setupStore() + const current = createSession({ + id: 'session-sync-1', + title: 'Current title', + status: 'generating', + revision: 3, + updatedAt: 3, + providerId: 'acp', + modelId: 'dimcode' + }) + store.sessions.value = [current, createSession({ id: 'session-other', revision: 1 })] + + await store.applyBootstrapShell({ + activeSessionId: 'session-sync-1', + activeSession: current + }) + store.applyRestoredSession(current) + store.applyRestoredSession( + createSession({ + id: 'session-sync-1', + title: 'Stale title', + status: 'idle', + revision: 2, + updatedAt: 4, + providerId: 'legacy', + modelId: 'legacy-model' + }) + ) + + expect(store.sessions.value.find((session) => session.id === 'session-sync-1')).toEqual( + expect.objectContaining({ title: 'Current title', revision: 3 }) + ) + expect(store.activeSession.value).toEqual( + expect.objectContaining({ + title: 'Current title', + revision: 3, + providerId: 'acp', + modelId: 'dimcode', + status: 'working' + }) + ) + + await store.applyBootstrapShell({ + activeSessionId: 'session-other', + activeSession: createSession({ id: 'session-other', revision: 1 }) + }) + await store.applyBootstrapShell({ + activeSessionId: 'session-sync-1', + activeSession: createSession({ + id: 'session-sync-1', + title: 'Stale bootstrap title', + revision: 2, + updatedAt: 4 + }) + }) + + expect(store.activeSession.value).toEqual( + expect.objectContaining({ id: 'session-sync-1', title: 'Current title', revision: 3 }) + ) + }) + it('clears streaming when bootstrap shell switches the active session', async () => { const { store, clearStreamingState } = await setupStore() store.activeSessionId.value = 'session-a' @@ -1027,6 +1230,83 @@ describe('sessionStore streaming cleanup', () => { expect(pageRouter.goToNewThread).toHaveBeenCalledTimes(1) }) + it('applies only the latest targeted update after runtime identity resolves', async () => { + const runtimeIdentity = createDeferred<{ windowId: number; webContentsId: number }>() + const { store, emitSessionUpdate, pageRouter } = await setupStore({ + runtimeIdentity: runtimeIdentity.promise + }) + store.sessions.value = [createSession({ id: 'session-early' })] + + emitSessionUpdate({ + sessionIds: ['session-early'], + reason: 'activated', + webContentsId: 1, + activeSessionId: 'session-early' + }) + emitSessionUpdate({ + sessionIds: [], + reason: 'deactivated', + webContentsId: 1 + }) + + expect(pageRouter.goToChat).not.toHaveBeenCalled() + expect(pageRouter.goToNewThread).not.toHaveBeenCalled() + + runtimeIdentity.resolve({ windowId: 1, webContentsId: 1 }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(store.activeSessionId.value).toBeNull() + expect(pageRouter.goToNewThread).toHaveBeenCalledTimes(1) + expect(pageRouter.goToChat).not.toHaveBeenCalled() + }) + + it('keeps the current window pending update when another window updates before identity resolves', async () => { + const runtimeIdentity = createDeferred<{ windowId: number; webContentsId: number }>() + const { store, emitSessionUpdate, pageRouter } = await setupStore({ + runtimeIdentity: runtimeIdentity.promise + }) + store.sessions.value = [createSession({ id: 'session-current' })] + + emitSessionUpdate({ + sessionIds: ['session-current'], + reason: 'activated', + webContentsId: 1, + activeSessionId: 'session-current' + }) + emitSessionUpdate({ + sessionIds: ['session-other'], + reason: 'activated', + webContentsId: 2, + activeSessionId: 'session-other' + }) + + runtimeIdentity.resolve({ windowId: 1, webContentsId: 1 }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(store.activeSessionId.value).toBe('session-current') + expect(pageRouter.goToChat).toHaveBeenCalledWith('session-current') + }) + + it('ignores pending targeted updates for another renderer window', async () => { + const runtimeIdentity = createDeferred<{ windowId: number; webContentsId: number }>() + const { store, emitSessionUpdate, pageRouter } = await setupStore({ + runtimeIdentity: runtimeIdentity.promise + }) + store.sessions.value = [createSession({ id: 'session-other' })] + + emitSessionUpdate({ + sessionIds: ['session-other'], + reason: 'activated', + webContentsId: 2, + activeSessionId: 'session-other' + }) + runtimeIdentity.resolve({ windowId: 1, webContentsId: 1 }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(store.activeSessionId.value).toBeNull() + expect(pageRouter.goToChat).not.toHaveBeenCalled() + }) + it('reloads sessions when the session list update event fires', async () => { const { sessionClient, emitSessionUpdate } = await setupStore() @@ -1225,7 +1505,7 @@ describe('sessionStore streaming cleanup', () => { session: createSession({ id: 'session-b', title: 'Session B hydrated' }) }) .mockResolvedValueOnce({ - session: createSession({ id: 'session-a', title: 'Session A latest' }) + session: createSession({ id: 'session-a', title: 'Session A latest', revision: 2 }) }) const firstSelection = store.selectSession('session-a') @@ -1243,6 +1523,86 @@ describe('sessionStore streaming cleanup', () => { expect(store.activeSession.value?.title).toBe('Session A latest') }) + it('does not let a pending close clear a later selected session', async () => { + const { store, sessionClient, pageRouter } = await setupStore() + const deactivation = createDeferred<{ deactivated: boolean }>() + sessionClient.deactivate.mockReturnValueOnce(deactivation.promise) + store.activeSessionId.value = 'session-a' + store.sessions.value = [ + createSession({ id: 'session-a' }), + createSession({ id: 'session-b', agentId: 'dimcode' }) + ] + + const close = store.closeSession() + await Promise.resolve() + await store.selectSession('session-b') + deactivation.resolve({ deactivated: true }) + await close + + expect(store.activeSessionId.value).toBe('session-b') + expect(pageRouter.goToChat).toHaveBeenCalledWith('session-b') + expect(pageRouter.goToNewThread).not.toHaveBeenCalled() + }) + + it('does not let a stale select failure replace a later selection', async () => { + const { store, sessionClient } = await setupStore() + const firstActivation = createDeferred<{ activated: boolean }>() + sessionClient.activate.mockReturnValueOnce(firstActivation.promise) + + const firstSelect = store.selectSession('session-a') + await Promise.resolve() + await store.selectSession('session-b') + firstActivation.reject(new Error('stale activation failure')) + await firstSelect + + expect(store.activeSessionId.value).toBe('session-b') + expect(store.error.value).toBeNull() + }) + + it('keeps a created session in the list without reclaiming a later selection', async () => { + const { store, sessionClient, pageRouter } = await setupStore() + const pendingCreation = createDeferred<{ session: ReturnType }>() + sessionClient.create.mockReturnValueOnce(pendingCreation.promise) + store.sessions.value = [createSession({ id: 'session-b', agentId: 'dimcode' })] + + const creation = store.createSession({ + agentId: 'deepchat', + message: '', + projectDir: '/tmp/workspace', + providerId: 'openai', + modelId: 'gpt-4' + }) + await Promise.resolve() + await store.selectSession('session-b') + pendingCreation.resolve({ session: createSession({ id: 'session-created', title: 'Created' }) }) + await creation + + expect(store.activeSessionId.value).toBe('session-b') + expect(store.sessions.value.map((session) => session.id)).toContain('session-created') + expect(pageRouter.goToChat).not.toHaveBeenCalledWith('session-created') + }) + + it('does not let a stale create failure replace a later selection error state', async () => { + const { store, sessionClient } = await setupStore() + const pendingCreation = createDeferred<{ session: ReturnType }>() + sessionClient.create.mockReturnValueOnce(pendingCreation.promise) + + const creation = store.createSession({ + agentId: 'deepchat', + message: '', + projectDir: '/tmp/workspace', + providerId: 'openai', + modelId: 'gpt-4' + }) + await Promise.resolve() + await store.selectSession('session-b') + pendingCreation.reject(new Error('stale create failure')) + await expect(creation).rejects.toThrow('stale create failure') + + expect(store.activeSessionId.value).toBe('session-b') + expect(store.error.value).toBeNull() + }) + it('updates the local session status immediately from the session status event', async () => { const { store, emitSessionStatusChange, invalidateRecentSessionView } = await setupStore() store.sessions.value = [createSession({ id: 'session-status', status: 'none' })] @@ -1250,7 +1610,8 @@ describe('sessionStore streaming cleanup', () => { emitSessionStatusChange({ sessionId: 'session-status', - status: 'generating' + status: 'generating', + version: 1 }) expect(store.activeSession.value?.status).toBe('working') @@ -1258,12 +1619,67 @@ describe('sessionStore streaming cleanup', () => { emitSessionStatusChange({ sessionId: 'session-status', - status: 'idle' + status: 'idle', + version: 2 }) expect(store.activeSession.value?.status).toBe('none') }) + it('does not let a stale restore snapshot overwrite a newer idle event', async () => { + const { store, emitSessionStatusChange } = await setupStore() + store.activeSessionId.value = 'session-status' + store.sessions.value = [createSession({ id: 'session-status', status: 'working' })] + + emitSessionStatusChange({ + sessionId: 'session-status', + status: 'idle', + version: 2 + }) + store.applyRestoredSession( + createSession({ id: 'session-status', status: 'generating', updatedAt: 2 }) + ) + + expect(store.activeSession.value?.status).toBe('none') + }) + + it('does not let a stale restore snapshot overwrite a newer generating event', async () => { + const { store, emitSessionStatusChange } = await setupStore() + store.activeSessionId.value = 'session-status' + store.sessions.value = [createSession({ id: 'session-status', status: 'none' })] + + emitSessionStatusChange({ + sessionId: 'session-status', + status: 'generating', + version: 2 + }) + store.applyRestoredSession( + createSession({ id: 'session-status', status: 'idle', updatedAt: 2 }) + ) + + expect(store.activeSession.value?.status).toBe('working') + }) + + it('ignores status events older than the latest observed version', async () => { + const { store, emitSessionStatusChange, invalidateRecentSessionView } = await setupStore() + store.activeSessionId.value = 'session-status' + store.sessions.value = [createSession({ id: 'session-status', status: 'none' })] + + emitSessionStatusChange({ + sessionId: 'session-status', + status: 'generating', + version: 2 + }) + emitSessionStatusChange({ + sessionId: 'session-status', + status: 'idle', + version: 1 + }) + + expect(store.activeSession.value?.status).toBe('working') + expect(invalidateRecentSessionView).toHaveBeenCalledTimes(1) + }) + it('purges message tracking when a session is permanently removed', async () => { const { store, emitSessionUpdate, invalidateRecentSessionView, purgeSessionTracking } = await setupStore() @@ -1281,6 +1697,243 @@ describe('sessionStore streaming cleanup', () => { }) describe('sessionStore pagination', () => { + it('keeps the newest overlapping session refresh result', async () => { + const { store, sessionClient } = await setupStore() + store.sessions.value = [ + createSession({ id: 'session-refresh', title: 'Original', updatedAt: 1 }) + ] + const firstRefresh = createDeferred []>() + const secondRefresh = createDeferred []>() + sessionClient.getLightweightByIds + .mockReturnValueOnce(firstRefresh.promise) + .mockReturnValueOnce(secondRefresh.promise) + + const firstRequest = store.refreshSessionsByIds(['session-refresh']) + const secondRequest = store.refreshSessionsByIds(['session-refresh']) + + secondRefresh.resolve([createSession({ id: 'session-refresh', title: 'New', updatedAt: 3 })]) + await secondRequest + firstRefresh.resolve([createSession({ id: 'session-refresh', title: 'Old', updatedAt: 2 })]) + await firstRequest + + expect(store.sessions.value).toEqual([ + expect.objectContaining({ id: 'session-refresh', title: 'New', updatedAt: 3 }) + ]) + }) + + it('commits concurrent targeted refreshes for disjoint session IDs', async () => { + const { store, sessionClient } = await setupStore() + const refreshA = createDeferred []>() + const refreshB = createDeferred []>() + sessionClient.getLightweightByIds + .mockReturnValueOnce(refreshA.promise) + .mockReturnValueOnce(refreshB.promise) + + const requestA = store.refreshSessionsByIds(['session-a']) + const requestB = store.refreshSessionsByIds(['session-b']) + + refreshB.resolve([createSession({ id: 'session-b', title: 'B', updatedAt: 3 })]) + await requestB + refreshA.resolve([createSession({ id: 'session-a', title: 'A', updatedAt: 2 })]) + await requestA + + expect(store.sessions.value).toEqual([ + expect.objectContaining({ id: 'session-a', title: 'A', updatedAt: 2 }), + expect.objectContaining({ id: 'session-b', title: 'B', updatedAt: 3 }) + ]) + }) + + it('keeps non-overlapping rows from an older targeted batch', async () => { + const { store, sessionClient } = await setupStore() + const firstRefresh = createDeferred []>() + const secondRefresh = createDeferred []>() + sessionClient.getLightweightByIds + .mockReturnValueOnce(firstRefresh.promise) + .mockReturnValueOnce(secondRefresh.promise) + + const firstRequest = store.refreshSessionsByIds(['session-a', 'session-b']) + const secondRequest = store.refreshSessionsByIds(['session-b', 'session-c']) + + secondRefresh.resolve([ + createSession({ id: 'session-b', title: 'New B', updatedAt: 4 }), + createSession({ id: 'session-c', title: 'C', updatedAt: 4 }) + ]) + await secondRequest + firstRefresh.resolve([ + createSession({ id: 'session-a', title: 'A', updatedAt: 3 }), + createSession({ id: 'session-b', title: 'Old B', updatedAt: 2 }) + ]) + await firstRequest + + // sortSessions orders the flat list by title collator, not updatedAt. + expect(store.sessions.value).toEqual([ + expect.objectContaining({ id: 'session-a', title: 'A', updatedAt: 3 }), + expect.objectContaining({ id: 'session-c', title: 'C', updatedAt: 4 }), + expect.objectContaining({ id: 'session-b', title: 'New B', updatedAt: 4 }) + ]) + }) + + it('does not let an older session update overwrite a newer local session', async () => { + const { store, sessionClient } = await setupStore() + store.sessions.value = [createSession({ id: 'session-refresh', title: 'New', updatedAt: 3 })] + sessionClient.getLightweightByIds.mockResolvedValueOnce([ + createSession({ id: 'session-refresh', title: 'Old', updatedAt: 2 }) + ]) + + await store.refreshSessionsByIds(['session-refresh']) + + expect(store.sessions.value).toEqual([ + expect.objectContaining({ id: 'session-refresh', title: 'New', updatedAt: 3 }) + ]) + }) + + it('uses durable revision to order snapshots with the same timestamp', async () => { + const { store, sessionClient } = await setupStore() + store.sessions.value = [ + createSession({ + id: 'session-refresh', + title: 'Current title', + isPinned: true, + updatedAt: 3, + revision: 10 + }) + ] + sessionClient.getLightweightByIds.mockResolvedValueOnce([ + createSession({ + id: 'session-refresh', + title: 'New title', + isPinned: false, + updatedAt: 3, + revision: 11 + }) + ]) + + await store.refreshSessionsByIds(['session-refresh']) + + expect(store.sessions.value).toEqual([ + expect.objectContaining({ + id: 'session-refresh', + title: 'New title', + isPinned: false, + updatedAt: 3, + revision: 11 + }) + ]) + }) + + it('rejects a lower durable revision even when its timestamp is newer', async () => { + const { store, sessionClient } = await setupStore() + store.sessions.value = [ + createSession({ id: 'session-refresh', title: 'Current', updatedAt: 3, revision: 11 }) + ] + sessionClient.getLightweightByIds.mockResolvedValueOnce([ + createSession({ id: 'session-refresh', title: 'Stale', updatedAt: 4, revision: 10 }) + ]) + + await store.refreshSessionsByIds(['session-refresh']) + + expect(store.sessions.value[0]).toMatchObject({ title: 'Current', revision: 11 }) + }) + + it('does not reinsert a deleted session from a pending targeted refresh', async () => { + const { store, sessionClient, emitSessionUpdate } = await setupStore() + const pendingRefresh = createDeferred []>() + sessionClient.getLightweightByIds.mockReturnValueOnce(pendingRefresh.promise) + store.sessions.value = [createSession({ id: 'session-deleted' })] + + const refresh = store.refreshSessionsByIds(['session-deleted']) + await Promise.resolve() + emitSessionUpdate({ reason: 'deleted', sessionIds: ['session-deleted'] }) + pendingRefresh.resolve([createSession({ id: 'session-deleted', title: 'Stale response' })]) + await refresh + + expect(store.sessions.value).toEqual([]) + expect(store.error.value).toBeNull() + }) + + it('does not reinsert a deleted session from a pending first-page response', async () => { + const { store, sessionClient, emitSessionUpdate } = await setupStore() + const pendingFirstPage = createDeferred<{ + items: ReturnType [] + hasMore: boolean + nextCursor: null + }>() + sessionClient.listLightweight.mockReturnValueOnce(pendingFirstPage.promise) + + const fetch = store.fetchSessions() + await Promise.resolve() + emitSessionUpdate({ reason: 'deleted', sessionIds: ['session-deleted'] }) + pendingFirstPage.resolve({ + items: [createSession({ id: 'session-deleted', title: 'Stale response' })], + hasMore: false, + nextCursor: null + }) + await fetch + + expect(store.sessions.value).toEqual([]) + expect(store.loading.value).toBe(false) + expect(store.loadingMore.value).toBe(false) + expect(store.error.value).toBeNull() + }) + + it('preserves a targeted update that commits while an older first page is pending', async () => { + const { store, sessionClient } = await setupStore() + const pendingFirstPage = createDeferred<{ + items: ReturnType [] + hasMore: boolean + nextCursor: null + }>() + sessionClient.listLightweight.mockReturnValueOnce(pendingFirstPage.promise) + + const fetch = store.fetchSessions() + await vi.waitFor(() => expect(sessionClient.listLightweight).toHaveBeenCalledTimes(1)) + + sessionClient.getLightweightByIds.mockResolvedValueOnce([ + createSession({ id: 'session-refresh', title: 'Targeted update', updatedAt: 3 }) + ]) + await store.refreshSessionsByIds(['session-refresh']) + + pendingFirstPage.resolve({ + items: [createSession({ id: 'session-refresh', title: 'Stale first page', updatedAt: 2 })], + hasMore: false, + nextCursor: null + }) + await fetch + + expect(store.sessions.value).toEqual([ + expect.objectContaining({ + id: 'session-refresh', + title: 'Targeted update', + updatedAt: 3 + }) + ]) + }) + + it('invalidates a pending targeted update after a new full list refresh', async () => { + const { store, sessionClient } = await setupStore() + const pendingTargetedUpdate = createDeferred []>() + sessionClient.getLightweightByIds.mockReturnValueOnce(pendingTargetedUpdate.promise) + sessionClient.listLightweight.mockResolvedValueOnce({ + items: [createSession({ id: 'session-current', title: 'Current', updatedAt: 40 })], + hasMore: false, + nextCursor: null + }) + + const targetedRefresh = store.refreshSessionsByIds(['session-stale']) + await Promise.resolve() + await store.fetchSessions() + + pendingTargetedUpdate.resolve([ + createSession({ id: 'session-stale', title: 'Stale', updatedAt: 20 }) + ]) + await targetedRefresh + + expect(store.sessions.value).toEqual([ + expect.objectContaining({ id: 'session-current', title: 'Current', updatedAt: 40 }) + ]) + expect(store.error.value).toBeNull() + }) + it('prioritizes the active bootstrap session when the first page starts after shell hydration', async () => { const { store, sessionClient } = await setupStore() @@ -1337,6 +1990,160 @@ describe('sessionStore pagination', () => { expect(sessionClient.listLightweight).toHaveBeenCalledTimes(2) }) + it('invalidates a pending pagination response when an initial refresh starts', async () => { + const { store, sessionClient } = await setupStore() + const stalePage = createDeferred<{ + items: ReturnType [] + hasMore: boolean + nextCursor: { updatedAt: number; id: string } | null + }>() + + sessionClient.listLightweight + .mockResolvedValueOnce({ + items: [createSession({ id: 'session-a', title: 'Alpha', updatedAt: 30 })], + hasMore: true, + nextCursor: { updatedAt: 30, id: 'session-a' } + }) + .mockReturnValueOnce(stalePage.promise) + .mockResolvedValueOnce({ + items: [createSession({ id: 'session-c', title: 'Current', updatedAt: 40 })], + hasMore: true, + nextCursor: { updatedAt: 40, id: 'session-c' } + }) + await store.fetchSessions() + + const loadMore = store.loadNextPage() + await Promise.resolve() + await store.fetchSessions() + + stalePage.resolve({ + items: [createSession({ id: 'session-b', title: 'Stale', updatedAt: 20 })], + hasMore: false, + nextCursor: null + }) + await loadMore + + expect(store.sessions.value.map((session) => session.id)).toEqual(['session-c']) + expect(store.hasMore.value).toBe(true) + expect(store.nextCursor.value).toEqual({ updatedAt: 40, id: 'session-c' }) + expect(store.error.value).toBeNull() + expect(store.loadingMore.value).toBe(false) + }) + + it('keeps a pending pagination success after refreshing sessions by ID', async () => { + const { store, sessionClient } = await setupStore() + const pendingPage = createDeferred<{ + items: ReturnType [] + hasMore: boolean + nextCursor: { updatedAt: number; id: string } | null + }>() + + sessionClient.listLightweight + .mockResolvedValueOnce({ + items: [createSession({ id: 'session-a', title: 'Original', updatedAt: 30 })], + hasMore: true, + nextCursor: { updatedAt: 30, id: 'session-a' } + }) + .mockReturnValueOnce(pendingPage.promise) + sessionClient.getLightweightByIds.mockResolvedValueOnce([ + createSession({ id: 'session-a', title: 'Refreshed', updatedAt: 40 }) + ]) + await store.fetchSessions() + + const loadMore = store.loadNextPage() + await Promise.resolve() + await store.refreshSessionsByIds(['session-a']) + + pendingPage.resolve({ + items: [createSession({ id: 'session-b', title: 'Next page', updatedAt: 20 })], + hasMore: false, + nextCursor: null + }) + await loadMore + + // sortSessions orders the flat list by title collator, not updatedAt. + expect(store.sessions.value).toEqual([ + expect.objectContaining({ id: 'session-b', title: 'Next page', updatedAt: 20 }), + expect.objectContaining({ id: 'session-a', title: 'Refreshed', updatedAt: 40 }) + ]) + expect(store.hasMore.value).toBe(false) + expect(store.nextCursor.value).toBeNull() + expect(store.error.value).toBeNull() + expect(store.loadingMore.value).toBe(false) + }) + + it('does not let a stale targeted failure replace a newer targeted error', async () => { + const { store, sessionClient } = await setupStore() + const firstRefresh = createDeferred []>() + const secondRefresh = createDeferred []>() + sessionClient.getLightweightByIds + .mockReturnValueOnce(firstRefresh.promise) + .mockReturnValueOnce(secondRefresh.promise) + + const firstRequest = store.refreshSessionsByIds(['session-a', 'session-b']) + const secondRequest = store.refreshSessionsByIds(['session-b', 'session-c']) + + secondRefresh.reject(new Error('new failure')) + await secondRequest + expect(store.error.value).toBe('Failed to refresh sessions: Error: new failure') + + firstRefresh.reject(new Error('old failure')) + await firstRequest + + expect(store.error.value).toBe('Failed to refresh sessions: Error: new failure') + }) + + it('keeps a list error while a background targeted refresh succeeds', async () => { + const { store, sessionClient } = await setupStore() + store.error.value = 'Failed to load more sessions: Error: pagination failure' + sessionClient.getLightweightByIds.mockResolvedValueOnce([ + createSession({ id: 'session-a', title: 'Refreshed', updatedAt: 40 }) + ]) + + await store.refreshSessionsByIds(['session-a']) + + expect(store.error.value).toBe('Failed to load more sessions: Error: pagination failure') + expect(store.sessions.value).toEqual([ + expect.objectContaining({ id: 'session-a', title: 'Refreshed', updatedAt: 40 }) + ]) + }) + + it('reports a pending pagination error after refreshing sessions by ID', async () => { + const { store, sessionClient } = await setupStore() + const pendingPage = createDeferred<{ + items: ReturnType [] + hasMore: boolean + nextCursor: { updatedAt: number; id: string } | null + }>() + + sessionClient.listLightweight + .mockResolvedValueOnce({ + items: [createSession({ id: 'session-a', title: 'Original', updatedAt: 30 })], + hasMore: true, + nextCursor: { updatedAt: 30, id: 'session-a' } + }) + .mockReturnValueOnce(pendingPage.promise) + sessionClient.getLightweightByIds.mockResolvedValueOnce([ + createSession({ id: 'session-a', title: 'Refreshed', updatedAt: 40 }) + ]) + await store.fetchSessions() + + const loadMore = store.loadNextPage() + await Promise.resolve() + await store.refreshSessionsByIds(['session-a']) + + pendingPage.reject(new Error('pagination failure')) + await loadMore + + expect(store.sessions.value).toEqual([ + expect.objectContaining({ id: 'session-a', title: 'Refreshed', updatedAt: 40 }) + ]) + expect(store.hasMore.value).toBe(true) + expect(store.nextCursor.value).toEqual({ updatedAt: 30, id: 'session-a' }) + expect(store.error.value).toBe('Failed to load more sessions: Error: pagination failure') + expect(store.loadingMore.value).toBe(false) + }) + it('does not deduplicate next-page loading while an initial fetch is in flight', async () => { const { store, sessionClient } = await setupStore() diff --git a/test/renderer/stores/themeStore.test.ts b/test/renderer/stores/themeStore.test.ts new file mode 100644 index 0000000000..bf0d764659 --- /dev/null +++ b/test/renderer/stores/themeStore.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const deferred = () => { + let resolve!: (value: T) => void + const promise = new Promise ((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +const setupStore = async () => { + vi.resetModules() + + const themeListeners: Array< + (payload: { theme: 'dark' | 'light' | 'system'; isDark: boolean }) => void + > = [] + const systemThemeListeners: Array<(payload: { isDark: boolean }) => void> = [] + const snapshot = deferred<{ theme: 'dark' | 'light' | 'system'; isDark: boolean }>() + const configClient = { + getThemeState: vi.fn(() => snapshot.promise), + setTheme: vi.fn().mockResolvedValue(false), + onThemeChanged: vi.fn((listener) => { + themeListeners.push(listener) + return () => undefined + }), + onSystemThemeChanged: vi.fn((listener) => { + systemThemeListeners.push(listener) + return () => undefined + }) + } + + vi.doMock('pinia', () => ({ + defineStore: (_id: string, setup: () => unknown) => setup + })) + vi.doMock('@vueuse/core', () => ({ + useDark: () => ({ value: false }), + useToggle: (state: { value: boolean }) => (value: boolean) => { + state.value = value + } + })) + vi.doMock('vue', () => ({ + ref: (value: T) => ({ value }), + onScopeDispose: () => undefined + })) + vi.doMock('../../../src/renderer/api/ConfigClient', () => ({ + createConfigClient: vi.fn(() => configClient) + })) + + const { useThemeStore } = await import('@/stores/theme') + return { + store: useThemeStore() as ReturnType , + configClient, + snapshot, + emitTheme: (payload: { theme: 'dark' | 'light' | 'system'; isDark: boolean }) => { + for (const listener of themeListeners) listener(payload) + }, + emitSystemTheme: (payload: { isDark: boolean }) => { + for (const listener of systemThemeListeners) listener(payload) + } + } +} + +describe('theme store', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('registers listeners before the initial snapshot and does not let it overwrite newer events', async () => { + const { store, configClient, snapshot, emitTheme, emitSystemTheme } = await setupStore() + + expect(configClient.onThemeChanged).toHaveBeenCalledTimes(1) + expect(configClient.onSystemThemeChanged).toHaveBeenCalledTimes(1) + expect(configClient.getThemeState).toHaveBeenCalledTimes(1) + + emitTheme({ theme: 'dark', isDark: true }) + emitTheme({ theme: 'light', isDark: false }) + emitSystemTheme({ isDark: true }) + snapshot.resolve({ theme: 'system', isDark: true }) + await store.initTheme() + + expect(store.themeMode.value).toBe('light') + expect(store.isDark.value).toBe(false) + }) + + it('uses the user-theme event payload instead of a follow-up snapshot read', async () => { + const { store, snapshot, emitTheme } = await setupStore() + + snapshot.resolve({ theme: 'system', isDark: false }) + await store.initTheme() + emitTheme({ theme: 'dark', isDark: true }) + + expect(store.themeMode.value).toBe('dark') + expect(store.isDark.value).toBe(true) + }) +}) diff --git a/test/renderer/utils/deferred.ts b/test/renderer/utils/deferred.ts new file mode 100644 index 0000000000..2e5d22d9b0 --- /dev/null +++ b/test/renderer/utils/deferred.ts @@ -0,0 +1,15 @@ +export interface Deferred { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} + +export function createDeferred (): Deferred { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise ((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} diff --git a/test/setup.renderer.ts b/test/setup.renderer.ts index 1f285464b3..91e0fd9be8 100644 --- a/test/setup.renderer.ts +++ b/test/setup.renderer.ts @@ -536,6 +536,10 @@ beforeEach(() => { }) afterEach(() => { - // Clean up after each test + // Tests must not leak pending fake-timer callbacks, fake timers, or spies into + // the next jsdom environment. Clear before restoring real timers so scheduled + // callbacks cannot escape into a later test's clock. + vi.clearAllTimers() + vi.useRealTimers() vi.restoreAllMocks() }) diff --git a/vitest.config.renderer.ts b/vitest.config.renderer.ts index a1567994ff..005bdb4d56 100644 --- a/vitest.config.renderer.ts +++ b/vitest.config.renderer.ts @@ -52,6 +52,10 @@ export default defineConfig({ 'dist/**', 'out/**' ], + // Heavy jsdom/Markstream suites compete for CPU and GC when unconstrained; keep + // enough parallelism for feedback while preserving the existing timeout signal. + minWorkers: 1, + maxWorkers: 2, testTimeout: 10000, hookTimeout: 10000, setupFiles: ['./test/setup.renderer.ts']