kimi-code/apps/web/package.json
Luyu Cheng dfa3d31759
feat(desktop): mention pills for files, folders, and skills (#226)
* feat(desktop): mention pills for files, folders, and skills

@ -mentions now insert as inline pill atoms in the ProseMirror composer
(desktop; the web textarea keeps plain-path behavior):

- schema gains a mention inline atom (file/folder/skill) rendered by a
  NodeView with the same glyph as the menu row; the doc model's
  serialization, offset mapping, and clipboard contract all go through
  the node's leafText, so the wire payload stays plain text — pills
  serialize as Markdown links: [name](path), [name](path/) for folders,
  [name](kimi-code://skill/<name>) for skills
- the mention menu gains a Skills section (instant local filter) below
  the Files section; useMentionMenu takes optional skills + insertMention
  deps, so the web textarea path is unchanged
- send semantics: a message with exactly one skill pill activates that
  skill (the pill form of /skill:<name>, surrounding text becomes args,
  attachments ride along); two or more skill pills degrade to plain
  references
- pill insertion replaces the @token via a dedicated transaction that
  adds a separating trailing space and leaves the caret after the pill

* fix(desktop): hide the phantom line after a paragraph-ending pill

PM appends a trailing <br> when a paragraph ends with an inline atom
(a caret target after the pill), and its stock stylesheet has no rule
for it — rendered, it reads as a forced extra line after the pill.
Hide it except when it is the paragraph's only child (the empty-state
placeholder line still needs it).

* feat(desktop): instant @ menu, stable list while searching, pill-adjacent tokens

- a bare '@' now opens the mention menu immediately with the daemon's
  empty-query listing (workspace root entries) instead of staying closed
  until the query has content; Esc still dismisses
- typing in the open menu no longer swaps the candidate list for a
  full-area 'searching' note: rows stay visible and a corner spinner
  marks the in-flight search (the note only remains when the list is
  empty); same for the in-flight state of a new keystroke
- the @token scan no longer crosses into a mention pill's serialized
  form: the editor reports the caret's inline-run start and the scan is
  bounded there, so '@' typed right after a pill opens the menu
- folder detection uses the daemon's explicit kind (now plumbed through
  FileItem) with the trailing-slash convention as fallback — icons and
  pill serialization follow it

* fix: trigger the mention menu on the full-width @ too

Chinese IMEs produce U+FF20 (@) for the @ key, which the token scan
rejected — the menu never opened for IME-typed mentions. Accept both
forms; the replacement range covers the full-width char as before.

* fix(desktop): the @ menu never opened after a keystroke

inlineTextRunStart assumed $pos.textOffset is the caret's offset within
the current text node — but ProseMirror reports 0 at a text node's END
boundary ('between nodes'), which is exactly where the caret lands after
every keystroke. The lower bound therefore equalled the caret, the token
scan saw nothing, and the menu never opened on real typing (paste-driven
flows happened to work). Extract the logic as inlineRunStartOffset and
handle the boundary via nodeBefore; cover all caret placements with
pure tests (no DOM needed).

* fix(desktop): give the caret a home right after a paragraph-trailing pill

When a paragraph ends with a mention atom, PM appends a separator img
plus a trailing <br>, and Chromium draws the caret on the br's phantom
line below the pill (hiding the br instead leaves the caret homeless).
Add a decoration plugin that drops a zero-width caret-anchor span right
after a paragraph-trailing pill, and keep the br hidden in that state —
the caret now sits on the pill's own line.

* feat: render mention links as pills in sent messages too

One pill vocabulary in both directions: the Markdown layer now decorates
every workspace link (plain paths, trailing-slash folders, and
kimi-code://skill/ links) with the same classes and kind glyph the
composer's atom uses — the pill styles move to app-ui's global sheet.
Classification is the pure classifyMentionHref (unit-tested); files open
the preview on click, folders and skills stay inert until they have a
navigation target. app-markdown gains a (one-way, acyclic) dependency on
app-client for the shared helpers.

* docs: spec the mention pill as §05 Rich Text Messages

Promote the pill from a Composer paragraph to its own design-system
chapter: the shared two-surface concept (composer atom + decorated
message link), the visual recipe, the kinds/wire-forms table, the
behavior contract (bare-@ listing, sections, stable list while
searching, atom deletion + caret anchor, single-skill activation), and
the implementation map. §04 keeps a pointer; later sections renumber
(nav + banners + sec-nums) on both ends.

* fix(desktop): keep a real text node after an inserted pill

The caret-after-trailing-pill problem, solved the way Slate-style
editors do it: give the caret a real text home instead of letting the
paragraph end in an atom (which is what forced PM's trailing <br> and
dropped the caret onto a phantom line). buildMentionInsertion now
appends the separating space also at end-of-text, and the zero-width
caret-anchor decoration stays as the safety net for when the user
deletes that space. Also registers a dev-only window.__composerEditors
handle for CDP verification.

* fix: give skill mentions their own glyph

The skill pill used the 'sparkles' registry slot, which is backed by the
task.svg glyph — the Swarm/sub-agent icon. Register ri/sparkling-line as
'sparkling' (an actual sparkles glyph) and use it for skill pills and
skill menu rows instead.

* fix: restyle mention pills as plain text, not filled chips

Per design feedback: no background, no radius, no padding — the mention
is body text set in --weight-ui-strong on --color-text-muted, baseline-
flush with the surrounding text, with the muted kind glyph leading.
States follow the same idiom: editor node-selection deepens the ink,
message hover underlines the clickable file pill. §05 spec text updated
on both ends.

* fix: tighten the mention pill's icon-to-label gap

* fix: mention pill icon — match text height, heavier glyph, selectable

- size moves to the --p-ic-sm token (14px, level with the 14px text)
- registry glyphs are fill-based, so a hairline currentColor stroke adds
  the perceived weight
- ::selection paints the icon span, so a text selection crossing the
  pill no longer leaves the glyph unselected

* fix(desktop): revive mention pills from their link form on load

Drafts are the serialized Markdown-link text, so a refresh used to drop
the pills. textToDoc now parses our own link forms back into mention
atoms on the editor's load paths (draft restore, history recall, queue
reload), making the serialization a true round trip; the paste path
keeps pills off. Also escapes '<' in angle-bracket destinations so the
parse is faithful.

