diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index 29578bf5a9..b5fb0fd50d 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -148,7 +148,7 @@ Clarify session status typing for internal SDK callers. `@moonshot-ai/kimi-web` is ignored by changesets and must **never** appear in a changeset frontmatter. Because the web app is bundled into the CLI release artifact, any web change that ships must list `@moonshot-ai/kimi-code` instead and describe the actual web-facing change in the text. - Prefix the changelog entry text with `web: ` (for example `web: Fix the chat not scrolling to the bottom after sending a message.`) so the synced docs changelog can mark web UI entries. Apply this whenever the change is to the web project (`@moonshot-ai/kimi-web`). -- If a PR contains both web UI changes and server API changes, split them into separate changesets so each entry has a focused description. +- If a PR ships a web UI feature backed by server API changes that exist solely to power that feature, prefer a single `web:` entry describing what the web user gets. Do not add a separate server-API changeset unless the API has independent user value (a public endpoint that SDK or server consumers call directly). The docs changelog sync also deduplicates this pattern, but catching it here avoids duplicate changesets. - Do not enumerate every micro-tweak; keep it to one sentence that captures what the web user gets. Web-only fix: @@ -161,7 +161,7 @@ Web-only fix: web: Fix the chat not scrolling to the bottom after sending a message. ``` -Web UI plus server APIs in the same PR (split into two changesets): +Web UI plus backing server APIs in the same PR (prefer a single `web:` entry; the API is plumbing): ```markdown --- @@ -171,13 +171,7 @@ Web UI plus server APIs in the same PR (split into two changesets): web: Add the server-hosted web UI, including chat layout and session list behaviors. ``` -```markdown ---- -"@moonshot-ai/kimi-code": minor ---- - -Add the server REST and WebSocket APIs that power the web UI. -``` +Split into two changesets only when the API has independent user value on its own (for example, a public endpoint SDK consumers call directly). In that case add the web entry above plus a separate one such as `Add a public REST API to list archived sessions for SDK consumers.` ## `@moonshot-ai/pi-tui` changes @@ -228,3 +222,4 @@ Fix the transcript jumping to the top when scrolling up through history during s - The entry includes real internal identifiers instead of neutral placeholders. - A change that only touches `@moonshot-ai/pi-tui` lists `@moonshot-ai/kimi-code` instead of `@moonshot-ai/pi-tui`, or mixes both packages in one frontmatter. - A web app change entry is missing the `web: ` prefix. +- A server/API changeset exists only to back a web feature that a `web:` changeset already describes (use one `web:` entry instead, unless the API has independent user value). diff --git a/.agents/skills/pre-changelog/SKILL.md b/.agents/skills/pre-changelog/SKILL.md index c79016a0dc..adde7c1b36 100644 --- a/.agents/skills/pre-changelog/SKILL.md +++ b/.agents/skills/pre-changelog/SKILL.md @@ -37,9 +37,10 @@ If the CLI changelog is not in the diff (for example an SDK-only release), stop Process the version block exactly as `sync-changelog` does for the docs site, but only in memory: -- **Strip** (`sync-changelog` step 3): drop the H1, the `### Patch Changes` / `### Minor Changes` / `### Major Changes` subheadings, PR links, and commit-hash links; keep only each entry's body text. -- **Classify** (`sync-changelog` step 4): bucket into Features / Bug Fixes / Polish / Refactors / Other; order within each section by reader value. -- **Translate** (`sync-changelog` step 6): translate entry bodies to Chinese; section headings become 新功能 / 修复 / 优化 / 重构 / 其他. +- **Strip** (`sync-changelog` step 3): drop the H1, the `### Patch Changes` / `### Minor Changes` / `### Major Changes` subheadings, PR links, and commit-hash links; keep only each entry's body text. The `Thanks [@user](...)!` credit (including the multi-author form) must be removed every time. Within each entry, drop SDK-only and provider-internal sentences (SDK capability mapping / API exposure, provider wire-format mechanics, internal XML markers) and keep only the user-facing effect and required constraints. +- **Merge and deduplicate** (`sync-changelog` step 4): merge micro-tweaks to the same surface into one higher-level entry; when three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry (do not merge broad or genuinely distinct fixes); and drop a server/API entry that only backs a web feature already listed. +- **Classify** (`sync-changelog` step 4): bucket into Features / Bug Fixes / Polish / Refactors / Other; order within each section by reader value (in Polish, user-visible improvements before protocol/internal adjustments). +- **Translate** (`sync-changelog` step 6): translate entry bodies to Chinese; keep one sentence per entry with a parallel rhythm within a section; section headings become 新功能 / 修复 / 优化 / 重构 / 其他. If an upstream entry is not in English, flag it and stop (changeset entries must be English). diff --git a/.agents/skills/sync-changelog/SKILL.md b/.agents/skills/sync-changelog/SKILL.md index 6e752387da..25e542d4b9 100644 --- a/.agents/skills/sync-changelog/SKILL.md +++ b/.agents/skills/sync-changelog/SKILL.md @@ -93,13 +93,13 @@ Use upstream order: newest version first. Upstream entries look like this: ```markdown -- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) - Clean up lint warnings ... +- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) Thanks [@user](https://github.com/...)! - Clean up lint warnings ... ``` -Keep: +Changesets may add a `Thanks ...!` credit, but it must be removed every time. Keep: - Version headings such as `## 0.2.0`. -- Only the body text of each entry, after the PR/hash decoration. +- Only the body text of each entry, after the PR/hash decoration and any `Thanks ...!` credit have been removed. Remove: @@ -107,12 +107,17 @@ Remove: - Changesets subheadings such as `### Patch Changes`, `### Minor Changes`, and `### Major Changes`. - PR links such as `[#317](...)`. - Commit hash links such as ``[`2f51db4`](...)``. +- The `Thanks [@user](...)!` credit, including the multi-author form `Thanks [@a](...), [@b](...)!`. Drop the whole `Thanks ...!` segment every time, regardless of whether the feature is enabled. -After stripping, each entry should be only: +After stripping, each entry is `- `. -```markdown -- -``` +Drop SDK-only and provider-internal detail. This changelog serves `@moonshot-ai/kimi-code` CLI and web users. Within an entry, keep only what CLI/web users can perceive, and remove sentences that document internals instead of user-visible behavior. Apply this on both the English and Chinese pages: + +- Drop sentences about how the SDK maps a capability, builds model aliases, or exposes a flag through an API such as `getExperimentalFeatures()` — that belongs in the SDK changelog, not here. +- Drop provider / wire-format implementation mechanics (XML markers like ``, protocol field explanations, "the wire protocol is unchanged", cache-hit mechanics) unless they are the behavior a user perceives. +- Keep the user-facing effect and any constraints users must follow (for example "question texts must be unique"). + +Do not change facts or drop a real user-facing behavior — only trim the internal-only scaffolding. For over-long, internal-heavy entries, this trim applies on the English page too, not only in translation. Web UI prefix: if the entry is a web UI change, prefix the body text with `web: ` so readers can tell it affects the web UI: @@ -126,15 +131,25 @@ Upstream language rule: `gen-changesets` requires changelog entries to be Englis Public-text rule: do not copy real internal endpoints, key names, account names, or service names into docs changelogs. Replace examples with neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY` while preserving the user-visible meaning. -### 4. Classify Entries +### 4. Merge, Deduplicate, And Classify Entries + +Before classifying, merge related entries and drop redundant ones from the user-facing changelog: + +- **Merge micro-tweaks to the same surface.** Collapse several small tweaks to the same UI area or feature into one concise entry at the higher level. For example, "change the composer's default height" and "change the composer's default font" merge into "Polish the composer's default styling." Use the most specific common ancestor (composer, settings page, tool card, and so on). Classify the merged entry by its combined effect, and keep the `web:` prefix if the combined change is still web-facing. +- **Merge same-surface or same-kind fixes when you have three or more.** The `Bug Fixes` section tends to accumulate many narrow UI/polish fixes that read as noise when listed one by one. When three or more fixes target the same area (for example several tool cards in the TUI, or the web session/conversation surface) or the same class of problem (for example several "jumping/flickering/collapsing during streaming" fixes), merge them into one higher-level entry. Examples: + - "Fix the Bash tool card collapsing...", "Fix the Edit tool card jumping in height...", "Fix the Edit tool card flickering while its result streams in" → "Fix several TUI tool cards jumping, flickering, or collapsing in height when results stream in or end with short output." + - "Fix the collapsed sidebar not hiding...", "Stop the chat history from replaying its entrance animation...", "Fix tool components jumping the conversation when expanded/collapsed" → "web: Fix several layout and display glitches when switching sessions, including the collapsed sidebar not hiding, the chat history replaying its entrance animation, and tool components jumping the conversation." + - Keep `web:` if the merged fixes are all web-facing. Classify as `Bug Fixes`. + - **Do not over-merge.** Leave a fix standalone when it is broad, high-value, or genuinely distinct (for example model/provider tool-calling bugs, session-list corruption, file-completion gaps). Merging is for low-reader-value, similar-shape fixes that read as a wall of similar bullets. +- **Drop server/API plumbing covered by a web entry.** If one entry adds a web UI feature (for example, an Archived sessions page) and another entry only adds the server or REST/WebSocket endpoints that exist solely to power that web feature, keep the `web:` entry and drop the API entry. CLI and web users perceive the web page; the backing API is implementation detail with no independent user value on this changelog. Keep the API entry only when it has independent user value — a new public endpoint that SDK or server consumers call directly, or a capability usable outside the web feature. When unsure, keep both and let the reviewer decide. The docs changelog uses five section types: | English section | Chinese section | Meaning | |---|---|---| | `### Features` | `### 新功能` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before | -| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken | | `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities | +| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken | | `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames | | `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts | @@ -148,6 +163,8 @@ Classification process: Features vs. Polish: ask whether the entry introduces something the user could not do before. If yes (new command, flag, mode, viewer, or capability), use `Features`. If it only improves an existing surface (a UI panel that already existed, an existing prompt, an existing tool card, an existing payload pipeline), use `Polish`. Verbs like `Add` do not automatically mean `Features` — a small visual addition to an existing UI is still polish. +Default-behavior changes: changing the default value of an existing capability (for example flipping a feature on by default) is usually `Polish`, because the capability already existed. Use `Features` only when the new default materially changes the out-of-box experience for most users in a way they could not get before. When genuinely ambiguous, flag it and confirm with the reviewer rather than guessing. + Keyword hints: - **Features**: `Add ... command/flag/option/mode/viewer`, `Introduce`, `Support`, `Allow`, `Enable`, `Implement`, `New ... command/flag/option` @@ -159,14 +176,15 @@ Keyword hints: Within each version, section order is: ```text -Features → Bug Fixes → Polish → Refactors → Other +Features → Polish → Bug Fixes → Refactors → Other ``` Omit empty sections. Within each section, order entries by reader value, not upstream order: 1. Put the most valuable, obvious, and larger changes first. 2. Prefer broad user-visible features, workflow-changing fixes, high-frequency bugs, and large cross-cutting improvements over small polish, narrow edge cases, and internal cleanup. -3. If entries have similar value, preserve upstream order. +3. Within `Polish`, put directly user-visible UX or performance improvements (something users can see or feel) before protocol or internal-behavior adjustments (something that makes the model or pipeline behave more reliably but is invisible to users). +4. If entries have similar value, preserve upstream order. Do not reword or exaggerate entries just to make them look more important; only reorder existing entries. @@ -246,6 +264,7 @@ Structural fidelity does not mean literal translation. The Chinese entries shoul Guidelines: - **One entry, one sentence.** Avoid chaining multiple effects with commas or semicolons. If the English entry is long, split it into shorter sentences or keep only the most important effect. +- **Drop SDK-only and provider-internal detail.** Apply the trim from step 3 while translating: keep the user-facing effect and required constraints, drop SDK-mapping sentences, provider / wire-format mechanics, and internal XML markers. A long internal entry should collapse to one short Chinese sentence about what the user gets. - **Prefer common changelog verbs**: 新增、支持、修复、优化、改进、调整. - **Avoid indirect "through... make..." structures**. Do not write "通过 X,使 Y"; prefer direct cause-effect or just state the result. - Bad: `通过缓存已渲染消息行,使终端在长篇对话中保持响应。` @@ -263,6 +282,7 @@ Guidelines: - Bad: `传入 --allowed-host 以允许额外的 host。例如 ... (多句展开)` - Better: `例如 kimi web --allowed-host example.com。` - **Do not translate technical identifiers**: keep command names, flag names, file names, env vars, config keys, and the `web:` scope prefix as-is. +- **Keep parallel rhythm within a section.** When several entries fix similar web surfaces (layout, animation, sizing), phrase them with a consistent structure (for example 修复 <问题>,现 <行为>) so the section reads as a tidy list rather than a mix of shapes. Example — translating a feature entry: @@ -300,6 +320,7 @@ Check: - Each section has the same number of entries on both pages. - Within each section, the most valuable, obvious, and larger entries appear before smaller or narrower entries. - PR links and commit hashes were stripped. +- No `Thanks ...!` credit remains (remove it every time). - Real internal identifiers were replaced with neutral placeholders. - There are no empty sections. - Markdown indentation and blank lines are intact. @@ -416,6 +437,10 @@ Return the PR URL to the user when done. |---|---| | Adding entries directly to the English docs page without reading upstream | Use `apps/kimi-code/CHANGELOG.md` as the source | | Copying PR links or commit hashes into docs | Strip them; keep only body text | +| Leaving the `Thanks ...!` credit in docs | Remove it every time, including the multi-author form | +| Leaving near-duplicate micro-tweaks as separate bullets | Merge small tweaks to the same surface into one higher-level entry (e.g. composer height + font → composer's default styling) | +| Listing many narrow fixes to the same surface as separate bullets | When three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry; keep genuinely distinct or high-value fixes standalone | +| Listing a server/API entry that only backs a web feature already listed | Drop the API entry and keep the `web:` entry, unless the API has independent user value | | Rewording upstream English entries | Upstream is frozen; copy the body text unless the user explicitly asks otherwise | | Leaving English text untranslated in the Chinese page | The Chinese page must be fully Chinese except preserved technical terms | | Editing upstream changelog text | Do not edit upstream | diff --git a/.changeset/README.md b/.changeset/README.md index dd568888bd..42457c1326 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -29,7 +29,6 @@ All other workspace packages are private internal packages, are not published to - `@moonshot-ai/vis` - `@moonshot-ai/vis-server` - `@moonshot-ai/vis-web` -- `kimi-migration-legacy` Version impact from internal dependencies must be judged manually. The published artifacts for CLI and SDK bundle internal workspace packages into the artifact itself; runtime `dependencies` of published packages must not include any `@moonshot-ai/*` internal workspace packages. diff --git a/.changeset/ask-user-dismiss-not-recommended.md b/.changeset/ask-user-dismiss-not-recommended.md new file mode 100644 index 0000000000..734886d44c --- /dev/null +++ b/.changeset/ask-user-dismiss-not-recommended.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Treat a dismissed question prompt as the user choosing not to answer, instead of implicitly selecting the recommended option. diff --git a/.changeset/config.json b/.changeset/config.json index c430a5cee9..2320f45e31 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,5 +1,5 @@ { - "changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code", "disableThanks": true }], + "changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code" }], "commit": false, "fixed": [], "linked": [], @@ -10,8 +10,7 @@ "@moonshot-ai/server-e2e", "@moonshot-ai/vis", "@moonshot-ai/vis-server", - "@moonshot-ai/vis-web", - "kimi-migration-legacy" + "@moonshot-ai/vis-web" ], "snapshot": { "useCalculatedVersion": true, diff --git a/.changeset/dynamically-loaded-tools-capability.md b/.changeset/dynamically-loaded-tools-capability.md new file mode 100644 index 0000000000..1ff8e84f24 --- /dev/null +++ b/.changeset/dynamically-loaded-tools-capability.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kosong": patch +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +--- + +Rename the dynamic tool loading model capability from `select_tools` to `dynamically_loaded_tools`. diff --git a/.changeset/fix-gemini-tool-calling.md b/.changeset/fix-gemini-tool-calling.md deleted file mode 100644 index f0d7bbe91d..0000000000 --- a/.changeset/fix-gemini-tool-calling.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix tool calling with Google Gemini models, including Gemini 3 thinking-signature round-trips across turns. diff --git a/.changeset/print-goal-cron-turns.md b/.changeset/print-goal-cron-turns.md new file mode 100644 index 0000000000..4d6c7936b9 --- /dev/null +++ b/.changeset/print-goal-cron-turns.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Keep `kimi -p` runs alive after a turn ends while a goal is still active or a cron task is pending, so goal continuations and cron fires run their turns instead of being cut off when the main turn finishes. diff --git a/.changeset/rebuild-session-index-on-boot.md b/.changeset/rebuild-session-index-on-boot.md deleted file mode 100644 index e9bd68cb3b..0000000000 --- a/.changeset/rebuild-session-index-on-boot.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix sessions that exist on disk but were missing from the session list or returned 404 on direct access, by rebuilding the session index at server startup and keeping it consistent. diff --git a/.changeset/refill-composer-attachments.md b/.changeset/refill-composer-attachments.md deleted file mode 100644 index dfb5ae1b88..0000000000 --- a/.changeset/refill-composer-attachments.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Fix queued media messages not loading back into the composer and keep attachments when undoing a message. diff --git a/.changeset/web-ask-user-question-result.md b/.changeset/web-ask-user-question-result.md deleted file mode 100644 index cd5146ccc4..0000000000 --- a/.changeset/web-ask-user-question-result.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Render AskUserQuestion answers as a readable option list with the chosen option(s) highlighted, instead of raw JSON. diff --git a/.changeset/web-markdown-diff-block.md b/.changeset/web-markdown-diff-block.md deleted file mode 100644 index 3679acf5bd..0000000000 --- a/.changeset/web-markdown-diff-block.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Align the markdown diff code block with the design system: code text keeps the normal ink colour while the sign and a soft row background carry the change, matching the ~/diff panel. diff --git a/.changeset/web-pre-session-skills.md b/.changeset/web-pre-session-skills.md deleted file mode 100644 index 45c26e4fda..0000000000 --- a/.changeset/web-pre-session-skills.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Show available skills in the composer before a session is created. diff --git a/.changeset/web-resume-media-tool.md b/.changeset/web-resume-media-tool.md new file mode 100644 index 0000000000..a18aea7b5e --- /dev/null +++ b/.changeset/web-resume-media-tool.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix ReadMediaFile results rendering as plain tool cards instead of images after resuming or reloading a session. diff --git a/.changeset/web-tool-row-body-padding.md b/.changeset/web-tool-row-body-padding.md deleted file mode 100644 index ce4f4d8961..0000000000 --- a/.changeset/web-tool-row-body-padding.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Drop the stray left indent in the tool-call card body so expanded content aligns with the header. diff --git a/.changeset/webbridge-plugins-link.md b/.changeset/webbridge-plugins-link.md new file mode 100644 index 0000000000..55ce6bb282 --- /dev/null +++ b/.changeset/webbridge-plugins-link.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Update the WebBridge install page link opened from the /plugins panel. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16b9c0f12a..e5c4c6d37b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Upgrade npm for Trusted Publishing - run: npm install -g npm@latest + run: npm install -g npm@11 - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 46dca949ef..203492e4e1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,16 @@ docker-compose.yml docs/superpowers/ reports/ .superpowers/ +/plan/ + +# Agent scratch / throwaway files - do not commit +.tmp/ +HANDOVER*.md +HANDOFF*.md +handoff.md +handover.md +*-designs.html +*-design.html +*-mockup.html +*-demo.html +*-demos.html diff --git a/AGENTS.md b/AGENTS.md index a9f77b457d..f8cdf7c107 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,3 +77,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - After finishing a task and before submitting a PR, you must run the `gen-changesets` skill (see `.agents/skills/gen-changesets/SKILL.md`) and generate a changeset under `.changeset/` according to its rules. - When generating a changeset, **never** decide on a `major` bump on your own. When you judge a change to meet the major criteria (breaking changes, incompatible user configuration, renamed or removed commands/arguments, changed behavior semantics, etc.), you must stop and explain it to the user and ask for confirmation. **Only write `major` after the user has explicitly agreed.** Otherwise default to `minor` (and fall back to `patch` if `minor` is unclear). See the "Hard rule: confirm with the user before writing `major`" section in `.agents/skills/gen-changesets/SKILL.md` for details. - Prefer importing via `import ... from '#/...'`, which serves the same purpose as `import ... from '@/...'`. +- Do not commit throwaway scratch or exploratory files. Never stage: + - Agent working notes or handoff/summary documents (e.g. `HANDOVER-*.md`, `HANDOFF-*.md`, `handoff.md`). + - Throwaway UI/UX prototypes or design mockups (e.g. `*-designs.html`, `*-mockup.html`, `*-demo(s).html`) at the repo root or under a `design/` folder. The only tracked `.html` files should be Vite `index.html` entrypoints. + Before committing or opening a PR, run `git status` and `git diff --staged --stat` and remove anything matching these patterns. Put scratch work under `.tmp/` (gitignored) instead of the repo root or the source tree. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/HANDOVER-kimi-web-table-width.md b/HANDOVER-kimi-web-table-width.md deleted file mode 100644 index c3ac05613a..0000000000 --- a/HANDOVER-kimi-web-table-width.md +++ /dev/null @@ -1,149 +0,0 @@ -# 接手文档:kimi-web 聊天表格宽度 - -> **当前状态(未提交,用户先用着)**:表格在阅读列(≈760px)内**单元格换行**显示—— -> 不横向滚动、不溢出、不裁切,但**没有比正文更宽**。这是目前效果最干净、可以先用的版本。 -> -> **仍未达成的目标**:让宽表格像 Manus 那样**突破到比正文更宽的列**再换行。两次突破尝试 -> (容器单位 `cqw`、固定 px `@container` 断点)都没做出满意效果,已回退/停用,见 §4、§6。 -> -> **只改了 1 个文件**:`apps/kimi-web/src/components/chat/Markdown.vue`(表格样式块)。 -> typecheck / check:style 通过。 - -## 1. 背景与目标 - -`apps/kimi-web` 聊天里 markstream 渲染的 markdown 表格,默认被钉在阅读列宽内 -(`width:100%` + `table-layout:fixed`),宽表格要么被挤成很窄的列、要么表格内横向滚动。 - -用户想要的最终效果(参照 Manus 截图): - -1. 表格**比正文阅读列更宽**(横向铺开),但 -2. 单元格**换行**,所以表格**永不横向滚动**(表格自身和对话面板都不滚),且 -3. 正文 / 代码 / diff / 工具块 / thinking 等其它块仍限在 760px 阅读列内。 - -> 第 1 点(撑得比正文宽)目前**没做到**——见 §6。当前上线的是"列内换行"的降级版。 - -## 2. 当前实现(列内换行版,正在用) - -`Markdown.vue` ` - - -
-
-

