minecodex 2.0.4 → 2.0.5

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 (38) hide show
  1. package/DESIGN.md +25 -1
  2. package/README.md +4 -2
  3. package/features/images/DESIGN.md +106 -41
  4. package/features/images/README.md +25 -16
  5. package/features/images/codex-feature.json +6 -3
  6. package/features/images/src/adapters/codex/image-prompt.mjs +24 -2
  7. package/features/images/src/adapters/codex/image-sources.mjs +19 -0
  8. package/features/images/src/adapters/codex/library-api.mjs +2 -0
  9. package/features/images/src/adapters/codex/library-viewer.mjs +26 -2
  10. package/features/images/src/adapters/codex/native-image-source.mjs +556 -0
  11. package/features/images/src/backend/local-image-source.mjs +138 -0
  12. package/features/images/src/frontend/cloud-image-actions.mjs +39 -0
  13. package/features/images/src/frontend/local-image-source.mjs +194 -0
  14. package/features/images/src/library-prompt-page.mjs +158 -53
  15. package/features/images/src/library-prompt-source.mjs +11 -3
  16. package/features/images/src/prompt-index.mjs +14 -2
  17. package/features/model-slider/DESIGN.md +2 -0
  18. package/features/model-slider/src/frontend/install.mjs +78 -11
  19. package/features/notes/DESIGN.md +7 -3
  20. package/features/notes/src/frontend/entry.mjs +26 -9
  21. package/features/session-status/DESIGN.md +4 -2
  22. package/features/session-status/src/adapters/codex/session-log.mjs +2 -1
  23. package/features/session-status/src/domain/session-stats.mjs +17 -9
  24. package/features/session-status/src/session-status.mjs +47 -11
  25. package/package.json +1 -1
  26. package/packages/codex-adapter/README.md +2 -1
  27. package/packages/codex-adapter/src/renderer/environment.mjs +24 -6
  28. package/packages/runtime-host/src/codex-injection.mjs +3 -6
  29. package/packages/runtime-host/src/codex-runtime.mjs +16 -5
  30. package/packages/runtime-host/src/feature-registry.mjs +1 -0
  31. package/packages/ui/DESIGN.md +5 -2
  32. package/packages/ui/src/components/button.mjs +2 -0
  33. package/packages/ui/src/components/navigation-rail.mjs +18 -10
  34. package/packages/ui/src/components/page.mjs +9 -2
  35. package/packages/ui/src/install.mjs +3 -1
  36. package/packages/ui/src/native/navigation-rail.mjs +6 -1
  37. package/packages/ui/src/native/page.mjs +23 -0
  38. package/packages/ui/src/native/tokens.mjs +3 -0
@@ -1,4 +1,4 @@
1
- // 仅增强原生 Library 图片详情;不创建第二个图库或侧栏入口。
1
+ // 原生 Library 图片详情的提示词增强;Image Gen 由同功能的独立安装器承载。
2
2
  const PANEL_ID = "minecodex-library-prompt";
3
3
  const zh = () => (document.documentElement.lang || navigator.language || "en").toLowerCase().startsWith("zh");
4
4
  const label = (en, cn) => zh() ? cn : en;
@@ -7,7 +7,8 @@ const libraryApi=createLibraryApi();
7
7
  let scheduled = false;
8
8
  let candidate = null;
9
9
 
