@sanqianx/claude-ai-workbench 0.13.5 → 0.13.6

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.
@@ -1063,6 +1063,21 @@ function renderHistoryBar() {
1063
1063
 
1064
1064
  function historyEventKey(event) { return JSON.stringify([event.type, event.id || '', event.sequence || 0, event.at || '', event.text || '']); }
1065
1065
 
1066
+ function mergeHistoryEvents(fetched, buffered) {
1067
+ const keys = new Set(fetched.map(historyEventKey));
1068
+ const sequences = new Set(fetched.map(event => Number(event.sequence)).filter(sequence => sequence > 0));
1069
+ const compacted = fetched.filter(event => Number(event.compactFrom) > 0);
1070
+ const queued = buffered.filter(event => {
1071
+ if (keys.has(historyEventKey(event))) return false;
1072
+ const sequence = Number(event.sequence);
1073
+ if (!(sequence > 0)) return true;
1074
+ if (sequences.has(sequence)) return false;
1075
+ return !compacted.some(row => row.type === event.type && row.id === event.id
1076
+ && row.parentToolUseId === event.parentToolUseId && sequence >= Number(row.compactFrom) && sequence <= Number(row.sequence));
1077
+ });
1078
+ return [...fetched, ...queued].sort((a, b) => (Number(a.sequence) || 0) - (Number(b.sequence) || 0));
1079
+ }
1080
+
1066
1081
  function rebuildHistory(view, events, prepend = true) {
1067
1082
  const stream = $('stream');
1068
1083
  const top = stream.scrollTop, height = stream.scrollHeight, following = state.follow, intent = state.scrollIntent;
@@ -1104,7 +1119,7 @@ async function loadEarlierHistory() {
1104
1119
  view.nextCursor = page.nextCursor;
1105
1120
  view.incomplete = page.incomplete;
1106
1121
  view.error = false;
1107
- rebuildHistory(view, [...page.events, ...view.events]);
1122
+ rebuildHistory(view, mergeHistoryEvents(page.events, view.events));
1108
1123
  } catch { if (historyView === view) view.error = true; }
1109
1124
  finally { if (historyView === view && revision === view.revision) { view.loading = false; renderHistoryBar(); } }
1110
1125
  }
@@ -1135,10 +1150,8 @@ async function reloadHistory(view) {
1135
1150
  // (including before this reload even started) survive unless the fetched
1136
1151
  // history already contains them. The fetched page must never overwrite
1137
1152
  // the live tail into silence.
1138
- const fetchedKeys = new Set(events.map(historyEventKey));
1139
- const queued = buffered.filter(event => !fetchedKeys.has(historyEventKey(event)));
1140
1153
  view.nextCursor = nextCursor; view.incomplete = page.incomplete; view.error = false;
1141
- rebuildHistory(view, [...events, ...queued], false);
1154
+ rebuildHistory(view, mergeHistoryEvents(events, buffered), false);
1142
1155
  } catch { if (historyView === view) view.error = true; }
1143
1156
  finally { if (historyView === view && revision === view.revision) { view.loading = false; renderHistoryBar(); } }
1144
1157
  }