消息队列 UX · 高保真原型

-

把「发送」和「中断」解耦,把队列从隐藏面板搬进对话流。下面是运行中 + 排队的核心场景,可交互(试着输入回车、点 ×、点排队项编辑、点“出队一条”)。

-
- -
- -
-
-

相对现在的改动(4 点)

-
A
发送永远是发送 — 运行中也不再变成“中断”,Enter / 按钮都是入队。
-
B
Stop 独立按钮 — 与发送平级、运行时固定显示,急停仍一键可达。
-
C
队列内联进对话流 — 作为半透明待发送气泡,看得见、点得动。
-
D
可编辑 / 可排序 / 可删除 — 点气泡编辑、拖拽排序、× 删除。
-
- -
场景 · 运行中(agent 正在改写中间件,你又追加了 3 条)
- -
-
-
- -
帮我把登录接口改成 JWT,并补上对应的单元测试。
- -

我看了一下 src/auth 的结构,当前是基于 session cookie 的实现,先读取相关文件。

- -
- - read_file - src/auth/session.ts - 0.2s -
-
- - read_file - src/auth/middleware.ts - 运行中… -
-
🌔正在改写认证中间件…
- - -
-
- - - 队列 · 3 - - 当前回合结束后自动逐条发送 -
-
-
-
- - -
-
- -
-
- - yolo - - plan - -
-
- kimi-k2· thinking - - - - - - -
-
-
- - 运行中:Enter 加入队列 · Ctrl+S 立即插入当前回合 -
-
- -
- - - - 提示:在输入框里打点什么按 Enter,会作为新气泡进队列。 -
-
-
- - -
- -
对照 · 空闲态(没有队列、没有运行中的 turn)
-
-
-
这个项目用的是什么测试框架?
-

