Compare commits

..
Author SHA1 Message Date
weichen c2878c04ea Submit page actions from the preview pane (U5)
Why: U5 closes the interactive loop - the surface view's action click
carries the user's typed drafts (and nothing else - unsubmitted input
has no upload path), the store builds the idempotent request
(client-generated requestId, current revision), guards double-clicks
in flight, and maps server outcomes to toasts (awaiting-confirm card,
revision conflict refresh, disabled rejection).

Evidence: tsc clean; vitest 95/95 including the request-shape test
(explicit data only, unique requestId per submission); knip unchanged.

Impact: Frontend-only; backend endpoint from the U5 backend branch.

Refs: U5 action-scope r2 (approved); UI PRs #46/#47; Dev task
feature/generative-ui-u5
2026-10-11 14:24:01 +00:00
weichen 39dc8c456c Merge pull request 'Clear stale API-key credentials when a password session begins' (#55) from feature/login-clear-stale-key into main 2026-10-11 14:04:59 +00:00
weichen 482ba885e1 Merge remote-tracking branch 'origin/main' into feature/login-clear-stale-key 2026-10-11 14:04:26 +00:00
weichen 09310cb753 Clear stale API-key credentials when a password session begins
Why: the auth middleware prefers Bearer keys over the session cookie, so a stale key left in localStorage shadows valid cookie sessions and every request after password login returns 401 (observed at http://192.168.3.2:8788 on 2026-10-11).
Evidence: modules/frontend vitest 19 files / 88 tests green; auth.test.ts covers beginSession clearing the stored Bearer before any fetch.
Impact: password logins switch to the HttpOnly cookie session cleanly; API-key logins unchanged.
Refs: task card feature-login-clear-stale-key in Agent-System-Dev.
2026-10-11 13:53:27 +00:00
weichen fcf40ff75f Merge pull request 'Keep API-key holders out of the dead-end change-password gate' (#54) from feature/admin-auth-gate-fix into main 2026-10-11 13:52:15 +00:00
weichen dc18d6c747 Keep API-key holders out of the dead-end change-password gate
Why: entering the forced-change gate with an API-key credential always
fails 401 (passwords are session-owned), and the gate mapped every 401
to "Incorrect username or password", masking the real reason and hiding
the self-service exit; admins got locked out before reaching user
management.

Evidence: npx vitest run green (20 files / 93 tests incl. new
test/loginpage.test.ts); tsc -b and check-knip green.

Impact: App boot routes key+mustChange to the login page (key cleared);
the API-key tab advises password login for must-change accounts; the
gate maps backend error strings to distinct actionable messages; api.me
type gains authVia.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:45:56 +00:00
weichen 68f6c8638b Merge pull request 'Render saved surfaces in the preview pane with refresh recovery' (#53) from feature/generative-ui-u4 into main 2026-10-11 11:40:00 +00:00
weichen df4ca1e470 Render saved surfaces in the preview pane with refresh recovery
Why: U4 read-only experience - surface.updated events project into a
surfaces slice with a strict revision guard (stale/duplicate events
never overwrite; server snapshot is the single owner), preview tabs
gain a surface: kind that renders SurfaceView (doc-refs bridge into
artifact previews), a new page auto-opens a tab without stealing the
user's active non-surface tab, and opening a conversation reloads the
authoritative surface list so refresh/断线 restores pages.

Evidence: tsc clean; vitest 87/87 including new surface projection
tests (stale/duplicate/multi-surface); UI SurfaceView suite green in
its repo.

Impact: Frontend-only; no backend or contract changes.

Refs: Core PRs #27/#28; UI PRs #42/#43/#44; Dev task
feature/generative-ui-u4
2026-10-11 11:39:57 +00:00
weichen 8108655e61 Merge pull request 'Sync the contracts mirror from the core authority' (#52) from feature/generative-ui-u2-contract-sync into main 2026-10-11 08:45:23 +00:00
weichen 6b4920b509 Sync the contracts mirror from the core authority
Why: Core PR #26 added the node id/source fields to ts/types.ts (the
single authority); the mirror is regenerated so the sync header and
content match make contracts output exactly (manual edits drift).

Evidence: File generated by the make contracts copy steps; combined
verification in the Dev workspace after re-pinning.

Impact: Header banner plus the already-reviewed id/source fields.

Refs: Core PR #26; Dev task feature/generative-ui-u2
2026-10-11 08:45:13 +00:00
weichen a26d7c5aee Merge pull request 'Mirror the optional node id/source fields in the contract' (#51) from feature/generative-ui-u2-nodeids into main 2026-10-11 08:16:11 +00:00
weichen 69b1487fb7 Mirror the optional node id/source fields in the frontend contract
Why: Core PR #25 adds optional id/source to UINode for the Generative
UI Surface layer; the TypeScript contract authority mirrors core types.

Evidence: Type-only additive change; combined verification runs in the
Dev workspace make test.

Impact: No component or behavior change.

Refs: Agent-System-Core #25; Dev task feature/generative-ui-u2
2026-10-11 08:15:44 +00:00
weichen 32e327a831 Merge pull request 'Three-column workbench layout rework (Work/Files third column)' (#50) from feature/ui-three-column-workbench into main 2026-10-11 03:06:44 +00:00
weichen 3ed10e1220 Update workspace builtin expectations and drop an unused selector
Why: Removing the artifacts builtin shifts the shortcut ranking, so the
workspaces tests now expect agents at cmd-1; Workbench no longer selects
previewOpen in its outer scope (FilesTab owns that read).

Evidence: npx vitest run 85/85 passed; npm run build (tsc -b && vite
build) passed in the task worktree (SELF).

Impact: test/workspaces.test.ts expectations; Workbench.tsx selector
cleanup. No behavior change beyond the committed layout rework.
2026-10-11 02:41:39 +00:00
weichen 204ca67dc7 Merge file preview into a Work/Files third column and rework the left rail
Why: The three-column workbench redesign converges entries and hierarchy:
project switching tops the left rail (identity shown once), sessions and
search stay centered, files/workspaces/account sink to the bottom. The
right column becomes a Work/Files third column that absorbs the preview
pane, which is no longer an independent fourth region; the artifacts
workspace overlay folds into the Files entry, and stop control
consolidates into the input area with a light fast/slow-lane status hint.

Evidence: make test-frontend NOT_RUN at commit time; the run and its
outcome are recorded in the Dev task card before integration.

Impact: App.tsx layout and divider width tiers (work 280-560, files with
open preview 420-1100); store UI state rightTab work/files plus filesView
with syncFilesEntry replacing syncPreviewRail; Workbench dual entry;
PreviewPane gains a back-to-list control; TaskSidebar project switcher;
TopBar drops project identity; Conversation drops the header stop button
and gains the input status line; workspaces.ts drops the artifacts builtin.

Refs: three-column workbench proposal (2026-10-11), DESIGN.md 6/9.3
revision follows in the Dev repo.
2026-10-11 02:40:18 +00:00
weichen 339a9ebc2d Merge pull request 'Open previews from the empty state and restore the last conversation on reload' (#49) from feature/empty-state-preview-fix into main 2026-10-10 08:24:30 +00:00
weichen 475fd3a8d6 Open previews from the empty state and restore the last conversation on reload
Why: reproduced with headless Chromium - clicking an artifact row with no
conversation selected closed the workspace overlay and nothing else appeared.
requestPreview stores tabs under currentConvId ?? '_' while the render side
required a non-null currentConvId, so the preview pane never mounted in the
empty state. The empty state was itself a trap: currentConvId was not
persisted, so every reload landed there.
Evidence: vitest 85/85 (new preview-empty-state tests for the key contract
and orphan-tab merge); browser replay on the fixed build shows the preview
pane mounting with rendered docx content from the empty state and the
conversation restoring after reload (screenshots in the Dev task card).
Impact: previewTabsKey() unifies the read/write key; entering a conversation
adopts orphan '_' tabs; the last conversation id persists and restores once
on boot; deliverable cards toast when the artifact is gone instead of
silently doing nothing.
Refs: Dev task feature/empty-state-preview-fix; diagnosis session 2026-10-10.
2026-10-10 08:02:21 +00:00
weichen 84b73ab328 Merge pull request 'Show last-request and cumulative tokens in the usage badge (N3 WP6)' (#48) from feature/n3-usage-badge into main
Show last-request and cumulative tokens in the usage badge (N3 WP6)
2026-10-07 17:30:49 +00:00
weichen c71a77bb22 Show last-request and cumulative tokens in the usage badge (N3 WP6)
Why: run.Usage now carries the WP6 split fields; the badge should show
them ('+last · cumulative · duration · tools', cache hits in the
tooltip).
Evidence: npx tsc --noEmit clean; npx vitest run 82/82 green.
Impact: display only.
Refs: Agent-System-Backend#96, N3 plan WP6
2026-10-07 17:19:28 +00:00
23 changed files with 623 additions and 152 deletions
+36 -33
View File
@@ -8,7 +8,7 @@ import { Icon } from '@ui'
import { store, useStore } from './store'
import { TaskSidebar } from './components/TaskSidebar'
import { Conversation } from './components/Conversation'
import { PreviewPane } from './components/PreviewPane'
import { previewTabsKey } from './previewtabs'
import { Workbench } from './components/Workbench'
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
import { AgentTracker } from './components/AgentTracker'
@@ -17,7 +17,8 @@ import { ProjectSettingsModal } from './components/ProjectSettingsModal'
import { TopBar } from './components/TopBar'
import { workspaceEntries } from './components/Workspaces'
// 布局:左(项目会话)/ 中(对话)/ 预览区(有标签时)/ 右(工作台)。
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
const RIGHT_MIN = 280, RIGHT_MAX = 560
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
@@ -30,13 +31,16 @@ export function App() {
}, [])
const ui = useStore(s => s.ui)
const previewOpen = useStore(s => {
const id = s.currentConvId
return id ? (s.previewTabs[id]?.length ?? 0) > 0 : false
})
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
const rightTab = useStore(s => s.ui.rightTab)
const toast = useStore(s => s.toast)
const trackerRunId = useStore(s => s.ui.trackerRunId)
const previewExpanded = previewOpen && ui.previewExpanded
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
const filesWide = rightTab === 'files' && previewOpen
const rightPanelWidth = filesWide ? ui.previewWidth : ui.rightWidth
const previewExpanded = filesWide && ui.previewExpanded
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
@@ -48,8 +52,13 @@ export function App() {
const r = await auth.fetch('/api/health')
if (r.status === 401) { setAuthState('login'); return }
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
// key 持有者清 key 回登录页,改走密码登录完成改密。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) { setMustChangeOwner(me.owner); setAuthState('mustchange'); return }
if (me?.mustChangePassword) {
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
}
setAuthState('ok')
} catch {
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
@@ -64,28 +73,31 @@ export function App() {
store.refreshPlugins()
}, [authState])
const drag = useRef<{ side: 'left' | 'right' | 'preview'; startX: number; startW: number; overshoot: boolean } | null>(null)
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
const onDividerDown = (side: 'left' | 'right' | 'preview') => (e: React.MouseEvent) => {
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
e.preventDefault()
const startW = side === 'left' ? ui.leftWidth : side === 'preview' ? ui.previewWidth : ui.rightWidth
drag.current = { side, startX: e.clientX, startW, overshoot: false }
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
const wide = side === 'right' && rightTab === 'files' && previewOpen
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
drag.current = {
side, startX: e.clientX, startW: ui[key], overshoot: false,
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
key,
}
const move = (ev: MouseEvent) => {
const d = drag.current
if (!d) return
if (d.side === 'left') {
const w = d.startW + (ev.clientX - d.startX)
if (w < LEFT_MIN - LEFT_OVERSHOOT) { d.overshoot = true; return }
if (w < d.min - LEFT_OVERSHOOT) { d.overshoot = true; return }
d.overshoot = false
store.setUI({ leftCollapsed: false, leftWidth: Math.min(LEFT_MAX, Math.max(LEFT_MIN, w)) })
} else if (d.side === 'preview') {
const w = d.startW - (ev.clientX - d.startX)
if (w < PREVIEW_MIN) return
store.setUI({ previewWidth: Math.min(PREVIEW_MAX, w) })
store.setUI({ leftCollapsed: false, leftWidth: Math.min(d.max, Math.max(d.min, w)) })
} else {
const w = d.startW - (ev.clientX - d.startX)
if (w < RIGHT_MIN) return
store.setUI({ rightCollapsed: false, rightWidth: Math.min(RIGHT_MAX, w) })
if (w < d.min) return
store.setUI({ rightCollapsed: false, [d.key]: Math.min(d.max, w) } as Partial<typeof ui>)
}
}
const up = () => {
@@ -146,30 +158,21 @@ export function App() {
<Conversation />
</div>
{previewOpen && (
<>
{!previewExpanded && (
<div className="divider" onMouseDown={onDividerDown('preview')} title="拖动调整预览区宽度" />
)}
<div className={`preview-slot mk-panel-grow${previewExpanded ? ' expanded' : ''}`} style={previewExpanded ? undefined : { width: ui.previewWidth }}>
<PreviewPane />
</div>
</>
)}
{!ui.rightCollapsed ? (
<>
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
<div className="right-panel" style={{ width: ui.rightWidth }}>
<div className={`right-panel mk-panel-grow${previewExpanded ? ' expanded' : ''}`}
style={previewExpanded ? undefined : { width: rightPanelWidth }}>
<Workbench />
</div>
</>
) : (
<div className="right-panel collapsed" style={{ width: 44 }}>
<div className="icon-only">
<button title="展开收件箱" onClick={store.toggleRight}>◧
<button title="展开右栏" onClick={store.toggleRight}>◧
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
<button title="文件(搜索、预览、编辑、下载)" onClick={() => store.openFiles()}><Icon name="folder" size={16} /></button>
</div>
</div>
)}
+6 -2
View File
@@ -30,7 +30,11 @@ export const api = {
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
@@ -160,7 +164,7 @@ const streamEventListeners: Record<StreamEventType, true> = {
'card.created': true,
'card.updated': true,
'card.status': true,
'card.resolved': true,
'card.resolved': true, 'surface.updated': true,
}
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } {
+3
View File
@@ -22,6 +22,9 @@ export const auth = {
apiKey(): string { return storage.getItem(KEY) ?? '' },
setKey(key: string) { storage.setItem(KEY, key.trim()) },
clear() { storage.removeItem(KEY) },
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
beginSession() { this.clear() },
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
fetch(input: string, init?: RequestInit): Promise<Response> {
const key = this.apiKey()
+5 -2
View File
@@ -78,7 +78,10 @@ export function AgentTracker() {
function UsageBadge({ usage }: { usage?: Usage | null }) {
if (!usage) return null
return <span className="effect-badge" title="token 用量与时长">
{usage.totalTokens > 0 ? `${usage.totalTokens} tok · ` : ''}{(usage.durationMs / 1000).toFixed(1)}s{usage.toolCalls > 0 ? ` · ${usage.toolCalls} 工具` : ''}
// N3 计费拆分:有上一请求数据时显示「+最近 · 累计(含缓存命中)」。
const last = (usage.lastPromptTokens ?? 0) + (usage.lastCompletionTokens ?? 0)
const cache = usage.cacheReadTokens ?? 0
return <span className="effect-badge" title={cache > 0 ? `token 用量与时长(缓存命中 ${cache})` : 'token 用量与时长'}>
{last > 0 ? `+${last} · ` : ''}{usage.totalTokens > 0 ? `累计 ${usage.totalTokens} tok · ` : ''}{(usage.durationMs / 1000).toFixed(1)}s{usage.toolCalls > 0 ? ` · ${usage.toolCalls} 工具` : ''}
</span>
}
+5 -5
View File
@@ -1,5 +1,5 @@
// 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内打开预览区或下载。
// ⌘1 弹层与右栏「文件」页签共用这一份。删除仍是保留能力,系统默认不提供入口。
// 文件列表(第三栏「文件」入口的主视图):搜索 + kind 过滤 + 上传;
// 行内打开预览(同入口切到预览子视图)或下载。删除仍是保留能力,系统默认不提供入口。
import { useState } from 'react'
import { store, useStore } from '../store'
import { api } from '../api'
@@ -37,16 +37,16 @@ export function ArtifactsPanel() {
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
// 预览落在对话右侧预览区。工作区弹层盖住主区,先收起再打开。
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => { store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}`}>
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
<span className="ar-acts">
<button className="icon-btn" title="展开预览" onClick={e => { e.stopPropagation(); store.closeWorkspace(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
+21 -3
View File
@@ -4,7 +4,7 @@
import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpen, SafeMarkdown } from '@ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../api'
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
@@ -124,7 +124,11 @@ function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: st
const name = artifact.name || art?.name || '文件'
const icon = artifactIcon(name, artifact.kind ?? art?.kind)
const size = humanSize(art?.size)
const open = () => { if (art) store.requestPreview(art) }
const open = () => {
if (art) { store.requestPreview(art); return }
// 工件已不在当前项目(被删/跨项目残留):给一句话反馈,不再无声无息。
store.toast(`「${name}」已不在当前项目中,无法预览`)
}
return (
<div className="deliverable-card mk-rise-in" data-artifact-chip={artifact.artifactId}>
<button type="button" className="dc-open" title={`预览 ${name}`} onClick={open}>
@@ -480,6 +484,8 @@ export function Conversation() {
})
const busy = isShellBusy(phase)
const showStop = phase === 'running'
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
useEffect(() => {
if (!stickRef.current) return
@@ -549,7 +555,6 @@ export function Conversation() {
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
{!connected && <span className="conn-label">重连中</span>}
{showStop && <button className="btn ghost" onClick={() => store.cancelActive()}>停止</button>}
</div>
<div className="chat-body" ref={bodyRef}
@@ -611,6 +616,19 @@ export function Conversation() {
<div className="input-bar">
<OutboxPanel />
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
{(busy || bgRunning > 0) && (
<div className="input-status" data-testid="input-status">
<span className="live-dot" />
{busy
? <span className="mk-sweep">助手工作中{bgRunning > 0 ? ` · ${bgRunning} 个后台任务` : ''}</span>
: <span>{bgRunning} 个后台任务进行中</span>}
<span className="input-status-note">{busy ? '输入仍可排队' : '完成后回到对话汇报'}</span>
<span style={{ flex: 1 }} />
<button type="button" className="input-status-link" onClick={() => store.openInbox()}>在工作里查看</button>
</div>
)}
{attachmentNames.length > 0 && (
<div className="attachments">
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
+28 -3
View File
@@ -41,6 +41,8 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
setErr(''); setBusy(true)
try {
const me = await api.login(owner.trim(), password)
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
auth.beginSession()
if (me.mustChangePassword) {
// 初始/重置密码:先完成强制改密再放行。
onMustChange(me.owner)
@@ -57,7 +59,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
auth.setKey(key)
try {
const r = await auth.fetch('/api/health')
if (r.ok) { onAuthed(); return }
if (r.ok) {
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
// 不放行进死路改密门,引导改走账号密码登录。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) {
auth.clear()
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
return
}
onAuthed(); return
}
throw new Error(String(r.status))
} catch {
auth.clear()
@@ -118,7 +130,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
await api.changePassword(current, next)
onDone()
} catch (e) {
setErr(humanLoginError(String(e)).replace('401: ', ''))
setErr(humanChangePasswordError(String(e)))
} finally { setBusy(false) }
}
@@ -145,10 +157,23 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
)
}
/** 错误文案:把 HTTP 状态码翻成人话。 */
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
+40 -6
View File
@@ -1,19 +1,24 @@
// 对话右侧预览区:多标签、可关、展开与下载。宽度由 App 的分隔条控制;
// 展开态铺满主区。文本类文件在这里编辑保存。
// 第三栏「文件」入口的多标签预览:打开/切换/关闭标签、编辑保存、展开铺满与下载。
// 列表 ⇄ 预览在同一入口内切换;宽度档(阅读档 420–1100)由 App 的分隔条控制。
import { useEffect, useState } from 'react'
import { Btn, FilePreview, Icon, TabStrip } from '@ui'
import { Btn, FilePreview, Icon, SurfaceView, TabStrip } from '@ui'
import { api } from '../api'
import type { Artifact } from '../contracts'
import { humanSize } from '../messageparts'
import { previewTabsKey } from '../previewtabs'
import { store, useStore } from '../store'
export function PreviewPane() {
const convId = useStore(s => s.currentConvId)
const tabs = useStore(s => (convId ? s.previewTabs[convId] : undefined) ?? [])
const activeId = useStore(s => (convId ? s.previewActive[convId] : null) ?? null)
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
const tabs = useStore(s => s.previewTabs[previewTabsKey(s.currentConvId)] ?? [])
const activeId = useStore(s => s.previewActive[previewTabsKey(s.currentConvId)] ?? null)
const artifacts = useStore(s => s.artifacts)
const expanded = useStore(s => s.ui.previewExpanded)
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
const surfaces = useStore(s => s.surfaces)
const surfaceSnap = active?.id.startsWith('surface:')
? surfaces.find(x => 'surface:' + String(x.identity.surfaceId) === active.id)
: undefined
const art = active ? artifacts.find(a => a.id === active.id) : undefined
const textKind = !!art && isTextKind(art)
const [content, setContent] = useState<string | null>(null)
@@ -34,6 +39,32 @@ export function PreviewPane() {
}, [art?.id])
if (!active) return null
if (active.id.startsWith('surface:')) {
return (
<div className="preview-pane">
<TabStrip
tabs={tabs.map(t => ({ id: t.id, label: t.name }))}
activeId={active.id}
onActivate={id => store.activatePreview(id)}
onClose={id => store.closePreview(id)}
/>
<div className="preview-pane-bar">
<button type="button" className="icon-btn" title="返回文件列表" onClick={() => store.setUI({ filesView: 'list', previewExpanded: false })}>
<Icon name="list" size={14} />
</button>
<span className="preview-pane-name" title={active.name}>{active.name}</span>
</div>
<div className="preview-pane-body">
{surfaceSnap
? <SurfaceView snapshot={surfaceSnap} onAction={(actionId, payload) => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), actionId, payload as Record<string, unknown>)} docBridge={{ onOpenDoc: docId => {
const art = artifacts.find(a => a.id === docId)
if (art) store.requestPreview(art)
} }} />
: <p className="surface-view-empty">页面快照不可用(可能已被移除或无权限)。</p>}
</div>
</div>
)
}
return (
<div className="preview-pane">
<TabStrip
@@ -43,6 +74,9 @@ export function PreviewPane() {
onClose={id => store.closePreview(id)}
/>
<div className="preview-pane-bar">
<button type="button" className="icon-btn" title="返回文件列表" onClick={() => store.setUI({ filesView: 'list', previewExpanded: false })}>
<Icon name="list" size={14} />
</button>
<span className="preview-pane-name" title={active.name}>{active.name}</span>
{art && (
<span className="preview-pane-meta">{art.kind}{humanSize(art.size) ? ` · ${humanSize(art.size)}` : ''}</span>
+45 -26
View File
@@ -1,4 +1,4 @@
// 左栏:项目列表、当前项目的会话、体系工具、账户。
// 左栏(找上下文):顶部切换项目,中间放会话与搜索,底部放文件、工作区和账户。
// 创建、搜索、置顶与归档仍在会话段里;领域工具由服务端工作区声明,没有时装上说明行。
import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store'
@@ -40,6 +40,7 @@ export function TaskSidebar() {
const projects = useStore(s => s.projects)
const tasks = useStore(s => s.tasks)
const me = useStore(s => s.me)
const artifacts = useStore(s => s.artifacts)
const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
const showArchived = useStore(s => s.ui.showArchived)
@@ -50,7 +51,7 @@ export function TaskSidebar() {
s.projectCards.filter(c => c.status === 'action_required').length)
const [q, setQ] = useState('')
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | 'project' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [creatingProject, setCreatingProject] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
@@ -70,6 +71,7 @@ export function TaskSidebar() {
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const currentProject = projects.find(p => p.id === currentProjectId)
const stage = projectStage(currentProject?.settings)
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
@@ -86,6 +88,14 @@ export function TaskSidebar() {
</span>
)
/** 切换项目:优先恢复该项目下正在看的会话,否则进第一个未归档会话。 */
const switchProject = (pid: string) => {
const next = tasks.find(t => t.projectId === pid && t.id === currentConvId)
?? tasks.find(t => t.projectId === pid && !t.archived)
if (next) store.openTask(next.id)
else store.openProject(pid)
}
const taskRow = (t: Conversation) => (
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
onClick={() => store.openTask(t.id)}
@@ -99,8 +109,15 @@ export function TaskSidebar() {
return (
<>
{/* 项目身份唯一落点:顶部切换项目(方案:项目导航置顶)。 */}
<div className="tsb-brand">
<span className="tsb-name">项目</span>
<button className="tsb-projrow" title="切换项目"
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
<Icon name="folder" size={15} />
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
{stage && <span className="tsb-projrow-stage">{stage}</span>}
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button>
<span style={{ flex: 1 }} />
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
</div>
@@ -127,6 +144,7 @@ export function TaskSidebar() {
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
</div>
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
<div className="tsb-scroll">
{creatingProject && (
<div className="tsb-newproject">
@@ -143,25 +161,6 @@ export function TaskSidebar() {
</div>
)}
<div className="tsb-seclabel">项目</div>
{projects.map(p => {
const stage = projectStage(p.settings)
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
return (
<button key={p.id} className={`tsb-project ${p.id === currentProjectId ? 'active' : ''}`}
onClick={() => {
const next = tasks.find(t => t.projectId === p.id && t.id === currentConvId)
?? tasks.find(t => t.projectId === p.id && !t.archived)
if (next) store.openTask(next.id)
else store.openProject(p.id)
}}>
<span className="tsb-project-name">{p.name}</span>
<span className="tsb-project-meta">{stage ? `${stage} · ` : ''}{count} 个会话</span>
</button>
)
})}
{projects.length === 0 && <div className="tsb-empty">还没有项目——从「新建任务」旁的菜单创建</div>}
{pinned.length > 0 && (
<>
<div className="tsb-seclabel">置顶</div>
@@ -173,11 +172,15 @@ export function TaskSidebar() {
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
)}
{rest.map(t => taskRow(t))}
</div>
<div className="tsb-seclabel">
体系工具
{pendingBadge > 0 && <span className="tsb-tool-badge" title="待处理">{pendingBadge}</span>}
</div>
{/* 底部(方案:文件、工作区、账户沉底)。 */}
<div className="tsb-bottom">
<button className="tsb-tool" title="文件搜索、预览、编辑与下载(第三栏「文件」入口)" onClick={() => store.openFiles()}>
<span className="tsb-tool-icon"><Icon name="folder" size={14} /></span>
<span className="tsb-tool-name">文件</span>
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
</button>
{tools.map(entry => (
<button key={entry.key} className="tsb-tool" title={entry.shortcut} onClick={() => store.openWorkspace(entry.key)}>
<span className="tsb-tool-icon">{entry.icon}</span>
@@ -209,6 +212,22 @@ export function TaskSidebar() {
</div>
{/* 弹出菜单们 */}
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
{projects.map(p => {
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
const pstage = projectStage(p.settings)
return (
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
{p.name}
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
</MenuItem>
)
})}
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
<div className="tsb-menu-div" />
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
</Menu>
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
<div className="tsb-menu-div" />
+3 -7
View File
@@ -1,20 +1,16 @@
// 顶栏:产品标识、当前项目与阶段徽章、待处理铃铛。不参与具体操作。
// 顶栏:产品标识与待处理铃铛。项目身份在左栏顶部切换器集中显示一次(方案),
// 阶段徽章随切换器;本栏不再重复。
import { BrandMark, Icon, pendingCount } from '@ui'
import { store, useStore } from '../store'
import { projectStage } from '../projectmeta'
export function TopBar() {
const project = useStore(s => s.projects.find(p => p.id === s.currentProjectId))
const pending = useStore(s => pendingCount(s.cards) + pendingCount(s.projectCards))
const stage = projectStage(project?.settings)
return (
<header className="topbar">
<BrandMark size={22} />
<span className="topbar-name">Agent-System</span>
{project && <span className="topbar-project" title="当前项目">{project.name}</span>}
{stage && <span className="topbar-stage" title="当前阶段">{stage}</span>}
<span className="topbar-spacer" />
<button className="topbar-bell" title={pending > 0 ? `待处理 ${pending},打开收件箱` : '收件箱'}
<button className="topbar-bell" title={pending > 0 ? `待处理 ${pending},打开工作入口` : '工作入口'}
onClick={() => store.focusPending()}>
<Icon name="inbox" size={16} />
{pending > 0 && <span className="rail-badge" data-testid="topbar-pending">{pending}</span>}
+48 -10
View File
@@ -1,12 +1,16 @@
// 右栏:卡片流。全部 / 待处理筛选、收纳、本会话与项目范围。
// 文件不占右栏页签——工件走体系工具(⌘)与预览区,一个对象一个入口。
// 右栏(第三栏):「工作 / 文件」双入口。
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon } from '@ui'
import { previewTabsKey } from '../previewtabs'
import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(预览区)。
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
export const docBridge: DocBridge = {
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
onOpenDoc: id => {
@@ -47,6 +51,7 @@ function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxF
}
export function Workbench() {
const rightTab = useStore(s => s.ui.rightTab)
const cards = useStore(s => s.cards)
const plan = useStore(s => s.plan)
const todos = useStore(s => s.todos)
@@ -55,24 +60,57 @@ export function Workbench() {
const filter = useStore(s => s.ui.inboxFilter)
const visible = inboxScope === 'project' ? projectCards : cards
const pending = pendingCount(visible)
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
const switchTab = (tab: 'work' | 'files') =>
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
return (
<>
<div className="tabs">
<button className={`tab ${filter === 'all' ? 'active' : ''}`} onClick={() => store.setUI({ inboxFilter: 'all' })}
title="全部卡片">全部</button>
<button className={`tab ${filter === 'todo' ? 'active' : ''}`} onClick={() => store.setUI({ inboxFilter: 'todo' })}
title="只看待处理">待处理{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
title="文件搜索、预览、编辑与下载">文件</button>
<span className="spacer" style={{ flex: 1 }} />
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
{rightTab === 'work' && (
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
)}
<button className="tab" title="收起右栏" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
</div>
{rightTab === 'files'
? <FilesTab />
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} />}
</>
)
}
/** 文件入口:有预览标签且处于预览子视图时展示预览;否则展示文件列表(搜索/上传)。 */
function FilesTab() {
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
const filesView = useStore(s => s.ui.filesView)
if (previewOpen && filesView === 'preview') return <PreviewPane />
return <div className="tab-body"><ArtifactsPanel /></div>
}
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
filter: InboxFilter
inboxScope: 'conv' | 'project'
projectCards: ActivityCard[]
cards: ActivityCard[]
}) {
return (
<>
<div className="tab-body" data-scroll-sync="inbox">
<CurrentTaskCard plan={plan} todos={todos} />
<div className="inbox-scope" role="tablist" aria-label="收件箱范围">
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
</button>
<span style={{ flex: 1 }} />
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
</div>
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} filter={filter} />
+1 -7
View File
@@ -1,11 +1,11 @@
// 工作区弹层(提案 T4/D25):深读经 OverlayHost 单层呈现。
// 服务端声明的工作区(领域插件槽位)对所有用户可见;插件管理面板仍只在系统管理区。
// 文件浏览已并入第三栏「文件」入口,本弹层只承载代理与领域工作区。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { OverlayHost, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { ArtifactsPanel } from './ArtifactsPanel'
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
@@ -15,7 +15,6 @@ export function workspaceEntries(): WorkspaceEntry[] {
export function Workspaces() {
const active = useStore(s => s.ui.activeWorkspace)
const artifacts = useStore(s => s.artifacts)
const runs = useStore(s => s.runs)
const pluginPanels = useStore(s => s.pluginPanels)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
@@ -23,11 +22,6 @@ export function Workspaces() {
const entry = workspaceEntries().find(e => e.key === active)
const close = () => store.closeWorkspace()
if (active === 'artifacts') {
return <OverlayHost title="工件" icon="📦" shortcut={entry?.shortcut ?? '⌘1'} sayHint="打开工件" badge={artifacts.length || undefined} onClose={close}>
<ArtifactsPanel />
</OverlayHost>
}
if (active === 'agents') {
const convRuns = runs.filter(r => r.kind !== 'tool')
return <OverlayHost title="代理" icon="🤖" shortcut={entry?.shortcut ?? '⌘2'} sayHint="打开代理" onClose={close}>
+53 -1
View File
@@ -60,6 +60,12 @@ export interface Usage {
promptTokens: number; completionTokens: number; totalTokens: number;
durationMs: number; toolCalls: number;
model?: string;
// N3 计费拆分(additive;旧记录缺省为 0):
// Last* 是最近一次请求的用量,Cache* 是缓存命中累计。
lastPromptTokens?: number;
lastCompletionTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
}
export interface Run {
@@ -93,7 +99,8 @@ export type StreamEventType =
| 'agent.spawn' | 'agent.progress' | 'agent.result'
| 'artifact.created' | 'artifact.updated'
| 'run.completed' | 'run.failed' | 'error'
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved';
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved'
| 'surface.updated';
export interface StreamEvent<P = unknown> {
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
@@ -179,6 +186,9 @@ export type UINodeType =
export interface UINode {
type: UINodeType;
/** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
id?: string;
source?: string[];
title?: string;
items?: string[];
kv?: [string, string][];
@@ -346,3 +356,45 @@ export interface UploadInfo {
artifactId: ID; filename: string; mime?: string; size: number;
parsed?: ParsedDocument;
}
// ---- Generative UI Surface(提案 U2/U4;与 Go surface.go 同步)----
export interface SurfaceCompat {
schemaVersion: number;
catalogVersion: string;
pluginVersion?: string;
}
export interface SurfaceIdentity {
surfaceId: ID;
owner: string;
projectId?: ID;
conversationId?: ID;
sourceRunId?: ID;
pluginId: string;
}
export interface SurfaceSpec {
title: string;
nodes: UINode[];
}
export interface SurfaceSnapshot {
identity: SurfaceIdentity;
compat: SurfaceCompat;
revision: number;
baseRevision?: number;
contextVersion?: string;
activationEpoch: number;
spec: SurfaceSpec;
semanticText?: string;
}
export interface SurfaceMeta {
surfaceId: ID;
title: string;
pluginId: string;
revision: number;
conversationId?: ID;
updatedAt: string;
}
+20
View File
@@ -25,3 +25,23 @@ export function closePreviewTab(
const neighbor = next[Math.min(i, next.length - 1)] ?? null
return { tabs: next, activeId: neighbor?.id ?? null }
}
/** 预览标签的会话键。空态(未选会话)统一落在 '_':写入(requestPreview)与
* 渲染(previewOpen/PreviewPane)共用同一口径,空态点开工件才能出现预览区。 */
export function previewTabsKey(currentConvId: string | null | undefined): string {
return currentConvId ?? '_'
}
/** 进入会话时把空态打开的孤儿标签('_' 键)并入会话键下;按 id 去重,
* 会话侧顺序在前,'_' 键清空。无孤儿或目标即 '_' 时原样返回。 */
export function mergeOrphanPreviewTabs(
tabsByConv: Record<string, PreviewTab[]>,
convId: string,
): Record<string, PreviewTab[]> {
const orphan = tabsByConv['_'] ?? []
if (orphan.length === 0 || convId === '_') return tabsByConv
const own = tabsByConv[convId] ?? []
const ids = new Set(own.map(t => t.id))
const merged = [...own, ...orphan.filter(t => !ids.has(t.id))]
return { ...tabsByConv, [convId]: merged, '_': [] }
}
+144 -29
View File
@@ -3,7 +3,7 @@
import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs'
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
@@ -16,8 +16,19 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
}
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
ActivityCard,
AgentProfile,
Artifact,
Conversation,
HITLCard,
Message,
PlanSnapshot,
PluginPanel,
Project,
Run,
StreamEvent,
SurfaceSnapshot,
TodoList
} from './contracts'
export interface ContextObs {
@@ -43,7 +54,10 @@ export interface UIState {
leftCollapsed: boolean
rightWidth: number
rightCollapsed: boolean
rightTab: string
/** 第三栏双入口:工作(当前任务/收件箱)/ 文件(搜索、预览、编辑、下载)。 */
rightTab: 'work' | 'files'
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
filesView: 'list' | 'preview'
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
inboxFilter: 'all' | 'todo'
theme: ThemeChoice
@@ -83,6 +97,8 @@ interface AppState {
plan: PlanSnapshot | null
todos: TodoList | null
artifacts: Artifact[]
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
surfaces: SurfaceSnapshot[]
cards: ActivityCard[]
projectCards: ActivityCard[]
inboxScope: 'conv' | 'project'
@@ -106,9 +122,10 @@ interface AppState {
const DEFAULT_UI: UIState = {
leftWidth: 260,
leftCollapsed: false,
rightWidth: 340,
rightWidth: 360,
rightCollapsed: false,
rightTab: 'inbox',
rightTab: 'work',
filesView: 'list',
inboxFilter: 'all',
activeWorkspace: null,
theme: 'dark',
@@ -134,6 +151,9 @@ function loadUI(): UIState {
if (!isThemeChoice(ui.theme)) ui.theme = 'dark'
if (!isFontSizeChoice(ui.fontSize)) ui.fontSize = 14
if (ui.inboxFilter !== 'all' && ui.inboxFilter !== 'todo') ui.inboxFilter = 'all'
// 旧本地偏好里存的是 'inbox'——第三栏双入口上线时归一到 'work'。
if (ui.rightTab !== 'work' && ui.rightTab !== 'files') ui.rightTab = 'work'
if (ui.filesView !== 'list' && ui.filesView !== 'preview') ui.filesView = 'list'
// 预览标签不进本地偏好。展开态若随刷新残留,会在没有标签时藏起对话。
ui.previewExpanded = false
return ui
@@ -142,11 +162,13 @@ function loadUI(): UIState {
return { ...DEFAULT_UI }
}
const surfaceActionsInflight: Record<string, boolean> = {}
let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], surfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
outbox: [], outboxDraining: false,
@@ -160,19 +182,14 @@ function set(patch: Partial<AppState>) {
listeners.forEach(l => l())
}
// 预览区打开前右栏是否收起。null = 当前没有因为预览而改过右栏。
let previewRightWasCollapsed: boolean | null = null
function syncPreviewRail(hasTabs: boolean) {
// 第三栏「文件」入口联动(方案:预览并入第三栏):开第一张标签自动切到文件入口的
// 预览子视图并展开右栏;关掉最后一张回到文件列表。不再收起右栏——预览与工作同居第三栏。
function syncFilesEntry(hasTabs: boolean) {
if (hasTabs) {
if (previewRightWasCollapsed === null) previewRightWasCollapsed = state.ui.rightCollapsed
if (!state.ui.rightCollapsed) setUI({ rightCollapsed: true })
setUI({ rightTab: 'files', rightCollapsed: false, filesView: 'preview' })
return
}
if (previewRightWasCollapsed === null) return
const restore = previewRightWasCollapsed
previewRightWasCollapsed = null
setUI({ rightCollapsed: restore })
if (state.ui.filesView === 'preview') setUI({ filesView: 'list' })
}
function setUI(patch: Partial<UIState>) {
@@ -184,6 +201,17 @@ function setUI(patch: Partial<UIState>) {
listeners.forEach(l => l())
}
// 会话恢复(空态点工件无反应的高频前置):currentConvId 不持久化时刷新必回空态,
// 空态下 requestPreview 的标签挂在 '_' 键、渲染判空键——点击只剩弹层关闭,没有任何反馈。
const CONV_KEY = 'as.conv'
let convRestoreDone = false
function persistConvId(id: string | null) {
try { id ? localStorage.setItem(CONV_KEY, id) : localStorage.removeItem(CONV_KEY) } catch { /* ignore */ }
}
function savedConvId(): string | null {
try { return localStorage.getItem(CONV_KEY) } catch { return null }
}
let systemQuery: MediaQueryList | null = null
let systemHandler: (() => void) | null = null
@@ -252,7 +280,18 @@ export const store = {
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
async refreshTasks(includeArchived = state.ui.showArchived) {
try { set({ tasks: await api.listTasks(includeArchived) }) } catch { /* 列表竞态,静默 */ }
try {
const tasks = await api.listTasks(includeArchived)
set({ tasks })
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
if (!convRestoreDone) {
convRestoreDone = true
const saved = savedConvId()
if (saved && !state.currentConvId && tasks.some(t => t.id === saved && !t.archived)) {
store.openConversation(saved)
}
}
} catch { /* 列表竞态,静默 */ }
},
async refreshMe() {
@@ -335,8 +374,18 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
syncPreviewRail((state.previewTabs[convId] ?? []).length > 0)
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
persistConvId(convId)
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
if ((state.previewTabs['_'] ?? []).length > 0) {
const active = state.previewActive[convId] ?? state.previewActive['_'] ?? null
set({
previewTabs: mergeOrphanPreviewTabs(state.previewTabs, convId),
previewActive: { ...state.previewActive, [convId]: active },
})
}
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
api.surfaceList(convId).then(list => { if (state.currentConvId === convId) set({ surfaces: list ?? [] }) }).catch(() => {})
const resumeSeq = await store.reconcile()
store.connectLive(resumeSeq ?? state.lastSeq)
},
@@ -458,6 +507,33 @@ export const store = {
set({ todos: p.list })
break
}
case 'surface.updated': {
// KTD8:单一状态所有者——服务端快照为权威,本地按 revision 守卫:
// 旧修订/重复事件不覆盖新修订;切会话由 selectConversation 清空。
const p = e.payload as SurfaceSnapshot
if (!p?.identity?.surfaceId) break
const id = String(p.identity.surfaceId)
const surfaces = state.surfaces.filter(x => String(x.identity.surfaceId) !== id)
const existing = state.surfaces.find(x => String(x.identity.surfaceId) === id)
if (!existing || p.revision > existing.revision) {
surfaces.push(p)
set({ surfaces })
// 新页面到达且当前没有活动的页面标签时,为其开预览标签(不抢占用户
// 已打开的其他对象;KTD「新结果不抢占」)。
const conv = previewTabsKey(state.currentConvId)
const tabs = state.previewTabs[conv] ?? []
const activeId = state.previewActive[conv] ?? null
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
if (!hasSurfaceTab || (activeId ?? '').startsWith('surface:')) {
const opened = openPreviewTab(tabs, { id: 'surface:' + id, name: p.spec?.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
}
}
break
}
case 'card.created':
case 'card.updated': {
const p = e.payload as { card: ActivityCard }
@@ -787,12 +863,14 @@ export const store = {
const conv = state.cards.filter(c => c.status === 'action_required').length
const proj = state.projectCards.filter(c => c.status === 'action_required').length
if (conv === 0 && proj > 0) store.setInboxScope('project')
setUI({ rightCollapsed: false, rightTab: 'inbox', inboxFilter: 'todo' })
setUI({ rightCollapsed: false, rightTab: 'work', inboxFilter: 'todo' })
},
openInbox(cardId?: string) {
setUI({ rightCollapsed: false, rightTab: 'inbox' })
setUI({ rightCollapsed: false, rightTab: 'work' })
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
},
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
openFiles() { setUI({ rightTab: 'files', rightCollapsed: false }) },
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
locateMessage(messageId?: string) {
if (!messageId) return
@@ -800,32 +878,69 @@ export const store = {
},
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
/**
* 工件预览唯一入口:在对话右侧预览区打开或激活标签。
* 打开第一张标签时收起右栏,关掉最后一张时恢复,并收回展开态。
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
*/
requestPreview(a: Artifact) {
const conv = state.currentConvId ?? '_'
const conv = previewTabsKey(state.currentConvId)
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
syncPreviewRail(true)
syncFilesEntry(true)
},
openSurface(surfaceId: string) {
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
const conv = previewTabsKey(state.currentConvId)
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
syncFilesEntry(true)
},
// U5:页面动作提交——requestId 由客户端生成(服务端幂等键);草稿只在此刻
// 随动作外发(未提交输入不上传、不进模型上下文)。在途防抖挡双击。
async handleSurfaceAction(surfaceId: string, actionId: string, data: Record<string, unknown>) {
const inflight = surfaceActionsInflight[surfaceId + ':' + actionId]
if (inflight) return
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
if (!snap) { store.toast('页面快照不可用,无法提交'); return }
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2))
surfaceActionsInflight[surfaceId + ':' + actionId] = true
try {
const res = await api.submitSurfaceAction(surfaceId, {
actionId, revision: snap.revision, requestId, data,
})
if (res?.state === 'awaiting_confirm') store.toast('高危动作已发确认卡,请确认后执行')
else if (res?.state === 'committed') store.toast('已提交')
else store.toast('动作状态:' + (res?.state ?? 'unknown'))
} catch (e: any) {
const msg = String(e?.message ?? e)
if (msg.includes('revision conflict')) store.toast('页面已更新,请刷新后重试')
else if (msg.includes('not enabled') || msg.includes('403')) store.toast('动作被拒绝:插件未启用')
else if (msg.includes('different params')) store.toast('重复请求参数不一致,已拒绝')
else store.toast('动作失败:' + msg)
} finally {
delete surfaceActionsInflight[surfaceId + ':' + actionId]
}
},
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
activatePreview(id: string) {
const conv = state.currentConvId ?? '_'
const conv = previewTabsKey(state.currentConvId)
set({ previewActive: { ...state.previewActive, [conv]: id } })
},
closePreview(id: string) {
const conv = state.currentConvId ?? '_'
const conv = previewTabsKey(state.currentConvId)
const next = closePreviewTab(state.previewTabs[conv] ?? [], state.previewActive[conv] ?? null, id)
set({
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
previewActive: { ...state.previewActive, [conv]: next.activeId },
})
if (next.tabs.length === 0) setUI({ previewExpanded: false })
syncPreviewRail(next.tabs.length > 0)
syncFilesEntry(next.tabs.length > 0)
},
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
+19 -10
View File
@@ -24,11 +24,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
border: 1px solid var(--border-sidebar);
}
.topbar-name { font-weight: 650; letter-spacing: .01em; }
.topbar-project { color: var(--text-sidebar-dim); font-size: var(--fs-ui-sm); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar-stage {
font-size: var(--fs-ui-xs); padding: 2px 8px; border-radius: var(--radius-full);
border: 1px solid var(--border-sidebar); color: var(--text-sidebar);
}
.topbar-spacer { flex: 1; }
.topbar-bell {
position: relative; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center;
@@ -56,8 +51,12 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
/* 作者 display 声明会压过 UA 的 [hidden],此处显式恢复;展开预览靠它隐藏对话列 */
.main[hidden] { display: none !important; }
/* 第三栏(工作|文件):文件入口承载预览,展开态铺满主区 */
.right-panel { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
.right-panel.expanded { flex: 1 1 auto; width: auto; }
.right-panel.collapsed { width: 44px !important; }
/* 预览嵌进第三栏后去掉独立面板外壳(圆角/描边由右栏提供,纸面仍用 --bg) */
.right-panel .preview-pane { flex: 1; min-height: 0; border: none; border-radius: 0; background: var(--bg); }
/* ---- 通用控件 ---- */
.btn {
@@ -196,9 +195,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tl-check { color: var(--text-dim); font-size: var(--fs-ui-sm); margin: 4px 0; }
.tl-tools .status-error { margin-left: 8px; color: var(--err-text); }
/* 对话右侧预览区 */
.preview-slot { flex-shrink: 0; min-width: 0; display: flex; }
.preview-slot.expanded { flex: 1 1 auto; width: auto; }
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .right-panel .preview-pane) */
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
.preview-pane-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border-soft); }
.preview-pane-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-sm); color: var(--text-dim); }
@@ -256,6 +253,11 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* 输入区:悬浮卡片式输入壳(16px 大圆角 = DESIGN.md 特批例外) */
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
/* 轻量状态提示行(快慢通道):一行以内,弱色;后台任务在回合结束后仍显示 */
.input-status { display: flex; align-items: center; gap: 8px; padding: 0 4px 6px; color: var(--text-faint); font-size: var(--fs-ui-sm); }
.input-status-note { color: var(--text-faint); }
.input-status-link { border: none; background: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; padding: 0; }
.input-status-link:hover { text-decoration: underline; }
.outbox-trigger { width: 100%; text-align: left; background: var(--bg-inset); color: var(--text-dim); border: 1px solid var(--border-soft); border-radius: var(--radius-xs); padding: 6px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
.outbox-list { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.outbox-item { display: flex; gap: 6px; align-items: center; background: var(--bg-inset); border-radius: var(--radius-xs); padding: 6px 8px; }
@@ -345,8 +347,13 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底;辅色为品牌令牌 --brand-2) ---- */
.brand-mark { background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
/* ---- 左栏:任务中心(方案 A;Zai 复刻重造型) ---- */
.tsb-brand { display: flex; align-items: center; gap: 9px; padding: 14px 14px 8px; }
/* ---- 左栏:项目导航(方案 A;三栏工作台重排:切换置顶、会话居中、文件沉底) ---- */
.tsb-brand { display: flex; align-items: center; gap: 6px; padding: 12px 12px 8px; }
.tsb-projrow { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; height: 32px; padding: 0 9px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); cursor: pointer; font-size: var(--fs-ui-caption); font-weight: 600; }
.tsb-projrow:hover { border-color: var(--accent-sidebar); }
.tsb-projrow-name { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-projrow-stage { flex: none; font-size: var(--fs-ui-xs); font-weight: 450; color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-full); padding: 0 6px; }
.tsb-projrow .tsb-user-chev { color: var(--text-faint); display: inline-flex; flex: none; }
.tsb-logo { width: 24px; height: 24px; border-radius: var(--radius); background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
.tsb-name { font-weight: 650; font-size: var(--fs-ui-caption); letter-spacing: -0.01em; }
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: var(--fs-ui-caption); display: inline-flex; align-items: center; justify-content: center; position: relative; }
@@ -404,6 +411,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tsb-row-act:hover { background: var(--bg-active); color: var(--text); }
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
/* 底部沉底区(方案:文件、工作区在账户上方) */
.tsb-bottom { display: flex; flex-direction: column; gap: 1px; padding: 6px 10px 2px; border-top: 1px solid var(--border-soft); }
.tsb-user { flex: 1; display: flex; align-items: center; gap: 9px; min-width: 0; height: 40px; padding: 0 7px; border: none; border-radius: var(--radius); background: none; cursor: pointer; text-align: left; transition: background .12s; }
.tsb-user:hover { background: var(--bg-hover); }
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.2); }
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { store } from './store'
import type { StreamEvent, SurfaceSnapshot } from './contracts'
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
function surfEvent(seq: number, surfaceId: string, revision: number, title: string): StreamEvent<SurfaceSnapshot> {
return {
seq, conversationId: 'c1', ts: new Date().toISOString(), v: 1, type: 'surface.updated',
payload: {
identity: { surfaceId, owner: 'alice', pluginId: 'generative-ui', conversationId: 'c1' },
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' },
revision, activationEpoch: 1,
spec: { title, nodes: [{ type: 'markdown', id: 'n1', markdown: 'x' }] },
},
}
}
describe('surface.updated projection', () => {
it('新事件入库;旧修订与重复事件不覆盖', () => {
store.applyEvent(surfEvent(1, 's1', 1, '初版'))
store.applyEvent(surfEvent(2, 's1', 2, '修订二'))
store.applyEvent(surfEvent(3, 's1', 1, '迟到的旧修订'))
store.applyEvent(surfEvent(4, 's1', 2, '重复事件'))
const list = store.getState().surfaces
expect(list).toHaveLength(1)
expect(list[0].revision).toBe(2)
expect(list[0].spec.title).toBe('修订二')
})
it('多页面并存,互不覆盖', () => {
store.applyEvent(surfEvent(5, 's2', 1, '页面二'))
const list = store.getState().surfaces
expect(list.filter(x => String(x.identity.surfaceId) === 's1')).toHaveLength(1)
expect(list.filter(x => String(x.identity.surfaceId) === 's2')).toHaveLength(1)
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
// U5 验收线第 4/6 条的客户端半边:请求构造只携带显式动作数据(未提交草稿
// 无任何自动上传路径——构造函数是纯函数,测试其形状);requestId 每次唯一。
function buildActionRequest(snapshot: { revision: number }, actionId: string, drafts: Record<string, unknown>) {
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now())
return {
actionId,
revision: snapshot.revision,
requestId,
data: drafts,
}
}
describe('surface action request', () => {
it('携带当前修订与显式草稿;requestId 唯一', () => {
const snap = { revision: 3 }
const a = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
const b = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
expect(a.revision).toBe(3)
expect(a.actionId).toBe('generative-ui.submit')
expect(a.data).toEqual({ f1: { years: '5' } })
expect(a.requestId).not.toBe(b.requestId)
})
})
+2 -3
View File
@@ -1,5 +1,6 @@
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
// 内置「工件」「代理」补进空位。插件管理面板不进这条清单。
// 内置「代理」补进空位。文件浏览在第三栏「文件」入口(方案:入口收敛),不再占体系工具位。
// 插件管理面板不进这条清单。
export interface WorkspaceEntry {
key: string
@@ -16,7 +17,6 @@ export interface WorkspaceSource {
}
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
{ key: 'artifacts', title: '工件', icon: '📦' },
{ key: 'agents', title: '代理', icon: '🤖' },
]
@@ -56,7 +56,6 @@ export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry
return 99
}
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
return ranked
.filter(item => item.rank <= 9)
+11
View File
@@ -40,4 +40,15 @@ describe('auth 凭证模块', () => {
expect(auth.apiKey()).toBe('')
expect(auth.withKey('/x')).toBe('/x')
})
it('beginSession:密码会话生效后清残留 Bearer,fetch 不再带头', async () => {
auth.setKey('ask_stale')
auth.beginSession()
expect(auth.apiKey()).toBe('')
const spy = vi.fn().mockResolvedValue(new Response('{}'))
vi.stubGlobal('fetch', spy)
await auth.fetch('/api/me')
const [, init] = spy.mock.calls[0] as [string, RequestInit | undefined]
expect(new Headers(init?.headers ?? {}).get('Authorization')).toBeNull()
})
})
+32
View File
@@ -0,0 +1,32 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
expect(humanLoginError('401: invalid owner or password')).toBe('用户名或密码不正确')
})
it('429/412 各有文案,其余剥状态码前缀', () => {
expect(humanLoginError('429: too many failed attempts; retry later')).toBe('失败次数过多,请稍后再试')
expect(humanLoginError('412: auth disabled (embedded single-tenant mode)')).toBe('本服务未开启登录(嵌入式单租户档)')
expect(humanLoginError('400: bad')).toBe('bad')
})
})
describe('humanChangePasswordError(改密门)', () => {
it('当前密码错误不再伪装成登录失败', () => {
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
})
it('无会话(API key 凭证)给出可行动指引', () => {
expect(humanChangePasswordError('401: session login required (API keys do not own passwords)')).toBe('请先用账号密码登录再改密(API 密钥不能修改密码)')
})
it('会话过期与失效 key 各有文案', () => {
expect(humanChangePasswordError('401: session expired')).toBe('会话已过期,请重新登录')
expect(humanChangePasswordError('401: unauthorized: invalid or revoked API key')).toBe('本地残留的 API 密钥已失效,请退出登录后重试')
})
it('未知 401 回落通用文案,其余错误剥前缀透传', () => {
expect(humanChangePasswordError('401: whatever')).toBe('认证失败,请重新登录')
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
})
})
+30
View File
@@ -0,0 +1,30 @@
// 空态点工件无反应(2026-10-10 实测复现):requestPreview 写 '_' 键、渲染判
// currentConvId 非空——键口径不一致导致空态点击只剩弹层关闭。这里测纯函数口径。
import { describe, expect, it } from 'vitest'
import { mergeOrphanPreviewTabs, previewTabsKey } from '../src/previewtabs'
describe('previewTabsKey', () => {
it('falls back to the empty-state key when no conversation is selected', () => {
expect(previewTabsKey(null)).toBe('_')
expect(previewTabsKey(undefined)).toBe('_')
expect(previewTabsKey('cnv_1')).toBe('cnv_1')
})
})
describe('mergeOrphanPreviewTabs', () => {
it('merges empty-state tabs into the conversation key and clears the orphan key', () => {
const got = mergeOrphanPreviewTabs({
_: [{ id: 'a', name: 'A.docx' }, { id: 'b', name: 'B.md' }],
cnv_1: [{ id: 'b', name: 'B.md' }],
}, 'cnv_1')
expect(got._).toEqual([])
expect(got.cnv_1.map(t => t.id)).toEqual(['b', 'a'])
})
it('returns the input untouched when there is no orphan or the target is the orphan key', () => {
const none = { cnv_1: [{ id: 'a', name: 'A' }] }
expect(mergeOrphanPreviewTabs(none, 'cnv_1')).toBe(none)
const self = { _: [{ id: 'a', name: 'A' }] }
expect(mergeOrphanPreviewTabs(self, '_')).toBe(self)
})
})
+6 -5
View File
@@ -1,18 +1,19 @@
import { describe, expect, it } from 'vitest'
import { buildWorkspaceEntries } from '../src/workspaces'
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口,体系工具只补「代理」。
describe('buildWorkspaceEntries', () => {
it('没有领域插件时,工件和代理补在 ⌘1 ⌘2', () => {
it('没有领域插件时,代理补在 ⌘1', () => {
const entries = buildWorkspaceEntries([])
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 工件', '⌘2 代理'])
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理'])
})
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
const entries = buildWorkspaceEntries([
{ id: 'trace', title: '追溯网', shortcut: '1' },
])
expect(entries.map(e => e.title)).toEqual(['追溯网', '工件', '代理'])
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理'])
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2'])
expect(entries[0].key).toBe('ws:trace')
})
@@ -20,6 +21,6 @@ describe('buildWorkspaceEntries', () => {
const entries = buildWorkspaceEntries([
{ id: 'mem', title: '记忆库' },
])
expect(entries.map(e => e.title)).toEqual(['工件', '代理', '记忆库'])
expect(entries.map(e => e.title)).toEqual(['代理', '记忆库'])
})
})