fix(changes): 会话事件改走宿主 changes.ops 路由 + diff 展开二级设置 + 视觉迭代

CI 修复(plugin-mount 的 ctx.sessions.get TypeError ×1003):
- 客户端 runtime 无事件日志面——badge/SessionLens 原在客户端直读
  ctx.sessions.get(id).events / ctx.on('session/event'),全部不可达。
- 新增宿主路由 changes.ops:live 会话日志优先,冷会话回放
  sessionPersistence.inspect;afterSeq 增量(缺省游标含 seq 0,
  显式游标为 seq > afterSeq,同 sidechat.events 约定),窗口上限
  4000 与客户端累加器一致。
- ChangesTab 轮询(mount 一拉 + visible 2.5s 增量)折入 ops,写
  模块级 opCounts 缓存供 badge 同步读取;SessionLens 改纯展示。

diff 展开二级设置(changesDiffFloat,默认浮窗):
- prefs 四处接线 + git tab settings select(浮窗/面板,图标选项卡)
- 预览面板「展开为 diff tab」按设置落点:浮窗居中弹出或源 pane
  下方 split;i18n 全 21 词典同步。

视觉迭代(真机挂载截图审查后调整):
- Session 视角:kind 徽章为左轨,大小+相对时间右对齐元列;失败
  操作行红色内嵌条;文件组头降为三级标签并加组间距。
- Git 视角:untracked '?' 徽章与新增同绿色;stage 按钮改 plus 图标。
- diff 折叠行:可展开折叠带 › 箭头 + 次级对比度,静态 gap 不再
  显示 pointer/hover;预览头路径改尾部截断(RTL 技巧,保文件名)。