用的是 vitest,纯逻辑测试,没有 jsdom / 组件测试。测试文件跟被测模块放在一起。

-
-
-
- -
-
- - yolo -
-
- kimi-k2· thinking - - - - -
-
-
-
-
- - -
- - - - diff --git a/apps/kimi-web/design/sidebar-show-more-demo.html b/apps/kimi-web/design/sidebar-show-more-demo.html deleted file mode 100644 index d42cf7c5d0..0000000000 --- a/apps/kimi-web/design/sidebar-show-more-demo.html +++ /dev/null @@ -1,649 +0,0 @@ - - - - - -kimi-web · 侧栏「展开更多」Demo - - - -
-

侧栏「展开更多」Demo

- - - -
- -
-
-
方案 A · 加载更多 + 整组折叠
(点工作区标题折叠整组)
- -
-
-
方案 B · 加载更多 + 组内展开/收起
(收回到第一页)
- -
-
-

怎么看这个 demo

-
    -
  • 左右两个侧栏是同一组数据(kimi-code 工作区共 23 个会话,每页 5 条),可独立点按。
  • -
  • 顶部 按钮样式 切换「当前实现」与「规范合规」,两套样式同时作用在两个方案上,方便对比。
  • -
  • 第二个工作区 docs 用来看「上面的列表展开后会把下面的工作区顶下去」——这就是想收起的动机。
  • -
- -

方案 A:加载更多 + 整组折叠

-
    -
  • 只有 加载更多 一个按钮,拉下一页追加。
  • -
  • 收起靠 点工作区标题(整组会话全部隐藏)。这是 §07 已经定义的折叠。
  • -
  • 改动最小,完全符合现有规范。
  • -
  • 一折就整组全收,连第一页也看不见。
  • -
- -

方案 B:加载更多 + 组内展开/收起

-
    -
  • 加载超过第一页后,多一个 收起 按钮;收回后变成 展开
  • -
  • 收起只回到第一页(前 5 条),数据不丢、不重新请求。
  • -
  • 能收回第一页之外的会话,缓解把下面工作区顶下去的问题。
  • -
  • §07 目前没有定义这个组内控件,需要扩展规范。
  • -
- -

样式差异(切到「当前实现」看)

-
    -
  • 当前:裸 button —— mono 字体、无圆角、hover 文字变 accent、无焦点环,且文字比 session 标题偏右 8px
  • -
  • 设计稿:做成「会话行同款」的紧凑列表控件 —— 行首留空使文字与 session 标题精确对齐font-uiradius-md、26px 行高、hover 只出 sunken 底、:focus-visible 焦点环。
  • -
-

规范参考:design-system.html §07(侧栏)/ §02(token / 字体)/ §08(焦点环)。

-
-
- - - - diff --git a/apps/kimi-web/design/undo-exit-demos.html b/apps/kimi-web/design/undo-exit-demos.html deleted file mode 100644 index de6bb3c117..0000000000 --- a/apps/kimi-web/design/undo-exit-demos.html +++ /dev/null @@ -1,398 +0,0 @@ - - - - - -用户消息 undo 退场动画 · 10 个候选 - - - -
-

用户消息 undo 退场动画 · 10 个候选

-

点击每条气泡预览退场效果;点击「重播」或「全部重置」可再次观看。气泡样式对齐真实 .u-bub(右对齐、accent-soft、非对称圆角)。

-
- -
- - 复杂度:低 = 纯 transform/opacity/filter;中 = 需 clip-path / perspective / 多 filter;高 = 需 @property + mask。 -
- -
- -
-
-

01 柔雾消散

-

原地失焦 + 轻微缩小。最克制、最贴设计系统的「安静」气质。

- opacity · blur · scale -
-
撤销这条消息,看看效果
-
- -
-
-

02 向右滑出

-

向右平移并淡出,方向明确(与「撤回」语义一致)。性能最好。

- translateX · opacity -
-
撤销这条消息,看看效果
-
- -
-
-

03 缩小淡出

-

向右锚点缩小并淡出。比柔雾多一点「被收走」的感觉,但仍然克制。

- scale · opacity(origin right) -
-
撤销这条消息,看看效果
-
- -
-
-

04 横向收束

-

沿右边缘把气泡压扁到 0。像把消息「抽走」,方向感强。

- scaleX · opacity(origin right) -
-
撤销这条消息,看看效果
-
- -
-
-

05 向上折起

-

3D 向上翻折消失,立体、有层次。需父级 perspective。

- rotateX · opacity · perspective -
-
撤销这条消息,看看效果
-
- -
-
-

06 裁切擦除

-

用 clip-path 从右向左裁掉。干净、利落,无明显位移。

- clip-path inset · opacity -
-
撤销这条消息,看看效果
-
- -
-
-

07 浮起消散 低~中

-

向上浮起、阴影放大、淡出。像消息「飘走」,轻盈。

- translateY · box-shadow · opacity -
-
撤销这条消息,看看效果
-
- -
-
-

08 墨迹晕开

-

失焦 + 提亮 + 去饱和,像墨迹在水里晕开。柔和、有质感。

- blur · brightness · grayscale · opacity -
-
撤销这条消息,看看效果
-
- -
-
-

09 粒子点阵消散

-

mask 点阵逐点收缩,气泡「碎成点」再消失。最接近「粒子消散」。

- @property --k · radial-gradient mask -
-
撤销这条消息,看看效果
-
- -
-
-

10 散焦放大

-

轻微放大 + 强失焦,像被一阵风吹散。比柔雾更「爆」一点。

