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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user