Compare commits

...
4 Commits
Author SHA1 Message Date
mesalogo 8b10ed583e chore: prepare Desktop 0.16.2 release
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
2026-10-11 11:46:35 +08:00
mesalogo f2f4c1273e fix(supervisor): review only finished sources and add an all-time range
Reviews now snapshot only messages in a terminal state (complete or error) and finished tasks, so a reply still streaming when the review starts waits for the next review instead of changing under it. A frozen source that still changes is skipped for that run and left for the next review, replacing the re-review at the latest version and its 200-source cap. The review period picker gains an All time option.
2026-10-10 15:44:14 +08:00
mesalogo ba30852058 fix(magic-notes): label entry actions and show note more button on hover 2026-10-10 13:43:14 +08:00
mesalogo 12a0291279 fix(terminal): contain IME composition layout 2026-10-10 12:55:18 +08:00
23 changed files with 262 additions and 81 deletions
+4 -1
View File
@@ -983,9 +983,12 @@ records are listed separately and do not introduce another feature status.
### Open source, builds, and releases
- The current Desktop candidate is `0.16.1`; Agent `0.15.6` remains paired with Desktop `0.16.1`, with
- The current Desktop candidate is `0.16.2`; Agent `0.15.6` remains paired with Desktop `0.16.2`, with
OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent
Desktop and Agent release channels.
- Desktop `0.16.2` adds an all-time Supervisor review range and waits for messages and tasks to
settle before including them in a review. It also improves Magic Notes action labels, contains
terminal IME composition layout, and upgrades the database to schema 63.
- Desktop `0.16.1` smooths the message-to-composer transition: messages extend to the composer
outline before fading, and light/dark theme backgrounds and focus boundaries no longer create
an abrupt solid band above the input. Schema remains unchanged.
+2 -1
View File
@@ -159,7 +159,8 @@
### 开源、构建与发布
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
- 当前 Desktop 候选为 `0.16.1`,Agent 保持 `0.15.6`、最低要求 Desktop `0.16.1`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
- 当前 Desktop 候选为 `0.16.2`,Agent 保持 `0.15.6`、最低要求 Desktop `0.16.2`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
- Desktop `0.16.2` 增加全部时间的监督回顾范围,并等待消息和任务完成后再纳入回顾;同时改进 Magic Notes 操作标签、修复终端输入法布局,数据库升级到 schema 63。
- Desktop `0.16.1` 平滑消息与输入框的过渡:消息会延伸到输入框轮廓前再淡出,浅色和深色主题的背景与焦点边界也不再在输入框上方形成突兀的实色横带;schema 保持不变。
- Desktop `0.16.0` 将专家和团队角色选择移入输入框,并按会话保存选择。历史消息保留请求当时使用的角色,即使角色之后被重命名或删除也不会丢失这项上下文;schema 保持不变。
- Desktop `0.15.14` 恢复因来源变化或移除而失败的历史监督回顾的继续操作。持续变化的来源只会在有限次数内重新冻结处理,之后自动省略,使回顾能够结束并保留其余来源的进度;设置分类增加对应图标,方便快速查找;schema 仍为 62。
@@ -0,0 +1,24 @@
# Desktop 0.16.2 发布准备
日期:2026-10-10。基线 `v0.16.1`,候选源码包含 `12a02912`、`ba308520`
和 `f2f4c127` 三个提交。本候选只更新 Desktop;Agent 保持 `0.15.6`,
不创建新的 Agent 标签。标准六平台 Desktop,不含 LoongArch。
## 范围与兼容性
- 监督回顾增加从最早记录到当前时间的全部时间范围。
- 监督回顾只读取已完成消息和任务,实时生成中的内容等待完成后再进入回顾。
- Magic Notes 操作入口增加文字标签,次要更多操作按需显示。
- 终端输入法组合区域保持在终端边界内。
- 数据库 schema 从 62 升到 63;Agent、runtime/acp、wire 和运行时锁版本不变。
## 升级须知
首次启动会执行 schema 63 数据库升级。升级前关闭应用并备份完整数据目录、
相邻 `notes/` 和设置文件。旧客户端回退需要恢复升级前备份。
## 发布门槛
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
要求精确候选的主分支 CI 和生产构建通过,再创建不可变的 `v0.16.2` 标签。
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.16.1",
"version": "0.16.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.16.1",
"version": "0.16.2",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.16.1",
"version": "0.16.2",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+34
View File
@@ -1,6 +1,40 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.16.2",
"releasedAt": "2026-10-10",
"notes": {
"zh-CN": {
"highlights": [],
"features": [
"**支持全部时间的监督回顾**:监督者现在可以选择从最早保存的工作记录到当前时间的全部时间范围,适合查看长期积累的工作进展。",
"**只在稳定后纳入监督回顾**:正在生成的消息和运行中的任务会等到完成后再进入回顾,避免回顾内容随着实时输出变化。"
],
"fixes": [
"**让 Magic Notes 操作更容易理解**:笔记入口和操作按钮增加清晰的文字标签,更多操作在需要时显示,减少误解和界面干扰。",
"**修复终端输入法布局**:在终端中输入中文或其他组合文字时,输入候选和组合区域会保持在终端边界内,不再撑开或遮挡周围界面。"
],
"notices": [
"本版本首次启动会将数据库升级到 schema 63。升级前请关闭应用并备份完整数据目录、相邻 `notes/` 和设置文件;Agent 0.15.6 无需更新。"
]
},
"en-US": {
"highlights": [],
"features": [
"**Review all time in Supervisor**: Supervisor now offers an all-time range from the earliest saved work records through the current time, making it easier to review long-running progress.",
"**Review sources only after they settle**: Messages that are still streaming and tasks that are still running wait until completion before entering a review, so review content does not change underneath an active run."
],
"fixes": [
"**Make Magic Notes actions easier to understand**: Note entry points and actions now use clearer text labels, while secondary actions appear when needed to reduce confusion and visual noise.",
"**Contain terminal IME layout**: Chinese and other composition input in the terminal now stays inside the terminal bounds instead of expanding into or obscuring surrounding UI."
],
"notices": [
"First launch upgrades the database to schema 63. Close the app and back up the complete data directory, adjacent `notes/`, and settings files before upgrading; Agent 0.15.6 does not need to be updated."
]
}
}
},
{
"version": "0.16.1",
"releasedAt": "2026-10-10",
+12 -2
View File
@@ -134,7 +134,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 62
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 63
function withoutHistoricalWorkMode(value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
@@ -11919,6 +11919,7 @@ export class AssistantDatabase {
database.exec('BEGIN IMMEDIATE')
try {
database.exec(supervisionReviewMigration)
database.exec(supervisionReviewCurrentView)
database.exec('PRAGMA user_version = 47; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
@@ -12250,6 +12251,15 @@ export class AssistantDatabase {
`)
} catch (error) { database.exec('ROLLBACK'); throw error }
}
if (version.user_version < 63) {
// Reviews read only finished messages and tasks, so a frozen manifest never changes.
database.exec('BEGIN IMMEDIATE')
try {
database.exec('DROP VIEW IF EXISTS supervision_review_current')
database.exec(supervisionReviewCurrentView)
database.exec('PRAGMA user_version = 63; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
}
supervisionExperiences(): SupervisionExperienceStore {
@@ -12314,7 +12324,7 @@ export class AssistantDatabase {
}
}
import type { ImageOperation } from '../../shared/image-generation-contracts'
import { SupervisionReviewStore, supervisionReviewMigration } from './supervision-review-store'
import { SupervisionReviewStore, supervisionReviewCurrentView, supervisionReviewMigration } from './supervision-review-store'
import { SupervisionStoryStore, supervisionStoriesMigration } from './supervision-stories'
import { SupervisionExperienceStore, supervisionExperiencesMigration } from './supervision-experiences'
import { migrateSupervisionTimeline, placeTimelineEvents, supersedeReextractedEvents, supervisionAttention, timelineCandidates } from './supervision-timeline'
@@ -70,9 +70,9 @@ it('scans read-only, transfers at most 200 rows, yields without a transaction, a
expect(wrote).toBe(true)
expect(f.store.progress(f.runId).sources).toBe(450)
expect(f.store.load(f.runId).initializing).toBeUndefined()
// A concurrent write is detected on resume and that one source is re-frozen, not the whole run failed.
// A concurrent write is detected on resume and that one source is skipped, not the whole run failed.
await f.store.resumeWithReader(f.runId, f.reader)
expect(f.store.progress(f.runId)).toMatchObject({ sources: 450, revisedSources: 1 })
expect(f.store.progress(f.runId)).toMatchObject({ sources: 449, skippedSources: 1 })
expect(f.store.load(f.runId).restartRequired).toBeUndefined()
await expect(f.worker.call('reviewManifestPage', [f.runId, 0])).rejects.toThrow('lost its snapshot')
})
+35 -37
View File
@@ -10,14 +10,7 @@ import { setImmediate as yieldTurn } from 'node:timers/promises'
export const TIMELINE_CHECKPOINT_SCOPE = 'timeline'
export type ReviewConfiguration = SupervisionReviewSettings & { timeoutSeconds: number; concurrency: number; version: 1 }
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; omittedSources?: number; revisedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
/**
* A source that keeps changing while it is reviewed (for example an assistant reply
* that is still being rewritten) is re-frozen at most this many times per run in total;
* after that further changed sources are omitted so the run always terminates.
*/
export const MAX_REVISED_REVIEW_SOURCES = 200
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; omittedSources?: number; skippedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
export class ReviewSourcesOmitted extends Error {}
@@ -59,6 +52,14 @@ export const supervisionReviewMigration = `
id TEXT NOT NULL, children_json TEXT NOT NULL, output_json TEXT NOT NULL,
PRIMARY KEY(run_id, id)
);
`
/**
* Reviewable sources. Only finished messages (complete or error) and finished tasks are
* visible: their content no longer changes, so the manifest frozen when a review starts
* is a fixed snapshot. Replies still streaming and running tasks wait for the next review.
*/
export const supervisionReviewCurrentView = `
CREATE VIEW supervision_review_current AS
SELECT 'message:' || m.id AS source, m.id AS record_id, 0 AS reference_index, 'conversation' AS type, c.id AS owner,
c.project_id AS project_id, c.id AS conversation_id, m.sequence AS sequence,
@@ -67,14 +68,15 @@ export const supervisionReviewMigration = `
json_array(m.review_revision, c.project_id, c.title, m.role, m.created_at) AS context,
json_object('messageId', m.id, 'conversationId', c.id, 'projectId', c.project_id, 'role', m.role, 'sequence', m.sequence) AS locator
FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant')
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant') AND m.state IN ('complete', 'error')
UNION ALL
SELECT 'task:' || t.id, t.id, 0, 'task', t.id, COALESCE(t.project_id, ''), '', 0, t.title,
t.created_at, COALESCE(t.completed_at, t.created_at),
json_object('title', t.title, 'status', t.status, 'createdAt', t.created_at, 'completedAt', t.completed_at),
json_array(t.project_id, t.title, t.status, t.created_at, t.completed_at), json_object('projectId', t.project_id)
FROM tasks t LEFT JOIN projects p ON p.id = t.project_id
WHERE t.visible = 1 AND (t.project_id IS NULL OR p.status = 'active')
WHERE t.visible = 1 AND t.status IN ('completed', 'failed', 'cancelled', 'interrupted')
AND (t.project_id IS NULL OR p.status = 'active')
UNION ALL
SELECT 'knowledge:' || m.id || ':' || ref.key, m.id, ref.key, 'knowledge',
COALESCE(json_extract(ref.value, '$.chunkId'), json_extract(ref.value, '$.documentId'),
@@ -86,7 +88,7 @@ export const supervisionReviewMigration = `
json_patch(ref.value, json_object('messageId', m.id, 'conversationId', c.id, 'projectId', c.project_id, 'referenceIndex', ref.key))
FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id
JOIN json_each(m.metadata_json, '$.sourceReferences') ref
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant');
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant') AND m.state IN ('complete', 'error');
`
export class SupervisionReviewStore {
@@ -248,13 +250,13 @@ export class SupervisionReviewStore {
resume(runId: string, signal?: AbortSignal): void {
const state = this.prepareResume(runId)
if (state.initializing) { this.initializeSources(runId, state, signal); return }
// Sources changed since they were frozen are re-frozen and reprocessed; removed ones are omitted.
// A source that no longer matches its frozen version is skipped and left for the next review.
let after = ''
for (;;) {
const rows = this.resumePage(runId, after)
if (!rows.length) break
const changed = this.changedSource(rows, signal)
if (changed) { this.reconcileSource(runId, changed); after = changed; continue }
if (changed) { this.skipSource(runId, changed); after = changed; continue }
after = rows.at(-1)!.source
}
}
@@ -287,8 +289,8 @@ export class SupervisionReviewStore {
if (!rows.length) break
const changed = await reader.changedSource(rows, signal)
this.checkRunning(runId, signal)
// The read snapshot may lag the writer; reconciliation re-reads the source here.
if (changed) { this.reconcileSource(runId, changed); after = changed; await yieldTurn(); continue }
// The read snapshot may lag the writer; skipSource re-reads the source here.
if (changed) { this.skipSource(runId, changed); after = changed; await yieldTurn(); continue }
after = rows.at(-1)!.source
await yieldTurn()
}
@@ -348,8 +350,8 @@ export class SupervisionReviewStore {
for (const metadata of rows) {
const current = this.currentSource(metadata.source, metadata.processed_offset, Math.min(remaining, 8000))
if (!current || current.current_revision !== metadata.revision) {
// Re-freeze (or omit) the changed source, then rebuild the batch from current offsets.
this.reconcileSource(runId, metadata.source)
// Skip the changed source, then rebuild the batch from current offsets.
this.skipSource(runId, metadata.source)
return this.chunk(runId, projectId, conversationId, config)
}
const row = { ...metadata, ...current }
@@ -396,33 +398,29 @@ export class SupervisionReviewStore {
}
/**
* Brings one frozen source in line with the business database without failing the run.
* A changed source is re-frozen at its current revision and reviewed again from the start
* (leaves that used it are discarded with their navigation); a removed or no longer visible
* source is omitted. Returns false when the source already matches.
* The manifest is a snapshot of finished sources, so a frozen source should never change.
* If one still does (or disappears), it is dropped from this run without failing it: leaves
* that used it are discarded with their navigation, and because no checkpoint is written for
* it, the next review picks it up at its current version. Returns false when it still matches.
*/
reconcileSource(runId: string, source: string): boolean {
skipSource(runId: string, source: string): boolean {
const frozen = this.db.prepare('SELECT revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, source) as { revision: string } | undefined
if (!frozen) return false
const live = this.currentSource(source, 0, 0)
if (live?.current_revision === frozen.revision) return false
if (this.omitDeletedConversations(runId)) return true
const current = live && { revision: live.current_revision, length: Number(live.body_length) }
const state = this.load(runId)
const revise = current !== undefined && current.length > 0 && (state.revisedSources ?? 0) < MAX_REVISED_REVIEW_SOURCES
this.db.exec('SAVEPOINT reconcile_review_source')
// No longer visible (archived, emptied): omitted. Still visible but changed: left for the next review.
const counter = live && Number(live.body_length) > 0 ? '$.skippedSources' : '$.omittedSources'
this.db.exec('SAVEPOINT skip_review_source')
try {
this.discardLeaves(runId, new Set([source]))
if (revise && current) {
this.db.prepare(`UPDATE supervision_review_sources SET revision = ?, length = ?, initial_offset = 0, processed_offset = 0
WHERE run_id = ? AND source = ?`).run(current.revision, current.length, runId, source)
} else this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source)
this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source)
this.db.prepare(`UPDATE supervision_review_runs SET state_json = json_set(json_remove(state_json, '$.restartRequired'),
'${revise ? '$.revisedSources' : '$.omittedSources'}', COALESCE(json_extract(state_json, '${revise ? '$.revisedSources' : '$.omittedSources'}'), 0) + 1,
'${counter}', COALESCE(json_extract(state_json, '${counter}'), 0) + 1,
'$.phase', 'extracting') WHERE run_id = ?`).run(runId)
this.db.exec('RELEASE reconcile_review_source')
this.db.exec('RELEASE skip_review_source')
return true
} catch (error) { this.db.exec('ROLLBACK TO reconcile_review_source; RELEASE reconcile_review_source'); throw error }
} catch (error) { this.db.exec('ROLLBACK TO skip_review_source; RELEASE skip_review_source'); throw error }
}
/**
@@ -464,9 +462,9 @@ export class SupervisionReviewStore {
try {
this.omitDeletedConversations(runId)
if (!this.hasSources(runId, evidence)) { this.db.exec('COMMIT'); return }
// A source changed while the model was working: drop this answer, keep the run going.
// A source changed while the model was working: drop this answer and that source, keep the run going.
const stale = evidence.find(item => this.currentSource(String(item.locator!.source), 0, 0)?.current_revision !== item.locator!.revision)
if (stale) { this.reconcileSource(runId, String(stale.locator!.source)); this.db.exec('COMMIT'); return }
if (stale) { this.skipSource(runId, String(stale.locator!.source)); this.db.exec('COMMIT'); return }
const advance = this.db.prepare(`UPDATE supervision_review_sources SET processed_offset = ?
WHERE run_id = ? AND source = ? AND revision = ? AND processed_offset = ?`)
for (const item of evidence) {
@@ -500,8 +498,8 @@ export class SupervisionReviewStore {
FROM supervision_review_batches WHERE run_id = ?`).get(runId)!
const state = this.load(runId)
const navigation = this.db.prepare('SELECT COUNT(*) AS count FROM supervision_review_navigation WHERE run_id = ?').get(runId)!
// Changed sources are reconciled on resume, so no saved run requires a restart.
return { runId, omittedSources: state.omittedSources, revisedSources: state.revisedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
// Changed sources are skipped on resume, so no saved run requires a restart.
return { runId, omittedSources: state.omittedSources, skippedSources: state.skippedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
remainingSources: Number(sources.remaining), complete: this.db.prepare("SELECT id FROM supervision_runs WHERE id = ? AND status IN ('completed', 'no_change')").get(runId) !== undefined }
}
+32 -15
View File
@@ -313,21 +313,35 @@ it('still pauses on user request and resumes saved batches without changing batc
expect(f.db.supervisionReviewStore().batches(result.runId!, 100).flatMap(row => row.evidence).map(row => row.content.length)).toEqual([1000, 1000, 500])
})
it('rejects changed source versions and rolls back offsets when batch persistence fails', async () => {
const f = await fixture([['x'.repeat(2500)]], { concurrency: 1 })
it('skips changed source versions and rolls back offsets when batch persistence fails', async () => {
const f = await fixture([['x'.repeat(2500)], ['Kept']], { concurrency: 1 })
f.sql.exec("CREATE TRIGGER fail_batch BEFORE INSERT ON supervision_review_batches BEGIN SELECT RAISE(ABORT, 'disk failure'); END")
await expect(f.service().run(request)).rejects.toThrow('disk failure')
const runId = f.db.listSupervisionActivity()[0]!.id
expect(f.db.supervisionReviewStore().progress(runId).characters).toBe(0)
expect(f.sql.prepare('SELECT processed_offset FROM supervision_review_sources').get()!.processed_offset).toBe(0)
expect(f.sql.prepare('SELECT MAX(processed_offset) AS offset FROM supervision_review_sources').get()!.offset).toBe(0)
f.sql.exec('DROP TRIGGER fail_batch')
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', f.conversations[0]!.messages[0]!.id)
const resumed = await f.service().resume(runId)
expect(resumed).toMatchObject({ status: 'completed', coverage: { revisedSources: 1, characters: 'Changed'.length, complete: true } })
expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Changed'])
expect(resumed).toMatchObject({ status: 'completed', coverage: { skippedSources: 1, sources: 1, characters: 'Kept'.length, complete: true } })
expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Kept'])
})
it('re-reviews a source rewritten while its batch is with the model and discards the stale answer', async () => {
it('reviews only finished messages: a streaming reply waits for the next review', async () => {
const f = await fixture([['Finished question', 'Partial reply']], { concurrency: 1 })
const reply = f.conversations[0]!.messages[1]!.id
f.sql.prepare("UPDATE messages SET state = 'streaming' WHERE id = ?").run(reply)
const manual = { ...request, reanalyze: undefined }
const first = await f.service().run(manual)
expect(first.coverage).toMatchObject({ sources: 1, complete: true })
expect(f.db.supervisionReviewStore().batches(first.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Finished question'])
f.sql.prepare("UPDATE messages SET content = ?, state = 'complete' WHERE id = ?").run('Partial reply, finished', reply)
const next = await f.service().run(manual)
expect(next.coverage).toMatchObject({ sources: 1, complete: true })
expect(f.db.supervisionReviewStore().batches(next.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Partial reply, finished'])
})
it('skips a source rewritten while its batch is with the model and discards the stale answer', async () => {
const f = await fixture([['Original reply'], ['Other conversation']], { concurrency: 1 })
const target = f.conversations[0]!.messages[0]!.id
const base = f.summarize.getMockImplementation()!
@@ -340,13 +354,12 @@ it('re-reviews a source rewritten while its batch is with the model and discards
return { ...await base(input), summary: input.evidence.map(item => item.content).join(', ') }
})
const result = await f.service().run(request)
expect(result).toMatchObject({ status: 'completed', coverage: { revisedSources: 1, sources: 2, remainingSources: 0, complete: true } })
expect(result).toMatchObject({ status: 'completed', coverage: { skippedSources: 1, sources: 1, remainingSources: 0, complete: true } })
const contents = f.db.supervisionReviewStore().batches(result.runId!).flatMap(batch => batch.evidence).map(item => item.content)
expect(contents).toContain('Original reply, continued after reconnect')
expect(contents).not.toContain('Original reply')
expect(contents).toEqual(['Other conversation'])
})
it('discards a mixed leaf when one of its sources changes and reviews only current versions', async () => {
it('discards a mixed leaf when one of its sources changes and skips only that source', async () => {
const f = await fixture([['A', 'B']], { concurrency: 1 })
const store = f.db.supervisionReviewStore()
const runId = f.db.startSupervisionRun(request)
@@ -360,8 +373,8 @@ it('discards a mixed leaf when one of its sources changes and reviews only curre
f.db.failSupervisionRun(runId, 'Provider failed')
await f.service().resume(runId)
expect(store.navigation(runId, 'dependent')).toBeUndefined()
expect(store.batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['A', 'B2'])
expect(store.progress(runId)).toMatchObject({ revisedSources: 1, complete: true })
expect(store.batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['A'])
expect(store.progress(runId)).toMatchObject({ skippedSources: 1, sources: 1, complete: true })
})
it('omits a source that is no longer visible and resumes old runs failed by the earlier restart rule', async () => {
@@ -443,9 +456,13 @@ it('lets the next automatic review continue a failed run whose source changed in
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('New version', f.conversations[0]!.messages[0]!.id)
const result = await f.service().run(automatic)
expect(result.runId).toBe(runId)
expect(result).toMatchObject({ status: 'completed', coverage: { revisedSources: 1, characters: 'New version'.length } })
// The leaf of the old version is discarded; only the current version is published.
expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['New version'])
expect(result).toMatchObject({ status: 'no_change', coverage: { skippedSources: 1, sources: 0 } })
// The leaf of the old version is discarded and no checkpoint is written for the source,
// so the next automatic review reads its current version.
expect(f.db.supervisionReviewStore().batches(runId)).toEqual([])
const next = await f.service().run(automatic)
expect(next.runId).not.toBe(runId)
expect(f.db.supervisionReviewStore().batches(next.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['New version'])
})
it('makes a manual review incremental by default and keeps explicit re-analysis out of shared progress', async () => {
@@ -45,7 +45,7 @@ async function fixture() {
{ documentId: 'document', documentName: 'Reference', snippet: 'Knowledge \u{1f600}', chunkId: 'chunk' }
] }), conversations[0]!.messages[0]!.id)
db.createTask({ id: randomUUID(), conversationId: conversations[0]!.id, projectId: project.id,
title: 'Visible task', instructions: 'Test', origin: 'user', visible: true })
title: 'Visible task', instructions: 'Test', origin: 'user', visible: true, status: 'completed' })
const state: ReviewState = { request: { trigger: 'manual', scope: { kind: 'global' },
timeRange: { from: '2026-01-01T00:00:00.000Z', to: '2027-01-01T00:00:00.000Z' } },
config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, timeoutSeconds: 30, concurrency: 1 }, phase: 'collecting' }
@@ -96,15 +96,15 @@ it('rebuilds a partially written manifest after restart and validates completed
await f.storage.resumeSupervisionReview(id, new AbortController().signal)
expect(f.db.supervisionReviewStore().progress(id).sources).toBe(422)
expect(f.db.supervisionReviewStore().load(id).initializing).toBeUndefined()
// A source changed after freezing is re-frozen at its new version instead of failing the run.
// A source changed after freezing is skipped for this run instead of failing it.
const changedId = f.conversations[0]!.messages[0]!.id
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', changedId)
await f.storage.resumeSupervisionReview(id, new AbortController().signal)
const store = f.db.supervisionReviewStore()
expect(store.progress(id)).toMatchObject({ sources: 422, revisedSources: 1 })
expect(store.progress(id)).toMatchObject({ sources: 421, skippedSources: 1 })
expect(store.load(id).restartRequired).toBeUndefined()
expect(f.sql.prepare('SELECT length, processed_offset FROM supervision_review_sources WHERE run_id = ? AND source = ?')
.get(id, `message:${changedId}`)).toMatchObject({ length: 'Changed'.length, processed_offset: 0 })
expect(f.sql.prepare('SELECT 1 FROM supervision_review_sources WHERE run_id = ? AND source = ?')
.get(id, `message:${changedId}`)).toBeUndefined()
})
it.each(['paused', 'cancelled'] as const)('accepts %s during production initialization, holds the slot, and never calls the model', async status => {
@@ -1460,7 +1460,7 @@ describe('MagicNotesWorkspace detail behavior', () => {
const { container } = render(<MagicNotesWorkspace onNotify={onNotify} />)
await openNote()
expect(container.querySelector('.magic-note-detail-header > :first-child')).toBe(screen.getByLabelText('笔记标题'))
expect(screen.getByRole('button', { name: '删除记录' })).toHaveClass('icon-button', 'magic-note-entry__delete-button')
expect(screen.getByRole('button', { name: '删除记录' })).toHaveClass('magic-note-entry__action-button', 'magic-note-entry__delete-button')
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
expect(container.querySelector('.magic-note-entry__editor')).toBeVisible()
expect(screen.getByRole('button', { name: '删除记录' })).toBeDisabled()
+6 -4
View File
@@ -3091,7 +3091,7 @@ function MagicNotesWorkspaceView({
</button>
)}
<button
className="icon-button"
className="magic-note-entry__action-button"
aria-label={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
title={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
disabled={Boolean(busy) || editingEntry?.id === entry.id}
@@ -3104,12 +3104,13 @@ function MagicNotesWorkspaceView({
})
}
>
<Pencil aria-hidden="true" size={15} />
<Pencil aria-hidden="true" size={14} />
<span>{t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}</span>
</button>
<button
aria-label={t('actions.deleteEntry')}
title={t('actions.deleteEntry')}
className="icon-button magic-note-entry__delete-button"
className="magic-note-entry__action-button magic-note-entry__delete-button"
disabled={Boolean(busy) || Boolean(editingEntry)}
type="button"
onClick={() => {
@@ -3119,7 +3120,8 @@ function MagicNotesWorkspaceView({
setDeletingEntryId(entry.id)
}}
>
<Trash2 aria-hidden="true" size={15} />
<Trash2 aria-hidden="true" size={14} />
<span>{t('actions.deleteEntry')}</span>
</button>
</div>}
</header>
+1 -1
View File
@@ -24,7 +24,7 @@ export function SupervisionReviewStages({ row, stopping }: { row: SupervisionAct
</ol>
<p className="supervisor-activity__coverage">{t('activity.coverage', { batches: progress.batches, remaining: progress.remainingSources })}</p>
{!!progress.omittedSources && <p className="supervisor-activity__note">{t('activity.omittedSources', { count: progress.omittedSources })}</p>}
{!!progress.revisedSources && <p className="supervisor-activity__note">{t('activity.revisedSources', { count: progress.revisedSources })}</p>}
{!!progress.skippedSources && <p className="supervisor-activity__note">{t('activity.skippedSources', { count: progress.skippedSources })}</p>}
{!progress.complete && !progress.phase && <p className="supervisor-activity__note">{t('activity.stageUnknown')}</p>}
{row.supervisionStatus === 'running' && !stopping && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>}
</div>
+1 -1
View File
@@ -229,7 +229,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
</dl>
{row.reviewProgress && <p>{t('reviewSettings.progress', { batches: row.reviewProgress.batches, characters: row.reviewProgress.characters, remaining: row.reviewProgress.remainingSources })}</p>}
{!!row.reviewProgress?.omittedSources && <p>{t('activity.omittedSources', { count: row.reviewProgress.omittedSources })}</p>}
{!!row.reviewProgress?.revisedSources && <p>{t('activity.revisedSources', { count: row.reviewProgress.revisedSources })}</p>}
{!!row.reviewProgress?.skippedSources && <p>{t('activity.skippedSources', { count: row.reviewProgress.skippedSources })}</p>}
{row.reviewProgress?.navigationNodes !== undefined && <p>{t('activity.navigationSaved', { count: row.reviewProgress.navigationNodes })}</p>}
{row.summary && <p className="supervisor-activity__summary">{row.summary}</p>}
{row.reviewProgress?.settings && <details><summary>{t('reviewSettings.configuration')}</summary>
+24 -1
View File
@@ -225,7 +225,7 @@ describe('SupervisorWorkspace', () => {
vi.setSystemTime(now)
fireEvent.click(screen.getByRole('button', { name: copy.newReview }))
const picker = screen.getByLabelText(copy.period)
expect(within(picker).getAllByRole('option').map(option => (option as HTMLOptionElement).value)).toEqual(['1', '7', '30', 'custom', 'range'])
expect(within(picker).getAllByRole('option').map(option => (option as HTMLOptionElement).value)).toEqual(['1', '7', '30', 'all', 'custom', 'range'])
expect(picker).toHaveValue('7')
fireEvent.change(picker, { target: { value: 'custom' } })
fireEvent.change(screen.getByRole('spinbutton', { name: copy.dayCount }), { target: { value: '45' } })
@@ -245,6 +245,29 @@ describe('SupervisorWorkspace', () => {
timeRange: { from: new Date(now.getTime() - 46 * 86400_000).toISOString(), to: now.toISOString() } })
})
it.each(['zh-CN', 'en-US'] as const)('reviews all time from the epoch through now and names it in the reanalysis dialog in %s', async locale => {
await changeUiLocale(locale)
const copy = i18nResources[locale].heartbeat.supervisor
const run = vi.fn().mockResolvedValue(undefined)
window.goodbuddy = { supervision: { overview: async () => [], run } } as never
render(<SupervisorWorkspace />)
await screen.findByText(copy.empty)
vi.useFakeTimers({ toFake: ['Date'] })
const now = new Date(2026, 9, 5, 15, 30)
vi.setSystemTime(now)
fireEvent.click(screen.getByRole('button', { name: copy.newReview }))
fireEvent.change(screen.getByLabelText(copy.period), { target: { value: 'all' } })
const timeRange = { from: new Date(0).toISOString(), to: now.toISOString() }
await act(async () => fireEvent.click(screen.getByRole('button', { name: copy.run })))
expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', scope: { kind: 'global' }, timeRange })
fireEvent.click(screen.getByRole('button', { name: copy.more }))
fireEvent.click(screen.getByRole('menuitem', { name: copy.reanalyze }))
const dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveTextContent(copy.allTimeLabel)
await act(async () => fireEvent.click(within(dialog).getByRole('button', { name: copy.reanalyzeConfirm })))
expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange })
})
it.each([
['2025-11-02', '2025-11-02'],
['2026-03-08', '2026-03-08'],
+3
View File
@@ -240,6 +240,8 @@ export function SupervisorWorkspace({
const loading = overviewLoading || (tab === 'graph' && Boolean(resultId) && graphResultId !== resultId && !loadError)
const resolvePeriod = (now: Date): { range?: SupervisionRunRequest['timeRange']; label?: string; error?: string } => {
// All time: from the epoch, which predates any stored source, through now.
if (period === 'all') return { range: { from: new Date(0).toISOString(), to: now.toISOString() }, label: t('supervisor.allTimeLabel') }
if (period === 'range') {
const from = new Date(`${startDate}T00:00:00`)
const end = new Date(`${endDate}T00:00:00`)
@@ -651,6 +653,7 @@ export function SupervisorWorkspace({
{t('supervisor.days', { count: value })}
</option>
))}
<option value="all">{t('supervisor.allTime')}</option>
<option value="custom">{t('supervisor.customDays')}</option>
<option value="range">{t('supervisor.dateRange')}</option>
</select>
+12
View File
@@ -221,6 +221,18 @@ describe('TerminalPanel', () => {
)
})
it('contains IME composition layout inside the terminal', () => {
expect(terminalStylesheet).toMatch(
/\.terminal-panel__emulator\s*\{[^}]*contain:\s*size layout paint;[^}]*overflow-anchor:\s*none;/u
)
expect(terminalStylesheet).toMatch(
/\.terminal-panel__emulator \.xterm\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*100%;[^}]*overflow:\s*hidden;/u
)
expect(terminalStylesheet).toMatch(
/\.terminal-panel__emulator \.xterm-helpers\s*\{[^}]*max-width:\s*0;[^}]*overflow:\s*visible;/u
)
})
it('creates only after the first valid fitted size and forwards input', async () => {
const backend = adapterHarness()
const emulator = emulatorHarness()
@@ -64,7 +64,7 @@ export const heartbeat = {
stageUnknown: 'This historical run has no saved stage. Zero remaining sources does not mean the review was published.',
coverage: '{{batches}} saved batches · {{remaining}} sources remaining',
omittedSources: '{{count}} sources skipped because their conversations were deleted. Coverage includes only retained sources. If none remain, the review finishes without an empty report.',
revisedSources: '{{count}} sources changed during the review and were reviewed again at their latest version; batches of older versions were discarded.',
skippedSources: '{{count}} sources changed during the review. They were skipped this time and will be handled in the next review.',
navigationSaved: '{{count}} navigation merges saved',
resumeMerge: 'Continue merging', resumeSave: 'Retry publication', runDetails: 'Run details and configuration',
treePageHint: 'Expand projects and conversations to browse this page of saved batches. Tasks without a saved result are not listed here.',
@@ -123,6 +123,7 @@ export const heartbeat = {
empty: 'No successful review yet', emptyHint: 'You can review current progress manually even without an heartbeat plan.', run: 'Review', running: 'Reviewing…',
more: 'More review actions', reanalyze: 'Reanalyze…', reanalyzeTitle: 'Reanalyze this period?', reanalyzeConfirm: 'Reanalyze',
reanalyzeHint: 'All content in {{scope}} for {{period}} will be analyzed again, which may use more model tokens. Confirmed content and heartbeat progress are not affected.', retryRun: 'Retry review', dismiss: 'Dismiss',
allTime: 'All time', allTimeLabel: 'all time',
customDays: 'Custom days', dateRange: 'Date range', dayCount: 'Number of days', startDate: 'Start date', endDate: 'End date',
recentDays: 'the last {{count}} days', dateRangeLabel: '{{from}} through {{to}} (local dates, through now if ending today)',
customDaysHelp: 'Counts back from now in 24-hour days.', dateRangeHelp: 'Local dates, including the end day. If ending today, includes content through now.',
@@ -61,7 +61,7 @@ export const heartbeat = {
stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。',
coverage: '已保存 {{batches}} 批 · 剩余 {{remaining}} 个来源',
omittedSources: '会话已删除,已跳过 {{count}} 个来源;覆盖统计仅包含保留的来源。没有剩余来源时结束回顾,不生成空报告。',
revisedSources: '回顾期间有 {{count}} 个来源被修改,已按最新内容重新回顾;旧版本的批次已丢弃。',
skippedSources: '回顾期间有 {{count}} 个来源发生变化,本次已跳过,将在下次回顾中处理。',
navigationSaved: '已保存 {{count}} 次导航合并',
resumeMerge: '继续合并摘要', resumeSave: '重试发布', runDetails: '运行详情与配置',
treePageHint: '按项目、会话展开本页已保存批次。这里不包含尚未保存结果的任务。',
@@ -120,6 +120,7 @@ export const heartbeat = {
empty: '还没有成功回顾', emptyHint: '即使没有心跳计划,也可以手动回顾当前进展。', run: '回顾', running: '回顾整理中…',
more: '更多回顾操作', reanalyze: '重新整理…', reanalyzeTitle: '重新整理这段时间?', reanalyzeConfirm: '重新整理',
reanalyzeHint: '将重新分析{{scope}}在{{period}}内的全部内容,可能产生较多模型用量。已确认的内容和心跳处理进度不受影响。', retryRun: '重试回顾', dismiss: '关闭提示',
allTime: '全部', allTimeLabel: '全部时间',
customDays: '自定义天数', dateRange: '指定日期', dayCount: '天数', startDate: '开始日期', endDate: '结束日期',
recentDays: '最近 {{count}} 天', dateRangeLabel: '{{from}} 至 {{to}}(本地日期,结束日期为今天时截至当前)',
customDaysHelp: '从当前时刻向前计算,每天按 24 小时计。', dateRangeHelp: '按本地日期计算,包含结束日期当天;结束日期为今天时截至当前时刻。',
+42 -3
View File
@@ -2433,7 +2433,8 @@
position: absolute;
top: var(--space-2);
right: var(--space-2);
opacity: 1;
opacity: 0;
transition: opacity var(--motion-fast) ease-out;
}
.magic-note-row:hover .magic-note-more,
@@ -2721,9 +2722,18 @@
line-height: 1;
}
.magic-note-entry {
position: relative;
}
/* Floats over the top-right corner so the actions never take their own row. */
.magic-note-entry__actions {
display: flex;
gap: 2px;
position: absolute;
z-index: 1;
top: var(--space-1);
right: var(--space-2);
gap: var(--space-1);
}
.magic-note-entry__actions .icon-button {
@@ -2731,7 +2741,36 @@
height: 28px;
}
.magic-note-entry__actions .magic-note-entry__delete-button:hover:not(:disabled) {
.magic-note-entry__action-button {
height: 28px;
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: var(--font-caption);
white-space: nowrap;
cursor: pointer;
}
.magic-note-entry__action-button:hover:not(:disabled),
.magic-note-entry__action-button:focus-visible {
background: var(--surface-subtle);
color: var(--text-primary);
}
.magic-note-entry__action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.magic-note-entry__actions .magic-note-entry__delete-button {
color: var(--danger-strong);
}
.magic-note-entry__actions .magic-note-entry__delete-button:hover:not(:disabled),
.magic-note-entry__actions .magic-note-entry__delete-button:focus-visible {
background: var(--danger-subtle);
color: var(--danger-strong);
}
+13
View File
@@ -116,11 +116,24 @@
flex: 1;
padding: 0;
overflow: hidden;
contain: size layout paint;
overflow-anchor: none;
background: var(--surface-canvas);
}
.terminal-panel__emulator .xterm {
position: relative;
width: 100%;
min-width: 0;
max-width: 100%;
height: 100%;
overflow: hidden;
}
/* IME composition must stay inside the terminal's own scroll boundary. */
.terminal-panel__emulator .xterm-helpers {
max-width: 0;
overflow: visible;
}
.terminal-panel__status {
+2 -2
View File
@@ -39,8 +39,8 @@ export type SupervisionReviewProgress = {
remainingSources: number
/** Sources excluded because their owning conversation was deleted; not reviewed coverage. */
omittedSources?: number
/** Sources that changed during the run and were reviewed again at their newer version. */
revisedSources?: number
/** Frozen sources that changed or disappeared during the run; skipped and left for the next review. */
skippedSources?: number
complete: boolean
restartRequired?: boolean
inFlight?: number