验证:typecheck ✓ / vitest 1123 ✓ / pnpm test:mount(钉 alpha.2 CLI)
14/14 ✓;真机挂载截图(badge=5、双视角、浮窗展开)0 console error。
This commit is contained in:
Menghuan1918
2026-08-31 17:52:59 +08:00
parent 24a3813c63
commit f024fbf092
40 changed files with 456 additions and 112 deletions
+89 -12
View File
@@ -3,23 +3,45 @@
* (repository truth: staged/unstaged files, commit box, history) and the
* session round (agent truth: every file the model read, wrote, or edited).
* Both lenses preview their selections in a shared resizable bottom pane
* ({@link DiffPane}); git targets can expand into the dedicated diff tab.
* ({@link DiffPane}); git targets expand into the dedicated diff tab —
* docked in a pane, or floated as a free window per the tab's setting.
* The active lens and the pane height persist in the tab's meta, so the tab
* reopens exactly where it was left.
*
* The session events ride the host's `changes.ops` route (the client
* runtime exposes no event-log face): the tab pulls the delta past its
* cursor while visible, folds it into ops, and publishes the op count to a
* module-level cache the tab-strip badge reads.
*/
import { useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SidebarSessionEvent } from '../../context-types.ts'
import type { TabComponentProps } from '../service.ts'
import { t } from '../locales.ts'
import type { SidebarDiffRef } from '../state.ts'
import { api } from '../api.ts'
import { floatTab, type SidebarDiffRef } from '../state.ts'
import { GitLens } from './GitLens.tsx'
import { SessionLens } from './SessionLens.tsx'
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import type { FileOp } from './ops.ts'
import { extractFileOps, knownContentBefore, type FileOp } from './ops.ts'
import css from './changes.module.css'
/** The default preview pane height (px) before the first drag. */
const PANE_HEIGHT_DEFAULT = 300
/** Cap on accumulated events: the lens shows the recent window, not eternity
* (the host enforces the same bound per response). */
const EVENTS_CAP = 4000
/** Live op count per session: the tab's poller writes, the badge reads (a
* badge cannot fetch — it must resolve synchronously during render). */
const opCounts = new Map<string, number>()
/** The session's traced-op count as of the last poll (undefined before the
* tab has ever pulled; 0 hides the badge pill). */
export function opCountOf(sessionId: string): number | undefined {
return opCounts.get(sessionId)
}
type Lens = 'git' | 'session'
/** The persisted tab meta (JSON-serializable; rides the layout). */
@@ -36,6 +58,50 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
typeof meta.previewH === 'number' && meta.previewH >= 140 ? meta.previewH : PANE_HEIGHT_DEFAULT,
)
// ── Session-event accumulation: one pull on mount, then a 2.5s delta
// poll while visible (paused otherwise; the next visible tick catches
// up). The cursor is the last delivered seq, so each poll ships only
// what the accumulator lacks. ────────────────────────────────────────
const eventsRef = useRef<readonly SidebarSessionEvent[]>([])
const seqRef = useRef(0)
const pollGen = useRef(0)
const [opsError, setOpsError] = useState(false)
const [tick, setTick] = useState(0)
const pull = useCallback(async (): Promise<void> => {
const generation = pollGen.current
try {
const { events, lastSeq } = await api.changesOps(scope, seqRef.current)
if (generation !== pollGen.current) return
if (events.length > 0) {
const merged = [...eventsRef.current, ...events]
eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged
}
if (lastSeq > seqRef.current) seqRef.current = lastSeq
opCounts.set(scope.sessionId, extractFileOps(eventsRef.current).length)
setOpsError(false)
setTick(value => value + 1)
} catch {
// Offline / route unavailable: keep the last fold; surface it inline
// only while nothing has ever loaded.
if (generation === pollGen.current) setOpsError(true)
}
}, [scope.sessionId, scope.cwd])
useEffect(() => {
pollGen.current += 1
eventsRef.current = []
seqRef.current = 0
setOpsError(false)
}, [scope.sessionId])
useEffect(() => {
void pull()
if (!visible) return
const timer = window.setInterval(() => { void pull() }, 2_500)
return () => { window.clearInterval(timer) }
}, [visible, pull])
// tick only forces the re-render; the fold reads the ref directly.
void tick
const ops = extractFileOps(eventsRef.current)
/** Persist a meta patch onto the tab (lens choice, pane height). */
const patchMeta = (patch: ChangesMeta): void => {
ctx.get('betterSidebar')?.updateTab(tab.id, {
@@ -55,8 +121,22 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
}
/** Preview one session op (with its best-effort prior content snapshot). */
const previewOp = (path: string, op: FileOp, prior: string | undefined): void => {
setPreview({ kind: 'op', path, op, prior })
const previewOp = (path: string, op: FileOp): void => {
setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) })
}
/** Expand the current git preview into the dedicated diff tab: docked
* into the shell's diff pane, or floated as a free window centered on
* the viewport when the tab's diff-open setting asks for it (default). */
const expandPreview = (): void => {
if (preview?.kind !== 'git') return
const diffTab = diffTabOf(preview.ref)
onOpenDiff?.(diffTab)
if (store.getPrefs().changesDiffFloat !== false) {
const x = Math.round(window.innerWidth / 2)
const y = Math.round(window.innerHeight / 2)
store.reduce(state => floatTab(state, diffTab.id, x, y))
}
}
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
@@ -102,9 +182,8 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
)
: (
<SessionLens
ctx={ctx}
scope={scope}
visible={visible}
ops={ops}
loadError={opsError && ops.length === 0}
onPreview={previewOp}
selectedCallId={preview !== null && preview.kind === 'op' ? preview.op.callId : null}
/>
@@ -117,9 +196,7 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
height={paneHeight}
onHeightCommit={(height) => { setPaneHeight(height); patchMeta({ previewH: height }) }}
onClose={() => { setPreview(null) }}
onExpand={() => {
if (preview.kind === 'git') onOpenDiff?.(diffTabOf(preview.ref))
}}
onExpand={expandPreview}
/>
)}
</div>
+4 -4
View File
@@ -12,8 +12,8 @@
*/
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import {
Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
IconTrashOutline16, Input, Menu, Modal, writeClipboard,
Button, IconCodeOutline16, IconCopyOutline16, IconPlusOutline16,
IconRefreshOutline16, IconTrashOutline16, Input, Menu, Modal, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
import { api } from '../api.ts'
@@ -418,7 +418,7 @@ export function GitLens(props: GitLensProps) {
disabled={busy}
onClick={() => { void stageEntry(entry, staged) }}
>
{staged ? <IconTrashOutline16 /> : <IconBranchOutline16 />}
{staged ? <IconTrashOutline16 /> : <IconPlusOutline16 />}
</button>
</div>
)
@@ -596,7 +596,7 @@ export function GitLens(props: GitLensProps) {
: []),
fileMenu?.staged === true
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
: { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
: { id: 'stage', label: t('stage'), icon: <IconPlusOutline16 size={14} /> },
...(fileMenu !== null && !isUntracked(fileMenu.entry)
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
: []),
+27 -52
View File
@@ -1,62 +1,34 @@
/**
* The session lens of the changes tab: agent truth — every file the model
* read, wrote, or edited in this session (folded from the session event
* log), grouped by file, newest first, with kind filters. Clicking an op
* previews it in the tab's shared bottom pane (see {@link DiffPane}): writes
* and edits as line diffs, reads as a line-numbered content view, failures
* as their real error text. Live `tool/call` / `tool/result` events append
* while the tab is visible (bounded; paused and caught up when hidden).
* read, wrote, or edited in this session, grouped by file, newest first,
* with kind filters. Clicking an op previews it in the tab's shared bottom
* pane (see {@link DiffPane}): writes and edits as line diffs, reads as a
* line-numbered content view, failures as their real error text. The ops
* arrive pre-folded from the tab (which owns the event poll); this
* component is purely presentational.
*/
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import type { SidebarSessionEvent } from '../../context-types.ts'
import type { Context } from '../../context-types.ts'
import type { SessionScope } from '../api.ts'
import { useMemo, useState, type ReactNode } from 'react'
import { relativeTime, t } from '../locales.ts'
import { extractFileOps, groupByFile, knownContentBefore, type FileOp, type FileOpKind } from './ops.ts'
import { groupByFile, type FileOp, type FileOpKind } from './ops.ts'
import { formatBytes } from '../diff/rows.ts'
import css from './changes.module.css'
/** Cap on accumulated events: the lens shows the recent window, not eternity. */
const EVENTS_CAP = 4000
/** The op-kind filter chips: 'all' or one concrete kind. */
type OpFilter = 'all' | FileOpKind
export interface SessionLensProps {
ctx: Context
scope: SessionScope
/** Preview one op in the shared bottom pane (with its best-effort prior content). */
onPreview: (path: string, op: FileOp, prior: string | undefined) => void
/** The folded file operations, newest first (the tab's poll owns them). */
ops: readonly FileOp[]
/** Every poll failed and nothing loaded: an inline notice, not an empty
* state that would read as "no operations". */
loadError: boolean
/** Preview one op in the shared bottom pane. */
onPreview: (path: string, op: FileOp) => void
/** The op currently previewed (row highlight); null when the pane is closed. */
selectedCallId: string | null
/** Fold events only while the tab is actually visible. */
visible: boolean
}
export function SessionLens({ ctx, scope, onPreview, selectedCallId, visible }: SessionLensProps) {
// The event log snapshot seeds the lens once; live tool/call and
// tool/result events append (bounded) and re-extract while visible.
const eventsRef = useRef<readonly SidebarSessionEvent[] | null>(null)
if (eventsRef.current === null) {
eventsRef.current = ctx.sessions.get(scope.sessionId)?.events ?? []
}
const [tick, setTick] = useState(0)
useEffect(() => {
if (typeof ctx.on !== 'function') return
const off = ctx.on('session/event', (session: unknown, event: SidebarSessionEvent) => {
const sessionId = (session as { id?: unknown } | null)?.id
if (sessionId !== scope.sessionId) return
if (event.type !== 'tool/call' && event.type !== 'tool/result') return
const list = [...eventsRef.current ?? [], event]
eventsRef.current = list.length > EVENTS_CAP ? list.slice(list.length - EVENTS_CAP) : list
if (visible) setTick(value => value + 1)
})
return () => { off() }
}, [ctx, scope.sessionId, visible])
// Becoming visible again catches up on events appended while paused.
useEffect(() => { if (visible) setTick(value => value + 1) }, [visible])
const ops = useMemo(() => extractFileOps(eventsRef.current ?? []), [tick])
export function SessionLens({ ops, loadError, onPreview, selectedCallId }: SessionLensProps) {
const [filter, setFilter] = useState<OpFilter>('all')
const filteredOps = useMemo(
() => (filter === 'all' ? ops : ops.filter(op => op.kind === filter)),
@@ -91,7 +63,8 @@ export function SessionLens({ ctx, scope, onPreview, selectedCallId, visible }:
{chip('read', t('changesRead'), counts.get('read') ?? 0)}
</div>
<div className={css.sessionList}>
{ops.length === 0 && <div className={css.empty}>{t('changesSessionEmpty')}</div>}
{loadError && <div className={css.loadError}>{t('changesLoadError')}</div>}
{ops.length === 0 && !loadError && <div className={css.empty}>{t('changesSessionEmpty')}</div>}
{ops.length > 0 && filteredOps.length === 0 && <div className={css.empty}>{t('changesFilterEmpty')}</div>}
{[...groups.entries()].map(([path, fileOps]) => (
<div key={path} className={css.fileGroup}>
@@ -104,19 +77,21 @@ export function SessionLens({ ctx, scope, onPreview, selectedCallId, visible }:
data-op-kind={op.kind}
data-op-error={op.isError ? 'true' : undefined}
data-selected={selectedCallId === op.callId ? 'true' : undefined}
onClick={() => { onPreview(path, op, knownContentBefore(ops, path, op)) }}
onClick={() => { onPreview(path, op) }}
>
<span className={css.opKind} data-kind={op.kind}>
{t(op.kind === 'read' ? 'changesRead' : op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
<span className={css.opTime}>{relativeTime(new Date(op.time).toISOString())}</span>
{op.running && <span className={css.opFlag}>{t('changesRunning')}</span>}
{op.isError && <span className={css.opFlagError}>{t('changesError')}</span>}
{op.kind !== 'read' && !op.isError && (
<span className={css.opSize}>
{formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
</span>
)}
<span className={css.opMeta}>
{op.kind !== 'read' && !op.isError && (
<span className={css.opSize}>
{formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
</span>
)}
<span className={css.opTime}>{relativeTime(new Date(op.time).toISOString())}</span>
</span>
</button>
))}
</div>
+19 -6
View File
@@ -140,8 +140,9 @@
color: var(--dsw-alias-label-secondary);
}
/* Status-letter tint: A added / D deleted / M·R modified / C·U conflicts. */
.gitBadge[data-letter='A'] { color: var(--dsw-alias-state-success-primary); }
/* Status-letter tint: A added / D deleted / M·R modified / C·U conflicts;
untracked '?' reads as a not-yet-added file, so it shares the added tint. */
.gitBadge[data-letter='A'], .gitBadge[data-letter='?'] { color: var(--dsw-alias-state-success-primary); }
.gitBadge[data-letter='D'] { color: var(--dsw-alias-state-error-primary); }
.gitBadge[data-letter='M'], .gitBadge[data-letter='R'] { color: var(--dsw-alias-state-business-primary); }
.gitBadge[data-letter='C'], .gitBadge[data-letter='U'] { color: var(--dsw-alias-state-warn-primary); }
@@ -316,19 +317,28 @@
.sessionList { flex: 1; min-height: 0; overflow-y: auto; padding: 0 6px 10px; }
.empty { color: var(--dsw-alias-label-tertiary); padding: 24px 8px; text-align: center; }
.loadError { margin: 4px 8px; padding: 6px 10px; border-left: 3px solid var(--dsw-alias-state-warn-primary); border-radius: 0 6px 6px 0; color: var(--dsw-alias-label-secondary); font-size: 11px; }
.fileGroup { margin-bottom: 8px; }
.filePath { display: block; color: var(--dsw-alias-label-secondary); font-family: var(--ds-font-family-code, monospace); font-size: 11px; padding: 2px 4px; word-break: break-all; }
.fileGroup { margin-bottom: 6px; }
/* The group label sits between op blocks: extra head padding separates it from
the previous file's rows, the first group starts flush. */
.filePath { display: block; color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code, monospace); font-size: 11px; padding: 6px 4px 2px; word-break: break-all; }
.fileGroup:first-child .filePath { padding-top: 2px; }
.opRow { display: flex; align-items: center; gap: 6px; width: 100%; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 3px 5px; cursor: pointer; text-align: left; }
.opRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.opRow[data-selected='true'] { background: var(--dsw-alias-interactive-bg-active); }
/* A failed op carries a quiet error inset bar (the flag chip names it). */
.opRow[data-op-error='true'] { box-shadow: inset 2px 0 0 var(--dsw-alias-state-error-primary); }
.opKind { border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); flex: none; }
.opKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.opKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
/* Size + relative time ride as one right-aligned cluster so the kind chips
form a scannable left rail and the meta column stays flush right. */
.opMeta { margin-left: auto; display: inline-flex; align-items: baseline; gap: 8px; flex: none; min-width: 0; }
.opTime { color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; }
.opFlag { color: var(--dsw-alias-state-business-primary); font-size: 10px; flex: none; }
.opFlagError { color: var(--dsw-alias-state-error-primary); font-size: 10px; flex: none; }
.opSize { margin-left: auto; color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; }
.opSize { color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; font-family: var(--ds-font-family-code, monospace); }
/* ── Shared preview pane ───────────────────────────────────────────────── */
@@ -341,7 +351,10 @@
.diffKind[data-kind='git'] { color: var(--dsw-alias-brand-primary); }
.diffKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.diffKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
.diffPath { flex: 1; font-family: var(--ds-font-family-code, monospace); font-size: 11px; word-break: break-all; color: var(--dsw-alias-label-primary); min-width: 0; }
/* The path keeps its tail (the file name is the identity; directories are
context): an RTL paragraph renders an ASCII path in reading order while
clipping its HEAD under the ellipsis. */
.diffPath { flex: 1; direction: rtl; font-family: var(--ds-font-family-code, monospace); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-primary); min-width: 0; }
.diffStats { flex: none; display: inline-flex; gap: 5px; font-size: 10px; font-family: var(--ds-font-family-code, monospace); }
.iconButton {