@prettier-ai/dsh-client-ui-chat 0.1.2-alpha.1 → 0.1.2-alpha.3

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/lib/client.js CHANGED
@@ -8,6 +8,7 @@ window.__ModuleLoader__.load({
8
8
  let react = require("react");
9
9
  let _prettier_ai_dsh_client_ui_primitives = require("@prettier-ai/dsh-client-ui-primitives");
10
10
  let _prettier_ai_dsh_client_store = require("@prettier-ai/dsh-client-store");
11
+ let react_dom = require("react-dom");
11
12
  //#region ../../util/workspace-path/src/index.ts
12
13
  /**
13
14
  * Browser-safe Workspace path and display helpers.
@@ -946,7 +947,7 @@ window.__ModuleLoader__.load({
946
947
  }
947
948
  //#endregion
948
949
  //#region \0dsh-css:/home/runner/work/dsh-publisher/dsh-publisher/upstream/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css.mjs
949
- const css$12 = ".BmHDMW_actions{height:calc(28px + var(--dsh-content-font-delta,0px));align-items:center;gap:10px;display:flex}.BmHDMW_timeStart{font-size:var(--dsh-content-font-size,14px);line-height:calc(24px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);white-space:nowrap;padding-right:12px}.BmHDMW_timeEnd{font-size:var(--dsh-content-font-size,14px);line-height:calc(24px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);white-space:nowrap;padding-left:12px}.BmHDMW_runTimeDot{margin:0 10px}@media (hover:hover){[data-time-hover-root] :is(.BmHDMW_timeStart,.BmHDMW_timeEnd){opacity:0;transition:opacity 80ms}[data-time-hover-root]:hover :is(.BmHDMW_timeStart,.BmHDMW_timeEnd),[data-time-hover-root]:focus-within :is(.BmHDMW_timeStart,.BmHDMW_timeEnd){opacity:1}}.BmHDMW_action{width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:28px;justify-content:center;align-items:center;padding:6px;display:inline-flex}.BmHDMW_action svg{width:calc(16px + var(--dsh-content-font-delta,0px));height:calc(16px + var(--dsh-content-font-delta,0px))}.BmHDMW_action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.BmHDMW_action[data-unavailable]{cursor:default;opacity:.4}.BmHDMW_action[data-unavailable]:hover{color:var(--dsw-alias-label-tertiary);background:0 0}.BmHDMW_visuallyHidden{clip:rect(0 0 0 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}";
950
+ const css$12 = ".BmHDMW_actions{height:calc(28px + var(--dsh-content-font-delta,0px));align-items:center;gap:8px;display:flex}.BmHDMW_timeStart{font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(24px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);white-space:nowrap;padding-right:12px}.BmHDMW_timeEnd{font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(24px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);white-space:nowrap}@media (hover:hover){[data-actions-reveal=hover] .BmHDMW_actions{opacity:0;transition:opacity 80ms}[data-actions-reveal=hover]:hover .BmHDMW_actions,[data-actions-reveal=hover]:focus-within .BmHDMW_actions{opacity:1}}.BmHDMW_action{width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:28px;justify-content:center;align-items:center;padding:6px;display:inline-flex}.BmHDMW_action svg{width:calc(15px + var(--dsh-content-font-delta,0px));height:calc(15px + var(--dsh-content-font-delta,0px))}.BmHDMW_action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.BmHDMW_action[data-unavailable]{cursor:default;opacity:.4}.BmHDMW_action[data-unavailable]:hover{color:var(--dsw-alias-label-tertiary);background:0 0}.BmHDMW_visuallyHidden{clip:rect(0 0 0 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}";
950
951
  const tagId$12 = "@prettier-ai/dsh-client-ui-chat/MessageIconActions.module.css";
951
952
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$12) + "]") === null) {
952
953
  const tag = document.createElement("style");
@@ -958,7 +959,6 @@ window.__ModuleLoader__.load({
958
959
  var MessageIconActions_module_css_default = {
959
960
  "action": "BmHDMW_action",
960
961
  "actions": "BmHDMW_actions",
961
- "runTimeDot": "BmHDMW_runTimeDot",
962
962
  "timeEnd": "BmHDMW_timeEnd",
963
963
  "timeStart": "BmHDMW_timeStart",
964
964
  "visuallyHidden": "BmHDMW_visuallyHidden"
@@ -970,7 +970,7 @@ window.__ModuleLoader__.load({
970
970
  * @param props - Copy text, event time, clock side, branch callback, className.
971
971
  * @returns The actions row element.
972
972
  */
973
- function MessageIconActions({ text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, extraActions, t }) {
973
+ function MessageIconActions({ text, time, clock, onBranch, branchUnavailable = false, className, extraActions, usageAction, t }) {
974
974
  const day = useCalendarDay();
975
975
  const reasonId = (0, react.useId)();
976
976
  const [copied, setCopied] = (0, react.useState)(false);
@@ -997,41 +997,9 @@ window.__ModuleLoader__.load({
997
997
  }, 1e3);
998
998
  });
999
999
  }, [copied, text]);
1000
- const clockEl = time === void 0 ? null : (0, react_jsx_runtime.jsxs)("span", {
1000
+ const clockEl = time === void 0 ? null : (0, react_jsx_runtime.jsx)("span", {
1001
1001
  className: clock === "start" ? MessageIconActions_module_css_default.timeStart : MessageIconActions_module_css_default.timeEnd,
1002
- children: [
1003
- formatMessageClock(time, t, day),
1004
- runMs !== void 0 && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1005
- " ",
1006
- (0, react_jsx_runtime.jsx)("span", {
1007
- className: MessageIconActions_module_css_default.runTimeDot,
1008
- "aria-hidden": true,
1009
- children: "·"
1010
- }),
1011
- " ",
1012
- t("message.ranFor", { duration: formatRunDuration(runMs, t) })
1013
- ] }),
1014
- ttftMs !== void 0 && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1015
- " ",
1016
- (0, react_jsx_runtime.jsx)("span", {
1017
- className: MessageIconActions_module_css_default.runTimeDot,
1018
- "aria-hidden": true,
1019
- children: "·"
1020
- }),
1021
- " ",
1022
- t("message.ttft", { seconds: formatLatencySeconds(ttftMs) })
1023
- ] }),
1024
- tokensPerSecond !== void 0 && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1025
- " ",
1026
- (0, react_jsx_runtime.jsx)("span", {
1027
- className: MessageIconActions_module_css_default.runTimeDot,
1028
- "aria-hidden": true,
1029
- children: "·"
1030
- }),
1031
- " ",
1032
- t("message.tokensPerSecond", { tps: formatTokensPerSecond(tokensPerSecond) })
1033
- ] })
1034
- ]
1002
+ children: formatMessageClock(time, t, day)
1035
1003
  });
1036
1004
  return (0, react_jsx_runtime.jsxs)("div", {
1037
1005
  className: className === void 0 ? MessageIconActions_module_css_default.actions : `${MessageIconActions_module_css_default.actions} ${className}`,
@@ -1068,6 +1036,7 @@ window.__ModuleLoader__.load({
1068
1036
  className: MessageIconActions_module_css_default.visuallyHidden,
1069
1037
  children: t("message.branchUnavailable")
1070
1038
  }),
1039
+ usageAction,
1071
1040
  clock === "end" ? clockEl : null
1072
1041
  ]
1073
1042
  });
@@ -1200,7 +1169,7 @@ window.__ModuleLoader__.load({
1200
1169
  });
1201
1170
  }
1202
1171
  /** Right-aligned bubble shared by user and steering rows. */
