@co0ontty/wand 1.37.0 → 1.39.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.
- package/dist/process-manager.d.ts +16 -0
- package/dist/process-manager.js +179 -0
- package/dist/server-session-routes.js +110 -0
- package/dist/structured-session-manager.d.ts +48 -0
- package/dist/structured-session-manager.js +293 -30
- package/dist/types.d.ts +2 -0
- package/dist/web-ui/content/scripts.js +194 -17
- package/dist/web-ui/content/styles.css +70 -31
- package/package.json +1 -1
|
@@ -24,6 +24,18 @@ export interface ClaudeHistorySession {
|
|
|
24
24
|
hasConversation: boolean;
|
|
25
25
|
managedByWand: boolean;
|
|
26
26
|
}
|
|
27
|
+
/** A Codex session discovered by scanning ~/.codex/sessions/ rollout files. */
|
|
28
|
+
export interface CodexHistorySession {
|
|
29
|
+
/** Codex thread id(存进 claudeSessionId 字段以复用前端/路由)。 */
|
|
30
|
+
claudeSessionId: string;
|
|
31
|
+
cwd: string;
|
|
32
|
+
firstUserMessage: string;
|
|
33
|
+
timestamp: string;
|
|
34
|
+
mtimeMs: number;
|
|
35
|
+
hasConversation: boolean;
|
|
36
|
+
managedByWand: boolean;
|
|
37
|
+
provider: "codex";
|
|
38
|
+
}
|
|
27
39
|
export declare class ProcessManager extends EventEmitter {
|
|
28
40
|
private readonly config;
|
|
29
41
|
private readonly storage;
|
|
@@ -65,6 +77,10 @@ export declare class ProcessManager extends EventEmitter {
|
|
|
65
77
|
claudeSessionId: string;
|
|
66
78
|
cwd: string;
|
|
67
79
|
}[]): number;
|
|
80
|
+
private codexHistoryCache;
|
|
81
|
+
listCodexHistorySessions(): CodexHistorySession[];
|
|
82
|
+
hasCodexSessionFile(threadId: string): boolean;
|
|
83
|
+
deleteCodexHistoryFiles(threadIds: string[]): number;
|
|
68
84
|
get(id: string): SessionSnapshot | null;
|
|
69
85
|
getPtyTranscript(id: string): string | null;
|
|
70
86
|
/**
|
package/dist/process-manager.js
CHANGED
|
@@ -296,6 +296,150 @@ function listAllClaudeHistorySessions() {
|
|
|
296
296
|
return [];
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
|
+
function getCodexSessionsDir() {
|
|
300
|
+
return path.join(os.homedir(), ".codex", "sessions");
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* codex 的 user message 里混杂了系统注入(AGENTS.md 指令、<environment_context> 等
|
|
304
|
+
* XML 包裹块),它们都以 "#" 或 "<" 开头。真正的用户输入是首条不以这两者开头的
|
|
305
|
+
* input_text。
|
|
306
|
+
*/
|
|
307
|
+
function isCodexSystemInjectedText(text) {
|
|
308
|
+
const trimmed = text.trimStart();
|
|
309
|
+
return trimmed.startsWith("#") || trimmed.startsWith("<");
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Read the head of a rollout file to extract summary metadata. Codex prepends a
|
|
313
|
+
* large session_meta (full base_instructions + AGENTS.md + <environment_context>)
|
|
314
|
+
* before the first real user turn, so a small window misses it. 64KB covers the
|
|
315
|
+
* first real user message in every observed session.
|
|
316
|
+
*/
|
|
317
|
+
function readCodexSessionSummary(filePath) {
|
|
318
|
+
try {
|
|
319
|
+
const stats = statSync(filePath);
|
|
320
|
+
const fd = openSync(filePath, "r");
|
|
321
|
+
const buffer = Buffer.alloc(65536);
|
|
322
|
+
const bytesRead = readSync(fd, buffer, 0, 65536, 0);
|
|
323
|
+
closeSync(fd);
|
|
324
|
+
const chunk = buffer.toString("utf8", 0, bytesRead);
|
|
325
|
+
const lines = chunk.split("\n").filter((line) => line.trim().length > 0);
|
|
326
|
+
let id = "";
|
|
327
|
+
let cwd = "";
|
|
328
|
+
let timestamp = "";
|
|
329
|
+
let firstUserMessage = "";
|
|
330
|
+
let hasUser = false;
|
|
331
|
+
let hasAssistant = false;
|
|
332
|
+
for (const line of lines) {
|
|
333
|
+
let parsed;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(line);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (!timestamp && parsed.timestamp) {
|
|
341
|
+
timestamp = parsed.timestamp;
|
|
342
|
+
}
|
|
343
|
+
const payload = parsed.payload;
|
|
344
|
+
if (!payload)
|
|
345
|
+
continue;
|
|
346
|
+
if (parsed.type === "session_meta" || payload.type === "session_meta") {
|
|
347
|
+
if (!id && typeof payload.id === "string")
|
|
348
|
+
id = payload.id;
|
|
349
|
+
if (!cwd && typeof payload.cwd === "string")
|
|
350
|
+
cwd = payload.cwd;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (payload.type === "message" && payload.role === "user") {
|
|
354
|
+
const text = Array.isArray(payload.content)
|
|
355
|
+
? payload.content
|
|
356
|
+
.filter((b) => b?.type === "input_text" && typeof b.text === "string")
|
|
357
|
+
.map((b) => b.text)
|
|
358
|
+
.join("")
|
|
359
|
+
: "";
|
|
360
|
+
if (text.trim()) {
|
|
361
|
+
hasUser = true;
|
|
362
|
+
if (!firstUserMessage && !isCodexSystemInjectedText(text)) {
|
|
363
|
+
firstUserMessage = text.trim().slice(0, 120);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
else if (payload.type === "message" && payload.role === "assistant") {
|
|
368
|
+
hasAssistant = true;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (!id)
|
|
372
|
+
return null;
|
|
373
|
+
return {
|
|
374
|
+
claudeSessionId: id,
|
|
375
|
+
cwd,
|
|
376
|
+
firstUserMessage,
|
|
377
|
+
timestamp: timestamp || new Date(stats.mtimeMs).toISOString(),
|
|
378
|
+
mtimeMs: stats.mtimeMs,
|
|
379
|
+
hasConversation: hasUser && hasAssistant,
|
|
380
|
+
managedByWand: false,
|
|
381
|
+
provider: "codex",
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/** Recursively collect rollout-*.jsonl paths under ~/.codex/sessions/. */
|
|
389
|
+
function listCodexRolloutFiles() {
|
|
390
|
+
const root = getCodexSessionsDir();
|
|
391
|
+
try {
|
|
392
|
+
return readdirSync(root, { recursive: true, withFileTypes: true })
|
|
393
|
+
.filter((entry) => entry.isFile()
|
|
394
|
+
&& entry.name.startsWith("rollout-")
|
|
395
|
+
&& entry.name.endsWith(".jsonl"))
|
|
396
|
+
.map((entry) => {
|
|
397
|
+
// Node ≥ 20 dirents from a recursive read carry parentPath/path.
|
|
398
|
+
const parent = entry.parentPath
|
|
399
|
+
?? entry.path
|
|
400
|
+
?? root;
|
|
401
|
+
return path.join(parent, entry.name);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/** Scan ~/.codex/sessions/ and return one entry per thread id (newest rollout wins). */
|
|
409
|
+
function listAllCodexHistorySessions() {
|
|
410
|
+
const files = listCodexRolloutFiles();
|
|
411
|
+
// 同一 thread 同一天可能有多个 rollout 文件,按 thread id 去重保留 mtime 最新。
|
|
412
|
+
const byThread = new Map();
|
|
413
|
+
for (const filePath of files) {
|
|
414
|
+
const summary = readCodexSessionSummary(filePath);
|
|
415
|
+
if (!summary)
|
|
416
|
+
continue;
|
|
417
|
+
const existing = byThread.get(summary.claudeSessionId);
|
|
418
|
+
if (!existing || summary.mtimeMs > existing.mtimeMs) {
|
|
419
|
+
byThread.set(summary.claudeSessionId, summary);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return Array.from(byThread.values()).sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
423
|
+
}
|
|
424
|
+
/** Delete every rollout file belonging to the given codex thread ids. */
|
|
425
|
+
function deleteCodexRolloutFiles(threadIds) {
|
|
426
|
+
if (threadIds.size === 0)
|
|
427
|
+
return 0;
|
|
428
|
+
let deleted = 0;
|
|
429
|
+
for (const filePath of listCodexRolloutFiles()) {
|
|
430
|
+
const summary = readCodexSessionSummary(filePath);
|
|
431
|
+
if (summary && threadIds.has(summary.claudeSessionId)) {
|
|
432
|
+
try {
|
|
433
|
+
unlinkSync(filePath);
|
|
434
|
+
deleted++;
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
// Best-effort — file may already be gone
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return deleted;
|
|
442
|
+
}
|
|
299
443
|
function snapshotMessages(record) {
|
|
300
444
|
return record.ptyBridge?.getMessages() ?? record.messages;
|
|
301
445
|
}
|
|
@@ -898,6 +1042,41 @@ export class ProcessManager extends EventEmitter {
|
|
|
898
1042
|
}
|
|
899
1043
|
return deleted;
|
|
900
1044
|
}
|
|
1045
|
+
codexHistoryCache = null;
|
|
1046
|
+
listCodexHistorySessions() {
|
|
1047
|
+
const now = Date.now();
|
|
1048
|
+
if (this.codexHistoryCache && now < this.codexHistoryCache.expiresAt) {
|
|
1049
|
+
return this.codexHistoryCache.data;
|
|
1050
|
+
}
|
|
1051
|
+
const allSessions = listAllCodexHistorySessions();
|
|
1052
|
+
// Cross-reference with wand-managed sessions(codex 的 thread id 存在 claudeSessionId 字段)
|
|
1053
|
+
const managedIds = new Set();
|
|
1054
|
+
for (const record of this.sessions.values()) {
|
|
1055
|
+
if (record.provider === "codex" && record.claudeSessionId) {
|
|
1056
|
+
managedIds.add(record.claudeSessionId);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
for (const session of allSessions) {
|
|
1060
|
+
if (managedIds.has(session.claudeSessionId)) {
|
|
1061
|
+
session.managedByWand = true;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
this.codexHistoryCache = { data: allSessions, expiresAt: now + ProcessManager.HISTORY_CACHE_TTL_MS };
|
|
1065
|
+
return allSessions;
|
|
1066
|
+
}
|
|
1067
|
+
hasCodexSessionFile(threadId) {
|
|
1068
|
+
if (!UUID_V4_PATTERN.test(threadId))
|
|
1069
|
+
return false;
|
|
1070
|
+
return listAllCodexHistorySessions().some((s) => s.claudeSessionId === threadId);
|
|
1071
|
+
}
|
|
1072
|
+
deleteCodexHistoryFiles(threadIds) {
|
|
1073
|
+
const valid = new Set(threadIds.filter((id) => UUID_V4_PATTERN.test(id)));
|
|
1074
|
+
const deleted = deleteCodexRolloutFiles(valid);
|
|
1075
|
+
if (valid.size > 0) {
|
|
1076
|
+
this.codexHistoryCache = null;
|
|
1077
|
+
}
|
|
1078
|
+
return deleted;
|
|
1079
|
+
}
|
|
901
1080
|
get(id) {
|
|
902
1081
|
const record = this.sessions.get(id);
|
|
903
1082
|
if (!record) {
|
|
@@ -699,6 +699,41 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
699
699
|
res.status(response.statusCode).json(response.payload);
|
|
700
700
|
}
|
|
701
701
|
});
|
|
702
|
+
app.post("/api/codex-sessions/:threadId/resume", express.json(), async (req, res) => {
|
|
703
|
+
const threadId = String(req.params.threadId || "").trim();
|
|
704
|
+
const body = req.body;
|
|
705
|
+
console.log("[WAND] POST /api/codex-sessions/:threadId/resume threadId:", threadId, "cwd:", body.cwd);
|
|
706
|
+
try {
|
|
707
|
+
if (!threadId) {
|
|
708
|
+
res.status(400).json({ error: "Codex 会话 ID 不能为空。" });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const history = processes.listCodexHistorySessions().find((s) => s.claudeSessionId === threadId);
|
|
712
|
+
if (!history) {
|
|
713
|
+
res.status(400).json({ error: "对应的 Codex 历史会话不存在,无法恢复。" });
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const cwd = body.cwd?.trim() || history.cwd;
|
|
717
|
+
if (!cwd) {
|
|
718
|
+
res.status(400).json({ error: "无法确定工作目录 (cwd),无法恢复。" });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const newMode = normalizeMode(body.mode, defaultMode);
|
|
722
|
+
const snapshot = structured.createSession({
|
|
723
|
+
cwd,
|
|
724
|
+
mode: newMode,
|
|
725
|
+
provider: "codex",
|
|
726
|
+
runner: "codex-cli-exec",
|
|
727
|
+
worktreeEnabled: body.worktreeEnabled === true,
|
|
728
|
+
claudeSessionId: threadId,
|
|
729
|
+
});
|
|
730
|
+
onSessionCreated?.(cwd);
|
|
731
|
+
res.status(201).json({ resumedClaudeSessionId: threadId, ...snapshot });
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
res.status(400).json({ error: getErrorMessage(error, "无法按 Codex 会话 ID 恢复会话。") });
|
|
735
|
+
}
|
|
736
|
+
});
|
|
702
737
|
app.post("/api/sessions/:id/resize", (req, res) => {
|
|
703
738
|
const body = req.body;
|
|
704
739
|
try {
|
|
@@ -907,4 +942,79 @@ export function registerClaudeHistoryRoutes(app, processes, storage) {
|
|
|
907
942
|
res.status(500).json({ error: getErrorMessage(error, "无法批量删除历史会话。") });
|
|
908
943
|
}
|
|
909
944
|
});
|
|
945
|
+
// ── Codex history(~/.codex/sessions/ 扫描,对齐 Claude 历史区) ──
|
|
946
|
+
// codex 历史的"恢复"是新建一个结构化 codex 会话并预填 thread id(存进 claudeSessionId
|
|
947
|
+
// 字段),用户发第一条消息时 buildCodexArgs 自动拼 `codex exec ... resume <thread_id>`。
|
|
948
|
+
// hidden 集合与 claude 共用(id 全局唯一,不会冲突)。
|
|
949
|
+
app.get("/api/codex-history", (_req, res) => {
|
|
950
|
+
try {
|
|
951
|
+
const sessions = processes.listCodexHistorySessions();
|
|
952
|
+
const hidden = getHiddenClaudeSessionIds(storage);
|
|
953
|
+
const filtered = hidden.size > 0
|
|
954
|
+
? sessions.filter((s) => !hidden.has(s.claudeSessionId))
|
|
955
|
+
: sessions;
|
|
956
|
+
res.json(filtered);
|
|
957
|
+
}
|
|
958
|
+
catch (error) {
|
|
959
|
+
res.status(500).json({ error: getErrorMessage(error, "无法扫描 Codex 历史会话。") });
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
app.delete("/api/codex-history/:threadId", (req, res) => {
|
|
963
|
+
const threadId = req.params.threadId?.trim();
|
|
964
|
+
if (!threadId) {
|
|
965
|
+
res.status(400).json({ error: "会话 ID 不能为空。" });
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const exists = processes.listCodexHistorySessions().some((s) => s.claudeSessionId === threadId);
|
|
969
|
+
if (exists) {
|
|
970
|
+
processes.deleteCodexHistoryFiles([threadId]);
|
|
971
|
+
removeFromHiddenClaudeSessionIds(storage, [threadId]);
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
const hidden = getHiddenClaudeSessionIds(storage);
|
|
975
|
+
if (!hidden.has(threadId)) {
|
|
976
|
+
hidden.add(threadId);
|
|
977
|
+
saveHiddenClaudeSessionIds(storage, hidden);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
res.json({ ok: true });
|
|
981
|
+
});
|
|
982
|
+
app.post("/api/codex-history/batch-delete", express.json(), (req, res) => {
|
|
983
|
+
const threadIds = Array.isArray(req.body?.claudeSessionIds)
|
|
984
|
+
? req.body.claudeSessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
985
|
+
: [];
|
|
986
|
+
if (threadIds.length === 0) {
|
|
987
|
+
res.status(400).json({ error: "至少提供一个历史会话 ID。" });
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
try {
|
|
991
|
+
const existing = new Set(processes.listCodexHistorySessions().map((s) => s.claudeSessionId));
|
|
992
|
+
const toDelete = [];
|
|
993
|
+
const toHide = [];
|
|
994
|
+
for (const id of threadIds) {
|
|
995
|
+
if (existing.has(id))
|
|
996
|
+
toDelete.push(id);
|
|
997
|
+
else
|
|
998
|
+
toHide.push(id);
|
|
999
|
+
}
|
|
1000
|
+
const deleted = processes.deleteCodexHistoryFiles(toDelete);
|
|
1001
|
+
removeFromHiddenClaudeSessionIds(storage, toDelete);
|
|
1002
|
+
if (toHide.length > 0) {
|
|
1003
|
+
const hidden = getHiddenClaudeSessionIds(storage);
|
|
1004
|
+
let added = 0;
|
|
1005
|
+
for (const id of toHide) {
|
|
1006
|
+
if (!hidden.has(id)) {
|
|
1007
|
+
hidden.add(id);
|
|
1008
|
+
added++;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (added > 0)
|
|
1012
|
+
saveHiddenClaudeSessionIds(storage, hidden);
|
|
1013
|
+
}
|
|
1014
|
+
res.json({ ok: true, deleted: deleted + toHide.length });
|
|
1015
|
+
}
|
|
1016
|
+
catch (error) {
|
|
1017
|
+
res.status(500).json({ error: getErrorMessage(error, "无法批量删除历史会话。") });
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
910
1020
|
}
|
|
@@ -12,6 +12,13 @@ interface CreateStructuredSessionOptions {
|
|
|
12
12
|
model?: string;
|
|
13
13
|
/** 用户预设的思考深度。留空 / null 视为 off。 */
|
|
14
14
|
thinkingEffort?: SessionSnapshot["thinkingEffort"];
|
|
15
|
+
/**
|
|
16
|
+
* 恢复用的初始会话 id:
|
|
17
|
+
* - Codex:历史 thread id,首条消息即 `codex exec ... resume <id>` 续接。
|
|
18
|
+
* - Claude:历史 session id,首条消息即 `--resume` / SDK resume 续接。
|
|
19
|
+
* 留空表示新建会话。
|
|
20
|
+
*/
|
|
21
|
+
claudeSessionId?: string;
|
|
15
22
|
}
|
|
16
23
|
/**
|
|
17
24
|
* 把任意外部输入收敛到合法的 thinkingEffort 枚举值。`null` / 非法值都视为
|
|
@@ -150,6 +157,47 @@ export declare class StructuredSessionManager {
|
|
|
150
157
|
private normalizeToolInput;
|
|
151
158
|
private normalizeToolResultContent;
|
|
152
159
|
private extractCodexText;
|
|
160
|
+
/**
|
|
161
|
+
* Merge one codex `item.*` event into `turnState.blocks`.
|
|
162
|
+
*
|
|
163
|
+
* 三种 phase 行为:
|
|
164
|
+
* - "started": 首次出现的 item,块直接 push(tool_result 走 upsert 配对)。
|
|
165
|
+
* text/thinking/TodoWrite 这种"靠 id 替换"的块记录到
|
|
166
|
+
* codexBlockIndex 里,方便后续 updated/completed 找回原位。
|
|
167
|
+
* - "updated": codex 重发完整 ThreadItem(不是 delta)。已记录过的块就
|
|
168
|
+
* 替换;新块按 started 路径处理。
|
|
169
|
+
* - "completed": 把"in_progress"卡片定型——text 同时更新 turnState.result
|
|
170
|
+
* 以便 result fallback 不为空;tool_use ↔ tool_result 通过
|
|
171
|
+
* 共享 id 配对到一起(包括 file_change 子项的 `${id}#i`)。
|
|
172
|
+
*/
|
|
173
|
+
private applyCodexItem;
|
|
174
|
+
/**
|
|
175
|
+
* Map a codex `item.{started,updated,completed}` payload into wand's
|
|
176
|
+
* `ContentBlock[]` so the chat UI's existing tool/diff/todo cards just work.
|
|
177
|
+
*
|
|
178
|
+
* Codex `exec --json` emits 8 item.type values (see
|
|
179
|
+
* `codex-rs/exec/src/exec_events.rs`); below they're routed to whatever wand
|
|
180
|
+
* tool name reuses an existing renderer:
|
|
181
|
+
*
|
|
182
|
+
* agent_message → text
|
|
183
|
+
* reasoning → thinking
|
|
184
|
+
* command_execution → tool_use "Bash" + tool_result
|
|
185
|
+
* file_change → one tool_use per file, named Edit/Write/Bash by `kind`
|
|
186
|
+
* (codex does NOT carry old_string/new_string in the
|
|
187
|
+
* exec stream, only the path list; diff card body is
|
|
188
|
+
* empty but the file row + status still render)
|
|
189
|
+
* mcp_tool_call → tool_use named "<server>__<tool>" + tool_result
|
|
190
|
+
* web_search → tool_use "WebSearch" + tool_result (results not in stream)
|
|
191
|
+
* todo_list → tool_use "TodoWrite" (replaced in place on each update)
|
|
192
|
+
* error → text block prefixed with ❌
|
|
193
|
+
*
|
|
194
|
+
* Returns [] when there is nothing to emit yet (e.g. agent_message at
|
|
195
|
+
* `item.started` before any text has been produced).
|
|
196
|
+
*
|
|
197
|
+
* Callers handle in-place replacement for `item.updated` via
|
|
198
|
+
* `turnState.codexBlockIndex`; tool_use ↔ tool_result pairing still goes
|
|
199
|
+
* through `upsertCodexBlock` by matching ids.
|
|
200
|
+
*/
|
|
153
201
|
private extractCodexItemBlock;
|
|
154
202
|
private upsertCodexBlock;
|
|
155
203
|
/**
|
|
@@ -580,7 +580,7 @@ export class StructuredSessionManager {
|
|
|
580
580
|
output: "",
|
|
581
581
|
archived: false,
|
|
582
582
|
archivedAt: null,
|
|
583
|
-
claudeSessionId: null,
|
|
583
|
+
claudeSessionId: options.claudeSessionId?.trim() || null,
|
|
584
584
|
messages: [],
|
|
585
585
|
queuedMessages: [],
|
|
586
586
|
structuredState: {
|
|
@@ -1161,6 +1161,7 @@ export class StructuredSessionManager {
|
|
|
1161
1161
|
sessionId: session.claudeSessionId,
|
|
1162
1162
|
model: session.selectedModel ?? session.structuredState?.model,
|
|
1163
1163
|
usage: undefined,
|
|
1164
|
+
codexBlockIndex: new Map(),
|
|
1164
1165
|
};
|
|
1165
1166
|
let lineBuf = "";
|
|
1166
1167
|
let stderr = "";
|
|
@@ -1232,23 +1233,24 @@ export class StructuredSessionManager {
|
|
|
1232
1233
|
return;
|
|
1233
1234
|
}
|
|
1234
1235
|
if (parsed?.type === "item.started" && parsed.item) {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1236
|
+
this.applyCodexItem(turnState, parsed.item, "started");
|
|
1237
|
+
syncSnapshot();
|
|
1238
|
+
scheduleEmit();
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
if (parsed?.type === "item.updated" && parsed.item) {
|
|
1242
|
+
// codex `item.updated` 重新发送完整 ThreadItem(不是 delta)。
|
|
1243
|
+
// 对 text/thinking/TodoWrite 走 codexBlockIndex 替换;对 tool_use
|
|
1244
|
+
// 仍然按现有 id 复用,避免重复卡片。
|
|
1245
|
+
this.applyCodexItem(turnState, parsed.item, "updated");
|
|
1246
|
+
syncSnapshot();
|
|
1247
|
+
scheduleEmit();
|
|
1241
1248
|
return;
|
|
1242
1249
|
}
|
|
1243
1250
|
if (parsed?.type === "item.completed" && parsed.item) {
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
turnState.result = block.text;
|
|
1248
|
-
this.upsertCodexBlock(turnState.blocks, block);
|
|
1249
|
-
syncSnapshot();
|
|
1250
|
-
scheduleEmit();
|
|
1251
|
-
}
|
|
1251
|
+
this.applyCodexItem(turnState, parsed.item, "completed");
|
|
1252
|
+
syncSnapshot();
|
|
1253
|
+
scheduleEmit();
|
|
1252
1254
|
return;
|
|
1253
1255
|
}
|
|
1254
1256
|
if (parsed?.type === "turn.completed") {
|
|
@@ -2563,16 +2565,88 @@ export class StructuredSessionManager {
|
|
|
2563
2565
|
}
|
|
2564
2566
|
return "";
|
|
2565
2567
|
}
|
|
2568
|
+
/**
|
|
2569
|
+
* Merge one codex `item.*` event into `turnState.blocks`.
|
|
2570
|
+
*
|
|
2571
|
+
* 三种 phase 行为:
|
|
2572
|
+
* - "started": 首次出现的 item,块直接 push(tool_result 走 upsert 配对)。
|
|
2573
|
+
* text/thinking/TodoWrite 这种"靠 id 替换"的块记录到
|
|
2574
|
+
* codexBlockIndex 里,方便后续 updated/completed 找回原位。
|
|
2575
|
+
* - "updated": codex 重发完整 ThreadItem(不是 delta)。已记录过的块就
|
|
2576
|
+
* 替换;新块按 started 路径处理。
|
|
2577
|
+
* - "completed": 把"in_progress"卡片定型——text 同时更新 turnState.result
|
|
2578
|
+
* 以便 result fallback 不为空;tool_use ↔ tool_result 通过
|
|
2579
|
+
* 共享 id 配对到一起(包括 file_change 子项的 `${id}#i`)。
|
|
2580
|
+
*/
|
|
2581
|
+
applyCodexItem(turnState, item, phase) {
|
|
2582
|
+
const completed = phase === "completed";
|
|
2583
|
+
const itemId = typeof item.id === "string" ? item.id : "";
|
|
2584
|
+
const blocks = this.extractCodexItemBlock(item, completed);
|
|
2585
|
+
if (blocks.length === 0)
|
|
2586
|
+
return;
|
|
2587
|
+
const index = turnState.codexBlockIndex ??= new Map();
|
|
2588
|
+
for (const block of blocks) {
|
|
2589
|
+
// text / thinking / TodoWrite tool_use 的卡片是"按 item id 整体替换"语义,
|
|
2590
|
+
// 否则一个 agent_message 在 updated/completed 时会被重复 push 多次。
|
|
2591
|
+
const replaceable = block.type === "text"
|
|
2592
|
+
|| block.type === "thinking"
|
|
2593
|
+
|| (block.type === "tool_use" && block.name === "TodoWrite");
|
|
2594
|
+
if (replaceable && itemId) {
|
|
2595
|
+
const existing = index.get(itemId);
|
|
2596
|
+
if (existing !== undefined && existing < turnState.blocks.length) {
|
|
2597
|
+
turnState.blocks[existing] = block;
|
|
2598
|
+
}
|
|
2599
|
+
else {
|
|
2600
|
+
index.set(itemId, turnState.blocks.length);
|
|
2601
|
+
turnState.blocks.push(block);
|
|
2602
|
+
}
|
|
2603
|
+
if (block.type === "text" && completed) {
|
|
2604
|
+
turnState.result = block.text;
|
|
2605
|
+
}
|
|
2606
|
+
continue;
|
|
2607
|
+
}
|
|
2608
|
+
// 其它块(tool_use 非 Todo / tool_result / 文件改动的多 sub-id 块)
|
|
2609
|
+
// 仍然走原有 upsert:tool_result 按 tool_use_id 配对,其余直接 push。
|
|
2610
|
+
this.upsertCodexBlock(turnState.blocks, block);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
/**
|
|
2614
|
+
* Map a codex `item.{started,updated,completed}` payload into wand's
|
|
2615
|
+
* `ContentBlock[]` so the chat UI's existing tool/diff/todo cards just work.
|
|
2616
|
+
*
|
|
2617
|
+
* Codex `exec --json` emits 8 item.type values (see
|
|
2618
|
+
* `codex-rs/exec/src/exec_events.rs`); below they're routed to whatever wand
|
|
2619
|
+
* tool name reuses an existing renderer:
|
|
2620
|
+
*
|
|
2621
|
+
* agent_message → text
|
|
2622
|
+
* reasoning → thinking
|
|
2623
|
+
* command_execution → tool_use "Bash" + tool_result
|
|
2624
|
+
* file_change → one tool_use per file, named Edit/Write/Bash by `kind`
|
|
2625
|
+
* (codex does NOT carry old_string/new_string in the
|
|
2626
|
+
* exec stream, only the path list; diff card body is
|
|
2627
|
+
* empty but the file row + status still render)
|
|
2628
|
+
* mcp_tool_call → tool_use named "<server>__<tool>" + tool_result
|
|
2629
|
+
* web_search → tool_use "WebSearch" + tool_result (results not in stream)
|
|
2630
|
+
* todo_list → tool_use "TodoWrite" (replaced in place on each update)
|
|
2631
|
+
* error → text block prefixed with ❌
|
|
2632
|
+
*
|
|
2633
|
+
* Returns [] when there is nothing to emit yet (e.g. agent_message at
|
|
2634
|
+
* `item.started` before any text has been produced).
|
|
2635
|
+
*
|
|
2636
|
+
* Callers handle in-place replacement for `item.updated` via
|
|
2637
|
+
* `turnState.codexBlockIndex`; tool_use ↔ tool_result pairing still goes
|
|
2638
|
+
* through `upsertCodexBlock` by matching ids.
|
|
2639
|
+
*/
|
|
2566
2640
|
extractCodexItemBlock(item, completed) {
|
|
2567
2641
|
const id = typeof item.id === "string" ? item.id : randomUUID();
|
|
2568
2642
|
const type = typeof item.type === "string" ? item.type : "unknown";
|
|
2569
2643
|
if (type === "agent_message") {
|
|
2570
2644
|
const text = this.extractCodexText(item);
|
|
2571
|
-
return text ? { type: "text", text } :
|
|
2645
|
+
return text ? [{ type: "text", text }] : [];
|
|
2572
2646
|
}
|
|
2573
2647
|
if (type === "reasoning") {
|
|
2574
2648
|
const text = this.extractCodexText(item);
|
|
2575
|
-
return text ? { type: "thinking", thinking: text } :
|
|
2649
|
+
return text ? [{ type: "thinking", thinking: text }] : [];
|
|
2576
2650
|
}
|
|
2577
2651
|
if (type === "command_execution") {
|
|
2578
2652
|
const command = typeof item.command === "string" ? item.command : "";
|
|
@@ -2580,28 +2654,213 @@ export class StructuredSessionManager {
|
|
|
2580
2654
|
const exitCode = typeof item.exit_code === "number" ? item.exit_code : null;
|
|
2581
2655
|
const status = typeof item.status === "string" ? item.status : completed ? "completed" : "in_progress";
|
|
2582
2656
|
if (!completed) {
|
|
2657
|
+
return [{
|
|
2658
|
+
type: "tool_use",
|
|
2659
|
+
id,
|
|
2660
|
+
name: "Bash",
|
|
2661
|
+
input: { command, status },
|
|
2662
|
+
}];
|
|
2663
|
+
}
|
|
2664
|
+
// codex 的 status 可能是 declined(sandbox 拒了命令)/ failed(执行失败)—
|
|
2665
|
+
// 这时 exit_code 经常是 null,光靠 exitCode !== 0 判 is_error 会漏。
|
|
2666
|
+
const isError = status === "failed" || status === "declined"
|
|
2667
|
+
|| (typeof exitCode === "number" && exitCode !== 0);
|
|
2668
|
+
const fallbackText = status === "declined"
|
|
2669
|
+
? "command declined by sandbox"
|
|
2670
|
+
: (exitCode === null ? "" : `exit_code: ${exitCode}`);
|
|
2671
|
+
return [{
|
|
2672
|
+
type: "tool_result",
|
|
2673
|
+
tool_use_id: id,
|
|
2674
|
+
content: aggregatedOutput || fallbackText,
|
|
2675
|
+
is_error: isError,
|
|
2676
|
+
}];
|
|
2677
|
+
}
|
|
2678
|
+
if (type === "file_change") {
|
|
2679
|
+
// 注意:codex exec stream 没有 old_string/new_string——只给 path + kind。
|
|
2680
|
+
// 这里每个 file 一个 sub-id(`${item.id}#${i}`),这样如果 codex 一次给多
|
|
2681
|
+
// 个文件,每个文件能独立成卡片 + 独立 tool_result 状态。
|
|
2682
|
+
const rawChanges = Array.isArray(item.changes) ? item.changes : [];
|
|
2683
|
+
const status = typeof item.status === "string" ? item.status : completed ? "completed" : "in_progress";
|
|
2684
|
+
const isError = status === "failed";
|
|
2685
|
+
const blocks = [];
|
|
2686
|
+
rawChanges.forEach((entry, idx) => {
|
|
2687
|
+
if (!entry || typeof entry !== "object")
|
|
2688
|
+
return;
|
|
2689
|
+
const change = entry;
|
|
2690
|
+
const path = typeof change.path === "string" ? change.path : "";
|
|
2691
|
+
const kind = typeof change.kind === "string" ? change.kind : "update";
|
|
2692
|
+
const subId = `${id}#${idx}`;
|
|
2693
|
+
let toolName;
|
|
2694
|
+
let input;
|
|
2695
|
+
if (kind === "add") {
|
|
2696
|
+
toolName = "Write";
|
|
2697
|
+
input = { file_path: path, content: "" };
|
|
2698
|
+
}
|
|
2699
|
+
else if (kind === "delete") {
|
|
2700
|
+
// 复用 Bash 终端卡,rm 语义直观
|
|
2701
|
+
toolName = "Bash";
|
|
2702
|
+
input = { command: `rm ${path}`, description: `delete ${path}`, status };
|
|
2703
|
+
}
|
|
2704
|
+
else {
|
|
2705
|
+
toolName = "Edit";
|
|
2706
|
+
input = { file_path: path, old_string: "", new_string: "" };
|
|
2707
|
+
}
|
|
2708
|
+
if (!completed) {
|
|
2709
|
+
blocks.push({ type: "tool_use", id: subId, name: toolName, input });
|
|
2710
|
+
}
|
|
2711
|
+
else {
|
|
2712
|
+
blocks.push({ type: "tool_use", id: subId, name: toolName, input });
|
|
2713
|
+
blocks.push({
|
|
2714
|
+
type: "tool_result",
|
|
2715
|
+
tool_use_id: subId,
|
|
2716
|
+
content: isError ? `file change failed: ${path}` : "",
|
|
2717
|
+
is_error: isError,
|
|
2718
|
+
});
|
|
2719
|
+
}
|
|
2720
|
+
});
|
|
2721
|
+
return blocks;
|
|
2722
|
+
}
|
|
2723
|
+
if (type === "mcp_tool_call") {
|
|
2724
|
+
const server = typeof item.server === "string" ? item.server : "mcp";
|
|
2725
|
+
const tool = typeof item.tool === "string" ? item.tool : "tool";
|
|
2726
|
+
const args = item.arguments && typeof item.arguments === "object" ? item.arguments : {};
|
|
2727
|
+
const errObj = item.error && typeof item.error === "object" ? item.error : null;
|
|
2728
|
+
const status = typeof item.status === "string" ? item.status : completed ? "completed" : "in_progress";
|
|
2729
|
+
const isError = !!errObj || status === "failed";
|
|
2730
|
+
if (!completed) {
|
|
2731
|
+
return [{
|
|
2732
|
+
type: "tool_use",
|
|
2733
|
+
id,
|
|
2734
|
+
name: `${server}__${tool}`,
|
|
2735
|
+
input: args,
|
|
2736
|
+
}];
|
|
2737
|
+
}
|
|
2738
|
+
let resultText = "";
|
|
2739
|
+
if (errObj && typeof errObj.message === "string") {
|
|
2740
|
+
resultText = errObj.message;
|
|
2741
|
+
}
|
|
2742
|
+
else if (item.result && typeof item.result === "object") {
|
|
2743
|
+
const resultRec = item.result;
|
|
2744
|
+
const inner = this.extractCodexText(resultRec.content);
|
|
2745
|
+
resultText = inner || JSON.stringify(resultRec).slice(0, 4096);
|
|
2746
|
+
}
|
|
2747
|
+
return [{
|
|
2748
|
+
type: "tool_result",
|
|
2749
|
+
tool_use_id: id,
|
|
2750
|
+
content: resultText,
|
|
2751
|
+
is_error: isError,
|
|
2752
|
+
}];
|
|
2753
|
+
}
|
|
2754
|
+
if (type === "web_search") {
|
|
2755
|
+
const query = typeof item.query === "string" ? item.query : "";
|
|
2756
|
+
if (!completed) {
|
|
2757
|
+
return [{
|
|
2758
|
+
type: "tool_use",
|
|
2759
|
+
id,
|
|
2760
|
+
name: "WebSearch",
|
|
2761
|
+
input: { query },
|
|
2762
|
+
}];
|
|
2763
|
+
}
|
|
2764
|
+
return [{
|
|
2765
|
+
type: "tool_result",
|
|
2766
|
+
tool_use_id: id,
|
|
2767
|
+
// codex 不在 exec 流里回 search 结果,这里给个占位让 UI 卡片完成态。
|
|
2768
|
+
content: query ? `query: ${query}` : "",
|
|
2769
|
+
}];
|
|
2770
|
+
}
|
|
2771
|
+
if (type === "collab_tool_call") {
|
|
2772
|
+
// codex 的子-agent 编排(spawn_agent / send_input / wait / close_agent)。
|
|
2773
|
+
// 没有对应 Claude tool,所以名称用 "Codex/<op>" 让 UI 默认 tool 卡渲染时
|
|
2774
|
+
// 一眼能看出来是 codex 多 agent 操作。
|
|
2775
|
+
const tool = typeof item.tool === "string" ? item.tool : "collab";
|
|
2776
|
+
const prompt = typeof item.prompt === "string" ? item.prompt : "";
|
|
2777
|
+
const senderId = typeof item.sender_thread_id === "string" ? item.sender_thread_id : "";
|
|
2778
|
+
const receiverIds = Array.isArray(item.receiver_thread_ids)
|
|
2779
|
+
? item.receiver_thread_ids.filter((v) => typeof v === "string")
|
|
2780
|
+
: [];
|
|
2781
|
+
const agentsStates = item.agents_states && typeof item.agents_states === "object"
|
|
2782
|
+
? item.agents_states
|
|
2783
|
+
: {};
|
|
2784
|
+
const status = typeof item.status === "string" ? item.status : completed ? "completed" : "in_progress";
|
|
2785
|
+
const toolName = `Codex/${tool}`;
|
|
2786
|
+
const input = { tool };
|
|
2787
|
+
if (prompt)
|
|
2788
|
+
input.prompt = prompt;
|
|
2789
|
+
if (senderId)
|
|
2790
|
+
input.sender_thread_id = senderId;
|
|
2791
|
+
if (receiverIds.length > 0)
|
|
2792
|
+
input.receiver_thread_ids = receiverIds;
|
|
2793
|
+
if (Object.keys(agentsStates).length > 0)
|
|
2794
|
+
input.agents_states = agentsStates;
|
|
2795
|
+
if (!completed) {
|
|
2796
|
+
return [{ type: "tool_use", id, name: toolName, input }];
|
|
2797
|
+
}
|
|
2798
|
+
// 完成态:把每个 receiver agent 的最终状态汇总成可读 result。
|
|
2799
|
+
const summaryLines = [];
|
|
2800
|
+
for (const [tid, state] of Object.entries(agentsStates)) {
|
|
2801
|
+
if (!state || typeof state !== "object")
|
|
2802
|
+
continue;
|
|
2803
|
+
const rec = state;
|
|
2804
|
+
const s = typeof rec.status === "string" ? rec.status : "?";
|
|
2805
|
+
const msg = typeof rec.message === "string" && rec.message ? ` — ${rec.message}` : "";
|
|
2806
|
+
summaryLines.push(`${tid.slice(0, 8)}: ${s}${msg}`);
|
|
2807
|
+
}
|
|
2808
|
+
const isError = status === "failed"
|
|
2809
|
+
|| summaryLines.some((l) => /errored|not_found|interrupted/.test(l));
|
|
2810
|
+
const content = summaryLines.length > 0
|
|
2811
|
+
? summaryLines.join("\n")
|
|
2812
|
+
: (status === "completed" ? "ok" : status);
|
|
2813
|
+
return [
|
|
2814
|
+
{ type: "tool_use", id, name: toolName, input },
|
|
2815
|
+
{ type: "tool_result", tool_use_id: id, content, is_error: isError },
|
|
2816
|
+
];
|
|
2817
|
+
}
|
|
2818
|
+
if (type === "todo_list") {
|
|
2819
|
+
// codex 的 todo: { items: [{ text, completed: bool }] }
|
|
2820
|
+
// wand UI(renderTodoWrite)读的是 block.input.todos = [{content, status, activeForm}]
|
|
2821
|
+
// 这里做形状翻译;in_progress 状态 codex 不区分,全部 pending → completed 二值。
|
|
2822
|
+
const rawItems = Array.isArray(item.items) ? item.items : [];
|
|
2823
|
+
const todos = rawItems.map((entry) => {
|
|
2824
|
+
const rec = (entry && typeof entry === "object") ? entry : {};
|
|
2825
|
+
const text = typeof rec.text === "string" ? rec.text : "";
|
|
2826
|
+
const done = rec.completed === true;
|
|
2583
2827
|
return {
|
|
2828
|
+
content: text,
|
|
2829
|
+
status: done ? "completed" : "pending",
|
|
2830
|
+
activeForm: text,
|
|
2831
|
+
};
|
|
2832
|
+
});
|
|
2833
|
+
return [{
|
|
2584
2834
|
type: "tool_use",
|
|
2585
2835
|
id,
|
|
2586
|
-
name: "
|
|
2587
|
-
input: {
|
|
2588
|
-
};
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
is_error: typeof exitCode === "number" && exitCode !== 0,
|
|
2595
|
-
};
|
|
2836
|
+
name: "TodoWrite",
|
|
2837
|
+
input: { todos },
|
|
2838
|
+
}];
|
|
2839
|
+
}
|
|
2840
|
+
if (type === "error") {
|
|
2841
|
+
// item-level error(不是 top-level error 事件,那个走 codexErrors / 退出报错路径)
|
|
2842
|
+
const message = this.extractCodexText(item) || "codex item error";
|
|
2843
|
+
return [{ type: "text", text: `❌ ${message}` }];
|
|
2596
2844
|
}
|
|
2845
|
+
// unknown / 兜底:completed 时尝试取 text 字段免得 silently 丢
|
|
2597
2846
|
if (completed) {
|
|
2598
2847
|
const text = this.extractCodexText(item);
|
|
2599
2848
|
if (text)
|
|
2600
|
-
return { type: "text", text };
|
|
2849
|
+
return [{ type: "text", text }];
|
|
2601
2850
|
}
|
|
2602
|
-
return
|
|
2851
|
+
return [];
|
|
2603
2852
|
}
|
|
2604
2853
|
upsertCodexBlock(blocks, block) {
|
|
2854
|
+
// tool_use 按 id 去重——file_change 在 item.started 已经 push 过一份 tool_use,
|
|
2855
|
+
// 到 item.completed 还会再发一份相同 id 的(带 status 更新),不去重就出现
|
|
2856
|
+
// 两张同名卡片。command_execution 不受影响(它在 completed 只 emit tool_result)。
|
|
2857
|
+
if (block.type === "tool_use") {
|
|
2858
|
+
const existingIndex = blocks.findIndex((existing) => existing.type === "tool_use" && existing.id === block.id);
|
|
2859
|
+
if (existingIndex >= 0) {
|
|
2860
|
+
blocks[existingIndex] = block;
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2605
2864
|
if (block.type === "tool_result") {
|
|
2606
2865
|
const toolUseIndex = blocks.findIndex((existing) => existing.type === "tool_use" && existing.id === block.tool_use_id);
|
|
2607
2866
|
if (toolUseIndex >= 0) {
|
|
@@ -2725,8 +2984,12 @@ export class StructuredSessionManager {
|
|
|
2725
2984
|
inputTokens: typeof source.input_tokens === "number" ? source.input_tokens : undefined,
|
|
2726
2985
|
outputTokens: typeof source.output_tokens === "number" ? source.output_tokens : undefined,
|
|
2727
2986
|
cacheReadInputTokens: typeof source.cached_input_tokens === "number" ? source.cached_input_tokens : undefined,
|
|
2987
|
+
reasoningOutputTokens: typeof source.reasoning_output_tokens === "number" ? source.reasoning_output_tokens : undefined,
|
|
2728
2988
|
};
|
|
2729
|
-
if (value.inputTokens === undefined
|
|
2989
|
+
if (value.inputTokens === undefined
|
|
2990
|
+
&& value.outputTokens === undefined
|
|
2991
|
+
&& value.cacheReadInputTokens === undefined
|
|
2992
|
+
&& value.reasoningOutputTokens === undefined) {
|
|
2730
2993
|
return undefined;
|
|
2731
2994
|
}
|
|
2732
2995
|
return value;
|
package/dist/types.d.ts
CHANGED
|
@@ -365,6 +365,8 @@ export interface ConversationTurn {
|
|
|
365
365
|
outputTokens?: number;
|
|
366
366
|
cacheReadInputTokens?: number;
|
|
367
367
|
cacheCreationInputTokens?: number;
|
|
368
|
+
/** codex 专属:reasoning_output_tokens(GPT-5 等带思考模型,per-turn 计费)。 */
|
|
369
|
+
reasoningOutputTokens?: number;
|
|
368
370
|
totalCostUsd?: number;
|
|
369
371
|
};
|
|
370
372
|
}
|
|
@@ -370,6 +370,10 @@
|
|
|
370
370
|
sessionsManageMode: false,
|
|
371
371
|
selectedSessionIds: {},
|
|
372
372
|
selectedClaudeHistoryIds: {},
|
|
373
|
+
codexHistory: [],
|
|
374
|
+
codexHistoryLoaded: false,
|
|
375
|
+
codexHistoryExpandedDirs: {},
|
|
376
|
+
selectedCodexHistoryIds: {},
|
|
373
377
|
askUserSelections: {}, // { toolUseId: { 0: [optIdx...], submitted: false } }
|
|
374
378
|
queueEpoch: 0, // Monotonic counter for queue state freshness
|
|
375
379
|
pendingAttachments: [], // [{ file, previewUrl, name, size }]
|
|
@@ -2358,6 +2362,10 @@
|
|
|
2358
2362
|
state.quickCommitForm.makeTag = tagCb.checked;
|
|
2359
2363
|
var row = document.getElementById("quick-commit-tag-row");
|
|
2360
2364
|
if (row) row.classList.toggle("hidden", !tagCb.checked);
|
|
2365
|
+
if (tagCb.checked) {
|
|
2366
|
+
var input = document.getElementById("quick-commit-tag");
|
|
2367
|
+
if (input) setTimeout(function() { input.focus(); }, 0);
|
|
2368
|
+
}
|
|
2361
2369
|
});
|
|
2362
2370
|
var tagInput = document.getElementById("quick-commit-tag");
|
|
2363
2371
|
if (tagInput) tagInput.addEventListener("input", function() {
|
|
@@ -2485,9 +2493,8 @@
|
|
|
2485
2493
|
state.quickCommitForm.customMessage = aiMessage;
|
|
2486
2494
|
}
|
|
2487
2495
|
var currentTag = (state.quickCommitForm.tag || "").trim();
|
|
2488
|
-
if (!currentTag && aiTag) {
|
|
2496
|
+
if (state.quickCommitForm.makeTag && !currentTag && aiTag) {
|
|
2489
2497
|
state.quickCommitForm.tag = aiTag;
|
|
2490
|
-
state.quickCommitForm.makeTag = true;
|
|
2491
2498
|
}
|
|
2492
2499
|
})
|
|
2493
2500
|
.catch(function(error) {
|
|
@@ -2848,19 +2855,21 @@
|
|
|
2848
2855
|
'<div class="qc-files-wrap">' + fileRows + '</div>' +
|
|
2849
2856
|
'<div class="qc-message-row" id="quick-commit-message-row">' +
|
|
2850
2857
|
'<div class="qc-message-header"><label class="field-label" for="quick-commit-message">commit message</label>' +
|
|
2851
|
-
'<
|
|
2858
|
+
'<div class="qc-ai-controls">' +
|
|
2859
|
+
'<button type="button" id="quick-commit-ai-btn" class="btn btn-ghost btn-sm"' + (state.quickCommitGenerating ? ' disabled' : '') + '>' + (state.quickCommitGenerating ? '生成中…' : 'AI 生成') + '</button>' +
|
|
2860
|
+
'<label class="qc-ai-tag-toggle" title="开启后,AI 会一并建议 tag,提交时会为本次 commit 打 tag">' +
|
|
2861
|
+
'<span class="qc-ai-tag-label">含 tag</span>' +
|
|
2862
|
+
'<span class="qc-switch qc-switch--compact">' +
|
|
2863
|
+
'<input type="checkbox" id="quick-commit-make-tag" class="switch-toggle" aria-label="同时为本次 commit 打 tag"' + (f.makeTag ? ' checked' : '') + ((state.quickCommitSubmitting || state.quickCommitGenerating) ? ' disabled' : '') + '>' +
|
|
2864
|
+
'<span class="switch-slider"></span>' +
|
|
2865
|
+
'</span>' +
|
|
2866
|
+
'</label>' +
|
|
2867
|
+
'</div>' +
|
|
2852
2868
|
'</div>' +
|
|
2853
2869
|
'<textarea id="quick-commit-message" class="field-input" rows="2" placeholder="输入 commit message 或点击 AI 生成"' + (state.quickCommitSubmitting ? ' disabled' : '') + '>' + escapeHtml(f.customMessage || "") + '</textarea>' +
|
|
2854
2870
|
'</div>' +
|
|
2855
|
-
'<div class="qc-checkbox-row">' +
|
|
2856
|
-
'<label class="qc-checkbox-label" for="quick-commit-make-tag">同时为本次 commit 打 tag</label>' +
|
|
2857
|
-
'<label class="qc-switch">' +
|
|
2858
|
-
'<input type="checkbox" id="quick-commit-make-tag" class="switch-toggle"' + (f.makeTag ? ' checked' : '') + '>' +
|
|
2859
|
-
'<span class="switch-slider"></span>' +
|
|
2860
|
-
'</label>' +
|
|
2861
|
-
'</div>' +
|
|
2862
2871
|
'<div class="qc-tag-row' + (f.makeTag ? '' : ' hidden') + '" id="quick-commit-tag-row">' +
|
|
2863
|
-
'<input type="text" id="quick-commit-tag" class="field-input" placeholder="输入 tag
|
|
2872
|
+
'<input type="text" id="quick-commit-tag" class="field-input" placeholder="输入 tag 名称;留空则提交时由 AI 自动生成" value="' + escapeHtml(f.tag || "") + '"' + (state.quickCommitSubmitting ? ' disabled' : '') + '>' +
|
|
2864
2873
|
'</div>' +
|
|
2865
2874
|
(state.quickCommitError ? '<p class="error-message">' + escapeHtml(state.quickCommitError) + '</p>' : '') +
|
|
2866
2875
|
'<div class="qc-section-actions">' +
|
|
@@ -3640,7 +3649,9 @@
|
|
|
3640
3649
|
var visibleHistory = getClaudeHistoryRegionItems();
|
|
3641
3650
|
var expanded = !!state.claudeHistoryExpanded;
|
|
3642
3651
|
var loaded = !!state.claudeHistoryLoaded;
|
|
3643
|
-
var
|
|
3652
|
+
var codexVisible = getVisibleCodexHistorySessions();
|
|
3653
|
+
var codexLoaded = !!state.codexHistoryLoaded;
|
|
3654
|
+
var count = (loaded ? visibleHistory.length : 0) + (codexLoaded ? codexVisible.length : 0);
|
|
3644
3655
|
|
|
3645
3656
|
var badgeCls = "history-bubble";
|
|
3646
3657
|
var badgeContent;
|
|
@@ -3666,7 +3677,7 @@
|
|
|
3666
3677
|
'</button>';
|
|
3667
3678
|
|
|
3668
3679
|
var body = expanded
|
|
3669
|
-
? '<div class="sidebar-history-body" id="sidebar-history-body">' + renderClaudeHistoryBodyContent(visibleHistory) + '</div>'
|
|
3680
|
+
? '<div class="sidebar-history-body" id="sidebar-history-body">' + renderClaudeHistoryBodyContent(visibleHistory) + renderCodexHistoryBodyContent(codexVisible) + '</div>'
|
|
3670
3681
|
: '';
|
|
3671
3682
|
|
|
3672
3683
|
return header + body;
|
|
@@ -3897,6 +3908,44 @@
|
|
|
3897
3908
|
});
|
|
3898
3909
|
}
|
|
3899
3910
|
|
|
3911
|
+
function renderCodexHistoryDirectoryHeader(cwd, cwdShort, count, isExpanded) {
|
|
3912
|
+
var chevron = isExpanded ? "\u25be" : "\u25b8";
|
|
3913
|
+
return '<div class="claude-history-directory-header codex-history-directory-header" data-action="toggle-codex-history-directory" data-cwd="' + escapeHtml(cwd) + '" role="button" tabindex="0">' +
|
|
3914
|
+
'<div class="session-group-title claude-history-directory-title">' +
|
|
3915
|
+
'<span class="chevron">' + chevron + '</span>' +
|
|
3916
|
+
'<span class="claude-history-directory-label">' + escapeHtml(cwdShort) + ' (' + count + ')</span>' +
|
|
3917
|
+
'</div>' +
|
|
3918
|
+
'</div>';
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
function renderCodexHistoryBodyContent(visibleHistory) {
|
|
3922
|
+
if (!state.codexHistoryLoaded) {
|
|
3923
|
+
return '<div class="claude-history-loading">\u626b\u63cf Codex \u5386\u53f2\u4f1a\u8bdd\u4e2d\u2026</div>';
|
|
3924
|
+
}
|
|
3925
|
+
if (visibleHistory.length === 0) {
|
|
3926
|
+
return '';
|
|
3927
|
+
}
|
|
3928
|
+
var groups = {};
|
|
3929
|
+
var groupOrder = [];
|
|
3930
|
+
visibleHistory.forEach(function(s) {
|
|
3931
|
+
if (!groups[s.cwd]) {
|
|
3932
|
+
groups[s.cwd] = [];
|
|
3933
|
+
groupOrder.push(s.cwd);
|
|
3934
|
+
}
|
|
3935
|
+
groups[s.cwd].push(s);
|
|
3936
|
+
});
|
|
3937
|
+
var listHtml = '<div class="sidebar-history-section-label">Codex</div>';
|
|
3938
|
+
groupOrder.forEach(function(cwd) {
|
|
3939
|
+
var cwdShort = cwd.split("/").filter(Boolean).slice(-3).join("/");
|
|
3940
|
+
var isDirExpanded = !!state.codexHistoryExpandedDirs[cwd];
|
|
3941
|
+
listHtml += renderCodexHistoryDirectoryHeader(cwd, cwdShort, groups[cwd].length, isDirExpanded);
|
|
3942
|
+
if (isDirExpanded) {
|
|
3943
|
+
listHtml += groups[cwd].map(function(session) { return renderClaudeHistoryItem(session, "codex"); }).join("");
|
|
3944
|
+
}
|
|
3945
|
+
});
|
|
3946
|
+
return '<div class="sidebar-history-scroll codex-history-scroll">' + listHtml + '</div>';
|
|
3947
|
+
}
|
|
3948
|
+
|
|
3900
3949
|
function renderClaudeHistoryDirectoryHeader(cwd, cwdShort, count, isExpanded) {
|
|
3901
3950
|
var chevron = isExpanded ? "▾" : "▸";
|
|
3902
3951
|
return '<div class="claude-history-directory-header" data-action="toggle-history-directory" data-cwd="' + escapeHtml(cwd) + '" role="button" tabindex="0">' +
|
|
@@ -3910,19 +3959,23 @@
|
|
|
3910
3959
|
}
|
|
3911
3960
|
|
|
3912
3961
|
function renderClaudeHistoryItem(session, kind) {
|
|
3962
|
+
var isCodex = kind === "codex";
|
|
3963
|
+
var rAct = isCodex ? "resume-codex-history" : "resume-history";
|
|
3964
|
+
var dAct = isCodex ? "delete-codex-history" : "delete-history";
|
|
3965
|
+
var selMap = isCodex ? state.selectedCodexHistoryIds : state.selectedClaudeHistoryIds;
|
|
3913
3966
|
var shortId = session.claudeSessionId.slice(0, 8);
|
|
3914
3967
|
var preview = session.firstUserMessage || "(空会话)";
|
|
3915
3968
|
var timeStr = formatHistoryTime(session.timestamp);
|
|
3916
3969
|
var checkbox = renderManageCheckbox(kind, session.claudeSessionId, "选择历史会话 " + preview);
|
|
3917
3970
|
var deleteButton = state.sessionsManageMode ? '' :
|
|
3918
|
-
'<button class="session-action-btn delete-btn" data-action="
|
|
3971
|
+
'<button class="session-action-btn delete-btn" data-action="' + dAct + '" data-claude-session-id="' +
|
|
3919
3972
|
session.claudeSessionId + '" type="button" aria-label="删除会话" title="隐藏此历史会话"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg></button>';
|
|
3920
3973
|
var resumeButton = state.sessionsManageMode ? '' :
|
|
3921
|
-
'<button class="session-action-btn" data-action="
|
|
3974
|
+
'<button class="session-action-btn" data-action="' + rAct + '" data-claude-session-id="' +
|
|
3922
3975
|
session.claudeSessionId + '" data-cwd="' + escapeHtml(session.cwd) +
|
|
3923
|
-
'" type="button" aria-label="恢复会话" title="恢复此 Claude 历史会话"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 105.64-11.36L3 10"/></svg></button>';
|
|
3976
|
+
'" type="button" aria-label="恢复会话" title="' + (isCodex ? "恢复此 Codex 历史会话" : "恢复此 Claude 历史会话") + '"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 105.64-11.36L3 10"/></svg></button>';
|
|
3924
3977
|
|
|
3925
|
-
return '<div class="session-item claude-history-item' + (state.sessionsManageMode &&
|
|
3978
|
+
return '<div class="session-item claude-history-item' + (state.sessionsManageMode && selMap[session.claudeSessionId] ? ' selected' : '') + '" data-claude-history-id="' + session.claudeSessionId + '" data-cwd="' + escapeHtml(session.cwd) + '" role="button" tabindex="0">' +
|
|
3926
3979
|
'<div class="session-item-content">' +
|
|
3927
3980
|
'<div class="session-item-row">' +
|
|
3928
3981
|
checkbox +
|
|
@@ -3980,6 +4033,7 @@
|
|
|
3980
4033
|
// 且在已加载时立即 resolve。
|
|
3981
4034
|
var _claudeHistoryLoadingPromise = null;
|
|
3982
4035
|
function ensureClaudeHistoryLoaded() {
|
|
4036
|
+
ensureCodexHistoryLoaded();
|
|
3983
4037
|
if (state.claudeHistoryLoaded) return Promise.resolve();
|
|
3984
4038
|
if (_claudeHistoryLoadingPromise) return _claudeHistoryLoadingPromise;
|
|
3985
4039
|
_claudeHistoryLoadingPromise = loadClaudeHistory().then(function() {
|
|
@@ -3990,6 +4044,46 @@
|
|
|
3990
4044
|
return _claudeHistoryLoadingPromise;
|
|
3991
4045
|
}
|
|
3992
4046
|
|
|
4047
|
+
function loadCodexHistory() {
|
|
4048
|
+
return fetch("/api/codex-history", { credentials: "same-origin" })
|
|
4049
|
+
.then(function(res) {
|
|
4050
|
+
if (!res.ok) return [];
|
|
4051
|
+
return res.json();
|
|
4052
|
+
})
|
|
4053
|
+
.then(function(sessions) {
|
|
4054
|
+
state.codexHistory = sessions || [];
|
|
4055
|
+
state.codexHistoryLoaded = true;
|
|
4056
|
+
updateSessionsList();
|
|
4057
|
+
})
|
|
4058
|
+
.catch(function() {
|
|
4059
|
+
state.codexHistoryLoaded = true;
|
|
4060
|
+
state.codexHistory = [];
|
|
4061
|
+
updateSessionsList();
|
|
4062
|
+
});
|
|
4063
|
+
}
|
|
4064
|
+
|
|
4065
|
+
var _codexHistoryLoadingPromise = null;
|
|
4066
|
+
function ensureCodexHistoryLoaded() {
|
|
4067
|
+
if (state.codexHistoryLoaded) return Promise.resolve();
|
|
4068
|
+
if (_codexHistoryLoadingPromise) return _codexHistoryLoadingPromise;
|
|
4069
|
+
_codexHistoryLoadingPromise = loadCodexHistory().then(function() {
|
|
4070
|
+
_codexHistoryLoadingPromise = null;
|
|
4071
|
+
}, function() {
|
|
4072
|
+
_codexHistoryLoadingPromise = null;
|
|
4073
|
+
});
|
|
4074
|
+
return _codexHistoryLoadingPromise;
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4077
|
+
function getVisibleCodexHistorySessions() {
|
|
4078
|
+
var managedIds = new Set();
|
|
4079
|
+
state.sessions.forEach(function(s) {
|
|
4080
|
+
if (s.claudeSessionId) managedIds.add(s.claudeSessionId);
|
|
4081
|
+
});
|
|
4082
|
+
return state.codexHistory.filter(function(s) {
|
|
4083
|
+
return s.hasConversation && !s.managedByWand && !managedIds.has(s.claudeSessionId);
|
|
4084
|
+
});
|
|
4085
|
+
}
|
|
4086
|
+
|
|
3993
4087
|
function isMobileLayout() {
|
|
3994
4088
|
return window.innerWidth <= 768;
|
|
3995
4089
|
}
|
|
@@ -7244,6 +7338,14 @@
|
|
|
7244
7338
|
handleResumeAction(actionButton);
|
|
7245
7339
|
} else if (actionButton.dataset.action === "resume-history" && actionButton.dataset.claudeSessionId) {
|
|
7246
7340
|
handleResumeHistoryAction(actionButton);
|
|
7341
|
+
} else if (actionButton.dataset.action === "resume-codex-history" && actionButton.dataset.claudeSessionId) {
|
|
7342
|
+
handleResumeCodexHistoryAction(actionButton);
|
|
7343
|
+
} else if (actionButton.dataset.action === "delete-codex-history" && actionButton.dataset.claudeSessionId) {
|
|
7344
|
+
handleDeleteCodexHistoryAction(actionButton);
|
|
7345
|
+
} else if (actionButton.dataset.action === "toggle-codex-history-directory" && actionButton.dataset.cwd) {
|
|
7346
|
+
var codexDirCwd = actionButton.dataset.cwd;
|
|
7347
|
+
state.codexHistoryExpandedDirs[codexDirCwd] = !state.codexHistoryExpandedDirs[codexDirCwd];
|
|
7348
|
+
updateSessionsList();
|
|
7247
7349
|
} else if (actionButton.dataset.action === "worktree-merge" && actionButton.dataset.sessionId) {
|
|
7248
7350
|
openWorktreeMergeModal(actionButton.dataset.sessionId);
|
|
7249
7351
|
} else if (actionButton.dataset.action === "worktree-cleanup" && actionButton.dataset.sessionId) {
|
|
@@ -14644,6 +14746,81 @@
|
|
|
14644
14746
|
});
|
|
14645
14747
|
}
|
|
14646
14748
|
|
|
14749
|
+
function handleResumeCodexHistoryAction(actionButton) {
|
|
14750
|
+
var threadId = actionButton.dataset.claudeSessionId;
|
|
14751
|
+
var cwd = actionButton.dataset.cwd;
|
|
14752
|
+
console.log("[WAND] handleResumeCodexHistoryAction threadId:", threadId, "cwd:", cwd);
|
|
14753
|
+
if (!threadId) return;
|
|
14754
|
+
actionButton.disabled = true;
|
|
14755
|
+
resumeCodexHistorySession(threadId, cwd)
|
|
14756
|
+
.then(function(data) {
|
|
14757
|
+
if (data && data.id) {
|
|
14758
|
+
state.codexHistory = state.codexHistory.filter(function(s) {
|
|
14759
|
+
return s.claudeSessionId !== threadId;
|
|
14760
|
+
});
|
|
14761
|
+
state.selectedId = data.id;
|
|
14762
|
+
persistSelectedId();
|
|
14763
|
+
state.drafts[data.id] = "";
|
|
14764
|
+
activateSession(data).then(function() {
|
|
14765
|
+
dismissDrawerIfOverlay();
|
|
14766
|
+
});
|
|
14767
|
+
}
|
|
14768
|
+
})
|
|
14769
|
+
.finally(function() {
|
|
14770
|
+
actionButton.disabled = false;
|
|
14771
|
+
});
|
|
14772
|
+
}
|
|
14773
|
+
|
|
14774
|
+
function resumeCodexHistorySession(threadId, cwd) {
|
|
14775
|
+
return fetch("/api/codex-sessions/" + encodeURIComponent(threadId) + "/resume", {
|
|
14776
|
+
method: "POST",
|
|
14777
|
+
headers: { "Content-Type": "application/json" },
|
|
14778
|
+
credentials: "same-origin",
|
|
14779
|
+
body: JSON.stringify(withTerminalDimensions({
|
|
14780
|
+
mode: state.chatMode || (state.config && state.config.defaultMode) || "default",
|
|
14781
|
+
cwd: cwd
|
|
14782
|
+
}))
|
|
14783
|
+
})
|
|
14784
|
+
.then(function(res) { return res.json(); })
|
|
14785
|
+
.then(function(data) {
|
|
14786
|
+
if (data.error) {
|
|
14787
|
+
showToast(data.error, "error");
|
|
14788
|
+
return null;
|
|
14789
|
+
}
|
|
14790
|
+
return data;
|
|
14791
|
+
})
|
|
14792
|
+
.catch(function(error) {
|
|
14793
|
+
showToast((error && error.message) || "无法恢复历史会话。", "error");
|
|
14794
|
+
return null;
|
|
14795
|
+
});
|
|
14796
|
+
}
|
|
14797
|
+
|
|
14798
|
+
function handleDeleteCodexHistoryAction(actionButton) {
|
|
14799
|
+
var threadId = actionButton.dataset.claudeSessionId;
|
|
14800
|
+
if (!threadId) return;
|
|
14801
|
+
console.log("[WAND] handleDeleteCodexHistoryAction threadId:", threadId);
|
|
14802
|
+
var item = actionButton.closest(".claude-history-item");
|
|
14803
|
+
if (item) item.style.opacity = "0.5";
|
|
14804
|
+
fetch("/api/codex-history/" + encodeURIComponent(threadId), {
|
|
14805
|
+
method: "DELETE",
|
|
14806
|
+
credentials: "same-origin"
|
|
14807
|
+
})
|
|
14808
|
+
.then(function(res) { return res.json(); })
|
|
14809
|
+
.then(function(data) {
|
|
14810
|
+
if (data && data.ok) {
|
|
14811
|
+
state.codexHistory = state.codexHistory.filter(function(s) {
|
|
14812
|
+
return s.claudeSessionId !== threadId;
|
|
14813
|
+
});
|
|
14814
|
+
updateSessionsList();
|
|
14815
|
+
} else if (item) {
|
|
14816
|
+
item.style.opacity = "1";
|
|
14817
|
+
}
|
|
14818
|
+
})
|
|
14819
|
+
.catch(function() {
|
|
14820
|
+
if (item) item.style.opacity = "1";
|
|
14821
|
+
});
|
|
14822
|
+
}
|
|
14823
|
+
|
|
14647
14824
|
function handleResumeHistoryAction(actionButton) {
|
|
14648
14825
|
var claudeSessionId = actionButton.dataset.claudeSessionId;
|
|
14649
14826
|
var cwd = actionButton.dataset.cwd;
|
|
@@ -1351,6 +1351,14 @@
|
|
|
1351
1351
|
text-align: center;
|
|
1352
1352
|
font-style: italic;
|
|
1353
1353
|
}
|
|
1354
|
+
.sidebar-history-section-label {
|
|
1355
|
+
padding: 8px 10px 4px 10px;
|
|
1356
|
+
font-size: 0.6875rem;
|
|
1357
|
+
font-weight: 600;
|
|
1358
|
+
letter-spacing: 0.04em;
|
|
1359
|
+
text-transform: uppercase;
|
|
1360
|
+
opacity: 0.5;
|
|
1361
|
+
}
|
|
1354
1362
|
.claude-history-directory-header {
|
|
1355
1363
|
margin-top: 6px;
|
|
1356
1364
|
cursor: pointer;
|
|
@@ -15373,39 +15381,10 @@
|
|
|
15373
15381
|
letter-spacing: 0.02em;
|
|
15374
15382
|
white-space: nowrap;
|
|
15375
15383
|
}
|
|
15376
|
-
/* Switch row inside quick-commit modal — same iOS rhythm as settings */
|
|
15377
|
-
.qc-checkbox-row {
|
|
15378
|
-
display: flex;
|
|
15379
|
-
align-items: center;
|
|
15380
|
-
justify-content: space-between;
|
|
15381
|
-
gap: 12px;
|
|
15382
|
-
padding: 11px 14px;
|
|
15383
|
-
background: rgba(255, 255, 255, 0.55);
|
|
15384
|
-
border: 1px solid rgba(125, 91, 57, 0.08);
|
|
15385
|
-
border-radius: 12px;
|
|
15386
|
-
box-shadow: 0 1px 1px rgba(125, 91, 57, 0.02);
|
|
15387
|
-
transition: background 0.16s ease, border-color 0.16s ease;
|
|
15388
|
-
user-select: none;
|
|
15389
|
-
}
|
|
15390
|
-
.qc-checkbox-row:hover {
|
|
15391
|
-
background: rgba(255, 255, 255, 0.78);
|
|
15392
|
-
border-color: rgba(125, 91, 57, 0.16);
|
|
15393
|
-
}
|
|
15394
|
-
.qc-checkbox-row:has(.switch-toggle:checked) {
|
|
15395
|
-
background: linear-gradient(180deg, #fff7ef 0%, #fff1de 100%);
|
|
15396
|
-
border-color: rgba(197, 101, 61, 0.3);
|
|
15397
|
-
}
|
|
15398
|
-
.qc-checkbox-label {
|
|
15399
|
-
flex: 1;
|
|
15400
|
-
min-width: 0;
|
|
15401
|
-
font-size: 0.8125rem;
|
|
15402
|
-
font-weight: 600;
|
|
15403
|
-
color: var(--text-primary);
|
|
15404
|
-
cursor: pointer;
|
|
15405
|
-
line-height: 1.4;
|
|
15406
|
-
}
|
|
15407
15384
|
.qc-switch {
|
|
15408
15385
|
flex-shrink: 0;
|
|
15386
|
+
display: inline-flex;
|
|
15387
|
+
align-items: center;
|
|
15409
15388
|
cursor: pointer;
|
|
15410
15389
|
}
|
|
15411
15390
|
.qc-message-row,
|
|
@@ -15423,6 +15402,62 @@
|
|
|
15423
15402
|
align-items: center;
|
|
15424
15403
|
justify-content: space-between;
|
|
15425
15404
|
gap: 8px;
|
|
15405
|
+
flex-wrap: wrap;
|
|
15406
|
+
}
|
|
15407
|
+
.qc-ai-controls {
|
|
15408
|
+
display: inline-flex;
|
|
15409
|
+
align-items: center;
|
|
15410
|
+
justify-content: flex-end;
|
|
15411
|
+
gap: 7px;
|
|
15412
|
+
margin-left: auto;
|
|
15413
|
+
flex: 0 0 auto;
|
|
15414
|
+
}
|
|
15415
|
+
.qc-ai-tag-toggle {
|
|
15416
|
+
display: inline-flex;
|
|
15417
|
+
align-items: center;
|
|
15418
|
+
gap: 6px;
|
|
15419
|
+
min-height: 30px;
|
|
15420
|
+
padding: 3px 7px 3px 9px;
|
|
15421
|
+
border: 1px solid rgba(125, 91, 57, 0.12);
|
|
15422
|
+
border-radius: 999px;
|
|
15423
|
+
background: rgba(255, 255, 255, 0.58);
|
|
15424
|
+
color: var(--text-muted);
|
|
15425
|
+
font-size: 0.74rem;
|
|
15426
|
+
font-weight: 700;
|
|
15427
|
+
line-height: 1;
|
|
15428
|
+
user-select: none;
|
|
15429
|
+
cursor: pointer;
|
|
15430
|
+
transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
|
|
15431
|
+
}
|
|
15432
|
+
.qc-ai-tag-toggle:hover {
|
|
15433
|
+
background: rgba(255, 255, 255, 0.82);
|
|
15434
|
+
border-color: rgba(125, 91, 57, 0.20);
|
|
15435
|
+
}
|
|
15436
|
+
.qc-ai-tag-toggle:has(.switch-toggle:checked) {
|
|
15437
|
+
color: rgba(170, 79, 42, 0.98);
|
|
15438
|
+
background: linear-gradient(180deg, #fff8f1 0%, #fff1e3 100%);
|
|
15439
|
+
border-color: rgba(197, 101, 61, 0.32);
|
|
15440
|
+
box-shadow: 0 1px 4px rgba(197, 101, 61, 0.10);
|
|
15441
|
+
}
|
|
15442
|
+
.qc-ai-tag-toggle:has(.switch-toggle:disabled) {
|
|
15443
|
+
cursor: default;
|
|
15444
|
+
opacity: 0.68;
|
|
15445
|
+
}
|
|
15446
|
+
.qc-ai-tag-label {
|
|
15447
|
+
white-space: nowrap;
|
|
15448
|
+
}
|
|
15449
|
+
.qc-switch--compact .switch-slider {
|
|
15450
|
+
width: 34px;
|
|
15451
|
+
min-width: 34px;
|
|
15452
|
+
height: 20px;
|
|
15453
|
+
border-radius: 999px;
|
|
15454
|
+
}
|
|
15455
|
+
.qc-switch--compact .switch-slider::after {
|
|
15456
|
+
width: 16px;
|
|
15457
|
+
height: 16px;
|
|
15458
|
+
}
|
|
15459
|
+
.qc-switch--compact .switch-toggle:checked + .switch-slider::after {
|
|
15460
|
+
transform: translateX(14px);
|
|
15426
15461
|
}
|
|
15427
15462
|
.qc-message-row textarea {
|
|
15428
15463
|
resize: vertical;
|
|
@@ -15727,6 +15762,10 @@
|
|
|
15727
15762
|
.qc-files-wrap { max-height: 160px; }
|
|
15728
15763
|
.qc-section { padding: 10px 12px; }
|
|
15729
15764
|
.qc-dropdown-menu { min-width: 180px; }
|
|
15765
|
+
.qc-ai-controls {
|
|
15766
|
+
width: 100%;
|
|
15767
|
+
justify-content: flex-end;
|
|
15768
|
+
}
|
|
15730
15769
|
}
|
|
15731
15770
|
|
|
15732
15771
|
/* ============================================================ */
|