10
- const libraryImage=createLibraryViewerAdapter(config.codex).read;
10
+ const libraryViewer=createLibraryViewerAdapter(config.codex,{readViewer:header=>localImageSource?.readViewer(header)});
11
+ const libraryImage=libraryViewer.read;
11
12
  function element(tag, className, text) {
12
13
  const node = document.createElement(tag);
13
14
  node.className = className;
@@ -26,79 +27,128 @@ function copyGlyph(copied = false) {
26
27
  }
27
28
 
28
29
  function closePanel(record, restoreFocus = false) {
29
- record.request?.abort();
30
- record.request = null;
31
30
  record.panel.hidden = true;
32
- record.button.setAttribute("aria-expanded", "false");
31
+
33
32
  if (restoreFocus && record.button.isConnected) record.button.focus();
34
33
  }
35
34
 
36
35
  function showPrompt(record) {
37
36
  record.panel.hidden = false;
38
- record.button.setAttribute("aria-expanded", "true");
37
+
39
38
  record.close.focus();
40
39
  }
41
40
 
42
41
  async function resolvePrompt(file, request) {
43
- if (!file.origination_thread_id || !file.origination_message_id) return null;
44
42
  request.signal.throwIfAborted();
45
- const conversation = await libraryApi.conversation(file.origination_thread_id,AbortSignal.any([signal,request.signal,AbortSignal.timeout(25_000)]));
46
- return promptFromConversation(file, conversation);
43
+ if(file.kind === "local") return config.requestHostAction("local-image-source",{operation:"prompt",id:file.id});
44
+ if(!file.origination_thread_id || !file.origination_message_id)return null;
45
+ const conversation=await libraryApi.conversation(file.origination_thread_id,AbortSignal.any([signal,request.signal,AbortSignal.timeout(25_000)]));
46
+ return promptFromConversation(file,conversation);
47
47
  }
48
48
 
49
49
  function unmount() {
50
50
  if (!mounted) return;
51
51
  closePanel(mounted);
52
52
  clearTimeout(mounted.copyTimer);
53
- mounted.button.remove();
53
+ mounted.menuItem?.remove();
54
+ mounted.menuCleanup?.();
54
55
  mounted.panel.remove();
55
56
  mounted = null;
56
57
  }
57
58
 
59
+ function syncMenu(record) {
60
+ const anchor = libraryViewer.promptMenu(record.file);
61
+ if (record.menuItem?.isConnected && record.menuItem.parentElement === anchor?.original.parentElement) return;
62
+ record.menuItem?.remove();
63
+ record.menuCleanup?.();
64
+ record.menuItem = null;
65
+ record.menuCleanup = null;
66
+ if (!anchor) return;
67
+ const {menu,original} = anchor;
68
+ const item = element("div", original.className);
69
+ item.setAttribute("role", "menuitem");
70
+ item.tabIndex = -1;
71
+ item.dataset.minecodexLibraryPrompt = "menuitem";
72
+ const row = original.firstElementChild.cloneNode(true);
73
+ row.querySelector(".truncate").textContent = label("View prompts", "查看提示词");
74
+ // 使用项目现有 Prompts 图形,保留原生菜单的紧凑/标准图标布局。
75
+ const glyph = document.querySelector('[aria-label="Prompts"] svg');
76
+ if (glyph) for (const svg of row.querySelectorAll("svg")) {
77
+ const icon = glyph.cloneNode(true);
78
+ icon.setAttribute("width", svg.getAttribute("width"));
79
+ icon.setAttribute("height", svg.getAttribute("height"));
80
+ svg.replaceWith(icon);
81
+ }
82
+ item.append(row);
83
+ original.before(item);
84
+ record.menuItem = item;
85
+ const open = event => {
86
+ event.preventDefault();
87
+ event.stopPropagation();
88
+ menu.addEventListener("focusScope.autoFocusOnUnmount", event => {
89
+ event.preventDefault();
90
+ if (mounted?.identity === record.identity) mounted.close.focus();
91
+ }, {once:true});
92
+ // 原生菜单在 document capture 处理 Escape;阻止它再冒泡关闭图片视口。
93
+ const stopEscape = event => event.stopPropagation();
94
+ menu.addEventListener("keydown", stopEscape, {once:true});
95
+ menu.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape",bubbles:true,cancelable:true}));
96
+ menu.removeEventListener("keydown", stopEscape);
97
+ setTimeout(() => {
98
+ // 原生关闭菜单可能重建工具栏;把打开意图交给同一图片的新实例。
99
+ if (mounted?.identity === record.identity) showPrompt(mounted);
100
+ }, 0);
101
+ };
102
+ item.addEventListener("click", open);
103
+ item.addEventListener("pointermove", () => item.focus());
104
+ const keyboard = event => {
105
+ if (event.target === item && ["Enter", " "].includes(event.key)) return open(event);
106
+ if (!["ArrowUp", "ArrowDown"].includes(event.key)) return;
107
+ const items = [...menu.querySelectorAll('[role="menuitem"]')];
108
+ const index = items.indexOf(document.activeElement);
109
+ const next = items[(index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length];
110
+ // 仅补上未注册到原生 collection 的注入项,其他按键仍交给原生菜单。
111
+ if (index >= 0 && (document.activeElement === item || next === item)) {
112
+ event.preventDefault();event.stopImmediatePropagation();next.focus();
113
+ }
114
+ };
115
+ menu.addEventListener("keydown", keyboard, true);
116
+ record.menuCleanup = () => menu.removeEventListener("keydown", keyboard, true);
117
+ }
118
+
58
119
  async function scan() {
59
120
  scheduled = false;
60
121
  if (signal.aborted) return;
61
122
  const header = [...document.querySelectorAll('[data-testid="viewer-header"]')].find(header => header.getBoundingClientRect().width > 0 && libraryImage(header));
62
123
  const file = header && libraryImage(header);
124
+ const toolbar = header && libraryViewer.toolbar(header);
63
125
  const identity = file && JSON.stringify(file);
64
126
  if (candidate?.header === header && candidate?.identity === identity
65
- && (!mounted || (mounted.button.isConnected && mounted.panel.isConnected))) return;
127
+ && candidate?.zoom === toolbar?.zoom && candidate?.host === toolbar?.host
128
+ && (!mounted || (mounted.button.isConnected && mounted.panel.isConnected))) {
129
+ if (mounted) {syncMenu(mounted);openPendingLocalPrompt();}
130
+ return;
131
+ }
66
132
  candidate?.request.abort();
67
133
  unmount();
68
134
  candidate = null;
69
- if (!file) return;
70
- const next = { header, identity, request: new AbortController() };
135
+ // 等原生图片工具栏就绪再解析;否则早到的结果会把同图后续扫描短路。
136
+ if (!file || !toolbar || (file.kind !== "local" && (!file.origination_thread_id || !file.origination_message_id))) return;
137
+ const next = { header, identity, zoom:toolbar.zoom, host:toolbar.host, request: new AbortController() };
71
138
  candidate = next;
72
- let prompt;
73
- try { prompt = await resolvePrompt(file, next.request); }
74
- catch { return; }
75
- // 异步结果只能写回当前图片;没有提示词时不展示入口。
76
- if (!prompt?.text || signal.aborted || candidate !== next || !header.isConnected) return;
77
- // 缩放项在图片详情中是百分比菜单;沿用它的胶囊类,不固定构建 hash。
78
- const zoom = [...header.querySelectorAll('button[aria-haspopup="menu"]')].find(button => /^\d+(?:\.\d+)?%$/.test(button.textContent.trim()));
79
- const group = zoom?.parentElement?.parentElement;
80
- const host = header.closest('[role="presentation"]');
81
- if (!zoom || !group || !host) return;
82
- const capsuleClass = zoom.className.split(/\s+/).filter(name => !/^_(?:dropdown|zoom)_/.test(name)).join(" ");
83
- const button = element("button", capsuleClass, label("Prompt", "提示词"));
84
- button.type = "button";
85
- button.dataset.minecodexLibraryPrompt = "trigger";
86
- button.setAttribute("aria-expanded", "false");
87
- button.setAttribute("aria-controls", PANEL_ID);
88
- button.style.width = "max-content";
89
- button.style.minWidth = "0";
90
- button.style.flex = "0 0 auto";
91
- button.style.paddingInline = "calc(var(--mcx-spacing) * 3)";
92
- group.prepend(button);
93
- // 对齐原生 Library Version history 的面板、标题和内容区 token。
94
- const panel = element("aside", "absolute inset-x-2 top-toolbar bottom-2 z-30 ms-auto mt-2 flex max-w-75 flex-col overflow-hidden rounded-2xl border border-default bg-surface shadow-lg select-none");
139
+ const {host,trigger:button} = toolbar;
140
+ // 对齐当前原生 File info 的实体材质、尺寸和内容区。
141
+ const panel = element("aside", "absolute end-2 bottom-2 z-40 flex min-h-0 flex-col overflow-hidden rounded-2xl bg-surface-elevated-secondary electron:elevation-prominent text-default select-none");
95
142
  panel.id = PANEL_ID;
96
143
  panel.setAttribute("aria-label", label("Image prompt", "图片提示词"));
97
144
  panel.hidden = true;
98
145
  panel.style.setProperty("-webkit-app-region", "no-drag");
99
- panel.style.paddingBottom = "calc(var(--mcx-spacing) * 3)";
100
- const heading = element("div", "flex items-center justify-between py-2 ps-4 pe-2");
101
- const close = element("button", "flex size-8 shrink-0 items-center justify-center rounded-full text-default hover:bg-primary-ghost-hover focus-visible:outline-ring");
146
+ // File info 的 pt-13 与包装层 pt-px 合计;窄视口保留两侧 gutter。
147
+ panel.style.top = "calc(var(--mcx-spacing) * 13 + 1px)";
148
+ panel.style.width = "calc(var(--mcx-spacing) * 74)";
149
+ panel.style.maxWidth = "calc(100% - var(--mcx-spacing) * 4)";
150
+ const heading = element("div", "flex h-11 shrink-0 items-center justify-between gap-2 py-1 ps-4 pe-2");
151
+ const close = element("button", "no-drag cursor-interaction select-none disabled:cursor-default disabled:opacity-40 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 flex border gap-1 rounded-full text-default bg-text/5 not-disabled:not-aria-disabled:hover:bg-text/10 border-transparent button-toolbar py-0 text-sm leading-[18px] aspect-square shrink-0 items-center justify-center !px-0");
102
152
  close.type = "button";
103
153
  close.setAttribute("aria-label", label("Close prompt", "关闭提示词"));
104
154
  // 直接复用原生关闭图标,避免手绘近似图形。
@@ -109,34 +159,82 @@ async function scan() {
109
159
  const copy = element("button", close.className);
110
160
  copy.type = "button";
111
161
  function copyFeedback(copied = false, failed = false) {
112
- const text = failed ? label("Try copying again", "重试复制") : copied ? label("Copied", "已复制") : label("Copy prompt", "复制提示词");
162
+ const text = failed ? label("Try copying again", "重试复制") : copied ? label("Copied", "已复制") : record?.prompt?.kind === "request" ? label("Copy original request", "复制原始请求") : label("Copy prompt", "复制提示词");
113
163
  copy.setAttribute("data-minecodex-tooltip", text);
114
164
  copy.setAttribute("aria-label", text);
115
165
  copy.replaceChildren(copyGlyph(copied));
116
166
  }
117
- copyFeedback();
118
167
  actions.append(copy, close);
119
- heading.append(element("span", "text-base font-semibold text-default", label("Prompt", "提示词")), actions);
120
- const body = element("div", "min-h-0 flex-1 overflow-y-auto px-3");
121
- body.style.paddingTop = "var(--mcx-spacing)";
122
- body.append(
123
- element("p", "px-1 text-sm leading-5 text-default whitespace-pre-wrap break-words select-text", prompt.text),
124
- );
168
+ const title = element("span", "text-base font-semibold text-default select-none", label("Prompt", "提示词"));
169
+ const titleHeading = element("h2", "min-w-0 flex-1 truncate");
170
+ titleHeading.append(title);
171
+ heading.append(titleHeading, actions);
172
+ const body = element("div", "min-h-0 flex-1 overflow-y-auto px-3 py-4");
125
173
  panel.append(heading, body);
126
174
  host.append(panel);
127
- const record = { header, file, identity, button, panel, body, close, request: null,
175
+ const record = { header, file, identity, button, panel, body, close, copy, prompt: null, state: "loading", request: next.request,
128
176
  refreshLocale() {
129
- button.textContent = label("Prompt", "提示词");
177
+ if (record.menuItem) record.menuItem.querySelector(".truncate").textContent = label("View prompts", "查看提示词");
130
178
  panel.setAttribute("aria-label", label("Image prompt", "图片提示词"));
131
179
  close.setAttribute("aria-label", label("Close prompt", "关闭提示词"));
132
- heading.firstElementChild.textContent = label("Prompt", "提示词");
180
+ title.textContent = label("Prompt", "提示词");
133
181
  copyFeedback();
182
+ renderBody();
134
183
  },
135
184
  };
185
+ function renderBody() {
186
+ body.replaceChildren();
187
+ body.setAttribute("aria-busy", String(record.state === "loading"));
188
+ copy.disabled = record.state !== "ready";
189
+ copyFeedback();
190
+ if (record.state === "loading") {
191
+ const skeleton = element("div", "flex flex-col gap-2 px-1");
192
+ skeleton.setAttribute("aria-hidden", "true");
193
+ for (const width of ["100%", "92%", "100%", "68%"]) {
194
+ const line = element("div", "h-3 rounded-full bg-text/5");
195
+ line.style.width = width;
196
+ skeleton.append(line);
197
+ }
198
+ body.append(element("span", "sr-only", label("Loading prompt", "正在加载提示词")), skeleton);
199
+ } else if (record.state === "ready") {
200
+ if (record.prompt.kind === "request") {
201
+ body.append(element("p", "px-1 pb-4 text-sm leading-5 text-secondary", label("The generation prompt is unavailable. The original request is shown below.", "未获取到生成提示词,以下提供用户原始请求。")));
202
+ body.append(element("h3", "px-1 pb-2 text-sm font-semibold text-default", label("Original request", "原始请求")));
203
+ }
204
+ body.append(element("p", "px-1 text-sm leading-5 text-default whitespace-pre-wrap break-words select-text", record.prompt.text));
205
+ } else {
206
+ const message = record.state === "empty" ? label("Native data does not provide the generation prompt for this image", "原生数据未提供这张图片的生成提示词") : label("Could not load the prompt", "提示词加载失败");
207
+ body.append(element("p", "px-1 text-sm leading-5 text-secondary", message));
208
+ if (record.state === "error") {
209
+ const retry = element("button", "mt-3 rounded-full border border-default px-3 py-1 text-sm text-default hover:bg-primary-ghost-hover", label("Try again", "重试"));
210
+ retry.type = "button";
211
+ retry.addEventListener("click", loadPrompt);
212
+ body.append(retry);
213
+ }
214
+ }
215
+ }
216
+ async function loadPrompt() {
217
+ record.state = "loading";
218
+ renderBody();
219
+ try {
220
+ const prompt = await resolvePrompt(file, next.request);
221
+ // 换图或卸载后不写回;关闭面板仍可继续预取。
222
+ if (signal.aborted || candidate !== next || mounted !== record || !header.isConnected) return;
223
+ record.prompt = prompt;
224
+ record.state = prompt?.text ? "ready" : "empty";
225
+ } catch {
226
+ if (signal.aborted || candidate !== next || mounted !== record || next.request.signal.aborted) return;
227
+ record.state = "error";
228
+ }
229
+ renderBody();
230
+ }
136
231
  mounted = record;
232
+ renderBody();
233
+ openPendingLocalPrompt();
137
234
  copy.addEventListener("click", async () => {
235
+ if (record.state !== "ready") return;
138
236
  try {
139
- await navigator.clipboard.writeText(prompt.text);
237
+ await navigator.clipboard.writeText(record.prompt.text);
140
238
  if (mounted !== record) return;
141
239
  copyFeedback(true);
142
240
  } catch {
@@ -146,10 +244,17 @@ async function scan() {
146
244
  clearTimeout(record.copyTimer);
147
245
  record.copyTimer = setTimeout(() => copyFeedback(), 2000);
148
246
  });
149
- button.addEventListener("click", () => panel.hidden ? showPrompt(record) : closePanel(record));
247
+ syncMenu(record);
150
248
  close.addEventListener("click", () => closePanel(record, true));
249
+ await loadPrompt();
151
250
  }
152
251
 
252
+ function openPendingLocalPrompt(){
253
+ if(mounted?.file.kind!=="local" || localImageSource?.pendingPrompt()!==mounted.file.id)return;
254
+ localImageSource.consumePrompt();showPrompt(mounted);
255
+ }
256
+ function localPromptRequested(){openPendingLocalPrompt();void scan();}
257
+ document.addEventListener("minecodex:image-prompt",localPromptRequested);
153
258
  const localeObserver = new MutationObserver(() => mounted?.refreshLocale());
154
259
  localeObserver.observe(document.documentElement, { attributes:true, attributeFilter:["lang"] });
155
260
  const observer = new MutationObserver(() => {
@@ -165,7 +270,7 @@ function escape(event) {
165
270
  closePanel(mounted, true);
166
271
  }
167
272
  document.addEventListener("keydown", escape, true);
168
- const cleanup = () => { candidate?.request.abort(); candidate = null; observer.disconnect(); localeObserver.disconnect(); document.removeEventListener("keydown", escape, true); unmount(); };
273
+ const cleanup = () => { document.removeEventListener("minecodex:image-prompt",localPromptRequested); candidate?.request.abort(); candidate = null; observer.disconnect(); localeObserver.disconnect(); document.removeEventListener("keydown", escape, true); unmount(); };
169
274
  signal.addEventListener("abort", cleanup, { once: true });
170
275
  scan();
171
276
  return cleanup;
@@ -1,10 +1,18 @@
1
+ import { createCloudImageActions } from "./frontend/cloud-image-actions.mjs";
2
+ import { createImageSourcesAdapter } from "./adapters/codex/image-sources.mjs";
3
+ import { createNativeImageSource } from "./adapters/codex/native-image-source.mjs";
4
+ import { installLocalImageSource } from "./frontend/local-image-source.mjs";
1
5
  import { createLibraryApi } from "./adapters/codex/library-api.mjs";
2
6
  import { createLibraryViewerAdapter } from "./adapters/codex/library-viewer.mjs";
3
7
  import { readFile } from "node:fs/promises";
4
8
  import { promptResolverSource } from "./adapters/codex/prompt-resolver-source.mjs";
5
9
 
6
- // 两条 Renderer 路径共享纯规则,注入后不依赖 Node 模块闭包。
7
10
  export async function createPageScriptSource() {
8
- const source = await readFile(new URL("./library-prompt-page.mjs", import.meta.url), "utf8");
9
- return `const createLibraryApi=${createLibraryApi.toString()};\nconst createLibraryViewerAdapter=${createLibraryViewerAdapter.toString()};\nconst promptFromConversation = ${promptResolverSource()};\n${source}`;
11
+ const source=await readFile(new URL("./library-prompt-page.mjs",import.meta.url),"utf8");
12
+ const functions={createCloudImageActions,createImageSourcesAdapter,createNativeImageSource,installLocalImageSource,createLibraryApi,createLibraryViewerAdapter};
13
+ const declarations=Object.entries(functions).map(([name,fn])=>`const ${name}=${fn.toString()};`).join("\n");
14
+ return `${declarations}
15
+ let localImageSource=null;
16
+ const promptFromConversation=${promptResolverSource()};
17
+ ${source.replace("const observer =", "localImageSource=installLocalImageSource(config,signal,createImageSourcesAdapter(config.codex),createNativeImageSource(config.codex,{requestHostAction:config.requestHostAction,libraryApi:createLibraryApi(),createCloudImageActions}));\nconst observer =").replace("return cleanup;", "return () => { cleanup(); localImageSource.cleanup(); };")}`;
10
18
  }
@@ -49,10 +49,17 @@ async function readPrompts(logPath, prompts) {
49
49
  const stored = new Map();
50
50
  const pendingSources = new Map();
51
51
  for await (const line of lines) {
52
- if (line.includes('"image_generation_end"') || /"type"\s*:\s*"custom_tool_call"/.test(line) || /"name"\s*:\s*"wait"/.test(line) || [...pendingSources.keys()].some(id => line.includes(id))) {
52
+ if (line.includes('"image_generation_end"') || line.includes('"item_completed"') || /"type"\s*:\s*"custom_tool_call"/.test(line) || /"name"\s*:\s*"wait"/.test(line) || [...pendingSources.keys()].some(id => line.includes(id))) {
53
53
  let event;
54
54
  try { event = JSON.parse(line); } catch { continue; }
55
55
  const payload = event?.payload ?? event;
56
+ // 以完成事件为准;旧版内部状态可能仍是 generating,不能据此丢弃提示词。
57
+ const item = payload?.type === "item_completed" ? payload.item : null;
58
+ if (item?.type === "Extension" && item.kind === "image_gen.generation"
59
+ && typeof item.id === "string"
60
+ && typeof item.revisedPrompt === "string" && item.revisedPrompt.trim()) {
61
+ prompts.set(item.id, item.revisedPrompt);
62
+ }
56
63
  const legacy = legacyPromptFromPayload(payload);
57
64
  if (legacy && !prompts.has(legacy.imageGenerationItemId)) {
58
65
  prompts.set(legacy.imageGenerationItemId, legacy.prompt);
@@ -160,7 +167,12 @@ export class ThreadPromptIndex {
160
167
  async find(threadId, imageGenerationItemId) {
161
168
  const key = cacheKey(threadId, imageGenerationItemId);
162
169
  const cached = this.threadPrompts.get(threadId);
163
- if (cached?.has(imageGenerationItemId)) return cached.get(imageGenerationItemId);
170
+ if (cached?.has(imageGenerationItemId)) {
171
+ // 工具请求先到、完成事件后到时,重新读取更准确的 revisedPrompt。
172
+ if (!await this.threadLogsChanged(threadId)) return cached.get(imageGenerationItemId);
173
+ this.threadPrompts.delete(threadId);
174
+ this.clearNegative(threadId);
175
+ }
164
176
 
165
177
  const negative = this.negativeCache.get(key);
166
178
  if (negative) {
@@ -22,6 +22,8 @@ Composer 触发器区分两种状态:占位态("Select model" / "Select effo
22
22
  - **Composer Trigger:** 占位态恢复原生 160px Content 宽度;真实模型态 HUG 完整名称,头部保留原生可点击 chevron。
23
23
  - **Slider Header:** 模型名最多两行,容器高度随内容自然撑开;effort 与 chevron 不收缩。无 Fast 时移除原生 12px 预留占位,让按钮与面板严格居中。
24
24
  - **Model List:** 保持 264px 原生宽度、Provider 副文本、收藏与最多六项可视高度;收藏与未收藏之间保留分隔线,弹窗内容四周保留约 6px 内边距;模型弹窗与原生 Slider 一致地水平居中于触发器。列表打开期间,Composer 占位文案从 `Select effort` 切为原生语义 `Select model`,关闭后恢复。
25
+ - 模型列表使用共享 Dropdown 的 `opaque` 分支:不透明主题填充、无背景模糊,保留 Dropdown 圆角、边缘和阴影,避免后方聊天文字透入列表。此选择仅适用于模型列表。
26
+ - Slider 与模型列表切换沿用 Codex 26.930.21537 的动效节奏:底边锚定,高度 320ms(`cubic-bezier(.19,1,.22,1)`);列表内容 200ms 淡变与 10px 位移(`cubic-bezier(.23,1,.32,1)`),进入延迟 40ms。返回时收缩并淡出,Escape 先返回 Slider;减少动态效果时立即切换。切换与卸载会取消旧动画和清理定时器,不改原生 React 节点。
25
27
  - OCX 自动发现的 GPT 路由若与原生模型同名且没有独立推理档位,列表只显示原生项;当前已选中的路由仍显示,避免失去切换入口。有独立档位的 Provider 路由继续显示并标注 Provider。
26
28
  - Slider 预览档位时同步 effort 标签与 Fast 控件的 Ultra 状态;离开 Ultra 后立即恢复普通档位颜色。
27
29
 
@@ -683,6 +683,7 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
683
683
 
684
684
  function sizeModelMenuViewport(menu, maxVisibleItems) {
685
685
  if (!menu.isConnected) return;
686
+ if (modelSelectorControllerMenu?.menu === menu && modelSelectorControllerMenu.animations?.length) return;
686
687
  const items = modelMenuItems(menu).filter((item) => item.dataset.codexModelSliderRaw);
687
688
  if (items.length <= maxVisibleItems) {
688
689
  menu.removeAttribute("data-codex-model-slider-overflow");
@@ -959,9 +960,38 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
959
960
  }
960
961
  }
961
962
 
962
- function closeControllerModelMenu() {
963
+ function animateControllerModelMenu(record, opening, onFinish) {
964
+ const height = record.menu.getBoundingClientRect().height;
965
+ clearTimeout(record.motionTimer);
966
+ record.animations?.forEach((animation) => animation.cancel());
967
+ record.animations = [];
968
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
969
+ onFinish();
970
+ return;
971
+ }
972
+ // 原生视图切换:底边保持锚定,高度 320ms,内容 200ms 淡变并位移 10px。
973
+ record.menu.style.overflowY = "hidden";
974
+ const size = record.menu.animate([
975
+ { height: `${opening ? record.sliderHeight : height}px` },
976
+ { height: `${opening ? record.listHeight : record.sliderHeight}px` },
977
+ ], { duration: 320, easing: "cubic-bezier(.19,1,.22,1)", fill: "both" });
978
+ const content = record.content.animate(opening
979
+ ? [{ opacity: 0, translate: "0 10px" }, { opacity: 1, translate: "0 0" }]
980
+ : [{ opacity: 1, translate: "0 0" }, { opacity: 0, translate: "0 10px" }],
981
+ { duration: 200, delay: opening ? 40 : 0, easing: "cubic-bezier(.23,1,.32,1)", fill: "both" });
982
+ record.animations.push(size, content);
983
+ if (!opening) {
984
+ record.animations.push(record.menu.animate([{ opacity: 1 }, { opacity: 0 }],
985
+ { duration: 200, delay: 40, easing: "cubic-bezier(.23,1,.32,1)", fill: "both" }));
986
+ }
987
+ // 后台窗口未必派发动画完成事件,清理按同一时长结束,避免残留菜单。
988
+ record.motionTimer = setTimeout(onFinish, 320);
989
+ }
990
+
991
+ function closeControllerModelMenu({ immediate = false } = {}) {
963
992
  const record = modelSelectorControllerMenu;
964
993
  if (!record) return false;
994
+ if (record.closing && !immediate) return true;
965
995
  const placeholder = document.querySelector(
966
996
  '[data-codex-intelligence-trigger] [class*="_ModelPickerTriggerPlaceholder_"]',
967
997
  );
@@ -969,10 +999,18 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
969
999
  placeholder.firstChild.nodeValue = record.placeholderText;
970
1000
  }
971
1001
  record.button.setAttribute("aria-expanded", "false");
972
- record.parentMenu.style.position = record.position;
973
- record.parentMenu.style.overflow = record.overflow;
974
- record.menu.remove();
975
- modelSelectorControllerMenu = null;
1002
+ record.closing = true;
1003
+ record.menu.inert = true;
1004
+ const finish = () => {
1005
+ clearTimeout(record.motionTimer);
1006
+ record.animations?.forEach((animation) => animation.cancel());
1007
+ record.parentMenu.style.position = record.position;
1008
+ record.parentMenu.style.overflow = record.overflow;
1009
+ record.menu.remove();
1010
+ if (modelSelectorControllerMenu === record) modelSelectorControllerMenu = null;
1011
+ };
1012
+ if (immediate || !record.parentMenu.isConnected) finish();
1013
+ else animateControllerModelMenu(record, false, finish);
976
1014
  return true;
977
1015
  }
978
1016
 
@@ -988,8 +1026,11 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
988
1026
 
989
1027
  function openControllerModelMenu(parentMenu, modelButton, selector) {
990
1028
  if (modelSelectorControllerMenu) {
991
- closeControllerModelMenu();
992
- return;
1029
+ if (!modelSelectorControllerMenu.closing) {
1030
+ closeControllerModelMenu();
1031
+ return;
1032
+ }
1033
+ closeControllerModelMenu({ immediate: true });
993
1034
  }
994
1035
  const controller = nativeComposerModelController();
995
1036
  if (!Array.isArray(controller?.models) || typeof controller.onSelectModel !== "function") return;
@@ -1002,7 +1043,12 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
1002
1043
  menu.setAttribute("data-state", "open");
1003
1044
  menu.setAttribute("data-codex-model-slider-menu", "");
1004
1045
  menu.setAttribute("data-codex-model-slider-controller-menu", "");
1005
- featureUI.surface(menu, "dropdown");
1046
+ featureUI.surface(menu, "dropdown", { variant: "opaque" });
1047
+ // 切换同一个 picker 视图,不重复播放整个 Dropdown 的入场缩放。
1048
+ menu.style.animation = "none";
1049
+ const content = document.createElement("div");
1050
+ content.style.flexShrink = "0";
1051
+ menu.append(content);
1006
1052
  models.forEach((model, index) => {
1007
1053
  const item = document.createElement("div");
1008
1054
  item.className = "no-drag outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm text-default group hover:bg-primary-ghost-hover focus:bg-primary-ghost-hover cursor-interaction flex flex-col";
@@ -1014,7 +1060,7 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
1014
1060
  scheduleReturn: false,
1015
1061
  owned: true,
1016
1062
  });
1017
- menu.append(item);
1063
+ content.append(item);
1018
1064
  });
1019
1065
  menu.addEventListener("click", (event) => {
1020
1066
  const star = event.target.closest?.("[data-codex-model-slider-star]");
@@ -1038,6 +1084,7 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
1038
1084
  menu.addEventListener("keydown", (event) => {
1039
1085
  if (event.key === "Escape") {
1040
1086
  event.preventDefault();
1087
+ event.stopPropagation();
1041
1088
  closeControllerModelMenu();
1042
1089
  modelButton.focus();
1043
1090
  return;
@@ -1063,11 +1110,13 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
1063
1110
 
1064
1111
  modelSelectorControllerMenu = {
1065
1112
  menu,
1113
+ content,
1066
1114
  parentMenu,
1067
1115
  button: modelButton,
1068
1116
  placeholderText,
1069
1117
  position: parentMenu.style.position,
1070
1118
  overflow: parentMenu.style.overflow,
1119
+ sliderHeight: parentMenu.getBoundingClientRect().height,
1071
1120
  };
1072
1121
  parentMenu.style.position = "relative";
1073
1122
  parentMenu.style.overflow = "visible";
@@ -1076,6 +1125,15 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
1076
1125
  modelButton.setAttribute("aria-expanded", "true");
1077
1126
  (menu.querySelector('[data-codex-model-slider-selected]')
1078
1127
  ?? menu.querySelector('[role="menuitem"]'))?.focus();
1128
+ const record = modelSelectorControllerMenu;
1129
+ record.listHeight = menu.getBoundingClientRect().height;
1130
+ animateControllerModelMenu(record, true, () => {
1131
+ if (modelSelectorControllerMenu !== record || record.closing) return;
1132
+ record.animations.forEach((animation) => animation.cancel());
1133
+ record.animations = [];
1134
+ menu.style.overflowY = "auto";
1135
+ sizeModelMenuViewport(menu, selector.maxVisibleItems);
1136
+ });
1079
1137
  }
1080
1138
 
1081
1139
  // ========================================================================
@@ -2005,7 +2063,7 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
2005
2063
  enhanceComposerModelTrigger(selector);
2006
2064
  const parentMenu = nativeModelPickerMenu();
2007
2065
  if (modelSelectorControllerMenu && modelSelectorControllerMenu.parentMenu !== parentMenu) {
2008
- closeControllerModelMenu();
2066
+ closeControllerModelMenu({ immediate: true });
2009
2067
  }
2010
2068
  if (!parentMenu) {
2011
2069
  if (
@@ -2031,7 +2089,7 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
2031
2089
  }
2032
2090
 
2033
2091
  function restoreModelSelectorEnhancements() {
2034
- closeControllerModelMenu();
2092
+ closeControllerModelMenu({ immediate: true });
2035
2093
  clearTimeout(modelSelectorReturnTimer);
2036
2094
  clearTimeout(modelSelectorEffortTransactionTimer);
2037
2095
  modelSelectorTriggerObserver?.disconnect();
@@ -2091,6 +2149,15 @@ export function installModelSlider(signal, { feature, ui, createIcon, requestRef
2091
2149
  modelSelectorNativeToggleHandlers.clear();
2092
2150
  }
2093
2151
 
2152
+ window.addEventListener("keydown", event => {
2153
+ const record = modelSelectorControllerMenu;
2154
+ if (event.key !== "Escape" || !record) return;
2155
+ // 先退出插件模型视图,避免原生 document 监听同时关闭整个 picker。
2156
+ event.preventDefault();
2157
+ event.stopImmediatePropagation();
2158
+ closeControllerModelMenu();
2159
+ record.button.focus();
2160
+ }, { capture: true, signal });
2094
2161
  document.addEventListener("click", event => {
2095
2162
  if (modelSelectorControllerMenu
2096
2163
  && !modelSelectorControllerMenu.menu.contains(event.target)
@@ -38,7 +38,9 @@ Header 为 Prompts;右侧依次为排序图标、分类调节图标、常驻
38
38
 
39
39
  点击提示词只插入当前输入框,保留原草稿且不发送。Escape 关闭并回到入口;点击外部关闭;方向键移动列表焦点。原生 Add 或权限面板打开时关闭。切换输入框时关闭,异步结果不得插入另一会话。沿用原生焦点与 hover,无额外进场动画。
40
40
 
41
- 入口仅挂载到可见 Codex 输入框;原生可见的 Add 或菜单按钮展开时关闭 Popover。Session Status 的 Context 展开状态不是弹出菜单,不作为冲突条件;隐藏输入框和隐藏控件不参与锚点定位或菜单判断。
41
+ 入口通过 Codex Adapter 的 `purpose:"insertion"` 挂载到可见的原生输入框,覆盖 Chat、Work、Codex、Temporary Chat、浮动聊天,以及 side panel 全屏非聊天页面的 chat bar。多输入框并存时优先当前焦点所在输入框;插入操作显式绑定打开面板的输入框,切换后关闭旧面板,不向隐藏输入框插入。此插入契约不扩展会话指标使用的 Codex thread 契约。
42
+
43
+ 原生可见的 Add 或菜单按钮展开时关闭 Popover。Session Status 的 Context 展开状态不是弹出菜单,不作为冲突条件;隐藏输入框和隐藏控件不参与锚点定位或菜单判断。
42
44
 
43
45
  ## 统计
44
46
  成功插入后原子增加 `use_count` 并更新 `called_at`,保存于现有 SQLite。创建、编辑、复制和排序不计数。历史次数未知,从 0 开始,不从旧 `last_used_at` 推断。删除分类、切换页面、重启不清零。
@@ -50,9 +52,9 @@ Header 为 Prompts;右侧依次为排序图标、分类调节图标、常驻
50
52
 
51
53
  导航栏 book-open 单独采用 20px 容器、27×27 viewBox,实际轮廓约 16.15px,描边折算 1.33px,与原生导航一致。Lucide 两个描边子路径合为一个 path,一次绘制半透明 currentColor,避免书脊交叠变深。选中态使用 Phosphor 官方 book-open-fill 的20px资源;授权见 vendor/phosphor-LICENSE。输入框入口尺寸不变。
52
54
 
53
- 拖拽排序使用等高 Drop Placeholder,原生拖影跟随指针;同组条目以 180ms ease-out 让位,落位淡入 140ms。放到范围外或取消恢复原位,不写入排序。遵循 prefers-reduced-motion。
55
+ 拖拽排序使用等高 Drop Placeholder,拖影跟随指针并显式按源行圆角裁切,不留下矩形外边界;同组条目以 180ms ease-out 让位,落位淡入 140ms。放到范围外或取消恢复原位,不写入排序。遵循 prefers-reduced-motion。
54
56
 
55
- 页面选择通用 Page 外壳,保留原生 Home sidebar 的悬浮与导航行为。
57
+ 页面选择通用 Page 的 `sidebar:"none"` 分支,自身不带常驻二级侧栏,保留原生 Home、Space、Scheduled sidebar 的悬浮叠加与导航行为。关闭页面恢复原页面位置,不改变用户的侧栏展开偏好。
56
58
 
57
59
  提示词编辑沿用原生 Project 的复合标题输入:40px 图标段位于标题前,细分隔线,共用圆角边框与 focus-within 状态。图标选择入口不再单独占一行;Title 通过 aria-label 标识。
58
60
 
@@ -68,3 +70,5 @@ Category 下拉面板与触发栏等宽、左右对齐,宽度包含内边距
68
70
  内置 24 条来自 dsh-prompt 0.1.13 的 MIT 提示词,正文保持原样,按思考、学习、编程、效率分类并配 Emoji。来源与许可保存在 assets/dsh-prompt-LICENSE.txt。首次启动或旧库升级时单次导入;随后允许编辑和删除,重启不还原,也不重建被删除的分类。用户个人内容只保留在本机数据库,不纳入默认资源。
69
71
 
70
72
  简短悬浮提示调用共享 Tooltip;具体文案由本功能维护。Quick Prompts 通过通用 ComposerMenu 使用原生浮动建议列表材质和输入框主体锚点。排序、分类与图标菜单显式选择 Dropdown。
73
+
74
+ Categories 管理弹窗的 New category 是有实际功能的次级操作,使用共享 Button 的 `secondary`(outline)分支;分类编辑的 Save 保留主按钮。