@@ -1180,6 +1193,8 @@ function resetStream() {
1180
1193
  const col = $('stream-col');
1181
1194
  col.textContent = '';
1182
1195
  turn = null;
1196
+ pendingReplyGroups.length = 0;
1197
+ replyGroup = null;
1183
1198
  state.completedReply = null;
1184
1199
  state.follow = true;
1185
1200
  state.scrollIntent = null;
@@ -1248,8 +1263,22 @@ function checkMark() {
1248
1263
  // single collapsible block headed by the running/elapsed timer, ZCode-style
1249
1264
  // (工作中 X 秒 → 已工作 X 秒); only the final message stays in the open stream.
1250
1265
  let turn = null;
1266
+ const pendingReplyGroups = [];
1267
+ let replyGroup = null;
1268
+
1269
+ function newReplyGroup(turnId) {
1270
+ const group = { turnId: turnId || null, before: null, completedReply: null, context: null };
1271
+ pendingReplyGroups.push(group);
1272
+ return group;
1273
+ }
1251
1274
 
1252
- function startTurn() {
1275
+ function appendReply(node, group) {
1276
+ const col = $('stream-col');
1277
+ if (group?.before?.isConnected) col.insertBefore(node, group.before);
1278
+ else col.append(node);
1279
+ }
1280
+
1281
+ function startTurn(turnId) {
1253
1282
  endTurnVisuals();
1254
1283
  turn = {
1255
1284
  startedAt: Date.now(), timerId: null, proc: null, steps: null, meta: null,
@@ -1257,6 +1286,10 @@ function startTurn() {
1257
1286
  lastPrompt: '', changes: new Map(), interrupted: false,
1258
1287
  stderrNote: null, stderrLast: '', thinkHint: null, lastError: '',
1259
1288
  };
1289
+ if (!replyGroup || !pendingReplyGroups.includes(replyGroup)) replyGroup = newReplyGroup(turnId);
1290
+ turn.replyGroup = replyGroup;
1291
+ replyGroup.completedReply = null;
1292
+ replyGroup.context = turn;
1260
1293
  return turn;
1261
1294
  }
1262
1295
 
@@ -1316,6 +1349,7 @@ function showThinkingHint(t) {
1316
1349
  function endTurnVisuals(durationMs) {
1317
1350
  const t = turn;
1318
1351
  if (!t) return;
1352
+ if (t.liveText?.isConnected && t.replyGroup) t.replyGroup.completedReply = { node: t.liveText, text: t.liveRaw || '' };
1319
1353
  stampThinking(t);
1320
1354
  hideThinkingHint(t);
1321
1355
  if (t.timerId) { clearInterval(t.timerId); t.timerId = null; }
@@ -1529,14 +1563,16 @@ function renderEvent(event) {
1529
1563
  wrap.append(tray);
1530
1564
  }
1531
1565
  col.append(wrap);
1532
- const ctx = startTurn();
1566
+ for (const group of pendingReplyGroups) if (!group.before) group.before = wrap;
1567
+ replyGroup = newReplyGroup(event.turnId);
1568
+ const ctx = startTurn(event.turnId);
1533
1569
  ctx.lastPrompt = event.text || '';
1534
1570
  break;
1535
1571
  }
1536
1572
  case 'claude/text-start':
1537
1573
  case 'claude/text-delta': {
1538
1574
  stampThinking(t);
1539
- const ctx = t || startTurn();
1575
+ const ctx = t || startTurn(event.turnId);
1540
1576
  if (!ctx.liveText || !ctx.liveText.isConnected) {
1541
1577
  const msg = buildConversationMessage('assistant', '');
1542
1578
  const body = msg.querySelector('.md-body');
@@ -1557,7 +1593,7 @@ function renderEvent(event) {
1557
1593
  break;
1558
1594
  }
1559
1595
  case 'claude/thinking-start': {
1560
- const ctx = ensureProc(t || startTurn());
1596
+ const ctx = ensureProc(t || startTurn(event.turnId));
1561
1597
  stampThinking(ctx);
1562
1598
  const seg = el('div', 'think-seg');
1563
1599
  seg._startedAt = Date.now();
@@ -1578,7 +1614,7 @@ function renderEvent(event) {
1578
1614
  break;
1579
1615
  case 'claude/tool-use': {
1580
1616
  stampThinking(t);
1581
- const ctx = ensureProc(t || startTurn());
1617
+ const ctx = ensureProc(t || startTurn(event.turnId));
1582
1618
  const input = event.input || {};
1583
1619
  ctx.steps.append(buildToolStep(event, input));
1584
1620
  break;
@@ -1632,32 +1668,41 @@ function renderEvent(event) {
1632
1668
  refreshContextUsage();
1633
1669
  break;
1634
1670
  case 'claude/turn-end':
1671
+ if (event.boundary === 'result') break; // result already finalized its own response
1672
+ if (event.turnId && replyGroup?.turnId && event.turnId !== replyGroup.turnId) break;
1635
1673
  if (t?.liveText?.isConnected) state.completedReply = { node: t.liveText, text: t.liveRaw || '' };
1636
1674
  endTurnVisuals();
1637
1675
  setBusyState(event.exitCode ? 'failed' : 'idle');
1638
1676
  break;
1639
1677
  case 'claude/result': {
1640
- stampThinking(t);
1641
- const hadLiveText = Boolean(t?.liveText?.isConnected || state.completedReply?.node?.isConnected && String(state.completedReply.text).trim() === String(event.result || '').trim());
1642
- const lastPrompt = t ? t.lastPrompt : '';
1643
- const turnChanges = t ? t.changes : null;
1678
+ const index = event.turnId ? pendingReplyGroups.findIndex(group => group.turnId === event.turnId) : 0;
1679
+ if (event.turnId && index < 0) break; // an earlier response may be outside the currently loaded history page
1680
+ const group = pendingReplyGroups.splice(index, 1)[0] || replyGroup;
1681
+ const owner = group?.context || t;
1682
+ const current = !group || group === replyGroup;
1683
+ stampThinking(owner);
1684
+ const hadLiveText = Boolean(owner?.liveText?.isConnected || group?.completedReply?.node?.isConnected);
1685
+ const lastPrompt = owner ? owner.lastPrompt : '';
1686
+ const turnChanges = owner ? owner.changes : null;
1644
1687
  const turnModel = event.model || (state.sessionSummary && state.sessionSummary.model) || '';
1645
- endTurnVisuals(event.durationMs != null ? event.durationMs : event.duration_ms);
1688
+ if (t === owner) endTurnVisuals(event.durationMs != null ? event.durationMs : event.duration_ms);
1689
+ let replyNode = owner?.liveText?.isConnected ? owner.liveText : group?.completedReply?.node;
1646
1690
  if (event.isError) {
1647
1691
  const err = el('div', 'msg ai');
1648
1692
  const dim = el('p', 'dim');
1649
1693
  dim.append(icon('warn', 13), el('span', null, ` ${event.result || event.message || '执行出错'}`));
1650
1694
  err.append(dim);
1651
- col.append(err);
1695
+ appendReply(err, group);
1652
1696
  } else {
1653
1697
  if (!hadLiveText && event.result) {
1654
- col.append(buildConversationMessage('assistant', String(event.result)));
1698
+ replyNode = buildConversationMessage('assistant', String(event.result));
1699
+ appendReply(replyNode, group);
1655
1700
  }
1656
- const lastMsg = [...col.children].reverse().find(node => node.classList && node.classList.contains('ai'));
1701
+ const lastMsg = replyNode;
1657
1702
  if (lastMsg) lastMsg.append(msgFooter(String(event.result || lastMsg.textContent), lastPrompt, { model: turnModel, at: event.at }));
1658
- if (turnChanges && turnChanges.size) col.append(renderTurnDiffs(turnChanges));
1703
+ if (turnChanges && turnChanges.size) appendReply(renderTurnDiffs(turnChanges), group);
1659
1704
  }
1660
- setBusyState('idle');
1705
+ if (current) setBusyState('idle');
1661
1706
  refreshContextUsage();
1662
1707
  if (!state.replaying) refreshSessionRow();
1663
1708
  break;
@@ -1677,6 +1722,7 @@ function renderEvent(event) {
1677
1722
  case 'claude/aborted':
1678
1723
  if (t) t.interrupted = true;
1679
1724
  endTurnVisuals();
1725
+ pendingReplyGroups.length = 0; replyGroup = null;
1680
1726
  {
1681
1727
  const stopNote = el('div', 'turn-note');
1682
1728
  stopNote.append(icon('stop', 12), el('span', null, ` ${abortStopLabel(event.reason)}`));
@@ -1694,6 +1740,7 @@ function renderEvent(event) {
1694
1740
  case 'claude/error': {
1695
1741
  const message = String(event.message || event.error || '执行出错');
1696
1742
  endTurnVisuals();
1743
+ pendingReplyGroups.length = 0; replyGroup = null;
1697
1744
  const err = el('div', 'msg ai error');
1698
1745
  err.append(icon('warn', 15), el('span', 'err-text', message));
1699
1746
  col.append(err);
@@ -116,6 +116,8 @@
116
116
 
117
117
  .rd-error { padding: 4px 10px; color: var(--bad); font-size: 12px; flex: 0 0 auto; white-space: pre-wrap; }
118
118
  .rd-error[hidden] { display: none; }
119
+ .rd-retry { margin-left: 10px; padding: 3px 9px; font: inherit; border: 1px solid var(--line); border-radius: 6px; background: transparent; color: var(--text); cursor: pointer; }
120
+ .rd-retry:disabled { opacity: .5; cursor: default; }
119
121
 
120
122
  .rd-scroll { flex: 1 1 auto; overflow-y: auto; padding: 4px 16px 16px; min-height: 0; }
121
123
 
@@ -123,6 +123,8 @@
123
123
  let listState = { loading: false, error: null, code: null, resync: false };
124
124
  let persistTimer = null;
125
125
  const taskIndex = new Map();
126
+ const taskLiveVersions = new Map();
127
+ let taskLiveRevision = 0, taskListRevision = 0;
126
128
  const delegationIndex = new Map();
127
129
  const delegationTitles = new Map();
128
130
  let delegationState = { loading: false, error: null, code: null };
@@ -282,6 +284,7 @@
282
284
  for (const [oldKey, old] of taskIndex) if (!old.taskRef && old.taskId === ref.taskId) taskIndex.delete(oldKey);
283
285
  const entry = taskIndex.get(key) || { key, taskRef: ref, sdkSessionId: null, taskId: null, taskKind: null, taskType: null, typeConflict: false, status: null, statusSource: null, association: null, rowCount: null };
284
286
  taskIndex.set(key, { ...entry, ...patch });
287
+ taskLiveVersions.set(key, ++taskLiveRevision);
285
288
  }
286
289
 
287
290
  function upsertFromStateEvent(ref, event) {
@@ -317,6 +320,7 @@
317
320
  }
318
321
 
319
322
  async function validateScopeTasks() {
323
+ const revision = ++taskListRevision;
320
324
  if (!scope || !scope.storeScope) {
321
325
  listState = { loading: false, error: '服务端任务身份不可用,无法读取原生任务列表。', code: 'TASK_IDENTITY_UNAVAILABLE', resync: false };
322
326
  for (const tab of tabs) if (tab.kind !== 'delegated' && (!tab.panel || !tab.panel.loaded)) tab.invalid = { code: 'TASK_IDENTITY_UNAVAILABLE', message: '服务端任务身份不可用;本切片不会伪造任务,也不会重新派发。' };
@@ -324,20 +328,23 @@
324
328
  return;
325
329
  }
326
330
  const epoch = scopeEpoch;
331
+ const liveRevision = taskLiveRevision;
327
332
  listState = { loading: true, error: null, code: null, resync: false };
328
333
  render();
329
334
  let page = null, error = null;
330
335
  try { page = await client.listNativeTasks(scope.parentSessionId, { storeScope: scope.storeScope, contextId: scope.contextId }); }
331
336
  catch (err) { error = err; }
332
- if (epoch !== scopeEpoch) return;
337
+ if (epoch !== scopeEpoch || revision !== taskListRevision) return;
333
338
  if (error) {
334
339
  const code = (error && error.code) || String((error && error.status) || '');
335
340
  listState = { loading: false, error: `任务列表读取失败:${error.message || '未知错误'}`, code, resync: error.resync === true };
336
- for (const tab of tabs) if (tab.kind !== 'delegated' && (!tab.panel || !tab.panel.loaded)) tab.invalid = { code, message: listState.error, resync: listState.resync };
341
+ for (const tab of tabs) if (!tab.kind && !taskIndex.has(taskRefKey(tab.taskRef)) && (!tab.panel || !tab.panel.loaded)) tab.invalid = { code, message: listState.error, resync: listState.resync };
337
342
  render();
338
343
  return;
339
344
  }
345
+ const newer = [...taskIndex.entries()].filter(([key]) => (taskLiveVersions.get(key) || 0) > liveRevision);
340
346
  rebuildIndex(page && Array.isArray(page.tasks) ? page.tasks : []);
347
+ for (const [key, entry] of newer) taskIndex.set(key, { ...taskIndex.get(key), ...entry });
341
348
  const incomplete = page && Array.isArray(page.incomplete) && page.incomplete.length > 0;
342
349
  listState = { loading: false, error: null, code: null, resync: incomplete ? 'incomplete' : false };
343
350
  // A rebuilt native index changes backgroundActivity (running counts);
@@ -383,6 +390,7 @@
383
390
  return;
384
391
  }
385
392
  panel.events = page && Array.isArray(page.events) ? page.events : [];
393
+ tab.invalid = null;
386
394
  panel.nextCursor = page && page.nextCursor || null;
387
395
  panel.hasMore = page ? page.hasMore === true : false;
388
396
  panel.loaded = true;
@@ -972,16 +980,28 @@
972
980
  // dispatch state and diagnostics never enter customer DOM.
973
981
  function createConversation(scroll) {
974
982
  let turn = null;
983
+ let group = null;
984
+ let eventTurnId = null;
985
+ const pending = [];
975
986
  const tools = new Map();
987
+ function startGroup(turnId) { group = { turnId: turnId || null, before: null, turn: null }; pending.push(group); }
976
988
  function current() {
977
- if (!turn) turn = { messages: [], changes: [], process: null, live: null, thinking: null };
989
+ if (!turn) {
990
+ if (!group || !pending.includes(group)) startGroup(eventTurnId);
991
+ turn = { messages: [], changes: [], process: null, live: null, thinking: null, group };
992
+ group.turn = turn;
993
+ }
978
994
  return turn;
979
995
  }
996
+ function append(node, owner) {
997
+ if (owner?.group?.before?.parentNode === scroll) scroll.insertBefore(node, owner.group.before);
998
+ else scroll.append(node);
999
+ }
980
1000
  function mark(node, sequence) {
981
1001
  if (Number.isFinite(Number(sequence))) node.dataset.seq = String(sequence);
982
1002
  return node;
983
1003
  }
984
- function message(role, text, sequence) {
1004
+ function message(role, text, sequence, owner) {
985
1005
  const node = renderMessage ? renderMessage(role, text) : el('div', role === 'user' ? 'msg user' : 'msg ai');
986
1006
  let body = node.querySelector && node.querySelector(role === 'user' ? '.bubble' : '.md-body');
987
1007
  if (!body) {
@@ -991,7 +1011,7 @@
991
1011
  node.append(body);
992
1012
  }
993
1013
  mark(node, sequence);
994
- scroll.append(node);
1014
+ append(node, owner);
995
1015
  return { node, body, text };
996
1016
  }
997
1017
  function process(sequence) {
@@ -1017,20 +1037,24 @@
1017
1037
  return t.process;
1018
1038
  }
1019
1039
  function updateProcess(p) { p.meta.textContent = p.steps.children.length + ' 个步骤'; }
1020
- function finish(sequence) {
1021
- if (!turn) return;
1022
- if (renderChanges && turn.changes.length) {
1023
- const changes = renderChanges(turn.changes);
1024
- if (changes) scroll.append(mark(changes, sequence));
1040
+ function finish(sequence, owner = turn) {
1041
+ if (!owner) return;
1042
+ if (renderChanges && owner.changes.length) {
1043
+ const changes = renderChanges(owner.changes);
1044
+ if (changes) append(mark(changes, sequence), owner);
1045
+ owner.changes = [];
1025
1046
  }
1026
- turn = null;
1047
+ if (turn === owner) turn = null;
1027
1048
  }
1028
1049
  function accept(event) {
1029
1050
  if (!event) return;
1051
+ eventTurnId = event.turnId || null;
1030
1052
  const seq = event.sequence, type = event.type;
1031
1053
  if (type === 'claude/user-prompt') {
1032
1054
  finish(seq);
1033
- if (event.text) message('user', String(event.text), seq);
1055
+ const user = event.text ? message('user', String(event.text), seq) : null;
1056
+ if (user) for (const old of pending) if (!old.before) old.before = user.node;
1057
+ startGroup(event.turnId);
1034
1058
  current();
1035
1059
  } else if (type === 'claude/text-start') {
1036
1060
  current().live = null;
@@ -1079,15 +1103,20 @@
1079
1103
  mark(p.node, seq);
1080
1104
  updateProcess(p);
1081
1105
  } else if (type === 'claude/result') {
1082
- const t = current();
1083
- if (event.isError) scroll.append(mark(el('div', 'turn-note', '回复失败,请稍后重试。'), seq));
1084
- else if (!t.messages.length && event.result) message('assistant', String(event.result), seq);
1085
- finish(seq);
1106
+ const index = event.turnId ? pending.findIndex(group => group.turnId === event.turnId) : 0;
1107
+ if (event.turnId && index < 0) return;
1108
+ const t = pending.splice(index, 1)[0]?.turn || current();
1109
+ if (event.isError) append(mark(el('div', 'turn-note', '回复失败,请稍后重试。'), seq), t);
1110
+ else if (!t.messages.length && event.result) message('assistant', String(event.result), seq, t);
1111
+ finish(seq, t);
1112
+ } else if (type === 'claude/turn-end') {
1113
+ if (event.boundary !== 'result' && (!event.turnId || !group?.turnId || event.turnId === group.turnId)) finish(seq);
1086
1114
  } else if (type === 'conversation/unavailable') {
1087
1115
  scroll.append(mark(el('div', 'turn-note', '任务记录暂不可用,请刷新重试。'), seq));
1088
1116
  } else if (type === 'claude/aborted' || type === 'claude/task-interrupted' || type === 'claude/error') {
1089
1117
  scroll.append(mark(el('div', 'turn-note', type === 'claude/error' ? '回复失败,请稍后重试。' : '回复已中断'), seq));
1090
1118
  finish(seq);
1119
+ pending.length = 0; group = null;
1091
1120
  }
1092
1121
  }
1093
1122
  return { accept, finish };
@@ -1161,7 +1190,8 @@
1161
1190
  pane.title.textContent = delegationTitle(tab.delegationId);
1162
1191
  pane.status.textContent = delegationStatus(view).label;
1163
1192
  pane.errorBox.hidden = !tab.invalid && !panel.error;
1164
- if (tab.invalid || panel.error) pane.errorBox.textContent = '暂时无法查看此任务,请刷新后重试。';
1193
+ if (tab.invalid || panel.error) pane.errorText.textContent = '暂时无法查看此任务。';
1194
+ pane.retry.disabled = panel.loading === true;
1165
1195
  pane.banner.hidden = true;
1166
1196
  if (tab.invalid) {
1167
1197
  pane.scroll.textContent = '';
@@ -1169,7 +1199,7 @@
1169
1199
  return;
1170
1200
  }
1171
1201
  if (!panel.rendered) renderDelegationTimeline(tab);
1172
- if (tab.error || tab.connectionError) { pane.errorBox.hidden = false; pane.errorBox.textContent = tab.error || tab.connectionError; }
1202
+ if (tab.error || tab.connectionError) { pane.errorBox.hidden = false; pane.errorText.textContent = tab.error || tab.connectionError; }
1173
1203
  if (pane.composer) { pane.composer.setBusy(childBusy(tab)); pane.composer.send.disabled = Boolean(tab.invalid || tab.sending || !view || !view.child.present); }
1174
1204
  if (pane.composer && view) pane.composer.setLabel?.((view.child.model || '子代理') + ' · 默认防护');
1175
1205
  renderPermissions(tab);
@@ -1267,6 +1297,7 @@
1267
1297
  const previousScope = scope;
1268
1298
  flushPersist();
1269
1299
  scopeEpoch += 1;
1300
+ taskLiveVersions.clear();
1270
1301
  scope = sanitizeScope(input);
1271
1302
  restoreFor(scope, previousScope);
1272
1303
  pickerOpen = false;
@@ -1711,6 +1742,10 @@
1711
1742
  banner.append(bannerText, resyncBtn);
1712
1743
  const errorBox = el('div', 'rd-error');
1713
1744
  errorBox.hidden = true;
1745
+ const errorText = el('span');
1746
+ const retry = el('button', 'rd-retry', '重试');
1747
+ retry.addEventListener('click', () => { void (tab.kind === 'delegated' ? loadDelegationFirstPage(tab) : loadPanelFirstPage(tab)); });
1748
+ errorBox.append(errorText, retry);
1714
1749
  const scroll = el('div', 'rd-scroll');
1715
1750
  scroll.addEventListener('scroll', () => {
1716
1751
  const pane = tab.pane;
@@ -1737,7 +1772,7 @@
1737
1772
  });
1738
1773
  node.append(continuation);
1739
1774
  }
1740
- tab.pane = { node, head, title, status, banner, bannerText, resyncBtn, errorBox, scroll, more,
1775
+ tab.pane = { node, head, title, status, banner, bannerText, resyncBtn, errorBox, errorText, retry, scroll, more,
1741
1776
  follow: true, anchorSeq: null, anchorNote: null, pendingNew: false };
1742
1777
  }
1743
1778
 
@@ -1867,7 +1902,8 @@
1867
1902
  pane.title.textContent = taskTitle(entry);
1868
1903
  pane.status.textContent = entry && !entry.typeConflict ? (STATUS_LABELS[entry.status] || '状态待确认') : '状态待确认';
1869
1904
  pane.errorBox.hidden = !tab.invalid && !panel.error;
1870
- if (tab.invalid || panel.error) pane.errorBox.textContent = '暂时无法查看此任务,请刷新后重试。';
1905
+ if (tab.invalid || panel.error) pane.errorText.textContent = '暂时无法查看此任务。';
1906
+ pane.retry.disabled = panel.loading === true;
1871
1907
  pane.banner.hidden = !panel.resync;
1872
1908
  if (panel.resync) pane.bannerText.textContent = '记录已更新,请刷新查看。';
1873
1909
  if (tab.invalid) {
@@ -10,6 +10,8 @@ Session summaries include `tasks` and `backgroundTaskCount`. Task records use ac
10
10
 
11
11
  `claude/turn-end` closes the foreground reply. For Claude SDK streaming input this may occur at `end_turn` before its aggregate `result`, which can wait for background tasks. The input queue and event reader remain alive while tasks run. When foreground and tasks are both idle, the query is collected after 30 seconds; the next input resumes the same native session. Changing provider/model while a task runs is rejected.
12
12
 
13
+ Claude SDK inputs now carry an optional opaque `turnId` on `claude/user-prompt` and the normalized response events. Match a delayed `claude/result` to that input, even when a newer prompt is already visible; a result for an input outside a loaded history page must not become the current reply. `claude/turn-end` with `boundary: "result"` is the boundary for the result already processed, rather than another active reply. Older journals without `turnId` retain their original ordered input/result pairing.
14
+
13
15
  Bound native agent tasks also persist evidence rows in the same journal: `claude/native-trace` (sanitized in-process hook facts), `claude/native-task-state` (ownership/status projections) and `claude/native-task-transcript` (a snapshot of a bound agent's transcript read through the SDK's public `getSubagentMessages` at a real terminal signal, or at the bounded turn-end sweep). Transcript rows carry `source: "sdk-public-read"`, fixed content-free `read` reason codes when a read cannot happen honestly, byte-bounded `chunk` markers, and normalized redacted entries/tool steps/usage - a snapshot read, never a claim of per-token streaming. These rows are part of the single-session stream's named event vocabulary: the standalone client subscribes to `claude/native-trace`, `claude/native-task-state`, `claude/native-task-transcript` and `claude/task-interrupted` like every other chat event, so SDK consumers receive the native facts in real time. They are additionally read back through the two read-only native-task history APIs (`GET /sessions/:id/native-tasks`, `POST /sessions/:id/native-tasks/history`), which page them without dispatching input.
14
16
 
15
17
  Set `inputOrigin: "agent-delegation"` explicitly when creating a Leader-dispatched session and on its input options. A delegated session cannot reclassify itself as human. Browser composers identify human input; unmarked legacy/API records remain unknown. This provenance follows the durable capture event key through the event bridge. No task is inferred from prose claiming that an agent is running. Drivers advertise `capabilities.backgroundTasks`; unsupported drivers show the limitation explicitly.
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: Claude AI Workbench API
4
- version: 0.13.5
4
+ version: 0.13.6
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanqianx/claude-ai-workbench",
3
- "version": "0.13.5",
3
+ "version": "0.13.6",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/client",
3
- "version": "0.13.5",
3
+ "version": "0.13.6",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "browser": "index.js",
@@ -20,7 +20,7 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@claude-ai-workbench/contracts": "0.13.5"
23
+ "@claude-ai-workbench/contracts": "0.13.6"
24
24
  },
25
25
  "engines": {
26
26
  "node": ">=18"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/contracts",
3
- "version": "0.13.5",
3
+ "version": "0.13.6",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/core",
3
- "version": "0.13.5",
3
+ "version": "0.13.6",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "browser": "index.js",
@@ -20,7 +20,7 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@claude-ai-workbench/contracts": "0.13.5"
23
+ "@claude-ai-workbench/contracts": "0.13.6"
24
24
  },
25
25
  "engines": {
26
26
  "node": ">=18"
@@ -20,7 +20,8 @@ function parseLine(text) {
20
20
 
21
21
  function compactInto(events, event) {
22
22
  const previous = events[events.length - 1];
23
- if (previous && DELTAS.has(event.type) && previous.type === event.type && previous.id === event.id) {
23
+ if (previous && DELTAS.has(event.type) && previous.type === event.type && previous.id === event.id
24
+ && previous.parentToolUseId === event.parentToolUseId && previous.sdkSessionId === event.sdkSessionId && previous.turnId === event.turnId) {
24
25
  previous.text = String(previous.text || '') + String(event.text || '');
25
26
  // Provenance for later recovery: a merged row spans from its first
26
27
  // fragment's sequence, so a stale partial buffer can be told to replace
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/server",
3
- "version": "0.13.5",
3
+ "version": "0.13.6",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -25,7 +25,7 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "@anthropic-ai/claude-agent-sdk": "^0.2.141",
28
- "@claude-ai-workbench/contracts": "0.13.5"
28
+ "@claude-ai-workbench/contracts": "0.13.6"
29
29
  },
30
30
  "engines": {
31
31
  "node": ">=18"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/ui",
3
- "version": "0.13.5",
3
+ "version": "0.13.6",
4
4
  "type": "commonjs",
5
5
  "main": "ai-profile-settings.js",
6
6
  "browser": "ai-profile-settings.js",
@@ -33,7 +33,7 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
- "@claude-ai-workbench/core": "0.13.5"
36
+ "@claude-ai-workbench/core": "0.13.6"
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=18"
@@ -874,6 +874,10 @@ function cutSdkExecutionForNativeHistory(session, phase) {
874
874
  }
875
875
 
876
876
  function emit(session, event) {
877
+ if (event.type === 'claude/user-prompt') {
878
+ event.turnId ||= crypto.randomUUID();
879
+ session.inputTurnId = event.turnId;
880
+ } else if (session.sdkEventTurnId && !event.turnId) event.turnId = session.sdkEventTurnId;
877
881
  if (session.sdkEventParentToolUseId && !event.parentToolUseId) event.parentToolUseId = session.sdkEventParentToolUseId;
878
882
  // Only the SDK message currently being processed may lend its session id,
879
883
  // and only to events that state no fact of their own: an explicit
@@ -1462,6 +1466,7 @@ async function sendInput(sessionId, text, aiProfile = null, opts = {}) {
1462
1466
  }
1463
1467
  const isFresh = !session.claudeSessionId;
1464
1468
  emit(session, { type: 'claude/user-prompt', text, inputOrigin: opts.inputOrigin || 'unknown', attachments: Array.isArray(opts.displayAttachments) ? opts.displayAttachments : [], isFollowUp: !isFresh, isFresh });
1469
+ const inputTurnId = session.inputTurnId;
1465
1470
  const sdkOverrides = buildSdkOverridesFromProfile(aiProfile);
1466
1471
  session.permissionMode = normalizePermissionMode(opts.permissionMode || session.permissionMode || 'default');
1467
1472
  const envModel = session.claudeEnv && session.claudeEnv.ANTHROPIC_MODEL
@@ -1484,7 +1489,7 @@ async function sendInput(sessionId, text, aiProfile = null, opts = {}) {
1484
1489
  try {
1485
1490
  if (resident.query?.setPermissionMode) await resident.query.setPermissionMode(turnOpts.permissionMode);
1486
1491
  if (turnOpts.effort && resident.query?.setMaxThinkingTokens) await resident.query.setMaxThinkingTokens(EFFORT_THINKING_BUDGETS[normalizeEffortLevel(turnOpts.effort)]);
1487
- for await (const message of createSdkPrompt(turnOpts.userPrompt, turnOpts.attachments)) resident.send(message);
1492
+ for await (const message of createSdkPrompt(turnOpts.userPrompt, turnOpts.attachments)) resident.send(message, inputTurnId);
1488
1493
  session.turns++;
1489
1494
  setState(session, 'running', { turn: session.turns, runner: 'sdk' });
1490
1495
  } catch (error) { resident.close('input-failed'); throw error; }
@@ -1920,7 +1925,7 @@ async function runSdkTurn(session, opts, isResume, generation) {
1920
1925
  const attemptToolInputBuffers = attempt === 0 ? toolInputBuffers : new Map();
1921
1926
  try {
1922
1927
  const { SdkSessionStream, mainReplyEnded } = require('./sdk-session-stream');
1923
- const transport = new SdkSessionStream({ onMessage: message => {
1928
+ const transport = new SdkSessionStream({ onMessage: (message, ownership) => {
1924
1929
  if (!isCurrent()) return;
1925
1930
  if (!message.parent_tool_use_id && message.session_id) session.claudeSessionId = message.session_id;
1926
1931
  session.sdkEventParentToolUseId = message.parent_tool_use_id || null;
@@ -1930,9 +1935,10 @@ async function runSdkTurn(session, opts, isResume, generation) {
1930
1935
  // SDK envelope, and the stored claudeSessionId is never used to
1931
1936
  // backfill identity.
1932
1937
  session.sdkEventSessionId = typeof message.session_id === 'string' && message.session_id !== '' ? message.session_id : null;
1938
+ session.sdkEventTurnId = ownership.turnId;
1933
1939
  let event;
1934
1940
  try { event = handleNdjsonLine(session, JSON.stringify(message), attemptToolInputBuffers); }
1935
- finally { session.sdkEventParentToolUseId = null; session.sdkEventSessionId = null; }
1941
+ finally { session.sdkEventParentToolUseId = null; session.sdkEventSessionId = null; session.sdkEventTurnId = null; }
1936
1942
  // Existing normalized events were emitted above; the registry feed
1937
1943
  // comes strictly afterwards, and background subagent messages keep
1938
1944
  // arriving here after the main reply's end_turn.
@@ -1942,21 +1948,21 @@ async function runSdkTurn(session, opts, isResume, generation) {
1942
1948
  if (session.nativeTranscriptCollector) {
1943
1949
  try { session.nativeTranscriptCollector.notifySdkMessage(message); } catch { /* transcript reads must never break the stream */ }
1944
1950
  }
1945
- if (!message.parent_tool_use_id && event?.type === 'claude/result') {
1951
+ if (!message.parent_tool_use_id && event?.type === 'claude/result' && ownership.isCurrentTurn) {
1946
1952
  if (event.isError) {
1947
1953
  session.error = event.result || 'Claude Code returned an error result';
1948
1954
  setState(session, 'failed', { error: session.error });
1949
1955
  } else setState(session, 'idle', { exitCode: 0, message: 'turn complete, awaiting input' });
1950
- emit(session, { type: 'claude/turn-end', exitCode: event.isError ? 1 : 0 });
1956
+ emit(session, { type: 'claude/turn-end', exitCode: event.isError ? 1 : 0, boundary: 'result', turnId: ownership.turnId });
1951
1957
  // The query is ending: one bounded sweep reads every bound agent
1952
1958
  // already in a terminal state, so a completed background task is
1953
1959
  // never truncated by the main reply's end.
1954
1960
  if (session.nativeTranscriptCollector) {
1955
1961
  try { session.nativeTranscriptCollector.sweep('turn-end'); } catch { /* a sweep must never break the stream */ }
1956
1962
  }
1957
- } else if (mainReplyEnded(message) && session.state !== 'idle') {
1963
+ } else if (mainReplyEnded(message) && ownership.isCurrentTurn && session.state !== 'idle') {
1958
1964
  setState(session, 'idle', { message: 'main reply complete; background notifications remain subscribed' });
1959
- emit(session, { type: 'claude/turn-end', exitCode: 0, boundary: 'end_turn' });
1965
+ emit(session, { type: 'claude/turn-end', exitCode: 0, boundary: 'end_turn', turnId: ownership.turnId });
1960
1966
  if (session.nativeTranscriptCollector) {
1961
1967
  try { session.nativeTranscriptCollector.sweep('end-turn-boundary'); } catch { /* a sweep must never break the stream */ }
1962
1968
  }
@@ -1965,7 +1971,7 @@ async function runSdkTurn(session, opts, isResume, generation) {
1965
1971
  }
1966
1972
  } });
1967
1973
  session.sdkTransport = transport;
1968
- for await (const message of createSdkPrompt(opts.userPrompt, opts.attachments)) transport.send(message);
1974
+ for await (const message of createSdkPrompt(opts.userPrompt, opts.attachments)) transport.send(message, opts.turnId);
1969
1975
  queryInstance = query({
1970
1976
  prompt: transport.input,
1971
1977
  options: sdkOptions,
@@ -2042,6 +2048,7 @@ async function runSdkTurn(session, opts, isResume, generation) {
2042
2048
  }
2043
2049
 
2044
2050
  function startSdkTurn(session, opts, isResume) {
2051
+ opts.turnId ||= session.inputTurnId;
2045
2052
  const generation = (session.sdkTurnGeneration || 0) + 1;
2046
2053
  session.sdkTurnGeneration = generation;
2047
2054
  session.pendingPermission = null;
@@ -10,6 +10,12 @@ class SdkSessionStream {
10
10
  this.clearTimer = clearTimer;
11
11
  this.foregroundRunning = false;
12
12
  this.completedTurns = 0;
13
+ // A reply can end before its input's result arrives. Keep each input
14
+ // pending until that ordered result, even after accepting another input.
15
+ this.pendingTurns = [];
16
+ this.messageTurns = new Map();
17
+ this.currentTurn = null;
18
+ this.responseTurn = null;
13
19
  this.tasks = new Map();
14
20
  this.queue = [];
15
21
  this.waiter = null;
@@ -31,11 +37,13 @@ class SdkSessionStream {
31
37
  // unrecognized literal: still active, never a claimed terminal state.
32
38
  get activeTaskCount() { return [...this.tasks.values()].filter(status => status === 'running' || status === 'pending' || status === UNKNOWN_TASK_STATUS).length; }
33
39
 
34
- send(message) {
40
+ send(message, turnId = message.uuid || null) {
35
41
  if (this.closed) throw Object.assign(new Error('session transport is closed'), { code: 'SESSION_CLOSED' });
36
42
  if (this.foregroundRunning) throw Object.assign(new Error('session already has an active turn'), { code: 'TURN_BUSY', status: 409 });
37
43
  this.cancelIdle();
38
44
  this.foregroundRunning = true;
45
+ this.currentTurn = { ended: false, turnId };
46
+ this.pendingTurns.push(this.currentTurn);
39
47
  if (this.waiter) { const resolve = this.waiter; this.waiter = null; resolve({ value: message, done: false }); }
40
48
  else this.queue.push(message);
41
49
  }
@@ -55,14 +63,32 @@ class SdkSessionStream {
55
63
 
56
64
  observe(message) {
57
65
  const nested = Boolean(message.parent_tool_use_id);
66
+ let owner = this.responseTurn || this.currentTurn;
67
+ if (!nested && message.type === 'stream_event' && message.event?.type === 'message_start') {
68
+ if (!this.pendingTurns.length) {
69
+ this.currentTurn = { ended: false, turnId: this.currentTurn?.turnId || null };
70
+ this.pendingTurns.push(this.currentTurn);
71
+ }
72
+ owner = this.pendingTurns.find(turn => !turn.ended) || this.currentTurn;
73
+ this.responseTurn = owner;
74
+ if (message.event.message?.id && owner) this.messageTurns.set(message.event.message.id, owner);
75
+ } else if (!nested && message.type === 'assistant' && message.message?.id) {
76
+ // The completed assistant envelope can trail its streamed end_turn.
77
+ owner = this.messageTurns.get(message.message.id) || owner;
78
+ } else if (!nested && message.type === 'result') {
79
+ owner = this.pendingTurns.shift() || owner;
80
+ }
81
+ const isCurrentTurn = !owner || owner === this.currentTurn;
58
82
  if (message.type === 'system' && message.task_id) this._observeTask(message);
59
83
  if (!nested && (message.type === 'result' || mainReplyEnded(message))) {
60
- this.foregroundRunning = false;
84
+ if (owner) owner.ended = true;
85
+ if (isCurrentTurn) this.foregroundRunning = false;
61
86
  if (message.type === 'result') this.completedTurns++;
62
87
  } else if (!nested && message.type === 'stream_event' && message.event?.type === 'message_start') {
63
- this.foregroundRunning = true;
88
+ if (owner) owner.ended = false;
89
+ if (isCurrentTurn) this.foregroundRunning = true;
64
90
  }
65
- this.onMessage(message);
91
+ this.onMessage(message, { isCurrentTurn, turnId: owner?.turnId || null });
66
92
  this.cancelIdle();
67
93
  if (!this.foregroundRunning && !this.activeTaskCount) {
68
94
  this.idleTimer = this.setTimer(() => this.close('idle'), this.idleMs);