Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-code-block-line-numbers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix code block line numbers drifting out of sync with the code lines in plain-text code blocks.
5 changes: 5 additions & 0 deletions .changeset/web-ctrl-s-save-dialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Prevent the browser Save Page dialog on Ctrl+S / Cmd+S anywhere in the app; the shortcut still steers into the running turn from the message input.
5 changes: 5 additions & 0 deletions .changeset/web-full-width-chat-column.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Let the chat column follow the window width instead of capping it at a fixed reading width, removing the wide empty margins on large screens.
9 changes: 9 additions & 0 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,15 @@ onUnmounted(() => {
});

function onGlobalKeydown(e: KeyboardEvent): void {
// Swallow Ctrl+S / Cmd+S app-wide, in capture phase: the composer advertises
// the shortcut for steering into the running turn, but its own keydown only
// fires while the composer is focused. Without this the browser opens its
// Save Page dialog whenever the shortcut is pressed anywhere else. Steering
// itself stays in the composer's handler.
if (e.key === 's' && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
e.preventDefault();
return;
}
if (e.key !== 'Escape') return;
// A modal dialog open on top of the side panel owns Escape — leave the event
// alone so the dialog can close itself instead of the panel behind it.
Expand Down
7 changes: 5 additions & 2 deletions apps/kimi-web/src/components/chat/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -491,10 +491,13 @@ function handleKeydown(e: KeyboardEvent): void {
}
}

