docs(web): design tab split workflow

This commit is contained in:
qer 2026-06-16 03:39:56 +08:00
parent 1ae8a38355
commit 3a7af1187d

View file

@ -0,0 +1,668 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kimi Web Tab / Split 设计方案</title>
<style>
:root {
--bg: #f7f8fa;
--surface: #ffffff;
--panel: #f0f3f7;
--line: #d8dee8;
--line-strong: #b9c3d1;
--ink: #17202b;
--muted: #647184;
--faint: #8a95a6;
--blue: #1565c0;
--blue-soft: #e8f1fc;
--green: #16835f;
--green-soft: #e4f5ee;
--amber: #b46b00;
--amber-soft: #fff3d9;
--red: #b42318;
--red-soft: #fde8e7;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
font-size: 14px;
line-height: 1.55;
}
header.hero {
padding: 28px 32px 18px;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.eyebrow {
color: var(--blue);
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
letter-spacing: .02em;
}
h1 {
margin: 8px 0 8px;
font-size: 28px;
line-height: 1.18;
letter-spacing: 0;
}
.lead {
max-width: 920px;
margin: 0;
color: var(--muted);
font-size: 15px;
}
main {
max-width: 1180px;
margin: 0 auto;
padding: 22px 22px 40px;
}
.summary {
display: grid;
grid-template-columns: 1.1fr .9fr;
gap: 14px;
margin-bottom: 16px;
}
.band {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
}
h2 {
margin: 0 0 10px;
font-size: 17px;
line-height: 1.25;
letter-spacing: 0;
}
h3 {
margin: 18px 0 8px;
font-size: 14px;
letter-spacing: 0;
}
.verdict {
display: grid;
grid-template-columns: 92px 1fr;
gap: 10px;
align-items: start;
padding: 9px 0;
border-top: 1px solid var(--line);
}
.verdict:first-of-type { border-top: none; }
.tag {
display: inline-flex;
width: max-content;
align-items: center;
border-radius: 999px;
padding: 2px 8px;
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
border: 1px solid var(--line);
background: var(--panel);
color: var(--muted);
white-space: nowrap;
}
.tag.good { background: var(--green-soft); color: var(--green); border-color: #b9e3d1; }
.tag.warn { background: var(--amber-soft); color: var(--amber); border-color: #ebd39f; }
.tag.risk { background: var(--red-soft); color: var(--red); border-color: #f4b9b5; }
.tag.blue { background: var(--blue-soft); color: var(--blue); border-color: #b7d5f5; }
code {
font-family: var(--mono);
font-size: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 4px;
padding: 1px 4px;
}
.tabs {
position: sticky;
top: 0;
z-index: 2;
display: flex;
gap: 6px;
margin: 16px 0;
padding: 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(247, 248, 250, .94);
backdrop-filter: blur(8px);
overflow-x: auto;
}
.tab {
flex: none;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--muted);
font: 700 13px var(--sans);
padding: 7px 10px;
cursor: pointer;
}
.tab.active {
background: var(--blue);
border-color: var(--blue);
color: #fff;
}
.section { display: none; }
.section.active { display: block; }
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.logic-list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.logic-list li {
display: grid;
grid-template-columns: 150px 1fr;
gap: 10px;
border-top: 1px solid var(--line);
padding-top: 8px;
}
.logic-list li:first-child {
border-top: none;
padding-top: 0;
}
.key {
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
}
.mock-shell {
height: 520px;
display: grid;
grid-template-columns: 240px 1fr;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: var(--surface);
overflow: hidden;
}
.mock-sidebar {
border-right: 1px solid var(--line);
background: #fbfcfd;
padding: 12px;
}
.mock-side-title {
font-weight: 800;
margin-bottom: 10px;
}
.mock-session {
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
background: var(--surface);
}
.mock-main {
min-width: 0;
display: grid;
grid-template-rows: 38px 1fr 44px;
background: var(--bg);
}
.mock-top {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.mock-pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 2px 8px;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
background: var(--panel);
}
.mock-workspace {
display: grid;
grid-template-columns: 1.05fr 1fr;
min-width: 0;
min-height: 0;
}
.mock-pane {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: 32px 1fr;
border-right: 1px solid var(--line);
background: var(--surface);
}
.mock-pane:last-child { border-right: none; }
.mock-tabs {
display: flex;
align-items: stretch;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.mock-tab {
display: flex;
align-items: center;
padding: 0 11px;
border-right: 1px solid var(--line);
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.mock-tab.on {
background: var(--surface);
color: var(--blue);
}
.mock-body {
padding: 12px;
min-width: 0;
overflow: hidden;
}
.chat-line {
height: 16px;
width: 88%;
margin: 9px 0;
border-radius: 4px;
background: var(--panel);
}
.chat-line.short { width: 58%; }
.file-split {
height: 100%;
display: grid;
grid-template-columns: 180px 1fr;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
}
.file-nav {
border-right: 1px solid var(--line);
background: #fbfcfd;
padding: 8px;
}
.file-row {
height: 22px;
border-radius: 4px;
margin-bottom: 6px;
background: var(--panel);
}
.file-row.on { background: var(--blue-soft); border: 1px solid #bdd8f5; }
.file-preview {
padding: 10px;
background: var(--surface);
}
.code-line {
height: 13px;
border-radius: 3px;
margin-bottom: 6px;
background: #edf1f6;
}
.mock-dock {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-top: 1px solid var(--line);
background: var(--surface);
}
.dock-input {
flex: 1;
height: 28px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fbfcfd;
}
.diagram {
width: 100%;
min-height: 260px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}
.table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.table th,
.table td {
border-bottom: 1px solid var(--line);
padding: 9px 10px;
vertical-align: top;
text-align: left;
}
.table th {
background: var(--panel);
color: var(--muted);
font-size: 12px;
font-family: var(--mono);
}
.table tr:last-child td { border-bottom: none; }
.steps {
display: grid;
gap: 10px;
counter-reset: step;
}
.step {
position: relative;
padding: 12px 12px 12px 42px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}
.step::before {
counter-increment: step;
content: counter(step);
position: absolute;
left: 12px;
top: 12px;
width: 20px;
height: 20px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--blue);
color: #fff;
font: 700 12px var(--mono);
}
.mini {
color: var(--muted);
font-size: 12px;
}
@media (max-width: 860px) {
header.hero { padding: 22px 18px 16px; }
main { padding: 16px 14px 32px; }
.summary,
.grid-2 { grid-template-columns: 1fr; }
.mock-shell {
height: auto;
grid-template-columns: 1fr;
}
.mock-sidebar { display: none; }
.mock-workspace { grid-template-columns: 1fr; }
.mock-pane { min-height: 260px; border-right: none; border-bottom: 1px solid var(--line); }
.logic-list li { grid-template-columns: 1fr; gap: 2px; }
}
</style>
</head>
<body>
<header class="hero">
<div class="eyebrow">2026-06-16 · apps/kimi-web</div>
<h1>Tab / 分屏逻辑整理与成熟版设计方案</h1>
<p class="lead">结论:当前实现已经收敛成「对话」和「文件」两个持久 tabPreview 只应作为工作区外资源或媒体的临时面板。下一版建议把分屏视为“工作区布局”把任务、Todo、BTW、Subagent 详情都放进 dock 或右侧临时面板,不再强行成为每个分屏都携带的主 tab。</p>
</header>
<main>
<section class="summary">
<div class="band">
<h2>先给结论</h2>
<div class="verdict">
<span class="tag good">保留</span>
<div>每个 split group 只持久保留 <code>chat</code><code>files</code>。这是现在代码最清晰的部分,符合重复使用的主工作流。</div>
</div>
<div class="verdict">
<span class="tag warn">收口</span>
<div><code>preview</code> 不应该常驻 tab。工作区内文件直接进 Files工作区外文件、媒体、Thinking、Compaction、Subagent detail 走临时右侧详情层。</div>
</div>
<div class="verdict">
<span class="tag blue">建议</span>
<div>分屏按钮只复制“当前 group 的工作区视图”,默认新 group 打开另一个主视图Chat 分屏出 FilesFiles 分屏出 Chat。任务类信息不参与 group tab。</div>
</div>
</div>
<div class="band">
<h2>真实代码入口</h2>
<ul class="logic-list">
<li><span class="key">布局状态</span><span><code>apps/kimi-web/src/composables/usePaneLayout.ts</code></span></li>
<li><span class="key">tab 渲染</span><span><code>apps/kimi-web/src/components/TabBar.vue</code></span></li>
<li><span class="key">主体承载</span><span><code>apps/kimi-web/src/components/ConversationPane.vue</code></span></li>
<li><span class="key">工作 dock</span><span><code>ConversationPane.vue</code> 底部 <code>dock-workbar</code></span></li>
</ul>
</div>
</section>
<nav class="tabs" aria-label="report sections">
<button class="tab active" data-tab="current">当前逻辑</button>
<button class="tab" data-tab="proposal">成熟方案</button>
<button class="tab" data-tab="rules">交互规则</button>
<button class="tab" data-tab="migration">落地步骤</button>
</nav>
<section id="current" class="section active">
<div class="grid-2">
<div class="band">
<h2>当前状态</h2>
<ul class="logic-list">
<li><span class="key">持久 tab</span><span><code>ALL_VIEWS = ['chat', 'files']</code>,每个 group 默认只有对话和文件。</span></li>
<li><span class="key">Preview</span><span><code>openPreview()</code> 临时插入,不写入默认 tabreload 时会被清掉。</span></li>
<li><span class="key">Tasks/Todo</span><span><code>PaneKey</code> 仍允许 <code>tasks</code>/<code>todo</code>,但当前主 UI 主要通过底部 dock 打开。</span></li>
<li><span class="key">Split</span><span>拆分当前 group另一边默认打开相反主视图当前 Files 则新 group Chat否则 Files。</span></li>
</ul>
</div>
<div class="band">
<h2>现在奇怪的点</h2>
<div class="verdict">
<span class="tag warn">历史痕迹</span>
<div><code>PaneKey</code> 还包含 tasks/todo<code>TabBar</code> 不展示它们,读代码时会觉得“能作为 tab但 UI 又没有”。</div>
</div>
<div class="verdict">
<span class="tag warn">分屏负担</span>
<div>每个 group 都带完整 tab 条和 split 按钮,视觉上像每块区域都能承载全部能力,但真实只有 Chat/Files 是稳定视图。</div>
</div>
<div class="verdict">
<span class="tag risk">Preview 误用</span>
<div>工作区内产物如果还走 Preview会增加一个临时 tab用户会在 Files 和 Preview 之间迷路。</div>
</div>
</div>
</div>
<div class="band" style="margin-top:14px;">
<h2>当前布局示意</h2>
<svg class="diagram" viewBox="0 0 960 280" role="img" aria-label="current pane layout">
<rect x="20" y="22" width="920" height="236" rx="10" fill="#fff" stroke="#b9c3d1" />
<rect x="20" y="22" width="920" height="34" rx="10" fill="#f0f3f7" />
<text x="38" y="44" font-family="ui-monospace,monospace" font-size="13" fill="#647184">ConversationPane</text>
<rect x="48" y="80" width="398" height="146" rx="8" fill="#fff" stroke="#d8dee8" />
<rect x="48" y="80" width="398" height="30" fill="#f0f3f7" />
<rect x="48" y="80" width="80" height="30" fill="#fff" />
<text x="70" y="100" font-size="12" fill="#1565c0" font-weight="700">对话</text>
<text x="146" y="100" font-size="12" fill="#647184">文件</text>
<text x="62" y="146" font-size="13" fill="#647184">ChatPane + Composer dock</text>
<rect x="514" y="80" width="398" height="146" rx="8" fill="#fff" stroke="#d8dee8" />
<rect x="514" y="80" width="398" height="30" fill="#f0f3f7" />
<text x="536" y="100" font-size="12" fill="#647184">对话</text>
<rect x="594" y="80" width="80" height="30" fill="#fff" />
<text x="616" y="100" font-size="12" fill="#1565c0" font-weight="700">文件</text>
<text x="532" y="146" font-size="13" fill="#647184">Changed / All + FilePreview</text>
<line x1="480" y1="78" x2="480" y2="228" stroke="#b9c3d1" stroke-width="2" stroke-dasharray="5 5" />
<text x="438" y="248" font-size="12" fill="#8a95a6">split handle</text>
</svg>
</div>
</section>
<section id="proposal" class="section">
<div class="band">
<h2>成熟版目标</h2>
<div class="mock-shell">
<aside class="mock-sidebar">
<div class="mock-side-title">Workspace</div>
<div class="mock-session">当前 session<br><span class="mini">main · 8 changes</span></div>
<div class="mock-session">历史 session<br><span class="mini">idle</span></div>
</aside>
<section class="mock-main">
<div class="mock-top">
<span class="mock-pill">feat/web</span>
<span class="mock-pill">Files > Changed</span>
<span class="mock-pill">Subagent running</span>
</div>
<div class="mock-workspace">
<div class="mock-pane">
<div class="mock-tabs">
<div class="mock-tab on">对话</div>
<div class="mock-tab">文件</div>
</div>
<div class="mock-body">
<div class="chat-line"></div>
<div class="chat-line short"></div>
<div class="chat-line"></div>
<div class="chat-line short"></div>
</div>
</div>
<div class="mock-pane">
<div class="mock-tabs">
<div class="mock-tab">对话</div>
<div class="mock-tab on">文件</div>
</div>
<div class="mock-body">
<div class="file-split">
<div class="file-nav">
<div class="file-row on"></div>
<div class="file-row"></div>
<div class="file-row"></div>
<div class="file-row"></div>
</div>
<div class="file-preview">
<div class="code-line"></div>
<div class="code-line"></div>
<div class="code-line" style="width:70%"></div>
</div>
</div>
</div>
</div>
</div>
<div class="mock-dock">
<span class="mock-pill">BTW</span>
<div class="dock-input"></div>
<span class="mock-pill">Tasks 2</span>
</div>
</section>
</div>
</div>
<div class="grid-2" style="margin-top:14px;">
<div class="band">
<h2>视图分层</h2>
<table class="table">
<thead><tr><th>层级</th><th>承载内容</th><th>规则</th></tr></thead>
<tbody>
<tr><td>主 tab</td><td>对话、文件</td><td>每个 split group 都固定存在,用户可重复使用。</td></tr>
<tr><td>文件子视图</td><td>Changed、All、Diff、File Preview</td><td>属于 Files 内部状态,不再新增全局 Preview tab。</td></tr>
<tr><td>底部 dock</td><td>Composer、Bash task、Subagent、Todo、Goal</td><td>跟当前 session 绑定,不跟每个 split group 绑定。</td></tr>
<tr><td>右侧详情</td><td>Thinking、Compaction、Subagent detail、工作区外 Preview</td><td>临时、互斥、可关闭,不进入持久布局。</td></tr>
</tbody>
</table>
</div>
<div class="band">
<h2>建议的数据模型</h2>
<pre><code>{
"layout": {
"type": "split",
"dir": "row",
"children": [
{ "type": "group", "views": ["chat", "files"], "active": "chat" },
{ "type": "group", "views": ["chat", "files"], "active": "files" }
],
"sizes": [1, 1]
},
"filesStateBySession": {
"sess_1": { "mode": "changed", "selectedPath": "reports/out.html" }
},
"detailPanel": { "kind": "subagent", "id": "agent_1" }
}</code></pre>
<p class="mini">重点layout 只描述空间Files 选择、详情面板、dock 展开状态都从 layout 里拆出去。</p>
</div>
</div>
</section>
<section id="rules" class="section">
<div class="band">
<h2>交互规则</h2>
<table class="table">
<thead><tr><th>动作</th><th>结果</th><th>原因</th></tr></thead>
<tbody>
<tr><td>点击右上角 git 区域</td><td>当前 Chat group 切到 Files并选中 Changed。</td><td>这是“看改动”的直接路径,不需要用户先找文件 tab。</td></tr>
<tr><td>点击对话中的工作区内文件链接</td><td>打开 Files tab左侧定位文件右侧展示 diff 或内容。</td><td>文件属于工作区,应该进入文件工作流。</td></tr>
<tr><td>点击工作区外路径或媒体</td><td>打开右侧临时详情面板。</td><td>Files API 不能保证能读工作区外资源。</td></tr>
<tr><td>拆分 Chat group</td><td>新 group 默认打开 Files。</td><td>最常见的并排需求是“对话 + 文件”。</td></tr>
<tr><td>拆分 Files group</td><td>新 group 默认打开 Chat。</td><td>避免两边都显示同一个 Files 初始态。</td></tr>
<tr><td>打开 Task/Subagent/Todo</td><td>底部 dock 展开,必要时右侧详情展示完整输出。</td><td>任务是 session 状态,不是每个 split 的独立 tab。</td></tr>
</tbody>
</table>
</div>
<div class="band" style="margin-top:14px;">
<h2>状态流</h2>
<svg class="diagram" viewBox="0 0 960 310" role="img" aria-label="interaction flow">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#647184" />
</marker>
</defs>
<rect x="40" y="36" width="190" height="54" rx="8" fill="#e8f1fc" stroke="#b7d5f5" />
<text x="68" y="68" font-size="13" fill="#1565c0" font-weight="700">用户点击文件入口</text>
<rect x="330" y="24" width="210" height="78" rx="8" fill="#fff" stroke="#d8dee8" />
<text x="360" y="56" font-size="13" fill="#17202b" font-weight="700">normalizePreviewPath</text>
<text x="360" y="78" font-size="12" fill="#647184">判断是否在当前 cwd 内</text>
<rect x="660" y="24" width="210" height="78" rx="8" fill="#e4f5ee" stroke="#b9e3d1" />
<text x="694" y="56" font-size="13" fill="#16835f" font-weight="700">Files tab</text>
<text x="694" y="78" font-size="12" fill="#647184">diff 或 file preview</text>
<rect x="660" y="172" width="210" height="78" rx="8" fill="#fff3d9" stroke="#ebd39f" />
<text x="688" y="204" font-size="13" fill="#b46b00" font-weight="700">临时 Preview</text>
<text x="688" y="226" font-size="12" fill="#647184">工作区外/媒体/错误态</text>
<line x1="230" y1="63" x2="330" y2="63" stroke="#647184" stroke-width="1.5" marker-end="url(#arrow)" />
<line x1="540" y1="63" x2="660" y2="63" stroke="#647184" stroke-width="1.5" marker-end="url(#arrow)" />
<line x1="435" y1="102" x2="435" y2="211" stroke="#647184" stroke-width="1.5" />
<line x1="435" y1="211" x2="660" y2="211" stroke="#647184" stroke-width="1.5" marker-end="url(#arrow)" />
<text x="565" y="48" font-size="12" fill="#16835f">在 cwd 内</text>
<text x="462" y="196" font-size="12" fill="#b46b00">不在 cwd 内</text>
</svg>
</div>
</section>
<section id="migration" class="section">
<div class="band">
<h2>落地步骤</h2>
<div class="steps">
<div class="step"><strong>清理类型边界:</strong><code>PaneKey</code> 拆成 <code>PrimaryPaneKey = 'chat' | 'files'</code><code>TransientPaneKey = 'preview'</code>。Tasks/Todo 不再进入 pane layout。</div>
<div class="step"><strong>沉淀 Files 状态:</strong>为每个 session 保存 <code>mode</code><code>selectedPath</code><code>selectedLine</code>,从 <code>ConversationPane</code> 的局部状态逐步提到 composable。</div>
<div class="step"><strong>统一打开入口:</strong>保留一个 <code>openWorkspaceFileInFiles()</code>所有工作区内文件链接、git 区域、改动列表都走这一条。</div>
<div class="step"><strong>收口详情层:</strong>Thinking、Compaction、Subagent detail、工作区外 Preview 共用一个 <code>detailPanel</code> 状态,互斥展示。</div>
<div class="step"><strong>最后清 UI</strong>TabBar 只展示对话/文件Preview 不常驻split 操作留在 group 右上角,但只影响空间布局。</div>
</div>
</div>
<div class="band" style="margin-top:14px;">
<h2>验收标准</h2>
<table class="table">
<tbody>
<tr><td><span class="tag good">通过</span></td><td>新开 session 只看到对话和文件两个主 tab。</td></tr>
<tr><td><span class="tag good">通过</span></td><td>工作区内 HTML/Markdown/代码产物链接打开到 Files不新增 Preview tab。</td></tr>
<tr><td><span class="tag good">通过</span></td><td>工作区外路径仍有可见反馈,不会静默失败。</td></tr>
<tr><td><span class="tag good">通过</span></td><td>拆分后两边默认互补显示 Chat 和 Files。</td></tr>
<tr><td><span class="tag good">通过</span></td><td>刷新页面后不会恢复空 Preview也不会丢失 Chat/Files split。</td></tr>
</tbody>
</table>
</div>
</section>
</main>
<script>
const buttons = Array.from(document.querySelectorAll('.tab'));
const sections = Array.from(document.querySelectorAll('.section'));
function activate(id) {
buttons.forEach((button) => button.classList.toggle('active', button.dataset.tab === id));
sections.forEach((section) => section.classList.toggle('active', section.id === id));
}
buttons.forEach((button) => {
button.addEventListener('click', () => activate(button.dataset.tab));
});
</script>
</body>
</html>