* fix(desktop): revive pills on paste too

Copying a pill puts its Markdown-link form on the clipboard, but the
paste path deliberately skipped mention parsing, so a pasted pill came
back as literal text. parseClipboardText now revives mention links, so
copy-paste round-trips like load-path revival does.

* fix: paint the mention icon on selection for real

The earlier rule targeted only the icon span; Chromium doesn't selection-
paint an svg inside it. Cover the pill and every descendant instead.

* feat: render mention pills in user and queue bubbles too

User/queue bubbles render verbatim text (never Markdown), so serialized
mention links arrived as literal [name](path) text. A new pillifyMentions
pass (ChatPane register/hooks) revives them into pill elements built by
the same shared builder as the composer's NodeView — one pill vocabulary
across menu, editor, and every message surface. File pills in bubbles
open the preview on click; folder/skill stay inert.

* fix: paint pill selection via a class, not ::selection

Browsers paint the selection highlight over text and <img>, but not over
an inline <svg> (csswg-drafts#5395) — the pill's glyph always escaped
the highlight. mentionSelectionSync watches document selectionchange and
toggles .pill-in-selection on the pills the range actually covers
(Range.intersectsNode); the stylesheet paints the wash on that class.
Wired into the composer and the chat stream on both ends. Verified live:
class applies when covered, clears on collapse.

* feat: show the skill as a pill in the activation card too

The activation card's title name now renders as the same skill pill the
editor shows (i18n-t slot), and carving the pill out of the args merges
the seam whitespace so the displayed/sent args read naturally.

* feat(composer): mention pill hover tooltip and click-to-open

One document-level mentionTooltip singleton serves every pill surface
(composer NodeViews, pillified bubbles, Markdown anchors) since pills
are raw DOM a Vue wrapper can't reach. File/folder hovers show the
full path (fixed max width, forced wrap at segment boundaries, muted
separators, bold basename); skill hovers show a card with the name,
an open button, and the description. Skill resolution rides the wire
skill descriptor's path through AppSkill.path, and clicking a skill
pill (or the card's open button) in messages opens its SKILL.md in
the preview panel. Composer pills keep pure editing semantics: I-beam
cursor, no underline, click places the caret. Native title tooltips
are removed wherever a pill appears.

* feat(composer): mention pill name truncation and missing-target marking

Over-long pill labels middle-ellipsis at 32 chars, keeping the base
name's head and the whole extension (the full name stays in the data
attributes, the full path on the tooltip). Hovering a file/folder pill
fires a one-byte existence probe with a spinner at the tooltip's tail;
a definitive fs.path_not_found fades every pill referencing the path
and strikes it through, while clicks stay enabled regardless. Only
confirmed-existing verdicts are cached (scoped to the session), so a
recreated file recovers on the next hover and transient daemon
failures can never strike a pill by mistake.

* fix(composer): give mention pills a small horizontal padding

CJK autospacing cannot cross element boundaries, so a pill abutting
Chinese text rendered flush against it. A 2px padding-inline on every
pill separates the mention from its surrounding text on all surfaces.

* feat(chat): render single-skill-pill messages verbatim, no activation card

A message carrying exactly one skill pill still activates the skill via
the slash-command channel, but the pill is no longer carved out of the
text: the full serialized text (the pill as its mention link) becomes
the args, so the sent bubble shows the original message with the skill
revived as an inline pill where the user typed it. The 'Activated
skill' card header is gone — the message content speaks for itself.
Skill activations also count as editable turns again: the args are the
full original text, so edit-and-resend revives the pill and
re-activates. Two-or-more skill pills still degrade to plain
references, and plugin command cards are unchanged.

* refactor(composer): one file glyph for every file mention

Drop the per-extension code/doc/image icon variants: every file pill
and mention-menu file row now uses the single folded-corner file glyph
(no lines on the page), folders keep the one folder glyph.

* feat(composer): copy-path button on file/folder mention tooltips

The path tooltip is now a flex row: the wrapping path on the left and
a copy button pinned top-right. A successful copy swaps the icon for a
check for about a second; clipboard failures stay silent and the
tooltip stays open. The button's aria-label is localized via a new
mention.copyPath string.

* fix(composer): harden mention link parsing and serialization

Address review findings in composerTextDoc:
- copying a lone pill via NodeSelection serialized to an empty string —
  top-level leaf mention nodes now emit their link form directly;
- Markdown image syntax (![alt](src)) and escaped brackets no longer
  revive into mention pills;
- parseMentionLinks rescanned the whole suffix per '[', degrading to
  O(n^2) on bracket-heavy text — it now scans linearly with proven
  dead-start skipping (fuzz-verified equivalent to the naive scan);
- hrefs with any URI scheme (ftp:, vscode:, …) no longer classify as
  workspace file mentions; Windows drive paths still do;
- a revived skill pill takes its identity from the decoded link target
  instead of the display label, so [发布](kimi-code://skill/deploy)
  activates 'deploy';
- folder classification no longer strips '#'/ '?' — folder names may
  legally contain them, and stripping ate the trailing slash.

* fix(markdown): open the real filesystem path behind mention links

Address review findings in the Markdown link pass:
- mention hrefs are pure paths — stop stripping at '#'/ '?' so file
  names containing them survive (stripFragmentAndQuery removed);
- percent-encoded hrefs (%20, UTF-8 sequences) are decoded before
  openFile and before the tooltip probe sees them, via the new pure
  mentionHrefToPath helper (malformed sequences fall back to the raw
  href);
- hosts without an openFile prop (UpdateIndicator, QuestionCard) no
  longer get their links killed by an unconditional preventDefault —
  default navigation passes through; skill links still always prevent
  it (the mention tooltip routes them).

* fix(composer): mention menu interaction and async race fixes

Address review findings in the @ menu and composer:
- keyboard no longer locks while a search is in flight: arrows,
  Enter/Tab and Escape work whenever the menu is open (web composer
  had the same gate);
- changing the query clears stale file candidates immediately instead
  of leaving the old query's rows clickable;
- the highlighted row is restored by identity when async file results
  arrive, not by a numeric index that shifts under it;
- an incrementing sequence guard makes only the latest search apply
  its results and clear loading;
- skills arriving asynchronously now refresh an already-open menu;
- Escape and select both cancel the pending debounced search, so a
  closed menu can't reopen itself into a stuck loading state;
- dropping folders onto the composer inserts through a real editor
  transaction (new insertTextAt API) instead of rebuilding the whole
  document, preserving the undo stack and existing pills.

* fix(composer): mention tooltip polish, pill a11y, and selection sync perf

Address review findings:
- tooltip padding and spinner animation now use design tokens
  (var(--space-1) var(--space-2), new --duration-spin) instead of
  hardcoded values;
- a hidden tooltip is marked inert so its buttons leave the tab order;
- path probes capture the session scope at start and discard verdicts
  that arrive after a session switch, and confirmed-existing verdicts
  now expire after 30s so a later-deleted file does get struck;
- message-side file pills are keyboard reachable (tabindex, role,
  Enter/Space) with the standard focus ring;
- mentionSelectionSync no longer scans every pill on each caret move —
  it only clears the previously marked set unless a non-collapsed
  selection intersects the watched root;
- design spec and native-todos divergence notes updated to the real
  padding / paste-revive / skill-click behavior.

* fix(chat): keep the activation card for slash-typed skill activations

Follow-up review on the verbatim skill-message change: the card removal
was unconditional, but a /skill-typed activation's bare args carry no
mention pill — its skill identity would vanish from the transcript.
skillActivationHasPill() checks whether the args revive the matching
skill pill: pill-composed activations show just the verbatim message,
slash-typed ones keep the identity card. The same guard keeps edit &
resend away from bare-args activations (resending those would degrade
to a plain prompt instead of re-activating).

Also documents the mention tooltip as a structural exception to the
component-primitive rule (its anchors are ProseMirror NodeViews and
pillified spans a Vue wrapper can't reach) and gives its open/copy
buttons the standard focus ring the Button primitive would enforce.

* fix(composer): lossless mention paths for literal percent signs and nested brackets

Address review findings:
- a filename containing a literal percent triplet (report%20final.md)
  was decoded as if the renderer had encoded it, opening the wrong
  file. The serializer now escapes '%' as '%25' in link destinations —
  a valid triplet the renderer preserves, so the message-side single
  decodeURIComponent restores the true name (a literal %2F no longer
  becomes a path separator);
- an unclosed '[' followed by a real mention (prefix [unfinished
  [README](README.md)) paired the later ']' with the earlier bracket
  and swallowed the prefix into the pill label. An unescaped inner '['
  now invalidates the outer candidate and scanning resumes from the
  inner bracket, still in linear time.

* fix(composer): clamp the mention tooltip on narrow viewports and make message skill pills keyboard-reachable

Address review findings:
- below ~336px viewport width the 320px bubble out-measured the
  viewport and the left clamp could go negative, cropping the path
  offscreen — the bubble now caps at innerWidth minus margins and the
  clamp bounds can no longer invert;
- message-side skill pills open their SKILL.md on mouse click but had
  no keyboard path — the singleton now routes Enter/Space on skill
  pills through the same resolve-and-open logic (capture phase), and
  both pillified bubbles and the legacy activation card expose
  tabindex/role so the pills are focusable with the standard ring.

* fix(composer): invalidate in-flight mention searches earlier and probe paths before the first message

Address review findings:
- the search sequence only incremented when the debounced runSearch
  started, so a same-query request could re-validate a stale in-flight
  response inside the debounce window — update() now bumps the
  sequence as soon as the token changes;
- probing a mention path before the session's first message returned
  an unconditional 'exists' and cached it. With no active session the
  probe now resolves the workspace root and reads via the daemon's
  global fs:content instead, keeping the definitive-not-found-only
  verdict semantics; the probe scope falls back to the workspace id so
  onboarding verdicts never leak across workspaces.

* refactor(app-client): group the composer editor cluster under lib/composer/

The ProseMirror document model and editor surface (composerTextDoc,
composerEditor), the per-session state cache, the shared TextFieldLike
abstraction, and the whole mention content type (icons, pill builder,
message-side pillify, selection painting, hover tooltip) move from the
flat lib/ into lib/composer/, where upcoming rich-text content types
will join. lib/index.ts keeps re-exporting the cluster, so consumer
imports are unchanged; only in-package relative imports were updated.
No behavior change.

* refactor(composer): parse mention links with micromark instead of a hand-rolled scanner

The hand-written scanner kept re-discovering tokenizer problems (O(n^2)
rescanning, image syntax reviving, inner brackets swallowing prefixes).
Replace it with micromark's event stream, construct-disabled so only
link-related constructs parse: emphasis, autolinks, definitions, HTML,
block structure, and friends are all off — only inline links, image
labels (rejected wholesale), character escapes, and code constructs
stay on. Rejects (images, titles, empty parts, non-mention schemes)
happen in our pipeline; malformed-input behavior is the library's
problem now. Dest decoding keeps the wire format's own inverse
(backslash + %25) applied to spans cut from the original text, so
micromark's normalization never touches it.

Also ships the wire-format spec (docs/specs) this parser is validated
against, and one serializer fix the differential fuzz caught: skill
names with unbalanced parens now percent-encode parens in the link
destination, or CommonMark would refuse to revive them.

Fuzz: 100k inputs vs the retired scanner — 375 differences, all in the
documented hand-typed-only buckets (code shielding, titles, balanced
parens, …); round-trip fidelity for serialized docs is identical.

* fix(composer): mention menu skill refresh timing, gate restore text, queue pill nesting

Address review findings:
- skills arriving during the file-search debounce window no longer get
  dropped: a dismissed flag separates 'closed on purpose' from 'not
  open yet', and the watcher refreshes candidates whenever the menu
  was not explicitly dismissed — without ever reopening it;
- a gated single-skill-pill send restored the synthesized /skill:<name>
  command line into the composer, so resending stacked another prefix
  into the args — the command payload now carries restoreText with the
  original message, and the gates prefer it when handing text back;
- queued prompts are one big click-to-edit button, so their pills no
  longer get button semantics of their own (no nested buttons): the
  queue body keeps the click, pills there stay display-only, and the
  tooltip's skill-open routing skips them too.

* fix(markdown): act on the fragment-free path, take skill identity from the link target

Address review findings:
- ordinary chat links with an in-page anchor or query
  ([Usage](README.md#usage)) were opened as a literal path and 404'd —
  action sites (click-to-open, existence probe) now cut the first
  unencoded #/? tail via the shared mentionActionPath helper, while
  display, tooltip, copy, and the wire format keep the full decoded
  path;
- a skill pill on the Markdown surface took its identity from the link
  label, so [发布](kimi-code://skill/deploy) resolved nothing — the
  tooltip and click now key off the decoded link target, same as the
  composer revive path;
- inert folder pills render as focusable anchors that do nothing on
  Enter — their href is removed so they leave the link tab order;
- pills inside FilePreview kept the raw relative href in
  data-mention-path, so the tooltip probed (and could wrongly strike)
  a workspace-root path — Markdown accepts a resolveMentionPath prop
  and FilePreview passes its click-time directory resolution through.

* fix(composer): close the remaining round-trip gaps in mention parsing

Address review findings:
- protocol-relative URLs ([site](//example.com/path)) misclassified as
  workspace files — excluded before the scheme check;
- filenames containing '<'/'>' serialized to a bare destination that
  not every parser reads back (a leading '<' is outright illegal), so
  our own pills failed to revive — the angle form now triggers on
  whitespace OR angle characters, and a filename corpus test pins the
  round-trip invariant as the arbiter for all future serialization
  questions;
- code constructs (codeText/codeFenced/codeIndented) shielded links
  from the wire parser, but the editor can legitimately hold a real
  mention atom between code delimiters — the round-trip invariant
  outranks literal purity for hand-typed text, so the wire parser now
  recognizes mention links in every context (assistant Markdown, a
  real renderer, is unaffected).

* fix(composer): keyboard access into the mention tooltip and grapheme-safe truncation

Address review findings:
- the any-key dismiss made the interactive tooltip inert before Tab
  could move focus into its buttons — Escape still always dismisses,
  Tab never does, other keys only dismiss outside the bubble, and
  focusin/focusout take over keeping/closing once focus actually
  moves;
- truncateMentionName sliced UTF-16 units and could halve a surrogate
  pair at the truncation boundary, rendering a replacement char — the
  budget now counts grapheme clusters via Intl.Segmenter, so emoji and
  ZWJ sequences are never cut in half.

* fix(composer): exact action paths, visible-workspace probe fallback, and tooltip timer races

Address review findings:
- FilePreview's display-path resolver stripped '#'/'?' from an
  already-decoded path, mangling literal-# filenames in the pill's
  dataset — the resolver splits into a display form (dir-resolution,
  no strip) and an action form (strip, then resolve); Markdown also
  stamps data-mention-action-path computed from the RAW href, so the
  tooltip probe can tell a fragment tail from a filename character;
- probing before a workspace is explicitly picked fell back to
  always-exists — the fresh-empty-workspace case now falls back to the
  first visible workspace, the same chain createGoal uses;
- a pill unmounted during the 150ms show delay produced a bubble
  stranded at (0,0) — detached anchors are discarded before show();
- re-entering the same pill inside the hide grace no longer lets the
  pending hide fire under the cursor — the timer is cancelled on
  same-anchor re-entry.

* fix(composer): focus-triggered tooltip, actionable-only skill pills, external-path probing

Address review findings:
- keyboard-only users got no tooltip at all — the singleton now opens
  the bubble on pill focusin and closes on focusout, mirroring the
  mouse path, so the interactive buttons are reachable without a
  mouse;
- an unresolvable skill (uninstalled, or a host that can't open files)
  was a permanently dead button: activation now only consumes the
  event when the skill resolves to a path and the host can open it,
  and the pill is degraded at tooltip time (tabindex/role stripped,
  pointer/underline affordance removed via .mention-inert);
- absolute paths outside the workspace went through the session fs
  endpoints, where the out-of-gate error masqueraded as 'exists' — the
  probe now routes absolute paths through the daemon's global
  fs:content, like the preview does.

* feat(composer): denser menu rows and UI polish for the mention surfaces

- the path tooltip's copy button now sits the same --space-1 from the
  top border as from the right border (it was optically off-corner);
- menu row block padding slims 5.5px → 3.5px (shared by slash /
  mention / add menus) and the mention menu's scrollport cap rises
  208px → 296px, so roughly ten rows show instead of six.

* feat(composer): slim menu rows further (3.5px -> 1.5px block padding)

* feat(composer): plain 12px corners for the composer menus, concentric 6px rows

The slash / mention / add menus drop the composer card's 32px
superellipse geometry: the frame is a plain --radius-lg corner (no
corner-shape), and --radius-menu-row resolves to --radius-sm so the
row caps stay concentric with the frame (12px - 6px hug). The 0.5px
transparent-border corner-shape workaround and its padding
compensation go with it; effective row geometry is unchanged. The
composer card itself keeps its own radius and shape.

* fix(composer): keep mention candidates on screen during re-search, dimmed

Retyping the @-query cleared the file section synchronously, so every
keystroke collapsed the menu into an empty 'searching' state for the
debounce + RPC round trip. The rows now stay visible with a stale
flag (dimmed at 0.55 opacity with a fade) until the new results land
— the menu never flashes empty. Selecting a stale row still inserts
its mention against the live token, so nothing about the async
correctness changes (the searchSeq guard is untouched).

* feat(composer): densify mention menu rows — 13px text, 14px icons, tighter paddings

Item text is one 13px size for both name and meta (riding the
font-shift scale; they now differ only in ink and weight, name keeps
500 + full color, meta keeps muted). Row glyphs pin to 14px (they were
locked at 13px), inline row padding drops 10px to 8px, and the menu
frame's own padding slims 6/12 to 4/8 for a more compact surface.
Mention menu only — the slash and add menus keep their current look.

* feat(composer): bump mention menu frame padding to 6/8 (4/8 read too tight)

* feat(composer): show only the containing directory in mention menu rows

The meta text after a file/folder name was the full path — noisy and
mostly ellipsis-truncated. It now shows just the containing directory
(apps/desktop for apps/desktop/package.json); root-level entries show
nothing, since there is no directory to disambiguate.

* feat(composer): fixed gap between mention row name and directory meta

The name's 80px min-width made the name-to-meta distance vary with
name length (column-aligned for short names, flush for long ones). The
name now hugs its content, so the meta always follows at the row's
fixed 7px flex gap.

* feat(composer): land mention rows 4px inside the frame edge (hug -6 + 10px frame padding)

Restore the -6px hug outreach (the scrollport clip trick needs it) and
pad the frame 10px inline instead, so the visible row inset is exactly
4px (6 + 4) instead of the 8.5px the margin-less version had.

* fix(composer): hide the UA default button border on add-menu rows

The corner-shape cleanup removed the 0.5px transparent border from
.am-row, but it is a <button> — the UA default border immediately
showed through as a thick outline around every row.

* feat(composer): align mention menu frame padding with the slash/add menus

The tighter frame experiments (3px/4px row inset) read worse in
practice — the mention frame goes back to the shared 6/12 padding, so
all three composer menus hug their rows 6px from the frame edge. The
item-level changes (13px text, 14px icons, 8px inline padding,
directory-only meta, fixed name gap) stay.

* feat(composer): slow the mention menu stale fade (120ms -> 260ms)

The whole-menu dim on re-search read as a jarring flash at the fast
duration; the slow token eases it into a settle.

* fix(composer): address the latest review round

- tooltip vertical clamp could invert on very tall bubbles (high zoom /
  long wrapped paths), pushing the bubble above the viewport — the
  upper bound is floored at the margin like the horizontal one;
- probe spinner geometry (10px box, -1px baseline) now derives from
  spacing tokens and an em-based offset;
- 32-grapheme labels can still outgrow narrow bubbles (CJK/full-width
  glyphs), so the pill label also caps by inline size and ellipsizes;
- copy-path reuses copyTextToClipboard (execCommand fallback for
  plain-HTTP web) and only confirms real copies;
- stale file rows are no longer selectable (Enter/Tab/click during a
  re-search can't insert an old query's path; skills stay selectable);
- composer-wire pills act on their exact dataset path everywhere —
  fragment/query trimming is confined to raw Markdown hrefs (the
  Markdown surface stamps data-mention-action-path for it), so literal
  '#'-filenames open and probe correctly from user bubbles;
- a leading '#' in a destination is percent-encoded as %23, so real
  files like #notes.md revive (classification now runs on the raw,
  still-encoded destination; the two encoding layers cannot alias);
- FilePreview remounts Markdown on path change, so identical-content
  files re-resolve mention paths against the new directory;
- native-todos paths updated for the lib/composer/ move.

* fix(composer): key path probes by action path, encode CR/LF in mention destinations

Address review findings:
- two pills can share a display path while meaning different files
  ([Usage](README.md#usage) vs a literal '#'-filename) — the probe
  cache, inflight dedupe, and the missing strike are now all keyed by
  the pill's ACTION path instead of its display path, so one verdict
  no longer poisons or strikes the other meaning;
- POSIX allows newlines in filenames, but a literal CR/LF in the wire
  text would split paragraphs and kill the link — destinations now
  encode them reversibly as %0A / %0D (aliasing with the %25 layer
  verified).

* fix(composer): use the --text-sm rung for mention menu text, restore skill pill semantics on resolve

Address review findings:
- the 13px mention-menu font size was a hardcoded base value instead of
  a design token — it is now the existing --text-sm ladder rung
  (calc(var(--ui-b2) - 1px)), so the menu follows the global font
  scale like everything else;
- a skill pill degraded while the skill list was still loading stayed
  inert forever — when a later hover/focus resolves the skill, the
  button semantics (tabindex, role, affordance) are restored
  symmetrically (queue-body pills excepted: their row IS the edit
  button).

* fix(composer): queue single-skill-pill sends while busy, Tab into the tooltip, colon filenames

Address review findings:
- sending a single-skill-pill message into a RUNNING session fired
  activateSkill immediately — a busy refusal after the composer had
  already cleared lost the message and its attachments. The branch now
  only activates while idle; busy sends fall through to the normal
  queue (plain prompt on replay, same as multi-pill). A failed
  activation also restores the original text (restoreText) as defense;
- Tab from a focused anchor pill now moves focus into the tooltip's
  first button instead of skipping past it in the DOM order
  (Shift+Tab and action-less bubbles keep the default order);
- POSIX filenames containing ':' ('notes:old.md', 'http:fixture')
  matched the URI-scheme guard and could never revive — the first
  colon is encoded as %3A in destinations (Windows drives exempt,
  aliasing with the %25 layer verified), closing the last
  own-serializer round-trip gap in this family.

* fix(composer): guard the late activation-failure restore, decode standard percent-encoding

Address review findings:
- a slow activateSkill failure restored the old text unconditionally —
  a new draft typed in the meantime (or a session switch) got
  clobbered, possibly writing the old session's content into another
  one. The restore now only lands when the request's session is still
  active AND the composer is still empty (new isComposerEmpty expose);
- hand-written percent-encoded local links ([文档](my%20file.md))
  revived with the '%' left in the path, and re-serialization
  compounded it to '%25' — silently rewriting the link's meaning.
  unescapeLinkDest now decodes with decodeURIComponent (malformed
  sequences keep the raw text), covering the serializer's own layers
  and standard Markdown encoding in one step.

* chore: lockfile after origin/main merge (main's lockfile + micromark)

* fix(desktop): optional-call Composer isEmpty (exposed type may be undefined); lockfile with micromark

* fix(composer): explicit slash wins over skill-pill activation, report first-activation outcome

Address review findings:
- a message starting with a known slash command whose args held exactly
  one skill pill (/compact [deploy](…)) was rewritten into a skill
  activation before parseSlash ran, hijacking the chosen command. The
  command is resolved first now; the single-skill-pill branch only
  applies to plain messages (still idle-only);
- startSessionAndActivateSkill swallowed the activation result and
  always returned the new session id, so a refused first activation
  (persist failure, daemon busy) looked like success while the composer
  had already cleared. It now returns { sessionId, activated }, and
  both app shells restore the original text (restoreText) on
  activated:false — guarded the same as the active-session path (same
  session + still-empty composer). Web gains the same guarded restores
  on both branches and the isComposerEmpty expose.

* fix(composer): use the --p-ic-sm token for mention menu icons

The menu's icon box and glyph were pinned at a hardcoded 14px — the
design-system icon scale already owns that rung (--p-ic-sm), so a
global icon-size adjustment now reaches the mention menu too.

* fix(composer): fully-idle gate for skill activation, folder href only where openFile exists, tooltip tokens

Address review findings:
- a single-skill-pill send activated directly even with a non-empty
  queue (jumping the FIFO the normal submit preserves) or while
  running-but-not-working (approval/question pending) — direct
  activation now requires a fully idle session AND an empty queue;
- folder links on Markdown surfaces without an openFile handler
  (UpdateIndicator, QuestionCard) had their href removed and became
  dead static text — the inert-pill treatment now only applies where
  the host can actually open files;
- the mention tooltip's geometry and delays are read from design
  tokens via getComputedStyle (--space-1-5 / --space-2 / new
  --duration-tooltip / --duration-fast), and the width cap moved into
  CSS (new --p-mention-tip-w with a min() viewport clamp), so the
  interaction follows the spacing/motion scale like everything else.

* fix(composer): literal folder-drop insertion, tab order out of the tooltip

Address review findings:
- a dropped folder path whose dirname merely looked like a mention link
  ('[archive](old)') was revived into a pill pointing at the wrong path
  — insertTextAt now takes reviveMentions (default true) and folder
  drops pass false, keeping the single-transaction undo behavior while
  the path stays literal text;
- tabbing out of the tooltip (Tab on its last button, Shift+Tab on its
  first) returns focus to the anchor pill and closes, instead of
  continuing from the page end and skipping every control after the
  anchor (the bubble is appended to document.body).

* fix(composer): stale-opacity token, no re-strip in preview, no skill-edit on web

Address review findings:
- the stale-row dim (opacity 0.55) was a hardcoded value in both menu
  copies — it is now the semantic --opacity-stale token in the shared
  sheet;
- FilePreview re-stripped '#'/'?' from paths Markdown.vue had already
  normalized (strip on the raw href, then decode), so a literal
  '#'-filename opened its prefix instead — the preview's open-file
  forwarding now only does directory resolution, and the strip variant
  is gone;
- web offered edit-and-resend on a desktop-sent single-skill-pill
  activation, but the web textarea can neither revive the pill nor
  re-activate it — the edit would silently degrade to a plain prompt
  while the original turn is undone. canEditTurn now excludes skill
  activations on web entirely (divergence documented in
  native-todos.md; desktop keeps the pill-aware editing).

* fix(composer): single-decode skill names, workspace-scoped probes on empty session id

Address review findings:
- a skill name with a literal percent triplet (review%20draft) was
  decoded twice on revive — unescapeLinkDest turned %2520 into %20 and
  decodeSkillName finished it into a space, landing on a nonexistent
  skill. The skill branch now hands the RAW destination to
  decodeSkillName so exactly one decodeURIComponent happens;
- the no-session probe scope fell back to the workspace id only on
  null, but activeSessionId normalizes to an empty string — two
  sessionless workspaces shared the '' scope and poisoned each other's
  verdicts. The fallback now uses ||, covering both null and ''.

* fix(composer): restore on failed session creation, encode the last round-trip breakers

Address review findings:
- a first-skill-pill send whose session CREATION failed returned
  { sessionId: null, activated: false }, but the restore guard compared
  the empty-string active id with null and never fired — the cleared
  message was lost. The new-session restore now fires on a failed
  creation too (still on the same workspace + empty composer);
- a newline in the BASENAME broke the wire the same way as one in the
  path — labels now encode CR/LF as %0A/%0D (and decode them back);
- paths with a leading '//' (POSIX or forward-slash UNC) read as
  protocol-relative URLs to the classifier and could never revive —
  the second slash is encoded as %2F;
- filenames containing '&' decoded as HTML character references in the
  real Markdown renderer ('a&amp;b.md' opened 'a&b.md') — '&' is now
  encoded as %26 in destinations.

* docs(spec): label CR/LF and the %26/%2F destination layers

* fix(composer): label percent layer, structured skill name in command payloads

Address review findings:
- a literal '%0A'/'%0D' in a filename was decoded as a real newline in
  the label on revive, rewriting the pill's name — labels now encode
  '%' as '%25' FIRST (before the newline layer, so the two cannot
  alias), same as the destination;
- a skill name containing spaces ('write goal') was synthesized into
  the space-delimited '/skill:write goal …' command string, which the
  handler split at the first space and activated 'write' instead. The
  name now rides the command payload structured (skillName), on both
  the single-pill path and the slash-menu path; the handler prefers it
  over parsing.

* fix(composer): decode wire labels on the Markdown surface, pill-name span, stale-aware default highlight

Address review findings:
- a name with a literal '%' showed its encoded wire form (a%2520b.md)
  in assistant Markdown / previews, because the decoration used the
  raw link text — it now decodes the label with the composer's own
  unescapeLinkText;
- Markdown pills kept the name as an anonymous text node, so the
  shared max-width/ellipsis rules never applied and a long nowrap name
  could stretch the column — the display name is now wrapped in
  .mention-pill-name like the shared builder;
- after retyping, the default highlight landed on index 0 — a stale,
  unselectable file row — making Enter look dead until the new search
  returned. With fresh skill rows present the default now lands on the
  first skill instead.

* fix(composer): structured args with structured skill names, interior #/? encoding, token feedback delay, keep image links

Address review findings:
- the structured skillName fixed the name but args still parsed from
  the cmd string's first space, so a spaced skill name leaked its
  suffix into the args — when skillName is present the args now come
  from the original message (restoreText) or the exact
  '/skill:<name>' head, never from string splitting;
- interior '#'/'?' in a path survived unencoded, so action sites on
  Markdown surfaces stripped them as fragment/query and opened the
  path prefix — every literal '#'/'?' in a destination now encodes as
  %23/%3F (hand-written fragment links still strip, since theirs is
  unencoded);
- the copy-success check reset used a hardcoded 1000ms — it now reads
  --duration-flash via the same tokenPx path as the other delays;
- a clickable thumbnail ([![alt](thumb.png)](full.png)) was pillified
  and its <img> subtree erased by the name span — links containing
  images are now skipped by mention decoration.

* fix(composer): label metachar encoding, keep pills focusable while skills load

Address review findings:
- a name containing '&', '<', or '>' showed decoded forms in assistant
  Markdown ('a&amp;b.md' displayed 'a&b.md') — labels now encode those
  inline-HTML metacharacters as %26/%3C/%3E, decoded back at
  decoration time;
- a keyboard user focusing a skill pill while the skill list was still
  loading got it stripped from the tab order permanently (no mouse, no
  way back to the restore branch). The facade now exposes skillsLoaded
  (record key presence), and the degrade strips tab semantics only once
  the list HAS loaded — during loading the pill stays focusable and
  self-heals when it arrives.

* fix(chat): re-pillify only mutated bubbles, strip native titles on pill links

* fix(composer): reject query-only mention hrefs, reset menu state on close, require a single skill pill for edit, handle Windows Markdown base dirs

* fix(composer): strip the leading slash in skill names, mark failed skill fetches loaded, keep parent segments escaping a Markdown base dir

* fix(composer): retry failed skill fetches while still marking them finished, resolve Markdown relatives per Windows segment

* fix(composer): reset the skills-loaded marker while a retry is in flight, keep the POSIX root as a Markdown base dir

* fix(composer): classify renderer-encoded Windows drives, keep forward-slash UNC roots, degrade dead skill pills on direct activation, let an armed goal preempt skill activation

* fix(composer): decode rendered labels percent-only, lift the highlight off stale rows on async skills, strip dead skill links' href, inline the probe spinner at the path tail

* refactor(composer): collapse mention dest encoding to one canonical percent rule, pinned by an exhaustive wire matrix

* fix(composer): keep non-ASCII mention paths literal in the canonical wire encoding

* refactor(composer): extract the composer rich-text cluster into @moonshot-ai/app-composer

* feat(composer): render user messages with the declarative ComposerText component, retiring the pillify pass

* docs(composer): move the wire-format spec into the app-composer package

* fix(composer): keep skill rows from stealing the mention menu's default highlight

* fix(composer): dedupe in-flight skill fetches per scope so only one request writes the loaded marker

* fix(composer): document the full label escape set in the wire spec, exempt only absolute filesystem paths in the preview resolver, reject empty-named skill links

* fix(composer): token-based mention tooltip line height, Windows trailing-separator folder classification, fragment-stripped mention action paths in ComposerText

* fix(composer): per-line mention link parsing, Windows folder separator fidelity, tooltip Escape focus return

- parseMentionLinks now parses line by line so a cross-line label never
  pillifies on the message surface (textToDoc parity; spec section 5 and
  invariant 5 updated)
- serializeMention no longer appends '/' to a folder path already ending
  in a Windows separator
- mentionTooltip's Escape returns focus to the anchor pill before hiding,
  instead of stranding it inside the inert bubble
- AGENTS.md: document the mention tooltip bubble as a component-primitive
  exception alongside the dock overlay

* fix(composer): keep a degraded skill pill's href while the skill list is still loading

A Markdown skill pill's only focus hook is its href — stashing it during
the loading window dropped the anchor out of the Tab order for good, and
contentFor's restore branch only re-runs on hover/focus, so keyboard
users had no way back once the list arrived. Defer the href/tabIndex
strip until the list is loaded; the click stays swallowed by the
document-level routing either way.

* fix(composer): consume the tooltip dismissal Escape, copy bubble selections as wire text

- mentionTooltip only handles Escape while the bubble is open, and now
  consumes it (preventDefault + stopImmediatePropagation): an unconsumed
  Escape kept travelling to ConversationPane's document handler, so
  closing a tooltip also aborted the running turn
- ComposerText wraps its inline flow in a copy interceptor: selections
  fully inside the component are re-serialized from the pills' full
  data-mention-* attrs, so copying a bubble yields the exact wire text
  again instead of a truncated basename

* fix(composer): associate the tooltip with its pill, token the spinner stroke, key the probe spinner

- mentionTooltip gives the bubble a stable id and sets aria-describedby
  on the anchor pill while shown (cleared on hide/retarget), so screen
  readers announce the full path / skill description instead of only
  the truncated label
- the probe spinner's ring stroke now rides --p-ring-stroke, the shared
  ring-family token, instead of a hardcoded 1.5px
- the in-flight existence probe is tracked by its full key
  (scope|actionPath): two pills can share a display path while probing
  different action paths, and an earlier flight's settle must not clear
  the spinner the current probe still needs

* fix(composer): token the mention icon stroke, split Windows tooltip paths, drop Markdown pill link navigation

- the pill glyph stroke rides the existing --p-hairline token instead of
  a hardcoded 0.5px
- the path tooltip splits on both '/' and '\' and renders each
  separator with its original character, so a Windows path no longer
  bolds as one blob
- Markdown file pills (on hosts with openFile) and every skill pill
  lose their href: middle-click and 'open in new tab' would otherwise
  navigate a workspace path or kimi-code:// as a web URL, bypassing the
  in-app routes. Explicit tabIndex + button role keep keyboard
  semantics, and file pills gain an Enter/Space keydown that fires the
  same action the click does

* fix(composer): degrade stale skill pills to plain references, generation-guard the activation failure restore

- desktop composer: a revived pill naming a skill that is gone from the
  workspace no longer enters the auto-activation path once the skill
  list is loaded — the daemon refusal plus restore looped the same
  message forever, so it could never go out as a plain prompt. The new
  skillsLoaded prop (client -> ConversationPane -> Composer) gates the
  check so the loading window keeps the old attempt path
- App.vue (desktop + web): a monotonic send generation now guards the
  failure restore on both activation branches. A user sending or
  queueing the next message while the activation was in flight left the
  composer empty again, and the old same-session + empty check then
  re-filled the old command for an easy duplicate send

* fix(chat): keep the command-args styling for slash-typed skill activations

isCommandArgs now covers a skill activation only when its args do NOT
carry the skill as a pill: pill-composed activations keep the plain
bubble styling (the args ARE the message with the pill revived inline),
while slash-typed ones (/skill:name args) keep the skill-act-args
indent/muting and u-text-wrap-args spacing under the identity card,
same as a plugin command's

* fix(composer): move the probe spinner's baseline lift onto a token

The optical vertical-align now rides --p-mention-tip-spinner-lift in the
central token block (em-based, so it still tracks the tooltip's font
size) instead of a hardcoded coefficient in the shared sheet

* fix(composer): widen the mention tooltip's viewport margin to --space-3

The hover card sat a bare 8px (--space-2) from the window edge, which
reads as no gap at all for a floating surface. The margin is now a
single-sourced token (--p-mention-tip-vmargin, 12px) read by position()
via tokenPx and folded into the width cap, so the card keeps a visible
breathing room from the viewport on every side
2026-08-18 14:21:12 +08:00

51 lines
1.5 KiB
JSON

{
"name": "kimi-code-web",
"version": "0.1.2",
"private": true,
"type": "module",
"scripts": {
"prepare:fonts": "node ../../scripts/prepare-fonts.mjs",
"predev": "npm run prepare:fonts",
"dev": "vite",
"predev:stub": "npm run prepare:fonts",
"dev:stub": "node dev/stub-daemon.mjs",
"prebuild": "npm run prepare:fonts",
"build": "vite build",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"check:style": "node scripts/check-style.mjs"
},
"dependencies": {
"@chenglou/pretext": "0.0.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@moonshot-ai/app-client": "workspace:*",
"@moonshot-ai/app-composer": "workspace:*",
"@moonshot-ai/app-core": "workspace:*",
"@moonshot-ai/app-i18n": "workspace:*",
"@moonshot-ai/app-markdown": "workspace:*",
"@moonshot-ai/app-ui": "workspace:*",
"@rive-app/canvas": "2.38.5",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"katex": "^0.17.0",
"markstream-vue": "1.0.9-beta.1",
"mermaid": "^11.15.0",
"photoswipe": "^5.4.4",
"shiki": "^4.3.0",
"stream-markdown": "0.0.16",
"vue": "^3.5.35",
"vue-i18n": "^11.4.5"
},
"devDependencies": {
"@iconify-json/ri": "^1.2.10",
"@iconify-json/tabler": "^1.2.35",
"@moonshot-ai/vite-preset": "workspace:*",
"@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",
"ws": "^8.18.0"
}
}