@zq-silk/yui 2.16.0 → 2.17.0

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.
Files changed (69) hide show
  1. package/dist/cli/commandCatalog.js +17 -11
  2. package/dist/cli/interactionCandidates.js +20 -5
  3. package/dist/cli/interactionPolicy.js +5 -4
  4. package/dist/cli/roleWizard.js +7 -0
  5. package/dist/commands/globalRoleCommands.js +13 -2
  6. package/dist/commands/operatorCommands.js +2 -0
  7. package/dist/commands/profileCommands.js +19 -2
  8. package/dist/commands/roleConfiguration.js +3 -0
  9. package/dist/commands/taskCommands.js +16 -0
  10. package/dist/commands/taskRoleRuntimeStatus.js +3 -0
  11. package/dist/controller/agentJobControl.js +9 -1
  12. package/dist/controller/fileSchedulerStoreAdapter.js +35 -4
  13. package/dist/controller/globalInputDelivery.js +14 -8
  14. package/dist/controller/providerRetryAdmission.js +9 -0
  15. package/dist/controller/providerRetryDelivery.js +16 -0
  16. package/dist/controller/runtime.js +14 -0
  17. package/dist/controller/runtimeLaunchCoordinator.js +3 -1
  18. package/dist/executor/fileRoleLaunchPlanner.js +3 -0
  19. package/dist/message/messageContinuation.js +4 -0
  20. package/dist/output/rolePresentation.js +3 -0
  21. package/dist/profile/agentProfile.js +8 -0
  22. package/dist/profile/agentProfileRuntime.js +16 -1
  23. package/dist/review/reviewDecision.js +6 -0
  24. package/dist/role/availability.js +28 -0
  25. package/dist/role/role.js +6 -0
  26. package/dist/runtime/codexNativeAccess.js +21 -4
  27. package/dist/runtime/tmuxAdapters.js +5 -0
  28. package/dist/scheduler/activeRoleRunDelivery.js +9 -0
  29. package/dist/scheduler/leaderWakeupProcessor.js +8 -1
  30. package/dist/scheduler/roleRunLiveness.js +3 -0
  31. package/dist/storage/sqliteSchema.js +10 -0
  32. package/dist/storage/storageVersions.js +1 -1
  33. package/dist/web/assets/client/app/main.js +15 -22
  34. package/dist/web/assets/client/app/taskView.js +7 -11
  35. package/dist/web/assets/client/i18n/en.js +62 -18
  36. package/dist/web/assets/client/i18n/zh-CN.js +62 -18
  37. package/dist/web/assets/client/index.js +2 -0
  38. package/dist/web/assets/client/layout/shortcuts.js +2 -2
  39. package/dist/web/assets/client/layout/workspace.js +99 -112
  40. package/dist/web/assets/client/ui/primitives.js +15 -2
  41. package/dist/web/assets/client/views/dock/conversation.js +421 -123
  42. package/dist/web/assets/client/views/dock/session.js +229 -0
  43. package/dist/web/assets/client/views/dock/terminal.js +48 -39
  44. package/dist/web/assets/client/views/overview.js +2 -2
  45. package/dist/web/assets/client/views/task/page.js +2 -12
  46. package/dist/web/assets/shell/dock.js +9 -27
  47. package/dist/web/assets/styles/views/conversation.js +96 -17
  48. package/dist/web/assets/styles/views/dock.js +17 -5
  49. package/dist/web/assets/styles/views/task.js +1 -2
  50. package/dist/web/assets/styles/views/terminal.js +12 -8
  51. package/dist/web/webConversation.js +1 -0
  52. package/dist/web/webSettings.js +10 -2
  53. package/docs/managed-turn-and-session-runtime.md +10 -0
  54. package/docs/managed-turn-and-session-runtime.zh-CN.md +6 -0
  55. package/docs/release-workflow.md +24 -0
  56. package/docs/release-workflow.zh-CN.md +19 -0
  57. package/docs/roles-and-configuration.md +45 -1
  58. package/docs/roles-and-configuration.zh-CN.md +35 -0
  59. package/docs/storage-baseline.md +6 -1
  60. package/docs/storage-baseline.zh-CN.md +5 -1
  61. package/docs/testing/review-evaluation.md +35 -1
  62. package/docs/testing/review-evaluation.zh-CN.md +25 -1
  63. package/docs/testing/verification-levels.md +2 -2
  64. package/docs/testing/verification-levels.zh-CN.md +2 -2
  65. package/package.json +1 -1
  66. package/skills/yui-leader/VERSION +1 -1
  67. package/skills/yui-leader/references/execution.md +19 -8
  68. package/skills/yui-reviewer/SKILL.md +13 -2
  69. package/skills/yui-reviewer/VERSION +1 -1
