@sanqianx/claude-ai-workbench 0.13.2-rc.13 → 0.13.2-rc.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,11 +10,17 @@
10
10
 
11
11
  重复收到当前项目的嵌入通知不会取消正在创建的会话;真正切换项目时仍隔离迟到的响应和草稿。项目查找尚未完成时,以最新的选择为准。
12
12
 
13
+ Agent 发现为异步扫描,最多两个并发、每项 5 秒、整轮 12 秒;`GET /agents` 立即返回缓存及 `scan` 状态、代次和更新时间,`POST /agents/refresh` 强制发起或合并后台扫描并立即返回 202。不确定的超时保留上次探测结果,不误报卸载。界面先绑定控件再独立读取数据,手动重扫、页面重新可见和联网恢复可合并刷新;`kb:visibility` / `kb:refresh` 回执为 `kb:refreshed`,不重载 iframe、不清空草稿或运行会话。嵌入时可用 `transport=live` 选择同源外壳注入的 EventSource 兼容传输;独立运行保留原 SSE。
14
+
13
15
  受信任本地外壳通过 `KB_WORKBENCH_RUNTIME_TOKEN`、`KB_EVENT_BRIDGE_URL` 和引擎地址连接后台服务。原生工作台捕获原始用户输入及成功最终回答,通过持久化 outbox 投递到 bridge 的鉴权入口;不捕获系统提示词、注入的知识、工具输出或供应商凭据。`/api/health` 中 `conversationCapture` 展示启用状态、待投递数和错误;已有用户显式关闭的捕获设置不会被覆盖。桥接不可用时保留待投递记录,恢复或重启后继续,不伪造会话。
14
16
 
15
17
  工作台是产品供应商与凭据的活动配置源。后台维护默认模型持久化,不随会话临时模型切换;解析后的凭据只交给鉴权本地服务,不进入浏览器消息。SDK 会话从当前项目登记的工作区恢复,也兼容旧登记文件。
16
18
 
17
- 四客户端的会话历史使用同目录临时文件原子替换。磁盘写满、临时写入失败或 Windows 拒绝替换时保留上一次完整记录,不截断或删除原历史;空间恢复后新会话仍可正常持久化。已经被旧版截断的历史不能凭空恢复。
19
+ 四客户端的运行快照使用同目录临时文件原子替换;5000 条实时事件只用于短期回放,不再作为完整会话历史。Workbench 在自己的数据目录 `session-history/` 中独立追加保存历史,并合并连续文字增量;页面先读取近期 20 轮,向上翻阅或点击“加载更早记录”时按轮分页,补入记录与断线恢复均保持阅读位置。浏览器关闭不停止历史采集。磁盘写入失败或记录损坏时保留已有数据,在页面明确提示不完整并写入本地诊断,实时对话不因此中断。
20
+
21
+ 旧版已截断的 Claude 会话在原始 CLI 记录仍完整且会话空闲时,可只读恢复用户问答与工具过程,包括沿上下文压缩的逻辑父节点恢复早期记录;不修改原始文件、不调用模型。原始记录不存在、损坏或链路不完整时,只展示实际保存的部分并明确提示,不能凭空补造历史。新历史接口为 `GET /sessions/{sessionId}/history?limit=20&before=<cursor>`,Client SDK 对应 `loadHistory()`;现有快照与 SSE 游标保持兼容。
22
+
23
+ 持续输出时,向上的滚轮或翻页键立即停止自动跟随;发送请求的迟到回执不会重新拉回底部。向下滚回底部或点击“回到底部”后恢复跟随,无需切换会话。
18
24
 
19
25
  MiniMax 向量模型使用独立的 `/v1/embeddings` 接口,不复用聊天的 `/anthropic` 路由。官方域名的已知接口后缀在运行期规范化为服务根地址,现有配置文件不被迁移或改写;自定义网关路径保持原样。
20
26
 
