diff --git a/.changeset/web2-bundled-ui.md b/.changeset/web2-bundled-ui.md new file mode 100644 index 0000000000..f043a6a096 --- /dev/null +++ b/.changeset/web2-bundled-ui.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +web: Replace the bundled web UI with the design-first kimi-web2 prototype built from the Kimi Design System. Run kimi web and open the printed URL to try it. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 70accd50ac..8e833b779a 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -49,7 +49,7 @@ "provenance": true }, "scripts": { - "build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs", + "build": "tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs", "prebuild": "node scripts/build-vis-asset.mjs", "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json", "smoke": "node scripts/smoke.mjs", diff --git a/apps/kimi-code/scripts/copy-web-assets.mjs b/apps/kimi-code/scripts/copy-web-assets.mjs index d82f40de0a..69ec1e7650 100644 --- a/apps/kimi-code/scripts/copy-web-assets.mjs +++ b/apps/kimi-code/scripts/copy-web-assets.mjs @@ -1,27 +1,33 @@ import { cp, rm, stat } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; +import { basename, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = resolve(appRoot, '../..'); -const source = resolve(repoRoot, 'apps/kimi-web/dist'); +// This branch ships the design-first kimi-web2 UI as the bundled web assets. +// kimi-web2 is a static, no-build app, so it is copied directly (no dist step). +const source = resolve(repoRoot, 'apps/kimi-web2'); const target = resolve(appRoot, 'dist-web'); -async function assertBuiltWeb() { +// Dev/docs files that should not ship in the web asset bundle. +const EXCLUDE = new Set(['serve.mjs', 'README.md', 'CONVENTIONS.md']); + +async function assertWebSource() { try { const info = await stat(resolve(source, 'index.html')); if (!info.isFile()) { throw new Error('index.html is not a file'); } } catch { - throw new Error( - `Kimi web build output was not found at ${source}. Run \`pnpm --filter @moonshot-ai/kimi-web run build\` first.`, - ); + throw new Error(`Kimi web assets were not found at ${source}.`); } } -await assertBuiltWeb(); +await assertWebSource(); await rm(target, { recursive: true, force: true }); -await cp(source, target, { recursive: true }); +await cp(source, target, { + recursive: true, + filter: (src) => !EXCLUDE.has(basename(src)), +}); -console.log(`Copied Kimi web assets to ${target}`); +console.log(`Copied Kimi web assets (kimi-web2) to ${target}`); diff --git a/apps/kimi-web2/README.md b/apps/kimi-web2/README.md new file mode 100644 index 0000000000..7a58020bc5 --- /dev/null +++ b/apps/kimi-web2/README.md @@ -0,0 +1,42 @@ +# kimi-web2 — Kimi Design System web UI (design-first rewrite) + +从 Kimi Design System(设计 skill)出发、从零构建的 kimi-web 界面。零依赖、无构建(vanilla +HTML/CSS/JS),可离线以 stub 数据预览设计,也可接上真实 kimi-code 服务器打通前后端。 + +## 预览(纯设计,无后端) + +直接打开 `index.html` 即可(file:// 也可以),所有数据为 stub。 + +直达参数:`?open=settings | aw | search | models | login` · `?theme=dark`(可叠加)。 + +## 接真实服务器 + +服务器 CORS 是白名单制,推荐用自带的同源代理: + +```bash +node serve.mjs # 默认 --port 8101 --target http://127.0.0.1:58627 +open "http://localhost:8101/?token=" +``` + +`?token=` 只需带一次(会存入 localStorage)。有 token 时 `live.js` 会: + +- 拉取真实 workspaces / sessions 替换 stub; +- 打开会话时按需拉取该会话最近 100 条消息并渲染(文本/思考/工具调用); +- 通过 WS 接收实时事件(回复流、工具进度、回合结束); +- 发送消息 POST 到该会话(本地新建的 stub 会话仍走 stub 回复)。 + +无 token 时完全离线,行为与设计稿一致。 + +## 文件 + +- `index.html` / `styles.css` / `app.js` — 外壳、渲染、交互(tokens 见 styles.css 顶部)。 +- `data.js` — stub 数据 + 轻量 store(`window.Store`)。 +- `live.js` — 真实服务器接线(REST + WS 协议说明见文件顶部注释)。 +- `serve.mjs` — 零依赖静态 + `/api/v1` 代理(HTTP + WS)。 +- `features/` — 自包含功能模块(模型选择、登录、右侧面板、设置四页),约定见 + `features/CONVENTIONS.md`。 + +## 设计基准 + +Kimi Design System:kimiDark 中性强调(蓝色仅品牌/数据)、状态用填充非边框、0.5px hairline、 +PingFang 优先、圆角 8/10/12/16/20、动效 `cubic-bezier(0.23,1,0.32,1)`、浅色 + 深色。 diff --git a/apps/kimi-web2/app.js b/apps/kimi-web2/app.js new file mode 100644 index 0000000000..63e60a151f --- /dev/null +++ b/apps/kimi-web2/app.js @@ -0,0 +1,657 @@ +/* Kimi Code Web prototype — rendering + interactions (no backend). */ +(function () { + 'use strict'; + var S = window.Store; + var root = document.documentElement; + + /* ----------------------------- i18n (light) --------------------------- */ + var I = { + zh: { + search: '搜索会话', newChat: '新建对话', workspaces: '工作区', showMore: '加载更多', + feedback: '问题与反馈', placeholder: '输入消息… Enter 发送 · Shift+Enter 换行', + send: '发送', stop: '打断', settings: '设置', close: '关闭', + emptyTitle: 'Kimi Code', emptySub: '从设计系统出发,calm、克制、填充式的编程助手。', + sug1: '帮我捋一下这个仓库的结构', sug2: '把这个页面改成 Kimi 风格', sug3: '解释一下这段代码', + general: '通用', agent: 'Agent', account: '账户', advanced: '高级', + }, + en: { + search: 'Search', newChat: 'New chat', workspaces: 'Workspaces', showMore: 'Show more', + feedback: 'Feedback', placeholder: 'Message… Enter to send · Shift+Enter for newline', + send: 'Send', stop: 'Stop', settings: 'Settings', close: 'Close', + emptyTitle: 'Kimi Code', emptySub: 'A calm, fill-based coding assistant built from the design system.', + sug1: 'Walk me through this repo', sug2: 'Restyle this page to Kimi', sug3: 'Explain this code', + general: 'General', agent: 'Agent', account: 'Account', advanced: 'Advanced', + }, + }; + function t(k) { return (I[S.state.lang] || I.zh)[k] || I.zh[k] || k; } + + /* ----------------------------- helpers -------------------------------- */ + function esc(s) { + return String(s).replace(/&/g, '&').replace(//g, '>'); + } + function icon(id, cls) { + return ''; + } + function $(sel) { return document.querySelector(sel); } + + /* ----------------------------- render: sidebar ------------------------ */ + function renderSidebar() { + var host = $('.sb-scroll'); + if (host) host.innerHTML = sbListHTML(); + } + + function sbListHTML() { + var h = ''; + h += '
' + esc(t('workspaces')) + '' + + '' + + '' + + '' + + '
'; + + S.workspaces.forEach(function (w) { + var collapsed = !!S.state.collapsed[w.id]; + var sessions = S.sessions.filter(function (s) { return s.ws === w.id; }); + var expanded = !!S.state.expanded[w.id]; + var showCount = expanded ? sessions.length : Math.min(sessions.length, 4); + h += '
'; + h += ''; + if (!collapsed) { + if (w.root) h += '
' + esc(w.root) + '
'; + sessions.slice(0, showCount).forEach(function (s) { h += sessHTML(s); }); + if (sessions.length > 4) { + h += ''; + } + } + h += '
'; + }); + return h; + } + + function sessHTML(s) { + var on = s.id === S.state.currentSessionId; + var lead = s.busy ? '' + : (s.unread || (s.pending && (s.pending.a || s.pending.q))) + ? '' + : ''; + var badge = ''; + if (s.pending && s.pending.q) badge += '待回答'; + else if (s.pending && s.pending.a) badge += '待审批'; + return ''; + } + + /* ----------------------------- render: topbar ------------------------- */ + function renderTopbar() { + var bar = $('.topbar'); + if (!bar) return; + var s = S.state.currentSessionId ? S.session(S.state.currentSessionId) : null; + var w = s ? S.workspace(s.ws) : null; + var crumb = s + ? '' + esc(w ? w.name : '') + '/' + esc(s.title) + '' + : 'Kimi Code'; + var meta = ''; + if (w && w.branch) { + meta += '' + icon('i-git') + esc(w.branch) + ''; + if (w.add || w.del) meta += '+' + w.add + ' -' + w.del + ''; + } + meta += ''; + bar.innerHTML = '
' + crumb + '
' + meta + '
'; + } + + /* ----------------------------- render: conversation ------------------- */ + function renderConv() { + var host = $('.conv-inner'); + if (!host) return; + var sid = S.state.currentSessionId; + var blocks = sid ? S.convo(sid) : []; + if (!sid || !blocks.length) { + host.innerHTML = emptyHTML(); + return; + } + var h = ''; + blocks.forEach(function (b) { h += blockHTML(b); }); + host.innerHTML = h; + } + + function emptyHTML() { + var chips = [t('sug1'), t('sug2'), t('sug3')].map(function (c) { + return ''; + }).join(''); + return '
' + + '' + + '

' + esc(t('emptyTitle')) + '

' + + '

' + esc(t('emptySub')) + '

' + + '
' + chips + '
' + + '
'; + } + + function blockHTML(b) { + switch (b.type) { + case 'user': + return '
' + esc(b.text) + '
'; + case 'lead': + return '

' + b.html + '

'; + case 'prose': + return '

' + b.html + '

'; + case 'think': + return '
' + icon('i-spark', 'spark') + '思考过程' + icon('i-chevron-right', 'chev') + '
' + b.html + '
'; + case 'code': + return '
' + esc(b.lang || '') + '
' + esc(b.code) + '
'; + case 'tool': + return '
' + + (b.body ? '
' + esc(b.body) + '
' : '') + '
'; + case 'toolGroup': + var items = (b.items || []).map(function (it) { + return ''; + }).join(''); + return '
' + items + '
'; + case 'approval': + return '
' + icon('i-alert', 'ic') + '' + esc(b.title) + '
' + + '
' + esc(b.body || '') + (b.cmd ? '' + esc(b.cmd) + '' : '') + '
' + + '
'; + case 'question': + var opts = (b.options || []).map(function (o) { + return ''; + }).join(''); + return '
' + icon('i-help', 'ic') + '' + esc(b.title) + '
' + + '
' + esc(b.body || '') + '
' + opts + '
'; + case 'status': + return '
' + esc(b.text) + '
'; + default: + return ''; + } + } + + /* ----------------------------- apply theme/font/lang ------------------ */ + function applyTheme() { + var mode = S.state.theme; + if (mode === 'light') root.setAttribute('data-theme', 'light'); + else if (mode === 'dark') root.setAttribute('data-theme', 'dark'); + else root.removeAttribute('data-theme'); + } + function applyFont() { + root.style.setProperty('--ui-font-size', S.state.fontSize + 'px'); + document.body.style.fontSize = S.state.fontSize + 'px'; + } + function applyLang() { root.setAttribute('lang', S.state.lang === 'en' ? 'en' : 'zh-CN'); } + + /* ----------------------------- render all ----------------------------- */ + function renderAll() { + applyTheme(); applyFont(); applyLang(); + renderSidebar(); renderTopbar(); renderConv(); + syncComposer(); syncComposerToolbar(); renderMobTitle(); + } + + /* ----------------------------- mobile shell --------------------------- */ + function renderMobTitle() { + var el = document.querySelector('[data-mob-title]'); + if (!el) return; + var s = S.state.currentSessionId && S.session(S.state.currentSessionId); + el.textContent = s ? s.title : 'Kimi Code'; + } + function closeSwitcher() { var b = document.querySelector('.sheet-back'); if (b) b.remove(); } + function openSwitcher() { + closeSwitcher(); + var back = document.createElement('div'); back.className = 'sheet-back'; + var sheet = document.createElement('div'); sheet.className = 'sheet'; + sheet.innerHTML = '

会话

'; + back.appendChild(sheet); document.body.appendChild(back); + sheet.querySelector('[data-sheet-body]').innerHTML = sbListHTML(); + requestAnimationFrame(function () { back.classList.add('is-open'); }); + } + + /* ----------------------------- toast ---------------------------------- */ + var toastTimer; + function toast(msg, kind) { + var el = $('[data-toast]'); + if (!el) return; + el.className = 'toast ' + (kind || 'success'); + el.innerHTML = icon(kind === 'error' ? 'i-close' : 'i-check') + esc(msg); + el.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { el.hidden = true; }, 1800); + } + + /* ----------------------------- composer ------------------------------- */ + function syncComposer() { + var perm = $('.pill.perm'); + if (perm) { + perm.className = 'pill perm perm-' + S.state.permission; + } + } + function stubReply(text) { + return '收到。这是一段 stub 助手回复(未接后端):关于「' + esc(text.slice(0, 24)) + (text.length > 24 ? '…' : '') + '」,我会按 Kimi 的填充式、hairline 分隔的样式继续。'; + } + function send() { + var ta = $('.cc-ta'); + var text = (ta.value || '').trim(); + if (!text) return; + var sid = S.state.currentSessionId; + if (!sid) { + // create a new session in the first workspace + sid = 's' + Date.now(); + var title = text.length > 26 ? text.slice(0, 26) + '…' : text; + S.sessions.unshift({ id: sid, ws: S.workspaces[0].id, title: title, ago: 1, busy: false, unread: false, pending: { a: 0, q: 0 } }); + S.conversations[sid] = []; + S.set({ currentSessionId: sid }); + } + var convo = S.conversations[sid]; + convo.push({ type: 'user', text: text }); + ta.value = ''; fit(); + renderAll(); + scrollBottom(); + var busy = S.session(sid); if (busy) { busy.busy = true; renderSidebar(); } + // Live mode (live.js): hand the prompt to the real server; the reply + // streams in over the WS. Falls through to the stub when offline or when + // the session only exists locally. + if (window.KimiLive && window.KimiLive.connected && window.KimiLive.send(sid, text)) return; + setTimeout(function () { + convo.push({ type: 'lead', html: stubReply(text) }); + var s = S.session(sid); if (s) s.busy = false; + renderAll(); scrollBottom(); + }, 500); + } + function scrollBottom() { + var c = $('.conv'); + if (c) requestAnimationFrame(function () { c.scrollTop = c.scrollHeight; }); + } + + /* composer auto-grow */ + var ta = $('.cc-ta'); + function fit() { + if (!ta) return; + ta.style.height = 'auto'; + ta.style.height = Math.min(ta.scrollHeight, window.innerHeight * 0.4) + 'px'; + } + if (ta) { + ta.addEventListener('input', function () { + fit(); + var v = ta.value; + if (/^\/\S*$/.test(v)) { renderSlash(v.slice(1)); showPop('slash'); } + else { + var m = v.match(/(?:^|\s)@(\S*)$/); + if (m) { renderMention(m[1]); showPop('mention'); } + else if (popState.name === 'slash' || popState.name === 'mention') hidePops(); + } + }); + ta.addEventListener('keydown', function (e) { + var name = popState.name; + if (name && (name === 'slash' || name === 'mention')) { + if (e.key === 'ArrowDown') { e.preventDefault(); setActive(name, popState.active + 1); return; } + if (e.key === 'ArrowUp') { e.preventDefault(); setActive(name, popState.active - 1); return; } + if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); activatePopItem(name); return; } + if (e.key === 'Escape') { e.preventDefault(); hidePops(); return; } + } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } + }); + fit(); + } + + /* ----------------------- composer menus ------------------------------- */ + var SLASH = [ + { cmd: 'compact', desc: '压缩当前会话上下文' }, + { cmd: 'clear', desc: '清空当前会话' }, + { cmd: 'model', desc: '切换当前模型' }, + { cmd: 'thinking', desc: '切换思考等级' }, + { cmd: 'plan', desc: '切换计划模式' }, + { cmd: 'swarm', desc: '切换 Swarm 模式' }, + { cmd: 'help', desc: '查看所有命令' }, + ]; + var FILES = ['README.md', 'src/agent.ts', 'src/style.css', 'packages/core/index.ts', 'apps/kimi-web/src/App.vue', 'docs/zh/quickstart.md']; + var PERMS = [ + { mode: 'manual', label: '手动确认', desc: '每个工具调用都需要确认' }, + { mode: 'auto', label: '自动编辑', desc: '自动编辑文件,其他仍需确认' }, + { mode: 'yolo', label: '自动通过', desc: '自动执行所有工具,不再询问' }, + ]; + var popState = { name: null, items: [], active: 0 }; + + function hidePops() { + popState.name = null; + [].forEach.call(document.querySelectorAll('.pop'), function (p) { p.hidden = true; }); + } + function showPop(name) { + hidePops(); + var el = document.querySelector('[data-pop="' + name + '"]'); + if (el) { el.hidden = false; popState.name = name; popState.active = 0; } + } + function setActive(name, idx) { + var el = document.querySelector('[data-pop="' + name + '"]'); + if (!el) return; + var items = el.querySelectorAll('.pop-item'); + if (!items.length) return; + popState.active = ((idx % items.length) + items.length) % items.length; + [].forEach.call(items, function (it, i) { it.classList.toggle('on', i === popState.active); }); + } + function renderList(name, items, rowFn) { + var el = document.querySelector('[data-pop="' + name + '"]'); + if (!el) return; + el.innerHTML = items.length ? items.map(rowFn).join('') : '
无匹配
'; + popState.items = items; + setActive(name, 0); + } + function renderSlash(filter) { + var q = filter.toLowerCase(); + var items = SLASH.filter(function (it) { return it.cmd.indexOf(q) !== -1; }); + renderList('slash', items, function (it) { + return ''; + }); + } + function renderMention(filter) { + var q = filter.toLowerCase(); + var items = FILES.filter(function (f) { return f.toLowerCase().indexOf(q) !== -1; }); + renderList('mention', items, function (f) { + return ''; + }); + } + function permLabel(mode) { var p = PERMS.find(function (x) { return x.mode === mode; }); return p ? p.label : mode; } + function renderPerm() { + renderList('perm', PERMS, function (p) { + var on = p.mode === S.state.permission; + return ''; + }); + } + function renderModes() { + var el = document.querySelector('[data-pop="modes"]'); + if (!el) return; + function row(key, name, desc, on, disabled) { + return ''; + } + el.innerHTML = + row('plan', '计划模式', '先规划再执行', S.state.planMode, false) + + row('swarm', 'Swarm 模式', '多 Agent 协作', S.state.swarmMode, false) + + row('goal', 'Goal 模式', '由目标驱动(仅展示)', false, true); + } + function activatePopItem(name) { + var el = document.querySelector('[data-pop="' + name + '"]'); + var item = el && el.querySelectorAll('.pop-item')[popState.active]; + if (item) item.click(); + } + function syncComposerToolbar() { + var pill = document.querySelector('[data-act="perm-menu"]'); + if (pill) { pill.className = 'pill perm perm-' + S.state.permission; var lab = pill.querySelector('[data-perm-label]'); if (lab) lab.textContent = permLabel(S.state.permission); } + var tags = document.querySelector('[data-modes-tags]'); + if (tags) { var ts = []; if (S.state.planMode) ts.push('Plan'); if (S.state.swarmMode) ts.push('Swarm'); tags.textContent = ts.join(' · '); } + } + + /* --------------------- sidebar interactions --------------------------- */ + function closeMenu() { + var m = document.querySelector('.menu-float'); + if (m) m.remove(); + document.removeEventListener('mousedown', onMenuDoc, true); + document.removeEventListener('keydown', onMenuKey); + } + function onMenuDoc(e) { var m = document.querySelector('.menu-float'); if (m && !m.contains(e.target)) closeMenu(); } + function onMenuKey(e) { if (e.key === 'Escape') closeMenu(); } + function openMenu(anchor, items) { + closeMenu(); + var m = document.createElement('div'); + m.className = 'menu menu-float'; + m.style.position = 'fixed'; + m.innerHTML = items.map(function (it, i) { + if (it.sep) return '
'; + return ''; + }).join(''); + document.body.appendChild(m); + var r = anchor.getBoundingClientRect(); + var mh = m.offsetHeight, mw = m.offsetWidth; + var top = r.bottom + 4; if (top + mh > innerHeight - 8) top = Math.max(8, r.top - mh - 4); + var left = r.right - mw; if (left < 8) left = 8; + m.style.top = Math.round(top) + 'px'; m.style.left = Math.round(left) + 'px'; + m.addEventListener('click', function (e) { + var b = e.target.closest('.mi'); if (!b) return; + var it = items[Number(b.getAttribute('data-i'))]; closeMenu(); + if (it && it.onClick) it.onClick(); + }); + setTimeout(function () { document.addEventListener('mousedown', onMenuDoc, true); }, 0); + document.addEventListener('keydown', onMenuKey); + } + + function startRename(el, initial, onCommit) { + var input = document.createElement('input'); + input.className = 'rename-input' + (el.classList.contains('ws-name') ? ' ws-name' : ''); + input.value = initial; + el.replaceWith(input); + input.focus(); input.select(); + function finish(commit) { + var v = input.value.trim(); + if (commit && v) onCommit(v); + renderAll(); + } + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); renderAll(); } + }); + input.addEventListener('blur', function () { finish(true); }); + } + + function removeSession(sid) { + var idx = S.sessions.findIndex(function (s) { return s.id === sid; }); + if (idx >= 0) S.sessions.splice(idx, 1); + if (S.state.currentSessionId === sid) S.set({ currentSessionId: null }); else renderAll(); + } + function removeWorkspace(wid) { + var wi = S.workspaces.findIndex(function (w) { return w.id === wid; }); + if (wi >= 0) S.workspaces.splice(wi, 1); + for (var i = S.sessions.length - 1; i >= 0; i--) if (S.sessions[i].ws === wid) S.sessions.splice(i, 1); + if (!S.session(S.state.currentSessionId)) S.set({ currentSessionId: null }); else renderAll(); + } + + function sessMenuItems(sid) { + var s = S.session(sid); if (!s) return []; + return [ + { label: '复制会话 ID', icon: 'i-copy', onClick: function () { toast('已复制 ID', 'success'); } }, + { label: '重命名', onClick: function () { var el = document.querySelector('.sess[data-sid="' + sid + '"] .t'); if (el) startRename(el, s.title, function (v) { s.title = v; }); } }, + { label: 'Fork', icon: 'i-git', onClick: function () { toast('已 Fork(stub)', 'success'); } }, + { sep: true }, + { label: '归档', danger: true, onClick: function () { confirm({ title: '归档会话', message: '确定归档「' + s.title + '」?归档后将从列表移除。', okLabel: '归档', onConfirm: function () { removeSession(sid); toast('已归档', 'success'); } }); } }, + ]; + } + function wsMenuItems(wid) { + var w = S.workspace(wid); if (!w) return []; + return [ + { label: '复制路径', icon: 'i-copy', onClick: function () { toast('已复制路径', 'success'); } }, + { label: '重命名', onClick: function () { var el = document.querySelector('.ws[data-ws="' + wid + '"] .ws-name'); if (el) startRename(el, w.name, function (v) { w.name = v; }); } }, + { sep: true }, + { label: '删除工作区', danger: true, onClick: function () { confirm({ title: '删除工作区', message: '确定删除「' + w.name + '」及其全部会话?', okLabel: '删除', onConfirm: function () { removeWorkspace(wid); toast('已删除', 'success'); } }); } }, + ]; + } + + document.addEventListener('dblclick', function (e) { + var t = e.target.closest('.sess .t'); + if (t) { var row = t.closest('[data-sid]'); var s = row && S.session(row.getAttribute('data-sid')); if (s) startRename(t, s.title, function (v) { s.title = v; }); return; } + var wn = e.target.closest('.ws-name'); + if (wn) { var ws = wn.closest('[data-ws]'); var w = ws && S.workspace(ws.getAttribute('data-ws')); if (w) startRename(wn, w.name, function (v) { w.name = v; }); } + }); + + /* ----------------------------- modals --------------------------------- */ + function openOverlay(name) { var el = document.querySelector('[data-overlay="' + name + '"]'); if (el) el.classList.add('is-open'); } + function closeOverlays() { [].forEach.call(document.querySelectorAll('.overlay.is-open'), function (o) { o.classList.remove('is-open'); }); } + + /* ----------------------------- search (⌘K) --------------------------- */ + function renderSearch(q) { + var list = $('[data-search-list]'); + if (!list) return; + var query = (q || '').trim().toLowerCase(); + var items = S.sessions.filter(function (s) { + if (!query) return true; + return s.title.toLowerCase().indexOf(query) !== -1; + }); + if (!items.length) { list.innerHTML = '
  • 没有匹配的会话
  • '; return; } + list.innerHTML = items.map(function (s) { + var w = S.workspace(s.ws); + return '
  • '; + }).join(''); + } + function openSearch() { + openOverlay('search'); + renderSearch(''); + var inp = $('[data-search-input]'); + if (inp) { inp.value = ''; setTimeout(function () { inp.focus(); }, 0); } + } + + /* ----------------------------- confirm -------------------------------- */ + var confirmCb = null; + function confirm(opts) { + $('[data-confirm-title]').textContent = opts.title || ''; + $('[data-confirm-msg]').textContent = opts.message || ''; + var ok = $('[data-confirm-ok]'); + ok.textContent = opts.okLabel || '确认'; + ok.className = 'btn ' + (opts.danger === false ? 'btn-primary' : 'btn-danger'); + confirmCb = opts.onConfirm || null; + openOverlay('confirm'); + } + + /* ----------------------------- server auth ---------------------------- */ + function connectToken() { + var inp = $('[data-token-input]'); + var v = inp && inp.value.trim(); + if (!v) { toast('请输入 Token', 'error'); return; } + try { localStorage.setItem('kimi2-token', v); } catch (err) {} + location.reload(); + } + + /* ----------------------------- delegated clicks ----------------------- */ + document.addEventListener('click', function (e) { + var act = e.target.closest('[data-act]'); + if (act) { + var a = act.getAttribute('data-act'); + switch (a) { + case 'open-session': + S.set({ currentSessionId: act.getAttribute('data-sid') }); scrollBottom(); closeSwitcher(); return; + case 'toggle-ws': + var w = act.getAttribute('data-ws'); S.state.collapsed[w] = !S.state.collapsed[w]; S.set({}); return; + case 'show-more': + var w2 = act.getAttribute('data-ws'); S.state.expanded[w2] = !S.state.expanded[w2]; S.set({}); return; + case 'add-ws': + openOverlay('aw'); return; + case 'sess-menu': + openMenu(act, sessMenuItems(act.getAttribute('data-sid'))); return; + case 'ws-item-menu': + openMenu(act, wsMenuItems(act.getAttribute('data-ws'))); return; + case 'copy': + toast('已复制到剪贴板', 'success'); return; + case 'sug': + var inp = $('.cc-ta'); if (inp) { inp.value = act.getAttribute('data-text'); inp.focus(); fit(); } return; + case 'approve': + toast('已允许(stub)', 'success'); return; + case 'reject': + toast('已拒绝(stub)', 'error'); return; + case 'qopt': + toast('已选择(stub)', 'success'); return; + } + } + + /* ---- composer menus ---- */ + var cmdBtn = e.target.closest('[data-pop="slash"] .pop-item'); + if (cmdBtn) { + var cmd = cmdBtn.getAttribute('data-cmd'); hidePops(); + if (cmd === 'model') { var b = document.querySelector('[data-open-models]'); if (b) b.click(); } + else { ta.value = ''; fit(); toast('已选择 /' + cmd, 'success'); } + return; + } + var fileBtn = e.target.closest('[data-pop="mention"] .pop-item'); + if (fileBtn) { ta.value = ta.value.replace(/@(\S*)$/, fileBtn.getAttribute('data-file') + ' '); fit(); ta.focus(); hidePops(); return; } + var permBtn = e.target.closest('[data-pop="perm"] .pop-item'); + if (permBtn) { S.set({ permission: permBtn.getAttribute('data-mode') }); hidePops(); return; } + var modeBtn = e.target.closest('[data-pop="modes"] .pop-item'); + if (modeBtn && !modeBtn.getAttribute('data-disabled')) { + var k = modeBtn.getAttribute('data-modekey'); + if (k === 'plan') S.set({ planMode: !S.state.planMode }); + else if (k === 'swarm') S.set({ swarmMode: !S.state.swarmMode }); + renderModes(); return; + } + if (e.target.closest('[data-act="perm-menu"]')) { renderPerm(); showPop('perm'); return; } + if (e.target.closest('[data-act="modes-menu"]')) { renderModes(); showPop('modes'); return; } + if (!e.target.closest('.pop') && !e.target.closest('[data-act="perm-menu"]') && !e.target.closest('[data-act="modes-menu"]')) hidePops(); + + /* new chat */ + if (e.target.closest('.sb-new')) { S.set({ currentSessionId: null }); return; } + /* send / stop */ + if (e.target.closest('.send')) { send(); return; } + /* mobile */ + if (e.target.closest('[data-act="mob-menu"]')) { openSwitcher(); return; } + if (e.target.closest('[data-act="mob-new"]')) { S.set({ currentSessionId: null }); closeSwitcher(); return; } + if (e.target.closest('[data-sheet-close]')) { closeSwitcher(); return; } + if (e.target.classList && e.target.classList.contains('sheet-back')) { closeSwitcher(); return; } + /* search row -> open session */ + var srow = e.target.closest('.search-row'); + if (srow) { S.set({ currentSessionId: srow.getAttribute('data-sid') }); closeOverlays(); scrollBottom(); return; } + /* search box trigger */ + if (e.target.closest('.sb-search')) { openSearch(); return; } + /* confirm cancel / ok */ + if (e.target.closest('[data-confirm-cancel]')) { closeOverlays(); confirmCb = null; return; } + if (e.target.closest('[data-confirm-ok]')) { closeOverlays(); var cb = confirmCb; confirmCb = null; if (cb) cb(); return; } + /* token connect */ + if (e.target.closest('[data-token-connect]')) { connectToken(); return; } + /* settings gear */ + if (e.target.closest('[data-open-settings]')) { openOverlay('settings'); return; } + /* close modal */ + if (e.target.closest('[data-close]')) { closeOverlays(); return; } + /* backdrop */ + if (e.target.classList && e.target.classList.contains('overlay')) { closeOverlays(); return; } + + /* settings segmented (明暗 / 语言) */ + var segBtn = e.target.closest('[data-seg] button'); + if (segBtn) { + var seg = segBtn.parentNode; + [].forEach.call(seg.children, function (b) { b.classList.remove('on'); }); + segBtn.classList.add('on'); + var label = segBtn.textContent.trim(); + if (label === '月之亮面') S.set({ theme: 'light' }); + else if (label === '月之暗面') S.set({ theme: 'dark' }); + else if (label === '跟随系统') S.set({ theme: 'system' }); + else if (label === 'English') S.set({ lang: 'en' }); + else if (label === '中文') S.set({ lang: 'zh' }); + return; + } + /* settings switch */ + var sw = e.target.closest('[data-sw]'); + if (sw) { sw.classList.toggle('on'); return; } + + /* collapsible think / tool */ + var head = e.target.closest('[data-collapsible] > .th-head, [data-collapsible] > .tool-row'); + if (head) { var box = head.parentNode; if (box.classList.contains('think') || box.classList.contains('tool')) box.classList.toggle('open'); return; } + }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeOverlays(); + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openSearch(); } + }); + + /* search input filtering */ + document.addEventListener('input', function (e) { + if (e.target.matches('[data-search-input]')) renderSearch(e.target.value); + if (e.target.matches('[data-fontsize]')) { + var v = Math.max(12, Math.min(20, parseInt(e.target.value, 10) || 14)); + S.set({ fontSize: v }); + } + }); + + /* ---- shared helpers for feature files (prototype/features/*) ---------- */ + window.KP = { + Store: S, esc: esc, icon: icon, t: t, toast: toast, + openOverlay: openOverlay, closeOverlays: closeOverlays, confirm: confirm, + renderAll: renderAll, + }; + + /* URL helpers */ + var params = new URLSearchParams(location.search); + if (params.get('theme')) S.state.theme = params.get('theme'); + S.subscribe(renderAll); + renderAll(); + if (params.get('open')) openOverlay(params.get('open')); +})(); diff --git a/apps/kimi-web2/data.js b/apps/kimi-web2/data.js new file mode 100644 index 0000000000..e653f8f698 --- /dev/null +++ b/apps/kimi-web2/data.js @@ -0,0 +1,106 @@ +/* Kimi Code Web prototype — stub data + a tiny store (no backend). */ +(function () { + 'use strict'; + + /* ----------------------------- stub data ----------------------------- */ + const workspaces = [ + { id: 'w1', name: 'kimi-code-web3', root: '~/code/kimi-code-web3', branch: 'main', add: 397, del: 358 }, + { id: 'w2', name: 'kimi-code-web2', root: '~/code/kimi-code-web2', branch: 'dev', add: 12, del: 4 }, + { id: 'w3', name: 'kimi-code-dev2', root: '~/code/kimi-code-dev2', branch: 'main', add: 0, del: 0 }, + ]; + + // time is minutes-ago; rendered to a relative label. + const sessions = [ + { id: 's1', ws: 'w1', title: '继续完成这个任务,现在的问题是 前端的 UI 不对', ago: 1, busy: true, unread: false, pending: { a: 0, q: 0 } }, + { id: 's2', ws: 'w1', title: '调研一个任务,现在 TUI 里支持了思考等级', ago: 22 * 60, busy: false, unread: false, pending: { a: 1, q: 0 } }, + { id: 's3', ws: 'w1', title: '修复一个问题,kimi-web 里的发送队列', ago: 26 * 60, busy: false, unread: true, pending: { a: 0, q: 1 } }, + { id: 's4', ws: 'w1', title: '我不想为了彩蛋而维护 kimi-web 的动画', ago: 27 * 60, busy: false, unread: false, pending: { a: 0, q: 0 } }, + { id: 's5', ws: 'w2', title: '看一下我们 main 上的最新的仓库', ago: 2 * 24 * 60, busy: false, unread: false, pending: { a: 0, q: 0 } }, + { id: 's6', ws: 'w2', title: 'Fork: 看一下签名的实现', ago: 2 * 24 * 60, busy: false, unread: false, pending: { a: 0, q: 0 } }, + ]; + + const richConvo = [ + { type: 'user', text: '继续完成这个任务,现在的问题是 前端的 UI 不对,帮我捋一下,给出一份从设计系统出发的重做方案。' }, + { type: 'lead', html: '我先停下增量修补,按设计系统从第一性原理重新搭。这一版只关心三件事:信息层级、间距节奏、状态用填充而非边框。下面是我对当前问题的判断和重做计划。' }, + { type: 'think', html: '

    用户说"看不出区别"且"和例子大相径庭"。最可能的根因不是样式没改,而是旧的蓝色强调偏好把中性强调色盖掉了。

    设计上,Kimi 的交互强调是 kimiDark(近黑/近白),蓝色只给品牌与数据。所以移除蓝色强调项,让默认体验回归中性,是第一刀。

    ' }, + { type: 'code', lang: 'style.css · tokens', code: '/* Interaction emphasis = kimiDark, not blue */\n:root {\n --color-accent: rgba(0, 0, 0, 0.9);\n --color-info: #1783ff; /* brand / data only */\n --radius-md: 10px;\n}' }, + { type: 'tool', name: '运行', arg: 'pnpm --filter @moonshot-ai/kimi-web check:style', ok: '通过', open: true, body: 'check-style: 9 finding(s) across 4 rule(s). (baseline mode — not failing)\n✓ built in 8.76s' }, + { type: 'toolGroup', count: 2, state: '已完成', items: [ + { name: '搜索', arg: 'var\\(--color-accent\\)', ok: '53 处' }, + { name: '运行', arg: 'pnpm --filter @moonshot-ai/kimi-web build', ok: '通过' }, + ] }, + { type: 'approval', title: '需要确认 · 运行写入命令', body: 'Agent 想在工作区执行以下命令,这会修改你的文件:', cmd: 'pnpm --filter @moonshot-ai/kimi-web build' }, + { type: 'question', title: '一个问题 · 想确认你的偏好', body: '设置弹窗里,你更希望分段控件的选中态是哪种?', options: [ + { k: 'A', t: '深色胶囊(推荐)', d: '和参考样稿一致,选中更明确。' }, + { k: 'B', t: '浅色浮起', d: '更克制,但选中对比更弱。' }, + ] }, + { type: 'prose', html: '计划分四步:先纠正强调色,再把设置弹窗对齐参考结构,随后用同样的 token 重做侧栏与聊天主表面,最后把这套设计落回真实组件。要不要我继续?' }, + { type: 'status', text: '已完成本轮 · 等待你的下一条输入' }, + { type: 'user', text: '继续,把设置弹窗先按参考对齐。' }, + ]; + + const shortConvo = [ + { type: 'user', text: '现在这个会话还空着,随便说点什么测试一下切换。' }, + { type: 'lead', html: '好的,这是一条简短的助手回复,用来验证会话切换与空态之外的渲染。' }, + { type: 'prose', html: '你可以继续问,我会按 Kimi 的 calm、填充式、hairline 分隔的样式来呈现。' }, + ]; + + const conversations = { s1: richConvo, s2: shortConvo, s3: shortConvo, s4: shortConvo, s5: shortConvo, s6: shortConvo }; + + const models = [ + { id: 'm1', name: 'Coding Model', provider: 'moonshot', starred: true, thinking: 'on' }, + { id: 'm2', name: 'Kimi K2', provider: 'moonshot', starred: true, thinking: 'off' }, + { id: 'm3', name: 'GPT-5', provider: 'openai', starred: false, thinking: 'off' }, + { id: 'm4', name: 'Claude 4.5', provider: 'anthropic', starred: false, thinking: 'off' }, + ]; + + const config = { + defaultModel: 'm1', + defaultPermission: 'yolo', + defaultThinking: true, + defaultPlanMode: false, + mergeSkills: true, + telemetry: true, + serverVersion: '0.1.2', + daemon: 'localhost:58627', + }; + + /* ----------------------------- store --------------------------------- */ + const state = { + currentSessionId: 's1', + theme: 'light', // 'light' | 'dark' | 'system' + fontSize: 14, + lang: 'zh', // 'zh' | 'en' + permission: 'yolo', // 'manual' | 'auto' | 'yolo' + modelId: 'm1', + planMode: false, + swarmMode: false, + rightPanel: null, // null | { kind, data } + collapsed: {}, // workspaceId -> bool + expanded: {}, // workspaceId -> bool (show-more) + authed: true, // server token gate + }; + + const listeners = new Set(); + function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); } + function emit() { listeners.forEach((fn) => fn(state)); } + function set(patch) { Object.assign(state, patch); emit(); } + + function session(id) { return sessions.find((s) => s.id === id); } + function workspace(id) { return workspaces.find((w) => w.id === id); } + function convo(id) { return conversations[id] || []; } + function model(id) { return models.find((m) => m.id === id); } + + function relTime(ago) { + if (ago < 2) return '刚刚'; + if (ago < 60) return ago + 'm'; + if (ago < 24 * 60) return Math.round(ago / 60) + 'h'; + return Math.round(ago / (24 * 60)) + 'd'; + } + + window.Store = { + workspaces, sessions, conversations, models, config, + state, subscribe, set, + session, workspace, convo, model, relTime, + }; +})(); diff --git a/apps/kimi-web2/features/CONVENTIONS.md b/apps/kimi-web2/features/CONVENTIONS.md new file mode 100644 index 0000000000..2b52130cfd --- /dev/null +++ b/apps/kimi-web2/features/CONVENTIONS.md @@ -0,0 +1,48 @@ +# Feature-module conventions for the kimi-web prototype + +You are writing **one self-contained feature module** for the from-scratch kimi-web +prototype at `apps/kimi-web2/`. + +## First, read these to match conventions +- `index.html` — app shell + modal/overlay structure + the `
    ` mount. +- `styles.css` — token vars (`--c-*`, `--r-*`, `--s-*`), components (`.btn`, `.seg`, `.sw`, `.field`, `.menu`, `.modal`, `.overlay`). +- `data.js` — `window.Store` data + API. +- `app.js` — event-delegation pattern, helpers, `window.KP`. + +## Output +Write **only** your feature file(s), never the shared files: +- `prototype/features/.js` (required) +- `prototype/features/.css` (only if your feature needs new rules not already in `styles.css`) + +Do **not** edit `index.html`, `styles.css`, `data.js`, or `app.js`. + +## Helpers (already on `window`) +`window.KP = { Store, esc, icon, t, toast, openOverlay, closeOverlays, confirm, renderAll }` +- `Store` — data + state (see below). +- `esc(s)` — HTML-escape. +- `icon(id, cls?)` — `` (ids come from the sprite in `index.html`; reuse existing ids like `i-close`, `i-search`, `i-check`, `i-star`, `i-shield`, `i-external`, `i-copy`, `i-chevron-down`, `i-dots`, `i-folder`). +- `toast(msg, kind?)` — transient toast. +- `openOverlay(name)` / `closeOverlays()` — open/close an overlay whose root is `[data-overlay="name"]`. +- `confirm({ title, message, okLabel?, danger?, onConfirm })` — generic confirm dialog. +- `renderAll()` — re-render the base shell. + +`Store` shape (read freely; mutate via `Store.set({...})` when appropriate): +- `Store.workspaces` `[{id,name,root,branch,add,del}]` +- `Store.sessions` `[{id,ws,title,ago,busy,unread,pending:{a,q}}]` +- `Store.conversations` `{sessionId: [blocks]}` +- `Store.models` `[{id,name,provider,starred,thinking}]` +- `Store.config` `{defaultModel,defaultPermission,defaultThinking,defaultPlanMode,mergeSkills,telemetry,serverVersion,daemon}` +- `Store.state` `{currentSessionId,theme,fontSize,lang,permission,modelId,planMode,swarmMode,rightPanel,collapsed,expanded,authed}` +- `Store.set(patch)`, `Store.session(id)`, `Store.workspace(id)`, `Store.model(id)`, `Store.relTime(ago)`, `Store.subscribe(fn)` + +## Patterns +- **Overlay/dialog:** inject + `
    ` + into `#feature-mount`. Open with `openOverlay('')`. Backdrop click + Esc already close overlays (handled in `app.js`); add a `[data-close]` button to close. +- **Self-wire:** add your own `document` listeners inside your module (do not touch `app.js`). Use `data-act` attributes + a delegated `document` click listener for your elements. +- **Tokens only:** colors via `var(--c-*)`, radius `var(--r-*)`, spacing `var(--s-*)`, fonts `var(--font-sans)`/`var(--font-mono)`. **No** hardcoded hex / fonts / gradients / glassmorphism. +- **Buttons/controls:** reuse `.btn .btn-primary|.btn-secondary|.btn-danger`, `.seg` (segmented), `.sw` (switch), `.field` (input), `.badge`, `.menu/.mi` — see `styles.css`. +- **Calm, fill-based:** hairline `0.5px var(--c-sep)` separators; state via fill, not borders; near-black emphasis. + +## Entry point +Expose a way to open/init your feature, e.g. `window.KP_open = openFn`, and/or self-wire to a trigger selector (e.g. `document` click on `[data-open-]`). Return a short summary with your entry point(s) and the file(s) you wrote. diff --git a/apps/kimi-web2/features/login.css b/apps/kimi-web2/features/login.css new file mode 100644 index 0000000000..73babf6658 --- /dev/null +++ b/apps/kimi-web2/features/login.css @@ -0,0 +1,53 @@ +/* Feature module: login — only rules not already provided by styles.css. + * Tokens only (var(--c-*) / var(--r-*) / var(--s-*) / var(--font-*)); no hex, gradients or glass. */ + +.login-modal { width: min(440px, 96vw); } +.login-head { + flex: none; display: flex; align-items: center; justify-content: space-between; + height: 56px; padding: 0 18px 0 22px; border-bottom: 0.5px solid var(--c-sep); +} +.login-head h2 { font-size: 16px; font-weight: 500; color: var(--c-text); } +.login-body { padding: 22px; display: flex; flex-direction: column; } + +/* centered states: starting / success / expired */ +.login-state { + display: flex; flex-direction: column; align-items: center; gap: var(--s-3); + padding: var(--s-6) 0; text-align: center; +} +.login-state .spin { width: 22px; height: 22px; } +.login-state-t { font-size: 15px; font-weight: 500; color: var(--c-text); } +.login-state-s { font-size: 13px; color: var(--c-text-3); } +.login-state .btn { margin-top: var(--s-2); } +.login-check, .login-alert { + width: 44px; height: 44px; border-radius: var(--r-full); + display: inline-flex; align-items: center; justify-content: center; +} +.login-check { background: var(--c-green-soft); color: var(--c-green); } +.login-alert { background: var(--c-orange-soft); color: var(--c-orange); } +.login-check svg, .login-alert svg { width: 22px; height: 22px; } + +/* code step */ +.login-lead { margin: 0 0 var(--s-4); font-size: 14px; line-height: 1.6; color: var(--c-text-2); } +.login-auth-btn { width: 100%; } +.login-auth-btn svg { width: 15px; height: 15px; } +.login-or { + display: flex; align-items: center; gap: var(--s-3); + margin: var(--s-4) 0; font-size: 12px; color: var(--c-text-3); +} +.login-or::before, .login-or::after { content: ''; flex: 1; height: 0.5px; background: var(--c-sep); } +.login-fallback { margin: 0 0 var(--s-3); font-size: 13px; color: var(--c-text-3); } +.login-code-row { + display: flex; align-items: center; gap: var(--s-3); + padding: var(--s-2) var(--s-2) var(--s-2) var(--s-4); + background: var(--c-fill-1); border-radius: var(--r-md); +} +.login-code { + flex: 1; text-align: center; + font-family: var(--font-mono); font-size: 22px; font-weight: 500; letter-spacing: .14em; color: var(--c-text); +} +.login-status { + display: flex; align-items: center; gap: var(--s-2); + margin-top: var(--s-4); font-size: 13px; color: var(--c-text-3); +} +.login-status-t { flex: 1; } +.login-countdown { font-family: var(--font-mono); color: var(--c-text-2); } diff --git a/apps/kimi-web2/features/login.js b/apps/kimi-web2/features/login.js new file mode 100644 index 0000000000..a927121f25 --- /dev/null +++ b/apps/kimi-web2/features/login.js @@ -0,0 +1,188 @@ +/* Kimi Code Web prototype — feature module: managed OAuth device-code login (UI only). + * Self-contained: injects its overlay into #feature-mount, self-wires document listeners, + * and exposes window.KP_openLogin. No real OAuth; all transitions are timed stubs. + */ +(function () { + 'use strict'; + + function KP() { return window.KP || {}; } + function ic(id, cls) { + var f = KP().icon; + return f ? f(id, cls) : ''; + } + + var CODE = 'ABCD-1234'; + var START_SECONDS = 598; // 9:58 + + var step = 'starting'; // starting | code | success | expired + var remaining = START_SECONDS; + var authorizing = false; + + var tStart = null; // starting -> code + var tSuccess = null; // authorize -> success + var tClose = null; // success -> close + var tickId = null; // countdown interval + + function clearTimers() { + if (tStart) { clearTimeout(tStart); tStart = null; } + if (tSuccess) { clearTimeout(tSuccess); tSuccess = null; } + if (tClose) { clearTimeout(tClose); tClose = null; } + if (tickId) { clearInterval(tickId); tickId = null; } + } + + function fmt(s) { + s = Math.max(0, s | 0); + var m = Math.floor(s / 60); + var sec = s % 60; + return m + ':' + (sec < 10 ? '0' : '') + sec; + } + + function hasIcon(id) { return !!document.getElementById(id); } + + /* ----------------------------- render --------------------------------- */ + function skeleton() { + return '
    ' + + '' + + '
    '; + } + + function startingHTML() { + return ''; + } + + function codeHTML() { + var ext = hasIcon('i-external') ? 'i-external' : 'i-arrow-up'; + return '' + + '' + + '' + + '' + + '' + + ''; + } + + function successHTML() { + return ''; + } + + function expiredHTML() { + return ''; + } + + function bodyHTML() { + if (step === 'starting') return startingHTML(); + if (step === 'success') return successHTML(); + if (step === 'expired') return expiredHTML(); + return codeHTML(); + } + + function render() { + var body = document.querySelector('[data-login-body]'); + if (body) body.innerHTML = bodyHTML(); + } + + /* --------------------------- transitions ------------------------------ */ + function enterCode() { + tStart = null; + step = 'code'; + render(); + tickId = setInterval(function () { + remaining--; + if (remaining <= 0) { + clearInterval(tickId); tickId = null; + remaining = 0; + step = 'expired'; + render(); + return; + } + var el = document.querySelector('[data-login-countdown]'); + if (el) el.textContent = fmt(remaining); + }, 1000); + } + + function begin() { + clearTimers(); + step = 'starting'; + remaining = START_SECONDS; + authorizing = false; + render(); + tStart = setTimeout(enterCode, 600); + } + + function authorize() { + if (step !== 'code' || authorizing) return; + authorizing = true; + if (tickId) { clearInterval(tickId); tickId = null; } + tSuccess = setTimeout(function () { + tSuccess = null; + step = 'success'; + render(); + tClose = setTimeout(function () { + tClose = null; + authorizing = false; + KP().closeOverlays(); + KP().toast('登录成功(stub)', 'success'); + }, 1000); + }, 1000); + } + + function open() { + begin(); + KP().openOverlay('login'); + } + + /* ----------------------------- mount ---------------------------------- */ + function mount() { + var host = document.getElementById('feature-mount'); + if (!host || host.querySelector('[data-overlay="login"]')) return; + host.insertAdjacentHTML('beforeend', skeleton()); + var overlay = host.querySelector('[data-overlay="login"]'); + if (overlay && window.MutationObserver) { + new MutationObserver(function () { + if (!overlay.classList.contains('is-open')) clearTimers(); + }).observe(overlay, { attributes: true, attributeFilter: ['class'] }); + } + } + + /* --------------------------- self-wire -------------------------------- */ + document.addEventListener('click', function (e) { + if (e.target.closest('[data-open-login]')) { open(); return; } + var act = e.target.closest('[data-act]'); + if (!act) return; + switch (act.getAttribute('data-act')) { + case 'login-authorize': authorize(); return; + case 'login-copy': KP().toast('已复制'); return; + case 'login-retry': begin(); return; + } + }); + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount); + else mount(); + + window.KP_openLogin = open; +})(); diff --git a/apps/kimi-web2/features/modelpicker.css b/apps/kimi-web2/features/modelpicker.css new file mode 100644 index 0000000000..babb33b5b4 --- /dev/null +++ b/apps/kimi-web2/features/modelpicker.css @@ -0,0 +1,59 @@ +/* Model picker feature (prototype/features/modelpicker.js) + Token-only rules for parts not already covered by styles.css. */ + +/* scrollable list area (reuses .search-list metrics) */ +[data-overlay="models"] [data-mp-list] { + margin: 10px 0 0; + padding: 0; + max-height: calc(72vh - 80px); + overflow-y: auto; +} + +/* uppercase group label ("星标" / "MOONSHOT" / "OPENAI" ...) */ +[data-overlay="models"] .mp-group { + padding: 8px 10px 4px; + font-size: 11px; + letter-spacing: .05em; + text-transform: uppercase; + color: var(--c-text-3); + user-select: none; +} + +/* leading check slot (fixed width so names stay aligned) */ +[data-overlay="models"] .mp-lead { + width: 16px; + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--c-text); +} +[data-overlay="models"] .mp-lead svg { width: 15px; height: 15px; } + +/* model name */ +[data-overlay="models"] .mp-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* row (a div styled via .mi) */ +[data-overlay="models"] .mp-row { cursor: pointer; } +[data-overlay="models"] .mp-row:focus-visible { + outline: 2px solid var(--c-brand); + outline-offset: 2px; +} + +/* star toggle: faint when off, near-black emphasis when on */ +[data-overlay="models"] .mp-star { color: var(--c-text-4); } +[data-overlay="models"] .mp-star.on { color: var(--c-text); } + +/* empty result */ +[data-overlay="models"] .mp-empty { + padding: 18px; + text-align: center; + color: var(--c-text-3); + font-size: 13px; +} diff --git a/apps/kimi-web2/features/modelpicker.js b/apps/kimi-web2/features/modelpicker.js new file mode 100644 index 0000000000..b66bb5d861 --- /dev/null +++ b/apps/kimi-web2/features/modelpicker.js @@ -0,0 +1,170 @@ +/* Kimi Code Web prototype — feature module: model picker. + Self-contained. Reads helpers from window.KP (set by app.js) and + injects its overlay into #feature-mount. Wires its own document listeners. */ +(function () { + 'use strict'; + + function kp() { return window.KP || {}; } + function S() { return kp().Store; } + function esc(s) { return kp().esc ? kp().esc(s) : String(s); } + function icon(id, cls) { return kp().icon ? kp().icon(id, cls) : ''; } + + /* i-star is not in the sprite; fall back to i-check per spec. */ + var starGlyph = null; + function starIcon() { + if (starGlyph) return starGlyph; + starGlyph = document.getElementById('i-star') ? 'i-star' : 'i-check'; + return starGlyph; + } + + /* ----------------------------- overlay -------------------------------- */ + function ensureOverlay() { + if (document.querySelector('[data-overlay="models"]')) return; + var mount = document.getElementById('feature-mount'); + if (!mount) return; + var h = + '
    ' + + '' + + '
    '; + mount.insertAdjacentHTML('beforeend', h); + } + + /* ----------------------------- data ----------------------------------- */ + function currentProvider() { + var store = S(); + var m = store.model(store.state.modelId); + return m ? m.provider : null; + } + + function matches(m, q) { + if (!q) return true; + return m.name.toLowerCase().indexOf(q) !== -1 || + m.provider.toLowerCase().indexOf(q) !== -1; + } + + function rowHTML(m) { + var store = S(); + var cur = m.id === store.state.modelId; + var lead = cur ? icon('i-check') : ''; + var badge = m.thinking !== 'off' ? 'thinking' : ''; + var starCls = 'ic-btn mp-star' + (m.starred ? ' on' : ''); + return '
  • ' + + '' + lead + '' + + '' + esc(m.name) + '' + + badge + + '' + + '
  • '; + } + + /* ----------------------------- render --------------------------------- */ + function renderList(q) { + var list = document.querySelector('[data-mp-list]'); + if (!list) return; + var store = S(); + var query = (q || '').trim().toLowerCase(); + var curProv = currentProvider(); + + var models = store.models.filter(function (m) { return matches(m, query); }); + + var starred = models.filter(function (m) { + return m.starred && m.provider !== curProv; + }); + + // providers present among the (filtered) models, sorted + var providers = []; + models.forEach(function (m) { + if (providers.indexOf(m.provider) === -1) providers.push(m.provider); + }); + providers.sort(); + + var h = ''; + if (starred.length) { + h += '
  • 星标
  • '; + starred.forEach(function (m) { h += rowHTML(m); }); + } + providers.forEach(function (p) { + h += '
  • ' + esc(p.toUpperCase()) + '
  • '; + models.forEach(function (m) { if (m.provider === p) h += rowHTML(m); }); + }); + + if (!h) { + h = '
  • 没有匹配的模型
  • '; + } + list.innerHTML = h; + } + + function currentQuery() { + var inp = document.querySelector('[data-mp-search]'); + return inp ? inp.value : ''; + } + + /* ----------------------------- actions -------------------------------- */ + function select(id) { + var store = S(); + var m = store.model(id); + if (!m) return; + store.set({ modelId: id }); + if (kp().closeOverlays) kp().closeOverlays(); + var label = document.querySelector('[data-model-label]'); + if (label) { + label.textContent = m.name + (m.thinking !== 'off' ? ' · thinking' : ''); + } + } + + function toggleStar(id) { + var store = S(); + var m = store.model(id); + if (!m) return; + m.starred = !m.starred; // mutate the item in Store.models + renderList(currentQuery()); // re-render, do not close + } + + function open() { + ensureOverlay(); + if (kp().openOverlay) kp().openOverlay('models'); + renderList(''); + var inp = document.querySelector('[data-mp-search]'); + if (inp) { + inp.value = ''; + setTimeout(function () { inp.focus(); }, 0); + } + } + + /* ----------------------------- wiring --------------------------------- */ + document.addEventListener('click', function (e) { + if (e.target.closest('[data-open-models]')) { open(); return; } + + var star = e.target.closest('[data-mp-star]'); + if (star) { + e.preventDefault(); + toggleStar(star.getAttribute('data-id')); + return; + } + + var row = e.target.closest('[data-mp-row]'); + if (row) { select(row.getAttribute('data-id')); return; } + }); + + document.addEventListener('input', function (e) { + if (e.target.matches('[data-mp-search]')) renderList(e.target.value); + }); + + document.addEventListener('keydown', function (e) { + if (e.key !== 'Enter' && e.key !== ' ') return; + var row = e.target.closest && e.target.closest('[data-mp-row]'); + if (row) { e.preventDefault(); select(row.getAttribute('data-id')); } + }); + + /* ----------------------------- entry ---------------------------------- */ + window.KP_openModels = open; + + // Inject once at load so openOverlay('models') works from anywhere. + if (document.body) ensureOverlay(); + else document.addEventListener('DOMContentLoaded', ensureOverlay); +})(); diff --git a/apps/kimi-web2/features/rightpanel.css b/apps/kimi-web2/features/rightpanel.css new file mode 100644 index 0000000000..bf5950b334 --- /dev/null +++ b/apps/kimi-web2/features/rightpanel.css @@ -0,0 +1,57 @@ +/* Feature module: rightpanel — right-side slide-in detail panel (NOT a masked overlay). + * Tokens only (var(--c-*) / var(--r-*) / var(--s-*) / var(--font-*)); no hex, gradients or glass. */ + +/* ---- shell (spec) -------------------------------------------------------- */ +.rpanel { + position: fixed; right: 0; top: 0; height: 100vh; width: 420px; max-width: 90vw; + background: var(--c-bg); border-left: 0.5px solid var(--c-sep); z-index: 700; + transform: translateX(105%); transition: transform 260ms var(--ease); + display: flex; flex-direction: column; +} +.rpanel.is-open { transform: translateX(0); } + +/* ---- header -------------------------------------------------------------- */ +.rp-head { + flex: none; display: flex; align-items: center; gap: var(--s-2); + height: 48px; padding: 0 var(--s-2) 0 var(--s-4); + border-bottom: 0.5px solid var(--c-sep); +} +.rp-title { + font-size: 14px; font-weight: 500; color: var(--c-text); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.rp-head .seg { margin-left: auto; } +.rp-head .seg button { height: 26px; padding: 0 10px; font-size: 12.5px; } +.rp-head .ic-btn { flex: none; } + +/* ---- body ---------------------------------------------------------------- */ +.rp-body { flex: 1; min-height: 0; overflow-y: auto; padding: var(--s-4); } + +/* 思考 */ +.rp-h { margin: 0 0 var(--s-3); font-size: 15px; font-weight: 500; color: var(--c-text); } +.rp-think { display: flex; flex-direction: column; gap: var(--s-2); } +.rp-think p { margin: 0; font-size: 13px; line-height: 1.65; color: var(--c-text-2); } + +/* shared filename header (Diff / 文件) */ +.rp-file-head { + font-family: var(--font-mono); font-size: 12px; color: var(--c-text-2); + padding: 0 0 var(--s-2); margin-bottom: var(--s-2); + border-bottom: 0.5px solid var(--c-sep); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +/* Diff */ +.rp-diff { font-family: var(--font-mono); font-size: 12.5px; line-height: 1.7; } +.rp-dline { display: flex; gap: var(--s-2); padding: 0 var(--s-2); border-radius: var(--r-sm); white-space: pre; } +.rp-dline .rp-sign { flex: none; width: 1em; text-align: center; color: var(--c-text-3); } +.rp-dline .rp-dt { flex: 1; min-width: 0; color: var(--c-text); } +.rp-dadd { background: var(--c-green-soft); } +.rp-dadd .rp-sign { color: var(--c-green); } +.rp-ddel { background: var(--c-danger-soft); } +.rp-ddel .rp-sign { color: var(--c-danger); } + +/* 文件 (code with faint line-number gutter) */ +.rp-code { font-family: var(--font-mono); font-size: 12.5px; line-height: 1.7; } +.rp-cline { display: flex; gap: var(--s-3); white-space: pre; } +.rp-ln { flex: none; width: 2em; text-align: right; color: var(--c-text-4); user-select: none; } +.rp-cc { flex: 1; min-width: 0; color: var(--c-text); } diff --git a/apps/kimi-web2/features/rightpanel.js b/apps/kimi-web2/features/rightpanel.js new file mode 100644 index 0000000000..69880a3424 --- /dev/null +++ b/apps/kimi-web2/features/rightpanel.js @@ -0,0 +1,198 @@ +/* Kimi Code Web prototype — feature module: right-side slide-in detail panel. + * Self-contained: injects an
    ' + + '' + + '
    ' + + '
    语言
    界面显示语言。
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    显示对话目录
    在右侧显示可点击跳转的对话目录。
    ' + + '
    ' + + '
    ' + + '' + + + '
    ' + + '
    通知
    ' + + '
    ' + + '
    会话完成时通知
    Agent 完成任务后发送系统通知。
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    待回答时通知
    有待回答的提问时发送系统通知。
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    会话完成或待回答时播放提示音
    ' + + '
    ' + + '
    ' + + '
    '; + } + + /* ----------------------------- agent ---------------------------------- */ + function modelSelect(S) { + var groups = {}; + var order = []; + S.models.forEach(function (m) { + if (!groups[m.provider]) { groups[m.provider] = []; order.push(m.provider); } + groups[m.provider].push(m); + }); + var h = ''; + } + + function renderAgent(S) { + var c = S.config; + return panelHead('AGENT', 'Agent', '新会话的默认模型、权限、思考等。') + + '
    ' + + '
    默认值
    ' + + row('默认模型', '新建会话时使用的模型。', modelSelect(S)) + + row('默认权限', '新会话的工具执行权限。', seg('defaultPermission', c.defaultPermission, [ + { v: 'manual', l: '手动' }, { v: 'auto', l: '自动' }, { v: 'yolo', l: 'YOLO' }, + ])) + + row('默认开启思考', '新会话默认启用思考过程。', sw('defaultThinking', !!c.defaultThinking)) + + row('默认计划模式', '新会话默认进入计划模式。', sw('defaultPlanMode', !!c.defaultPlanMode)) + + row('合并所有可用 Skills', '把已启用的 Skills 注入每次会话。', sw('mergeSkills', !!c.mergeSkills)) + + '
    '; + } + + /* ----------------------------- account -------------------------------- */ + function renderAccount() { + return panelHead('ACCOUNT', '账户', '登录状态与当前会话。') + + '
    ' + + '
    当前账户
    ' + + row('托管模型', '由 Kimi Code 托管的默认模型来源。', 'managed:kimi-code') + + '
    ' + + '
    会话
    重新打开引导,或退出当前账户。
    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + '
    '; + } + + /* ----------------------------- advanced ------------------------------- */ + function renderAdvanced(S) { + var c = S.config; + return panelHead('ADVANCED', '高级', '诊断、数据与隐私。') + + '
    ' + + '
    服务
    ' + + row('服务地址', '本地 daemon 监听地址。', '' + KP().esc(c.daemon) + '') + + row('服务端版本', '当前连接的服务版本。', '' + KP().esc(c.serverVersion) + '') + + '
    ' + + '
    ' + + '
    数据
    ' + + row('使用数据改进产品', '匿名收集使用数据,帮助我们改进产品。', sw('telemetry', !!c.telemetry)) + + row('导出故障排查日志', '打包诊断信息用于排查问题。', '') + + '
    '; + } + + /* ----------------------------- render --------------------------------- */ + function render() { + var api = KP(); + if (!api) return; + var S = api.Store; + var panel = document.querySelector('.st-panel'); + if (!panel) return; + var scroll = panel.scrollTop; + var html; + switch (tab) { + case 'agent': html = renderAgent(S); break; + case 'account': html = renderAccount(S); break; + case 'advanced': html = renderAdvanced(S); break; + default: html = renderGeneral(); break; + } + panel.innerHTML = html; + panel.scrollTop = scroll; + + // sync nav active state (we do not re-render the nav itself) + var items = document.querySelectorAll('.st-nav .nav-item[data-tab]'); + [].forEach.call(items, function (it) { + it.classList.toggle('on', it.getAttribute('data-tab') === tab); + }); + } + + /* ----------------------------- wiring --------------------------------- */ + document.addEventListener('click', function (e) { + // open trigger → render after app.js opens the overlay + if (e.target.closest('[data-open-settings]')) { setTimeout(render, 0); return; } + + // tab switching + var nav = e.target.closest('.nav-item[data-tab]'); + if (nav) { tab = nav.getAttribute('data-tab') || 'general'; render(); return; } + + // agent / advanced segmented + var segBtn = e.target.closest('[data-st-seg] button[data-st-val]'); + if (segBtn) { + var key = segBtn.parentNode.getAttribute('data-st-seg'); + var api2 = KP(); if (api2) { api2.Store.config[key] = segBtn.getAttribute('data-st-val'); render(); } + return; + } + + // agent / advanced switch (boolean config keys) + var swEl = e.target.closest('[data-st-sw]'); + if (swEl) { + var k = swEl.getAttribute('data-st-sw'); + var api3 = KP(); if (api3) { api3.Store.config[k] = !api3.Store.config[k]; render(); } + return; + } + + // account / advanced actions + var act = e.target.closest('[data-st-act]'); + if (act) { + var a = act.getAttribute('data-st-act'); + var api4 = KP(); if (!api4) return; + if (a === 'onboarding') { api4.toast('已重新打开新手引导(stub)'); return; } + if (a === 'export-logs') { api4.toast('已导出(stub)'); return; } + if (a === 'logout') { + api4.confirm({ + title: '退出登录', + message: '确定要退出当前账户吗?', + onConfirm: function () { api4.toast('已退出(stub)'); }, + }); + return; + } + } + }); + + // model +
    +
    + + + +
    +
    + + + 89k/1049k + + + +
    +
    + + + + + + + + + + + +
    + +
    + + +
    + +
    + + + + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + + + + + + + + + + diff --git a/apps/kimi-web2/live.js b/apps/kimi-web2/live.js new file mode 100644 index 0000000000..36ee75bf98 --- /dev/null +++ b/apps/kimi-web2/live.js @@ -0,0 +1,450 @@ +/* Kimi Code Web prototype — live bridge to the REAL kimi-code server. + * + * Load order: index.html loads this AFTER app.js — data.js gives Store, app.js + * does the initial stub render, then this file (when a token exists) replaces + * the stub data with real workspaces/sessions and streams live events in. + * Without a token it does nothing and the app stays fully offline/stub. + * + * ── Real protocol (extracted from apps/kimi-web/src/api/*, verified via curl) ── + * Auth: `Authorization: Bearer ` on every REST call. Envelope on every + * response: { code, msg, data, request_id } — code 0 = success, data = payload. + * + * GET /api/v1/workspaces + * → { items: [{ id, root, name, is_git_repo, branch, last_opened_at, + * session_count }], has_more } + * GET /api/v1/sessions?page_size=N + * → { items: [{ id, workspace_id, title, created_at, updated_at, + * status: idle|running|awaiting_approval|awaiting_question|aborted, + * archived, last_prompt, metadata:{cwd}, ... }], has_more } + * (ordered most-recently-updated first) + * GET /api/v1/sessions/{sid}/messages?page_size=N + * → { items: [{ id, session_id, role: user|assistant|tool|system, + * content: [ {type:'text',text} | {type:'thinking',thinking} + * | {type:'tool_use',tool_call_id,tool_name,input} + * | {type:'tool_result',tool_call_id,output,is_error} + * | {type:'image'|'video'|'file', ...} ], + * created_at }], has_more } + * (NEWEST FIRST — must reverse for chronological rendering) + * POST /api/v1/sessions/{sid}/prompts body { content: [{type:'text',text}] } + * → { prompt_id, user_message_id, status: 'running'|'queued' } + * + * WS ws(s)://host/api/v1/ws?client_id= + * Browser WS cannot set headers → bearer rides in the subprotocol: + * `Sec-WebSocket-Protocol: kimi-code.bearer.`. + * S→C: server_hello first; then ping (reply {type:'pong',payload:{nonce}}), + * ack, resync_required, and per-session frames {type, seq, session_id, + * timestamp, payload} — a mix of projected `event.*` frames and RAW + * agent-core frames (assistant.delta, thinking.delta, tool.call.started, + * tool.result, turn.ended, prompt.completed, session.meta.updated, …). + * C→S after server_hello: {type:'client_hello',id,payload:{client_id, + * subscriptions:[sid],cursors?}} and {type:'subscribe',id,payload: + * {session_ids:[sid]}}. Subscribing WITHOUT cursors = live-only (no + * journal replay) — packages/server/src/ws/connection.ts syncSessions(). + * + * ── CORS finding ── + * packages/server/src/middleware/origin.ts: cross-origin requests only get + * `Access-Control-Allow-*` when the full origin is whitelisted via the + * `KIMI_CODE_CORS_ORIGINS` env (no `*` wildcard). Verified against the live + * server: a GET with `Origin: http://localhost:8101` returned 200 but NO + * Access-Control-Allow-Origin header → the browser would block it. Therefore: + * by default we use the page's own origin (works behind a proxy or when the + * server serves this app); an explicit `?server=` URL is still attempted + * directly (requires the whitelist, or a non-browser context). The WS upgrade + * only rejects a PRESENT-but-disallowed Origin. + */ +(function () { + 'use strict'; + var S = window.Store; + + /* ------------------------- config: server + token --------------------- */ + var params = new URLSearchParams(location.search); + var qsServer = params.get('server'); + var qsToken = params.get('token'); + if (qsServer) { try { localStorage.setItem('kimi2-server', qsServer); } catch (e) {} } + if (qsToken) { try { localStorage.setItem('kimi2-token', qsToken); } catch (e) {} } + + var token = qsToken || safeGet('kimi2-token'); + var httpPage = location.protocol.indexOf('http') === 0; + // file:// without a token stays pure stub; an http(s) page always tries the + // server — the same origin may be unprotected, and a 401 opens the token + // dialog (wired in app.js) so the server-hosted build works out of the box. + if (!token && !httpPage) return; + + // Prefer explicit ?server=; else same-origin (proxy-friendly, no CORS need); + // else the default local server (e.g. when opened via file://). + var origin = qsServer || safeGet('kimi2-server') + || (location.protocol.indexOf('http') === 0 ? location.origin : 'http://127.0.0.1:58627'); + origin = origin.replace(/\/+$/, ''); + + function safeGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } } + function api(path) { return origin + '/api/v1' + path; } + function toast(msg, kind) { if (window.KP && window.KP.toast) window.KP.toast(msg, kind); } + + /* ------------------------------ REST ---------------------------------- */ + function authHeaders(extra) { + var h = extra || {}; + if (token) h.Authorization = 'Bearer ' + token; + return h; + } + function get(path) { + return fetch(api(path), { headers: authHeaders() }).then(unwrap); + } + function post(path, body) { + return fetch(api(path), { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(body || {}), + }).then(unwrap); + } + function unwrap(res) { + if (res.status === 401 || res.status === 403) { + var e = new Error('未授权(需要服务器 Token)'); + e.auth = true; + throw e; + } + return res.json().then(function (env) { + if (env.code !== 0) throw new Error(env.msg || ('服务端错误 ' + env.code)); + return env.data; + }); + } + + /* --------------------------- mapping helpers -------------------------- */ + function esc(s) { + return String(s).replace(/&/g, '&').replace(//g, '>'); + } + function minutesAgo(iso) { + var t = Date.parse(iso); + return isNaN(t) ? 0 : Math.max(0, Math.round((Date.now() - t) / 60000)); + } + function textToHtml(s) { + return esc(s).replace(/\n/g, '
    '); + } + // One-line summary of a tool's input, like the TUI arg column. + function argSummary(input) { + if (input == null) return ''; + if (typeof input === 'string') return input.slice(0, 120); + var keys = ['command', 'path', 'pattern', 'file_path', 'url', 'query', 'description']; + for (var i = 0; i < keys.length; i++) { + if (typeof input[keys[i]] === 'string') return input[keys[i]].slice(0, 120); + } + try { return JSON.stringify(input).slice(0, 120); } catch (e) { return ''; } + } + function outputText(output) { + if (output == null) return ''; + if (typeof output === 'string') return output; + if (typeof output.text === 'string') return output.text; + try { return JSON.stringify(output, null, 2); } catch (e) { return String(output); } + } + + function mapSession(w) { + return { + id: w.id, + ws: w.workspace_id || '', + title: w.title || w.last_prompt || '(无标题)', + ago: minutesAgo(w.updated_at || w.created_at), + busy: w.status === 'running', + unread: false, + pending: { + a: w.status === 'awaiting_approval' ? 1 : 0, + q: w.status === 'awaiting_question' ? 1 : 0, + }, + }; + } + + /* wire messages (chronological) → Store block list */ + function mapMessages(items) { + var blocks = []; + var toolById = {}; // tool_call_id → tool block + items.forEach(function (m) { + (m.content || []).forEach(function (c) { + try { mapContent(m.role, c, blocks, toolById); } + catch (e) { /* tolerant: skip malformed content */ } + }); + }); + return blocks; + } + function mapContent(role, c, blocks, toolById) { + switch (c.type) { + case 'text': + if (!c.text || !c.text.trim()) return; + if (role === 'user') { + // Skip injected wrapper messages (, , …) + if (/^<[a-z_-]+[ >]/.test(c.text.trim())) return; + blocks.push({ type: 'user', text: c.text }); + } else { + blocks.push({ type: 'prose', html: textToHtml(c.text) }); + } + return; + case 'thinking': + if (c.thinking && c.thinking.trim()) blocks.push({ type: 'think', html: textToHtml(c.thinking) }); + return; + case 'tool_use': { + var tb = { type: 'tool', name: c.tool_name || '工具', arg: argSummary(c.input), ok: '' }; + toolById[c.tool_call_id] = tb; + blocks.push(tb); + return; + } + case 'tool_result': { + var t = toolById[c.tool_call_id]; + var body = outputText(c.output).slice(0, 2000); + if (t) { t.ok = c.is_error ? '出错' : '完成'; t.body = body; } + return; + } + case 'image': blocks.push({ type: 'status', text: '[图片]' }); return; + case 'video': blocks.push({ type: 'status', text: '[视频]' }); return; + case 'file': blocks.push({ type: 'status', text: '[文件] ' + (c.name || '') }); return; + default: return; // unknown content kinds are skipped + } + } + + /* --------------------------- throttled render ------------------------- */ + var renderTimer = null; + function scheduleRender() { + if (renderTimer) return; + renderTimer = setTimeout(function () { + renderTimer = null; + S.set({}); // triggers app.js renderAll via subscriber + }, 100); // ≤10 renders/s + } + + /* --------------------------- initial load ----------------------------- */ + var loadedHistory = {}; // sid → true once messages were fetched + var live = { connected: false, send: sendPrompt }; + window.KimiLive = live; + + Promise.all([get('/workspaces'), get('/sessions?page_size=60')]) + .then(function (rs) { + var wss = (rs[0].items || []).map(function (w) { + return { id: w.id, name: w.name, root: w.root, branch: w.branch || '', add: 0, del: 0 }; + }); + var sess = (rs[1].items || []).map(mapSession); + // Sessions referencing an unknown workspace get a derived placeholder. + var known = {}; + wss.forEach(function (w) { known[w.id] = true; }); + sess.forEach(function (s) { + if (!known[s.ws]) { + known[s.ws] = true; + wss.push({ id: s.ws, name: s.ws || '其他', root: '', branch: '', add: 0, del: 0 }); + } + }); + // Only keep workspaces that actually have visible sessions, most recent first. + wss.sort(function (a, b) { + function first(w) { var i = sess.findIndex(function (s) { return s.ws === w.id; }); return i < 0 ? 1e9 : i; } + return first(a) - first(b); + }); + S.workspaces.length = 0; Array.prototype.push.apply(S.workspaces, wss); + S.sessions.length = 0; Array.prototype.push.apply(S.sessions, sess); + Object.keys(S.conversations).forEach(function (k) { delete S.conversations[k]; }); + live.connected = true; + connectWs(); + S.set({ currentSessionId: sess.length ? sess[0].id : null, authed: true }); + toast('已连接服务器 ' + origin.replace(/^https?:\/\//, ''), 'success'); + }) + .catch(function (err) { + if (err && err.auth) { + // Protected server, no/invalid token → ask for one (app.js persists it + // to localStorage and reloads). + console.warn('[live] 服务器需要 Token'); + if (window.KP && window.KP.openOverlay) window.KP.openOverlay('serverauth'); + return; + } + console.warn('[live] 连接服务器失败,保持离线模式:', err); + toast('连接服务器失败,使用离线数据', 'error'); + }); + + /* ----------------------- lazy history on session open ----------------- */ + var lastSid = null; + S.subscribe(function (state) { + var sid = state.currentSessionId; + if (sid === lastSid) return; + lastSid = sid; + if (!sid || loadedHistory[sid] || !live.connected) return; + if (!S.session(sid)) return; // locally-created stub session + loadedHistory[sid] = true; + subscribeWs(sid); + get('/sessions/' + encodeURIComponent(sid) + '/messages?page_size=100') + .then(function (page) { + var items = (page.items || []).slice().reverse(); // newest-first → chronological + S.conversations[sid] = mapMessages(items); + scheduleRender(); + }) + .catch(function (err) { + S.conversations[sid] = [{ type: 'status', text: '加载历史失败:' + err.message }]; + loadedHistory[sid] = false; + scheduleRender(); + }); + }); + + /* ------------------------------ send ---------------------------------- */ + // Returns true when the prompt was handed to the server (app.js then skips + // its stub reply); false for sessions the server does not know about. + function sendPrompt(sid, text) { + if (!live.connected || !/^session_/.test(String(sid))) return false; + subscribeWs(sid); + post('/sessions/' + encodeURIComponent(sid) + '/prompts', { + content: [{ type: 'text', text: text }], + }).catch(function (err) { + var s = S.session(sid); + if (s) s.busy = false; + var convo = S.conversations[sid] || (S.conversations[sid] = []); + convo.push({ type: 'status', text: '发送失败:' + err.message }); + scheduleRender(); + toast('发送失败:' + err.message, 'error'); + }); + return true; + } + + /* ------------------------------ WebSocket ------------------------------ */ + var ws = null; + var wsReady = false; + var wsMsgSeq = 0; + var subscribed = {}; // sid → true + var clientId = 'web2_' + Math.random().toString(36).slice(2, 10); + // Per-session streaming state: the blocks currently being accumulated. + var streams = {}; // sid → { prose, think, tools: {toolCallId: block} } + + function wsSend(msg) { + if (ws && ws.readyState === WebSocket.OPEN) { + try { ws.send(JSON.stringify(msg)); } catch (e) {} + } + } + function subscribeWs(sid) { + if (subscribed[sid]) return; + subscribed[sid] = true; + // No cursors → live-only subscription, no journal replay. + if (wsReady) wsSend({ type: 'subscribe', id: 'c_' + (++wsMsgSeq), payload: { session_ids: [sid] } }); + } + function connectWs() { + var url = origin.replace(/^http/, 'ws') + '/api/v1/ws?client_id=' + encodeURIComponent(clientId); + try { ws = new WebSocket(url, token ? ['kimi-code.bearer.' + token] : undefined); } + catch (e) { console.warn('[live] WS 创建失败:', e); return; } + ws.onmessage = function (ev) { + var f; + try { f = JSON.parse(ev.data); } catch (e) { return; } + handleFrame(f); + }; + ws.onclose = function () { + wsReady = false; + setTimeout(function () { if (live.connected) connectWs(); }, 3000); + }; + } + + function handleFrame(f) { + if (f.type === 'server_hello') { + wsReady = true; + wsMsgSeq = 0; + wsSend({ + type: 'client_hello', + id: 'c_' + (++wsMsgSeq), + payload: { client_id: clientId, subscriptions: Object.keys(subscribed) }, + }); + return; + } + if (f.type === 'ping') { wsSend({ type: 'pong', payload: { nonce: f.payload && f.payload.nonce } }); return; } + if (f.type === 'ack' || f.type === 'resync_required' || f.type === 'error') return; + if (typeof f.session_id !== 'string') return; + // Both raw agent-core frames and projected `event.*` frames arrive here; + // strip the prefix and switch on the base name. + handleEvent(f.type.replace(/^event\./, ''), f.session_id, f.payload || {}); + } + + function stream(sid) { + return streams[sid] || (streams[sid] = { prose: null, think: null, tools: {} }); + } + function convo(sid) { + return S.conversations[sid] || (S.conversations[sid] = []); + } + function setBusy(sid, busy) { + var s = S.session(sid); + if (s) { s.busy = busy; if (!busy) s.ago = 0; } + } + + function handleEvent(type, sid, p) { + var isCurrent = sid === S.state.currentSessionId && loadedHistory[sid]; + var st = stream(sid); + switch (type) { + case 'assistant.delta': { + if (!isCurrent) return; + // raw: {delta: string} · projected: {delta: {text?, thinking?}} + var d = typeof p.delta === 'string' ? p.delta : (p.delta && p.delta.text) || ''; + var th = typeof p.delta === 'object' && p.delta ? p.delta.thinking : ''; + if (th) appendThink(sid, st, th); + if (!d) return; + if (!st.prose) { st.prose = { type: 'prose', html: '', _raw: '' }; convo(sid).push(st.prose); } + st.prose._raw += d; + st.prose.html = textToHtml(st.prose._raw); + scheduleRender(); + return; + } + case 'thinking.delta': + if (isCurrent && typeof p.delta === 'string') appendThink(sid, st, p.delta); + return; + case 'tool.use': + case 'tool.call.started': + case 'assistant.tool_use_started': + case 'tool.started': { + setBusy(sid, true); + if (!isCurrent) return; + var id = p.toolCallId || p.tool_call_id; + var name = p.name || p.toolName || p.tool_name || '工具'; + if (!id || st.tools[id]) return; + var tb = { type: 'tool', name: name, arg: argSummary(p.args || p.input), ok: '' }; + st.tools[id] = tb; + convo(sid).push(tb); + st.prose = null; st.think = null; // next text starts a fresh block + scheduleRender(); + return; + } + case 'tool.result': + case 'tool.completed': { + if (!isCurrent) return; + var rid = p.toolCallId || p.tool_call_id; + var t = rid && st.tools[rid]; + if (t) { + t.ok = (p.isError || p.is_error) ? '出错' : '完成'; + t.body = outputText(p.output).slice(0, 2000); + scheduleRender(); + } + return; + } + case 'turn.started': + case 'prompt.submitted': + setBusy(sid, true); + st.prose = null; st.think = null; + scheduleRender(); + return; + case 'turn.ended': + case 'prompt.completed': + setBusy(sid, false); + st.prose = null; st.think = null; st.tools = {}; + if (isCurrent) convo(sid).push({ type: 'status', text: '已完成本轮 · 等待你的下一条输入' }); + scheduleRender(); + return; + case 'session.status_changed': { + setBusy(sid, p.status === 'running'); + var s = S.session(sid); + if (s) { + s.pending.a = p.status === 'awaiting_approval' ? 1 : 0; + s.pending.q = p.status === 'awaiting_question' ? 1 : 0; + } + scheduleRender(); + return; + } + case 'session.meta.updated': { + var ss = S.session(sid); + if (ss && p.patch && typeof p.patch.title === 'string') ss.title = p.patch.title; + if (ss) ss.ago = 0; + scheduleRender(); + return; + } + default: + return; // unknown event kinds are ignored + } + } + function appendThink(sid, st, delta) { + if (!st.think) { st.think = { type: 'think', html: '', _raw: '' }; convo(sid).push(st.think); } + st.think._raw += delta; + st.think.html = textToHtml(st.think._raw); + scheduleRender(); + } +})(); diff --git a/apps/kimi-web2/serve.mjs b/apps/kimi-web2/serve.mjs new file mode 100644 index 0000000000..a2533d67bf --- /dev/null +++ b/apps/kimi-web2/serve.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +// apps/kimi-web2/serve.mjs — zero-dependency dev server for the kimi-web2 +// design prototype: serves the static files AND proxies /api/v1 (HTTP + WS) +// to a running kimi-code server, so the app talks to it same-origin (no CORS). +// +// node serve.mjs [--port 8101] [--target http://127.0.0.1:58627] +// +// Then open: http://localhost:8101/?token= +// Without a token the app runs in offline stub mode (design-only). + +import http from 'node:http'; +import net from 'node:net'; +import { readFile } from 'node:fs/promises'; +import { extname, join, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = fileURLToPath(new URL('.', import.meta.url)); +const args = process.argv.slice(2); +function arg(name, dflt) { + const i = args.indexOf('--' + name); + return i >= 0 && args[i + 1] ? args[i + 1] : dflt; +} +const PORT = Number(arg('port', '8101')); +const TARGET = new URL(arg('target', 'http://127.0.0.1:58627')); + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.md': 'text/plain; charset=utf-8', +}; + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + + // Proxy the API to the kimi server (same-origin for the browser). + if (url.pathname.startsWith('/api/')) { + const opts = { + hostname: TARGET.hostname, + port: TARGET.port, + path: req.url, + method: req.method, + headers: { ...req.headers, host: TARGET.host }, + }; + const up = http.request(opts, (upRes) => { + res.writeHead(upRes.statusCode ?? 502, upRes.headers); + upRes.pipe(res); + }); + up.on('error', () => { + res.writeHead(502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ code: 502, msg: 'kimi server unreachable: ' + TARGET.href })); + }); + req.pipe(up); + return; + } + + // Static files. + let file = url.pathname === '/' ? '/index.html' : url.pathname; + file = normalize(file).replace(/^(\.\.[/\\])+/, ''); + try { + const body = await readFile(join(ROOT, file)); + res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' }); + res.end(body); + } catch { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found'); + } +}); + +// WebSocket proxy: forward the Upgrade handshake and pipe both sockets. +server.on('upgrade', (req, clientSocket, head) => { + const upSocket = net.connect(Number(TARGET.port), TARGET.hostname, () => { + const lines = [`${req.method} ${req.url} HTTP/1.1`]; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const k = req.rawHeaders[i]; + const v = k.toLowerCase() === 'host' ? TARGET.host : req.rawHeaders[i + 1]; + lines.push(`${k}: ${v}`); + } + upSocket.write(lines.join('\r\n') + '\r\n\r\n'); + if (head?.length) upSocket.write(head); + clientSocket.pipe(upSocket); + upSocket.pipe(clientSocket); + }); + const kill = () => { + clientSocket.destroy(); + upSocket.destroy(); + }; + upSocket.on('error', kill); + clientSocket.on('error', kill); +}); + +server.listen(PORT, () => { + console.log(`kimi-web2 → http://localhost:${PORT}/ (API proxied to ${TARGET.href})`); + console.log(`open with a token: http://localhost:${PORT}/?token=`); +}); diff --git a/apps/kimi-web2/styles.css b/apps/kimi-web2/styles.css new file mode 100644 index 0000000000..4b3e4a2624 --- /dev/null +++ b/apps/kimi-web2/styles.css @@ -0,0 +1,682 @@ +/* ============================================================ + Kimi Code Web — from-scratch high-fidelity prototype. + Built purely from the Kimi Design System (tokens.json + Web rules). + Not wired to a backend; content is stub data for design evaluation. + ============================================================ */ + +/* ---- Tokens (from tokens.json) ------------------------------------------- */ +:root { + --font-sans: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", + "Source Han Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "Geist Mono", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + + --c-brand: rgba(0, 0, 0, 0.9); + --c-brand-hover: rgba(37, 37, 37, 1); + + --c-bg: #ffffff; + --c-bg-2: #f5f5f5; + --c-bg-3: #ffffff; + --c-ground: #f9fbfc; + + --c-text: rgba(0, 0, 0, 0.9); + --c-text-2: rgba(0, 0, 0, 0.6); + --c-text-3: rgba(0, 0, 0, 0.45); + --c-text-4: rgba(0, 0, 0, 0.3); + + --c-fill-1: rgba(0, 0, 0, 0.03); + --c-fill-2: rgba(0, 0, 0, 0.05); + --c-fill-3: rgba(0, 0, 0, 0.15); + --c-fill-4: rgba(0, 0, 0, 0.25); + + --c-sep: rgba(0, 0, 0, 0.13); + --c-mask: rgba(0, 0, 0, 0.4); + --c-white: #ffffff; + --c-on-brand: #ffffff; + + --c-danger: #ff3849; + --c-danger-soft: rgba(255, 77, 77, 0.1); + --c-green: #16c456; + --c-green-soft: rgba(22, 196, 86, 0.1); + --c-blue: #1783ff; + --c-blue-soft: rgba(23, 131, 255, 0.1); + --c-orange: #ff9500; + --c-orange-soft: rgba(255, 149, 0, 0.1); + --c-purple: #985ffb; + + --r-sm: 8px; --r-md: 10px; --r-lg: 12px; --r-xl: 16px; --r-2xl: 20px; --r-full: 999px; + --s-1: 4px; --s-2: 8px; --s-3: 12px; --s-4: 16px; --s-5: 20px; --s-6: 24px; --s-8: 32px; + + --sh-sm: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.06); + --sh-md: 0 4px 16px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.05); + --sh-xl: 0 24px 64px rgba(0, 0, 0, 0.18), 0 8px 20px rgba(0, 0, 0, 0.1); + + --z-dd: 600; --z-modal-bg: 800; --z-modal: 810; --z-toast: 1000; + --ease: cubic-bezier(0.23, 1, 0.32, 1); +} + +[data-theme="dark"] { + --c-brand: rgba(255, 255, 255, 0.84); + --c-brand-hover: rgba(255, 255, 255, 0.92); + --c-bg: #121212; --c-bg-2: #1f1f1f; --c-bg-3: #292929; --c-ground: #161717; + --c-text: rgba(255, 255, 255, 0.84); + --c-text-2: rgba(255, 255, 255, 0.56); + --c-text-3: rgba(255, 255, 255, 0.42); + --c-text-4: rgba(255, 255, 255, 0.26); + --c-fill-1: rgba(255, 255, 255, 0.05); + --c-fill-2: rgba(255, 255, 255, 0.1); + --c-fill-3: rgba(255, 255, 255, 0.18); + --c-fill-4: rgba(255, 255, 255, 0.25); + --c-sep: rgba(255, 255, 255, 0.12); + --c-mask: rgba(0, 0, 0, 0.6); + --c-on-brand: rgba(0, 0, 0, 0.9); + --c-danger: #ff4756; + --c-blue: #1a88ff; + --c-orange: #ff9f0a; + --c-purple: #a16bff; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --c-brand: rgba(255, 255, 255, 0.84); + --c-brand-hover: rgba(255, 255, 255, 0.92); + --c-bg: #121212; --c-bg-2: #1f1f1f; --c-bg-3: #292929; --c-ground: #161717; + --c-text: rgba(255, 255, 255, 0.84); + --c-text-2: rgba(255, 255, 255, 0.56); + --c-text-3: rgba(255, 255, 255, 0.42); + --c-text-4: rgba(255, 255, 255, 0.26); + --c-fill-1: rgba(255, 255, 255, 0.05); + --c-fill-2: rgba(255, 255, 255, 0.1); + --c-fill-3: rgba(255, 255, 255, 0.18); + --c-sep: rgba(255, 255, 255, 0.12); + --c-mask: rgba(0, 0, 0, 0.6); + --c-on-brand: rgba(0, 0, 0, 0.9); + --c-danger: #ff4756; --c-blue: #1a88ff; --c-orange: #ff9f0a; --c-purple: #a16bff; + } +} + +/* ---- Reset / base -------------------------------------------------------- */ +*, *::before, *::after { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { + font-family: var(--font-sans); + color: var(--c-text); + background: var(--c-bg); + font-size: 14px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + overflow: hidden; +} +button, input, textarea, select { font: inherit; color: inherit; } +button { cursor: pointer; background: none; border: none; padding: 0; } +a { color: inherit; text-decoration: none; } +ul, ol { list-style: none; margin: 0; padding: 0; } +svg { display: block; } +[hidden] { display: none !important; } + +* { scrollbar-width: thin; scrollbar-color: rgba(128,128,128,.3) transparent; } +*::-webkit-scrollbar { width: 6px; height: 6px; } +*::-webkit-scrollbar-thumb { background: rgba(128,128,128,.3); border-radius: 999px; } +*::-webkit-scrollbar-thumb:hover { background: rgba(128,128,128,.5); } +*::-webkit-scrollbar-track { background: transparent; } + +.t2-em { font-size: 16px; font-weight: 500; line-height: 24px; } +.b2 { font-size: 14px; line-height: 20px; } +.c1 { font-size: 12px; line-height: 18px; } +.mono { font-family: var(--font-mono); } +.muted { color: var(--c-text-2); } +.faint { color: var(--c-text-3); } + +.ic { width: 18px; height: 18px; flex: none; stroke: currentColor; } +.ic-sm { width: 16px; height: 16px; } +.ic-lg { width: 20px; height: 20px; } + +/* ---- App shell ----------------------------------------------------------- */ +.app { + display: grid; + grid-template-columns: 268px 1fr; + grid-template-rows: minmax(0, 1fr); + height: 100vh; + background: var(--c-bg); +} +.side { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--c-bg); + border-right: 0.5px solid var(--c-sep); +} +.main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--c-bg); +} + +/* ---- Sidebar ------------------------------------------------------------- */ +.sb-head { + display: flex; align-items: center; gap: 8px; + padding: 12px 12px 8px; +} +.brand { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; } +.logo { + width: 26px; height: 26px; flex: none; border-radius: var(--r-full); + background: var(--c-brand); color: var(--c-on-brand); + display: inline-flex; align-items: center; justify-content: center; + font-size: 12px; font-weight: 500; +} +.brand-name { font-size: 14px; font-weight: 500; color: var(--c-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.brand-endpoint { font-family: var(--font-mono); font-size: 12px; color: var(--c-text-3); } +.ic-btn { + width: 28px; height: 28px; flex: none; display: inline-flex; align-items: center; justify-content: center; + border-radius: var(--r-sm); color: var(--c-text-2); + transition: background 120ms ease, color 120ms ease; +} +.ic-btn:hover { background: var(--c-fill-1); color: var(--c-text); } +.ic-btn:focus-visible { outline: 2px solid var(--c-brand); outline-offset: 2px; } +.ic-btn svg { width: 17px; height: 17px; } + +.sb-search, .sb-new { + display: flex; align-items: center; gap: 8px; + height: 36px; margin: 0 10px 6px; padding: 0 10px; + border: 0.5px solid var(--c-sep); border-radius: var(--r-md); + background: var(--c-bg); color: var(--c-text-2); font-size: 13px; + transition: background 120ms ease, border-color 120ms ease; +} +.sb-search:hover, .sb-new:hover { background: var(--c-fill-1); color: var(--c-text); } +.sb-search svg, .sb-new svg { width: 16px; height: 16px; color: var(--c-text-3); flex: none; } +.sb-new { color: var(--c-text); } +.sb-search .kbd { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--c-text-4); } + +.sb-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 10px 12px; } +.sec-label { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 8px 4px; font-size: 12px; color: var(--c-text-3); user-select: none; +} +.sec-label .acts { display: flex; gap: 2px; opacity: 0; transition: opacity 120ms ease; } +.sec-label:hover .acts { opacity: 1; } +.sec-label .ic-btn { width: 22px; height: 22px; } +.sec-label .ic-btn svg { width: 14px; height: 14px; } + +.ws { margin-bottom: 4px; } +.ws-head { + display: flex; align-items: center; gap: 7px; width: 100%; + padding: 5px 8px; border-radius: var(--r-md); text-align: left; + color: var(--c-text); transition: background 120ms ease; +} +.ws-head:hover { background: var(--c-fill-1); } +.ws-head > .folder { width: 16px; height: 16px; color: var(--c-text-3); flex: none; } +.ws-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ws-head .ws-actions { margin-left: auto; display: flex; gap: 1px; opacity: 0; transition: opacity 120ms ease; } +.ws-head:hover .ws-actions { opacity: 1; } +.ws-head .ic-btn { width: 22px; height: 22px; } +.ws-head .ic-btn svg { width: 14px; height: 14px; } +.ws-path { padding: 0 8px 5px 31px; font-family: var(--font-mono); font-size: 10.5px; color: var(--c-text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.sess { + display: flex; align-items: center; gap: 8px; width: 100%; + padding: 5px 8px 5px 31px; border-radius: var(--r-md); text-align: left; + color: var(--c-text); font-size: 13px; transition: background 120ms ease; +} +.sess:hover { background: var(--c-fill-1); } +.sess.on { background: var(--c-fill-2); } +.sess .lead { width: 16px; flex: none; display: inline-flex; align-items: center; justify-content: center; } +.sess .dot { width: 7px; height: 7px; border-radius: 999px; } +.sess .dot.run { background: var(--c-brand); } +.sess .dot.unread { background: var(--c-blue); } +.sess .t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sess .time { margin-left: auto; flex: none; font-family: var(--font-mono); font-size: 11px; color: var(--c-text-3); } +.sess:hover .time { display: none; } +.sess .kebab { display: none; margin-left: auto; } +.sess:hover .kebab { display: inline-flex; } +.sess .kebab svg { width: 15px; height: 15px; } + +.show-more { + display: block; width: 100%; padding: 4px 8px 4px 31px; text-align: left; + font-size: 12px; color: var(--c-text-3); border-radius: var(--r-md); +} +.show-more:hover { background: var(--c-fill-1); color: var(--c-text-2); } + +.sb-foot { + flex: none; border-top: 0.5px solid var(--c-sep); padding: 8px 10px; + display: flex; align-items: center; gap: 6px; +} +.foot-fb { + flex: 1; display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 10px; + border-radius: var(--r-md); color: var(--c-text-2); font-size: 12.5px; +} +.foot-fb:hover { background: var(--c-fill-1); color: var(--c-text); } +.foot-fb svg { width: 15px; height: 15px; } +.version { font-size: 11px; color: var(--c-text-3); padding: 3px 7px; border-radius: var(--r-full); background: var(--c-fill-1); } + +/* ---- Chat top bar -------------------------------------------------------- */ +.topbar { + flex: none; display: flex; align-items: center; gap: 10px; + height: 48px; padding: 0 18px; border-bottom: 0.5px solid var(--c-sep); +} +.tb-crumb { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; } +.tb-crumb .ws-ref { color: var(--c-text-2); font-size: 13px; } +.tb-crumb .sep { color: var(--c-text-4); } +.tb-crumb .sess-ref { color: var(--c-text); font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tb-meta { display: flex; align-items: center; gap: 10px; flex: none; } +.branch { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-mono); font-size: 12px; color: var(--c-text-2); } +.branch svg { width: 14px; height: 14px; color: var(--c-text-3); } +.gitstat { font-family: var(--font-mono); font-size: 12px; } +.gitstat .add { color: var(--c-green); } +.gitstat .del { color: var(--c-danger); } +.tb-tools { display: flex; align-items: center; gap: 2px; } + +/* ---- Conversation -------------------------------------------------------- */ +.conv { flex: 1; min-height: 0; overflow-y: auto; } +.conv-inner { width: 100%; max-width: 768px; margin: 0 auto; padding: 28px 18px 40px; display: flex; flex-direction: column; } + +/* assistant prose */ +.a-msg { display: flex; flex-direction: column; gap: 14px; margin-top: 26px; } +.a-msg:first-child { margin-top: 0; } +.prose { font-size: 15px; line-height: 1.7; color: var(--c-text); } +.prose p + p { margin-top: 10px; } +.prose strong { font-weight: 500; } +.prose code { font-family: var(--font-mono); font-size: 0.88em; padding: 1px 5px; border-radius: 6px; background: var(--c-fill-1); } +.lead { font-size: 15px; line-height: 1.7; color: var(--c-text); } + +/* user bubble */ +.u-turn { display: flex; flex-direction: column; align-items: flex-end; margin-top: 22px; } +.u-bub { + max-width: 78%; background: var(--c-fill-2); color: var(--c-text); + border-radius: var(--r-xl) var(--r-xl) var(--r-sm) var(--r-xl); + padding: 10px 15px; font-size: 15px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; +} + +/* ---- Code block ---------------------------------------------------------- */ +.code { + border: 0.5px solid var(--c-sep); border-radius: var(--r-lg); overflow: hidden; + background: var(--c-bg-2); +} +.code-head { + display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; + border-bottom: 0.5px solid var(--c-sep); +} +.code-lang { font-family: var(--font-mono); font-size: 12px; color: var(--c-text-3); } +.code-head .spacer { flex: 1; } +.code-copy { font-size: 12px; color: var(--c-text-2); display: inline-flex; align-items: center; gap: 5px; padding: 2px 6px; border-radius: var(--r-sm); } +.code-copy:hover { background: var(--c-fill-1); } +.code-copy svg { width: 13px; height: 13px; } +.code pre { + margin: 0; padding: 12px 14px; overflow-x: auto; + font-family: var(--font-mono); font-size: 13px; line-height: 1.65; color: var(--c-text); +} +.code .kw { color: #034C7C; } .code .str { color: #A44185; } .code .fn { color: #7EB233; } +.code .com { color: #B2B2B2; } .code .num { color: #174781; } +[data-theme="dark"] .code .kw { color: #C586C0; } [data-theme="dark"] .code .str { color: #CE9178; } +[data-theme="dark"] .code .fn { color: #DCDCAA; } [data-theme="dark"] .code .num { color: #B5CEA8; } + +/* ---- Tool call (compact row) -------------------------------------------- */ +.tool { + border: 0.5px solid var(--c-sep); border-radius: var(--r-lg); background: var(--c-bg); overflow: hidden; +} +.tool-row { + display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 12px; + text-align: left; font-size: 13px; color: var(--c-text); +} +.tool-row:hover { background: var(--c-fill-1); } +.tool-row .glyph { width: 16px; height: 16px; flex: none; color: var(--c-text-3); } +.tool-row .tname { font-family: var(--font-mono); font-weight: 500; color: var(--c-text); } +.tool-row .targ { font-family: var(--font-mono); color: var(--c-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.tool-row .ok { margin-left: auto; flex: none; color: var(--c-green); display: inline-flex; align-items: center; gap: 4px; font-size: 12px; } +.tool-row .ok svg { width: 14px; height: 14px; } +.tool-row .chev { flex: none; width: 15px; height: 15px; color: var(--c-text-4); transition: transform 160ms var(--ease); } +.tool.open .tool-row .chev { transform: rotate(90deg); } +.tool-body { border-top: 0.5px solid var(--c-sep); padding: 10px 12px; } +.tool-body pre { margin: 0; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6; color: var(--c-text-2); white-space: pre-wrap; overflow-wrap: anywhere; } + +/* grouped tool calls */ +.tool-group { border: 0.5px solid var(--c-sep); border-radius: var(--r-lg); background: var(--c-bg); overflow: hidden; } +.tool-group > .tg-head { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; width: 100%; text-align: left; + font-size: 13px; color: var(--c-text-2); +} +.tool-group > .tg-head:hover { background: var(--c-fill-1); } +.tg-head .dot { width: 7px; height: 7px; border-radius: 999px; background: var(--c-green); } +.tg-head .tg-count { font-weight: 500; color: var(--c-text); } +.tg-head .spacer { flex: 1; } +.tg-head .tg-state { font-size: 12px; color: var(--c-text-3); } +.tg-head .chev { width: 15px; height: 15px; color: var(--c-text-4); } +.tg-items { border-top: 0.5px solid var(--c-sep); } +.tg-items .tool-row { border-radius: 0; } +.tg-items .tool-row + .tool-row { border-top: 0.5px solid var(--c-sep); } + +/* ---- Thinking block ------------------------------------------------------ */ +.think { } +.think > .th-head { + display: inline-flex; align-items: center; gap: 7px; padding: 4px 0; + font-size: 13px; color: var(--c-text-2); cursor: pointer; +} +.th-head .spark { width: 15px; height: 15px; color: var(--c-text-3); } +.th-head .chev { width: 14px; height: 14px; color: var(--c-text-4); transition: transform 160ms var(--ease); } +.think.open .th-head .chev { transform: rotate(90deg); } +.th-body { + margin: 4px 0 0; padding: 0 0 0 22px; border-left: 0.5px solid var(--c-sep); + font-size: 13.5px; line-height: 1.65; color: var(--c-text-2); +} +.th-body p + p { margin-top: 8px; } + +/* ---- Approval / question cards ------------------------------------------ */ +.action-card { + border: 0.5px solid var(--c-sep); border-radius: var(--r-xl); background: var(--c-bg); overflow: hidden; +} +.action-head { + display: flex; align-items: center; gap: 9px; padding: 11px 14px; + border-bottom: 0.5px solid var(--c-sep); +} +.action-head .ic { width: 17px; height: 17px; } +.action-head.warn .ic { color: var(--c-orange); } +.action-head .ttl { font-size: 14px; font-weight: 500; color: var(--c-text); } +.action-head .spacer { flex: 1; } +.action-body { padding: 12px 14px; font-size: 14px; color: var(--c-text); } +.action-body .cmd { + display: block; margin: 8px 0 0; padding: 8px 10px; border-radius: var(--r-md); + background: var(--c-fill-1); font-family: var(--font-mono); font-size: 12.5px; color: var(--c-text); overflow-wrap: anywhere; +} +.action-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 0 14px 12px; } + +.q-opts { display: flex; flex-direction: column; gap: 6px; padding: 4px 14px 12px; } +.q-opt { + display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 10px 12px; text-align: left; + border: 0.5px solid var(--c-sep); border-radius: var(--r-md); background: var(--c-bg); + transition: background 120ms ease, border-color 120ms ease; +} +.q-opt:hover { background: var(--c-fill-1); border-color: var(--c-brand); } +.q-opt .k { flex: none; width: 20px; height: 20px; border-radius: 999px; background: var(--c-fill-2); color: var(--c-text-2); font-size: 12px; display: inline-flex; align-items: center; justify-content: center; } +.q-opt .qt { font-size: 14px; color: var(--c-text); font-weight: 500; } +.q-opt .qd { font-size: 12px; color: var(--c-text-3); margin-top: 2px; } + +/* status line */ +.status-line { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--c-text-3); } +.status-line .dot { width: 7px; height: 7px; border-radius: 999px; background: var(--c-green); } + +/* ---- Composer ------------------------------------------------------------ */ +.composer { flex: none; padding: 0 18px 16px; } +.composer-inner { width: 100%; max-width: 768px; margin: 0 auto; } +.cc { + border: 0.5px solid var(--c-sep); border-radius: var(--r-2xl); background: var(--c-bg); + box-shadow: var(--sh-sm); transition: border-color 150ms ease, box-shadow 150ms ease; +} +.cc:focus-within { border-color: var(--c-brand); box-shadow: var(--sh-sm), 0 0 0 3px var(--c-fill-2); } +.cc-ta { + display: block; width: 100%; resize: none; border: none; outline: none; background: transparent; + padding: 14px 16px 6px; font-size: 15px; line-height: 1.55; color: var(--c-text); min-height: 52px; max-height: 40vh; +} +.cc-ta::placeholder { color: var(--c-text-3); } +.cc-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px 10px; } +.cc-tools { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; } +.cc-right { display: flex; align-items: center; gap: 8px; flex: none; } +.pill { + display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 9px; + border-radius: var(--r-sm); font-size: 12.5px; color: var(--c-text-2); white-space: nowrap; +} +.pill:hover { background: var(--c-fill-1); color: var(--c-text); } +.pill svg { width: 15px; height: 15px; } +.pill .tag { padding: 1px 6px; border-radius: var(--r-full); background: var(--c-fill-2); color: var(--c-text-2); font-size: 11px; } +.pill.perm-manual { color: var(--c-text-2); } +.pill.perm-yolo { color: var(--c-orange); } +.pill.perm-auto { color: var(--c-danger); } +.ctx { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 12px; color: var(--c-text-3); } +.ring { width: 16px; height: 16px; } +.send, .stop { + width: 32px; height: 32px; flex: none; border-radius: 999px; + display: inline-flex; align-items: center; justify-content: center; + transition: transform 120ms ease, background 150ms ease; +} +.send { background: var(--c-brand); color: var(--c-on-brand); } +.send:hover { background: var(--c-brand-hover); } +.send:active { transform: scale(0.94); } +.send svg { width: 16px; height: 16px; } +.stop { background: var(--c-danger-soft); color: var(--c-danger); } +.stop:hover { background: var(--c-danger); color: var(--c-white); } + +/* ---- Buttons ------------------------------------------------------------- */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + height: 32px; min-width: 62px; padding: 0 12px; + border: 0.5px solid transparent; border-radius: var(--r-md); + font-size: 14px; font-weight: 500; line-height: 1; white-space: nowrap; + transition: background 150ms ease, color 150ms ease, transform 120ms var(--ease); +} +.btn:active { transform: scale(0.97); } +.btn:focus-visible { outline: 2px solid var(--c-brand); outline-offset: 2px; } +.btn-primary { background: var(--c-brand); color: var(--c-on-brand); } +.btn-primary:hover { background: var(--c-brand-hover); } +.btn-primary[disabled] { background: var(--c-fill-2); color: var(--c-text-4); cursor: not-allowed; } +.btn-secondary { background: var(--c-fill-1); color: var(--c-text); } +.btn-secondary:hover { background: var(--c-fill-2); } +.btn-danger { background: var(--c-danger); color: var(--c-white); } + +/* segmented */ +.seg { display: inline-flex; gap: 4px; padding: 3px; background: var(--c-fill-2); border-radius: var(--r-md); } +.seg button { height: 28px; padding: 0 12px; border-radius: 8px; font-size: 13px; color: var(--c-text-2); transition: background 150ms ease, color 150ms ease; } +.seg button:hover:not(.on) { color: var(--c-text); } +.seg button.on { background: var(--c-brand); color: var(--c-on-brand); } + +/* switch */ +.sw { position: relative; width: 44px; height: 24px; flex: none; border-radius: 999px; background: var(--c-fill-3); transition: background 150ms ease; } +.sw.on { background: var(--c-brand); } +.sw i { position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 999px; background: var(--c-white); box-shadow: var(--sh-sm); transition: transform 160ms var(--ease); } +.sw.on i { transform: translateX(20px); } + +/* menu */ +.menu { + position: absolute; z-index: var(--z-dd); min-width: 180px; padding: 8px; + background: var(--c-bg-3); border: 0.5px solid var(--c-sep); border-radius: var(--r-lg); box-shadow: var(--sh-sm); +} +.mi { + display: flex; align-items: center; gap: 8px; width: 100%; height: 34px; padding: 0 8px; + border-radius: var(--r-md); text-align: left; font-size: 14px; color: var(--c-text); +} +.mi:hover { background: var(--c-fill-1); } +.mi.danger { color: var(--c-danger); } +.mi svg { width: 16px; height: 16px; flex: none; } +.mi-sep { height: 0.5px; background: var(--c-sep); margin: 5px 0; } + +/* badge */ +.badge { display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 8px; border-radius: var(--r-full); font-size: 11px; font-weight: 500; } +.badge.neutral { background: var(--c-fill-1); color: var(--c-text-2); } +.badge.info { background: var(--c-blue-soft); color: var(--c-blue); } +.badge.warn { background: var(--c-orange-soft); color: var(--c-orange); } +.badge.danger { background: var(--c-danger-soft); color: var(--c-danger); } +.badge.solid { background: var(--c-brand); color: var(--c-on-brand); } + +/* spinner */ +.spin { + width: 14px; height: 14px; border-radius: 999px; flex: none; + border: 2px solid var(--c-fill-3); border-top-color: var(--c-text-2); + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ---- Modal overlay ------------------------------------------------------- */ +.overlay { + position: fixed; inset: 0; z-index: var(--z-modal-bg); background: var(--c-mask); + display: flex; align-items: center; justify-content: center; padding: 24px; + opacity: 0; pointer-events: none; transition: opacity 160ms ease; +} +.overlay.is-open { opacity: 1; pointer-events: auto; } +.modal { + position: relative; z-index: var(--z-modal); + background: var(--c-bg-3); border-radius: var(--r-xl); box-shadow: var(--sh-xl); + display: flex; flex-direction: column; overflow: hidden; + max-width: calc(100vw - 32px); max-height: calc(100vh - 64px); + opacity: 0; transform: scale(0.97); transition: opacity 180ms var(--ease), transform 180ms var(--ease); +} +.overlay.is-open .modal { opacity: 1; transform: scale(1); } + +/* ---- Settings modal ------------------------------------------------------ */ +.settings { width: min(900px, 96vw); height: min(640px, 88vh); } +.st-head { + flex: none; display: flex; align-items: center; gap: 10px; height: 56px; padding: 0 18px; + border-bottom: 0.5px solid var(--c-sep); +} +.st-head .h-ic { width: 30px; height: 30px; border-radius: var(--r-md); background: var(--c-fill-1); display: inline-flex; align-items: center; justify-content: center; } +.st-head .h-ic svg { width: 17px; height: 17px; } +.st-head h2 { font-size: 16px; font-weight: 500; } +.st-head .spacer { flex: 1; } +.st-body { flex: 1; min-height: 0; display: flex; } +.st-nav { + flex: none; width: 188px; border-right: 0.5px solid var(--c-sep); padding: 12px 10px; + display: flex; flex-direction: column; gap: 2px; +} +.nav-label { font-size: 11px; color: var(--c-text-3); padding: 4px 10px; } +.nav-item { + display: flex; align-items: center; gap: 9px; width: 100%; height: 34px; padding: 0 10px; + border-radius: var(--r-md); text-align: left; font-size: 13.5px; color: var(--c-text-2); +} +.nav-item svg { width: 16px; height: 16px; color: var(--c-text-3); flex: none; } +.nav-item:hover { background: var(--c-fill-1); color: var(--c-text); } +.nav-item.on { background: var(--c-fill-2); color: var(--c-text); font-weight: 500; } +.nav-item.on svg { color: var(--c-text); } + +.st-panel { flex: 1; min-width: 0; overflow-y: auto; padding: 22px 26px 28px; } +.ph-kicker { font-size: 11px; letter-spacing: .05em; text-transform: uppercase; color: var(--c-text-3); margin-bottom: 4px; } +.ph-title { font-size: 19px; font-weight: 600; line-height: 27px; margin-bottom: 4px; color: var(--c-text); } +.ph-desc { font-size: 13px; line-height: 20px; color: var(--c-text-2); margin-bottom: 18px; } + +.card { border: 0.5px solid var(--c-sep); border-radius: var(--r-xl); overflow: hidden; margin-bottom: 16px; background: var(--c-bg); } +.card-title { padding: 12px 16px 4px; font-size: 12px; font-weight: 500; color: var(--c-text-2); } +.srow { + display: flex; align-items: center; justify-content: space-between; gap: 24px; + padding: 13px 16px; border-top: 0.5px solid var(--c-sep); +} +.srow .name { font-size: 14px; font-weight: 500; color: var(--c-text); } +.srow .desc { font-size: 12px; color: var(--c-text-3); margin-top: 2px; } +.srow .ctrl { flex: none; } +.num { + display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; + border: 0.5px solid var(--c-sep); border-radius: var(--r-md); background: var(--c-fill-1); + font-family: var(--font-mono); font-size: 14px; +} +.num .u { color: var(--c-text-3); font-size: 12px; } + +/* ---- Add-workspace modal ------------------------------------------------- */ +.aw { width: min(640px, 96vw); height: min(540px, 88vh); } +.aw-head { flex: none; display: flex; align-items: center; justify-content: space-between; height: 56px; padding: 0 22px; } +.aw-head h2 { font-size: 18px; font-weight: 600; } +.aw-body { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0 22px; } +.crumb { display: flex; align-items: center; gap: 2px; height: 28px; font-size: 14px; } +.crumb button { padding: 4px 6px; border-radius: var(--r-sm); color: var(--c-text-2); } +.crumb button:hover { background: var(--c-fill-1); color: var(--c-text); } +.crumb .cur { color: var(--c-text); font-weight: 500; } +.crumb .sep { color: var(--c-text-4); } +.crumb .up { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--c-text-4); } +.field { position: relative; margin-top: 16px; } +.field svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: var(--c-text-3); } +.field input { + width: 100%; height: 46px; padding: 0 14px 0 40px; border: 0.5px solid var(--c-sep); border-radius: var(--r-md); + background: var(--c-fill-1); color: var(--c-text); font-size: 15px; outline: none; + transition: background 150ms ease, border-color 150ms ease, box-shadow 150ms ease; +} +.field input:focus { background: var(--c-bg); border-color: var(--c-brand); box-shadow: 0 0 0 3px var(--c-fill-2); } +.dir { flex: 1; min-height: 0; overflow-y: auto; margin: 12px -8px 0; padding: 0 8px; } +.dir-row { display: flex; align-items: center; gap: 12px; height: 42px; padding: 0 12px; border-radius: var(--r-md); color: var(--c-text); } +.dir-row:hover { background: var(--c-fill-1); } +.dir-row.on { background: var(--c-fill-2); } +.dir-row.on .dname { font-weight: 500; } +.dir-row .dic { width: 18px; height: 18px; color: var(--c-text-2); flex: none; } +.dir-row .dname { flex: 1; min-width: 0; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.aw-foot { flex: none; display: flex; justify-content: flex-end; gap: 8px; padding: 14px 22px; border-top: 0.5px solid var(--c-sep); } + +/* ---- Toast --------------------------------------------------------------- */ +.toast { + position: fixed; z-index: var(--z-toast); top: 16px; left: 50%; transform: translateX(-50%); + display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 10px 14px; + border-radius: var(--r-lg); background: #2b2b2b; color: #fff; font-size: 14px; + box-shadow: var(--sh-md); +} +.toast svg { width: 18px; height: 18px; } +.toast.success svg { color: var(--c-green); } + +/* ---- Responsive ---------------------------------------------------------- */ +@media (max-width: 760px) { + .app { grid-template-columns: 1fr; } + .side { display: none; } + .conv-inner { padding: 18px 14px 28px; } + .settings { width: 100%; height: 100%; border-radius: 0; max-height: 100vh; } + .st-nav { width: 150px; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } +} + +/* ---- Empty landing ----------------------------------------------------- */ +.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 40px 24px; text-align: center; } +.empty-logo { width: 56px; height: 56px; border-radius: var(--r-full); background: var(--c-brand); color: var(--c-on-brand); display: inline-flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 500; } +.empty-title { margin: 0; font-size: 30px; font-weight: 500; letter-spacing: 0; color: var(--c-text); } +.empty-sub { margin: 0; font-size: 14px; line-height: 1.6; color: var(--c-text-3); max-width: 42ch; } +.empty-chips { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 8px; } +.sug { height: 34px; padding: 0 13px; border: 0.5px solid var(--c-sep); border-radius: var(--r-md); background: var(--c-bg); color: var(--c-text-2); font-size: 13px; transition: background 120ms ease, border-color 120ms ease; } +.sug:hover { background: var(--c-fill-1); color: var(--c-text); border-color: var(--c-brand); } + +/* numeric input inside .num (settings font size) */ +.num input { width: 40px; border: none; outline: none; background: transparent; color: var(--c-text); font-family: var(--font-mono); font-size: 14px; text-align: right; } + +/* ---- Search modal (⌘K) ------------------------------------------------ */ +.search-modal { width: min(560px, 96vw); max-height: 72vh; padding: 14px; } +.search-list { margin: 10px 0 0; padding: 0; max-height: 50vh; overflow-y: auto; } +.search-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border-radius: var(--r-md); text-align: left; } +.search-row:hover, .search-row.on { background: var(--c-fill-1); } +.search-row .sr-t { flex: 1; min-width: 0; font-size: 14px; color: var(--c-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.search-row .sr-ws { flex: none; max-width: 40%; font-size: 12px; color: var(--c-text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.search-empty { padding: 18px; text-align: center; color: var(--c-text-3); font-size: 13px; } + +/* ---- Confirm / server-auth modal ------------------------------------- */ +.confirm-modal { width: 360px; padding: 18px; } +.confirm-title { font-size: 16px; font-weight: 500; color: var(--c-text); line-height: 24px; } +.confirm-msg { margin-top: 8px; font-size: 14px; line-height: 20px; color: var(--c-text); } +.confirm-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } + +/* ---- Composer popovers (slash / mention / permission / modes) -------- */ +.composer-inner { position: relative; } +.pop { + position: absolute; bottom: calc(100% + 8px); left: 0; z-index: var(--z-dd); + min-width: 240px; max-width: 100%; padding: 8px; + background: var(--c-bg-3); border: 0.5px solid var(--c-sep); border-radius: var(--r-lg); + box-shadow: var(--sh-sm); +} +.pop.wide { right: 0; } +.pop[hidden] { display: none; } +.pop-item { + display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 10px; + border-radius: var(--r-md); text-align: left; color: var(--c-text); +} +.pop-item:hover, .pop-item.on { background: var(--c-fill-1); } +.pop-item .pi-ic { width: 16px; height: 16px; flex: none; color: var(--c-text-3); } +.pop-item .pi-t { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.pop-item .pi-name { font-size: 14px; color: var(--c-text); } +.pop-item .pi-name .cmd { font-family: var(--font-mono); } +.pop-item .pi-desc { display: block; font-size: 12px; color: var(--c-text-3); margin-top: 1px; } +.pop-item .pi-check { width: 16px; height: 16px; flex: none; color: var(--c-text); } +.pop-sep { height: 0.5px; background: var(--c-sep); margin: 5px 0; } +.pop-head { padding: 4px 10px 6px; font-size: 11px; color: var(--c-text-3); } + +/* ---- inline rename input ---------------------------------------------- */ +.rename-input { flex: 1; min-width: 0; font: inherit; font-size: 13px; color: var(--c-text); background: var(--c-bg); border: 0.5px solid var(--c-brand); border-radius: var(--r-sm); padding: 2px 6px; outline: none; } +.ws-name.rename-input { font-weight: 500; } + +.mi-ic { width: 16px; flex: none; display: inline-flex; align-items: center; justify-content: center; } +.menu-float { position: fixed; z-index: var(--z-dd); } + +/* ---- Mobile top bar + switcher sheet ---------------------------------- */ +.mob-bar { display: none; } +@media (max-width: 760px) { + .mob-bar { display: flex; align-items: center; gap: 8px; height: 48px; padding: 0 12px; border-bottom: 0.5px solid var(--c-sep); flex: none; } + .mob-title { flex: 1; min-width: 0; font-size: 15px; font-weight: 500; color: var(--c-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; } + .topbar { display: none; } +} +.sheet-back { position: fixed; inset: 0; z-index: 800; background: var(--c-mask); opacity: 0; pointer-events: none; transition: opacity 200ms ease; } +.sheet-back.is-open { opacity: 1; pointer-events: auto; } +.sheet { position: fixed; left: 0; right: 0; bottom: 0; z-index: 810; max-height: 80vh; background: var(--c-bg-3); border-radius: var(--r-2xl) var(--r-2xl) 0 0; box-shadow: var(--sh-xl); transform: translateY(100%); transition: transform 260ms var(--ease); display: flex; flex-direction: column; } +.sheet-back.is-open .sheet { transform: translateY(0); } +.sheet-handle { width: 36px; height: 4px; border-radius: 999px; background: var(--c-fill-3); margin: 8px auto 0; } +.sheet-head { flex: none; display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; border-bottom: 0.5px solid var(--c-sep); } +.sheet-head h3 { font-size: 16px; font-weight: 500; } +.sheet-body { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 12px 24px; }