feat(agent): add mobile swipe navigation to run timeline

This commit is contained in:
Darkatse 2026-06-17 02:32:03 +08:00
parent dcad6d14bf
commit c2d23fc2f7
6 changed files with 296 additions and 3 deletions

View file

@ -173,6 +173,7 @@ future APIs approveToolCall/readDiff/rollback throw explicitly
run timeline/detail view switching has a single explicit state source and does not derive detailsOpen from scrollLeft
run timeline main panel does not use horizontal scroll-snap or smooth scroll as a view state machine
run timeline closes detail by resetting detail state and does not measure or auto-stick hidden timeline scrollers
run timeline mobile view gesture uses Pointer Events only as an input shortcut and commits through openDetails/showTimeline
```
Provider normalizer tests 必须覆盖可见 reasoning 提取:Claude `thinking`、Gemini `thought` 文本、OpenAI Responses reasoning summary 进入 `reasoning_content`;signature / encrypted continuation 仍作为 native/provider state 保留,不能作为可展示文本。

View file

@ -23,7 +23,7 @@
- Handoff MVP 已落地:具备 `delegation.canHandoff` 且显式暴露 `agent.handoff` 的 foreground owner 可把同一 `AgentRun` 的下一阶段交给 `allowAsHandoffTarget` 的 target Profile。handoff 创建 `TransferControl` task 与 `Handoff` invocation,不进入后台 scheduler;当前 invocation 记为 `Transferred` 后,executor 在同一 run 内继续目标 Agent 的工具循环。已经 `workspace.commit` 过的 Agent 仍允许 handoff,最终 handoff owner 可继续修订、再次 commit 并 `workspace.finish`。实现框架、结构体与事件序列见 `docs/Agent/Handoff.md`。
- 当前工具循环是非 streaming;provider stream 仍不是 Agent timeline event。
- Agent System 扩展开关开启时,当前前端会把普通发送、regenerate 与 overswipe 新候选生成接入 Agent;实际 root run 使用扩展设置中的 `activeProfileId`。Profile 面板的 `editingProfileId` 只表示当前正在编辑的配置档案,不影响生成。Agent Mode off 时上游 SillyTavern 生成、事件和保存语义不变。
- Agent System 前端已提供 run timeline / detail panel;timeline 以 `readEvents(beforeSeq, limit)` 读取最新页并按需补拉更早 journal 页,默认视图只投影用户可见操作,DOM 使用窗口化渲染避免长 run 在低端移动设备上堆积节点;Timeline / Detail 使用 `detailsOpen` 单一事实源驱动互斥视图,不再依赖横向 scroll-snap、smooth scroll 或 scroll 事件反写状态,避免低端 WebView 在切换中进入半页卡住状态;关闭 Detail 会 reset detail state 并取消 stale detail load,Detail 打开期间不测量或贴底隐藏的 timeline scroller;详情面板顶部可拖动调整高度,高度仅作为扩展 UI 偏好保存,不进入 Agent Host ABI、journal 或 Rust runtime。前端实现将 timeline 数据会话、projection 规范化、详情读取、显示格式与 Vue 外壳拆分:运行中贴片、历史回看弹窗与 SubAgent 局部 timeline 共享同一 session/detail primitives,但各自仍通过 Host ABI 表达运行态、只读历史态与 `invocationId` 局部事件页。
- Agent System 前端已提供 run timeline / detail panel;timeline 以 `readEvents(beforeSeq, limit)` 读取最新页并按需补拉更早 journal 页,默认视图只投影用户可见操作,DOM 使用窗口化渲染避免长 run 在低端移动设备上堆积节点;Timeline / Detail 使用 `detailsOpen` 单一事实源驱动互斥视图,不再依赖横向 scroll-snap、smooth scroll 或 scroll 事件反写状态,避免低端 WebView 在切换中进入半页卡住状态;移动端横向滑动只作为 Pointer Events 输入快捷方式,最终仍调用 `openDetails()` / `showTimeline()`,并通过 `touch-action: pan-y pinch-zoom` 保留原生纵向滚动与缩放;关闭 Detail 会 reset detail state 并取消 stale detail load,Detail 打开期间不测量或贴底隐藏的 timeline scroller;详情面板顶部可拖动调整高度,高度仅作为扩展 UI 偏好保存,不进入 Agent Host ABI、journal 或 Rust runtime。前端实现将 timeline 数据会话、projection 规范化、详情读取、显示格式与 Vue 外壳拆分:运行中贴片、历史回看弹窗与 SubAgent 局部 timeline 共享同一 session/detail primitives,但各自仍通过 Host ABI 表达运行态、只读历史态与 `invocationId` 局部事件页。
- Agent run history 已提供只读 `listRuns()` / `list_agent_runs(dto)` 入口;首版从 `_tauritavern/agent-workspaces/index/runs/*.json` 读取 `AgentRun` 摘要,支持 `chatRef`、`stableChatId`、`statuses`、时间游标与 limit。列表会维护 `_tauritavern/agent-workspaces/index/run-summaries/<run-id>.json` projection,从 `chat_commit_completed.messageId` 派生提交当时的 `messageIndex`;该索引是提交时快照,不扫描聊天消息、不反查当前楼层。只有已经写入 terminal event 的终态 run 会复用/落盘 summary projection,运行中或终态事件尚未出现的窗口只即时投影。
- Agent run retention policy 已进入 `tauritavern-settings.agent.retention`:`auto_prune_enabled` 默认 `false`,控制 Rust 后端是否在 TauriTavern 进程运行期间周期性执行自动清理;`keep_recent_terminal_runs` 默认 100,表示保留最近 terminal run 的核心历史;`keep_full_recent_runs` 默认 20,表示其中保留完整 workspace/debug artifacts 的窗口。两个数量允许为 0,最大 10000,且 full 窗口必须是 history 窗口子集。前端 Host ABI 已提供 `api.agent.retention.readSettings()` / `updateSettings()` / `planPrune()` / `applyPrune()` facade,Agent System Runs 面板可保存策略、显示 dry-run 清理预览,并在用户确认后执行手动清理。后端已提供 `plan_agent_run_prune(dto)` 与 `apply_agent_run_prune(dto)` command,用当前设置或一次性 override 生成并执行 `slim_heavy_artifacts` / `delete_run` 候选;`apply` 使用显式 execution plan 模式,在后端重新规划全量候选,不信任前端预览明细,且同一服务实例内串行执行。自动清理由 `AgentRunRetentionAutomationService` 拥有生命周期,不依赖 Agent System 面板打开;保存 retention 设置只唤醒调度器重读配置,不同步执行清理。`slim_heavy_artifacts` 基于 Agent run storage class,路径归属词汇与 TT-Sync Agent run datasets 对齐,但 retention 不依赖同步 profile。明细受 `detailLimit` 限制但 totals 不截断,active run、缺失 terminal event、journal/storage 异常会进入 `blockedRuns`;单个执行失败会进入 `failedRuns`。
- 输入框 Agent 快捷开关 `#ttas_agent_send_toggle` 可以在 Agent System 扩展抽屉中隐藏;该状态只是扩展 UI 偏好,不改变 `agentModeEnabled`、生成路由、Agent Host ABI、journal 或 Rust runtime。

View file

@ -11,6 +11,14 @@ import {
RUN_TIMELINE_PAGE_STEP_PX,
runTimelineHeightBounds,
} from './run-timeline-resize.js';
import {
canStartRunTimelineViewGesture,
createRunTimelineViewGesture,
resolveRunTimelineViewGesture,
RUN_TIMELINE_VIEW_GESTURE_ACTION_DETAILS,
RUN_TIMELINE_VIEW_GESTURE_ACTION_TIMELINE,
shouldCancelRunTimelineViewGesture,
} from './run-timeline-view-gesture.js';
import {
projectAgentInvocations,
} from './run-invocation-projector.js';
@ -82,6 +90,7 @@ function createAgentRunTimelineApp(options = {}) {
resizeStartY: 0,
resizeStartHeightPx: 0,
resizeBounds: null,
viewGesture: null,
timelineScrollTop: 0,
timelineViewportHeight: 1,
unsubscribeSettings: null,
@ -288,6 +297,7 @@ function createAgentRunTimelineApp(options = {}) {
},
unmounted() {
this.stopResize(false);
this.cancelViewGesture();
this.$refs.subAgentDialog?.reset?.();
if (this.timelineProjectionRefreshTimer) {
clearTimeout(this.timelineProjectionRefreshTimer);
@ -328,6 +338,7 @@ function createAgentRunTimelineApp(options = {}) {
this.detailsOpen = false;
this.timelineScrollTop = 0;
this.subAgentTrayExpanded = false;
this.cancelViewGesture();
this.detail.reset();
this.$refs.subAgentDialog?.reset?.();
await this.loadRunHistory();
@ -514,6 +525,47 @@ function createAgentRunTimelineApp(options = {}) {
this.stickToBottomIfNeeded();
});
},
startViewGesture(event) {
if (this.viewGesture || !canStartRunTimelineViewGesture({
event,
target: event.target,
collapsed: this.collapsed,
resizing: this.resizing,
detailsOpen: this.detailsOpen,
selectedHasDetails: this.selectedHasDetails,
})) {
return;
}
this.viewGesture = createRunTimelineViewGesture(event, this.detailsOpen);
},
trackViewGesture(event) {
if (shouldCancelRunTimelineViewGesture(this.viewGesture, event)) {
this.cancelViewGesture(event);
}
},
finishViewGesture(event) {
const gesture = this.viewGesture;
if (!gesture || event.pointerId !== gesture.pointerId) {
return;
}
this.viewGesture = null;
const action = resolveRunTimelineViewGesture(gesture, event, {
detailsOpen: this.detailsOpen,
selectedHasDetails: this.selectedHasDetails,
});
if (action === RUN_TIMELINE_VIEW_GESTURE_ACTION_DETAILS) {
this.openDetails();
} else if (action === RUN_TIMELINE_VIEW_GESTURE_ACTION_TIMELINE) {
this.showTimeline();
}
},
cancelViewGesture(event = null) {
if (event && this.viewGesture && event.pointerId !== this.viewGesture.pointerId) {
return;
}
this.viewGesture = null;
},
onTimelineViewport(viewport) {
this.timelineScrollTop = viewport.scrollTop;
this.timelineViewportHeight = viewport.viewportHeight;
@ -759,7 +811,15 @@ function createAgentRunTimelineApp(options = {}) {
</div>
</header>
<div v-if="!collapsed" ref="panelBody" class="ttas-run-body">
<div
v-if="!collapsed"
ref="panelBody"
class="ttas-run-body"
@pointerdown.passive="startViewGesture"
@pointermove.passive="trackViewGesture"
@pointerup.passive="finishViewGesture"
@pointercancel.passive="cancelViewGesture"
>
<section
v-show="!detailsOpen"
class="ttas-run-view ttas-run-view-events"

View file

@ -0,0 +1,98 @@
export const RUN_TIMELINE_VIEW_GESTURE_ACTION_DETAILS = 'details';
export const RUN_TIMELINE_VIEW_GESTURE_ACTION_TIMELINE = 'timeline';
export const RUN_TIMELINE_VIEW_GESTURE_MIN_DISTANCE_PX = 64;
export const RUN_TIMELINE_VIEW_GESTURE_AXIS_RATIO = 1.5;
const TOUCH_POINTER_TYPE = 'touch';
const VERTICAL_CANCEL_DISTANCE_PX = 24;
const VERTICAL_CANCEL_AXIS_RATIO = 1.2;
const EXCLUDED_TARGET_SELECTOR = [
'.ttas-run-header',
'.ttas-run-resize-handle',
'.ttas-run-detail-head',
'.ttas-run-detail-nav',
'.ttas-run-detail-actions',
'.ttas-run-detail-block-head',
'.ttas-run-detail-block pre',
'.ttas-run-diff',
'.ttas-subagent-tray',
'a[href]',
'input',
'textarea',
'select',
'option',
'[contenteditable="true"]',
].join(', ');
function isPrimaryTouchPointer(event) {
return event?.isPrimary === true && event.pointerType === TOUCH_POINTER_TYPE;
}
function isGestureStartTarget(target) {
const element = typeof target?.closest === 'function' ? target : target?.parentElement;
return Boolean(element && typeof element.closest === 'function' && !element.closest(EXCLUDED_TARGET_SELECTOR));
}
function gestureDelta(gesture, event) {
return {
dx: event.clientX - gesture.startX,
dy: event.clientY - gesture.startY,
};
}
function hasHorizontalIntent(dx, dy) {
return Math.abs(dx) >= RUN_TIMELINE_VIEW_GESTURE_MIN_DISTANCE_PX
&& Math.abs(dx) >= Math.abs(dy) * RUN_TIMELINE_VIEW_GESTURE_AXIS_RATIO;
}
export function canStartRunTimelineViewGesture({
event,
target,
collapsed,
resizing,
detailsOpen,
selectedHasDetails,
} = {}) {
if (!isPrimaryTouchPointer(event) || collapsed || resizing || !isGestureStartTarget(target)) {
return false;
}
return Boolean(detailsOpen || selectedHasDetails);
}
export function createRunTimelineViewGesture(event, detailsOpen) {
return {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
detailsOpen: Boolean(detailsOpen),
};
}
export function shouldCancelRunTimelineViewGesture(gesture, event) {
if (!gesture || event?.pointerId !== gesture.pointerId) {
return false;
}
const { dx, dy } = gestureDelta(gesture, event);
const absX = Math.abs(dx);
const absY = Math.abs(dy);
return absY >= VERTICAL_CANCEL_DISTANCE_PX && absY > absX * VERTICAL_CANCEL_AXIS_RATIO;
}
export function resolveRunTimelineViewGesture(gesture, event, {
detailsOpen,
selectedHasDetails,
} = {}) {
if (!gesture || event?.pointerId !== gesture.pointerId || Boolean(detailsOpen) !== gesture.detailsOpen) {
return null;
}
const { dx, dy } = gestureDelta(gesture, event);
if (!hasHorizontalIntent(dx, dy)) {
return null;
}
if (gesture.detailsOpen) {
return dx > 0 ? RUN_TIMELINE_VIEW_GESTURE_ACTION_TIMELINE : null;
}
return dx < 0 && selectedHasDetails ? RUN_TIMELINE_VIEW_GESTURE_ACTION_DETAILS : null;
}

View file

@ -635,6 +635,14 @@ body:is([data-generating="true"], [data-swiping="true"]) .ttas-agent-send-toggle
overscroll-behavior: contain;
}
@media (pointer: coarse) {
.ttas-run-body,
.ttas-run-event-scroll,
.ttas-run-detail-scroll {
touch-action: pan-y pinch-zoom;
}
}
.ttas-run-detail-scroll {
flex: 1;
height: auto;

View file

@ -448,11 +448,31 @@ test('Agent run timeline view switching is not driven by horizontal scroll state
assert.doesNotMatch(panelSource, /@scroll\.passive="onPagesScroll"/);
assert.match(panelSource, /v-show="!detailsOpen"/);
assert.match(panelSource, /v-if="detailsOpen"/);
assert.match(panelSource, /run-timeline-view-gesture\.js/);
assert.match(panelSource, /@pointerdown\.passive="startViewGesture"/);
assert.match(panelSource, /@pointermove\.passive="trackViewGesture"/);
assert.match(panelSource, /@pointerup\.passive="finishViewGesture"/);
assert.match(panelSource, /@pointercancel\.passive="cancelViewGesture"/);
const showTimelineSource = sourceBetween(
panelSource,
'showTimeline() {',
'onTimelineViewport(viewport) {',
'startViewGesture(event) {',
);
const startGestureSource = sourceBetween(
panelSource,
'startViewGesture(event) {',
'trackViewGesture(event) {',
);
const trackGestureSource = sourceBetween(
panelSource,
'trackViewGesture(event) {',
'finishViewGesture(event) {',
);
const finishGestureSource = sourceBetween(
panelSource,
'finishViewGesture(event) {',
'cancelViewGesture(event = null) {',
);
const measureTimelineSource = sourceBetween(
panelSource,
@ -465,15 +485,121 @@ test('Agent run timeline view switching is not driven by horizontal scroll state
'async loadDetails() {',
);
assert.match(showTimelineSource, /this\.detailsOpen = false;\s*this\.detail\.reset\(\);/);
assert.doesNotMatch(startGestureSource, /preventDefault/);
assert.doesNotMatch(trackGestureSource, /preventDefault/);
assert.doesNotMatch(finishGestureSource, /preventDefault/);
assert.match(finishGestureSource, /this\.openDetails\(\);/);
assert.match(finishGestureSource, /this\.showTimeline\(\);/);
assert.match(measureTimelineSource, /if \(this\.collapsed \|\| this\.detailsOpen\) \{\s*return;\s*\}/);
assert.match(stickToBottomSource, /if \(!this\.autoStick \|\| this\.collapsed \|\| this\.detailsOpen\) \{\s*return;\s*\}/);
assert.doesNotMatch(styleSource, /\.ttas-run-pages/);
assert.doesNotMatch(styleSource, /scroll-snap-type/);
assert.doesNotMatch(styleSource, /scroll-snap-align/);
assert.match(styleSource, /touch-action:\s*pan-y pinch-zoom;/);
assert.match(styleSource, /\.ttas-run-view/);
});
test('Agent run timeline mobile view gesture commits only conservative touch intent', async () => {
const gesture = await importFresh(
'src/scripts/extensions/agent-system/src/run-timeline-view-gesture.js',
);
const target = { closest: () => null };
const textTarget = { parentElement: target };
const detailActionTarget = {
closest(selector) {
return selector.includes('.ttas-run-detail-actions') ? {} : null;
},
};
const touch = (clientX, clientY, overrides = {}) => ({
pointerId: 7,
pointerType: 'touch',
isPrimary: true,
clientX,
clientY,
...overrides,
});
assert.equal(gesture.canStartRunTimelineViewGesture({
event: touch(200, 20),
target,
collapsed: false,
resizing: false,
detailsOpen: false,
selectedHasDetails: true,
}), true);
assert.equal(gesture.canStartRunTimelineViewGesture({
event: touch(200, 20),
target: textTarget,
collapsed: false,
resizing: false,
detailsOpen: false,
selectedHasDetails: true,
}), true);
assert.equal(gesture.canStartRunTimelineViewGesture({
event: touch(200, 20, { pointerType: 'mouse' }),
target,
collapsed: false,
resizing: false,
detailsOpen: false,
selectedHasDetails: true,
}), false);
assert.equal(gesture.canStartRunTimelineViewGesture({
event: touch(200, 20, { isPrimary: false }),
target,
collapsed: false,
resizing: false,
detailsOpen: false,
selectedHasDetails: true,
}), false);
assert.equal(gesture.canStartRunTimelineViewGesture({
event: touch(200, 20),
target,
collapsed: false,
resizing: false,
detailsOpen: false,
selectedHasDetails: false,
}), false);
assert.equal(gesture.canStartRunTimelineViewGesture({
event: touch(200, 20),
target: detailActionTarget,
collapsed: false,
resizing: false,
detailsOpen: true,
selectedHasDetails: false,
}), false);
const timelineSwipe = gesture.createRunTimelineViewGesture(touch(220, 30), false);
assert.equal(gesture.resolveRunTimelineViewGesture(timelineSwipe, touch(150, 34), {
detailsOpen: false,
selectedHasDetails: true,
}), gesture.RUN_TIMELINE_VIEW_GESTURE_ACTION_DETAILS);
assert.equal(gesture.resolveRunTimelineViewGesture(timelineSwipe, touch(150, 34), {
detailsOpen: false,
selectedHasDetails: false,
}), null);
assert.equal(gesture.resolveRunTimelineViewGesture(timelineSwipe, touch(170, 88), {
detailsOpen: false,
selectedHasDetails: true,
}), null);
assert.equal(gesture.resolveRunTimelineViewGesture(timelineSwipe, touch(150, 34), {
detailsOpen: true,
selectedHasDetails: true,
}), null);
const detailSwipe = gesture.createRunTimelineViewGesture(touch(80, 30), true);
assert.equal(gesture.resolveRunTimelineViewGesture(detailSwipe, touch(154, 33), {
detailsOpen: true,
selectedHasDetails: false,
}), gesture.RUN_TIMELINE_VIEW_GESTURE_ACTION_TIMELINE);
assert.equal(gesture.resolveRunTimelineViewGesture(detailSwipe, touch(8, 33), {
detailsOpen: true,
selectedHasDetails: false,
}), null);
assert.equal(gesture.shouldCancelRunTimelineViewGesture(detailSwipe, touch(84, 72)), true);
assert.equal(gesture.shouldCancelRunTimelineViewGesture(detailSwipe, touch(150, 60)), false);
});
test('Agent run timeline refresh predicates use narrated model turns explicitly', async () => {
const panelSource = await readFile(path.join(
REPO_ROOT,