@@ -2,61 +2,90 @@ export const CONVERSATION_SCRIPT = String.raw `
2
2
  import { h, clear, icon } from "/assets/js/lib/dom.js";
3
3
  import { api, releaseMutation, requestJson, submitMutation } from "/assets/js/lib/api.js";
4
4
  import { richText } from "/assets/js/ui/text.js";
5
- import { readSessionAccessMode, writeSessionAccessMode } from "/assets/js/lib/prefs.js";
5
+ import { formatDateTime, formatShort, fill } from "/assets/js/lib/format.js";
6
6
  import { inputCard } from "/assets/js/domain/work.js";
7
7
 
8
- export function createConversationController(host, t, locale = function () { return "en"; }) {
8
+ // options.tools: a node placed in the header row (the session page's mode
9
+ // switch). options.onSelection(sessionId, currentSessionId) reports the chosen
10
+ // Session; currentSessionId is undefined until the owner's facts are read.
11
+ export function createConversationController(host, t, locale = function () { return "en"; }, options = {}) {
9
12
  let owner = null, selected = null, facts = null, next = null, latestItems = [], olderItems = [];
10
13
  let generation = 0, busy = false, timer = null, pending = null, historyOk = false;
11
14
  let recoveryUnavailable = false;
15
+ // A background read (the poll) holds busy only to serialize requests. It
16
+ // never disables controls, so an open picker or a typed draft does not grey
17
+ // out every cycle; an action started meanwhile waits for the read to finish
18
+ // and then revalidates instead of being dropped or racing it.
19
+ let reading = false, readWaiters = [];
12
20
  let rendered = new Map();
13
21
  let materials = [], incomingMaterials = null;
14
- let requestSignature = "";
22
+ let requestSignature = "", sessionSignature = "", detailSignature = "";
15
23
  let modelPending = null, modelCatalog = null, lastModelObservation = null;
24
+ // The selected Session's own native confirmation (or its stated absence).
25
+ let modelObservation = null;
26
+ let pageFacts = null;
16
27
  const requests = h("div.cv-requests");
17
28
  const questions = h("div.cv-requests");
18
29
  let questionSignature = "";
30
+ // Native answer drafts typed in this view (discarded with it on a mode switch).
31
+ const nativeDraftKeys = new Set();
19
32
  let confirmedModel = null, modelRowMap = new Map();
20
- const defaultMode = h("select", { "aria-label": t("conversation.defaultMode") },
21
- h("option", { value: "structured" }, t("conversation.title")),
22
- h("option", { value: "native" }, t("dock.session")));
23
- defaultMode.value = readSessionAccessMode();
24
- defaultMode.addEventListener("change", function () {
25
- const saved = writeSessionAccessMode(defaultMode.value);
26
- receipt.textContent = t(saved ? "conversation.defaultSaved" : "settings.storageFailed");
27
- defaultMode.value = readSessionAccessMode();
28
- });
29
- const select = h("select.cv-session", { "aria-label": t("conversation.sessions") });
30
- const status = h("p.cv-status", { role: "status" });
33
+ // Who/which Session: one row. The picker lists current and previous Sessions
34
+ // by readable name and time; exact identities live in the details menu.
35
+ const pickerName = h("span.cv-picker-name", null, t("conversation.sessions"));
36
+ const sessionList = h("div.cv-session-list", { role: "listbox", "aria-label": t("conversation.sessions") });
37
+ const moreSessions = h("button.cv-link.cv-more-sessions", { type: "button", hidden: true }, t("conversation.moreSessions"));
38
+ const picker = h("details.cv-pop.cv-picker", null,
39
+ h("summary.cv-picker-btn", { "aria-label": t("conversation.sessions"), title: t("conversation.sessions") },
40
+ pickerName, icon("down", "cv-picker-chevron")),
41
+ h("div.cv-popbody", null, sessionList, moreSessions));
42
+ const status = h("span.cv-status", { role: "status" });
43
+ const detailFacts = h("dl.cv-facts");
31
44
  const identity = h("p.cv-identity");
45
+ const copyIdentity = h("button.cv-link", { type: "button" }, icon("copy"), h("span", null, t("conversation.copyIdentity")));
46
+ const liveNote = h("p.cv-note");
32
47
  const feed = h("div.feed", { tabIndex: 0, "aria-label": t("conversation.history") });
33
48
  const text = h("textarea.cv-input", { rows: 2, placeholder: t("conversation.placeholder"), "aria-label": t("conversation.input"), maxLength: 12000 });
34
49
  const receipt = h("p.cv-note", { role: "status" });
35
50
  const draftStatus = h("p.cv-note", { role: "status" });
36
51
  const receiptDetail = h("pre.cv-code");
52
+ const receiptBlock = h("details.cv-sub", { hidden: true }, h("summary", null, t("conversation.receiptDetails")), receiptDetail);
53
+ // Two facts only: what the Session last confirmed, and what is selected.
54
+ // Their nodes are built once and updated in place on each poll.
55
+ const modelFactNodes = [0, 1].map(function () {
56
+ const value = h("span.cv-mf-value"), note = h("small.cv-mf-note");
57
+ return { term: h("dt"), value: value, note: note, detail: h("dd", null, value, note) };
58
+ });
59
+ const modelFacts = h("dl.cv-facts.cv-model-facts", null,
60
+ modelFactNodes[0].term, modelFactNodes[0].detail, modelFactNodes[1].term, modelFactNodes[1].detail);
37
61
  const modelStatus = h("p.cv-note", { role: "status" });
38
62
  const modelResult = h("p.cv-note", { role: "status" });
39
63
  const modelSelect = h("select", { "aria-label": t("conversation.model"), hidden: true });
40
64
  const modelRows = h("div.cv-options", { role: "listbox", "aria-label": t("conversation.selectModel") });
41
- const modelLabel = h("span.cv-model-label", null, t("conversation.model"));
42
- const modelLoad = h("button.cv-link", { type: "button" }, t("conversation.modelsLoad"));
43
- const modelApply = h("button.btn.btn-primary", { type: "button" }, t("conversation.modelApply"));
44
- const modelMenu = h("details.cv-pop.cv-model", null,
45
- h("summary.cv-pill", null, modelLabel, icon("down")),
46
- h("div.cv-popbody", null, h("p.cv-pop-title", null, t("conversation.selectModel")),
47
- modelRows, modelSelect, modelStatus, modelResult,
48
- h("div.cv-pop-actions", null, modelLoad, modelApply),
49
- h("p.cv-note", null, t("conversation.modelBoundary"))));
65
+ // The pill names its state in words (not only the dot's colour) so the
66
+ // source reads without hovering: last confirmed, applying, or unconfirmed.
67
+ const modelState = h("span.cv-model-state");
68
+ const modelLabel = h("span.cv-model-label", null, t("conversation.modelUnconfirmed"));
69
+ const modelSummary = h("summary.cv-pill", { "aria-label": t("conversation.model"), title: t("conversation.model") },
70
+ h("i.cv-model-dot", { "aria-hidden": "true" }), modelState, modelLabel, icon("down"));
71
+ const modelLoad = h("button.cv-link", { type: "button", hidden: true }, t("conversation.modelsLoad"));
72
+ const modelApply = h("button.btn.btn-primary.cv-apply", { type: "button" }, t("conversation.modelApply"));
73
+ const modelMenu = h("details.cv-pop.cv-model", null, modelSummary,
74
+ h("div.cv-popbody", null, modelFacts, modelRows, modelSelect,
75
+ h("div.cv-pop-actions", null, h("div.cv-model-notes", null, modelStatus, modelResult), modelLoad, modelApply)));
50
76
  const slash = h("div.conversation-slash", { "aria-label": t("conversation.commands") });
51
77
  const literal = h("button.cv-link", { type: "button", hidden: true }, t("conversation.literal"));
52
- const send = h("button.cv-send", { type: "button", "aria-label": t("conversation.send"), title: t("conversation.send") }, icon("up"));
78
+ const send = h("button.cv-send", { type: "button", "aria-label": t("conversation.send"), title: t("conversation.sendHint") }, icon("up"));
53
79
  const stop = h("button.cv-stop", { type: "button", hidden: true, "aria-label": t("conversation.stop"), title: t("conversation.stop") }, icon("stop"));
54
80
  const check = h("button.cv-link", { type: "button", hidden: true }, t("conversation.receipt"));
55
- const older = h("button.cv-link", { type: "button", hidden: true }, t("conversation.older"));
81
+ const older = h("button.cv-link.cv-older", { type: "button", hidden: true }, t("conversation.older"));
56
82
  const latest = h("button.cv-jump", { type: "button", hidden: true, "aria-label": t("conversation.jump"), title: t("conversation.latest") }, icon("down"));
57
- const current = h("button.cv-link", { type: "button", hidden: true }, t("conversation.current"));
58
- const moreSessions = h("button.cv-link", { type: "button", hidden: true }, t("conversation.moreSessions"));
59
- const writerChip = h("span.cv-chip.tone-warn", { hidden: true }, icon("alert"), h("span", null, t("conversation.terminalWriter")));
83
+ const current = h("button.cv-link.cv-back", { type: "button", hidden: true, title: t("conversation.backCurrent") }, icon("back"), h("span", null, t("conversation.backCurrentShort")));
84
+ // Why input is unavailable, in one line above the composer, instead of a
85
+ // silently disabled send button and stacked engineering notes.
86
+ const noticeText = h("span");
87
+ const noticeCurrent = h("button.cv-link", { type: "button", hidden: true }, t("conversation.backCurrent"));
88
+ const notice = h("p.cv-notice", { role: "status", hidden: true }, icon("info"), noticeText, noticeCurrent);
60
89
  const file = h("input", { type: "file", "aria-label": t("materials.attach") });
61
90
  const materialList = h("div.row-stack");
62
91
  const materialAlert = h("p.cv-note", { role: "alert" });
@@ -65,25 +94,51 @@ export function createConversationController(host, t, locale = function () { ret
65
94
  h("div.cv-popbody", null, h("p.cv-pop-title", null, t("materials.attach")), file,
66
95
  h("p.cv-note", null, t("materials.boundary"))));
67
96
  const menu = h("details.cv-pop.cv-menu", null,
68
- h("summary.cv-iconbtn", { "aria-label": t("conversation.more"), title: t("conversation.more") }, icon("dots")),
97
+ h("summary.cv-iconbtn", { "aria-label": t("conversation.details"), title: t("conversation.details") }, icon("dots")),
69
98
  h("div.cv-popbody", null,
70
- h("label.cv-field", null, h("span", null, t("conversation.defaultMode")), defaultMode),
71
- moreSessions, identity,
72
- h("details.cv-sub", null, h("summary", null, t("conversation.receiptDetails")), receiptDetail),
99
+ h("p.cv-pop-title", null, t("conversation.details")), detailFacts,
100
+ h("div.cv-identity-row", null, identity, copyIdentity), liveNote, receiptBlock,
73
101
  h("p.cv-note", null, t("conversation.boundary"))));
74
- const pops = [menu, attachMenu, modelMenu];
102
+ const pops = [menu, attachMenu, modelMenu, picker];
75
103
  pops.forEach(function (pop) {
76
104
  pop.addEventListener("toggle", function () {
77
105
  if (!pop.open) return;
78
106
  pops.forEach(function (other) { if (other !== pop) other.open = false; });
79
107
  if (pop === modelMenu && !modelCatalog && !modelLoad.disabled) loadModels();
108
+ else if (pop === modelMenu) focusOption(modelRowMap.get(modelSelect.value) || modelRows.querySelector("button"));
109
+ if (pop === picker) focusOption(sessionList.querySelector("[aria-selected=true]") || sessionList.querySelector("button"));
80
110
  });
81
111
  });
82
112
  host.addEventListener("click", function (event) {
83
113
  pops.forEach(function (pop) { if (pop.open && !pop.contains(event.target)) pop.open = false; });
84
114
  });
115
+ // Escape closes only the open popover; the dock-level Escape (hide the
116
+ // dock) must not also fire for the same key press.
85
117
  host.addEventListener("keydown", function (event) {
86
- if (event.key === "Escape") pops.forEach(function (pop) { pop.open = false; });
118
+ if (event.key !== "Escape") return;
119
+ pops.forEach(function (pop) {
120
+ if (!pop.open) return;
121
+ event.stopPropagation();
122
+ pop.open = false;
123
+ pop.querySelector("summary")?.focus?.();
124
+ });
125
+ });
126
+ sessionList.addEventListener("keydown", function (event) {
127
+ const options = [...sessionList.querySelectorAll("button")];
128
+ const index = options.indexOf(document.activeElement);
129
+ const target = event.key === "ArrowDown" ? options[Math.min(options.length - 1, index + 1)]
130
+ : event.key === "ArrowUp" ? options[Math.max(0, index - 1)]
131
+ : event.key === "Home" ? options[0] : event.key === "End" ? options[options.length - 1] : null;
132
+ if (!target) return;
133
+ event.preventDefault(); focusOption(target);
134
+ });
135
+ modelRows.addEventListener("keydown", function (event) {
136
+ const rows = [...modelRowMap.values()];
137
+ const index = rows.indexOf(document.activeElement);
138
+ const target = event.key === "ArrowDown" ? rows[Math.min(rows.length - 1, index + 1)]
139
+ : event.key === "ArrowUp" ? rows[Math.max(0, index - 1)]
140
+ : event.key === "Home" ? rows[0] : event.key === "End" ? rows[rows.length - 1] : null;
141
+ if (target) { event.preventDefault(); focusOption(target); }
87
142
  });
88
143
  feed.addEventListener("scroll", function () {
89
144
  syncJump();
@@ -91,15 +146,51 @@ export function createConversationController(host, t, locale = function () { ret
91
146
  if (feed.scrollTop < 120 && next && historyOk && !busy) refresh(true);
92
147
  });
93
148
  host.append(
94
- h("div.cv-head", null, h("div.cv-picker", null, select, current), menu), status,
95
- h("div.cv-older", null, older),
96
- h("div.cv-stage", null, feed, latest),
149
+ h("div.cv-head", null, picker, current, h("span.spacer"), status, options.tools || null, menu),
150
+ h("div.cv-stage", null, older, feed, latest),
97
151
  questions, requests,
98
- h("div.cv-composer", null, slash,
152
+ h("div.cv-composer", null, slash, notice,
99
153
  h("div.cv-box", null, materialList, materialAlert, text,
100
- h("div.cv-tools", null, attachMenu, writerChip, h("span.spacer"), modelMenu, stop, send)),
154
+ h("div.cv-tools", null, attachMenu, h("span.spacer"), modelMenu, stop, send)),
101
155
  h("div.cv-hint", null, draftStatus, literal, check, receipt)));
102
156
 
157
+ function focusOption(option) { if (option && typeof option.focus === "function") option.focus(); }
158
+ // Write only real changes so an unchanged poll leaves the DOM untouched.
159
+ function setText(node, value) { if (node.textContent !== value) node.textContent = value; }
160
+ function setChildren(node, value) { if (node.textContent !== value) node.replaceChildren(value); }
161
+ function setProp(node, name, value) { if (node[name] !== value) node[name] = value; }
162
+ function setAttr(node, name, value) {
163
+ if ((node.getAttribute ? node.getAttribute(name) : node[name]) !== value) node.setAttribute(name, value);
164
+ }
165
+ async function afterRead() {
166
+ const own = generation;
167
+ while (reading) await new Promise(function (resolve) { readWaiters.push(resolve); });
168
+ return own === generation;
169
+ }
170
+ function endRead() {
171
+ reading = false;
172
+ const waiting = readWaiters; readWaiters = [];
173
+ waiting.forEach(function (resolve) { resolve(); });
174
+ }
175
+ function report() {
176
+ if (options.onSelection) options.onSelection(selected, facts ? facts.currentSessionId || null : undefined);
177
+ }
178
+ function selectedSession() {
179
+ return facts && facts.sessions.find(function (s) { return s.nativeSessionId === selected; }) || null;
180
+ }
181
+ function ownerLabel() {
182
+ if (!owner) return "";
183
+ return owner.scope === "global" ? owner.roleName === "operator" ? t("actions.operator") : owner.roleName
184
+ : owner.roleName + " · " + owner.taskId;
185
+ }
186
+ function sessionWhen(s) { return formatShort(s.createdAt, locale()) || "—"; }
187
+ function sessionLifecycle(s) {
188
+ return t(s.status === "active" ? "conversation.sessionActive"
189
+ : s.endReason === "failed" ? "conversation.sessionFailed" : "conversation.sessionEnded");
190
+ }
191
+ function sessionName(s) {
192
+ return (s.current ? t("conversation.current") : t("conversation.previous")) + " · " + (s.title || sessionWhen(s));
193
+ }
103
194
  function key() { return "yui.conversation." + JSON.stringify(owner) + "." + selected; }
104
195
  function saved(suffix, value, expectedRequestId) {
105
196
  try {
@@ -135,36 +226,61 @@ export function createConversationController(host, t, locale = function () { ret
135
226
  return "/api/conversation?" + q;
136
227
  }
137
228
  function syncJump() {
138
- latest.hidden = !((selected && !historyOk) || feed.scrollHeight - feed.clientHeight - feed.scrollTop > 80);
229
+ setProp(latest, "hidden", !((selected && !historyOk) || feed.scrollHeight - feed.clientHeight - feed.scrollTop > 80));
230
+ setProp(older, "hidden", !next || !historyOk || feed.scrollTop > 160);
139
231
  }
140
232
  function controls() {
141
233
  if (owner && selected) {
142
234
  try { pending = JSON.parse(saved(".pending") || "null"); }
143
235
  catch { recoveryUnavailable = true; }
144
236
  }
145
- defaultMode.value = readSessionAccessMode();
146
- const active = facts && facts.sessions.find(function (s) { return s.nativeSessionId === selected; });
237
+ const active = selectedSession();
238
+ // Task Roles other than the Leader keep their assignment controls; here they are read only.
239
+ const roleReadOnly = !!owner && owner.scope === "task" && owner.roleName !== "leader";
147
240
  const writable = historyOk && active && active.current && active.status === "active" && active.adapterId === "codex"
148
- && facts.authority && facts.authority.owner === "controller" && !facts.terminalWriter;
241
+ && facts.authority && facts.authority.owner === "controller" && !facts.terminalWriter && !roleReadOnly;
149
242
  const turn = facts && facts.turn;
150
- send.disabled = busy || recoveryUnavailable || !!pending || !!modelPending || !writable || (turn && ["submitting", "delivery-unknown"].includes(turn.status));
151
- stop.disabled = busy || recoveryUnavailable || !!pending || !writable || !turn || turn.status !== "accepted" || !turn.nativeTurnId;
152
- check.disabled = busy || !pending;
153
- older.disabled = busy || !next;
154
- older.hidden = !next;
155
- check.hidden = !pending;
156
- stop.hidden = !turn || turn.status !== "accepted";
157
- current.hidden = !facts || !selected || selected === facts.currentSessionId;
158
- writerChip.hidden = !(facts && facts.terminalWriter);
159
- literal.disabled = send.disabled;
243
+ const unsettled = turn && ["submitting", "delivery-unknown"].includes(turn.status);
244
+ const locked = busy && !reading;
245
+ setProp(send, "disabled", !!(locked || recoveryUnavailable || !!pending || !!modelPending || !writable || unsettled));
246
+ setProp(stop, "disabled", !!(locked || recoveryUnavailable || !!pending || !writable || !turn || turn.status !== "accepted" || !turn.nativeTurnId));
247
+ setProp(check, "disabled", !!(locked || !pending));
248
+ setProp(older, "disabled", !!(locked || !next));
249
+ setProp(check, "hidden", !pending);
250
+ setProp(stop, "hidden", !turn || turn.status !== "accepted");
251
+ setProp(current, "hidden", !facts || !selected || selected === facts.currentSessionId);
252
+ setProp(literal, "disabled", !!send.disabled);
160
253
  syncJump();
161
- modelLoad.disabled = busy || !active || active.adapterId !== "codex";
162
- modelApply.disabled = busy || !!pending || !!modelPending || !writable || !modelSelect.value
254
+ setProp(modelLoad, "disabled", !!(locked || !active || active.adapterId !== "codex"));
255
+ setProp(modelApply, "disabled", !!(locked || !!pending || !!modelPending || !writable || !modelSelect.value
256
+ || modelSelect.value === confirmedModel
163
257
  || !modelCatalog || modelCatalog.source === "fallback" || !!modelCatalog.failure
164
- || turn && ["submitting", "accepted", "delivery-unknown"].includes(turn.status);
165
- attachMenu.hidden = !owner || owner.scope !== "task";
166
- file.disabled = busy || !!pending || !writable || materials.length >= 8;
167
- materialList.querySelectorAll("button").forEach(function (control) { control.disabled = busy || !!pending; });
258
+ || turn && ["submitting", "accepted", "delivery-unknown"].includes(turn.status)));
259
+ modelRowMap.forEach(function (row) { setProp(row, "disabled", locked || !!modelPending); });
260
+ const modelReason = modelPending ? (locked ? "" : "conversation.modelUnknown")
261
+ : active && !active.current ? "conversation.readOnlyHistory"
262
+ : facts && facts.terminalWriter ? "conversation.modelTerminalBusy"
263
+ : turn && ["submitting", "accepted", "delivery-unknown"].includes(turn.status) ? "conversation.modelBusy"
264
+ : active && (!writable || roleReadOnly) ? "conversation.inputUnavailable" : "";
265
+ setText(modelStatus, modelReason ? t(modelReason) : "");
266
+ setProp(attachMenu, "hidden", !owner || owner.scope !== "task");
267
+ setProp(file, "disabled", !!(locked || !!pending || !writable || materials.length >= 8));
268
+ materialList.querySelectorAll("button").forEach(function (control) { setProp(control, "disabled", locked || !!pending); });
269
+ // One plain reason for unavailable input; ordered by what the user can act on.
270
+ const reason = !facts || !selected || !historyOk || !active ? ""
271
+ : !active.current ? "conversation.readOnlyHistory"
272
+ : facts.terminalWriter ? "conversation.terminalWriter"
273
+ : roleReadOnly ? "conversation.roleReadOnly"
274
+ : recoveryUnavailable ? "conversation.storageFailed"
275
+ : pending ? "conversation.pendingInput"
276
+ : unsettled ? "conversation.turnUnsettled"
277
+ : modelPending ? "conversation.modelApplying"
278
+ : !writable ? "conversation.inputUnavailable" : "";
279
+ setProp(notice, "hidden", !reason);
280
+ setProp(notice, "className", "cv-notice" + (["conversation.terminalWriter", "conversation.readOnlyHistory", "conversation.roleReadOnly"].includes(reason) ? "" : " is-warn"));
281
+ setText(noticeText, reason ? t(reason) : "");
282
+ setProp(noticeCurrent, "hidden", reason !== "conversation.readOnlyHistory");
283
+ setProp(text, "placeholder", t(reason === "conversation.readOnlyHistory" ? "conversation.placeholderHistory" : "conversation.placeholder"));
168
284
  }
169
285
  function showReceipt(result) {
170
286
  const refused = result.interrupt && result.interrupt.state === "not-interrupted";
@@ -174,72 +290,156 @@ export function createConversationController(host, t, locale = function () { ret
174
290
  + (result.messageId ? " · " + result.messageId : "")
175
291
  + (result.notDelivered ? " · " + (typeof result.notDelivered === "string" ? result.notDelivered : JSON.stringify(result.notDelivered)) : "");
176
292
  receiptDetail.textContent = JSON.stringify(result, null, 2);
293
+ receiptBlock.hidden = false;
294
+ }
295
+ // Keep execution evidence and the user's selected target apart. A Codex observation is the
296
+ // most recent native confirmation (start, resume or an adopted switch) with
297
+ // its time, not a live reading of the current Turn or of Desktop changes, so
298
+ // it is labelled that way. A pending switch is shown as pending. An absent, unknown or unreadable confirmation
299
+ // is shown as unconfirmed: never filled from launch, Role or poll time.
300
+ function observedAxis(observation, category) {
301
+ if (!observation || observation.status !== "observed" || !Array.isArray(observation.axes)) return null;
302
+ const axis = observation.axes.find(function (a) { return a.category === category; });
303
+ return axis && axis.current && axis.current.status === "observed" ? axis.current.value : null;
177
304
  }
305
+ function modelText(model, effort) { return model + (effort ? " · " + effort : ""); }
178
306
  function showModel(observation) {
179
- const axis = observation && observation.status === "observed"
180
- && observation.axes.find(function (a) { return a.key === "model"; });
181
- const value = axis && axis.current.status === "observed" ? axis.current.value : null;
182
- const session = facts && facts.sessions.find(function (s) { return s.nativeSessionId === selected; });
307
+ const value = observedAxis(observation, "model");
183
308
  confirmedModel = value;
184
- modelLabel.textContent = value || (session && session.launchModel) || t("conversation.model");
185
- modelStatus.textContent = t("conversation.launchModel") + ": " + (session && session.launchModel || "—")
186
- + " · " + t("conversation.confirmedModel") + ": " + (value || t("conversation.unknown"))
187
- + (value ? " · " + observation.observedAt : "");
188
- if (modelPending) {
189
- if (value === modelPending.model && observation.observedAt !== modelPending.previousObservation) {
190
- modelPending = null; saved(".modelPending", null);
191
- releaseMutation(url({ session: selected }).replace("/api/conversation?", "/api/conversation/model?"));
192
- } else modelStatus.textContent += " · " + t("conversation.modelUnknown");
309
+ modelObservation = observation || null;
310
+ // An unresolved switch keeps its pending record; controls() owns the note.
311
+ if (modelPending && value === modelPending.model && observation.observedAt !== modelPending.previousObservation) {
312
+ modelPending = null; saved(".modelPending", null);
313
+ releaseMutation(url({ session: selected }).replace("/api/conversation?", "/api/conversation/model?"));
193
314
  }
194
315
  lastModelObservation = value ? observation.observedAt : null;
316
+ // A catalog read before the first confirmation leaves nothing chosen;
317
+ // default that untouched choice to the confirmed model, as a load does.
318
+ if (value && !modelPending && !modelSelect.value && modelRowMap.has(value)) { pickModel(value); return; }
319
+ drawModel();
195
320
  }
196
- function pickModel(value) {
197
- modelSelect.value = value;
321
+ function fact(list, label, value, note) {
322
+ list.append(h("dt", null, label), h("dd", null, h("span", null, value), note ? h("small", null, note) : null));
323
+ }
324
+ function modelName(value) {
325
+ const known = modelCatalog && (modelCatalog.catalog.models || []).find(function (m) { return m.value === value; });
326
+ return known && known.label && known.label !== value ? known.label : value;
327
+ }
328
+ function drawModel() {
329
+ const observation = modelObservation;
330
+ const value = confirmedModel;
331
+ const effort = value ? observedAxis(observation, "effort") : null;
332
+ // Dated, so a confirmation from yesterday is not read as today's.
333
+ const at = value ? formatDateTime(observation.observedAt, locale()) : "";
334
+ let state, tag, label, title;
335
+ if (modelPending) {
336
+ state = "pending"; tag = t("conversation.modelStatePending"); label = modelPending.model; title = t("conversation.modelApplying");
337
+ } else if (value) {
338
+ state = "confirmed"; tag = t("conversation.modelStateConfirmed"); label = modelText(value, effort);
339
+ title = fill(t("conversation.modelConfirmedShort"), { time: at });
340
+ } else {
341
+ state = "unknown"; tag = ""; label = t("conversation.modelUnconfirmed"); title = t("conversation.modelUnconfirmedHint");
342
+ }
343
+ if (modelMenu.dataset.state !== state) modelMenu.dataset.state = state;
344
+ setText(modelState, tag);
345
+ setProp(modelState, "hidden", !tag);
346
+ setText(modelLabel, label);
347
+ setAttr(modelSummary, "title", t("conversation.model") + " · " + title);
348
+ setAttr(modelSummary, "aria-label", t("conversation.model") + " · " + (tag ? tag + " " : "") + label);
349
+ const selection = modelPending ? modelPending.model : modelSelect.value;
350
+ const rows = [
351
+ [t("conversation.confirmedModel"), value ? modelText(modelName(value), effort) : t("conversation.modelUnconfirmed"),
352
+ value ? t("conversation.modelStateConfirmed") : "", value ? title : t("conversation.modelUnconfirmedHint"), !value],
353
+ [t("conversation.selectedModel"), selection ? modelName(selection) : t("conversation.modelNotSelected"),
354
+ modelPending ? t("conversation.modelStatePending") : "", selection || "", !selection]];
355
+ rows.forEach(function (row, index) {
356
+ const node = modelFactNodes[index];
357
+ setChildren(node.term, row[0]);
358
+ setChildren(node.value, row[1]);
359
+ setChildren(node.note, row[2]);
360
+ setProp(node.note, "hidden", !row[2]);
361
+ setAttr(node.detail, "title", row[3]);
362
+ const kind = "cv-mf-value" + (row[4] ? " is-empty" : "");
363
+ if (node.value.className !== kind) node.value.className = kind;
364
+ });
198
365
  modelRowMap.forEach(function (row, key) {
199
- row.className = "cv-option" + (key === value ? " is-selected" : "");
200
- row.ariaSelected = key === value ? "true" : "false";
366
+ const kind = "cv-option" + (key === modelSelect.value ? " is-selected" : "") + (key === value ? " is-current" : "");
367
+ if (row.className !== kind) row.className = kind;
201
368
  });
369
+ }
370
+ function pickModel(value) {
371
+ modelSelect.value = value;
372
+ modelRowMap.forEach(function (row, key) { setAttr(row, "aria-selected", key === value ? "true" : "false"); });
373
+ modelResult.textContent = "";
374
+ modelResult.title = "";
375
+ drawModel();
202
376
  controls();
203
377
  }
204
378
  async function loadModels() {
205
- if (modelLoad.disabled) return;
379
+ if (!await afterRead()) return;
380
+ controls();
381
+ if (modelLoad.disabled || busy) return;
206
382
  const own = generation;
207
- busy = true; controls();
383
+ busy = true; modelResult.textContent = t("conversation.modelsLoading"); controls();
208
384
  try {
209
385
  const result = await requestJson(url({ session: selected }).replace("/api/conversation?", "/api/conversation/model?"));
210
386
  if (own !== generation) return;
387
+ const previousChoice = modelSelect.value;
211
388
  modelCatalog = result; clear(modelSelect); clear(modelRows); modelRowMap = new Map();
212
389
  (result.catalog.models || []).forEach(function (m) {
213
390
  modelSelect.append(h("option", { value: m.value }, m.label + " · " + m.value));
214
- const row = h("button.cv-option", { type: "button", role: "option" },
215
- h("span.cv-option-name", null, m.label), h("span.cv-option-id", null, m.value), icon("check"));
391
+ // The native id is shown beside a differing label (it is what the
392
+ // pill and facts name), not in a hover tooltip.
393
+ const row = h("button.cv-option", { type: "button", role: "option", "aria-selected": "false" },
394
+ h("i.cv-option-mark", { "aria-hidden": "true" }), h("span.cv-option-name", null, m.label || m.value),
395
+ m.label && m.label !== m.value ? h("span.cv-option-id", null, m.value) : null, icon("check"));
216
396
  row.addEventListener("click", function () { pickModel(m.value); });
217
397
  modelRowMap.set(m.value, row); modelRows.append(row);
218
398
  });
219
- pickModel(modelRowMap.has(confirmedModel) ? confirmedModel : "");
220
- modelResult.textContent = result.source + " · " + (result.fetchedAt || result.attemptedAt)
221
- + (result.failure ? " · " + result.failure.message : "");
222
- } catch (error) { if (own === generation) modelResult.textContent = error.message; }
223
- finally { finish(own); }
399
+ pickModel(modelRowMap.has(previousChoice) ? previousChoice : modelRowMap.has(confirmedModel) ? confirmedModel : "");
400
+ modelLoad.hidden = !result.failure && result.source !== "fallback" && modelRowMap.size > 0;
401
+ modelResult.textContent = result.failure || result.source === "fallback" ? t("conversation.modelsFailed")
402
+ : !modelRowMap.size ? t("conversation.modelsEmpty") : "";
403
+ modelResult.title = result.failure?.message || "";
404
+ } catch (error) {
405
+ if (own === generation) {
406
+ modelCatalog = null; modelLoad.hidden = false;
407
+ modelResult.textContent = t("conversation.modelsFailed"); modelResult.title = error.message;
408
+ }
409
+ }
410
+ finally {
411
+ finish(own);
412
+ if (own === generation && modelMenu.open) focusOption(modelRowMap.get(modelSelect.value) || modelRows.querySelector("button"));
413
+ }
224
414
  }
225
415
  async function applyModel() {
226
- if (modelApply.disabled) return;
416
+ const choice = modelSelect.value;
417
+ if (!await afterRead() || choice !== modelSelect.value) return;
418
+ controls();
419
+ if (modelApply.disabled || busy) return;
227
420
  const own = generation;
228
421
  const endpoint = url({ session: selected }).replace("/api/conversation?", "/api/conversation/model?");
229
422
  modelPending = { model: modelSelect.value, previousObservation: lastModelObservation };
230
423
  saved(".modelPending", JSON.stringify(modelPending));
424
+ // The Host drops its own confirmation before adopting; so does this view.
425
+ confirmedModel = null; drawModel();
231
426
  busy = true; controls();
232
427
  modelResult.textContent = t("conversation.modelApplying");
233
428
  try {
234
429
  const result = await submitMutation(endpoint, endpoint, { model: modelPending.model });
235
430
  if (own !== generation) return;
236
- modelPending = null; saved(".modelPending", null);
237
431
  showModel(result.runConfiguration);
238
- modelResult.textContent = t("conversation.modelConfirmed");
432
+ modelResult.textContent = modelPending ? "" : t("conversation.modelConfirmed");
433
+ if (!modelPending && confirmedModel === modelSelect.value) {
434
+ modelResult.textContent = ""; modelMenu.open = false; modelSummary.focus?.();
435
+ }
239
436
  } catch (error) {
240
437
  if (own !== generation) return;
241
- modelResult.textContent = error.message + " · " + t(error.disposition === "not-submitted" ? "conversation.modelFailed" : "conversation.modelUnknown");
438
+ modelResult.textContent = error.disposition !== "not-submitted" ? ""
439
+ : t(error.message.includes("Exit the native TUI") ? "conversation.modelTerminalBusy" : "conversation.modelFailed");
440
+ modelResult.title = error.message;
242
441
  if (error.disposition === "not-submitted") { modelPending = null; saved(".modelPending", null); }
442
+ drawModel();
243
443
  } finally { finish(own); }
244
444
  }
245
445
  const commands = ["help", "status", "model", "stop", "latest"];
@@ -281,10 +481,11 @@ export function createConversationController(host, t, locale = function () { ret
281
481
  ref.relativePath + " · " + ref.commit + " · sha256:" + ref.digest), remove));
282
482
  });
283
483
  }
284
- function renderRequests(rows) {
285
- const signature = JSON.stringify([selected, rows, !!facts.terminalWriter]);
484
+ function renderRequests(rows, omitted) {
485
+ const signature = JSON.stringify([selected, rows, !!facts.terminalWriter, !!omitted]);
286
486
  if (signature === requestSignature) return;
287
487
  requestSignature = signature; clear(requests);
488
+ if (omitted) requests.append(h("p.cv-note", null, t("conversation.nativeOmitted")));
288
489
  rows.forEach(function (request) {
289
490
  const card = h("section.cv-card", null, h("h3.cv-card-title", null, t("conversation.nativeRequest")),
290
491
  h("p.cv-note", null, request.method + " · " + request.turnId));
@@ -294,6 +495,7 @@ export function createConversationController(host, t, locale = function () { ret
294
495
  (params.questions || []).forEach(function (question) {
295
496
  const field = h("input", { type: question.isSecret ? "password" : "text", "aria-label": question.question });
296
497
  const draftKey = ".nativeDraft." + request.turnId + "." + request.id + "." + question.id;
498
+ nativeDraftKeys.add(draftKey);
297
499
  if (!question.isSecret) {
298
500
  field.value = saved(draftKey) || "";
299
501
  field.addEventListener("input", function () { saved(draftKey, field.value); });
@@ -383,7 +585,11 @@ export function createConversationController(host, t, locale = function () { ret
383
585
  try { materials = JSON.parse(saved(".materials") || "[]"); } catch { materials = []; }
384
586
  try { modelPending = JSON.parse(saved(".modelPending") || "null"); } catch { modelPending = null; }
385
587
  modelCatalog = null; lastModelObservation = null; clear(modelSelect); clear(modelRows); modelRowMap = new Map(); confirmedModel = null;
386
- modelLabel.textContent = t("conversation.model"); modelStatus.textContent = ""; modelResult.textContent = ""; receiptDetail.textContent = "";
588
+ // A historical Session never borrows another Session's model confirmation.
589
+ modelObservation = null; pageFacts = null;
590
+ modelStatus.textContent = ""; modelResult.textContent = ""; receiptDetail.textContent = ""; receiptBlock.hidden = true;
591
+ status.textContent = ""; detailSignature = ""; sessionSignature = "";
592
+ drawModel();
387
593
  drawCommands();
388
594
  materialAlert.textContent = "";
389
595
  if (incomingMaterials) {
@@ -400,6 +606,7 @@ export function createConversationController(host, t, locale = function () { ret
400
606
  clear(requests); requestSignature = "";
401
607
  clear(questions); questionSignature = "";
402
608
  controls();
609
+ report();
403
610
  }
404
611
  function finish(own) {
405
612
  busy = false; controls();
@@ -441,6 +648,8 @@ export function createConversationController(host, t, locale = function () { ret
441
648
  function messageNode(item) {
442
649
  const meta = itemMeta(item);
443
650
  if (item.kind === "user") {
651
+ // User text is shown exactly as sent: tags in it are the user's words,
652
+ // never guessed to be attached context.
444
653
  return h("article.cv-msg.cv-user", { title: meta },
445
654
  item.source === "yui-input" ? h("span.cv-tag", null, t("conversation.linkedInput") + " " + item.messageId) : null,
446
655
  h("div.cv-bubble", null, richText(null, item.text, t, { threshold: 1800 })),
@@ -511,20 +720,74 @@ export function createConversationController(host, t, locale = function () { ret
511
720
  feed.replaceChildren(...nodes);
512
721
  }
513
722
  }
514
- function drawSessions(rows, append) {
515
- if (!append) clear(select);
516
- rows.forEach(function (s) {
517
- if ([...select.options].some(function (o) { return o.value === s.nativeSessionId; })) return;
518
- select.append(h("option", { value: s.nativeSessionId },
519
- (s.current ? t("conversation.current") : t("conversation.previous")) + " · " + s.executionAuthority + " · " + s.nativeSessionId));
723
+ function chooseSession(id) {
724
+ picker.open = false;
725
+ picker.querySelector("summary")?.focus?.();
726
+ if (id === selected) return;
727
+ choose(id); refresh();
728
+ }
729
+ function drawSessions() {
730
+ const session = selectedSession();
731
+ pickerName.textContent = session ? sessionName(session)
732
+ : selected ? t(selected === (facts && facts.currentSessionId) ? "conversation.current" : "conversation.previous") : t("conversation.noSessionShort");
733
+ picker.querySelector("summary").title = pickerName.textContent;
734
+ const rows = facts.sessions.slice().sort(function (a, b) {
735
+ return Number(b.current) - Number(a.current) || String(b.createdAt).localeCompare(String(a.createdAt));
520
736
  });
521
- select.value = selected || "";
737
+ const signature = JSON.stringify([selected, facts.nextOffset, locale(), rows.map(function (s) {
738
+ return [s.nativeSessionId, s.current, s.status, s.endReason, s.title, s.createdAt];
739
+ })]);
522
740
  moreSessions.hidden = !facts.nextOffset;
741
+ if (signature === sessionSignature) return;
742
+ sessionSignature = signature;
743
+ clear(sessionList);
744
+ let historyHeading = false;
745
+ rows.forEach(function (s) {
746
+ if (!s.current && !historyHeading) {
747
+ historyHeading = true;
748
+ sessionList.append(h("p.cv-pop-title", { role: "presentation" }, t("conversation.previousSessions")));
749
+ }
750
+ const chosen = s.nativeSessionId === selected;
751
+ const option = h("button.cv-session-opt", { type: "button", role: "option", "aria-selected": String(chosen),
752
+ title: s.title || sessionWhen(s), dataset: { session: s.nativeSessionId } },
753
+ h("span.cv-so-dot.is-" + (s.status === "active" ? "active" : s.endReason === "failed" ? "failed" : "ended"), { "aria-hidden": "true" }),
754
+ h("span.cv-so-text", null,
755
+ h("span.cv-so-main", null, s.title || (s.current ? t("conversation.current") : sessionWhen(s))),
756
+ h("span.cv-so-meta", null, (s.title || s.current ? sessionWhen(s) + " · " : "") + sessionLifecycle(s))),
757
+ chosen ? icon("check", "cv-so-check") : null);
758
+ option.addEventListener("click", function () { chooseSession(s.nativeSessionId); });
759
+ sessionList.append(option);
760
+ });
761
+ }
762
+ // Low-frequency identity and diagnostics: readable first, exact values copyable.
763
+ function drawDetails() {
764
+ const session = selectedSession();
765
+ const turn = facts && selected === facts.currentSessionId ? facts.turn : null;
766
+ const exact = (owner.taskId || "Global") + " / " + owner.roleName + " / " + selected
767
+ + (turn ? " / " + (turn.nativeTurnId || turn.attemptId) + (turn.runId ? " / " + turn.runId : "") : "");
768
+ const live = pageFacts && pageFacts.live === "unavailable";
769
+ const signature = JSON.stringify([exact, session, pageFacts && [pageFacts.live, pageFacts.linkedInputsOmitted],
770
+ !!(facts && facts.terminalWriter), locale()]);
771
+ if (signature === detailSignature) return;
772
+ detailSignature = signature;
773
+ clear(detailFacts);
774
+ fact(detailFacts, t("dock.target"), ownerLabel());
775
+ if (session) {
776
+ fact(detailFacts, t("conversation.sessionLabel"), sessionName(session), sessionLifecycle(session));
777
+ fact(detailFacts, t("conversation.sessionId"), session.nativeSessionId);
778
+ if (session.executionAuthority) fact(detailFacts, t("conversation.authority"), session.executionAuthority);
779
+ if (session.agentId) fact(detailFacts, t("conversation.agent"), session.agentId + (session.adapterId ? " · " + session.adapterId : ""));
780
+ }
781
+ if (turn) fact(detailFacts, t("conversation.turn"), String(turn.nativeTurnId || turn.attemptId), turn.status);
782
+ fact(detailFacts, t("conversation.writer"), t(facts && facts.terminalWriter ? "conversation.terminalWriter" : "conversation.structuredWriter"));
783
+ identity.textContent = exact;
784
+ liveNote.textContent = [live ? t("conversation.liveUnavailable") : "",
785
+ pageFacts && pageFacts.linkedInputsOmitted ? t("conversation.linkedOmitted") : ""].filter(Boolean).join(" · ");
523
786
  }
524
787
  async function refresh(older) {
525
788
  if (!owner || busy || host.hidden || host.closest("[hidden]") || document.hidden) return;
526
789
  if (older && (!next || !selected || !facts || !historyOk)) return;
527
- busy = true; controls();
790
+ busy = true; reading = true; controls();
528
791
  let own = generation;
529
792
  try {
530
793
  if (older) {
@@ -553,13 +816,17 @@ export function createConversationController(host, t, locale = function () { ret
553
816
  choose(facts.currentSessionId);
554
817
  own = generation;
555
818
  }
556
- if (!selected) { status.textContent = t("conversation.noSession"); return; }
819
+ report();
820
+ if (!selected) {
821
+ status.textContent = t("conversation.noSession"); status.className = "cv-status is-unknown";
822
+ pickerName.textContent = t("conversation.noSessionShort"); return;
823
+ }
557
824
  // Preserve a historical choice even if it is outside the newest page.
558
- drawSessions(facts.sessions, false);
825
+ drawSessions(); drawDetails();
559
826
  renderQuestions();
560
827
  const page = await requestJson(url({ session: selected }));
561
828
  if (own !== generation) return;
562
- historyOk = true; latestItems = page.items;
829
+ historyOk = true; latestItems = page.items; pageFacts = page;
563
830
  if (!olderItems.length) next = page.nextCursor;
564
831
  const turn = selected === facts.currentSessionId ? facts.turn : null;
565
832
  let state = page.status === "waiting-user" ? "waiting" : page.status === "active" ? "active"
@@ -567,26 +834,28 @@ export function createConversationController(host, t, locale = function () { ret
567
834
  const session = facts.sessions.find(function (s) { return s.nativeSessionId === selected; });
568
835
  if (session && session.status === "ended") state = session.endReason === "failed" ? "failed" : "ended";
569
836
  if (turn && turn.status === "delivery-unknown") state = "unknown";
570
- status.textContent = t("conversation." + state);
837
+ status.textContent = t(state === "unknown" ? "conversation.stateUnknown" : "conversation." + state);
571
838
  status.title = page.observedAt + " · " + t(facts.terminalWriter ? "conversation.terminalWriter" : "conversation.structuredWriter");
572
839
  status.className = "cv-status is-" + state;
573
- if (page.live === "unavailable") status.textContent += " · " + t("conversation.liveUnavailable");
574
- if (page.linkedInputsOmitted) status.textContent += " · " + t("conversation.linkedOmitted");
575
- if (page.nativeRequestsOmitted) status.textContent += " · " + t("conversation.nativeOmitted");
576
- renderRequests(page.nativeRequests || []);
840
+ renderRequests(page.nativeRequests || [], !!page.nativeRequestsOmitted);
577
841
  showModel(page.runConfiguration);
578
- identity.textContent = (owner.taskId || "Global") + " / " + owner.roleName + " / " + selected
579
- + (turn ? " / " + (turn.nativeTurnId || turn.attemptId) + (turn.runId ? " / " + turn.runId : "") : "");
842
+ drawDetails();
580
843
  const scroll = feed.scrollTop, atEnd = feed.scrollHeight - feed.clientHeight - scroll < 40;
581
844
  renderItems(allItems());
582
845
  feed.scrollTop = atEnd ? feed.scrollHeight : scroll;
583
846
  syncJump();
584
847
  } catch (error) {
585
- if (own === generation) { historyOk = false; status.textContent = t("conversation.disconnected") + " · " + error.message; }
586
- } finally { finish(own); }
848
+ if (own === generation) {
849
+ historyOk = false; status.textContent = t("conversation.disconnectedShort");
850
+ status.title = t("conversation.disconnected") + " · " + error.message; status.className = "cv-status is-failed";
851
+ // An unreadable confirmation is unconfirmed, not the earlier value.
852
+ if (confirmedModel) { confirmedModel = null; modelObservation = null; drawModel(); }
853
+ }
854
+ } finally { endRead(); finish(own); }
587
855
  if (own === generation && pending && historyOk) await inspectReceipt();
588
856
  }
589
857
  async function inspectReceipt() {
858
+ if (!await afterRead()) return;
590
859
  if (!pending || busy) return;
591
860
  const own = generation;
592
861
  const inspected = pending;
@@ -613,6 +882,10 @@ export function createConversationController(host, t, locale = function () { ret
613
882
  finally { finish(own); }
614
883
  }
615
884
  async function submit(interrupt, asLiteral) {
885
+ const body = text.value;
886
+ const target = facts && facts.turn && (facts.turn.nativeTurnId || facts.turn.attemptId);
887
+ if (!await afterRead() || body !== text.value
888
+ || target !== (facts && facts.turn && (facts.turn.nativeTurnId || facts.turn.attemptId))) return;
616
889
  controls(); // Another tab may have acquired an unresolved input since refresh.
617
890
  if (busy || pending || (interrupt ? stop.disabled : send.disabled)) return;
618
891
  if (!interrupt && !text.value.trim()) return;
@@ -650,7 +923,7 @@ export function createConversationController(host, t, locale = function () { ret
650
923
  if (own === generation) await inspectReceipt();
651
924
  }
652
925
  text.addEventListener("input", function () {
653
- if (selected) { draftStatus.textContent = t("conversation.draftSaved"); saved(".draft", text.value); }
926
+ if (selected) saved(".draft", text.value);
654
927
  drawCommands();
655
928
  });
656
929
  text.addEventListener("keydown", function (event) {
@@ -664,12 +937,14 @@ export function createConversationController(host, t, locale = function () { ret
664
937
  }
665
938
  });
666
939
  modelLoad.addEventListener("click", loadModels);
667
- modelSelect.addEventListener("change", controls);
940
+ modelSelect.addEventListener("change", function () { pickModel(modelSelect.value); });
668
941
  modelApply.addEventListener("click", applyModel);
669
942
  literal.addEventListener("click", function () { submit(false, true); });
670
943
  file.addEventListener("change", async function () {
944
+ if (!await afterRead()) return;
945
+ controls();
671
946
  const chosen = file.files[0];
672
- if (!chosen || file.disabled) return;
947
+ if (!chosen || file.disabled || busy) return;
673
948
  const own = generation;
674
949
  const endpoint = url({ session: selected }).replace("/api/conversation?", "/api/conversation/material?");
675
950
  const requestId = crypto.randomUUID();
@@ -688,9 +963,17 @@ export function createConversationController(host, t, locale = function () { ret
688
963
  + " materials/" + requestId + "/" + chosen.name;
689
964
  } finally { file.value = ""; finish(own); drawMaterials(); }
690
965
  });
691
- select.addEventListener("change", function () { choose(select.value); refresh(); });
692
- current.addEventListener("click", function () { if (facts) { choose(facts.currentSessionId); refresh(); } });
693
- older.addEventListener("click", function () { refresh(true); });
966
+ function backToCurrent() { if (facts && facts.currentSessionId) chooseSession(facts.currentSessionId); }
967
+ current.addEventListener("click", backToCurrent);
968
+ noticeCurrent.addEventListener("click", backToCurrent);
969
+ copyIdentity.addEventListener("click", function () {
970
+ navigator.clipboard.writeText(identity.textContent).then(function () {
971
+ copyIdentity.title = t("conversation.copied");
972
+ }, function () { copyIdentity.title = t("conversation.copyFailed"); });
973
+ });
974
+ older.addEventListener("click", function () {
975
+ if (reading) afterRead().then(function (same) { if (same) refresh(true); }); else refresh(true);
976
+ });
694
977
  latest.addEventListener("click", function () {
695
978
  refresh().then(function () { feed.scrollTop = feed.scrollHeight; syncJump(); });
696
979
  });
@@ -705,7 +988,7 @@ export function createConversationController(host, t, locale = function () { ret
705
988
  const page = await requestJson(url({ offset: facts.nextOffset }));
706
989
  if (own !== generation) return;
707
990
  facts.sessions = facts.sessions.concat(page.sessions); facts.nextOffset = page.nextOffset;
708
- drawSessions(page.sessions, true);
991
+ drawSessions();
709
992
  } catch (error) { status.textContent = error.message; }
710
993
  finally { finish(own); }
711
994
  });
@@ -742,11 +1025,14 @@ export function createConversationController(host, t, locale = function () { ret
742
1025
  // from the shared endpoint, not retain a historical selection or stale ID.
743
1026
  if (JSON.stringify(target) !== JSON.stringify(owner) || options && options.current) {
744
1027
  if (selected) saved(".draft", text.value);
745
- generation++; owner = target; selected = null; facts = null; pending = null; clear(select); clear(feed);
1028
+ generation++; owner = target; selected = null; facts = null; pending = null; clear(feed);
1029
+ clear(sessionList); sessionSignature = ""; detailSignature = ""; clear(detailFacts); pageFacts = null;
1030
+ pickerName.textContent = t("conversation.sessions"); status.textContent = ""; status.className = "cv-status";
746
1031
  next = null; latestItems = []; olderItems = []; historyOk = false; rendered.clear();
747
- text.value = ""; receipt.textContent = ""; identity.textContent = ""; controls();
1032
+ text.value = ""; receipt.textContent = ""; identity.textContent = ""; liveNote.textContent = "";
1033
+ receiptBlock.hidden = true; controls();
748
1034
  modelPending = null; modelCatalog = null; clear(modelSelect); clear(modelRows); modelRowMap = new Map(); confirmedModel = null;
749
- modelLabel.textContent = t("conversation.model"); modelStatus.textContent = "";
1035
+ modelObservation = null; drawModel(); modelStatus.textContent = "";
750
1036
  modelResult.textContent = "";
751
1037
  receiptDetail.textContent = ""; draftStatus.textContent = ""; drawCommands();
752
1038
  materials = []; incomingMaterials = null; clear(materialList); materialAlert.textContent = "";
@@ -754,10 +1040,22 @@ export function createConversationController(host, t, locale = function () { ret
754
1040
  clear(questions); questionSignature = "";
755
1041
  }
756
1042
  if (options && options.materials) incomingMaterials = options.materials;
1043
+ // A named Session (carried from another mode) is kept even when it is no
1044
+ // longer current: never silently exchanged for today's current Session.
1045
+ if (options && options.session && options.session !== selected) choose(options.session);
757
1046
  if (!timer) timer = window.setInterval(function () { refresh(); }, 2000);
758
1047
  refresh();
759
1048
  },
760
- close: function () { if (timer) window.clearInterval(timer); timer = null; generation++; }
1049
+ close: function () { if (timer) window.clearInterval(timer); timer = null; generation++; },
1050
+ // The session page left this view: forget its unsent text. A draft that
1051
+ // is the body of an unresolved submission stays with that receipt.
1052
+ discardDraft: function () {
1053
+ if (!owner || !selected) return;
1054
+ let held = null;
1055
+ try { held = JSON.parse(saved(".pending") || "null"); } catch { held = null; }
1056
+ if (!held) saved(".draft", null);
1057
+ nativeDraftKeys.forEach(function (draftKey) { saved(draftKey, null); });
1058
+ }
761
1059
  };
762
1060
  }
763
1061
  `;