1203
- function UserStyleBubble({ content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t }) {
1172
+ function UserStyleBubble({ content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, reveal = "always", t }) {
1204
1173
  const { text, images: contentImages, rest } = contentParts(content);
1205
1174
  const images = previewImages ?? contentImages;
1206
1175
  const truncated = (total) => t("json.truncated", { total });
@@ -1209,7 +1178,7 @@ window.__ModuleLoader__.load({
1209
1178
  className: MessageItem_module_css_default.userRow,
1210
1179
  "data-pending-steering": pending || void 0,
1211
1180
  "data-submission-echo": echo || void 0,
1212
- "data-time-hover-root": true,
1181
+ "data-actions-reveal": reveal,
1213
1182
  children: [(0, react_jsx_runtime.jsxs)("div", {
1214
1183
  className: MessageItem_module_css_default.userStack,
1215
1184
  children: [
@@ -1254,10 +1223,10 @@ window.__ModuleLoader__.load({
1254
1223
  });
1255
1224
  }
1256
1225
  /**
1257
- * Render one local submission echo with the exact visual language of the
1258
- * durable user node that replaces it: draft text plus object-URL previews,
1259
- * visible from the submit click until the durable `user/message` (or its
1260
- * queue occurrence) renders.
1226
+ * Render one local transcript or steering submission echo with the same
1227
+ * visual language and surface marker as the Host occurrence that replaces
1228
+ * it: draft text plus object-URL previews, visible from the submit click
1229
+ * until the durable `user/message` or steering occurrence renders.
1261
1230
  * @param props - the session snapshot's pending submission and render seats.
1262
1231
  * @returns the echoed user bubble.
1263
1232
  */
@@ -1274,6 +1243,7 @@ window.__ModuleLoader__.load({
1274
1243
  ...image.height === void 0 ? {} : { height: image.height }
1275
1244
  } })), [submission.images]),
1276
1245
  renderMessageImages,
1246
+ pending: submission.placement === "steering",
1277
1247
  echo: true,
1278
1248
  t,
1279
1249
  actions: (text) => (0, react_jsx_runtime.jsx)(MessageIconActions, {
@@ -1286,12 +1256,20 @@ window.__ModuleLoader__.load({
1286
1256
  });
1287
1257
  }
1288
1258
  /** User and admitted-steering keyed Chat renderer. */
1289
- const UserMessageNodeView = (0, react.memo)(function UserMessageNodeView({ node, renderMessageImages, t }) {
1259
+ const UserMessageNodeView = (0, react.memo)(function UserMessageNodeView({ node, renderMessageImages, useChat, t }) {
1290
1260
  const data = node.data;
1261
+ const isLatestUserRow = useChat((snapshot) => {
1262
+ for (let index = snapshot.order.length - 1; index >= 0; index -= 1) {
1263
+ const candidate = snapshot.nodes.get(snapshot.order[index] ?? "");
1264
+ if (candidate?.kind === "user" || candidate?.kind === "steering") return candidate.key === node.key;
1265
+ }
1266
+ return true;
1267
+ });
1291
1268
  return (0, react_jsx_runtime.jsx)(UserStyleBubble, {
1292
1269
  content: data.content,
1293
1270
  renderMessageImages,
1294
1271
  ...data.referenceLabels === void 0 ? {} : { referenceLabels: data.referenceLabels },
1272
+ reveal: isLatestUserRow ? "always" : "hover",
1295
1273
  t,
1296
1274
  actions: (text) => (0, react_jsx_runtime.jsx)(MessageIconActions, {
1297
1275
  text,
@@ -1650,7 +1628,7 @@ window.__ModuleLoader__.load({
1650
1628
  });
1651
1629
  //#endregion
1652
1630
  //#region \0dsh-css:/home/runner/work/dsh-publisher/dsh-publisher/upstream/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css.mjs
1653
- const css$10 = ".alqd3G_slot{z-index:6;pointer-events:none;height:0;position:sticky;top:0}.alqd3G_rail{--turn-rail-band:calc(var(--dsh-conversation-viewport-height,100dvh) - var(--dsh-composer-height,152px));--turn-preview-height:100px;top:calc(var(--turn-rail-band) / 2);right:calc(12px - (var(--dsh-composer-side-clearance) + 16px));width:28px;height:min(var(--turn-natural-height), max(0px, calc(var(--turn-rail-band) - 64px)), 420px);cursor:pointer;pointer-events:auto;transition:height .22s cubic-bezier(.2,.8,.2,1);position:absolute;transform:translateY(-50%)}.alqd3G_marks{inset:var(--turn-rail-inset) 0;position:absolute}.alqd3G_markPosition{top:min(var(--turn-natural-position), var(--turn-position));height:10px;transition:top .22s cubic-bezier(.2,.8,.2,1);animation:.15s ease-out alqd3G_dsh-turn-mark-enter;position:absolute;left:0;right:0;transform:translateY(-50%)}.alqd3G_mark{cursor:pointer;pointer-events:none;background:0 0;border:0;border-radius:8px;width:20px;padding:0;position:absolute;inset:0 0 0 auto}.alqd3G_mark:before{background:var(--dsw-alias-border-l4);content:\"\";border-radius:2px;width:12px;height:2px;transition:width .14s,background-color .14s;position:absolute;top:50%;right:0;transform:translateY(-50%)}.alqd3G_markPreview:before{background:var(--dsw-alias-label-tertiary);width:18px}.alqd3G_markActive:before{background:var(--dsw-alias-label-primary);width:20px}.alqd3G_mark:focus-visible:before{background:var(--dsw-alias-state-business-primary);width:20px}.alqd3G_mark:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:2px}.alqd3G_preview{top:clamp(0px, calc(min(var(--turn-natural-position), var(--turn-position)) + var(--turn-rail-inset) - var(--turn-preview-height) / 2), calc(100% - var(--turn-preview-height)));box-sizing:border-box;width:min(300px,100cqw - 120px);max-height:var(--turn-preview-height);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-shadow-lv2);pointer-events:none;border-radius:10px;padding:10px 12px;transition:top .14s cubic-bezier(.2,.8,.2,1);animation:.12s ease-out alqd3G_dsh-turn-preview-enter;position:absolute;right:calc(100% + 10px);overflow:hidden}.alqd3G_previewPrompt,.alqd3G_previewResponse{-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.alqd3G_previewPrompt{font:var(--dsw-font-xs-strong-13);-webkit-line-clamp:2}.alqd3G_previewResponse{color:var(--dsw-alias-label-caption);font:var(--dsw-font-xxs-12);-webkit-line-clamp:2;margin-top:4px}@keyframes alqd3G_dsh-turn-mark-enter{0%{opacity:0}to{opacity:1}}@keyframes alqd3G_dsh-turn-preview-enter{0%{opacity:0;transform:translate(4px)}to{opacity:1;transform:translate(0)}}@container (width<=900px){.alqd3G_slot{display:none}}@media (prefers-reduced-motion:reduce){.alqd3G_rail,.alqd3G_markPosition,.alqd3G_mark:before,.alqd3G_preview{transition:none;animation:none}}";
1631
+ const css$10 = ".alqd3G_slot{z-index:6;pointer-events:none;height:0;position:sticky;top:0}.alqd3G_frame{--turn-rail-band:calc(var(--dsh-conversation-viewport-height,100dvh) - var(--dsh-composer-height,152px));--turn-preview-height:100px;top:calc(var(--turn-rail-band) / 2);right:calc(12px - (var(--dsh-composer-side-clearance) + 16px));width:28px;height:min(var(--turn-natural-height), max(0px, calc(var(--turn-rail-band) - 64px)), 420px);cursor:pointer;pointer-events:auto;transition:height .22s cubic-bezier(.2,.8,.2,1);position:absolute;transform:translateY(-50%)}.alqd3G_scroller{overscroll-behavior:contain;scrollbar-width:none;position:absolute;inset:0;overflow-y:auto}.alqd3G_scroller::-webkit-scrollbar{display:none}.alqd3G_fadeTop{mask-image:linear-gradient(#0000 0,#000 24px 100%)}.alqd3G_fadeBottom{mask-image:linear-gradient(#000 0 calc(100% - 24px),#0000 100%)}.alqd3G_fadeTop.alqd3G_fadeBottom{mask-image:linear-gradient(#0000 0,#000 24px calc(100% - 24px),#0000 100%)}.alqd3G_marks{height:var(--turn-natural-height);position:relative}.alqd3G_markPosition{top:calc(var(--turn-natural-position) + var(--turn-rail-inset));height:10px;transition:top .22s cubic-bezier(.2,.8,.2,1);animation:.15s ease-out alqd3G_dsh-turn-mark-enter;position:absolute;left:0;right:0;transform:translateY(-50%)}.alqd3G_mark{cursor:pointer;pointer-events:none;background:0 0;border:0;border-radius:8px;width:20px;padding:0;position:absolute;inset:0 0 0 auto}.alqd3G_mark:before{background:var(--dsw-alias-border-l4);content:\"\";border-radius:2px;width:12px;height:2px;transition:width .14s,background-color .14s;position:absolute;top:50%;right:0;transform:translateY(-50%)}.alqd3G_markUnloaded:before{opacity:.6;width:8px}.alqd3G_markPreview:before{background:var(--dsw-alias-label-tertiary);width:18px}.alqd3G_markBusy:before{animation:1s ease-in-out infinite alqd3G_dsh-turn-mark-busy}.alqd3G_markActive:before{background:var(--dsw-alias-label-primary);width:20px}.alqd3G_mark:focus-visible:before{background:var(--dsw-alias-state-business-primary);width:20px}.alqd3G_mark:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:2px}.alqd3G_preview{top:clamp(0px, calc(var(--turn-natural-position) + var(--turn-rail-inset) - var(--turn-scroll-top,0px) - var(--turn-preview-height) / 2), calc(100% - var(--turn-preview-height)));box-sizing:border-box;width:min(300px,100cqw - 120px);max-height:var(--turn-preview-height);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-shadow-lv2);pointer-events:none;border-radius:10px;padding:10px 12px;transition:top .14s cubic-bezier(.2,.8,.2,1);animation:.12s ease-out alqd3G_dsh-turn-preview-enter;position:absolute;right:calc(100% + 10px);overflow:hidden}.alqd3G_previewPrompt,.alqd3G_previewResponse{-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.alqd3G_previewPrompt{font:var(--dsw-font-xs-strong-13);-webkit-line-clamp:1}.alqd3G_previewResponse{color:var(--dsw-alias-label-caption);font:var(--dsw-font-xxs-12);-webkit-line-clamp:3;margin-top:4px}@keyframes alqd3G_dsh-turn-mark-enter{0%{opacity:0}to{opacity:1}}@keyframes alqd3G_dsh-turn-preview-enter{0%{opacity:0;transform:translate(4px)}to{opacity:1;transform:translate(0)}}@keyframes alqd3G_dsh-turn-mark-busy{0%,to{opacity:1}50%{opacity:.35}}@container (width<=900px){.alqd3G_slot{display:none}}@media (prefers-reduced-motion:reduce){.alqd3G_frame,.alqd3G_scroller,.alqd3G_markPosition,.alqd3G_mark:before,.alqd3G_markBusy:before,.alqd3G_preview{scroll-behavior:auto;transition:none;animation:none}}";
1654
1632
  const tagId$10 = "@prettier-ai/dsh-client-ui-chat/TurnNavigator.module.css";
1655
1633
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$10) + "]") === null) {
1656
1634
  const tag = document.createElement("style");
@@ -1660,96 +1638,173 @@ window.__ModuleLoader__.load({
1660
1638
  document.head.appendChild(tag);
1661
1639
  }
1662
1640
  var TurnNavigator_module_css_default = {
1641
+ "dsh-turn-mark-busy": "alqd3G_dsh-turn-mark-busy",
1663
1642
  "dsh-turn-mark-enter": "alqd3G_dsh-turn-mark-enter",
1664
1643
  "dsh-turn-preview-enter": "alqd3G_dsh-turn-preview-enter",
1644
+ "fadeBottom": "alqd3G_fadeBottom",
1645
+ "fadeTop": "alqd3G_fadeTop",
1646
+ "frame": "alqd3G_frame",
1665
1647
  "mark": "alqd3G_mark",
1666
1648
  "markActive": "alqd3G_markActive",
1649
+ "markBusy": "alqd3G_markBusy",
1667
1650
  "markPosition": "alqd3G_markPosition",
1668
1651
  "markPreview": "alqd3G_markPreview",
1652
+ "markUnloaded": "alqd3G_markUnloaded",
1669
1653
  "marks": "alqd3G_marks",
1670
1654
  "preview": "alqd3G_preview",
1671
1655
  "previewPrompt": "alqd3G_previewPrompt",
1672
1656
  "previewResponse": "alqd3G_previewResponse",
1673
- "rail": "alqd3G_rail",
1657
+ "scroller": "alqd3G_scroller",
1674
1658
  "slot": "alqd3G_slot"
1675
1659
  };
1676
1660
  //#endregion
1677
1661
  //#region lib/types/client/chat/TurnNavigator.js
1678
- /** Resting gap between neighbouring marks before the rail compresses to fit. */
1662
+ /** Fixed pitch between neighbouring marks; overflow scrolls inside the frame. */
1679
1663
  const TURN_SPACING_PX = 10;
1680
1664
  /** Rail padding above the first mark and below the last one, per end. */
1681
1665
  const RAIL_INSET_PX = 6;
1682
- function itemPosition(index, count) {
1683
- const ratio = count <= 1 ? 0 : index / (count - 1);
1666
+ /** Fade band the mask reserves at a scrollable end. */
1667
+ const FADE_PX = 24;
1668
+ function itemPosition(index) {
1669
+ return { "--turn-natural-position": `${String(index * TURN_SPACING_PX)}px` };
1670
+ }
1671
+ function frameStyle(count, scrollTop) {
1684
1672
  return {
1685
- "--turn-natural-position": `${String(index * TURN_SPACING_PX)}px`,
1686
- "--turn-position": `${String(ratio * 100)}%`
1673
+ "--turn-natural-height": `${String((count - 1) * TURN_SPACING_PX + 2 * RAIL_INSET_PX)}px`,
1674
+ "--turn-rail-inset": `${String(RAIL_INSET_PX)}px`,
1675
+ "--turn-scroll-top": `${String(scrollTop)}px`
1687
1676
  };
1688
1677
  }
1689
- function railSize(count) {
1678
+ function itemAtPointer(items, frame, scrollTop, clientY) {
1679
+ const offset = clientY - frame.getBoundingClientRect().top + scrollTop - RAIL_INSET_PX;
1680
+ return items[Math.max(0, Math.min(items.length - 1, Math.round(offset / TURN_SPACING_PX)))];
1681
+ }
1682
+ const RAIL_AT_REST = {
1683
+ top: 0,
1684
+ canScrollUp: false,
1685
+ canScrollDown: false
1686
+ };
1687
+ function railScrollState(scroller) {
1688
+ const top = scroller.scrollTop;
1690
1689
  return {
1691
- "--turn-natural-height": `${String((count - 1) * TURN_SPACING_PX + 2 * RAIL_INSET_PX)}px`,
1692
- "--turn-rail-inset": `${String(RAIL_INSET_PX)}px`
1690
+ top,
1691
+ canScrollUp: top > 1,
1692
+ canScrollDown: top < scroller.scrollHeight - scroller.clientHeight - 1
1693
1693
  };
1694
1694
  }
1695
- function itemAtPointer(items, rail, clientY) {
1696
- const rect = rail.getBoundingClientRect();
1697
- const usableHeight = Math.max(1, rect.height - 2 * RAIL_INSET_PX);
1698
- const ratio = Math.max(0, Math.min(1, (clientY - rect.top - RAIL_INSET_PX) / usableHeight));
1699
- return items[Math.round(ratio * (items.length - 1))];
1695
+ function sameRailScrollState(left, right) {
1696
+ return left.top === right.top && left.canScrollUp === right.canScrollUp && left.canScrollDown === right.canScrollDown;
1700
1697
  }
1701
- function TurnNavigatorRail({ items, activeTurn, onNavigate, t }) {
1698
+ function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, t }) {
1702
1699
  const [previewTurn, setPreviewTurn] = (0, react.useState)(null);
1700
+ const [scrollState, setScrollState] = (0, react.useState)(RAIL_AT_REST);
1701
+ const scrollerRef = (0, react.useRef)(null);
1702
+ /** While the pointer works the rail, follow must not move it under the hand. */
1703
+ const pointerInsideRef = (0, react.useRef)(false);
1703
1704
  const previewId = (0, react.useId)();
1705
+ const syncScrollState = () => {
1706
+ const scroller = scrollerRef.current;
1707
+ if (scroller === null) return;
1708
+ const next = railScrollState(scroller);
1709
+ setScrollState((current) => sameRailScrollState(current, next) ? current : next);
1710
+ };
1711
+ (0, react.useEffect)(() => {
1712
+ const scroller = scrollerRef.current;
1713
+ if (scroller === null || typeof ResizeObserver === "undefined") return;
1714
+ const observer = new ResizeObserver(syncScrollState);
1715
+ observer.observe(scroller);
1716
+ return () => {
1717
+ observer.disconnect();
1718
+ };
1719
+ }, []);
1720
+ (0, react.useEffect)(syncScrollState, [items.length]);
1721
+ (0, react.useEffect)(() => {
1722
+ const scroller = scrollerRef.current;
1723
+ const index = items.findIndex((item) => item.turn === activeTurn);
1724
+ if (scroller === null || index < 0 || pointerInsideRef.current) return;
1725
+ const markTop = index * TURN_SPACING_PX + RAIL_INSET_PX;
1726
+ const viewTop = scroller.scrollTop;
1727
+ const viewHeight = scroller.clientHeight;
1728
+ if (viewHeight <= 0 || markTop >= viewTop + FADE_PX && markTop <= viewTop + viewHeight - FADE_PX) return;
1729
+ const target = Math.max(0, markTop - viewHeight / 2);
1730
+ const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
1731
+ if (typeof scroller.scrollTo === "function") scroller.scrollTo({
1732
+ top: target,
1733
+ behavior: reduced ? "auto" : "smooth"
1734
+ });
1735
+ else scroller.scrollTop = target;
1736
+ syncScrollState();
1737
+ }, [activeTurn, items]);
1704
1738
  if (items.length < 2) return null;
1705
1739
  const previewIndex = items.findIndex((item) => item.turn === previewTurn);
1706
1740
  const preview = previewIndex < 0 ? void 0 : items[previewIndex];
1707
- const previewPosition = previewIndex < 0 ? void 0 : itemPosition(previewIndex, items.length);
1741
+ const previewPosition = previewIndex < 0 ? void 0 : itemPosition(previewIndex);
1708
1742
  const previewAtPointer = (event) => {
1709
- setPreviewTurn(itemAtPointer(items, event.currentTarget, event.clientY)?.turn ?? null);
1743
+ const scrollTop = scrollerRef.current?.scrollTop ?? 0;
1744
+ setPreviewTurn(itemAtPointer(items, event.currentTarget, scrollTop, event.clientY)?.turn ?? null);
1710
1745
  };
1711
1746
  const navigateAtPointer = (event) => {
1712
- const item = itemAtPointer(items, event.currentTarget, event.clientY);
1747
+ const scrollTop = scrollerRef.current?.scrollTop ?? 0;
1748
+ const item = itemAtPointer(items, event.currentTarget, scrollTop, event.clientY);
1713
1749
  if (item !== void 0) onNavigate(item);
1714
1750
  };
1751
+ const fadeClasses = [TurnNavigator_module_css_default.scroller];
1752
+ if (scrollState.canScrollUp) fadeClasses.push(TurnNavigator_module_css_default.fadeTop);
1753
+ if (scrollState.canScrollDown) fadeClasses.push(TurnNavigator_module_css_default.fadeBottom);
1715
1754
  return (0, react_jsx_runtime.jsx)("div", {
1716
1755
  className: TurnNavigator_module_css_default.slot,
1717
1756
  children: (0, react_jsx_runtime.jsxs)("nav", {
1718
- className: TurnNavigator_module_css_default.rail,
1719
- style: railSize(items.length),
1757
+ className: TurnNavigator_module_css_default.frame,
1758
+ style: frameStyle(items.length, scrollState.top),
1720
1759
  "aria-label": t("chat.turnNavigation.label"),
1721
1760
  onClick: navigateAtPointer,
1722
1761
  onPointerMove: previewAtPointer,
1762
+ onPointerEnter: () => {
1763
+ pointerInsideRef.current = true;
1764
+ },
1723
1765
  onPointerLeave: () => {
1766
+ pointerInsideRef.current = false;
1724
1767
  setPreviewTurn(null);
1725
1768
  },
1726
1769
  children: [(0, react_jsx_runtime.jsx)("div", {
1727
- className: TurnNavigator_module_css_default.marks,
1728
- children: items.map((item, index) => {
1729
- const active = item.turn === activeTurn;
1730
- const showingPreview = item.turn === previewTurn;
1731
- const markClass = active ? `${TurnNavigator_module_css_default.mark} ${TurnNavigator_module_css_default.markActive}` : showingPreview ? `${TurnNavigator_module_css_default.mark} ${TurnNavigator_module_css_default.markPreview}` : TurnNavigator_module_css_default.mark;
1732
- return (0, react_jsx_runtime.jsx)("div", {
1733
- className: TurnNavigator_module_css_default.markPosition,
1734
- style: itemPosition(index, items.length),
1735
- children: (0, react_jsx_runtime.jsx)("button", {
1736
- type: "button",
1737
- className: markClass,
1738
- "aria-label": t("chat.turnNavigation.jump", { turn: item.turn }),
1739
- "aria-current": active ? "true" : void 0,
1740
- "aria-describedby": showingPreview ? previewId : void 0,
1741
- onClick: (event) => {
1742
- event.stopPropagation();
1743
- onNavigate(item);
1744
- },
1745
- onFocus: () => {
1746
- setPreviewTurn(item.turn);
1747
- },
1748
- onBlur: () => {
1749
- setPreviewTurn(null);
1750
- }
1751
- })
1752
- }, item.turn);
1770
+ ref: scrollerRef,
1771
+ className: fadeClasses.join(" "),
1772
+ onScroll: () => {
1773
+ syncScrollState();
1774
+ },
1775
+ children: (0, react_jsx_runtime.jsx)("div", {
1776
+ className: TurnNavigator_module_css_default.marks,
1777
+ children: items.map((item, index) => {
1778
+ const active = item.turn === activeTurn;
1779
+ const showingPreview = item.turn === previewTurn;
1780
+ const classes = [TurnNavigator_module_css_default.mark];
1781
+ if (item.anchor.kind === "unloaded") classes.push(TurnNavigator_module_css_default.markUnloaded);
1782
+ if (active) classes.push(TurnNavigator_module_css_default.markActive);
1783
+ else if (showingPreview) classes.push(TurnNavigator_module_css_default.markPreview);
1784
+ if (item.turn === busyTurn) classes.push(TurnNavigator_module_css_default.markBusy);
1785
+ return (0, react_jsx_runtime.jsx)("div", {
1786
+ className: TurnNavigator_module_css_default.markPosition,
1787
+ style: itemPosition(index),
1788
+ children: (0, react_jsx_runtime.jsx)("button", {
1789
+ type: "button",
1790
+ className: classes.join(" "),
1791
+ "aria-label": t(item.anchor.kind === "loaded" ? "chat.turnNavigation.jump" : "chat.turnNavigation.jumpLoad", { turn: item.turn }),
1792
+ "aria-current": active ? "true" : void 0,
1793
+ "aria-busy": item.turn === busyTurn ? "true" : void 0,
1794
+ "aria-describedby": showingPreview ? previewId : void 0,
1795
+ onClick: (event) => {
1796
+ event.stopPropagation();
1797
+ onNavigate(item);
1798
+ },
1799
+ onFocus: () => {
1800
+ setPreviewTurn(item.turn);
1801
+ },
1802
+ onBlur: () => {
1803
+ setPreviewTurn(null);
1804
+ }
1805
+ })
1806
+ }, item.turn);
1807
+ })
1753
1808
  })
1754
1809
  }), preview !== void 0 && previewPosition !== void 0 && (0, react_jsx_runtime.jsxs)("div", {
1755
1810
  id: previewId,
@@ -1768,9 +1823,12 @@ window.__ModuleLoader__.load({
1768
1823
  });
1769
1824
  }
1770
1825
  /**
1771
- * Compact rail of the currently loaded Turns with hover and focus previews.
1826
+ * Fixed-pitch rail of every known Turn loaded marks scroll, unloaded marks
1827
+ * page history in first — with hover and focus previews. Overflow scrolls
1828
+ * inside the frame, gradient fades marking each scrollable end, and the
1829
+ * active mark keeps itself in view while the pointer is elsewhere.
1772
1830
  *
1773
- * Memoized because it renders two host elements per loaded Turn while the
1831
+ * Memoized because it renders two host elements per Turn while the
1774
1832
  * enclosing view re-renders on every streaming delta: without the guard a long
1775
1833
  * session rebuilds hundreds of marks per commit for a rail that only changes
1776
1834
  * when a Turn is added, removed, or becomes active. Its props must therefore
@@ -1778,6 +1836,79 @@ window.__ModuleLoader__.load({
1778
1836
  */
1779
1837
  const TurnNavigator = (0, react.memo)(TurnNavigatorRail);
1780
1838
  //#endregion
1839
+ //#region lib/types/client/chat/turn-rail-items.js
1840
+ /**
1841
+ * View-layer union of the host turn outline and the loaded rail items. The
1842
+ * conversation snapshot never carries projection values, so this merge is the
1843
+ * one place the rail's two sources meet: the `turnOutline` projection names
1844
+ * every turn of the session, and the loaded window supplies anchors and
1845
+ * richer previews for the turns it holds.
1846
+ */
1847
+ const EMPTY_ITEMS$1 = [];
1848
+ /**
1849
+ * Structurally narrow one wire outline entry (projection values cross the
1850
+ * wire). `turn` and `seq` are the load-bearing fields — a mark cannot exist
1851
+ * or jump without them — so their damage drops the entry; the previews are
1852
+ * decorative, so a malformed one degrades to `''` and the turn stays
1853
+ * navigable by number.
1854
+ */
1855
+ function outlineEntry(value) {
1856
+ if (typeof value !== "object" || value === null) return void 0;
1857
+ const entry = value;
1858
+ if (typeof entry.turn !== "number" || !Number.isSafeInteger(entry.turn) || entry.turn < 0) return void 0;
1859
+ if (typeof entry.seq !== "number" || !Number.isSafeInteger(entry.seq) || entry.seq < 0) return void 0;
1860
+ return {
1861
+ turn: entry.turn,
1862
+ seq: entry.seq,
1863
+ prompt: typeof entry.prompt === "string" ? entry.prompt : "",
1864
+ response: typeof entry.response === "string" ? entry.response : ""
1865
+ };
1866
+ }
1867
+ /** Wire outline entries, or none when the projection is absent or malformed. */
1868
+ function outlineEntries(outline) {
1869
+ return Array.isArray(outline) ? outline : EMPTY_ITEMS$1;
1870
+ }
1871
+ /**
1872
+ * Merge the host outline with the loaded rail items into the full ladder.
1873
+ * A turn present in both sides keeps the loaded anchor, taking an outline
1874
+ * preview only where the window's own is empty (a mid-Turn window head, or a
1875
+ * turn whose loaded nodes carry no text); turns on one side only pass
1876
+ * through. Result ascends by turn.
1877
+ * @param loaded - loaded-window rail items (timeline order).
1878
+ * @param outline - `turnOutline` projection value, treated as wire data.
1879
+ * @returns every known turn, ascending; a stable empty array when none.
1880
+ */
1881
+ function mergeTurnRailItems(loaded, outline) {
1882
+ const byTurn = /* @__PURE__ */ new Map();
1883
+ for (const raw of outlineEntries(outline)) {
1884
+ const entry = outlineEntry(raw);
1885
+ if (entry === void 0) continue;
1886
+ byTurn.set(entry.turn, {
1887
+ turn: entry.turn,
1888
+ prompt: entry.prompt,
1889
+ response: entry.response,
1890
+ anchor: {
1891
+ kind: "unloaded",
1892
+ seq: entry.seq
1893
+ }
1894
+ });
1895
+ }
1896
+ for (const item of loaded) {
1897
+ const preview = byTurn.get(item.turn);
1898
+ byTurn.set(item.turn, {
1899
+ turn: item.turn,
1900
+ prompt: item.prompt !== "" ? item.prompt : preview?.prompt ?? "",
1901
+ response: item.response !== "" ? item.response : preview?.response ?? "",
1902
+ anchor: {
1903
+ kind: "loaded",
1904
+ key: item.anchorKey
1905
+ }
1906
+ });
1907
+ }
1908
+ if (byTurn.size === 0) return EMPTY_ITEMS$1;
1909
+ return [...byTurn.values()].sort((left, right) => left.turn - right.turn);
1910
+ }
1911
+ //#endregion
1781
1912
  //#region lib/types/client/chat/ChatView.js
1782
1913
  /** Active column host when present; otherwise the view-local scroller. */
1783
1914
  function scrollerOf(from) {
@@ -1915,10 +2046,12 @@ window.__ModuleLoader__.load({
1915
2046
  * The chat view slot entry: pure component over the composed props; each
1916
2047
  * ordered business Node crosses the keyed renderer seat.
1917
2048
  */
1918
- function ChatView({ useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt, fileMentions, useTranscriptView, t }) {
2049
+ function ChatView({ useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions, useTranscriptView, useProjection, t }) {
1919
2050
  const order = useChat((s) => s.order);
1920
2051
  const nodeStore = useChat((s) => s.nodes);
1921
2052
  const turnNavigationItems = useChat((s) => s.navigation.items());
2053
+ const turnOutline = useProjection("turnOutline");
2054
+ const railItems = (0, react.useMemo)(() => mergeTurnRailItems(turnNavigationItems, turnOutline), [turnNavigationItems, turnOutline]);
1922
2055
  const timeline = useChat((s) => s.timeline);
1923
2056
  const inbox = useSession((s) => s.queue);
1924
2057
  const cwd = useSessions((s) => s.byId[sessionId]?.cwd);
@@ -1961,7 +2094,7 @@ window.__ModuleLoader__.load({
1961
2094
  const visibleSubmissions = (0, react.useMemo)(() => {
1962
2095
  if (pendingSubmissions.length === 0) return pendingSubmissions;
1963
2096
  const observed = observedRpcIds(order, nodeStore, inbox);
1964
- return pendingSubmissions.filter((submission) => !observed.has(submission.requestId));
2097
+ return pendingSubmissions.filter((submission) => submission.placement !== "queued" && !observed.has(submission.requestId));
1965
2098
  }, [
1966
2099
  pendingSubmissions,
1967
2100
  order,
@@ -1983,6 +2116,15 @@ window.__ModuleLoader__.load({
1983
2116
  /** Paging anchor: semantic row/position at click, updated by reader scrolls
1984
2117
  * while the request is pending and restored after the prepend lands. */
1985
2118
  const anchorRef = (0, react.useRef)(null);
2119
+ /** Unloaded-turn jump in flight: target turn plus its load-through seq. */
2120
+ const pendingJumpRef = (0, react.useRef)(null);
2121
+ /** Whether the in-flight jump already landed mid-paging (settle then only corrects an untouched landing). */
2122
+ const jumpLandedRef = (0, react.useRef)(false);
2123
+ const [busyJumpTurn, setBusyJumpTurn] = (0, react.useState)(null);
2124
+ /** Bumped when a loadThrough completion settles, after its last page's commit. */
2125
+ const [jumpSettleTick, setJumpSettleTick] = (0, react.useState)(0);
2126
+ /** Window head at the last settle-time repage; an unmoved head falls back instead of repaging forever. */
2127
+ const jumpRepageHeadRef = (0, react.useRef)(null);
1986
2128
  const firstSeqRef = (0, react.useRef)(null);
1987
2129
  const openedRef = (0, react.useRef)(false);
1988
2130
  const lastKeyRef = (0, react.useRef)(null);
@@ -2007,13 +2149,17 @@ window.__ModuleLoader__.load({
2007
2149
  return;
2008
2150
  }
2009
2151
  const el = scrollerOf(local);
2152
+ if (el.scrollHeight - el.scrollTop - el.clientHeight <= 25) {
2153
+ const latest = turnNavigationItems.at(-1)?.turn ?? first.turn;
2154
+ setActiveTurn((current) => current === latest ? current : latest);
2155
+ return;
2156
+ }
2010
2157
  const reading = turnAtLine(local, el.getBoundingClientRect().top + Math.min(96, el.clientHeight * .2));
2011
2158
  let next = first.turn;
2012
2159
  if (reading !== null) for (const item of turnNavigationItems) {
2013
2160
  if (item.turn > reading) break;
2014
2161
  next = item.turn;
2015
2162
  }
2016
- if (el.scrollHeight - el.scrollTop - el.clientHeight <= 25) next = turnNavigationItems.at(-1)?.turn ?? next;
2017
2163
  setActiveTurn((current) => current === next ? current : next);
2018
2164
  }, [turnNavigationItems]);
2019
2165
  const activeTurnRef = (0, react.useRef)(null);
@@ -2038,6 +2184,8 @@ window.__ModuleLoader__.load({
2038
2184
  }, [scheduleActiveTurn]);
2039
2185
  const toBottom = (el) => {
2040
2186
  anchorRef.current = null;
2187
+ pendingJumpRef.current = null;
2188
+ setBusyJumpTurn((current) => current === null ? current : null);
2041
2189
  el.scrollTop = el.scrollHeight;
2042
2190
  observedTopRef.current = el.scrollTop;
2043
2191
  atBottomRef.current = true;
@@ -2045,6 +2193,50 @@ window.__ModuleLoader__.load({
2045
2193
  chatScroll.save(null);
2046
2194
  setActiveTurn(turnNavigationItems.at(-1)?.turn ?? null);
2047
2195
  };
2196
+ const landOnRowRef = (0, react.useRef)(() => {});
2197
+ landOnRowRef.current = (local, el, row, turn) => {
2198
+ el.scrollTop += flowTop(row, el) - 24;
2199
+ observedTopRef.current = el.scrollTop;
2200
+ const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 25;
2201
+ atBottomRef.current = isAtBottom;
2202
+ setAtBottom(isAtBottom);
2203
+ setActiveTurn(turn);
2204
+ const position = isAtBottom ? null : scrollPosition(local, el);
2205
+ if (isAtBottom) chatScroll.save(null);
2206
+ else if (position !== null) chatScroll.save(position);
2207
+ };
2208
+ /**
2209
+ * Land the pending jump once its Turn has a rendered anchor row; false
2210
+ * while it must keep waiting. Mid-jump landings (`settle` false) keep the
2211
+ * jump armed with the target row as the paging anchor, so later chunks and
2212
+ * the load-earlier button's unmount re-land on the same row; the settling
2213
+ * call clears the jump.
2214
+ */
2215
+ const realizePendingJump = (local, el, settle) => {
2216
+ const pending = pendingJumpRef.current;
2217
+ if (pending === null) return true;
2218
+ const item = railItems.find((candidate) => candidate.turn === pending.turn);
2219
+ if (item === void 0 || item.anchor.kind !== "loaded") return false;
2220
+ const row = anchorElement(local, item.anchor.key);
2221
+ if (row === null) return false;
2222
+ if (settle) {
2223
+ pendingJumpRef.current = null;
2224
+ setBusyJumpTurn(null);
2225
+ const held = anchorRef.current;
2226
+ const landedEarlier = jumpLandedRef.current;
2227
+ jumpLandedRef.current = false;
2228
+ anchorRef.current = null;
2229
+ if (!landedEarlier || held?.key === item.anchor.key) landOnRowRef.current(local, el, row, pending.turn);
2230
+ return true;
2231
+ }
2232
+ landOnRowRef.current(local, el, row, pending.turn);
2233
+ jumpLandedRef.current = true;
2234
+ anchorRef.current = {
2235
+ key: item.anchor.key,
2236
+ top: flowTop(row, el)
2237
+ };
2238
+ return true;
2239
+ };
2048
2240
  (0, react.useLayoutEffect)(() => {
2049
2241
  const local = listRef.current;
2050
2242
  /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
@@ -2079,6 +2271,10 @@ window.__ModuleLoader__.load({
2079
2271
  const row = anchorElement(local, anchor.key);
2080
2272
  if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top;
2081
2273
  observedTopRef.current = el.scrollTop;
2274
+ if (!realizePendingJump(local, el, false) && row !== null) anchorRef.current = {
2275
+ key: anchor.key,
2276
+ top: flowTop(row, el)
2277
+ };
2082
2278
  firstSeqRef.current = firstSeq;
2083
2279
  /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
2084
2280
  lastKeyRef.current = lastKey;
@@ -2096,7 +2292,11 @@ window.__ModuleLoader__.load({
2096
2292
  lastSteeringIdRef.current = lastSteeringId;
2097
2293
  lastSubmissionIdRef.current = lastSubmissionId;
2098
2294
  followSigRef.current = followSig;
2099
- if (appendedUser || appendedSteering || appendedSubmission || tipMoved && atBottomRef.current) toBottom(el);
2295
+ if (appendedUser || appendedSteering || appendedSubmission || tipMoved && atBottomRef.current) {
2296
+ toBottom(el);
2297
+ return;
2298
+ }
2299
+ if (pendingJumpRef.current !== null) realizePendingJump(local, el, false);
2100
2300
  });
2101
2301
  const onScrollRef = (0, react.useRef)(() => {});
2102
2302
  onScrollRef.current = () => {
@@ -2165,6 +2365,39 @@ window.__ModuleLoader__.load({
2165
2365
  (0, react.useEffect)(() => {
2166
2366
  if (!loadingOlder) anchorRef.current = null;
2167
2367
  }, [loadingOlder]);
2368
+ (0, react.useEffect)(() => {
2369
+ const pending = pendingJumpRef.current;
2370
+ const local = listRef.current;
2371
+ if (pending === null || local === null) return;
2372
+ const el = scrollerOf(local);
2373
+ if (realizePendingJump(local, el, true)) return;
2374
+ if ((firstSeq === null || firstSeq > pending.seq) && hasMore) {
2375
+ if (loadingOlder) return;
2376
+ if (jumpRepageHeadRef.current !== firstSeq) {
2377
+ jumpRepageHeadRef.current = firstSeq;
2378
+ const held = pagingAnchor(local, el);
2379
+ if (held !== null && held.dataset.chatAnchorKey !== void 0) anchorRef.current = {
2380
+ key: held.dataset.chatAnchorKey,
2381
+ top: flowTop(held, el)
2382
+ };
2383
+ loadThrough(pending.seq).finally(() => {
2384
+ setJumpSettleTick((tick) => tick + 1);
2385
+ });
2386
+ return;
2387
+ }
2388
+ }
2389
+ for (const row of local.querySelectorAll("[data-chat-turn]:not([hidden])")) {
2390
+ const turn = Number(row.dataset.chatTurn);
2391
+ if (!Number.isSafeInteger(turn) || turn < pending.turn) continue;
2392
+ landOnRowRef.current(local, el, row, turn);
2393
+ break;
2394
+ }
2395
+ pendingJumpRef.current = null;
2396
+ setBusyJumpTurn(null);
2397
+ }, [jumpSettleTick]);
2398
+ (0, react.useEffect)(() => {
2399
+ if (!loadingOlder && pendingJumpRef.current !== null) setJumpSettleTick((tick) => tick + 1);
2400
+ }, [loadingOlder]);
2168
2401
  const loadOlderAnchored = () => {
2169
2402
  const local = listRef.current;
2170
2403
  /* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
@@ -2181,24 +2414,38 @@ window.__ModuleLoader__.load({
2181
2414
  const navigateToTurn = (0, react.useCallback)((item) => {
2182
2415
  const local = listRef.current;
2183
2416
  if (local === null) return;
2184
- const row = anchorElement(local, item.anchorKey);
2185
- if (row === null) return;
2186
2417
  const el = scrollerOf(local);
2187
- el.scrollTop += flowTop(row, el) - 24;
2188
- observedTopRef.current = el.scrollTop;
2418
+ if (item.anchor.kind === "unloaded") {
2419
+ atBottomRef.current = false;
2420
+ setAtBottom(false);
2421
+ const held = pagingAnchor(local, el);
2422
+ if (held !== null && held.dataset.chatAnchorKey !== void 0) anchorRef.current = {
2423
+ key: held.dataset.chatAnchorKey,
2424
+ top: flowTop(held, el)
2425
+ };
2426
+ pendingJumpRef.current = {
2427
+ turn: item.turn,
2428
+ seq: item.anchor.seq
2429
+ };
2430
+ jumpRepageHeadRef.current = null;
2431
+ jumpLandedRef.current = false;
2432
+ setBusyJumpTurn(item.turn);
2433
+ loadThrough(item.anchor.seq).finally(() => {
2434
+ setJumpSettleTick((tick) => tick + 1);
2435
+ });
2436
+ return;
2437
+ }
2438
+ const row = anchorElement(local, item.anchor.key);
2439
+ if (row === null) return;
2440
+ pendingJumpRef.current = null;
2441
+ setBusyJumpTurn((current) => current === null ? current : null);
2442
+ landOnRowRef.current(local, el, row, item.turn);
2189
2443
  const landed = loadingOlder ? pagingAnchor(local, el) : null;
2190
2444
  anchorRef.current = landed === null || landed.dataset.chatAnchorKey === void 0 ? null : {
2191
2445
  key: landed.dataset.chatAnchorKey,
2192
2446
  top: flowTop(landed, el)
2193
2447
  };
2194
- const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 25;
2195
- atBottomRef.current = isAtBottom;
2196
- setAtBottom(isAtBottom);
2197
- setActiveTurn(item.turn);
2198
- const position = isAtBottom ? null : scrollPosition(local, el);
2199
- if (isAtBottom) chatScroll.save(null);
2200
- else if (position !== null) chatScroll.save(position);
2201
- }, [loadingOlder, chatScroll]);
2448
+ }, [loadingOlder, loadThrough]);
2202
2449
  return (0, react_jsx_runtime.jsxs)("div", {
2203
2450
  className: ChatView_module_css_default.root,
2204
2451
  children: [(0, react_jsx_runtime.jsxs)("div", {
@@ -2206,8 +2453,9 @@ window.__ModuleLoader__.load({
2206
2453
  className: ChatView_module_css_default.scroll,
2207
2454
  children: [
2208
2455
  (0, react_jsx_runtime.jsx)(TurnNavigator, {
2209
- items: turnNavigationItems,
2456
+ items: railItems,
2210
2457
  activeTurn,
2458
+ busyTurn: busyJumpTurn,
2211
2459
  onNavigate: navigateToTurn,
2212
2460
  t
2213
2461
  }),
@@ -2351,6 +2599,7 @@ window.__ModuleLoader__.load({
2351
2599
  "chat.deepDiving": "深度求索中...",
2352
2600
  "chat.turnNavigation.label": "轮次导航",
2353
2601
  "chat.turnNavigation.jump": "跳转到第 {turn} 轮",
2602
+ "chat.turnNavigation.jumpLoad": "加载并跳转到第 {turn} 轮",
2354
2603
  "chat.turnNavigation.turn": "第 {turn} 轮",
2355
2604
  "settings.transcript.title": "对话显示",
2356
2605
  "settings.transcript.description": "控制已完成轮次的过程内容",
@@ -2408,18 +2657,21 @@ window.__ModuleLoader__.load({
2408
2657
  "message.maxTokens": "已达到输出 token 上限",
2409
2658
  "message.maxTokens.hint": "回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。",
2410
2659
  "message.ranFor": "用时 {duration}",
2411
- "message.ttft": "首 token {seconds}秒",
2412
2660
  "message.tokensPerSecond": "{tps} tok/s",
2413
2661
  "message.turnUsage.title": "本轮用量",
2414
- "message.turnUsage.summaryWithCache": "{total} · 缓存命中率 {percent}%",
2662
+ "message.turnUsage.consumed": "用量 {total}",
2415
2663
  "message.turnUsage.model": "提供方 / 模型",
2664
+ "message.turnUsage.cacheHit": "缓存命中",
2416
2665
  "message.turnUsage.input": "未缓存输入",
2417
2666
  "message.turnUsage.cacheRead": "缓存读取",
2418
2667
  "message.turnUsage.cacheWrite": "缓存写入",
2419
2668
  "message.turnUsage.output": "输出",
2420
2669
  "message.turnUsage.reasoning": "(其中推理 {tokens})",
2421
- "message.turnUsage.total": "总计",
2422
2670
  "message.turnUsage.count": "{count} tok",
2671
+ "message.turnTime.title": "本轮用时和速度",
2672
+ "message.turnTime.duration": "本轮总用时",
2673
+ "message.turnTime.speed": "输出速度(TPS)",
2674
+ "message.turnTime.ttft": "首 token 用时(TTFT)",
2423
2675
  "duration.seconds": "{seconds}秒",
2424
2676
  "duration.minutes": "{minutes}分{seconds}秒",
2425
2677
  "command.running": "执行中…",
@@ -2460,6 +2712,7 @@ window.__ModuleLoader__.load({
2460
2712
  "chat.deepDiving": "Deep diving...",
2461
2713
  "chat.turnNavigation.label": "Turn navigation",
2462
2714
  "chat.turnNavigation.jump": "Jump to turn {turn}",
2715
+ "chat.turnNavigation.jumpLoad": "Load and jump to turn {turn}",
2463
2716
  "chat.turnNavigation.turn": "Turn {turn}",
2464
2717
  "settings.transcript.title": "Conversation display",
2465
2718
  "settings.transcript.description": "Controls process content in completed turns",
@@ -2517,18 +2770,21 @@ window.__ModuleLoader__.load({
2517
2770
  "message.maxTokens": "Output token limit reached",
2518
2771
  "message.maxTokens.hint": "The reply was cut off; earlier output is preserved in the conversation. Send \"continue\" to let the model resume.",
2519
2772
  "message.ranFor": "Ran for {duration}",
2520
- "message.ttft": "TTFT {seconds}s",
2521
2773
  "message.tokensPerSecond": "{tps} tok/s",
2522
2774
  "message.turnUsage.title": "Turn usage",
2523
- "message.turnUsage.summaryWithCache": "{total} · Cache hit {percent}%",
2775
+ "message.turnUsage.consumed": "Usage {total}",
2524
2776
  "message.turnUsage.model": "Provider / model",
2777
+ "message.turnUsage.cacheHit": "Cache hit",
2525
2778
  "message.turnUsage.input": "Uncached input",
2526
2779
  "message.turnUsage.cacheRead": "Cached input",
2527
2780
  "message.turnUsage.cacheWrite": "Cache write",
2528
2781
  "message.turnUsage.output": "Output",
2529
2782
  "message.turnUsage.reasoning": " ({tokens} reasoning)",
2530
- "message.turnUsage.total": "Total",
2531
2783
  "message.turnUsage.count": "{count} tok",
2784
+ "message.turnTime.title": "Turn time and speed",
2785
+ "message.turnTime.duration": "Total run time",
2786
+ "message.turnTime.speed": "Tokens per second (TPS)",
2787
+ "message.turnTime.ttft": "Time to first token (TTFT)",
2532
2788
  "duration.seconds": "{seconds}s",
2533
2789
  "duration.minutes": "{minutes}m {seconds}s",
2534
2790
  "command.running": "Running…",
@@ -3113,9 +3369,9 @@ window.__ModuleLoader__.load({
3113
3369
  return `99.${"9".repeat(distinguishingPlaces - 1)}${10 - roundedLoss}`;
3114
3370
  }
3115
3371
  //#endregion
3116
- //#region \0dsh-css:/home/runner/work/dsh-publisher/dsh-publisher/upstream/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css.mjs
3117
- const css$4 = ".psHEaW_root{min-width:0}.psHEaW_root[data-open]{padding-bottom:4px}.psHEaW_root [data-disclosure-row]:focus-visible{outline:2px solid var(--dsw-alias-label-tertiary);outline-offset:-2px;border-radius:6px}.psHEaW_chevron{color:var(--dsw-alias-label-secondary)}.psHEaW_separator{background:var(--dsw-alias-label-caption);border-radius:1px;flex:none;width:2px;height:2px;margin:0 8px}.psHEaW_summary{min-width:0;color:var(--dsw-alias-label-tertiary);font-size:var(--dsh-content-font-size-secondary,13px);font-variant-numeric:tabular-nums;line-height:calc(24px + var(--dsh-content-font-delta,0px));text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.psHEaW_details{box-sizing:border-box;width:calc(100% - 22px - var(--dsh-content-font-delta,0px));margin:4px 0 0 calc(22px + var(--dsh-content-font-delta,0px));background:var(--dsw-alias-markdown-code-block);color:var(--dsw-alias-label-tertiary);border-radius:8px;grid-template-columns:minmax(76px,auto) minmax(0,1fr);gap:6px 16px;padding:10px 16px 12px 12px;font-size:12px;line-height:18px;display:grid}.psHEaW_details dt,.psHEaW_details dd{min-width:0;margin:0}.psHEaW_details dd{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;text-align:right}.psHEaW_details .psHEaW_route{overflow-wrap:anywhere}.psHEaW_reasoning{color:var(--dsw-alias-label-tertiary);white-space:nowrap}.psHEaW_totalLabel,.psHEaW_details .psHEaW_totalValue{border-top:1px solid var(--dsw-alias-separator-primary);color:var(--dsw-alias-label-primary);padding-top:6px}@media (width<=480px){.psHEaW_details{gap-inline:10px;grid-template-columns:minmax(72px,auto) minmax(0,1fr)}}";
3118
- const tagId$4 = "@prettier-ai/dsh-client-ui-chat/TurnUsageDisclosure.module.css";
3372
+ //#region \0dsh-css:/home/runner/work/dsh-publisher/dsh-publisher/upstream/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css.mjs
3373
+ const css$4 = ".ZVOUHW_root{min-width:0;display:inline-flex}.ZVOUHW_root+.ZVOUHW_root{margin-left:-6px}.ZVOUHW_trigger{min-width:0;height:calc(28px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);font-size:var(--dsh-content-font-size-secondary,13px);font-variant-numeric:tabular-nums;line-height:calc(24px + var(--dsh-content-font-delta,0px));white-space:nowrap;cursor:pointer;background:0 0;border:none;border-radius:28px;align-items:center;gap:4px;padding:6px 8px;display:inline-flex}.ZVOUHW_label{text-overflow:ellipsis;min-width:0;overflow:hidden}.ZVOUHW_trigger svg{width:calc(15px + var(--dsh-content-font-delta,0px));height:calc(15px + var(--dsh-content-font-delta,0px));flex:none}.ZVOUHW_trigger:hover,.ZVOUHW_trigger[aria-expanded=true]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}@media (width<=480px){.ZVOUHW_trigger{width:calc(28px + var(--dsh-content-font-delta,0px));justify-content:center;padding:6px}.ZVOUHW_trigger .ZVOUHW_label{display:none}.ZVOUHW_root+.ZVOUHW_root{margin-left:0}}.ZVOUHW_panel{z-index:1100;box-sizing:border-box;border:1px solid var(--dsw-alias-border-inverted);background:var(--dsw-specific-menu);width:max-content;min-width:min(300px,100vw - 24px);max-width:min(440px,100vw - 24px);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-secondary);cursor:default;border-radius:12px;padding:16px;font-size:12px;line-height:18px;position:fixed}.ZVOUHW_title{color:var(--dsw-alias-label-primary);justify-content:space-between;gap:16px;margin-bottom:8px;font-weight:500;display:flex}.ZVOUHW_titleRule{border-top:1px solid var(--dsw-alias-border-l2);margin-bottom:10px}.ZVOUHW_titleValue{font-variant-numeric:tabular-nums}.ZVOUHW_titleLabel{align-items:center;gap:6px;min-width:0;display:inline-flex}.ZVOUHW_titleLabel svg{flex:none;width:14px;height:14px}.ZVOUHW_details{color:var(--dsw-alias-label-tertiary);grid-template-columns:minmax(76px,auto) minmax(0,1fr);gap:6px 16px;margin:0;display:grid}.ZVOUHW_details dt,.ZVOUHW_details dd{min-width:0;margin:0}.ZVOUHW_details dd{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;text-align:right}.ZVOUHW_details .ZVOUHW_route{overflow-wrap:anywhere}.ZVOUHW_reasoning{color:var(--dsw-alias-label-tertiary);white-space:nowrap}";
3374
+ const tagId$4 = "@prettier-ai/dsh-client-ui-chat/TurnUsagePanel.module.css";
3119
3375
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$4) + "]") === null) {
3120
3376
  const tag = document.createElement("style");
3121
3377
  tag.dataset.plugin = "@prettier-ai/dsh-client-ui-chat";
@@ -3123,81 +3379,194 @@ window.__ModuleLoader__.load({
3123
3379
  tag.textContent = css$4;
3124
3380
  document.head.appendChild(tag);
3125
3381
  }
3126
- var TurnUsageDisclosure_module_css_default = {
3127
- "chevron": "psHEaW_chevron",
3128
- "details": "psHEaW_details",
3129
- "reasoning": "psHEaW_reasoning",
3130
- "root": "psHEaW_root",
3131
- "route": "psHEaW_route",
3132
- "separator": "psHEaW_separator",
3133
- "summary": "psHEaW_summary",
3134
- "totalLabel": "psHEaW_totalLabel",
3135
- "totalValue": "psHEaW_totalValue"
3382
+ var TurnUsagePanel_module_css_default = {
3383
+ "details": "ZVOUHW_details",
3384
+ "label": "ZVOUHW_label",
3385
+ "panel": "ZVOUHW_panel",
3386
+ "reasoning": "ZVOUHW_reasoning",
3387
+ "root": "ZVOUHW_root",
3388
+ "route": "ZVOUHW_route",
3389
+ "title": "ZVOUHW_title",
3390
+ "titleLabel": "ZVOUHW_titleLabel",
3391
+ "titleRule": "ZVOUHW_titleRule",
3392
+ "titleValue": "ZVOUHW_titleValue",
3393
+ "trigger": "ZVOUHW_trigger"
3136
3394
  };
3137
3395
  //#endregion
3138
- //#region lib/types/client/chat/TurnUsageDisclosure.js
3396
+ //#region lib/types/client/chat/TurnUsagePanel.js
3139
3397
  function formatCompactCount(value, t) {
3140
3398
  return t("message.turnUsage.count", { count: formatTokens(value, t) });
3141
3399
  }
3142
3400
  function formatExactCount(value, t) {
3143
3401
  return t("message.turnUsage.count", { count: formatExactTokens(value, t) });
3144
3402
  }
3145
- /** Compact per-Turn usage summary with an opt-in bucket breakdown. */
3146
- function TurnUsageDisclosure({ usage, t }) {
3403
+ /** Viewport margin the placement clamp keeps (the Menu portal margin). */
3404
+ const PANEL_MARGIN = 12;
3405
+ /** Distance between the trigger's top edge and the panel's bottom. */
3406
+ const PANEL_GAP = 8;
3407
+ /**
3408
+ * Unplaced portal panel: hidden but laid out so the clamp measures real
3409
+ * dimensions (the `useAnchoredPosition` measure pass).
3410
+ */
3411
+ const MEASURE_STYLE = {
3412
+ visibility: "hidden",
3413
+ left: 0,
3414
+ top: 0
3415
+ };
3416
+ /** One trigger-anchored dialog seat: open state, viewport-clamped placement, outside-close. */
3417
+ function useStatDialog() {
3147
3418
  const [open, setOpen] = (0, react.useState)(false);
3419
+ const rootRef = (0, react.useRef)(null);
3420
+ const panelRef = (0, react.useRef)(null);
3421
+ const pos = (0, _prettier_ai_dsh_client_ui_primitives.useAnchoredPosition)({
3422
+ open,
3423
+ anchorRef: rootRef,
3424
+ panelRef,
3425
+ side: "top",
3426
+ gap: PANEL_GAP,
3427
+ margin: PANEL_MARGIN
3428
+ });
3429
+ (0, _prettier_ai_dsh_client_ui_primitives.useDismissOnOutsidePointer)(rootRef, open, setOpen, panelRef);
3430
+ (0, react.useEffect)(() => {
3431
+ if (!open) return;
3432
+ const onKeyDown = (e) => {
3433
+ if (e.key === "Escape") setOpen(false);
3434
+ };
3435
+ document.addEventListener("keydown", onKeyDown);
3436
+ return () => {
3437
+ document.removeEventListener("keydown", onKeyDown);
3438
+ };
3439
+ }, [open]);
3440
+ return {
3441
+ open,
3442
+ setOpen,
3443
+ rootRef,
3444
+ panelRef,
3445
+ pos
3446
+ };
3447
+ }
3448
+ /**
3449
+ * Turn-usage IconActions pill with a click-open Turn-usage details dialog.
3450
+ * @param props - Turn usage buckets and locale seat.
3451
+ * @returns The trigger and, while open, its portaled dialog anchored above the trigger.
3452
+ */
3453
+ function TurnUsagePanel({ usage, t }) {
3454
+ const { open, setOpen, rootRef, panelRef, pos } = useStatDialog();
3148
3455
  const cacheHit = usage.cacheReadTokens === void 0 ? null : formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1);
3149
3456
  const total = formatCompactCount(usage.totalTokens, t);
3150
- const summary = cacheHit === null ? total : t("message.turnUsage.summaryWithCache", {
3151
- total,
3152
- percent: cacheHit
3153
- });
3154
3457
  const routes = usage.routes?.map((route) => `${route.provider}/${route.model}`).join(", ") ?? "";
3155
- return (0, react_jsx_runtime.jsx)(_prettier_ai_dsh_client_ui_primitives.DisclosureRow, {
3156
- icon: (0, react_jsx_runtime.jsx)(_prettier_ai_dsh_client_ui_primitives.IconDataOutline16, {}),
3157
- title: t("message.turnUsage.title"),
3158
- open,
3159
- expandable: true,
3160
- onToggle: () => {
3161
- setOpen((value) => !value);
3162
- },
3163
- expandOnRowClick: true,
3164
- keepContentWhenOpen: true,
3165
- collapsedContent: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)("span", {
3166
- className: TurnUsageDisclosure_module_css_default.separator,
3167
- "aria-hidden": true
3168
- }), (0, react_jsx_runtime.jsx)("span", {
3169
- className: TurnUsageDisclosure_module_css_default.summary,
3170
- children: summary
3171
- })] }),
3172
- className: TurnUsageDisclosure_module_css_default.root,
3173
- chevronClassName: TurnUsageDisclosure_module_css_default.chevron,
3174
- children: (0, react_jsx_runtime.jsxs)("dl", {
3175
- className: TurnUsageDisclosure_module_css_default.details,
3176
- "data-turn-usage-details": true,
3458
+ return (0, react_jsx_runtime.jsxs)("span", {
3459
+ ref: rootRef,
3460
+ className: TurnUsagePanel_module_css_default.root,
3461
+ children: [(0, react_jsx_runtime.jsxs)("button", {
3462
+ type: "button",
3463
+ className: TurnUsagePanel_module_css_default.trigger,
3464
+ "aria-haspopup": "dialog",
3465
+ "aria-expanded": open,
3466
+ onClick: () => {
3467
+ setOpen(!open);
3468
+ },
3469
+ children: [(0, react_jsx_runtime.jsx)(_prettier_ai_dsh_client_ui_primitives.IconDatabaseOutline16, {}), (0, react_jsx_runtime.jsx)("span", {
3470
+ className: TurnUsagePanel_module_css_default.label,
3471
+ children: t("message.turnUsage.consumed", { total })
3472
+ })]
3473
+ }), open && (0, react_dom.createPortal)((0, react_jsx_runtime.jsxs)("div", {
3474
+ ref: panelRef,
3475
+ className: TurnUsagePanel_module_css_default.panel,
3476
+ role: "dialog",
3477
+ "aria-label": t("message.turnUsage.title"),
3478
+ style: pos ?? MEASURE_STYLE,
3177
3479
  children: [
3178
- routes !== "" && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)("dt", { children: t("message.turnUsage.model") }), (0, react_jsx_runtime.jsx)("dd", {
3179
- className: TurnUsageDisclosure_module_css_default.route,
3180
- children: routes
3181
- })] }),
3182
- (0, react_jsx_runtime.jsx)("dt", { children: t("message.turnUsage.input") }),
3183
- (0, react_jsx_runtime.jsx)("dd", { children: formatExactCount(usage.uncachedInputTokens, t) }),
3184
- usage.cacheReadTokens !== void 0 && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)("dt", { children: t("message.turnUsage.cacheRead") }), (0, react_jsx_runtime.jsx)("dd", { children: formatExactCount(usage.cacheReadTokens, t) })] }),
3185
- usage.cacheWriteTokens !== void 0 && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)("dt", { children: t("message.turnUsage.cacheWrite") }), (0, react_jsx_runtime.jsx)("dd", { children: formatExactCount(usage.cacheWriteTokens, t) })] }),
3186
- (0, react_jsx_runtime.jsx)("dt", { children: t("message.turnUsage.output") }),
3187
- (0, react_jsx_runtime.jsxs)("dd", { children: [formatExactCount(usage.outputTokens, t), usage.reasoningTokens !== void 0 && (0, react_jsx_runtime.jsx)("span", {
3188
- className: TurnUsageDisclosure_module_css_default.reasoning,
3189
- children: t("message.turnUsage.reasoning", { tokens: formatExactCount(usage.reasoningTokens, t) })
3190
- })] }),
3191
- (0, react_jsx_runtime.jsx)("dt", {
3192
- className: TurnUsageDisclosure_module_css_default.totalLabel,
3193
- children: t("message.turnUsage.total")
3480
+ (0, react_jsx_runtime.jsxs)("div", {
3481
+ className: TurnUsagePanel_module_css_default.title,
3482
+ children: [(0, react_jsx_runtime.jsxs)("span", {
3483
+ className: TurnUsagePanel_module_css_default.titleLabel,
3484
+ children: [(0, react_jsx_runtime.jsx)(_prettier_ai_dsh_client_ui_primitives.IconDatabaseOutline16, {}), t("message.turnUsage.title")]
3485
+ }), (0, react_jsx_runtime.jsx)("span", {
3486
+ className: TurnUsagePanel_module_css_default.titleValue,
3487
+ children: formatExactCount(usage.totalTokens, t)
3488
+ })]
3194
3489
  }),
3195
- (0, react_jsx_runtime.jsx)("dd", {
3196
- className: TurnUsageDisclosure_module_css_default.totalValue,
3197
- children: formatExactCount(usage.totalTokens, t)
3490
+ (0, react_jsx_runtime.jsx)("div", {
3491
+ className: TurnUsagePanel_module_css_default.titleRule,
3492
+ "aria-hidden": true
3493
+ }),
3494
+ (0, react_jsx_runtime.jsxs)("dl", {
3495
+ className: TurnUsagePanel_module_css_default.details,
3496
+ "data-turn-usage-details": true,
3497
+ children: [
3498
+ routes !== "" && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)("dt", { children: t("message.turnUsage.model") }), (0, react_jsx_runtime.jsx)("dd", {
3499
+ className: TurnUsagePanel_module_css_default.route,
3500
+ children: routes
3501
+ })] }),
3502
+ cacheHit !== null && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)("dt", { children: t("message.turnUsage.cacheHit") }), (0, react_jsx_runtime.jsx)("dd", { children: `${cacheHit}%` })] }),
3503
+ (0, react_jsx_runtime.jsx)("dt", { children: t("message.turnUsage.input") }),
3504
+ (0, react_jsx_runtime.jsx)("dd", { children: formatExactCount(usage.uncachedInputTokens, t) }),
3505
+ usage.cacheReadTokens !== void 0 && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)("dt", { children: t("message.turnUsage.cacheRead") }), (0, react_jsx_runtime.jsx)("dd", { children: formatExactCount(usage.cacheReadTokens, t) })] }),
3506
+ usage.cacheWriteTokens !== void 0 && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)("dt", { children: t("message.turnUsage.cacheWrite") }), (0, react_jsx_runtime.jsx)("dd", { children: formatExactCount(usage.cacheWriteTokens, t) })] }),
3507
+ (0, react_jsx_runtime.jsx)("dt", { children: t("message.turnUsage.output") }),
3508
+ (0, react_jsx_runtime.jsxs)("dd", { children: [formatExactCount(usage.outputTokens, t), usage.reasoningTokens !== void 0 && (0, react_jsx_runtime.jsx)("span", {
3509
+ className: TurnUsagePanel_module_css_default.reasoning,
3510
+ children: t("message.turnUsage.reasoning", { tokens: formatExactCount(usage.reasoningTokens, t) })
3511
+ })] })
3512
+ ]
3198
3513
  })
3199
3514
  ]
3200
- })
3515
+ }), document.body)]
3516
+ });
3517
+ }
3518
+ /**
3519
+ * Turn-time IconActions pill with a click-open Turn-time details dialog.
3520
+ * @param props - Turn timing facts and locale seat.
3521
+ * @returns The clock-and-duration trigger and, while open, its portaled dialog anchored above the trigger.
3522
+ */
3523
+ function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }) {
3524
+ const { open, setOpen, rootRef, panelRef, pos } = useStatDialog();
3525
+ return (0, react_jsx_runtime.jsxs)("span", {
3526
+ ref: rootRef,
3527
+ className: TurnUsagePanel_module_css_default.root,
3528
+ children: [(0, react_jsx_runtime.jsxs)("button", {
3529
+ type: "button",
3530
+ className: TurnUsagePanel_module_css_default.trigger,
3531
+ "aria-haspopup": "dialog",
3532
+ "aria-expanded": open,
3533
+ onClick: () => {
3534
+ setOpen(!open);
3535
+ },
3536
+ children: [(0, react_jsx_runtime.jsx)(_prettier_ai_dsh_client_ui_primitives.IconClockOutline16, {}), (0, react_jsx_runtime.jsx)("span", {
3537
+ className: TurnUsagePanel_module_css_default.label,
3538
+ children: t("message.ranFor", { duration: formatRunDuration(runMs, t) })
3539
+ })]
3540
+ }), open && (0, react_dom.createPortal)((0, react_jsx_runtime.jsxs)("div", {
3541
+ ref: panelRef,
3542
+ className: TurnUsagePanel_module_css_default.panel,
3543
+ role: "dialog",
3544
+ "aria-label": t("message.turnTime.title"),
3545
+ style: pos ?? MEASURE_STYLE,
3546
+ children: [
3547
+ (0, react_jsx_runtime.jsx)("div", {
3548
+ className: TurnUsagePanel_module_css_default.title,
3549
+ children: (0, react_jsx_runtime.jsxs)("span", {
3550
+ className: TurnUsagePanel_module_css_default.titleLabel,
3551
+ children: [(0, react_jsx_runtime.jsx)(_prettier_ai_dsh_client_ui_primitives.IconClockOutline16, {}), t("message.turnTime.title")]
3552
+ })
3553
+ }),
3554
+ (0, react_jsx_runtime.jsx)("div", {
3555
+ className: TurnUsagePanel_module_css_default.titleRule,
3556
+ "aria-hidden": true
3557
+ }),
3558
+ (0, react_jsx_runtime.jsxs)("dl", {
3559
+ className: TurnUsagePanel_module_css_default.details,
3560
+ "data-turn-time-details": true,
3561
+ children: [
3562
+ (0, react_jsx_runtime.jsx)("dt", { children: t("message.turnTime.duration") }),
3563
+ (0, react_jsx_runtime.jsx)("dd", { children: formatRunDuration(runMs, t) }),
3564
+ tokensPerSecond !== void 0 && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)("dt", { children: t("message.turnTime.speed") }), (0, react_jsx_runtime.jsx)("dd", { children: t("message.tokensPerSecond", { tps: formatTokensPerSecond(tokensPerSecond) }) })] }),
3565
+ ttftMs !== void 0 && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)("dt", { children: t("message.turnTime.ttft") }), (0, react_jsx_runtime.jsx)("dd", { children: t("duration.seconds", { seconds: formatLatencySeconds(ttftMs) }) })] })
3566
+ ]
3567
+ })
3568
+ ]
3569
+ }), document.body)]
3201
3570
  });
3202
3571
  }
3203
3572
  //#endregion
@@ -3212,7 +3581,7 @@ window.__ModuleLoader__.load({
3212
3581
  }
3213
3582
  //#endregion
3214
3583
  //#region \0dsh-css:/home/runner/work/dsh-publisher/dsh-publisher/upstream/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css.mjs
3215
- const css$3 = ".v8MmmG_root{flex-direction:column;gap:16px;display:flex}.v8MmmG_footer{flex-direction:column;gap:4px;min-width:0;display:flex}.v8MmmG_actions{margin-left:-6px}";
3584
+ const css$3 = ".v8MmmG_root{flex-direction:column;gap:16px;display:flex}.v8MmmG_actions{margin-left:-6px}";
3216
3585
  const tagId$3 = "@prettier-ai/dsh-client-ui-chat/TurnTailNodeView.module.css";
3217
3586
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$3) + "]") === null) {
3218
3587
  const tag = document.createElement("style");
@@ -3223,7 +3592,6 @@ window.__ModuleLoader__.load({
3223
3592
  }
3224
3593
  var TurnTailNodeView_module_css_default = {
3225
3594
  "actions": "v8MmmG_actions",
3226
- "footer": "v8MmmG_footer",
3227
3595
  "root": "v8MmmG_root"
3228
3596
  };
3229
3597
  //#endregion
@@ -3232,6 +3600,7 @@ window.__ModuleLoader__.load({
3232
3600
  const TurnTailNodeView = (0, react.memo)(function TurnTailNodeView({ node, openFile, forkAt, renderSlot, renderSlotChain, t, useChat }) {
3233
3601
  const data = node.data;
3234
3602
  const hasLaterChatNode = useChat((snapshot) => snapshot.locations.getTurn(data.turn).at(-1) !== node.key);
3603
+ const isLatestTurn = useChat((snapshot) => snapshot.timeline.turnOrder.at(-1) === data.turn);
3235
3604
  const turn = node.location.kind === "turn" || node.location.kind === "step" ? node.location.turn : void 0;
3236
3605
  if (turn === void 0) return null;
3237
3606
  const closing = data.closing;
@@ -3250,27 +3619,27 @@ window.__ModuleLoader__.load({
3250
3619
  return (0, react_jsx_runtime.jsxs)("div", {
3251
3620
  className: TurnTailNodeView_module_css_default.root,
3252
3621
  "data-turn-tail": data.turn,
3253
- "data-time-hover-root": true,
3254
- children: [tail, (0, react_jsx_runtime.jsxs)("div", {
3255
- className: TurnTailNodeView_module_css_default.footer,
3256
- children: [data.tokenUsage === void 0 ? null : (0, react_jsx_runtime.jsx)(TurnUsageDisclosure, {
3622
+ "data-actions-reveal": isLatestTurn ? "always" : "hover",
3623
+ children: [tail, (0, react_jsx_runtime.jsx)(MessageIconActions, {
3624
+ text: assistantText(closing.blocks),
3625
+ time: closing.time,
3626
+ clock: "end",
3627
+ onBranch: () => {
3628
+ forkAt(closing.finalNode.seq);
3629
+ },
3630
+ branchUnavailable: data.branchUnavailable || hasLaterChatNode,
3631
+ className: TurnTailNodeView_module_css_default.actions,
3632
+ extraActions: assistantActions,
3633
+ usageAction: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [data.tokenUsage !== void 0 && (0, react_jsx_runtime.jsx)(TurnUsagePanel, {
3257
3634
  usage: data.tokenUsage,
3258
3635
  t
3259
- }), (0, react_jsx_runtime.jsx)(MessageIconActions, {
3260
- text: assistantText(closing.blocks),
3261
- time: closing.time,
3636
+ }), runMs !== void 0 && (0, react_jsx_runtime.jsx)(TurnTimePanel, {
3262
3637
  runMs,
3263
- ttftMs: data.ttftMs,
3264
3638
  tokensPerSecond: data.tokensPerSecond,
3265
- clock: "end",
3266
- onBranch: () => {
3267
- forkAt(closing.finalNode.seq);
3268
- },
3269
- branchUnavailable: data.branchUnavailable || hasLaterChatNode,
3270
- className: TurnTailNodeView_module_css_default.actions,
3271
- extraActions: assistantActions,
3639
+ ttftMs: data.ttftMs,
3272
3640
  t
3273
- })]
3641
+ })] }),
3642
+ t
3274
3643
  })]
3275
3644
  });
3276
3645
  });
@@ -4252,27 +4621,42 @@ window.__ModuleLoader__.load({
4252
4621
  //#endregion
4253
4622
  //#region lib/types/client/conversation-nodes/turn-navigation.js
4254
4623
  /**
4255
- * Preview budget per field. The rail clamps two short lines, so anything past
4256
- * this is invisible; copying whole transcripts into navigation state would
4624
+ * Preview budgets, sized to the rail card's clamps (one prompt line, up to
4625
+ * three response lines) and mirrored by the turnOutline projection so a turn
4626
+ * shows the same words before and after its events load. Anything past a
4627
+ * budget is invisible; copying whole transcripts into navigation state would
4257
4628
  * otherwise grow with the loaded window on every structural update.
4258
4629
  */
4259
- const PREVIEW_LIMIT = 160;
4260
- /** Join rendered text until the preview budget is met, then stop reading. */
4261
- function preview(parts) {
4630
+ const PROMPT_PREVIEW_LIMIT = 50;
4631
+ const RESPONSE_PREVIEW_LIMIT = 120;
4632
+ /** Join rendered text, collapse whitespace, and cap at `limit` with a trailing ellipsis when clipped. */
4633
+ function preview(parts, limit) {
4262
4634
  let text = "";
4635
+ let unread = false;
4263
4636
  for (const part of parts) {
4264
- text += text === "" ? part : ` ${part}`;
4265
- if (text.length >= PREVIEW_LIMIT) break;
4637
+ if (text.length >= limit * 2) {
4638
+ unread = true;
4639
+ break;
4640
+ }
4641
+ const clipped = part.length > limit * 2;
4642
+ const chunk = clipped ? part.slice(0, limit * 2) : part;
4643
+ text += text === "" ? chunk : ` ${chunk}`;
4644
+ if (clipped) {
4645
+ unread = true;
4646
+ break;
4647
+ }
4266
4648
  }
4267
- return text.replace(/\s+/g, " ").trim().slice(0, PREVIEW_LIMIT);
4649
+ const normalized = text.replace(/\s+/g, " ").trim();
4650
+ if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…`;
4651
+ return unread ? `${normalized}…` : normalized;
4268
4652
  }
4269
4653
  function promptText(node) {
4270
4654
  if (node.kind !== "user") return "";
4271
- return preview(node.data.content.flatMap((block) => block.type === "text" ? [block.text] : []));
4655
+ return preview(node.data.content.flatMap((block) => block.type === "text" ? [block.text] : []), PROMPT_PREVIEW_LIMIT);
4272
4656
  }
4273
4657
  function responseText(node) {
4274
4658
  if (node.kind !== "assistant-step") return "";
4275
- return preview(node.data.blocks.flatMap((block) => block.kind === "text" ? [block.text] : []));
4659
+ return preview(node.data.blocks.flatMap((block) => block.kind === "text" ? [block.text] : []), RESPONSE_PREVIEW_LIMIT);
4276
4660
  }
4277
4661
  /**
4278
4662
  * Whether two items carry the same rail state, so the reader can keep its array.
@@ -5128,43 +5512,87 @@ window.__ModuleLoader__.load({
5128
5512
  }
5129
5513
  //#endregion
5130
5514
  //#region lib/types/client/conversation-nodes/inbox.js
5515
+ const EMPTY_PENDING = {
5516
+ kind: "snapshot",
5517
+ ids: []
5518
+ };
5519
+ const EMPTY_CURRENT_CLAIMED = /* @__PURE__ */ new Set();
5520
+ function materializePending(state) {
5521
+ const splices = [];
5522
+ let current = state;
5523
+ while (current.kind === "splice") {
5524
+ splices.push(current);
5525
+ current = current.previous;
5526
+ }
5527
+ const pending = [...current.ids];
5528
+ for (const splice of splices.reverse()) pending.splice(splice.start, splice.removedCount, ...splice.inserted);
5529
+ return pending;
5530
+ }
5531
+ function withoutInserted(claimed, inserted) {
5532
+ let next;
5533
+ for (const id of inserted) {
5534
+ if (!claimed.has(id)) continue;
5535
+ next ??= new Set(claimed);
5536
+ next.delete(id);
5537
+ }
5538
+ return next ?? claimed;
5539
+ }
5540
+ /**
5541
+ * Apply one next-step splice under the AgentLoop's durable event ordering.
5542
+ * An entered claim logs its complete message batch before another claim; a
5543
+ * rejected claim logs no messages, so only the current claim can classify a
5544
+ * later `user/message`.
5545
+ */
5131
5546
  function applySplice(previous, splice) {
5132
- const pending = [...previous?.state.pending ?? []];
5133
- const claimed = new Set(previous?.state.claimed ?? []);
5134
- const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted);
5135
- for (const identity of splice.inserted) claimed.delete(identity.id);
5136
- if (splice.target === "next-step" && splice.outcome !== "canceled") for (const identity of removed) claimed.add(identity.id);
5547
+ const priorPending = previous?.state.pending ?? EMPTY_PENDING;
5548
+ const inserted = splice.inserted.map((identity) => identity.id);
5549
+ const removedCount = splice.removedCount ?? 0;
5550
+ if (removedCount > 0 && splice.outcome !== "canceled") {
5551
+ const pending = materializePending(priorPending);
5552
+ const removed = pending.splice(splice.start, removedCount, ...inserted);
5553
+ return {
5554
+ pending: {
5555
+ kind: "snapshot",
5556
+ ids: pending
5557
+ },
5558
+ currentClaimed: new Set(removed)
5559
+ };
5560
+ }
5561
+ const currentClaimed = withoutInserted(previous?.state.currentClaimed ?? EMPTY_CURRENT_CLAIMED, inserted);
5137
5562
  return {
5138
- pending,
5139
- claimed
5563
+ pending: {
5564
+ kind: "splice",
5565
+ previous: priorPending,
5566
+ start: splice.start,
5567
+ removedCount,
5568
+ inserted
5569
+ },
5570
+ currentClaimed
5140
5571
  };
5141
5572
  }
5142
- function inboxDefinition(target) {
5143
- const kind = `inbox-${target}`;
5144
- return {
5145
- kind,
5146
- match: (event) => event.type === "agent/inbox/spliced" && event.data.target === target ? {
5573
+ const NEXT_STEP_INBOX_KIND = "inbox-next-step";
5574
+ /** Persistent next-step Inbox state used to classify the current claimed batch as steering. */
5575
+ const nextStepInboxDefinition = {
5576
+ kind: NEXT_STEP_INBOX_KIND,
5577
+ match: (event) => {
5578
+ if (event.type === "agent/inbox/spliced" && event.data.target === "next-step") return {
5147
5579
  id: String(event.seq),
5148
5580
  role: "start"
5149
- } : null,
5150
- start: (_context, match, reader) => {
5151
- if (match.event.type !== "agent/inbox/spliced") throw new Error(`${kind} start requires agent/inbox/spliced`);
5152
- return applySplice(reader.previous(kind), match.event.data);
5153
- },
5154
- update: (context) => context.state,
5155
- publication: () => "none"
5156
- };
5157
- }
5158
- /** Cumulative next-turn inbox splice Definition. */
5159
- const nextTurnInboxDefinition = inboxDefinition("next-turn");
5160
- /** Cumulative next-step inbox splice Definition used to classify steering. */
5161
- const nextStepInboxDefinition = inboxDefinition("next-step");
5581
+ };
5582
+ return null;
5583
+ },
5584
+ start: (_context, match, reader) => {
5585
+ if (match.event.type !== "agent/inbox/spliced") throw new Error("inbox-next-step start requires agent/inbox/spliced");
5586
+ return applySplice(reader.previous(NEXT_STEP_INBOX_KIND), match.event.data);
5587
+ },
5588
+ update: (context) => context.state,
5589
+ publication: () => "none"
5590
+ };
5162
5591
  /**
5163
- * Register the two durable Inbox-state contributions.
5592
+ * Register the next-step Inbox state used by Chat message classification.
5164
5593
  * @param ctx - owning UI Conversation context.
5165
5594
  */
5166
5595
  function registerInboxConversationNodes(ctx) {
5167
- ctx.uiConversation.events.register(nextTurnInboxDefinition);
5168
5596
  ctx.uiConversation.events.register(nextStepInboxDefinition);
5169
5597
  }
5170
5598
  //#endregion
@@ -5194,7 +5622,7 @@ window.__ModuleLoader__.load({
5194
5622
  provenance: contextProvenance(event.data.source),
5195
5623
  form: contextForm(event.data.source)
5196
5624
  };
5197
- return reader.previous("inbox-next-step")?.state.claimed.has(String(event.data.id)) === true ? {
5625
+ return reader.previous("inbox-next-step")?.state.currentClaimed.has(String(event.data.id)) === true ? {
5198
5626
  kind: "steering",
5199
5627
  messageId: event.data.id,
5200
5628
  seq: event.seq,
@@ -7534,6 +7962,7 @@ window.__ModuleLoader__.load({
7534
7962
  loadOlder: () => {
7535
7963
  session.loadOlder();
7536
7964
  },
7965
+ loadThrough: (seq) => session.loadThrough(seq),
7537
7966
  loadImage: Object.assign((attachment) => ctx.uiConversation.imageUrl(sessionId, attachment), { peek: (attachment) => ctx.uiConversation.peekImageUrl(sessionId, attachment) }),
7538
7967
  chatScroll: {
7539
7968
  save: (position) => {