@@ -147,6 +147,10 @@ body.light .top-strip .st { color:#2f5fd0; }
147
147
 
148
148
  /* ===== 消息流(ZCode 风格:过程折叠,正文直出)===== */
149
149
  .stream { flex:1; overflow-y:auto; padding:8px 24px 24px; }
150
+ .stream:focus { outline:none; }
151
+ .history-bar { max-width:860px; margin:0 auto 16px; display:flex; align-items:center; justify-content:center; gap:8px; color:var(--muted); font-size:12px; }
152
+ .history-bar[hidden], .history-bar [hidden] { display:none; }
153
+ .history-bar button:disabled { opacity:.6; cursor:wait; }
150
154
  .col { max-width:860px; margin:0 auto; }
151
155
  .empty-hint { text-align:center; color:var(--muted); margin-top:18vh; font-size:13px; line-height:1.7; }
152
156
  .empty-hint .big { display:flex; align-items:center; justify-content:center; margin-bottom:12px; }
@@ -14,6 +14,7 @@ let selectionEpoch = 0;
14
14
  let sessionEpoch = 0;
15
15
  let sessionCreationEpoch = 0;
16
16
  let pendingEmbedSelection = null;
17
+ let historyView = null;
17
18
  const projectDrafts = new Map();
18
19
  function savedProjectDraft(projectId, text) {
19
20
  try {
@@ -51,8 +52,12 @@ function forgetSessionSelection(sessionId) {
51
52
  }
52
53
  }
53
54
 
55
+ function embeddedTransport() {
56
+ try { if (embedded && embedParams.get('transport') === 'live' && window.parent.location.origin === location.origin && window.parent.KnowledgeLive) return { EventSource: window.parent.KnowledgeLive.EventSource }; } catch { /* Cross-origin hosts retain standalone SSE. */ }
57
+ return {};
58
+ }
54
59
  const client = window.createClaudeWorkbenchClient
55
- ? window.createClaudeWorkbenchClient({ apiBase })
60
+ ? window.createClaudeWorkbenchClient({ apiBase, ...embeddedTransport() })
56
61
  : null;
57
62
 
58
63
  const PERMISSION_LABELS = { default: '默认防护', acceptEdits: '接受编辑', plan: '计划模式', bypassPermissions: '完全访问' };
@@ -61,6 +66,8 @@ const AGENT_LABELS = { 'claude-code': 'Claude Code', codex: 'Codex', opencode: '
61
66
 
62
67
  const state = {
63
68
  agents: [],
69
+ agentsScan: { state: 'idle' },
70
+ agentsError: false,
64
71
  projects: [],
65
72
  profiles: [],
66
73
  profilesStatus: 'idle', // idle | loading | ready | error (error keeps the last good list)
@@ -78,6 +85,7 @@ const state = {
78
85
  attachments: [],
79
86
  busy: false,
80
87
  follow: true, // auto-scroll sticks to bottom until the user scrolls away
88
+ scrollIntent: null,
81
89
  replaying: false, // true while replaying a session snapshot: no live timers/durations
82
90
  expanded: new Set(),
83
91
  caps: null,
@@ -128,7 +136,7 @@ function applyPreferences(values) {
128
136
 
129
137
  async function loadPreferences() {
130
138
  try {
131
- const response = await fetch(`${apiBase}/ui-preferences`);
139
+ const response = await fetch(`${apiBase}/ui-preferences`, { signal: AbortSignal.timeout(3000) });
132
140
  if (!response.ok) return;
133
141
  const body = await response.json();
134
142
  savedPreferences = { ...(body && body.values || {}) };
@@ -154,14 +162,6 @@ function savePreferences(partial) {
154
162
  /* ===================== init ===================== */
155
163
 
156
164
  async function init() {
157
- try {
158
- const health = await client.health();
159
- $('app-version').textContent = `v${health.version}`;
160
- $('about-text').textContent = `Agent Terminal v${health.version} · API ${health.apiVersion} · ${health.sessions} 个会话`;
161
- } catch { /* server unreachable is surfaced by the panels themselves */ }
162
- await Promise.all([refreshAgents(), refreshProfiles()]);
163
- await loadPreferences();
164
- await refreshProjects();
165
165
  wireChrome();
166
166
  wireComposer();
167
167
  wireSettings();
@@ -169,6 +169,13 @@ async function init() {
169
169
  $('view-terminal').inert = false;
170
170
  $('view-terminal').setAttribute('aria-busy', 'false');
171
171
  $('view-settings').inert = false;
172
+ const healthJob = client.health().then(health => {
173
+ $('app-version').textContent = `v${health.version}`;
174
+ $('about-text').textContent = `Agent Terminal v${health.version} · API ${health.apiVersion} · ${health.sessions} 个会话`;
175
+ }).catch(() => {});
176
+ const preferences = loadPreferences();
177
+ const projects = preferences.then(() => refreshProjects()).catch(error => toast(`项目读取失败:${error.message}`, true));
178
+ await Promise.allSettled([healthJob, refreshAgents(), refreshProfiles(), projects]);
172
179
  if (!state.activeSessionId) $('session-title').textContent = '选择或创建一个会话';
173
180
  // Ready is the usable-page boundary: the host may immediately select a
174
181
  // project or open settings, so data and handlers must both be available.
@@ -177,11 +184,21 @@ async function init() {
177
184
 
178
185
  /* ===================== agents & capabilities ===================== */
179
186
 
180
- async function refreshAgents() {
181
- try { state.agents = await client.listAgents(); } catch { state.agents = []; }
187
+ let agentsRefreshJob = null, agentsPollTimer = null;
188
+ function refreshAgents(force = false) {
189
+ if (agentsRefreshJob) return force ? agentsRefreshJob.then(() => refreshAgents(true)) : agentsRefreshJob;
190
+ agentsRefreshJob = (async () => {
191
+ try {
192
+ const result = force && client.refreshAgents ? await client.refreshAgents() : client.agentStatus ? await client.agentStatus() : { agents: await client.listAgents() };
193
+ state.agents = result.agents || []; state.agentsScan = result.scan || { state: 'ready' }; state.agentsError = false;
194
+ } catch { state.agentsError = true; }
182
195
  renderAgentSummary();
183
196
  renderAgentCards();
184
197
  applyCapabilityGates();
198
+ clearTimeout(agentsPollTimer);
199
+ if (state.agentsScan.state === 'scanning') agentsPollTimer = setTimeout(() => refreshAgents(), 500);
200
+ })().finally(() => { agentsRefreshJob = null; });
201
+ return agentsRefreshJob;
185
202
  }
186
203
 
187
204
  function renderAgentSummary() {
@@ -191,7 +208,8 @@ function renderAgentSummary() {
191
208
  dot.style.background = available.length ? 'var(--good)' : 'var(--bad)';
192
209
  $('agent-summary').textContent = available.length
193
210
  ? `已发现 ${available.length} 个 Agent:${available.map(a => a.label).join(' · ')}`
194
- : '未发现可用 Agent';
211
+ : state.agentsScan.state === 'scanning' ? '正在检查 Agent…' : state.agentsError ? 'Agent 状态读取失败,请重试' : '未发现可用 Agent';
212
+ if (available.length && (state.agentsError || state.agentsScan.state === 'scanning')) $('agent-summary').textContent += state.agentsError ? ' · 刷新失败,显示上次结果' : ' · 正在检查';
195
213
  }
196
214
 
197
215
  function renderAgentCards() {
@@ -203,7 +221,7 @@ function renderAgentCards() {
203
221
  const body = el('div', 'a-body');
204
222
  const name = el('div', 'a-name');
205
223
  name.append(el('span', null, agent.label));
206
- const chip = el('span', 'chip ' + (agent.available ? 'good' : ''), agent.available ? (agent.version ? `v${agent.version}` : '可用') : '不可用');
224
+ const chip = el('span', 'chip ' + (agent.available ? 'good' : ''), agent.scanState === 'unknown' ? '待检查' : agent.scanState === 'error' ? '检查未完成' : agent.available ? (agent.version ? `v${agent.version}` : '可用') : '不可用');
207
225
  name.append(chip);
208
226
  body.append(name);
209
227
  body.append(el('div', 'a-meta', `${agent.transport} · 认证:${agent.authModes.join(' / ')}${agent.reason ? ` · ${agent.reason}` : ''}`));
@@ -487,7 +505,11 @@ function renderModelMenu() {
487
505
  /* ===================== projects & sidebar ===================== */
488
506
 
489
507
  async function refreshProjects(keepActive) {
490
- try { state.projects = await client.listProjects(); } catch { state.projects = []; }
508
+ const epoch = selectionEpoch;
509
+ let projects;
510
+ try { projects = await client.listProjects(); } catch { toast('项目读取失败,保留上次内容', true); return; }
511
+ if (epoch !== selectionEpoch) return;
512
+ state.projects = projects;
491
513
  if (!keepActive || !state.projects.some(p => p.id === state.activeProjectId)) {
492
514
  state.activeProjectId = state.projects.length ? state.projects[0].id : null;
493
515
  if (state.activeProjectId && !state.expanded.has(state.activeProjectId)) state.expanded.add(state.activeProjectId);
@@ -703,6 +725,15 @@ async function openSession(sessionId, options = {}) {
703
725
  let snapshot;
704
726
  try { snapshot = await client.loadSession(sessionId); } catch (error) { if (epoch !== sessionEpoch) return; state.activeSessionId = null; if (error.status === 404) forgetSessionSelection(sessionId); toast(error.message, true); return; }
705
727
  if (epoch !== sessionEpoch || state.activeSessionId !== sessionId) return;
728
+ let page = null, historyError = false;
729
+ if (typeof client.loadHistory === 'function') {
730
+ try { page = await client.loadHistory(sessionId); } catch { historyError = true; }
731
+ if (epoch !== sessionEpoch || state.activeSessionId !== sessionId) return;
732
+ }
733
+ historyView = { sessionId, epoch, events: [...(page?.events || snapshot.events || [])],
734
+ nextCursor: page?.nextCursor || null, incomplete: page?.incomplete === true, error: historyError,
735
+ supported: typeof client.loadHistory === 'function', loading: false, revision: 0 };
736
+ renderHistoryBar();
706
737
  const summary = snapshot.session;
707
738
  state.sessionSummary = summary;
708
739
  state.activeProjectId = summary.contextId || state.activeProjectId;
@@ -716,15 +747,16 @@ async function openSession(sessionId, options = {}) {
716
747
  renderModeLabel();
717
748
  $('session-title').textContent = sessionTitle(summary);
718
749
  setBusyState(summary.state);
719
- let last = 0;
750
+ let last = Number(page?.highWater) || 0;
751
+ let streamOpened = false;
720
752
  let replayThrough = 0;
721
753
  // Timers and per-step durations are wall-clock side effects; replayed
722
754
  // snapshots did not witness them, so the stream must not fake them.
723
755
  state.replaying = true;
724
756
  try {
725
- for (const event of snapshot.events || []) { renderEvent(event); last = Math.max(last, event.sequence || 0); }
757
+ for (const event of historyView.events) { renderEvent(event); if (!page) last = Math.max(last, event.sequence || 0); }
726
758
  } finally { state.replaying = false; }
727
- rememberTitleFromEvents(snapshot.events || []);
759
+ rememberTitleFromEvents(historyView.events);
728
760
  // The SSE replay window can overlap the snapshot (a slow turn raced the
729
761
  // open, or the server answered a stale cursor with a full replay). Late or
730
762
  // duplicate events are dropped by session sequence so each renders once.
@@ -735,6 +767,13 @@ async function openSession(sessionId, options = {}) {
735
767
  state.unsub = client.subscribe(sessionId, event => {
736
768
  if (epoch !== sessionEpoch || state.activeSessionId !== sessionId) return;
737
769
  if (event.type === 'workbench/resync') {
770
+ if (historyView?.supported && !historyView.error) {
771
+ last = Number(event.highWater) || 0;
772
+ // The replay cache is not conversation truth. Re-read durable history
773
+ // on a gap without replacing the full transcript with its 5000-event tail.
774
+ void reloadHistory(historyView);
775
+ return;
776
+ }
738
777
  last = 0;
739
778
  replayThrough = Number(event.highWater);
740
779
  if (!Number.isFinite(replayThrough) || replayThrough < 0) replayThrough = 0;
@@ -748,8 +787,14 @@ async function openSession(sessionId, options = {}) {
748
787
  last = sequence;
749
788
  }
750
789
  if (state.replaying && !(Number.isFinite(sequence) && sequence > 0 && sequence <= replayThrough)) state.replaying = false;
790
+ if (historyView?.sessionId === sessionId) historyView.events.push(event);
791
+ state.liveRevision = (state.liveRevision || 0) + 1;
751
792
  renderEvent(event);
752
- }, () => {}, { afterSequence: last });
793
+ }, () => {}, { afterSequence: last, onOpen: () => {
794
+ if (epoch !== sessionEpoch || state.activeSessionId !== sessionId) return;
795
+ if (streamOpened) { lastVisibleRefresh = 0; void refreshVisibleState(); }
796
+ streamOpened = true;
797
+ } });
753
798
  await renderProjectList();
754
799
  refreshContextUsage();
755
800
  if (embedded) {
@@ -761,6 +806,95 @@ async function openSession(sessionId, options = {}) {
761
806
  }
762
807
  }
763
808
 
809
+ function renderHistoryBar() {
810
+ const bar = $('history-bar'), more = $('history-more'), status = $('history-status');
811
+ if (!bar || !more || !status) return;
812
+ const view = historyView;
813
+ bar.hidden = !view || (!view.nextCursor && !view.incomplete && !view.error && !view.loading);
814
+ more.hidden = !view?.nextCursor && !view?.error;
815
+ more.disabled = view?.loading === true;
816
+ more.textContent = view?.loading ? '正在加载…' : view?.error ? '重试读取历史' : '加载更早记录';
817
+ status.textContent = view?.error ? '完整历史读取失败,当前记录可能不完整。'
818
+ : view?.incomplete ? '旧版记录已截断;当前仅展示实际保存的部分。' : '';
819
+ }
820
+
821
+ function historyEventKey(event) { return JSON.stringify([event.type, event.id || '', event.sequence || 0, event.at || '', event.text || '']); }
822
+
823
+ function rebuildHistory(view, events, prepend = true) {
824
+ const stream = $('stream');
825
+ const top = stream.scrollTop, height = stream.scrollHeight, following = state.follow, intent = state.scrollIntent;
826
+ const prompts = [...($('stream-col').querySelectorAll?.('.msg.user') || [])];
827
+ const streamY = stream.getBoundingClientRect?.().top;
828
+ const anchor = streamY == null ? null : prompts.filter(node => node.getBoundingClientRect().top <= streamY + 8).pop() || prompts[0];
829
+ const anchorKey = anchor?.dataset.historyKey;
830
+ const anchorY = anchor ? anchor.getBoundingClientRect().top - streamY : 0;
831
+ const folds = [...($('stream-col').querySelectorAll?.('details') || [])].map(node => node.open);
832
+ const busyState = state.sessionState;
833
+ resetStream();
834
+ state.follow = false;
835
+ state.replaying = true;
836
+ try { for (const event of events) renderEvent(event); }
837
+ finally { state.replaying = false; state.follow = following; state.scrollIntent = intent; }
838
+ const rebuiltFolds = [...($('stream-col').querySelectorAll?.('details') || [])];
839
+ const offset = rebuiltFolds.length - folds.length;
840
+ if (offset >= 0) for (let i = 0; i < folds.length; i++) rebuiltFolds[offset + i].open = folds[i];
841
+ if (busyState) setBusyState(busyState);
842
+ const restoredAnchor = anchorKey && [...($('stream-col').querySelectorAll?.('.msg.user') || [])].find(node => node.dataset.historyKey === anchorKey);
843
+ stream.scrollTop = following ? stream.scrollHeight : restoredAnchor
844
+ ? stream.scrollTop + restoredAnchor.getBoundingClientRect().top - streamY - anchorY
845
+ : top + (prepend ? stream.scrollHeight - height : 0);
846
+ view.events = events;
847
+ renderHistoryBar();
848
+ }
849
+
850
+ async function loadEarlierHistory() {
851
+ const view = historyView;
852
+ if (!view || view.loading) return;
853
+ if (view.error) return reloadHistory(view);
854
+ if (!view.nextCursor) return;
855
+ view.loading = true; renderHistoryBar();
856
+ const revision = ++view.revision;
857
+ try {
858
+ const page = await client.loadHistory(view.sessionId, { before: view.nextCursor });
859
+ if (historyView !== view || view.epoch !== sessionEpoch || revision !== view.revision) return;
860
+ view.nextCursor = page.nextCursor;
861
+ view.incomplete = page.incomplete;
862
+ view.error = false;
863
+ rebuildHistory(view, [...page.events, ...view.events]);
864
+ } catch { if (historyView === view) view.error = true; }
865
+ finally { if (historyView === view && revision === view.revision) { view.loading = false; renderHistoryBar(); } }
866
+ }
867
+
868
+ async function reloadHistory(view) {
869
+ if (!view || historyView !== view || !view.supported) return;
870
+ const revision = ++view.revision;
871
+ view.loading = true; renderHistoryBar();
872
+ const oldCursor = view.nextCursor;
873
+ const oldest = view.events.find(event => event.type === 'claude/user-prompt') || view.events[0];
874
+ const oldestKey = oldest && historyEventKey(oldest);
875
+ const buffered = view.events;
876
+ const liveStart = buffered.length;
877
+ try {
878
+ let page = await client.loadHistory(view.sessionId);
879
+ let events = page.events, nextCursor = page.nextCursor;
880
+ // Preserve the amount of history the reader already loaded, including a
881
+ // fully-read thread; a reconnect must not silently drop its older pages.
882
+ const seen = new Set();
883
+ while (nextCursor && (!oldCursor || (oldestKey && !events.some(event => historyEventKey(event) === oldestKey)))) {
884
+ if (seen.has(nextCursor)) throw new Error('history cursor did not advance');
885
+ seen.add(nextCursor);
886
+ const older = await client.loadHistory(view.sessionId, { before: nextCursor });
887
+ events = [...older.events, ...events]; nextCursor = older.nextCursor;
888
+ if (historyView !== view || revision !== view.revision) return;
889
+ }
890
+ if (historyView !== view || view.epoch !== sessionEpoch || revision !== view.revision) return;
891
+ const queued = buffered.slice(liveStart).filter(event => Number(event.sequence) > page.highWater);
892
+ view.nextCursor = nextCursor; view.incomplete = page.incomplete; view.error = false;
893
+ rebuildHistory(view, [...events, ...queued], false);
894
+ } catch { if (historyView === view) view.error = true; }
895
+ finally { if (historyView === view && revision === view.revision) { view.loading = false; renderHistoryBar(); } }
896
+ }
897
+
764
898
  function publishKnowledge(projectId, sessionId, knowledge) {
765
899
  if (embedded) window.parent.postMessage({ type: 'kb:knowledge', app: 'agent-terminal', projectId, sessionId, knowledge }, document.referrer ? new URL(document.referrer).origin : location.origin);
766
900
  }
@@ -770,6 +904,8 @@ function detachSession(options = {}) {
770
904
  if (state.unsub) { try { state.unsub(); } catch {} state.unsub = null; }
771
905
  state.activeSessionId = null;
772
906
  state.sessionSummary = null;
907
+ historyView = null;
908
+ renderHistoryBar();
773
909
  if (!options.preserveAttachments) state.attachments = [];
774
910
  $('btn-ctx').hidden = true;
775
911
  renderAttachmentTray();
@@ -790,6 +926,7 @@ function resetStream() {
790
926
  col.textContent = '';
791
927
  turn = null;
792
928
  state.follow = true;
929
+ state.scrollIntent = null;
793
930
  const jump = $('jump-latest');
794
931
  if (jump) jump.hidden = true;
795
932
  }
@@ -956,7 +1093,7 @@ function endTurnVisuals(durationMs) {
956
1093
  const meta = step.querySelector('.s-meta');
957
1094
  if (meta) { meta.classList.remove('running'); meta.textContent = t.interrupted ? '已中断' : '完成'; }
958
1095
  }
959
- t.proc.open = false;
1096
+ if (state.replaying || state.follow) t.proc.open = false;
960
1097
  }
961
1098
  turn = null;
962
1099
  }
@@ -1038,6 +1175,7 @@ function renderEvent(event) {
1038
1175
  rememberTitleFromEvents([event]);
1039
1176
  if (state.activeSessionId && event.text) state.sessionTitles.set(state.activeSessionId, event.text.replace(/\s+/g, ' ').slice(0, 24));
1040
1177
  const wrap = el('div', 'msg user');
1178
+ wrap.dataset.historyKey = historyEventKey(event);
1041
1179
  const bubble = el('div', 'bubble', event.text || '');
1042
1180
  wrap.append(bubble);
1043
1181
  const copy = el('button', 'bubble-copy');
@@ -1084,7 +1222,8 @@ function renderEvent(event) {
1084
1222
  if (event.type === 'claude/text-delta') {
1085
1223
  ctx.liveRaw += event.text || '';
1086
1224
  ctx.liveText._rawFull = ctx.liveRaw;
1087
- ctx.liveText._pacer.push(ctx.liveRaw);
1225
+ if (state.replaying) ctx.liveText._pacer.finish(ctx.liveRaw);
1226
+ else ctx.liveText._pacer.push(ctx.liveRaw);
1088
1227
  }
1089
1228
  break;
1090
1229
  }
@@ -1678,6 +1817,7 @@ function fmtTokens(value) {
1678
1817
  /* ===================== busy / activity / context ===================== */
1679
1818
 
1680
1819
  function setBusyState(sessionState) {
1820
+ state.sessionState = sessionState;
1681
1821
  state.busy = ['running', 'thinking', 'tool-running', 'spawning', 'pending-permission'].includes(sessionState);
1682
1822
  const status = $('session-status');
1683
1823
  const send = $('btn-send');
@@ -1841,6 +1981,10 @@ async function sendCurrent() {
1841
1981
  renderAttachmentTray();
1842
1982
  const sentSessionId = state.activeSessionId, sentProjectId = state.activeProjectId;
1843
1983
  rememberProjectDraft(sentProjectId, { text: '', attachments: [], sessionId: sentSessionId });
1984
+ // Engage before awaiting the backend: output can arrive while send is still
1985
+ // pending, and its eventual acknowledgement must not undo an upward scroll.
1986
+ state.follow = true;
1987
+ state.scrollIntent = null;
1844
1988
  try {
1845
1989
  const accepted = await client.send({
1846
1990
  sessionId: state.activeSessionId,
@@ -1852,7 +1996,6 @@ async function sendCurrent() {
1852
1996
  if (accepted?.knowledge && state.activeSessionId === sentSessionId) publishKnowledge(sentProjectId, sentSessionId, accepted.knowledge);
1853
1997
  if (state.activeSessionId !== sentSessionId) return;
1854
1998
  setBusyState(accepted?.state || 'running');
1855
- state.follow = true; // sending a message re-engages follow mode
1856
1999
  } catch (error) {
1857
2000
  if (state.activeProjectId !== sentProjectId || state.activeSessionId !== sentSessionId) {
1858
2001
  const draft = projectDrafts.get(sentProjectId) || {};
@@ -1933,6 +2076,7 @@ function renderModeLabel() {
1933
2076
  /* ===================== settings ===================== */
1934
2077
 
1935
2078
  function openSettings(tab) {
2079
+ if (tab === 'agents') void refreshAgents();
1936
2080
  closePops();
1937
2081
  document.querySelectorAll('.stab').forEach(node => node.classList.toggle('active', node.dataset.s === tab));
1938
2082
  document.querySelectorAll('.spanel').forEach(node => node.classList.remove('active'));
@@ -1955,7 +2099,7 @@ function wireSettings() {
1955
2099
  $(`s-${tab.dataset.s}`).classList.add('active');
1956
2100
  });
1957
2101
  });
1958
- $('btn-rescan').addEventListener('click', async () => { await refreshAgents(); toast('已重新扫描', false, true); });
2102
+ $('btn-rescan').addEventListener('click', async () => { await refreshAgents(true); toast(state.agentsError ? '检查未完成,请重试' : '正在重新检查 Agent', state.agentsError); });
1959
2103
  $('mgmt-add').addEventListener('click', () => renderProviderDetail(null));
1960
2104
  wireCaptureSetting();
1961
2105
  }
@@ -2433,16 +2577,38 @@ function wireChrome() {
2433
2577
  const stream = $('stream');
2434
2578
  const jump = $('jump-latest');
2435
2579
  if (stream && stream.addEventListener) {
2580
+ const pauseFollow = () => {
2581
+ state.follow = false;
2582
+ state.scrollIntent = 'up';
2583
+ if (jump) jump.hidden = false;
2584
+ };
2585
+ // Wheel intent precedes the browser's scroll action. Waiting for a 120px
2586
+ // distance lets a fast delta repeatedly win against small wheel steps.
2587
+ stream.addEventListener('wheel', event => {
2588
+ if (event.deltaY < 0) pauseFollow();
2589
+ else if (event.deltaY > 0) state.scrollIntent = 'down';
2590
+ }, { passive: true });
2591
+ stream.addEventListener('keydown', event => {
2592
+ if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) pauseFollow();
2593
+ else if (['ArrowDown', 'PageDown', 'End', ' '].includes(event.key)) state.scrollIntent = 'down';
2594
+ });
2595
+ let previousTop = stream.scrollTop;
2436
2596
  stream.addEventListener('scroll', () => {
2437
2597
  const distance = stream.scrollHeight - stream.scrollTop - stream.clientHeight;
2438
- if (distance > 120) state.follow = false;
2439
- else if (distance < 40) state.follow = true;
2598
+ if (stream.scrollTop < previousTop - 1 && distance > 2) state.follow = false;
2599
+ if (distance > 2) state.follow = false;
2600
+ else if (state.scrollIntent !== 'up') state.follow = true;
2601
+ previousTop = stream.scrollTop;
2440
2602
  if (jump) jump.hidden = state.follow;
2603
+ if (stream.scrollTop < 80 && historyView?.nextCursor && !historyView.loading) void loadEarlierHistory();
2441
2604
  });
2442
2605
  }
2606
+ const historyMore = $('history-more');
2607
+ if (historyMore) historyMore.addEventListener('click', loadEarlierHistory);
2443
2608
  if (jump) {
2444
2609
  jump.addEventListener('click', () => {
2445
2610
  state.follow = true;
2611
+ state.scrollIntent = null;
2446
2612
  jump.hidden = true;
2447
2613
  if (stream) stream.scrollTop = stream.scrollHeight;
2448
2614
  });
@@ -2474,6 +2640,28 @@ function embedAllowedOrigin(origin) {
2474
2640
  return origin === (document.referrer ? new URL(document.referrer).origin : location.origin);
2475
2641
  } catch { return false; }
2476
2642
  }
2643
+ let visibleRefreshJob = null, lastVisibleRefresh = 0, embedVisible = false;
2644
+ function refreshVisibleState() {
2645
+ if (visibleRefreshJob) return visibleRefreshJob;
2646
+ if (Date.now() - lastVisibleRefresh < 1000) return Promise.resolve();
2647
+ const project = state.activeProjectId, session = state.activeSessionId, epoch = sessionEpoch;
2648
+ visibleRefreshJob = (async () => {
2649
+ await Promise.allSettled([refreshAgents(), refreshProfiles(), refreshProjects(true)]);
2650
+ if (epoch !== sessionEpoch || project !== state.activeProjectId || session !== state.activeSessionId || !session) return;
2651
+ try {
2652
+ if (historyView?.supported) await reloadHistory(historyView);
2653
+ const revision = state.liveRevision;
2654
+ const snapshot = await client.loadSession(session);
2655
+ if (epoch !== sessionEpoch || session !== state.activeSessionId || revision !== state.liveRevision) return;
2656
+ state.sessionSummary = snapshot.session;
2657
+ setBusyState(snapshot.session.state);
2658
+ } catch { /* Stream stays alive; never erase an active conversation on a failed read. */ }
2659
+ })().finally(() => { visibleRefreshJob = null; lastVisibleRefresh = Date.now(); });
2660
+ return visibleRefreshJob;
2661
+ }
2662
+ window.addEventListener('focus', () => { void refreshVisibleState(); });
2663
+ window.addEventListener('online', () => { lastVisibleRefresh = 0; void refreshVisibleState(); });
2664
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) void refreshVisibleState(); });
2477
2665
  function setEmbedSidebarVisible(visible) {
2478
2666
  document.body.classList.toggle('embed-collapse', !visible);
2479
2667
  }
@@ -2518,6 +2706,16 @@ window.addEventListener('message', async ev => {
2518
2706
  if (!data || typeof data !== 'object') return;
2519
2707
  const reply = payload => { if (ev.source) ev.source.postMessage(Object.assign({ app: 'agent-terminal', requestId: data.requestId }, payload), ev.origin); };
2520
2708
  if (data.type === 'kb:theme') { document.body.classList.toggle('light', data.theme !== 'dark'); document.body.dataset.kbTheme = data.theme === 'dark' ? 'dark' : 'light'; reply({ type: 'kb:configured', theme: document.body.dataset.kbTheme }); return; }
2709
+ if (data.type === 'kb:visibility' || data.type === 'kb:refresh') {
2710
+ if (data.type === 'kb:visibility') {
2711
+ const wasVisible = embedVisible; embedVisible = data.visible === true;
2712
+ if (!embedVisible || wasVisible) return;
2713
+ }
2714
+ const project = state.activeProjectId, session = state.activeSessionId, epoch = selectionEpoch;
2715
+ await refreshVisibleState();
2716
+ if (epoch === selectionEpoch && project === state.activeProjectId && session === state.activeSessionId) reply({ type: 'kb:refreshed', project, sessionId: session, ok: !state.agentsError });
2717
+ return;
2718
+ }
2521
2719
  if (data.type === 'kb:open-settings') { openSettings('providers'); return; }
2522
2720
  if (data.type === 'kb:sidebar') setEmbedSidebarVisible(!!data.visible);
2523
2721
  else if (data.type === 'kb:select-project') {
@@ -40,7 +40,11 @@
40
40
  <button class="ghost" id="btn-top-settings" title="设置(模型供应商)"><svg class="ics" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
41
41
  </div>
42
42
 
43
- <div class="stream" id="stream">
43
+ <div class="stream" id="stream" tabindex="0" aria-label="会话记录">
44
+ <div class="history-bar" id="history-bar" hidden>
45
+ <button class="linklike" id="history-more" type="button">加载更早记录</button>
46
+ <span id="history-status" role="status"></span>
47
+ </div>
44
48
  <div class="col" id="stream-col">
45
49
  <div class="empty-hint" id="empty-hint">
46
50
  <div class="big"><span class="hint-wrap"><span class="hint-art"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 8 4 4-4 4"/><path d="M13 16h6"/></svg></span><span class="hint-spark"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l1.9 5.8L20 12l-6.1 3.2L12 21l-1.9-5.8L4 12l6.1-3.2z"/></svg></span></span></div>
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: Claude AI Workbench API
4
- version: 0.13.2-rc.13
4
+ version: 0.13.2-rc.14
5
5
  description: Versioned local API for AI Profile, credential and Claude Code session management.
6
6
  servers:
7
7
  - url: http://127.0.0.1:5760/api/claude-workbench/v1
@@ -48,6 +48,13 @@ paths:
48
48
  agents:
49
49
  type: array
50
50
  items: { $ref: "#/components/schemas/AgentInfo" }
51
+ /agents/refresh:
52
+ post:
53
+ tags: [Agents]
54
+ operationId: refreshAgents
55
+ responses:
56
+ "202":
57
+ description: Background scan started or reused; previous agents remain available with scan state and generation.
51
58
  /projects:
52
59
  get:
53
60
  tags: [Projects]
@@ -311,6 +318,37 @@ paths:
311
318
  responses:
312
319
  "200": { description: Session snapshot }
313
320
  "404": { $ref: "#/components/responses/Error" }
321
+ /sessions/{sessionId}/history:
322
+ parameters:
323
+ - $ref: "#/components/parameters/SessionId"
324
+ get:
325
+ tags: [Sessions]
326
+ operationId: getSessionHistory
327
+ description: Durable conversation history, separate from the bounded SSE replay cache. Pages contain whole turns in chronological order.
328
+ parameters:
329
+ - in: query
330
+ name: before
331
+ description: Cursor returned by the previous page. Omit for recent turns.
332
+ schema: { type: string }
333
+ - in: query
334
+ name: limit
335
+ schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
336
+ responses:
337
+ "200":
338
+ description: History page; incomplete is true when an earlier prefix cannot be recovered.
339
+ content:
340
+ application/json:
341
+ schema:
342
+ type: object
343
+ required: [events, nextCursor, incomplete, highWater]
344
+ properties:
345
+ events: { type: array, items: { type: object } }
346
+ nextCursor: { type: string, nullable: true }
347
+ incomplete: { type: boolean }
348
+ highWater: { type: integer, minimum: 0 }
349
+ "400": { $ref: "#/components/responses/Error" }
350
+ "404": { $ref: "#/components/responses/Error" }
351
+ "503": { $ref: "#/components/responses/Error" }
314
352
  /sessions/{sessionId}/events:
315
353
  parameters:
316
354
  - $ref: "#/components/parameters/SessionId"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanqianx/claude-ai-workbench",
3
- "version": "0.13.2-rc.13",
3
+ "version": "0.13.2-rc.14",
4
4
  "description": "Shared Claude Code terminal and Anthropic-compatible AI profile modules for local developer tools.",
5
5
  "type": "commonjs",
6
6
  "main": "src/index.js",
@@ -15,6 +15,7 @@ import type {
15
15
  AttachmentInput,
16
16
  SlashCommand,
17
17
  SessionSnapshot,
18
+ SessionHistoryPage,
18
19
  SessionSummary,
19
20
  WorkbenchContext,
20
21
  WorkbenchEvent,
@@ -41,12 +42,15 @@ export interface ClaudeWorkbenchClientOptions {
41
42
  apiBase?: string;
42
43
  fetch?: typeof fetch;
43
44
  EventSource?: typeof EventSource;
45
+ readTimeoutMs?: number;
44
46
  }
45
47
 
46
48
  export interface ClaudeWorkbenchClient {
47
49
  health(): Promise<HealthResult>;
48
50
  runtime(): Promise<RuntimeInfo>;
49
51
  listAgents(): Promise<AgentStatus[]>;
52
+ agentStatus(): Promise<{ agents: AgentStatus[]; scan?: { state: string; generation: number; updatedAt: string | null } }>;
53
+ refreshAgents(): ReturnType<ClaudeWorkbenchClient['agentStatus']>;
50
54
  listProjects(): Promise<Project[]>;
51
55
  createProject(project: ProjectInput): Promise<Project>;
52
56
  getProject(id: string): Promise<Project>;
@@ -62,6 +66,7 @@ export interface ClaudeWorkbenchClient {
62
66
  listSessions(context: WorkbenchContext | string, options?: ListSessionsOptions): Promise<SessionSummary[]>;
63
67
  startSession(context: WorkbenchContext, options?: StartSessionOptions): Promise<SessionSummary>;
64
68
  loadSession(id: string, options?: RequestOptions): Promise<SessionSnapshot>;
69
+ loadHistory(id: string, options?: RequestOptions & { before?: string; limit?: number }): Promise<SessionHistoryPage>;
65
70
  listCommands(id: string, options?: RequestOptions): Promise<SlashCommand[]>;
66
71
  subscribe(id: string, handlers: StreamHandlers | ((event: WorkbenchEvent) => void), onError?: (error: Error) => void, options?: {afterSequence?: number}): () => void;
67
72
  send(sessionId: string, text: string, options?: SendOptions): Promise<unknown>;