@zq-silk/yui 1.0.1 → 1.1.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/README.md +27 -9
- package/dist/cli/commandCatalog.js +7 -5
- package/dist/cli/invocationAuthority.js +4 -0
- package/dist/cli.js +130 -5
- package/dist/commands/releaseCommands.js +33 -24
- package/dist/commands/taskCommands.js +16 -25
- package/dist/commands/taskFactCommands.js +6 -2
- package/dist/controller/clientRuntime.js +7 -2
- package/dist/controller/domainIdentity.js +7 -7
- package/dist/controller/handoverCandidate.js +2 -5
- package/dist/controller/jobSupervisor.js +4 -4
- package/dist/controller/resourceCleanupLinux.js +35 -19
- package/dist/controller/resourceInventoryLinux.js +104 -7
- package/dist/controller/runtime.js +4 -13
- package/dist/controller/runtimeLaunchCoordinator.js +2 -4
- package/dist/controller/sessionOwnerReconciliation.js +40 -16
- package/dist/core/controllerClient.js +44 -16
- package/dist/core/controllerEndpoint.js +4 -3
- package/dist/core/controllerProcessIdentity.js +54 -0
- package/dist/core/controllerServer.js +16 -11
- package/dist/core/fileLockOwner.js +53 -12
- package/dist/doctor/doctor.js +230 -12
- package/dist/execution/workItemMainRun.js +1 -1
- package/dist/executor/agentAdapter.js +0 -8
- package/dist/executor/agentExecutor.js +4 -1
- package/dist/executor/fileRoleLaunchPlanner.js +3 -22
- package/dist/message/messageContinuation.js +1 -1
- package/dist/release/releaseHandover.js +77 -25
- package/dist/release/runtimeRelease.js +7 -7
- package/dist/repository/gitWorkspace.js +9 -8
- package/dist/repository/taskWorkspacePreparer.js +15 -57
- package/dist/resources/liveReferences.js +64 -7
- package/dist/runtime/agentEndpointIdentity.js +4 -1
- package/dist/runtime/agentHost.js +8 -9
- package/dist/runtime/codexInteractiveHost.js +1 -1
- package/dist/runtime/codexSharedDaemon.js +211 -0
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/jsonLineChannel.js +6 -1
- package/dist/runtime/localStartup.js +65 -0
- package/dist/runtime/native/darwin-arm64/claude-process-owner +0 -0
- package/dist/runtime/native/darwin-arm64/process-identity +0 -0
- package/dist/runtime/native/darwin-x64/claude-process-owner +0 -0
- package/dist/runtime/native/darwin-x64/process-identity +0 -0
- package/dist/runtime/{claude-process-owner → native/linux-x64/claude-process-owner} +0 -0
- package/dist/runtime/nativeExecutable.js +10 -0
- package/dist/runtime/runtimeCoherence.js +10 -3
- package/dist/runtime/sessionOwnerIdentity.js +19 -0
- package/dist/runtime/sessionTerminationGuard.js +10 -3
- package/dist/runtime/structuredProviderHost.js +51 -26
- package/dist/scheduler/activeRoleRunDelivery.js +2 -11
- package/dist/scheduler/leaderWakeupProcessor.js +1 -2
- package/dist/scheduler/ports.js +1 -8
- package/dist/scheduler/taskExecutionProjection.js +1 -6
- package/dist/storage/sqliteSchema.js +15 -3
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/taskCatalog.js +10 -2
- package/dist/storage/upgrade/taskMainWorkspaceMigration.js +83 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +25 -6
- package/dist/task/task.js +0 -12
- package/dist/tmux/tmuxManager.js +8 -2
- package/dist/tmux/tmuxSocketEndpoint.js +23 -1
- package/dist/web/assets/client/app.js +131 -11
- package/dist/web/assets/client/i18n.js +18 -0
- package/dist/web/assets/client/taskSummary.js +7 -2
- package/dist/web/assets/client/taskSurface.js +54 -5
- package/dist/web/assets/client/view.js +7 -2
- package/dist/web/assets/shell.js +21 -11
- package/dist/web/assets/styles/layout.js +33 -6
- package/dist/web/assets/styles/responsive.js +18 -5
- package/dist/web/assets/styles/tokens.js +1 -1
- package/dist/web/assets/styles/widgets.js +5 -0
- package/docs/provider-runtime.md +7 -0
- package/docs/provider-runtime.zh-CN.md +5 -0
- package/docs/release-workflow.md +7 -1
- package/docs/task-delivery.md +5 -4
- package/docs/testing/verification-levels.md +9 -7
- package/i18n/README.zh-CN.md +19 -6
- package/package.json +5 -6
- package/skills/yui-leader/SKILL.md +8 -0
- package/skills/yui-leader/references/execution.md +4 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import Database from "better-sqlite3";
|
|
3
3
|
import { BASELINE_SCHEMA_SQL, SQLITE_SCHEMA_TABLES } from "./baselineSchema.js";
|
|
4
|
+
import { migrateTaskMainWorkspaces } from "./upgrade/taskMainWorkspaceMigration.js";
|
|
4
5
|
import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION, STORAGE_FORMAT, isMinorStorageUpgrade, isStorageVersion, storageVersionParts } from "./storageVersions.js";
|
|
5
6
|
export { BASELINE_SCHEMA_SQL, SQLITE_SCHEMA_TABLES };
|
|
6
7
|
export const TELEMETRY_KEEP_PER_RUN = 200;
|
|
@@ -14,8 +15,16 @@ export class SqliteSchemaError extends Error {
|
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
17
|
// Only explicit, contiguous minor changes in this baseline may be added here.
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
const MINOR_UPGRADES = Object.freeze([{
|
|
19
|
+
fromVersion: "1.0",
|
|
20
|
+
toVersion: "1.1",
|
|
21
|
+
name: "task-main-workspace",
|
|
22
|
+
introducedIn: "1.1.0",
|
|
23
|
+
sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
24
|
+
targetChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
25
|
+
sql: "",
|
|
26
|
+
dataMigration: "task-main-workspace"
|
|
27
|
+
}]);
|
|
19
28
|
export function storageMinorUpgradePlan(from) {
|
|
20
29
|
if (from === CURRENT_STORAGE_VERSION)
|
|
21
30
|
return [];
|
|
@@ -109,7 +118,7 @@ export function initializeSqliteSchema(db) {
|
|
|
109
118
|
})();
|
|
110
119
|
}
|
|
111
120
|
/** Called only after an explicit minor upgrade has obtained its maintenance fence. */
|
|
112
|
-
export function applySqliteMinorUpgrades(db) {
|
|
121
|
+
export function applySqliteMinorUpgrades(db, home, createdRoots = []) {
|
|
113
122
|
db.transaction(() => {
|
|
114
123
|
const before = inspectSqliteSchema(db);
|
|
115
124
|
const plan = storageMinorUpgradePlan(before.currentVersion);
|
|
@@ -121,6 +130,9 @@ export function applySqliteMinorUpgrades(db) {
|
|
|
121
130
|
throw new SqliteSchemaError("minor upgrade source changed.");
|
|
122
131
|
}
|
|
123
132
|
db.exec(step.sql);
|
|
133
|
+
if (step.dataMigration === "task-main-workspace") {
|
|
134
|
+
migrateTaskMainWorkspaces(db, home, createdRoots);
|
|
135
|
+
}
|
|
124
136
|
const { major, minor } = storageVersionParts(step.toVersion);
|
|
125
137
|
db.prepare("UPDATE storage_schema SET major=?,minor=?,checksum=? WHERE id=1")
|
|
126
138
|
.run(major, minor, step.targetChecksum);
|
|
@@ -57,6 +57,14 @@ export function queryTaskCatalog(db, query) {
|
|
|
57
57
|
after.push("(createdAt, id) > (@afterDate, @afterId)");
|
|
58
58
|
}
|
|
59
59
|
const count = (table, condition) => `(SELECT count(*) FROM ${table} x WHERE x.task_id = c.task_id AND (${condition}))`;
|
|
60
|
+
const displaySummary = `CASE
|
|
61
|
+
WHEN page.status IN ('completed','archived')
|
|
62
|
+
AND json_extract(r.payload,'$.completionSummary') IS NOT NULL
|
|
63
|
+
THEN json_extract(r.payload,'$.completionSummary')
|
|
64
|
+
WHEN page.status IN ('cancelled','archived')
|
|
65
|
+
AND json_extract(r.payload,'$.retirementSummary') IS NOT NULL
|
|
66
|
+
THEN json_extract(r.payload,'$.retirementSummary')
|
|
67
|
+
ELSE json_extract(r.brief,'$.leaderSummary') END`;
|
|
60
68
|
// Execution signals deliberately preserve raw inspectable conditions:
|
|
61
69
|
// live runs (including unknown admission/identity/stall), unresolved work,
|
|
62
70
|
// integrations, and pending Leader recovery. They are NOT a second
|
|
@@ -94,8 +102,8 @@ export function queryTaskCatalog(db, query) {
|
|
|
94
102
|
(SELECT json_group_array(json_object(
|
|
95
103
|
'id',page.id,'createdAt',page.createdAt,'updatedAt',page.updatedAt,'status',page.status,
|
|
96
104
|
'title',substr(json_extract(r.payload,'$.title'),1,256),
|
|
97
|
-
'summary',substr(
|
|
98
|
-
'summaryPresent',json(CASE WHEN
|
|
105
|
+
'summary',substr(${displaySummary},1,512),
|
|
106
|
+
'summaryPresent',json(CASE WHEN ${displaySummary} IS NULL THEN 'false' ELSE 'true' END),
|
|
99
107
|
'workItems',page.workItems,'activeRuns',page.activeRuns,
|
|
100
108
|
'openInputs',page.openInputs,'pendingOperations',page.pendingOperations,
|
|
101
109
|
'unknownOperations',page.unknownOperations,'executionSignals',page.executionSignals)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { updateRole, validateTaskRole } from "../../role/role.js";
|
|
4
|
+
import { validateRoleSessionSet } from "../../executor/agentExecutor.js";
|
|
5
|
+
import { validateTask } from "../../task/task.js";
|
|
6
|
+
import { createManagedWorkspace } from "../../worktree/managedWorkspace.js";
|
|
7
|
+
import { managedTaskRoot } from "../homeLayout.js";
|
|
8
|
+
/** 1.0 -> 1.1: give old workspace-free Tasks the same main owner as new activations. */
|
|
9
|
+
export function migrateTaskMainWorkspaces(db, home, createdRoots) {
|
|
10
|
+
const candidates = db.prepare("SELECT task_id, payload FROM task_records ORDER BY task_id").all()
|
|
11
|
+
.map(row => validateTask(JSON.parse(row.payload)))
|
|
12
|
+
.filter(task => task.cwd === undefined
|
|
13
|
+
&& (task.status === "active"
|
|
14
|
+
|| (task.status === "completed" && task.projectBindings.length === 0)));
|
|
15
|
+
const taskRoot = managedTaskRoot(home);
|
|
16
|
+
for (const task of candidates) {
|
|
17
|
+
if (task.workspaceIdentity !== undefined
|
|
18
|
+
|| db.prepare("SELECT 1 FROM managed_workspaces WHERE owner_kind='task' AND owner_id=?")
|
|
19
|
+
.get(`task:${task.id}`) !== undefined) {
|
|
20
|
+
throw new Error(`Task ${task.id} has conflicting workspace state; preserve it for diagnosis.`);
|
|
21
|
+
}
|
|
22
|
+
if (db.prepare("SELECT 1 FROM active_turns WHERE task_id=? LIMIT 1").get(task.id) !== undefined) {
|
|
23
|
+
throw new Error(`Task ${task.id} has an active Turn; settle it before workspace migration.`);
|
|
24
|
+
}
|
|
25
|
+
for (const row of db.prepare("SELECT payload FROM role_session_sets WHERE task_id=?")
|
|
26
|
+
.all(task.id)) {
|
|
27
|
+
const set = validateRoleSessionSet(JSON.parse(row.payload));
|
|
28
|
+
if (Object.values(set.sessions).some(session => session.status !== "ended")
|
|
29
|
+
|| set.providerBinding !== null) {
|
|
30
|
+
throw new Error(`Task ${task.id} has a native Session or unsettled Provider binding; stop it before workspace migration.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const root = join(taskRoot, task.id, "main");
|
|
34
|
+
if (existsSync(root)) {
|
|
35
|
+
throw new Error(`Task ${task.id} has an unowned main directory: ${root}.`);
|
|
36
|
+
}
|
|
37
|
+
// Existing Task directories may contain independently owned WorkItems.
|
|
38
|
+
// Refuse symbolic ancestors before creating this Task's new main directory.
|
|
39
|
+
for (let path = dirname(root); path !== dirname(resolve(home)); path = dirname(path)) {
|
|
40
|
+
if (existsSync(path) && lstatSync(path).isSymbolicLink()) {
|
|
41
|
+
throw new Error(`Task ${task.id} workspace ancestor is a symlink: ${path}.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const task of candidates) {
|
|
46
|
+
const root = join(taskRoot, task.id, "main");
|
|
47
|
+
const missing = [];
|
|
48
|
+
for (let path = root; !existsSync(path); path = dirname(path))
|
|
49
|
+
missing.push(path);
|
|
50
|
+
for (const path of missing.reverse()) {
|
|
51
|
+
mkdirSync(path, { mode: 0o700 });
|
|
52
|
+
createdRoots.push(path);
|
|
53
|
+
}
|
|
54
|
+
if (realpathSync(root) !== root) {
|
|
55
|
+
throw new Error(`Task ${task.id} main workspace is not a physical directory: ${root}.`);
|
|
56
|
+
}
|
|
57
|
+
const timestamp = new Date(Math.max(Date.now(), Date.parse(task.updatedAt))).toISOString();
|
|
58
|
+
const workspace = createManagedWorkspace({
|
|
59
|
+
owner: { type: "task", taskId: task.id }, root, entries: []
|
|
60
|
+
}, new Date(timestamp));
|
|
61
|
+
db.prepare(`INSERT INTO managed_workspaces
|
|
62
|
+
(owner_kind,owner_id,task_id,path,payload,status,created_at,updated_at)
|
|
63
|
+
VALUES ('task',?,?,?,?,?,?,?)`)
|
|
64
|
+
.run(`task:${task.id}`, task.id, root, JSON.stringify(workspace), "active", timestamp, timestamp);
|
|
65
|
+
const updatedTask = validateTask({ ...task, cwd: root, updatedAt: timestamp });
|
|
66
|
+
db.prepare("UPDATE task_records SET payload=?,updated_at=? WHERE task_id=?")
|
|
67
|
+
.run(JSON.stringify(updatedTask), timestamp, task.id);
|
|
68
|
+
db.prepare("UPDATE tasks_catalog SET updated_at=? WHERE task_id=?")
|
|
69
|
+
.run(timestamp, task.id);
|
|
70
|
+
for (const row of db.prepare("SELECT role_name,payload FROM task_roles WHERE task_id=?")
|
|
71
|
+
.all(task.id)) {
|
|
72
|
+
const role = validateTaskRole(JSON.parse(row.payload));
|
|
73
|
+
const isolated = db.prepare(`SELECT 1 FROM managed_workspaces
|
|
74
|
+
WHERE task_id=? AND owner_kind!='task' AND path=? LIMIT 1`)
|
|
75
|
+
.get(task.id, role.workspace);
|
|
76
|
+
if (isolated !== undefined)
|
|
77
|
+
continue;
|
|
78
|
+
const updatedRole = updateRole(role, { workspace: root }, new Date(timestamp));
|
|
79
|
+
db.prepare("UPDATE task_roles SET payload=?,updated_at=? WHERE task_id=? AND role_name=?")
|
|
80
|
+
.run(JSON.stringify(updatedRole), timestamp, task.id, row.role_name);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, rmdirSync, rmSync } from "node:fs";
|
|
3
3
|
import Database from "better-sqlite3";
|
|
4
4
|
import { RUNTIME_OBSERVATION_TASK_EVENT, createRuntimeObservation } from "../../runtime/runtimeObservation.js";
|
|
5
5
|
import { RUNTIME_PROCESS_EXIT_TASK_EVENT, validateRuntimeProcessExitObservation } from "../../runtime/processExitObservation.js";
|
|
@@ -113,6 +113,7 @@ export async function runStorageUpgrade(options) {
|
|
|
113
113
|
};
|
|
114
114
|
}
|
|
115
115
|
let migrationCommitted = false;
|
|
116
|
+
const createdWorkspaceDirectories = [];
|
|
116
117
|
try {
|
|
117
118
|
const database = new Database(join(options.home, CURRENT_DATABASE_FILENAME));
|
|
118
119
|
try {
|
|
@@ -120,7 +121,7 @@ export async function runStorageUpgrade(options) {
|
|
|
120
121
|
database.pragma("synchronous = FULL");
|
|
121
122
|
database.pragma("foreign_keys = ON");
|
|
122
123
|
database.pragma("busy_timeout = 5000");
|
|
123
|
-
applySqliteMinorUpgrades(database);
|
|
124
|
+
applySqliteMinorUpgrades(database, options.home, createdWorkspaceDirectories);
|
|
124
125
|
migrationCommitted = true;
|
|
125
126
|
}
|
|
126
127
|
finally {
|
|
@@ -132,10 +133,19 @@ export async function runStorageUpgrade(options) {
|
|
|
132
133
|
const restoration = migrationCommitted
|
|
133
134
|
? tryRestoreDatabaseBackup(options.home, backupPath)
|
|
134
135
|
: { restored: true };
|
|
136
|
+
let directoryCleanupError;
|
|
137
|
+
try {
|
|
138
|
+
for (const path of createdWorkspaceDirectories.reverse())
|
|
139
|
+
rmdirSync(path);
|
|
140
|
+
}
|
|
141
|
+
catch (cleanupError) {
|
|
142
|
+
directoryCleanupError = messageOf(cleanupError);
|
|
143
|
+
}
|
|
135
144
|
return {
|
|
136
145
|
outcome: "failed",
|
|
137
146
|
stage: "migration",
|
|
138
|
-
message: `Storage migration failed: ${messageOf(error)}
|
|
147
|
+
message: `Storage migration failed: ${messageOf(error)}`
|
|
148
|
+
+ (directoryCleanupError === undefined ? "" : ` Directory cleanup failed: ${directoryCleanupError}`),
|
|
139
149
|
action: restoration.restored
|
|
140
150
|
? "The original database was restored from the timestamped backup. "
|
|
141
151
|
+ "Resolve the reported problem and rerun `yui upgrade`."
|
|
@@ -143,16 +153,25 @@ export async function runStorageUpgrade(options) {
|
|
|
143
153
|
+ `${backupPath} manually before retrying. Restore error: ${restoration.error}`,
|
|
144
154
|
backupPath,
|
|
145
155
|
classification,
|
|
146
|
-
sceneUnchanged: restoration.restored
|
|
156
|
+
sceneUnchanged: restoration.restored && directoryCleanupError === undefined
|
|
147
157
|
};
|
|
148
158
|
}
|
|
149
159
|
const finalState = inspectStorageSchema(options.home);
|
|
150
160
|
if (finalState.status !== "current") {
|
|
151
161
|
const restoration = tryRestoreDatabaseBackup(options.home, backupPath);
|
|
162
|
+
let directoryCleanupError;
|
|
163
|
+
try {
|
|
164
|
+
for (const path of createdWorkspaceDirectories.reverse())
|
|
165
|
+
rmdirSync(path);
|
|
166
|
+
}
|
|
167
|
+
catch (cleanupError) {
|
|
168
|
+
directoryCleanupError = messageOf(cleanupError);
|
|
169
|
+
}
|
|
152
170
|
return {
|
|
153
171
|
outcome: "failed",
|
|
154
172
|
stage: "migration",
|
|
155
|
-
message: `Storage migration did not reach version ${CURRENT_STORAGE_VERSION}
|
|
173
|
+
message: `Storage migration did not reach version ${CURRENT_STORAGE_VERSION}.`
|
|
174
|
+
+ (directoryCleanupError === undefined ? "" : ` Directory cleanup failed: ${directoryCleanupError}`),
|
|
156
175
|
action: restoration.restored
|
|
157
176
|
? "The original database was restored from the timestamped backup. "
|
|
158
177
|
+ "Inspect the migration registry before retrying."
|
|
@@ -160,7 +179,7 @@ export async function runStorageUpgrade(options) {
|
|
|
160
179
|
+ `${backupPath} manually before retrying. Restore error: ${restoration.error}`,
|
|
161
180
|
backupPath,
|
|
162
181
|
classification,
|
|
163
|
-
sceneUnchanged: restoration.restored
|
|
182
|
+
sceneUnchanged: restoration.restored && directoryCleanupError === undefined
|
|
164
183
|
};
|
|
165
184
|
}
|
|
166
185
|
return {
|
package/dist/task/task.js
CHANGED
|
@@ -370,18 +370,6 @@ export function isTaskArchived(task) {
|
|
|
370
370
|
export function isTaskExecutionEnabled(task) {
|
|
371
371
|
return task.executionGate.state === "enabled";
|
|
372
372
|
}
|
|
373
|
-
/**
|
|
374
|
-
* Whether this Task owns a managed Git workspace at all.
|
|
375
|
-
*
|
|
376
|
-
* A Task activated with an empty environment plan legitimately owns none: it
|
|
377
|
-
* binds no Project and adopted no directory, so there is no worktree to be
|
|
378
|
-
* ready and no cwd to verify. Workspace fences use this to distinguish "not
|
|
379
|
-
* prepared yet" from "correctly owns nothing", instead of creating a workspace
|
|
380
|
-
* only to satisfy the fence.
|
|
381
|
-
*/
|
|
382
|
-
export function taskOwnsManagedWorkspace(task) {
|
|
383
|
-
return task.projectBindings.length > 0 || task.cwd !== undefined;
|
|
384
|
-
}
|
|
385
373
|
export function stopTaskExecution(task, now) {
|
|
386
374
|
validateTask(task);
|
|
387
375
|
if (task.status !== "active" && task.status !== "draft") {
|
package/dist/tmux/tmuxManager.js
CHANGED
|
@@ -4,6 +4,7 @@ import { runtimeError } from "../errors/cliError.js";
|
|
|
4
4
|
import { usableInteractiveTerminal } from "../output/terminal.js";
|
|
5
5
|
import { handoffTerminal } from "./terminalHandoff.js";
|
|
6
6
|
import { CommandExecutionError } from "./commandExecutor.js";
|
|
7
|
+
import { tmuxInvocationEnvironment } from "./tmuxSocketEndpoint.js";
|
|
7
8
|
const DEFAULT_READINESS_TIMEOUT_MS = 15_000;
|
|
8
9
|
const DEFAULT_READINESS_POLL_MS = 50;
|
|
9
10
|
const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
|
|
@@ -1082,13 +1083,18 @@ export class TmuxManager {
|
|
|
1082
1083
|
run(args, options) {
|
|
1083
1084
|
const bounded = options?.inheritStdio === true
|
|
1084
1085
|
? options
|
|
1085
|
-
: {
|
|
1086
|
+
: {
|
|
1087
|
+
...options,
|
|
1088
|
+
timeoutMs: options?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
|
1089
|
+
environment: tmuxInvocationEnvironment({ ...process.env, ...options?.environment })
|
|
1090
|
+
};
|
|
1086
1091
|
return this.executor.run(this.tmuxBin, ["-L", this.#serverName, ...args], bounded);
|
|
1087
1092
|
}
|
|
1088
1093
|
runAsync(args, options) {
|
|
1089
1094
|
const bounded = {
|
|
1090
1095
|
...options,
|
|
1091
|
-
timeoutMs: options?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
|
|
1096
|
+
timeoutMs: options?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
|
1097
|
+
environment: tmuxInvocationEnvironment({ ...process.env, ...options?.environment })
|
|
1092
1098
|
};
|
|
1093
1099
|
if (this.executor.runAsync !== undefined) {
|
|
1094
1100
|
return this.executor.runAsync(this.tmuxBin, ["-L", this.#serverName, ...args], bounded);
|
|
@@ -11,9 +11,31 @@ export function tmuxSocketDirectory(environment = process.env) {
|
|
|
11
11
|
export function tmuxSocketEnvironment(environment = process.env) {
|
|
12
12
|
return {
|
|
13
13
|
...environment,
|
|
14
|
-
TMUX_TMPDIR: nonEmptyTmuxTmpdir(environment)
|
|
14
|
+
TMUX_TMPDIR: nonEmptyTmuxTmpdir(environment),
|
|
15
|
+
...tmuxLocaleEnvironment(environment)
|
|
15
16
|
};
|
|
16
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* tmux 3.6+ substitutes `_` for non-printable bytes (such as the 0x1f field
|
|
20
|
+
* separator in -F output) when it starts in the C/POSIX locale. Guarantee a
|
|
21
|
+
* UTF-8 capable locale for tmux invocations while leaving an explicit UTF-8
|
|
22
|
+
* user locale untouched. macOS always provides the portable "UTF-8" locale;
|
|
23
|
+
* glibc systems provide C.UTF-8.
|
|
24
|
+
*/
|
|
25
|
+
export function tmuxLocaleEnvironment(environment = process.env) {
|
|
26
|
+
const effectiveLocale = environment.LC_ALL ?? environment.LC_CTYPE ?? environment.LANG;
|
|
27
|
+
if (typeof effectiveLocale === "string" && /utf-?8/iu.test(effectiveLocale)) {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
// LC_ALL wins over LANG/LC_CTYPE, so it also repairs an explicit C value.
|
|
32
|
+
LC_ALL: process.platform === "darwin" ? "UTF-8" : "C.UTF-8"
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Environment for tmux lifecycle/inspection calls that do not pin a socket root. */
|
|
36
|
+
export function tmuxInvocationEnvironment(environment = process.env) {
|
|
37
|
+
return { ...environment, ...tmuxLocaleEnvironment(environment) };
|
|
38
|
+
}
|
|
17
39
|
function nonEmptyTmuxTmpdir(environment) {
|
|
18
40
|
const configured = environment.TMUX_TMPDIR;
|
|
19
41
|
return configured !== undefined && configured.length > 0 ? configured : undefined;
|
|
@@ -30,6 +30,12 @@ const elements = {
|
|
|
30
30
|
topbar: document.querySelector(".topbar"),
|
|
31
31
|
detailBack: document.querySelector("#detail-back"),
|
|
32
32
|
detailTabs: document.querySelector("#detail-tabs"),
|
|
33
|
+
conversationToggle: document.querySelector("#conversation-toggle"),
|
|
34
|
+
conversationPanel: document.querySelector("#conversation-panel"),
|
|
35
|
+
conversationContent: document.querySelector("#conversation-content"),
|
|
36
|
+
conversationClose: document.querySelector("#conversation-close"),
|
|
37
|
+
conversationSwap: document.querySelector("#conversation-swap"),
|
|
38
|
+
paneDivider: document.querySelector("#pane-divider"),
|
|
33
39
|
pageTitle: document.querySelector("#page-title"),
|
|
34
40
|
toast: document.querySelector("#toast"),
|
|
35
41
|
lastSync: document.querySelector("#last-sync"),
|
|
@@ -68,6 +74,10 @@ const VALID_ATTENTION = ["openInputs", "pendingOperations", "unknownOperations",
|
|
|
68
74
|
let terminalSession = null;
|
|
69
75
|
let terminalStateKey = "terminal.closed";
|
|
70
76
|
const submittedRequests = new Set();
|
|
77
|
+
let conversationVisible = true;
|
|
78
|
+
let paneWidth = Number(localStorage.getItem("yui.conversation.width")) || 400;
|
|
79
|
+
const paneSide = localStorage.getItem("yui.conversation.side") === "left" ? "left" : "right";
|
|
80
|
+
document.body.classList.toggle("pane-left", paneSide === "left");
|
|
71
81
|
|
|
72
82
|
const i18n = createI18n(elements.locale);
|
|
73
83
|
createThemeController(elements.theme);
|
|
@@ -146,6 +156,7 @@ function syncUrlFromState(options) {
|
|
|
146
156
|
|
|
147
157
|
function detailActions() {
|
|
148
158
|
return {
|
|
159
|
+
conversationHost: elements.conversationContent,
|
|
149
160
|
answerInput: answerInput,
|
|
150
161
|
openTerminal: openTerminal,
|
|
151
162
|
inspect: inspectRecord,
|
|
@@ -190,6 +201,33 @@ function setDetailActive(active) {
|
|
|
190
201
|
elements.pageTitle.dataset.i18n = "page.title";
|
|
191
202
|
}
|
|
192
203
|
}
|
|
204
|
+
updateConversationLayout();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function maxPaneWidth() {
|
|
208
|
+
return Math.max(280, Math.min(640,
|
|
209
|
+
window.innerWidth - elements.tasks.closest(".sidebar").offsetWidth - 460 - 8));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function setPaneWidth(width, persist, keepPreference) {
|
|
213
|
+
const effectiveWidth = Math.max(280, Math.min(maxPaneWidth(), width));
|
|
214
|
+
if (!keepPreference) paneWidth = effectiveWidth;
|
|
215
|
+
document.documentElement.style.setProperty("--pane-w", effectiveWidth + "px");
|
|
216
|
+
elements.paneDivider.setAttribute("aria-valuemax", String(maxPaneWidth()));
|
|
217
|
+
elements.paneDivider.setAttribute("aria-valuenow", String(effectiveWidth));
|
|
218
|
+
if (persist) localStorage.setItem("yui.conversation.width", String(effectiveWidth));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function updateConversationLayout() {
|
|
222
|
+
const visible = !!state.selected && conversationVisible && !terminalPanelOpen();
|
|
223
|
+
document.body.classList.toggle("conversation-active", visible);
|
|
224
|
+
elements.conversationPanel.hidden = !visible;
|
|
225
|
+
elements.paneDivider.hidden = !visible;
|
|
226
|
+
elements.conversationToggle.hidden = !state.selected || terminalPanelOpen();
|
|
227
|
+
elements.conversationToggle.setAttribute("aria-expanded", String(visible));
|
|
228
|
+
elements.conversationToggle.textContent = i18n.t(visible ? "conversation.hide" : "conversation.show");
|
|
229
|
+
elements.conversationToggle.setAttribute("aria-label", elements.conversationToggle.textContent);
|
|
230
|
+
setPaneWidth(paneWidth, false, true);
|
|
193
231
|
}
|
|
194
232
|
|
|
195
233
|
function showOverview() {
|
|
@@ -237,12 +275,17 @@ function renderCurrentDetail(force) {
|
|
|
237
275
|
if (elements.detail.dataset.taskId === state.detail.task.id
|
|
238
276
|
&& (elements.detail.querySelector('[data-unsent="true"]')
|
|
239
277
|
|| elements.detail.querySelector('[data-reading="true"]')
|
|
240
|
-
|| elements.detail.contains(document.activeElement)
|
|
278
|
+
|| elements.detail.contains(document.activeElement)
|
|
279
|
+
|| elements.conversationContent.querySelector('[data-unsent="true"]')
|
|
280
|
+
|| elements.conversationContent.contains(document.activeElement))) return;
|
|
241
281
|
const key = i18n.getLocale() + "|" + state.detailKey
|
|
242
282
|
+ "|" + runtimeSignatureOf(state.detail);
|
|
243
283
|
if (!force && key === renderedDetailKey) return;
|
|
244
284
|
const openSections = Array.from(elements.detail.querySelectorAll("details[data-view-key][open]"))
|
|
245
285
|
.map(element => element.dataset.viewKey);
|
|
286
|
+
const oldFeed = elements.conversationContent.querySelector(".conversation-feed");
|
|
287
|
+
const feedScroll = oldFeed ? oldFeed.scrollTop : 0;
|
|
288
|
+
const atFeedEnd = oldFeed && oldFeed.scrollHeight - oldFeed.clientHeight - feedScroll < 32;
|
|
246
289
|
renderTaskDetail(
|
|
247
290
|
elements.detail,
|
|
248
291
|
state.detail,
|
|
@@ -253,6 +296,8 @@ function renderCurrentDetail(force) {
|
|
|
253
296
|
elements.detail.querySelectorAll("details[data-view-key]").forEach(element => {
|
|
254
297
|
element.open = openSections.includes(element.dataset.viewKey);
|
|
255
298
|
});
|
|
299
|
+
const newFeed = elements.conversationContent.querySelector(".conversation-feed");
|
|
300
|
+
if (newFeed) newFeed.scrollTop = atFeedEnd ? newFeed.scrollHeight : feedScroll;
|
|
256
301
|
renderedDetailKey = key;
|
|
257
302
|
} else if (!state.selected) {
|
|
258
303
|
renderedDetailKey = null;
|
|
@@ -342,6 +387,7 @@ function clearSelection() {
|
|
|
342
387
|
state.selected = null;
|
|
343
388
|
state.detail = null;
|
|
344
389
|
setDetailActive(false);
|
|
390
|
+
elements.conversationContent.replaceChildren();
|
|
345
391
|
syncUrlFromState({ replace: !urlTaskId() });
|
|
346
392
|
showOverview();
|
|
347
393
|
const savedTaskScroll = elements.tasks.scrollTop;
|
|
@@ -386,12 +432,16 @@ async function submitMutation(key, path, body) {
|
|
|
386
432
|
}
|
|
387
433
|
}
|
|
388
434
|
|
|
389
|
-
async function loadTaskDetail(taskId,
|
|
390
|
-
|
|
435
|
+
async function loadTaskDetail(taskId, navigate) {
|
|
436
|
+
// navigate marks an explicit user navigation (task selection, deep link,
|
|
437
|
+
// back/forward). Only a navigation may move the viewport; the 5s background
|
|
438
|
+
// refresh (navigate=false) preserves the current scroll, so a quiet tick
|
|
439
|
+
// never yanks the page back to the URL section.
|
|
440
|
+
if (navigate) {
|
|
391
441
|
renderLoading(elements.detail, i18n.t, "loading.detail");
|
|
392
442
|
elements.mainCol.scrollTop = 0;
|
|
393
443
|
}
|
|
394
|
-
const savedScrollTop =
|
|
444
|
+
const savedScrollTop = elements.mainCol.scrollTop;
|
|
395
445
|
const base = "/api/tasks/" + encodeURIComponent(taskId);
|
|
396
446
|
// Rendering consumes the current snapshot, not event pages. Reconnect uses
|
|
397
447
|
// this same read; the independent delta API retains its fixed-bound contract.
|
|
@@ -428,11 +478,17 @@ async function loadTaskDetail(taskId, showLoading) {
|
|
|
428
478
|
// Reveal the tab bar before measuring/scroll so anchors land correctly.
|
|
429
479
|
setDetailActive(true);
|
|
430
480
|
updateStickyOffsets();
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (
|
|
435
|
-
|
|
481
|
+
// Only an explicit navigation may reposition the viewport. On navigation a
|
|
482
|
+
// section in the URL wins over the top; a background refresh keeps the reader
|
|
483
|
+
// where they are so the page does not jump on every poll.
|
|
484
|
+
if (navigate) {
|
|
485
|
+
const section = readQuery().get("section");
|
|
486
|
+
const anchor = section ? elements.detail.querySelector("#detail-" + section) : null;
|
|
487
|
+
if (anchor) {
|
|
488
|
+
anchor.scrollIntoView({ block: "start" });
|
|
489
|
+
} else {
|
|
490
|
+
elements.mainCol.scrollTop = savedScrollTop;
|
|
491
|
+
}
|
|
436
492
|
} else {
|
|
437
493
|
elements.mainCol.scrollTop = savedScrollTop;
|
|
438
494
|
}
|
|
@@ -477,6 +533,8 @@ async function selectTask(taskId) {
|
|
|
477
533
|
}
|
|
478
534
|
state.selected = taskId;
|
|
479
535
|
state.detail = null;
|
|
536
|
+
conversationVisible = true;
|
|
537
|
+
elements.conversationContent.replaceChildren();
|
|
480
538
|
renderedDetailKey = null;
|
|
481
539
|
// When the selection is driven by the URL (initial load or popstate), the
|
|
482
540
|
// URL already reflects the task id, so replace instead of pushing a
|
|
@@ -496,12 +554,14 @@ async function selectTask(taskId) {
|
|
|
496
554
|
}
|
|
497
555
|
|
|
498
556
|
function canLeaveDetail() {
|
|
499
|
-
return !elements.detail.querySelector('[data-unsent="true"]')
|
|
557
|
+
return !(elements.detail.querySelector('[data-unsent="true"]')
|
|
558
|
+
|| elements.conversationContent.querySelector('[data-unsent="true"]')) || window.confirm(
|
|
500
559
|
i18n.getLocale().startsWith("zh") ? "此任务有未提交或结果尚不确定的输入。仍要离开?"
|
|
501
560
|
: "This Task has unsent input or an unresolved submission. Leave anyway?");
|
|
502
561
|
}
|
|
503
562
|
window.addEventListener("beforeunload", event => {
|
|
504
|
-
if (!elements.detail.querySelector('[data-unsent="true"]')
|
|
563
|
+
if (!elements.detail.querySelector('[data-unsent="true"]')
|
|
564
|
+
&& !elements.conversationContent.querySelector('[data-unsent="true"]')) return;
|
|
505
565
|
event.preventDefault();
|
|
506
566
|
event.returnValue = "";
|
|
507
567
|
});
|
|
@@ -621,6 +681,7 @@ function openTerminalPanel() {
|
|
|
621
681
|
elements.terminalPanel.hidden = false;
|
|
622
682
|
elements.terminalPanel.setAttribute("aria-hidden", "false");
|
|
623
683
|
document.body.classList.add("terminal-active");
|
|
684
|
+
updateConversationLayout();
|
|
624
685
|
}
|
|
625
686
|
|
|
626
687
|
function closeTerminalPanel() {
|
|
@@ -628,6 +689,7 @@ function closeTerminalPanel() {
|
|
|
628
689
|
document.body.classList.remove("terminal-active");
|
|
629
690
|
elements.terminalPanel.setAttribute("aria-hidden", "true");
|
|
630
691
|
elements.terminalPanel.hidden = true;
|
|
692
|
+
updateConversationLayout();
|
|
631
693
|
}
|
|
632
694
|
|
|
633
695
|
function disposeTerminal() {
|
|
@@ -856,6 +918,52 @@ globalForm.addEventListener("submit", async event => {
|
|
|
856
918
|
});
|
|
857
919
|
elements.detailBack.addEventListener("click", clearSelection);
|
|
858
920
|
elements.terminalClose.addEventListener("click", closeTerminalPanel);
|
|
921
|
+
elements.conversationToggle.addEventListener("click", function () {
|
|
922
|
+
conversationVisible = !conversationVisible;
|
|
923
|
+
updateConversationLayout();
|
|
924
|
+
});
|
|
925
|
+
elements.conversationClose.addEventListener("click", function () {
|
|
926
|
+
conversationVisible = false;
|
|
927
|
+
updateConversationLayout();
|
|
928
|
+
elements.conversationToggle.focus();
|
|
929
|
+
});
|
|
930
|
+
elements.conversationSwap.addEventListener("click", function () {
|
|
931
|
+
const left = !document.body.classList.contains("pane-left");
|
|
932
|
+
document.body.classList.toggle("pane-left", left);
|
|
933
|
+
localStorage.setItem("yui.conversation.side", left ? "left" : "right");
|
|
934
|
+
});
|
|
935
|
+
let resizingPane = false;
|
|
936
|
+
elements.paneDivider.addEventListener("pointerdown", function (event) {
|
|
937
|
+
if (event.button !== 0 || elements.paneDivider.hidden || window.innerWidth <= 900) return;
|
|
938
|
+
resizingPane = true;
|
|
939
|
+
elements.paneDivider.setPointerCapture(event.pointerId);
|
|
940
|
+
document.body.classList.add("pane-resizing");
|
|
941
|
+
event.preventDefault();
|
|
942
|
+
});
|
|
943
|
+
elements.paneDivider.addEventListener("pointermove", function (event) {
|
|
944
|
+
if (!resizingPane) return;
|
|
945
|
+
const left = document.body.classList.contains("pane-left");
|
|
946
|
+
const width = left ? event.clientX - elements.tasks.closest(".sidebar").offsetWidth
|
|
947
|
+
: window.innerWidth - event.clientX;
|
|
948
|
+
setPaneWidth(width - 4, false);
|
|
949
|
+
});
|
|
950
|
+
function finishPaneResize() {
|
|
951
|
+
if (!resizingPane) return;
|
|
952
|
+
resizingPane = false;
|
|
953
|
+
document.body.classList.remove("pane-resizing");
|
|
954
|
+
localStorage.setItem("yui.conversation.width", String(paneWidth));
|
|
955
|
+
}
|
|
956
|
+
elements.paneDivider.addEventListener("pointerup", finishPaneResize);
|
|
957
|
+
elements.paneDivider.addEventListener("pointercancel", finishPaneResize);
|
|
958
|
+
elements.paneDivider.addEventListener("keydown", function (event) {
|
|
959
|
+
const left = document.body.classList.contains("pane-left");
|
|
960
|
+
const delta = left ? (event.key === "ArrowRight" ? 20 : event.key === "ArrowLeft" ? -20 : 0)
|
|
961
|
+
: (event.key === "ArrowLeft" ? 20 : event.key === "ArrowRight" ? -20 : 0);
|
|
962
|
+
if (!delta && !["Home", "End"].includes(event.key)) return;
|
|
963
|
+
event.preventDefault();
|
|
964
|
+
setPaneWidth(event.key === "Home" ? 280 : event.key === "End" ? maxPaneWidth() : paneWidth + delta, true);
|
|
965
|
+
});
|
|
966
|
+
window.addEventListener("resize", function () { setPaneWidth(paneWidth, false, true); });
|
|
859
967
|
document.addEventListener("keydown", function (event) {
|
|
860
968
|
if (globalDialog.open) return;
|
|
861
969
|
const active = document.activeElement;
|
|
@@ -864,6 +972,12 @@ document.addEventListener("keydown", function (event) {
|
|
|
864
972
|
closeTerminalPanel();
|
|
865
973
|
return;
|
|
866
974
|
}
|
|
975
|
+
if (event.key === "Escape" && document.body.classList.contains("conversation-active")) {
|
|
976
|
+
conversationVisible = false;
|
|
977
|
+
updateConversationLayout();
|
|
978
|
+
elements.conversationToggle.focus();
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
867
981
|
if (event.key === "Escape" && state.selected) {
|
|
868
982
|
clearSelection();
|
|
869
983
|
return;
|
|
@@ -901,9 +1015,14 @@ function applyStateFromUrl() {
|
|
|
901
1015
|
if (taskId) {
|
|
902
1016
|
selectTask(taskId);
|
|
903
1017
|
} else {
|
|
1018
|
+
if (state.selected && !canLeaveDetail()) {
|
|
1019
|
+
syncUrlFromState({ replace: true });
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
904
1022
|
state.selected = null;
|
|
905
1023
|
state.detail = null;
|
|
906
1024
|
setDetailActive(false);
|
|
1025
|
+
elements.conversationContent.replaceChildren();
|
|
907
1026
|
showOverview();
|
|
908
1027
|
}
|
|
909
1028
|
}
|
|
@@ -916,6 +1035,7 @@ window.addEventListener("popstate", function () {
|
|
|
916
1035
|
i18n.subscribe(function () {
|
|
917
1036
|
renderDynamicContent();
|
|
918
1037
|
if (terminalSession) setTerminalState(terminalStateKey);
|
|
1038
|
+
updateConversationLayout();
|
|
919
1039
|
if (!state.selected && elements.pageTitle) elements.pageTitle.textContent = i18n.t("page.title");
|
|
920
1040
|
});
|
|
921
1041
|
showOverview();
|
|
@@ -34,6 +34,15 @@ const messages = {
|
|
|
34
34
|
"sync.label": "LAST SYNC",
|
|
35
35
|
"actions.openSession": "Open Session",
|
|
36
36
|
"actions.operator": "Operator session",
|
|
37
|
+
"conversation.title": "Task discussion",
|
|
38
|
+
"conversation.source": "Recorded Task messages · not a Session transcript",
|
|
39
|
+
"conversation.show": "Discussion",
|
|
40
|
+
"conversation.hide": "Hide discussion",
|
|
41
|
+
"conversation.swap": "Move discussion to the other side",
|
|
42
|
+
"conversation.resize": "Resize task discussion",
|
|
43
|
+
"tabs.workResults": "Work & results",
|
|
44
|
+
"tabs.executionReviews": "Execution & reviews",
|
|
45
|
+
"tabs.historyMessages": "History & messages",
|
|
37
46
|
"actions.refresh": "Refresh",
|
|
38
47
|
"actions.showLess": "Show less",
|
|
39
48
|
"actions.showMore": "Show more",
|
|
@@ -458,6 +467,15 @@ const messages = {
|
|
|
458
467
|
"sync.label": "最近同步",
|
|
459
468
|
"actions.openSession": "打开会话",
|
|
460
469
|
"actions.operator": "Operator 会话",
|
|
470
|
+
"conversation.title": "任务讨论",
|
|
471
|
+
"conversation.source": "已记录的 Task 消息 · 非 Session 完整记录",
|
|
472
|
+
"conversation.show": "讨论",
|
|
473
|
+
"conversation.hide": "收起讨论",
|
|
474
|
+
"conversation.swap": "将讨论移至另一侧",
|
|
475
|
+
"conversation.resize": "调整讨论栏宽度",
|
|
476
|
+
"tabs.workResults": "工作与结果",
|
|
477
|
+
"tabs.executionReviews": "执行与审阅",
|
|
478
|
+
"tabs.historyMessages": "历史与消息",
|
|
461
479
|
"actions.refresh": "刷新",
|
|
462
480
|
"actions.showLess": "收起",
|
|
463
481
|
"actions.showMore": "展开更多",
|
|
@@ -99,10 +99,15 @@ export function renderTaskSummary(container, data, t, locale, actions, recordCar
|
|
|
99
99
|
const progress = block(say("Task progress", "任务进展"), "progress");
|
|
100
100
|
const briefEntry = entries("task-brief")[0];
|
|
101
101
|
const brief = available("task-brief")[0];
|
|
102
|
+
const working = data.task.status === "draft" || data.task.status === "active";
|
|
102
103
|
if (data.task.completionSummary) progress.append(richText(say("Recorded completion", "已记录完成"), data.task.completionSummary, t));
|
|
103
104
|
if (brief) {
|
|
104
|
-
progress.append(richText(
|
|
105
|
-
|
|
105
|
+
progress.append(richText(working
|
|
106
|
+
? say("Current focus", "当前工作")
|
|
107
|
+
: say("Last working focus", "结束前的工作重点"), brief.currentFocus, t));
|
|
108
|
+
progress.append(richText(working
|
|
109
|
+
? say("Leader's progress report", "Leader 进展摘要")
|
|
110
|
+
: say("Last working report", "结束前的进展摘要"), brief.leaderSummary, t));
|
|
106
111
|
progress.append(node("small", "muted", say("Brief updated: ", "Brief 更新:") + formatDateTime(brief.updatedAt, locale)));
|
|
107
112
|
} else if (briefEntry) progress.append(recordCard(briefEntry));
|
|
108
113
|
else progress.append(node("p", "muted", say("No progress summary in this read; no percentage or ETA is inferred.",
|