Compare commits

..
6 Commits
Author SHA1 Message Date
mesalogo 9f0f231e3b chore: prepare GoodBuddy 0.11.12 release
GoodBuddy Agents / Validate Agent source (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
GoodBuddy Agent Release / Authorize Agent release (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
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
2026-08-30 11:08:55 +08:00
mesalogo 967796539f fix: recover remote conversations after uncertain prompts
An interrupted managed SSH prompt could leave its ACP binding as the permanent owner of the conversation, so every later user message failed with the same uncertain-recovery error. A later explicit prompt now retires that binding and starts a fresh ACP session without replaying the uncertain operation.

Ask-mode Runtime startup also waited on the wrong bubblewrap executable identity. The Agent now supervises the bounded transition to the signed Runtime or model-bridge executable, and the Agent version advances to 0.11.10.

Release note: 修复远程 ACP 请求中断后同一会话无法继续输入,以及 Ask 模式可能在模型调用前启动失败的问题。
2026-08-30 11:02:20 +08:00
mesalogo 8e08ab0bac feat: show Magic Notes update times
The Magic Notes list previously showed only each title, content preview, and
entry count, making similarly named or recently changed notes harder to scan.
Each note now shows its localized update time beside the entry count, with a
semantic timestamp and bounded single-line layout for the narrow list pane.

Release note: 魔法笔记左侧列表现在显示每篇笔记的本地化更新时间,便于快速找到最近修改的内容。
2026-08-30 10:09:39 +08:00
mesalogo edba31e873 chore: prepare GoodBuddy 0.11.11 release
GoodBuddy Agents / Validate Agent source (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 / 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
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Bump the desktop package metadata to 0.11.11 and package the approved Simplified Chinese and English release notes for complete remote conversation saves and discoverable Magic Notes list shortcuts.

Release note: GoodBuddy 0.11.11 修复远程项目长工具结果的会话持久化,并补充魔法笔记列表快捷输入提示。
2026-08-30 09:33:58 +08:00
mesalogo 0804f5ccd2 fix: preserve complete remote tool details
Managed SSH Runtime events could complete successfully and then fail local conversation persistence when tool details exceeded a second, smaller renderer-to-Main schema limit. Conversation persistence now stores the complete tool details already accepted from the Runtime, so long remote results no longer leave the final assistant message stuck in a streaming state.

Release note: 修复远程项目工具结果较长时会话持久化失败的问题;Runtime 已返回的工具详情现在会完整保存到本地会话。
2026-08-30 09:29:28 +08:00
mesalogo 29639ff516 fix: surface Magic Notes list shortcuts
Magic Notes already accepted Quill's native bullet, numbered, and checklist
autofill, but the editor did not make those shortcuts discoverable or protect
them with behavior coverage. The composer now explains the supported markers
in both languages, and tests verify they continue producing Delta list formats.

Release note: 魔法笔记编辑区现在提示项目符号、编号和待办清单快捷输入;输入标记后按空格即可创建对应列表。
2026-08-30 09:20:27 +08:00
22 changed files with 386 additions and 60 deletions
+2
View File
@@ -625,6 +625,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
- 笔记编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。
- 左侧笔记列表依次显示标题、最多两行内容预览,以及同一行的记录数和本地化更新时间。记录数靠左、更新时间靠右,窄宽度下均保持单行省略,不挤压标题或扩大列表栏。
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.11.9",
"agentVersion": "0.11.10",
"protocol": {
"major": 2,
"minor": 0
@@ -207,6 +207,7 @@ Execute 不经过 Ask 的 bubblewrap profile:
调度、跨 channel 超车、批发送或第二套拥塞控制。
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
- Runtime 事件进入桌面会话后,Renderer 和 Main 将完整工具详情保存到本地 SQLite,不再对已接受的工具输入、输出、错误或摘要施加额外的会话字段长度限制。
- 同一 detached Agent 存活时,短暂 SSH 断线依次执行 `controller/resume`、`runtime/resumeAcpChannel` 和 `runtime/replayAcpChannel`,从 Main 已确认的 cursor 后只重放 Agent 到 Main 的已记录输出。重复 frame 会被确认但不会再次交给上层。若上一代连接只留下没有活动请求的 detached binding,完成精确 controller takeover 后会先有界停止并核对遗留 Runtime process,再用新 channel epoch 重新打开同一 binding 并恢复已有 ACP session;其他 controller、未证明 takeover 或仍有活动请求的 binding 仍被拒绝。
- Main 到 Runtime 的 ACP 输入、模型请求、工具请求和 blob 不自动重放。
- 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。
@@ -283,7 +284,12 @@ Workspace、Runtime 和 Saving 进度仅用于新建或显式保存项目;进
产生冲突的项目创建和设置入口。远端 RPC 拒绝只向
Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转发 Host 私有错误详情。
`runtime/preparePrompt` 在远端明确拒绝且尚未接受 Prompt 时会关闭对应 Main binding,
不会把确定性失败错误保留为 `outcome-unknown`。
不会把确定性失败错误保留为 `outcome-unknown`。未确认终态的 Prompt 仍不会自动重放;
用户之后显式发送的新 Prompt 会关闭旧的非就绪 Main binding,并以新的 ACP session
继续当前 Conversation,避免一次恢复不确定永久阻塞后续输入。
Ask 模式的 Linux Runtime 进程由 `bwrap` 启动后 `exec` 到已签名 Runtime 或 Agent
model bridge helper;监督器只接受同一 PID/进程组从固定 `bwrap` 路径到预期最终可执行文件
的有界转换,再登记为运行中进程。
不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema
v31 保留项目及关联用户数据,但重建执行空间表为 `project_id/kind/root_path/ssh_host_id`
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.11.10",
"version": "0.11.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.11.10",
"version": "0.11.12",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.11.10",
"version": "0.11.12",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+56
View File
@@ -1,6 +1,62 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.11.12",
"releasedAt": "2026-08-30",
"notes": {
"zh-CN": {
"highlights": [],
"features": [
"**魔法笔记更新时间。** 魔法笔记列表现在显示每篇笔记的最后更新时间,便于快速判断最近修改过的内容。"
],
"fixes": [
"**远程会话中断后可以继续输入。** 修复托管 SSH 请求结果不确定后,同一会话的每条后续输入都会重复显示“恢复状态不确定”的问题。未确认的旧请求仍不会自动重放;用户再次明确发送时,GoodBuddy 会建立新的 ACP session 并继续当前会话。",
"**Ask 模式远程 Runtime 可以稳定启动。** 修复 Linux Host 上 `bwrap` 启动身份检查存在竞态,导致 Ask 模式可能在模型调用前被拒绝的问题。Agent 现在会验证从固定沙箱启动器到预期签名可执行文件的受限转换。"
],
"notices": [
"**远程项目技术预览与 Agent 要求。** 远程项目仍默认关闭且为技术预览。Host 需要更新到单独发布的 Agent `0.11.10` 才能获得 Ask 模式启动修复;提供 Linux x64 和 arm64,桌面包不内置 Agent 或远程 Runtime。本次不构建 LoongArch 预览。"
]
},
"en-US": {
"highlights": [],
"features": [
"**Magic Notes update times.** The Magic Notes list now shows when each note was last updated, making recently edited content easier to identify."
],
"fixes": [
"**Remote conversations continue after interruptions.** Fixed every later message in a managed SSH conversation repeatedly failing with an uncertain-recovery error after a prompt outcome could not be confirmed. The uncertain prompt is still never replayed automatically; when the user explicitly sends again, GoodBuddy starts a new ACP session and continues the current conversation.",
"**Reliable Ask-mode Remote Runtime startup.** Fixed a race in Linux Host `bwrap` launch identity verification that could reject Ask-mode requests before the model was called. The Agent now verifies the bounded transition from the fixed sandbox launcher to the expected signed executable."
],
"notices": [
"**Remote Projects technical preview and Agent requirement.** Remote Projects remain disabled by default and in technical preview. Hosts must update to the separately published Agent `0.11.10` to receive the Ask-mode startup fix. Linux x64 and arm64 are provided; desktop packages do not embed the Agent or Remote Runtime. This release does not include a LoongArch preview."
]
}
}
},
{
"version": "0.11.11",
"releasedAt": "2026-08-30",
"notes": {
"zh-CN": {
"highlights": [],
"features": [],
"fixes": [
"**远程会话完整保存。** 修复托管 SSH 远程项目的工具结果较长时,会话持久化失败并可能让已完成回复仍显示为生成中的问题。Runtime 已返回的工具输入、输出、错误和摘要现在会完整保存到本地会话,不再受额外的会话字段长度限制。",
"**魔法笔记列表快捷输入更易发现。** 编辑区现在提示输入 `-`、`1.`、`[ ]` 或 `[x]` 后按空格即可创建项目符号、编号或待办清单,并增加行为覆盖以防快捷输入回退。"
],
"notices": []
},
"en-US": {
"highlights": [],
"features": [],
"fixes": [
"**Complete remote conversation saves.** Fixed conversation persistence failing for managed SSH Remote Projects when tool results were long, which could leave a completed reply displayed as still generating. Tool input, output, errors, and summaries already returned by the Runtime are now saved completely without additional conversation-field length limits.",
"**Discoverable Magic Notes list shortcuts.** The editor now explains that typing `-`, `1.`, `[ ]`, or `[x]` followed by Space creates a bullet, numbered, or checklist item, with behavior coverage to prevent regressions."
],
"notices": []
}
}
},
{
"version": "0.11.10",
"releasedAt": "2026-08-29",
@@ -119,6 +119,47 @@ describe('direct Linux stdio Runtime ownership', () => {
registry.close()
})
it('waits for bubblewrap to exec the supervised bridge helper', async () => {
const registry = createRegistry()
const child = fakeChild()
const bwrapIdentity = identity()
const helperIdentity = {
...bwrapIdentity,
executablePath: '/agent/node'
}
const spawn: DirectLinuxStdioSpawn = () => {
queueMicrotask(() => child.emit('spawn'))
return child
}
const readProcessIdentity = vi
.fn()
.mockResolvedValueOnce(bwrapIdentity)
.mockResolvedValue(helperIdentity)
const owner = await launchDirectLinuxStdioProcessOwner({
manifest: manifest(),
profile: {
...profile(),
processExecutable: '/agent/node'
},
identity: { launchId: 'launch-1', processId: 'process-1' },
installationId: 'installation-1',
registry,
deadlineAt: '2030-01-01T00:00:00.000Z',
maximumInputBytes: 1024,
platform: 'linux',
spawn,
randomOwnerToken: () => 'a'.repeat(32),
readProcessIdentity
})
expect(readProcessIdentity).toHaveBeenCalledTimes(2)
expect(owner.processIdentity).toEqual(helperIdentity)
child.exitCode = 0
child.emit('close', 0, null)
registry.close()
})
it('rejects PID reuse before sending any signal', async () => {
const registry = createRegistry()
const child = fakeChild()
@@ -31,6 +31,7 @@ const DEFAULT_MAXIMUM_STDIN_WRITE_BYTES = 1024 * 1024
const DEFAULT_MAXIMUM_OUTPUT_QUEUE_CHUNKS = 128
const DEFAULT_MAXIMUM_LISTENERS = 8
const DEFAULT_STOP_TIMEOUT_MS = 10_000
const DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS = 5_000
const TERM_GRACE_MS = 2_000
const POLL_INTERVAL_MS = 25
const OWNER_TOKEN_ENVIRONMENT_NAME = 'GOODBUDDY_RUNTIME_OWNER_TOKEN'
@@ -603,8 +604,18 @@ export async function launchDirectLinuxStdioProcessOwner(
await waitForSpawn(child, options.signal, timeoutMs)
if (child.pid === undefined) throw new Error('Runtime spawn did not return a PID')
const readIdentity = options.readProcessIdentity ?? readLinuxRuntimeProcessIdentity
const processIdentity = await readIdentity(child.pid)
assertLaunchedIdentity(processIdentity, profile)
const processIdentity = await waitForLaunchedIdentity({
child,
pid: child.pid,
profile,
readIdentity,
signal: options.signal,
deadline: Math.min(
Date.parse(effectiveDeadlineAt),
now() + DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS
),
now
})
const supervisorIdentityDigest = supervisorDigest({
installationId,
ownerId,
@@ -871,15 +882,72 @@ function validateProfile(profile: OpenCodeLaunchProfile): OpenCodeLaunchProfile
}
}
function assertLaunchedIdentity(
identity: LinuxRuntimeProcessIdentity,
async function waitForLaunchedIdentity(input: {
child: DirectLinuxStdioChild
pid: number
profile: OpenCodeLaunchProfile
): void {
if (
identity.executablePath !== profile.processExecutable ||
identity.processGroupId !== identity.pid
) {
throw identityError('Spawned Runtime does not match the launch identity')
readIdentity: typeof readLinuxRuntimeProcessIdentity
signal?: AbortSignal
deadline: number
now: () => number
}): Promise<LinuxRuntimeProcessIdentity> {
for (;;) {
input.signal?.throwIfAborted()
if (
input.child.exitCode !== null ||
input.child.signalCode !== null
) {
throw new DirectLinuxStdioProcessOwnerError(
'Runtime process exited during launch',
'process'
)
}
let identity: LinuxRuntimeProcessIdentity
try {
identity = await input.readIdentity(input.pid)
} catch (error) {
if (!isMissingProcess(error)) {
throw error
}
if (input.now() >= input.deadline) {
throw new DirectLinuxStdioProcessOwnerError(
'Runtime launch identity was not observed',
'process'
)
}
await delay(
Math.min(
POLL_INTERVAL_MS,
Math.max(1, input.deadline - input.now())
)
)
continue
}
if (identity.processGroupId !== identity.pid) {
throw identityError(
'Spawned Runtime does not match the launch identity'
)
}
if (identity.executablePath === input.profile.processExecutable) {
return identity
}
if (identity.executablePath !== input.profile.executable) {
throw identityError(
'Spawned Runtime does not match the launch identity'
)
}
if (input.now() >= input.deadline) {
throw new DirectLinuxStdioProcessOwnerError(
'Runtime launch did not reach its supervised executable',
'process'
)
}
await delay(
Math.min(
POLL_INTERVAL_MS,
Math.max(1, input.deadline - input.now())
)
)
}
}
@@ -241,6 +241,9 @@ describe('OpenCode bubblewrap launch profile', () => {
'--opencode-entrypoint',
resolve(fixture.bundleDirectory, 'bin', 'opencode')
])
expect(profile.processExecutable).toBe(
resolve(agentInstallationDirectory, 'node')
)
expect(profile.args.join('\0')).not.toMatch(
/api[-_]?key|authorization|bearer|(?:^|\0)--token(?:\0|$)/iu
)
+1 -1
View File
@@ -266,7 +266,7 @@ export function createOpenCodeLaunchProfile(input: {
return {
executable: OPENCODE_BWRAP_EXECUTABLE,
processExecutable: OPENCODE_BWRAP_EXECUTABLE,
processExecutable: runtimeCommand.executable,
args,
cwd: scratchDirectory,
env: {
+25 -13
View File
@@ -599,7 +599,7 @@ describe('AcpRemoteRuntime', () => {
})
it.each(['prompt-running', 'outcome-unknown'] as const)(
'does not replace a %s binding after identity changes',
'retires a %s binding before starting an explicit later prompt',
async (state) => {
const store = new MemoryRuntimeSessionBindingStore()
const active = {
@@ -625,7 +625,13 @@ describe('AcpRemoteRuntime', () => {
lastMainAckSequence: '0'
}
await store.put(active)
const factory = vi.fn(async () => fakeServer().channel)
const replacement = fakeServer()
const factory = vi.fn(
async (bindingId: string) => {
expect(bindingId).not.toBe(active.bindingId)
return replacement.channel
}
)
const instance = factoryRuntime(factory, store, {
identity: {
...identity,
@@ -633,22 +639,28 @@ describe('AcpRemoteRuntime', () => {
}
})
await expect(
collect(
instance.run(
{ ...request, requestId: 'prompt-2' },
new AbortController().signal
)
await collect(
instance.run(
{ ...request, requestId: 'prompt-2' },
new AbortController().signal
)
).rejects.toThrow('不会自动重放')
)
expect(factory).not.toHaveBeenCalled()
expect(factory).toHaveBeenCalledOnce()
expect(factory).not.toHaveBeenCalledWith(active.bindingId)
await expect(
store.getById(active.bindingId)
).resolves.toMatchObject({
bindingId: active.bindingId,
state: 'closed',
activePromptOperationId: undefined
})
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
bindingId: active.bindingId,
state: 'outcome-unknown',
activePromptOperationId: request.requestId
bindingId: factory.mock.calls[0]![0],
state: 'ready',
activePromptOperationId: undefined
})
await instance.dispose()
}
+9 -14
View File
@@ -833,7 +833,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
return interrupted
}
private async persistRejectedPrompt(
private async persistClosedBinding(
binding: RuntimeSessionBinding
): Promise<RuntimeSessionBinding> {
const closed = runtimeSessionBindingSchema.parse({
@@ -1198,24 +1198,19 @@ export class AcpRemoteRuntime implements AgentRuntime {
request: AgentExecutionRequest,
signal: AbortSignal
): Promise<SessionRecord> {
const old = await this.awaitLocalOperation(
let binding = await this.awaitLocalOperation(
'读取会话绑定',
this.options.bindingStore.getByConversation(conversationId),
signal
)
if (
old &&
old.state !== 'closed' &&
old.state !== 'ready'
binding &&
binding.state !== 'closed' &&
binding.state !== 'ready'
) {
await this.persistInterrupted(old)
throw new Error(
old.state === 'prompt-running'
? '远端 ACP 会话存在未确认的活动请求,结果未知且不会自动重放'
: '远端 ACP 会话恢复状态不确定,已中断且不会自动重放'
)
await this.persistClosedBinding(binding)
binding = undefined
}
let binding = old
let bindingId = binding?.bindingId ?? randomUUID()
if (
binding?.state === 'ready' &&
@@ -1437,7 +1432,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
error,
'runtime/preparePrompt'
)
? this.persistRejectedPrompt(binding)
? this.persistClosedBinding(binding)
: this.persistInterrupted(binding)
await persist.catch(() => undefined)
}
@@ -1483,7 +1478,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
error,
'runtime/preparePrompt'
)
? this.persistRejectedPrompt(current.binding)
? this.persistClosedBinding(current.binding)
: this.persistInterrupted(current.binding)
current.binding = await persist.catch(() => current.binding)
this.sessions.delete(conversationId)
@@ -3299,6 +3299,15 @@ describe('AssistantDatabase', () => {
createdAt: 1_775_000_000_001,
state: 'complete' as const,
status: '已完成',
tools: [
{
callId: 'remote-tool-call',
name: '远程工具',
state: 'completed' as const,
summary: '完整远程工具结果',
output: '远程输出'.repeat(10_000)
}
],
contextCompressions: [
{
state: 'completed' as const,
@@ -3350,6 +3359,15 @@ describe('AssistantDatabase', () => {
content: '生成完成',
state: 'complete',
status: '已完成',
tools: [
{
callId: 'remote-tool-call',
name: '远程工具',
state: 'completed',
summary: '完整远程工具结果',
output: '远程输出'.repeat(10_000)
}
],
contextCompressions: [
{
state: 'completed',
+10 -1
View File
@@ -3917,7 +3917,16 @@ describe('registerIpcHandlers local conversation persistence', () => {
role: 'assistant' as const,
content: '增量内容',
createdAt: 1,
state: 'streaming' as const
state: 'streaming' as const,
tools: [
{
callId: 'remote-tool-call',
name: '远程工具',
state: 'completed' as const,
summary: '完整远程工具结果',
output: '远程输出'.repeat(10_000)
}
]
}
]
}
+41
View File
@@ -3,6 +3,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import Quill from 'quill'
import { describe, expect, it, vi } from 'vitest'
import type { MagicNoteRichContent } from '../../shared/magic-notes-contracts'
import { MagicNoteEditor } from './MagicNoteEditor'
@@ -45,6 +46,46 @@ describe('MagicNoteEditor', () => {
).toBeInTheDocument()
})
it.each([
['-', 'bullet'],
['*', 'bullet'],
['1.', 'ordered'],
['[ ]', 'unchecked'],
['[x]', 'checked']
])(
'keeps the native Quill %s list shortcut enabled',
async (marker, expectedList) => {
const onChange = vi.fn<(content: MagicNoteRichContent) => void>()
const { container } = render(
<MagicNoteEditor
ariaLabel="笔记正文"
onChange={onChange}
onError={vi.fn()}
/>
)
const editor = container.querySelector<HTMLElement>('.ql-editor')
const quillContainer = container.querySelector<HTMLElement>(
'.magic-note-editor__content'
)
expect(editor).not.toBeNull()
expect(quillContainer).not.toBeNull()
const quill = Quill.find(quillContainer!) as Quill
quill.setText(marker, 'user')
quill.setSelection(marker.length, 0, 'silent')
fireEvent.keyDown(editor!, { key: ' ' })
await waitFor(() => {
const content = onChange.mock.calls.at(-1)?.[0]
expect(
content?.ops.some(
(operation) => operation.attributes?.list === expectedList
)
).toBe(true)
})
}
)
it('intercepts a pasted image before Quill and inserts it once', async () => {
const onChange = vi.fn<(content: MagicNoteRichContent) => void>()
const { container } = render(
@@ -399,6 +399,11 @@ describe('MagicNotesWorkspace', () => {
expect(
screen.getByRole('button', { name: /发布笔记/ })
).toHaveAttribute('aria-pressed', 'true')
const updatedTime = screen
.getByRole('button', { name: /发布笔记/ })
.querySelector('time')
expect(updatedTime).toHaveAttribute('datetime', detail.updatedAt)
expect(updatedTime).toHaveTextContent('更新于')
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
expect(screen.getByText('准备演示')).toBeInTheDocument()
const selectedTodoButton = screen
+15 -6
View File
@@ -1657,12 +1657,21 @@ export function MagicNotesWorkspace({
{note.preview || t('notes.noPreview')}
</span>
<span className="magic-note-list-item__meta">
{t(
note.entryCount === 1
? 'notes.entryCountOne'
: 'notes.entryCountOther',
{ count: note.entryCount }
)}
<span>
{t(
note.entryCount === 1
? 'notes.entryCountOne'
: 'notes.entryCountOther',
{ count: note.entryCount }
)}
</span>
<time dateTime={note.updatedAt}>
{t('notes.updatedAt', {
date: dateFormatter.format(
new Date(note.updatedAt)
)
})}
</time>
</span>
</button>
))
@@ -82,13 +82,15 @@ export const magicNotes = {
noPreview: 'No entries yet',
entryCountOne: '{{count}} entry',
entryCountOther: '{{count}} entries',
updatedAt: 'Updated {{date}}',
emptySelectionTitle: 'No note selected',
emptySelectionDescription:
'Select a note on the left, or create a note to start writing.',
newEntryLabel: 'New entry content',
composerImmediateHint: 'AI comments after you stop typing for 5 seconds',
composerImmediateHint:
'Type -, 1., [ ], or [x] then Space to start a list; AI comments after 5 seconds of inactivity',
composerRichTextHint:
'Supports rich text and pasting or dropping local images, videos, and attachments',
'Type -, 1., [ ], or [x] then Space to start a list; you can also paste or drop local files',
emptyEntries: 'No entries yet. Write the first entry above.',
editEntryLabel: 'Edit entry content',
entryAt: '{{date}} entry'
@@ -77,11 +77,14 @@ export const magicNotes = {
noPreview: '还没有记录',
entryCountOne: '{{count}} 条记录',
entryCountOther: '{{count}} 条记录',
updatedAt: '更新于 {{date}}',
emptySelectionTitle: '还没有选择笔记',
emptySelectionDescription: '从左侧选择笔记,或新建一篇笔记开始记录。',
newEntryLabel: '新记录内容',
composerImmediateHint: '停止输入 5 秒后自动评论',
composerRichTextHint: '支持富文本,可粘贴或拖入本地图片、视频和附件',
composerImmediateHint:
'输入 -、1.、[ ] 或 [x] 后按空格可创建列表;停止输入 5 秒后自动评论',
composerRichTextHint:
'输入 -、1.、[ ] 或 [x] 后按空格可创建列表;也可粘贴或拖入本地文件',
emptyEntries: '还没有记录,在上方写下第一条内容。',
editEntryLabel: '编辑记录内容',
entryAt: '{{date}} 的记录'
+16
View File
@@ -1609,8 +1609,24 @@
}
.magic-note-list-item__meta {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
color: var(--text-muted);
font-size: var(--font-caption);
gap: var(--space-2);
}
.magic-note-list-item__meta > :where(span, time) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.magic-note-list-item__meta time {
flex-shrink: 1;
text-align: right;
}
.magic-notes-stream-pane {
+40
View File
@@ -6,6 +6,7 @@ import {
conversationBranchInputSchema,
conversationSnapshotSchema,
isUntouchedBuiltInDefaultProject,
localConversationSaveBatchSchema,
persistedProjectExecutionSpaceSchema,
projectCreateSchema,
projectExecutionSpaceSchema,
@@ -203,3 +204,42 @@ describe('conversation branch contracts', () => {
).toBe(true)
})
})
describe('conversation persistence contracts', () => {
it('preserves complete tool details without persistence length limits', () => {
const input = 'input'.repeat(10_000)
const output = 'output'.repeat(20_000)
const error = 'error'.repeat(10_000)
const batch = [
{
header: {
id: '00000000-0000-4000-8000-000000000301',
title: '远程工具会话',
updatedAt: 1
},
messages: [
{
id: '00000000-0000-4000-8000-000000000302',
role: 'assistant',
content: '',
createdAt: 1,
state: 'complete',
tools: [
{
callId: 'remote-tool-call',
name: '远程工具',
state: 'completed',
summary: '完整远程工具结果',
input,
output,
error
}
]
}
]
}
]
expect(localConversationSaveBatchSchema.parse(batch)).toEqual(batch)
})
})
+6 -6
View File
@@ -133,8 +133,8 @@ export type ConversationQueueItem = z.infer<
export const conversationToolActivitySchema = z
.object({
callId: z.string().max(256).optional(),
name: z.string().max(200),
callId: z.string().optional(),
name: z.string(),
state: z.enum([
'pending',
'running',
@@ -144,10 +144,10 @@ export const conversationToolActivitySchema = z
'cancelled',
'interrupted'
]),
summary: z.string().max(2_000),
input: z.string().max(4_000).optional(),
output: z.string().max(16_000).optional(),
error: z.string().max(2_000).optional()
summary: z.string(),
input: z.string().optional(),
output: z.string().optional(),
error: z.string().optional()
})
.strict()