// Ctrl+S / Cmd+S — steer into the running turn (TUI parity)
// Ctrl+S / Cmd+S — steer into the running turn (TUI parity). Always swallow
// the shortcut: without preventDefault the browser opens its Save Page dialog.
// (The same swallow lives app-wide in App.vue's onGlobalKeydown, so the
// dialog is suppressed even when the composer is not focused.)
if (e.key === 's' && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
e.preventDefault();
if (props.running) {
e.preventDefault();
handleSteer();
}
return;
Expand Down
15 changes: 9 additions & 6 deletions apps/kimi-web/src/components/chat/ConversationPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ function updateActiveTocQuery(): void {

// --- TOC occlusion by wide tables -------------------------------------------
// Wide markdown tables (up to --p-table-max) can extend past the TOC rail,
// which stays anchored to the reading-column edge. While a table actually
// which stays anchored to the pane's right edge. While a table actually
// covers the rail we hide the TOC temporarily so the table stays fully
// interactive (clicks, text selection, horizontal scroll). The user's TOC
// setting is untouched and the rail returns as soon as the table scrolls away.
Expand All @@ -354,7 +354,7 @@ function updateTocTableOcclusion(): void {
? pane.closest('.con')?.querySelector<HTMLElement>('.conversation-toc')
: null;
// The hit x is the centre of the fixed rail bar: `.toc-bar` keeps a stable x
// even when hover expands the labels rightward, so hovering the TOC itself
// even when hover expands the labels leftward, so hovering the TOC itself
// never flips the state (the nav centre would).
const bar = toc?.querySelector<HTMLElement>('.toc-bar');
let covered = false;
Expand Down Expand Up @@ -423,7 +423,9 @@ const panesRef = ref<HTMLElement | null>(null);
const dockRef = ref<HTMLElement | null>(null);
const panesScrollbarWidth = ref(0);
const dockHeight = ref(0);
const chatDockStyle = computed(() => ({
// Exposed on `.con` (not the dock alone) so the TOC rail can compensate for
// the panes scrollbar gutter at the pane's right edge, just like the dock does.
const conStyle = computed(() => ({
'--panes-scrollbar-width': `${panesScrollbarWidth.value}px`,
}));
type ComposerHandle = {
Expand Down Expand Up @@ -1251,7 +1253,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
</script>

<template>
<section class="con" :class="{ mobile }">
<section class="con" :class="{ mobile }" :style="conStyle">
<!-- Chat context header: workspace/session, git status, open-in-editor,
copy-all, PR. Hidden for the empty-composer (no session context yet). -->
<ChatHeader
Expand Down Expand Up @@ -1443,7 +1445,6 @@ defineExpose({ loadComposerForEdit, focusComposer });
<ChatDock
v-if="!(turns.length === 0 && !sessionLoading)"
:ref="bindChatDock"
:style="chatDockStyle"
:session-id="sessionId"
:running="running"
:starting="starting"
Expand Down Expand Up @@ -1530,7 +1531,9 @@ defineExpose({ loadComposerForEdit, focusComposer });

<style scoped>
.con {
--read-max: 760px;
/* The reading column follows the pane width — no fixed max-width cap, so
wide windows don't leave empty margins on both sides. */
--read-max: 100%;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep regular chat content capped by the reading token

As committed, --read-max: 100% makes regular assistant prose and user bubbles expand to 94%/78% of whatever desktop pane width is available, while the web design-system view still defines --p-content-max as the chat reading-column max width and states that regular chat prose stays within that 760px column. On large monitors this regresses readability and also bypasses the tokenized layout contract; if full-width prose is intended, the design-system contract should be updated together, otherwise keep the normal stream capped and reserve wider growth for the existing table/content exceptions.

AGENTS.md reference: apps/kimi-web/AGENTS.md:L11-L11

Useful? React with 👍 / 👎.

display: flex;
flex-direction: column;
min-width: 0;
Expand Down
73 changes: 43 additions & 30 deletions apps/kimi-web/src/components/chat/ConversationToc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,17 @@ const emit = defineEmits<{

const { t } = useI18n();

// Width the rail needs beside the reading column once its labels are fully
// revealed on hover/focus: 3px bar + 10px gap + 220px label, plus a small
// buffer so the text never kisses the container edge. Kept in sync with the
// `.toc-bar` / `.toc-label` rules below.
// Width the rail needs to its left once its labels are fully revealed on
// hover/focus: 3px bar + 10px gap + 220px label, plus a small buffer so the
// text never kisses the container edge. Kept in sync with the `.toc-bar` /
// `.toc-label` rules below.
const EXPANDED_WIDTH = 240;

const navRef = ref<HTMLElement | null>(null);
// Whether the rail, once expanded, fits within the room to the right of the
// reading column. When it would overflow, we hide the outline entirely rather
// than showing a panel that gets clipped by the container edge.
// Whether the rail, once expanded, fits within the room to its left (the
// labels reveal leftward over the content). When it would overflow, we hide
// the outline entirely rather than showing a panel that gets clipped by the
// container edge.
const fits = ref(true);

let observer: ResizeObserver | null = null;
Expand All @@ -47,9 +48,9 @@ function measure(): void {
const nav = navRef.value;
const parent = nav?.offsetParent as HTMLElement | null;
if (!nav || !parent) return;
const navLeft = nav.getBoundingClientRect().left;
const parentRight = parent.getBoundingClientRect().right;
fits.value = parentRight - navLeft >= EXPANDED_WIDTH;
const navRight = nav.getBoundingClientRect().right;
const parentLeft = parent.getBoundingClientRect().left;
fits.value = navRight - parentLeft >= EXPANDED_WIDTH;
}

// The outline is only useful once there is something to navigate, and it never
Expand Down Expand Up @@ -93,8 +94,9 @@ onBeforeUnmount(() => {

<template>
<!-- Conversation outline: a vertical list of short bars (one per user query),
vertically centered beside the chat. Hovering the list enlarges the bars
and reveals each query's title to the right, making rows easy to click. -->
vertically centered at the pane's right edge. Hovering the list enlarges
the bars and reveals each query's title to the left, over the content,
making rows easy to click. -->
<nav
v-if="visible"
ref="navRef"
Expand Down Expand Up @@ -125,15 +127,12 @@ onBeforeUnmount(() => {
z-index: var(--z-sticky);
top: 50%;
transform: translateY(-50%);
/* Anchor to the reading-column edge, the rail's original position. Tables
that grow past it (up to --p-table-max) temporarily hide the rail via the
occlusion hit-test in ConversationPane, so proximity is safe again.
The cqi cap keeps the rail inside narrow containers. */
--toc-content-max: min(
var(--p-content-max),
calc(100cqi - var(--space-5) - var(--space-5))
);
left: calc(50% + (var(--toc-content-max) / 2) + 14px);
/* Anchor to the pane's right edge, just inside the panes scrollbar gutter.
The reading column spans the full pane width, so that edge is the only
stable outside position left; labels reveal leftward over the content.
Tables that reach the rail temporarily hide it via the occlusion hit-test
in ConversationPane, so the overlap is safe. */
right: calc(var(--panes-scrollbar-width, 0px) + var(--space-2));
display: flex;
flex-direction: column;
justify-content: center;
Expand All @@ -142,10 +141,11 @@ onBeforeUnmount(() => {
}
/* Invisible hover bridge: the collapsed rail is only a few px wide, so this
extends the hover target on both sides to make the outline easy to open and
forgiving to stay within. The left side covers only the 14px gap to the
content edge — a table wide enough to reach past the gap also covers the
bar, which hides the rail (pointer-events: none) before the bridge can
steal its events. Kept at z-index 0 so it sits behind the rows (which are
forgiving to stay within. The left side covers only the 14px gap toward the
content — a table wide enough to reach past the gap also covers the bar,
which hides the rail (pointer-events: none) before the bridge can steal its
events. The right side stops at the scrollbar gutter so it never swallows
scrollbar clicks. Kept at z-index 0 so it sits behind the rows (which are
raised to z-index 1) — otherwise the bridge, as a positioned pseudo-element,
paints above the in-flow rows and swallows their clicks. */
.conversation-toc::before {
Expand All @@ -154,7 +154,7 @@ onBeforeUnmount(() => {
top: 0;
bottom: 0;
left: -14px;
right: -48px;
right: calc(-1 * var(--space-2));
z-index: 0;
}
.conversation-toc:hover,
Expand All @@ -175,6 +175,8 @@ onBeforeUnmount(() => {

.toc-row {
display: flex;
/* Bar rightmost (its x stays pinned at the pane edge), label extends left. */
flex-direction: row-reverse;
align-items: center;
gap: 10px;
height: 18px;
Expand Down Expand Up @@ -213,20 +215,31 @@ onBeforeUnmount(() => {
color var(--duration-fast) var(--ease-out);
}

/* Hover / focus: enlarge bars and reveal labels to the right. */
/* Hover / focus: enlarge bars and reveal labels to the left. */
.conversation-toc:hover .toc-bar,
.conversation-toc:focus-within .toc-bar { height: 18px; opacity: 0.5; }
.conversation-toc:hover .toc-label,
.conversation-toc:focus-within .toc-label { max-width: 220px; opacity: 1; }
/* The revealed labels float over the message content, so the expanded outline
gets its own panel background to stay readable. The padding grows leftward
only, keeping the rail bar's x stable (the table-occlusion hit-test in
ConversationPane depends on it). */
.conversation-toc:hover .toc-scroll,
.conversation-toc:focus-within .toc-scroll {
padding-left: var(--space-3);
border-radius: var(--radius-md);
background: var(--color-bg);
box-shadow: var(--shadow-md);
}

.toc-row.active .toc-bar { opacity: 1; height: 18px; }
.toc-row.active .toc-label { color: var(--color-accent); font-weight: var(--weight-medium); }
.toc-row:hover .toc-bar { opacity: 1; }
.toc-row:hover .toc-label { color: var(--color-text); }

/* When there is not enough room to the right of the reading column to reveal
the labels, the rail is kept mounted (so its position can keep being
measured) but hidden from view and from pointer/screen-reader interaction. */
/* When there is not enough room to the left of the rail to reveal the labels,
the rail is kept mounted (so its position can keep being measured) but
hidden from view and from pointer/screen-reader interaction. */
.conversation-toc.toc-clipped {
visibility: hidden;
pointer-events: none;
Expand Down
6 changes: 5 additions & 1 deletion apps/kimi-web/src/components/chat/Markdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,11 @@ function copyDiff(code: string, idx: number) {
overflow-x: auto;
font: var(--text-sm)/1.65 var(--font-mono);
}
.md :deep(.code-block-container pre code) {
.md :deep(.code-block-container pre code),
/* Bare markstream fallback pres (rendered without .code-block-container) nest
<code> deeper than `pre > code`, so the inline-chip rule above would shrink
it and desync the line-number overlay from the code lines. */
.md :deep(pre[data-markstream-pre] code) {
font: inherit;
color: var(--color-text);
background: none;
Expand Down
8 changes: 4 additions & 4 deletions apps/kimi-web/src/views/DesignSystemView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ onUnmounted(() => {
<thead><tr><th>Token</th><th>Value</th><th>Usage</th></tr></thead>
<tbody>
<tr><td class="tk">--p-sidebar-w</td><td class="val">264px</td><td>left session sidebar width</td></tr>
<tr><td class="tk">--p-content-max</td><td class="val">760px</td><td>chat reading-column max width (regular chat prose)</td></tr>
<tr><td class="tk">--p-content-max</td><td class="val">760px</td><td>xl dialog width</td></tr>
<tr><td class="tk">--p-content-wide</td><td class="val">920px</td><td>wide content (settings / panel)</td></tr>
<tr><td class="tk">--p-table-max</td><td class="val">1040px</td><td>desktop wide-table max width (see §04)</td></tr>
<tr><td class="tk">--p-table-cell-max</td><td class="val">700px</td><td>max width of a single table column; longer cell content wraps (see §04)</td></tr>
Expand Down Expand Up @@ -1028,7 +1028,7 @@ onUnmounted(() => {

<h3 class="sub">Unified message stream</h3>
<div class="stage-wrap">
<div class="stage-bar"><span class="st">Conversation · 760px reading column</span></div>
<div class="stage-bar"><span class="st">Conversation · full-width column</span></div>
<div class="stage p col" style="align-items:center;background:#fff">
<div class="demo-chat">

Expand Down Expand Up @@ -1122,7 +1122,7 @@ onUnmounted(() => {
</div>
</div>
</div>
<p><b>Wide markdown tables (desktop):</b> regular chat prose stays within the 760px reading column (<code>--p-content-max</code>). On desktop a wide table may grow naturally with its content up to 1040px (<code>--p-table-max</code>), centred within the conversation pane; beyond that the excess scrolls horizontally inside the table's own wrapper — the page and the chat area never scroll sideways. A single column is capped at 700px (<code>--p-table-cell-max</code>), so long cell content wraps inside the cell instead of stretching the table. The conversation outline (TOC) keeps its usual position just outside the reading column; when a table grows past it and scrolls under the rail, the TOC is hidden temporarily and returns as soon as the table leaves, without touching the user's TOC setting. On mobile a table never breaks out of the reading column.</p>
<p><b>Wide markdown tables (desktop):</b> the chat reading column follows the pane width — there is no fixed max-width cap. On desktop a wide table may grow naturally with its content up to 1040px (<code>--p-table-max</code>), centred within the conversation pane; beyond that the excess scrolls horizontally inside the table's own wrapper — the page and the chat area never scroll sideways. A single column is capped at 700px (<code>--p-table-cell-max</code>), so long cell content wraps inside the cell instead of stretching the table. The conversation outline (TOC) is anchored to the pane's right edge, just inside the scrollbar gutter, and reveals its labels leftward over the content on hover; when a table grows past it and scrolls under the rail, the TOC is hidden temporarily and returns as soon as the table leaves, without touching the user's TOC setting. On mobile a table never breaks out of the column.</p>

<h3 class="sub">Tool calls: compact by default, grouped, expand on demand</h3>
<p>High-frequency calls like <code>read_file</code> / <code>bash</code> / <code>grep</code> are "operational noise" — if each one took a full card, parallel triggers would quickly drown out the conversation.
Expand Down Expand Up @@ -1206,7 +1206,7 @@ onUnmounted(() => {
<h3 class="sub">Responsive</h3>
<p>See §02 <code>--p-bp-sm</code> for the breakpoint. This section only gives mobile-adaptation pointers for the chat interface; a full mobile mockup is out of scope for this spec.</p>
<div class="callout info"><span class="ico">i</span><div>
At ≤640px: dialogs anchor to the bottom as Sheets (xl top radius, top drag handle), the sidebar collapses into an expandable drawer, the Composer toolbar is allowed to wrap, and the chat reading column drops its max-width to fill the screen.
At ≤640px: dialogs anchor to the bottom as Sheets (xl top radius, top drag handle), the sidebar collapses into an expandable drawer, and the Composer toolbar is allowed to wrap.
</div></div>
</section>

Expand Down