- scale · blur · opacity -
-
撤销这条消息,看看效果
-
- -
- - - - diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index 1b2eb5f2b4..300d1848d2 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -10,15 +10,12 @@ "build": "vite build", "typecheck": "vue-tsc --noEmit", "test": "vitest run", - "check:style": "node scripts/check-style.mjs", - "gen:icons": "node scripts/gen-icon-data.mjs" + "check:style": "node scripts/check-style.mjs" }, "dependencies": { - "@fontsource-variable/inter": "^5.2.8", + "@chenglou/pretext": "0.0.8", + "@fontsource-variable/inter": "5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", - "@iconify-json/ri": "^1.2.10", - "@iconify/utils": "^3.1.3", - "@iconify/vue": "^5.0.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "katex": "^0.17.0", @@ -30,8 +27,11 @@ "vue-i18n": "^11.4.5" }, "devDependencies": { + "@iconify-json/ri": "^1.2.10", + "@iconify-json/tabler": "^1.2.35", "@vitejs/plugin-vue": "^5.2.4", "typescript": "6.0.2", + "unplugin-icons": "^23.0.0", "vite": "^6.3.3", "vitest": "4.1.4", "vue-tsc": "~3.2.0", diff --git a/apps/kimi-web/scripts/check-style.mjs b/apps/kimi-web/scripts/check-style.mjs index e626cfee05..81480efe96 100644 --- a/apps/kimi-web/scripts/check-style.mjs +++ b/apps/kimi-web/scripts/check-style.mjs @@ -27,13 +27,13 @@ const DOMAIN_HEX_EXEMPT = new Set([ 'Terminal.vue', ]); -// Files that legitimately render their own : the icon primitive itself, -// bespoke data-viz / colored illustrations, the spinner, and brand marks -// (the Kimi wordmark on the loading screen). Everything else should use -// lib/icons.ts via /iconSvg(). The 32x22 Kimi eye logo is also exempted -// inline (matched by viewBox). +// Files that legitimately render their own : bespoke data-viz / colored +// illustrations, the spinner, and brand marks (the Kimi wordmark on the loading +// screen). Everything else should use lib/icons.ts via /iconSvg(). The +// 32x22 Kimi eye logo is also exempted inline (matched by viewBox). The icon +// primitive (components/ui/Icon.vue) itself renders no hand-written , so it +// is not exempted here. const ICON_EXEMPT = new Set([ - 'components/ui/Icon.vue', 'components/ui/Spinner.vue', 'components/ui/MoonSpinner.vue', 'components/ui/ContextRing.vue', diff --git a/apps/kimi-web/scripts/gen-icon-catalog.mjs b/apps/kimi-web/scripts/gen-icon-catalog.mjs deleted file mode 100644 index 79063bf063..0000000000 --- a/apps/kimi-web/scripts/gen-icon-catalog.mjs +++ /dev/null @@ -1,40 +0,0 @@ -// scripts/gen-icon-catalog.mjs — generate the design-system §02 icon catalog -// HTML from the canonical registry (lib/icons.ts) so the two can never drift. -// Run: node --experimental-strip-types scripts/gen-icon-catalog.mjs -import { ICON_DATA } from '../src/lib/icon-data.ts'; - -// Display order + grouping. Names not listed here are appended under "Other". -const GROUPS = [ - ['Actions', ['plus', 'chat-new', 'close', 'check', 'search', 'copy', 'link', 'external-link', 'download', 'undo', 'send', 'image', 'settings', 'sliders', 'log-in']], - ['Navigation & layout', ['chevron-down', 'chevron-right', 'arrow-up', 'arrow-down', 'arrow-right', 'minus', 'panel-collapse', 'panel-expand', 'expand', 'collapse', 'list']], - ['Files & tools', ['folder', 'folder-closed', 'folder-plus', 'folder-solid', 'file', 'file-text', 'file-plus', 'file-off', 'image-off', 'code', 'terminal', 'pencil', 'glob', 'globe', 'check-list', 'bolt', 'git-pull-request']], - ['Communication', ['message', 'mail', 'user']], - ['Status & media', ['info', 'help-circle', 'alert-triangle', 'clock', 'sparkles', 'play', 'stop', 'star', 'star-outline', 'dots-horizontal']], -]; - -function render(name) { - const d = ICON_DATA[name]; - const vb = `0 0 ${d.width ?? 24} ${d.height ?? 24}`; - return `${d.body}`; -} - -const seen = new Set(); -const lines = []; -lines.push('
'); -for (const [label, names] of GROUPS) { - lines.push(`
${label.replaceAll('&', '&')}
`); - for (const name of names) { - seen.add(name); - lines.push(`
${render(name)}${name}
`); - } -} -const rest = Object.keys(ICON_DATA).filter((n) => !seen.has(n)); -if (rest.length) { - lines.push('
Other
'); - for (const name of rest) { - lines.push(`
${render(name)}${name}
`); - } -} -lines.push('
'); - -process.stdout.write(lines.join('\n') + '\n'); diff --git a/apps/kimi-web/scripts/gen-icon-data.mjs b/apps/kimi-web/scripts/gen-icon-data.mjs deleted file mode 100644 index d228a8d03a..0000000000 --- a/apps/kimi-web/scripts/gen-icon-data.mjs +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env node -// scripts/gen-icon-data.mjs — generate src/lib/icon-data.ts, the tree-shaken -// Remix Icon (ri) subset that backs apps/kimi-web's icon registry. -// -// Single source of truth for "which existing icon name maps to which Remix -// icon" is the NAME_TO_REMIX map below. The SVG bytes are pulled straight from -// @iconify-json/ri at generation time, so the registry stays library-sourced -// (no hand-copied SVG) yet fully offline and tree-shaken (only the icons we -// list here end up in the bundle). -// -// Run after changing the map: pnpm gen:icons - -import { writeFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import ri from '@iconify-json/ri/icons.json' with { type: 'json' }; -import { getIconData } from '@iconify/utils'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const OUT = resolve(__dirname, '../src/lib/icon-data.ts'); - -// Existing icon name → Remix icon name (ri:, prefix omitted here). -// Keep keys sorted within their group; the generated IconName union follows -// this order. Every value must exist in @iconify-json/ri (validated below). -const GROUPS = [ - ['Actions', { - plus: 'add-line', - 'chat-new': 'chat-new-line', - close: 'close-line', - check: 'check-line', - search: 'search-line', - copy: 'file-copy-line', - link: 'links-line', - 'external-link': 'external-link-line', - download: 'download-line', - undo: 'arrow-go-back-line', - send: 'arrow-up-line', - image: 'image-line', - settings: 'settings-3-line', - sliders: 'equalizer-line', - 'log-in': 'login-box-line', - }], - ['Navigation & layout', { - 'chevron-down': 'arrow-down-s-line', - 'chevron-right': 'arrow-right-s-line', - 'arrow-up': 'arrow-up-line', - 'arrow-down': 'arrow-down-line', - 'arrow-right': 'arrow-right-line', - minus: 'subtract-line', - 'panel-collapse': 'contract-left-line', - 'panel-expand': 'expand-right-line', - expand: 'expand-diagonal-line', - collapse: 'collapse-diagonal-line', - list: 'list-unordered', - sort: 'sort-desc', - grip: 'draggable', - }], - ['Files & tools', { - folder: 'folder-open-line', - 'folder-closed': 'folder-line', - 'folder-plus': 'folder-add-line', - 'folder-solid': 'folder-fill', - file: 'file-line', - 'file-text': 'file-text-line', - 'file-plus': 'file-add-line', - 'file-off': 'file-line', - 'image-off': 'image-line', - code: 'code-line', - terminal: 'terminal-box-line', - pencil: 'pencil-line', - glob: 'braces-line', - globe: 'global-line', - 'check-list': 'list-check', - bolt: 'flashlight-line', - 'git-pull-request': 'git-pull-request-line', - }], - ['Communication', { - message: 'message-line', - mail: 'mail-line', - user: 'user-line', - }], - ['Status & media', { - info: 'information-line', - 'help-circle': 'question-line', - 'alert-triangle': 'alert-line', - clock: 'time-line', - sparkles: 'sparkling-line', - play: 'play-fill', - stop: 'stop-fill', - star: 'star-fill', - 'star-outline': 'star-line', - 'dots-horizontal': 'more-line', - }], -]; - -const NAME_TO_REMIX = Object.assign({}, ...GROUPS.map(([, m]) => m)); - -// --- resolve + validate ---------------------------------------------------- -const missing = []; -const data = {}; -for (const [name, riName] of Object.entries(NAME_TO_REMIX)) { - const icon = getIconData(ri, riName); - if (!icon) { - missing.push(`${name} → ri:${riName}`); - continue; - } - data[name] = { body: icon.body, width: icon.width ?? 24, height: icon.height ?? 24 }; -} - -if (missing.length) { - console.error('gen-icon-data: the following Remix icons were not found in @iconify-json/ri:'); - for (const m of missing) console.error(` ${m}`); - process.exit(1); -} - -// --- emit ------------------------------------------------------------------ -const names = Object.keys(data); -const keyOf = (n) => (/^[a-zA-Z_$][\w$]*$/.test(n) ? n : JSON.stringify(n)); - -const lines = []; -lines.push('// GENERATED FILE — do not edit by hand.'); -lines.push('// Source of truth: scripts/gen-icon-data.mjs (run `pnpm gen:icons`).'); -lines.push('// Icons are Remix Icon (ri) — https://remixicon.com/ — Apache-2.0.'); -lines.push(''); -lines.push('export type IconName ='); -for (let i = 0; i < names.length; i++) { - const suffix = i === names.length - 1 ? ';' : ''; - lines.push(` | ${JSON.stringify(names[i])}${suffix}`); -} -lines.push(''); -lines.push('export interface IconData {'); -lines.push(' /** Inner SVG markup (paths/shapes), rendered inside our wrapper. */'); -lines.push(' body: string;'); -lines.push(' /** Source grid width in px. Remix icons are 24. */'); -lines.push(' width?: number;'); -lines.push(' /** Source grid height in px. Remix icons are 24. */'); -lines.push(' height?: number;'); -lines.push('}'); -lines.push(''); -lines.push('/** Existing name → fully-qualified Remix icon id. */'); -lines.push('export const NAME_TO_REMIX: Record = {'); -for (const name of names) lines.push(` ${keyOf(name)}: ${JSON.stringify('ri:' + NAME_TO_REMIX[name])},`); -lines.push('};'); -lines.push(''); -lines.push('/** Per-icon SVG data, pulled from @iconify-json/ri. */'); -lines.push('export const ICON_DATA: Record = {'); -for (const name of names) { - const { body, width, height } = data[name]; - lines.push(` ${keyOf(name)}: { body: ${JSON.stringify(body)}, width: ${width}, height: ${height} },`); -} -lines.push('};'); -lines.push(''); - -writeFileSync(OUT, lines.join('\n')); -console.log(`gen-icon-data: wrote ${names.length} icons to src/lib/icon-data.ts`); diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index f8e28149f3..26dfcaefc9 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -34,13 +34,17 @@ import { useFilePreview, type DetailTarget } from './composables/useFilePreview' import { useDetailPanel } from './composables/useDetailPanel'; import { useIsMobile } from './composables/useIsMobile'; import { openDialogCount } from './composables/dialogStack'; +import type { SwarmMember } from './composables/swarmGroups'; import ServerAuthDialog from './components/ServerAuthDialog.vue'; import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth'; import type { AppConfig, ThinkingLevel } from './api/types'; import { coerceThinkingForModel, commitLevel, segmentsFor } from './lib/modelThinking'; +import { stripSkillPrefix } from './lib/slashCommands'; import Button from './components/ui/Button.vue'; import IconButton from './components/ui/IconButton.vue'; import Icon from './components/ui/Icon.vue'; +import InternalBuildBanner from './components/InternalBuildBanner.vue'; +import { isMacosDesktop } from './lib/desktopFlag'; // Hydrate the server-transport credential (fragment token or sessionStorage) // BEFORE the client connects, so the first REST/WS calls already carry it. @@ -55,6 +59,15 @@ const showServerAuth = computed( () => !client.dangerousBypassAuth.value && authRequired.value, ); provide('resolveImage', client.resolveImageUrl); +// Live swarm member roster for the inline AgentSwarm tool card. Sourced from the +// AppTask store so the card shows each subagent's live phase; on refresh the +// tasks are gone and the card falls back to the parsed tool result. Includes +// single-member "swarms" (e.g. AgentSwarm with one resume_agent_ids entry), +// which buildSwarmGroups filters out for the badge counter. +provide( + 'resolveSwarmMembers', + (toolCallId: string): SwarmMember[] => client.swarmMembersByToolCallId.value.get(toolCallId) ?? [], +); const { t } = useI18n(); // KAP/daemon debug panel — opt-in via ?debug=1 or localStorage kimi-web.debug=1. @@ -202,6 +215,7 @@ const { sidebarMax, sessionColWidth, sidebarCollapsed, + sidebarDragging, sideWidth, loadSidebarCollapsed, toggleSidebarCollapse, @@ -328,7 +342,27 @@ function openLogin(): void { async function handleSelectModel(modelId: string): Promise { showModelPicker.value = false; - await client.setModel(modelId); + // Same semantics as the composer dropdown rows: the overlay is just the + // "more models" continuation of the same flow, so it must also bump the + // global default (see handleComposerSelectModel). + await handleComposerSelectModel(modelId); +} + +async function handleComposerSelectModel(modelId: string): Promise { + // Primary action: switch the active session's model via POST /sessions/{id}/profile + // (same as the model picker overlay). Awaited so the model pill reflects the + // result and failures surface. In the onboarding draft this just stores the + // pick for the first session. + const switched = await client.setModel(modelId); + + // Side effect: also bump the daemon-wide default model via POST /config so + // new sessions inherit the choice. Fire-and-forget — it must not block the UI + // or mask the session switch. Only after a confirmed switch (a stale/invalid + // alias must not become the global default), and skip when it already + // matches the default. + if (switched && modelId !== client.defaultModel.value) { + void client.updateConfig({ defaultModel: modelId }); + } } async function handleAddProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }): Promise { @@ -476,13 +510,22 @@ function handleCommand(cmd: string): void { break; default: { // Not a built-in command → treat it as a session skill activation - // (the user picked `/` from the menu, or typed `/ args`). - // The daemon answers an unknown name with skill.not_found, surfaced as a - // warning, so a stray slash is harmless. + // (the user picked `/skill:` from the menu, or typed + // `/ args`). Strip the `skill:` display prefix — the REST API + // takes the bare skill name. The daemon answers an unknown name with + // skill.not_found, surfaced as a warning, so a stray slash is harmless. + // With no active session, create one first (same path as the first + // prompt) so the activation isn't silently dropped on the new-session + // screen. const space = cmd.indexOf(' '); - const name = (space === -1 ? cmd : cmd.slice(0, space)).slice(1); + const name = stripSkillPrefix((space === -1 ? cmd : cmd.slice(0, space)).slice(1)); const args = space === -1 ? undefined : cmd.slice(space + 1).trim() || undefined; - if (name) void client.activateSkill(name, args); + if (!name) break; + if (!client.activeSessionId.value && client.activeWorkspaceId.value) { + void client.startSessionAndActivateSkill(client.activeWorkspaceId.value, name, args); + } else { + void client.activateSkill(name, args); + } break; } } @@ -605,13 +648,18 @@ function openPr(url: string): void {
@@ -681,7 +722,6 @@ function openPr(url: string): void { :tasks="client.tasks.value" :todos="client.todos.value" :goal="client.goal.value" - :swarms="client.swarms.value" :activation-badges="client.activationBadges.value" :status="client.status.value" :thinking="client.thinking.value" @@ -699,6 +739,7 @@ function openPr(url: string): void { :search-files="client.searchFiles" :upload-image="client.uploadImage" :sending="client.isSending.value" + :starting="client.isStartingFirstPrompt.value" :fast-moon="client.fastMoon.value" :file-reload-key="client.activeSessionId.value" :session-loading="client.sessionLoading.value" @@ -743,7 +784,7 @@ function openPr(url: string): void { @archive-session="(id) => client.archiveSession(id)" @compact="client.compact()" @pick-model="openModelPicker()" - @select-model="client.setModel($event)" + @select-model="handleComposerSelectModel($event)" @open-file="openFilePreview($event)" @open-media="openMediaPreview($event)" @open-thinking="openThinkingPanel($event)" @@ -753,8 +794,29 @@ function openPr(url: string): void { @edit-message="handleEditMessage" /> + + + + + + + + .side { grid-column: 1; } +.side-handle { grid-column: 2; } +.app:not(.mobile) > .con { grid-column: 3; } +.preview-handle { grid-column: 4; } + +/* Sidebar toggle — floating button pinned to the top-left corner. On macOS + desktop it is resident (rendered in both states beside the traffic lights); + on Windows/web it only appears while the sidebar is collapsed (the collapse + button lives inside the sidebar header). While collapsed the conversation + header pads left so its content clears the button (global block below). */ +.sidebar-toggle-btn { + position: absolute; + /* Vertically centered in the 48px conversation header. */ + top: 11px; + left: 16px; + z-index: var(--z-sticky); + /* Fade in on appearance (Windows/web: only rendered while collapsed, so + this plays as the sidebar finishes sliding away). macOS disables it. */ + animation: sidebar-toggle-btn-in 0.18s var(--ease-out) 0.12s backwards; + /* Floats over the macOS-desktop window-drag header; keep it clickable. */ + -webkit-app-region: no-drag; +} +/* macOS desktop (hidden title bar): resident beside the floating traffic + lights (green light's right edge ≈ 68px; 72 keeps a gap that matches the + lights' own 8px rhythm); no entrance animation since it never appears. */ +.app.macos-desktop .sidebar-toggle-btn { + left: 72px; + animation: none; } -/* The collapsed rail occupies track 1; keep the main pane pinned to the - conversation track even though the sidebar/handle are display:none. */ -.app.sidebar-collapsed > .con { - grid-column: 3; +@keyframes sidebar-toggle-btn-in { + from { opacity: 0; } +} + +/* Internal-build tag pinned to the app's bottom-right corner (desktop app + only — the component renders nothing elsewhere). Informational: never + intercepts pointer input. */ +.internal-build-fab { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + z-index: var(--z-sticky); + pointer-events: none; } /* Mobile single-column shell: slim top bar (auto) over the full-width @@ -1167,4 +1268,19 @@ function openPr(url: string): void { one continuous line across the layout. */ --panel-head-h: 48px; } + +/* Sidebar collapsed (desktop): the conversation header pads left so its + content clears the floating sidebar toggle (.sidebar-toggle-btn) — and the + macOS traffic lights on desktop builds. Animated in step with the sidebar + width transition. Cross-component rule (ChatHeader renders the header), so + it lives in this global block. */ +.app:not(.mobile) .chat-header { + transition: padding-left 0.28s cubic-bezier(0.4, 0, 0.2, 1); +} +.app.sidebar-collapsed .chat-header { + padding-left: 52px; +} +.app.sidebar-collapsed.macos-desktop .chat-header { + padding-left: 108px; +} diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 9561aa7134..6f328966c0 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -499,10 +499,11 @@ export interface AgentProjector { /** * Seed mid-turn state from a session snapshot's `in_flight_turn` (v2 sync): * resets per-session state, builds the partially-streamed assistant message - * (thinking + text + running tool_use parts), and returns the AppEvents - * (sessionStatusChanged + messageCreated) to apply to the reducer. Live - * deltas continue appending; their wire `offset` aligns against the seeded - * text so the overlap window around snapshot/subscribe is exact. + * (thinking + text + running tool_use parts), and returns the messageCreated + * AppEvent to apply to the reducer. Live deltas continue appending; their + * wire `offset` aligns against the seeded text so the overlap window around + * snapshot/subscribe is exact. Session status is NOT seeded here — the REST + * snapshot's `session.status` is the authoritative value. */ seedInFlight(sessionId: string, turn: AppInFlightTurn): AppEvent[]; /** Reset all per-session state (call on re-subscribe / resync). */ @@ -574,16 +575,7 @@ export function createAgentProjector(): AgentProjector { s.turnTextLen = turn.assistantText.length; s.turnThinkLen = turn.thinkingText.length; - return [ - { - type: 'sessionStatusChanged', - sessionId, - status: 'running', - previousStatus: 'idle', - currentPromptId: promptId, - }, - { type: 'messageCreated', message: cloneMessage(msg) }, - ]; + return [{ type: 'messageCreated', message: cloneMessage(msg) }]; } function project( @@ -701,6 +693,12 @@ export function createAgentProjector(): AgentProjector { // ----------------------------------------------------------------------- case 'turn.started': { // Bind turnId → promptId. Generate a synthetic one if none was pre-bound. + // Session status is intentionally NOT projected here — the daemon's + // `event.session.status_changed` is the single source of status + // transitions (it carries the authoritative previousStatus / + // currentPromptId and dedupes per real transition); projecting a + // second running/idle event per turn from the raw stream made every + // turn-end consumer (notifications, sounds) fire twice. const turnId: number = p?.turnId; const existingPromptId = s.currentPromptId ?? ulid('pr_'); s.currentPromptId = existingPromptId; @@ -710,14 +708,6 @@ export function createAgentProjector(): AgentProjector { // Fresh turn → fresh per-turn stream offsets. s.turnTextLen = 0; s.turnThinkLen = 0; - - out.push({ - type: 'sessionStatusChanged', - sessionId, - status: 'running', - previousStatus: 'idle', - currentPromptId: existingPromptId, - }); break; } @@ -973,14 +963,8 @@ export function createAgentProjector(): AgentProjector { const usageSnapshot = buildUsageSnapshot(s); out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot }); - const newStatus = - reason === 'cancelled' || reason === 'failed' || reason === 'filtered' ? 'aborted' : 'idle'; - out.push({ - type: 'sessionStatusChanged', - sessionId, - status: newStatus, - previousStatus: 'running', - }); + // No sessionStatusChanged here — see turn.started. The daemon's + // `event.session.status_changed` flips the session to idle/aborted. // Clear per-turn state. Reset the stream offsets too so a stale length // from this turn can't wedge the next turn's delta alignment into a @@ -1217,10 +1201,44 @@ export function createAgentProjector(): AgentProjector { break; } + // ----------------------------------------------------------------------- + case 'cron.fired': { + // A scheduled reminder fired into the session. agent-core persists the + // injected user message (so a refresh renders it via messagesToTurns), + // but turn.steer() does NOT broadcast a prompt.submitted / message.created + // for it — synthesize one here so the notice shows up live too. A later + // snapshot reload replaces the message log wholesale, so this synthesized + // copy never duplicates the persisted one. The promptId is intentionally + // omitted: the web client caches every user message's promptId into + // promptIdBySession for Stop/abort, and a synthetic id the daemon would + // reject would clobber the real active promptId. The reducer already skips + // optimistic-echo reconciliation for cron-origin messages, so no promptId + // is needed for de-dup either. + const origin = p?.origin; + const promptText = stringField(p ?? {}, 'prompt'); + if ( + origin && + typeof origin === 'object' && + (origin as Record)['kind'] === 'cron_job' && + promptText + ) { + const msg: AppMessage = { + id: ulid('cron_'), + sessionId, + role: 'user', + content: [{ type: 'text', text: promptText }], + createdAt: new Date().toISOString(), + metadata: { origin: origin as Record }, + }; + s.messages.push(msg); + out.push({ type: 'messageCreated', message: cloneMessage(msg) }); + } + break; + } + // ----------------------------------------------------------------------- // Explicitly known but not projected case 'compaction.blocked': - case 'cron.fired': case 'hook.result': case 'mcp.server.status': case 'skill.activated': @@ -1301,6 +1319,7 @@ const KNOWN_AGENT_CORE_TYPES = new Set([ 'subagent.failed', 'background.task.started', 'background.task.terminated', + 'cron.fired', ]); /** diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 5c5049b3c3..523c44199a 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -293,6 +293,7 @@ export class DaemonKimiWebApi implements KimiWebApi { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean; + archivedOnly?: boolean; excludeEmpty?: boolean; }, ): Promise> { @@ -302,6 +303,7 @@ export class DaemonKimiWebApi implements KimiWebApi { page_size: input?.pageSize, status: input?.status ? toWireSessionStatus(input.status) : undefined, include_archive: input?.includeArchive, + archived_only: input?.archivedOnly, exclude_empty: input?.excludeEmpty, // PRESUMED — daemon supports ?workspace_id= once the registry ships; it // ignores unknown query params until then, so this is safe to always send. @@ -420,6 +422,16 @@ export class DaemonKimiWebApi implements KimiWebApi { return data; } + // POST /sessions/{id}:restore — clear the archived flag. The daemon returns + // the full restored session, so callers can merge it straight back into lists. + async restoreSession(sessionId: string): Promise { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:restore`, + {}, + ); + return toAppSession(data); + } + // ------------------------------------------------------------------------- // Messages // ------------------------------------------------------------------------- @@ -1343,11 +1355,12 @@ export class DaemonKimiWebApi implements KimiWebApi { }, seedSnapshot(sessionId: string, snapshot: AppSessionSnapshot): void { // Rebuild the projector's mid-turn state from the snapshot. The - // resulting AppEvents (running status + partially-streamed assistant - // message) flow through the SAME onEvent path as live events, so the - // rendering layer needs no special handling. When there is no - // in-flight turn we only reset, so stale turn state can't leak into - // the freshly-loaded message list. + // resulting AppEvent (the partially-streamed assistant message) flows + // through the SAME onEvent path as live events, so the rendering layer + // needs no special handling; session status comes from the snapshot's + // authoritative session record. When there is no in-flight turn we + // only reset, so stale turn state can't leak into the freshly-loaded + // message list. if (snapshot.inFlightTurn === null) { projector.reset(sessionId); return; @@ -1385,6 +1398,12 @@ export class DaemonKimiWebApi implements KimiWebApi { markSideChannelAgent(agentId: string): void { projector.markSideChannelAgent(agentId); }, + health(): { connected: boolean; open: boolean; stale: boolean } { + return socket.health(); + }, + reconnect(): void { + socket.reconnect(); + }, close(): void { socket.close(); }, diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 5bec650985..970fbc925a 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -119,6 +119,11 @@ function isOptimisticUserMessage(message: AppMessage): boolean { ); } +function isCronOriginMessage(message: AppMessage): boolean { + const origin = message.metadata?.['origin'] as { kind?: string } | undefined; + return origin?.kind === 'cron_job' || origin?.kind === 'cron_missed'; +} + function sameMessageContent(a: AppMessage, b: AppMessage): boolean { return JSON.stringify(a.content) === JSON.stringify(b.content); } @@ -388,7 +393,12 @@ export function reduceAppEvent( const msgs = next.messagesBySession[sid] ?? []; const exists = msgs.some((m) => m.id === event.message.id); if (!exists) { - if (event.message.role === 'user') { + // Cron-injected user messages (origin cron_job/cron_missed) carry the + // reminder's prompt as their text, which can coincide with a still- + // optimistic user message. They must append as their own turn rather + // than reconcile into (and replace) that optimistic echo — so skip the + // echo lookup entirely for them. + if (event.message.role === 'user' && !isCronOriginMessage(event.message)) { const optimisticIndex = findOptimisticUserEchoIndex(msgs, event.message); if (optimisticIndex !== -1) { const updated = [...msgs]; @@ -539,12 +549,19 @@ export function reduceAppEvent( next.tasksBySession[sid] = [...list, event.task]; } else { const patched = [...list]; + const previous = list[idx]!; // The projected task does not carry reducer-owned accumulated progress; // preserve it across the replacement so subagent output keeps growing. + // A resync also rebuilds skeleton tasks without their identity metadata, + // so keep the previous value when the projected task omits it. patched[idx] = { ...event.task, - outputLines: list[idx]!.outputLines, - text: list[idx]!.text, + outputLines: previous.outputLines, + text: previous.text, + swarmIndex: event.task.swarmIndex ?? previous.swarmIndex, + parentToolCallId: event.task.parentToolCallId ?? previous.parentToolCallId, + subagentType: event.task.subagentType ?? previous.subagentType, + runInBackground: event.task.runInBackground ?? previous.runInBackground, }; next.tasksBySession[sid] = patched; } diff --git a/apps/kimi-web/src/api/daemon/ws.ts b/apps/kimi-web/src/api/daemon/ws.ts index 0ae23a94d7..00e3230c12 100644 --- a/apps/kimi-web/src/api/daemon/ws.ts +++ b/apps/kimi-web/src/api/daemon/ws.ts @@ -13,6 +13,11 @@ import type { WireEvent, WireServerFrame } from './wire'; // Sec-WebSocket-Protocol subprotocol instead. const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; +// A socket with no incoming frames for this long is presumed half-open even if +// the browser still reports OPEN (no onclose fired). Derived as 2x the server +// heartbeat, with a floor so a misconfigured tiny heartbeat can't thrash. +const STALE_SOCKET_FLOOR_MS = 30_000; + // --------------------------------------------------------------------------- // Handler interface // --------------------------------------------------------------------------- @@ -84,6 +89,14 @@ export class DaemonEventSocket { private reconnectAttempts = 0; private reconnectTimer: ReturnType | null = null; + /** Server-advertised heartbeat interval (ms); falls back to the daemon default. */ + private heartbeatMs = 30_000; + /** + * Epoch ms of the most recent frame (or the connect attempt). Used to detect + * a silent-half-open socket that the browser never fires `onclose` for. + */ + private lastActivityAt = 0; + constructor( private readonly wsUrl: string, private readonly clientId: string, @@ -94,6 +107,7 @@ export class DaemonEventSocket { connect(): void { if (this.ws !== null || this.closed) return; + this.lastActivityAt = Date.now(); traceWsLifecycle('connect', { url: this.wsUrl, attempt: this.reconnectAttempts }); const credential = getCredential(); const protocols = @@ -107,6 +121,8 @@ export class DaemonEventSocket { }; ws.onmessage = (ev: MessageEvent) => { + // Any received frame proves the link is alive; reset the stale detector. + this.lastActivityAt = Date.now(); try { const frame = JSON.parse(String(ev.data)) as WireServerFrame; traceWsIn(frame); @@ -256,6 +272,61 @@ export class DaemonEventSocket { } } + /** + * Snapshot the socket's health. `stale` is true when no frame has arrived for + * longer than 2x the server heartbeat (floored at {@link STALE_SOCKET_FLOOR_MS}). + * The browser may still report OPEN on a half-open connection that no longer + * delivers data, so foreground recovery keys on the staleness signal rather + * than the raw readyState. + */ + health(): { connected: boolean; open: boolean; stale: boolean } { + const open = this.ws !== null && this.ws.readyState === WebSocket.OPEN; + const threshold = Math.max(this.heartbeatMs * 2, STALE_SOCKET_FLOOR_MS); + const stale = this.lastActivityAt > 0 && Date.now() - this.lastActivityAt > threshold; + return { connected: this.connected, open, stale }; + } + + /** + * Force a clean reconnect. Used to recover from a silent-half-open socket + * (e.g. after the browser froze a background tab) where `onclose` never + * fires, so the automatic backoff reconnect wired into `onclose` is never + * triggered. + * + * Tears down the current socket without waiting for `onclose`, resets the + * handshake state, and opens a fresh socket immediately; `onServerHello` + * re-sends every subscription at the last durable cursor. No-op after + * {@link close()}. + */ + reconnect(): void { + if (this.closed) return; + // Cancel any pending automatic reconnect — we're reconnecting synchronously. + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + const old = this.ws; + if (old !== null) { + // Detach before closing so the old socket's `onclose` doesn't race our + // fresh connect (it would call scheduleReconnect and clobber `this.ws`). + old.onopen = null; + old.onmessage = null; + old.onerror = null; + old.onclose = null; + try { + old.close(1000, 'reconnect'); + } catch { + // Ignore — the socket may already be closing. + } + } + const wasConnected = this.connected; + this.ws = null; + this.connected = false; + if (wasConnected) { + this.handlers.onConnectionState(false); + } + this.connect(); + } + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- @@ -266,9 +337,12 @@ export class DaemonEventSocket { // eslint-disable-next-line @typescript-eslint/no-explicit-any const frame = rawFrame as any; switch ((rawFrame as { type: string }).type) { - case 'server_hello': + case 'server_hello': { + const hb = (frame.payload as { heartbeat_ms?: unknown } | undefined)?.heartbeat_ms; + if (typeof hb === 'number' && hb > 0) this.heartbeatMs = hb; this.onServerHello(); break; + } case 'ping': this.send({ type: 'pong', payload: { nonce: frame.payload.nonce } }); diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 16adbbcd3c..728b62a23c 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -539,6 +539,19 @@ export interface KimiEventConnection { * instead of dropping them like background subagents. */ markSideChannelAgent(agentId: string): void; + /** + * Report the underlying socket's health. Used to detect a silent-half-open + * connection after the tab was frozen in the background: the browser still + * reports OPEN (so no auto-reconnect) yet no frames have arrived for a while. + */ + health(): { connected: boolean; open: boolean; stale: boolean }; + /** + * Force a clean reconnect of the underlying socket. Used to recover from a + * silent-half-open (background-tab freeze) where onclose never fires. The + * reconnect handshake re-subscribes at the last durable cursor. No-op after + * close(). + */ + reconnect(): void; close(): void; } @@ -645,7 +658,7 @@ export interface AppSessionWarning { export interface KimiWebApi { getHealth(): Promise<{ status: 'ok'; uptimeSec: number }>; getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record; openInApps: string[]; dangerousBypassAuth: boolean }>; - listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean; excludeEmpty?: boolean }): Promise>; + listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean; archivedOnly?: boolean; excludeEmpty?: boolean }): Promise>; createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise; /** Fetch one session by id (deep links beyond the first listSessions page). */ getSession(sessionId: string): Promise; @@ -653,6 +666,7 @@ export interface KimiWebApi { getSessionStatus(sessionId: string): Promise; getSessionWarnings(sessionId: string): Promise; archiveSession(sessionId: string): Promise<{ archived: true }>; + restoreSession(sessionId: string): Promise; listMessages(sessionId: string, input?: PageRequest & { role?: AppMessageRole }): Promise>; /** v2 initial sync: atomic session state + `asOfSeq` watermark + epoch. */ getSessionSnapshot(sessionId: string): Promise; diff --git a/apps/kimi-web/src/components/InternalBuildBanner.vue b/apps/kimi-web/src/components/InternalBuildBanner.vue index e2638aaad8..a45748f7bf 100644 --- a/apps/kimi-web/src/components/InternalBuildBanner.vue +++ b/apps/kimi-web/src/components/InternalBuildBanner.vue @@ -5,8 +5,8 @@ import { isDesktop } from '../lib/desktopFlag'; const { t } = useI18n(); -// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders an inline -// tag meant to sit next to the "Kimi Code" brand in the sidebar header. +// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders a small +// tag pinned to the app's bottom-right corner (positioned by App.vue). const show = isDesktop; diff --git a/apps/kimi-web/src/components/SessionRow.vue b/apps/kimi-web/src/components/SessionRow.vue index f51e806531..30158be01c 100644 --- a/apps/kimi-web/src/components/SessionRow.vue +++ b/apps/kimi-web/src/components/SessionRow.vue @@ -254,7 +254,7 @@ defineExpose({ closeMenu }); :label="t('sidebar.options')" @click.stop="toggleMenu($event)" > - +
@@ -287,22 +287,24 @@ defineExpose({ closeMenu }); .se { /* --sb-* vars come from .side in Sidebar.vue: the title starts at --sb-pad-x + --sb-gutter + --sb-gap, exactly under the workspace name. - The row is an inset pill: a 6px horizontal margin + 10px padding lands the - leading icon at --sb-pad-x (16px), aligned with the workspace header. */ + The row is an inset pill: the .sessions container's --sb-inset padding + + the row's own padding land the leading slot at --sb-pad-x, aligned with + the workspace header. */ display: block; margin: 0; - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-md); + padding: 8px var(--space-2); + border-radius: var(--radius-sm); font-family: var(--font-ui); color: var(--color-text); cursor: pointer; position: relative; } -.se:hover { background: var(--color-surface-sunken); color: var(--color-text); } +.se:hover { background: var(--sb-hover, var(--color-surface-sunken)); color: var(--color-text); } +/* Selected: neutral fill (NOT accent-tinted — selection reads as "where I + am", the accent stays reserved for actions and status). */ .se.on { - background: var(--color-accent-soft); - color: var(--color-accent-hover); - box-shadow: inset 0 0 0 1px var(--color-accent-bd); + background: var(--color-selected); + color: var(--color-text); } .row { @@ -310,9 +312,9 @@ defineExpose({ closeMenu }); align-items: center; gap: var(--sb-gap, 6px); min-width: 0; - /* Floor the row at the hover-kebab height (IconButton sm = 26px) so swapping - the timestamp for the kebab on hover doesn't grow the row. */ - min-height: 26px; + /* Row height is font-driven: title line-height (13×1.25≈16px) + 2×5px + .se padding ≈ 26px. The hover kebab is absolutely positioned (see .act) + so it never contributes to row height and can't cause hover jitter. */ } .left { @@ -341,8 +343,9 @@ defineExpose({ closeMenu }); .t { color: inherit; - font-size: 15px; - font-weight: var(--weight-regular); + font-size: var(--ui-font-size-sm); + font-weight: 450; + line-height: var(--leading-tight); flex: 1; min-width: 0; overflow: hidden; @@ -353,29 +356,39 @@ defineExpose({ closeMenu }); .ts { color: var(--color-text-faint); font-size: var(--text-xs); - font-family: var(--font-mono); + font-family: var(--font-ui); + font-weight: 475; + line-height: var(--leading-tight); + font-variant-numeric: tabular-nums; + text-align: right; } -/* Trailing action slot: time and kebab share one grid cell (grid-area:1/1). - Both stay in the layout and swap via `visibility` (never display:none), so - the slot width = max(time width, IconButton sm 26px) is identical in hover - and rest — the badges and title don't reflow, eliminating hover jitter. - `.act .kebab` out-specificities IconButton's own display so the hidden - default wins. */ +/* Trailing action slot: the relative time (in flow) sets the slot size; the + kebab is absolutely positioned over it and swapped via `visibility`, so it + contributes neither height (the row stays font-driven) nor width changes + (min-width reserves the kebab's footprint, the title doesn't reflow). */ .act { - display: inline-grid; + position: relative; flex: none; + display: inline-flex; align-items: center; - justify-items: center; + justify-content: flex-end; + /* Reserve the kebab's width so the trailing slot (and thus the title) never + shifts between the time and the kebab, even for short times like "2m". */ + min-width: 26px; +} +.act .kebab { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + visibility: hidden; } -.act .ts, -.act .kebab { grid-area: 1 / 1; } -.act .kebab { visibility: hidden; } .se:hover .act .kebab, .act:has(.kebab.open) .kebab { visibility: visible; } .se:hover .act .ts, .act:has(.kebab.open) .ts { visibility: hidden; } -.kebab.open { color: var(--color-text); background: var(--color-surface-sunken); } +.kebab.open { color: var(--color-text); background: var(--sb-hover, var(--color-surface-sunken)); } /* Fixed + anchored to the ⋯ button via inline style (see positionMenu); the menu is teleported to so the collapsing list's `overflow: hidden` can't clip it. */ @@ -409,15 +422,10 @@ defineExpose({ closeMenu }); .sessions .se { margin: 0; - border-radius: var(--radius-md); - /* Trim the row padding by the inset margin so the title still starts at the - same x as the workspace name (whose header has no inset). */ - padding: var(--space-1) calc(var(--sb-pad-x, 12px) - var(--space-2)); -} -.sessions .se:hover { background: var(--panel2); } -.sessions .se.on { - background: var(--color-accent-soft); - box-shadow: inset 0 0 0 1px var(--color-accent-bd); + border-radius: var(--radius-sm); + /* Trim the row padding by the container inset so the title still starts at + the same x as the workspace name (whose header has no inset). */ + padding: 8px calc(var(--sb-pad-x, 20px) - var(--sb-inset, 12px)); } .sessions .se .rename-input { border-radius: var(--radius-sm); font-family: var(--sans); } .sessions .se .kebab { border-radius: var(--radius-sm); } diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index 0a31ae5fc0..8c9127e2eb 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -9,18 +9,16 @@ import { serverEndpointLabel } from '../api/config'; import { copyTextToClipboard } from '../lib/clipboard'; import { loadCollapsedWorkspaces, - loadShowWorkspacePaths, saveCollapsedWorkspaces, - saveShowWorkspacePaths, } from '../lib/storage'; import { moveInOrder, type DropPosition, type WorkspaceSortMode } from '../lib/workspaceOrder'; import type { Session, WorkspaceGroup as WorkspaceGroupType, WorkspaceView } from '../types'; import SearchSessionsDialog from './dialogs/SearchSessionsDialog.vue'; import WorkspaceGroup from './WorkspaceGroup.vue'; -import InternalBuildBanner from './InternalBuildBanner.vue'; import { isMacosDesktop } from '../lib/desktopFlag'; import IconButton from './ui/IconButton.vue'; import Icon from './ui/Icon.vue'; +import Kbd from './ui/Kbd.vue'; import Menu from './ui/Menu.vue'; import MenuItem from './ui/MenuItem.vue'; import { useConfirmDialog } from '../composables/useConfirmDialog'; @@ -49,6 +47,12 @@ const props = withDefaults( unreadBySession?: Record; /** Width (px) of the session column, driven by the App resize handle. */ colWidth?: number; + /** True when the sidebar is collapsed: the container animates to width 0 + * (content keeps `colWidth` and is clipped), then hides itself. */ + collapsed?: boolean; + /** True while the resize handle is dragged — disables the width transition + * so the sidebar follows the pointer 1:1. */ + dragging?: boolean; }>(), { activeWorkspace: null, @@ -57,6 +61,8 @@ const props = withDefaults( pendingBySession: () => ({}), unreadBySession: () => ({}), colWidth: 220, + collapsed: false, + dragging: false, }, ); @@ -83,6 +89,7 @@ const emit = defineEmits<{ // Session search dialog (Spotlight-style; filters title + last prompt) // --------------------------------------------------------------------------- const showSearch = ref(false); +const sessionSearchKeys = isAppleShortcutPlatform() ? ['⌘', 'K'] : ['Ctrl', 'K']; function openSearch(): void { // Sessions are loaded per-workspace (first page only); lazily drain the rest @@ -101,7 +108,15 @@ function onSearchKeydown(e: KeyboardEvent): void { onMounted(() => window.addEventListener('keydown', onSearchKeydown)); onBeforeUnmount(() => window.removeEventListener('keydown', onSearchKeydown)); -// Scroll-linked header seam: the .btn-wrap bottom border/shadow only appears +function isAppleShortcutPlatform(): boolean { + if (typeof navigator === 'undefined') return false; + if (/Mac|iPod|iPhone|iPad/.test(navigator.platform)) return true; + + const userAgentData = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData; + return userAgentData?.platform === 'macOS' || userAgentData?.platform === 'iOS'; +} + +// Scroll-linked header seam: the .search-wrap bottom border/shadow only appears // once the session list has actually scrolled, so an unscrolled list shows no // abrupt boundary. const sessionsScrolled = ref(false); @@ -176,18 +191,6 @@ function onLoadMore(id: string): void { emit('loadMoreSessions', id); } -// --------------------------------------------------------------------------- -// Workspace path display (toggle in the Workspaces section header) -// --------------------------------------------------------------------------- -// Off by default so the list stays compact; turning it on reveals every -// workspace's root path as a stable subtitle (no hover-induced layout shift). -const showWorkspacePaths = ref(loadShowWorkspacePaths()); - -function toggleShowWorkspacePaths(): void { - showWorkspacePaths.value = !showWorkspacePaths.value; - saveShowWorkspacePaths(showWorkspacePaths.value); -} - // --------------------------------------------------------------------------- // Workspace drag-to-reorder // --------------------------------------------------------------------------- @@ -477,11 +480,6 @@ function chooseSortMode(mode: WorkspaceSortMode): void { closeSectionMenu(); } -function toggleShowWorkspacePathsFromMenu(): void { - toggleShowWorkspacePaths(); - closeSectionMenu(); -} - onBeforeUnmount(() => { document.removeEventListener('mousedown', onGhMenuDocClick, true); document.removeEventListener('mousedown', onWsMenuDocClick); @@ -550,51 +548,49 @@ onBeforeUnmount(() => { diff --git a/apps/kimi-web/src/components/chat/GoalStrip.vue b/apps/kimi-web/src/components/chat/GoalStrip.vue index c978656b80..11dab6b78c 100644 --- a/apps/kimi-web/src/components/chat/GoalStrip.vue +++ b/apps/kimi-web/src/components/chat/GoalStrip.vue @@ -27,6 +27,15 @@ const tokenPct = computed(() => { return Math.max(0, Math.min(100, Math.round((props.goal.tokensUsed / budget) * 100))); }); +function goalStatusLabel(status: AppGoal['status']): string { + switch (status) { + case 'active': return t('status.goalStatusActive'); + case 'paused': return t('status.goalStatusPaused'); + case 'blocked': return t('status.goalStatusBlocked'); + case 'complete': return t('status.goalStatusComplete'); + } +} + function formatMs(ms: number): string { const sec = Math.max(0, Math.round(ms / 1000)); const min = Math.floor(sec / 60); @@ -42,13 +51,13 @@ function formatMs(ms: number): string { @@ -99,8 +119,21 @@ function formatMs(ms: number): string { diff --git a/apps/kimi-web/src/components/chat/QuestionCard.vue b/apps/kimi-web/src/components/chat/QuestionCard.vue index eff842d6e4..3617167790 100644 --- a/apps/kimi-web/src/components/chat/QuestionCard.vue +++ b/apps/kimi-web/src/components/chat/QuestionCard.vue @@ -452,7 +452,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown)); } .qstep { color: var(--color-text-muted); - font: var(--text-xs) var(--font-mono); + font: var(--text-xs) var(--font-ui); margin-left: var(--space-1); } /* Minimize toggle — pinned to the right of the header row. */ @@ -482,6 +482,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown)); align-items: center; gap: var(--space-2); margin-bottom: var(--space-3); + font-family: var(--font-ui); } .qstep-dot { display: inline-flex; @@ -493,7 +494,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown)); border: 1px solid var(--color-line); background: var(--color-surface); color: var(--color-text-muted); - font: var(--text-xs) var(--font-mono); + font: var(--text-xs) var(--font-ui); cursor: pointer; padding: 0; transition: background var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out); @@ -545,7 +546,8 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown)); .qopt-key { color: var(--color-text-muted); - font: var(--text-xs) var(--font-mono); + font: var(--text-xs) var(--font-ui); + font-weight: var(--weight-medium); width: 12px; flex: none; text-align: center; @@ -560,8 +562,16 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown)); flex-direction: column; gap: 2px; } -.qopt-label { color: var(--color-text); } -.qopt-desc { color: var(--color-text-muted); font: var(--text-xs)/var(--leading-normal) var(--font-ui); } +.qopt-label { + color: var(--color-text); + font-size: var(--text-base); + font-weight: var(--weight-medium); +} +.qopt-desc { + color: var(--color-text-muted); + font: var(--text-xs)/var(--leading-normal) var(--font-ui); + font-weight: var(--weight-medium); +} .chk, .rad { font: var(--text-base) var(--font-mono); } @@ -603,7 +613,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown)); .qstep-dot { width: 28px; height: 28px; - font: var(--text-xs) var(--font-mono); + font: var(--text-xs) var(--font-ui); } /* Options → taller, finger-friendly rows. Label + description already stack diff --git a/apps/kimi-web/src/components/chat/SwarmCard.vue b/apps/kimi-web/src/components/chat/SwarmCard.vue deleted file mode 100644 index bb9d70c0bd..0000000000 --- a/apps/kimi-web/src/components/chat/SwarmCard.vue +++ /dev/null @@ -1,208 +0,0 @@ - - - - - diff --git a/apps/kimi-web/src/components/chat/ThinkingBlock.vue b/apps/kimi-web/src/components/chat/ThinkingBlock.vue index 41200a2177..9fbb37db4e 100644 --- a/apps/kimi-web/src/components/chat/ThinkingBlock.vue +++ b/apps/kimi-web/src/components/chat/ThinkingBlock.vue @@ -35,7 +35,7 @@ const isFoldable = computed(() => props.foldable && paragraphs.value.length > 1) const open = computed(() => props.streaming || !isFoldable.value); /** Last non-empty paragraph, shown as the collapsed teaser. */ -const teaser = computed(() => paragraphs.value.pop() ?? ''); +const teaser = computed(() => paragraphs.value.at(-1) ?? ''); const bodyEl = ref(null); @@ -99,7 +99,13 @@ watch( } .tc-anim, .prev-anim { + /* min-height: 0 is required for the 0fr/1fr grid collapse to actually shrink + below the tracks' content. Without it, an inner scroll container (`.tc`, + overflow-y: auto) contributes its content as the automatic minimum, so the + row keeps its streaming height and never collapses to the short teaser — + most visible on iOS Safari. */ overflow: hidden; + min-height: 0; } /* Hover hints clickability (opens the full text in the side panel) */ @@ -112,14 +118,16 @@ watch( .prev { color: var(--color-text-faint); - font: var(--text-base)/var(--leading-relaxed) var(--font-mono); + font: var(--text-base)/var(--leading-relaxed) var(--font-ui); + font-weight: 425; white-space: pre-wrap; word-break: break-word; display: block; } .tc { - font: var(--text-base)/var(--leading-relaxed) var(--font-mono); + font: var(--text-base)/var(--leading-relaxed) var(--font-ui); + font-weight: 425; color: var(--color-text-muted); white-space: pre-wrap; word-break: break-word; diff --git a/apps/kimi-web/src/components/chat/ThinkingPanel.vue b/apps/kimi-web/src/components/chat/ThinkingPanel.vue index d2b3772b41..d917b82eb7 100644 --- a/apps/kimi-web/src/components/chat/ThinkingPanel.vue +++ b/apps/kimi-web/src/components/chat/ThinkingPanel.vue @@ -64,7 +64,8 @@ watch( overflow-y: auto; margin: 0; padding: 12px 14px; - font: var(--text-base)/var(--leading-relaxed) var(--font-mono); + font: var(--text-base)/var(--leading-relaxed) var(--font-ui); + font-weight: 425; color: var(--color-text-muted); white-space: pre-wrap; word-break: break-word; diff --git a/apps/kimi-web/src/components/chat/TodoCard.vue b/apps/kimi-web/src/components/chat/TodoCard.vue index 863846a540..48190f0c03 100644 --- a/apps/kimi-web/src/components/chat/TodoCard.vue +++ b/apps/kimi-web/src/components/chat/TodoCard.vue @@ -57,7 +57,6 @@ function glyphStatus(status: TodoView['status']): StatusGlyphStatus { .tc-row.s-done .tc-name { color: var(--color-text-faint); text-decoration: line-through; - text-decoration-color: var(--color-line-strong); } .tc-empty { diff --git a/apps/kimi-web/src/components/chat/ToolGroup.vue b/apps/kimi-web/src/components/chat/ToolGroup.vue index a80c7e92dd..8a30af9bea 100644 --- a/apps/kimi-web/src/components/chat/ToolGroup.vue +++ b/apps/kimi-web/src/components/chat/ToolGroup.vue @@ -1,6 +1,6 @@ @@ -131,6 +142,17 @@ function toggle(): void { transform: rotate(90deg); } .tool-group-body { + display: grid; + grid-template-rows: minmax(0, 0fr); + overflow: hidden; + transition: grid-template-rows var(--duration-base) var(--ease-out); +} +.tool-group-body.open { + grid-template-rows: minmax(0, 1fr); +} +.tool-group-body-inner { + min-height: 0; + overflow: hidden; display: flex; flex-direction: column; } diff --git a/apps/kimi-web/src/components/chat/ToolRow.vue b/apps/kimi-web/src/components/chat/ToolRow.vue index 239d1a4cb1..65d8a3bd5a 100644 --- a/apps/kimi-web/src/components/chat/ToolRow.vue +++ b/apps/kimi-web/src/components/chat/ToolRow.vue @@ -1,5 +1,6 @@