fix(conversation): preserve first drafts across Session retirement

This commit is contained in:
_Kerman committed 2026-09-20 15:57:45 +08:00
1 parent b93f55f7ff
commit 6fa56a0cf7
7 files changed
+71 -7

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 56b9d06ce5e48ab40691bede2ccb9669e180aa9f
README.zh.md: fd02565698171e0b6db35080181e57b8b5abd42d
README.md: 6ea403d46b9a019cbd24f8963a89835be4745980
README.zh.md: f6e28d563847316b538ab8c4317507ea7761c913
+1 -1
View File
@@ -52,7 +52,7 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c
The Client chooses the default directory name and initial title when sending: Chinese uses `默认工作区`, English uses `Default workspace`, and other languages use directory `default-workspace` with title `Default workspace`. The request retains those names during initialization. A retry after failed initialization uses the current language; a successfully initialized Workspace keeps its directory and title across language changes.
On first use, the same input accepts text before a Workspace is selected. The first send prepares the [default Workspace](../../workspace/workspace/README.md#first-use-workspace), selects its blank Session, transfers the draft, and uses ordinary message submission. Preparation locks further input. Failure preserves the draft and offers Cancel or Choose folder through the existing picker. A manually chosen folder receives the unsent draft; the user sends again. Once created, the Workspace survives a prompt failure. `conversation/prepare-first-send` awaits staged Session settings before automatically submitting the transferred first-use draft; a rejected listener keeps that draft and reports its error.
On first use, the same input accepts text before a Workspace is selected. The first send prepares the [default Workspace](../../workspace/workspace/README.md#first-use-workspace), selects its blank Session, transfers the draft, and uses ordinary message submission. Preparation locks further input. Failure preserves the draft and offers Cancel or Choose folder through the existing picker. A manually chosen folder receives the unsent draft; the user sends again. Once created, the Workspace survives a prompt failure. `conversation/prepare-first-send` awaits staged Session settings before automatically submitting the transferred first-use draft; a rejected listener keeps that draft and reports its error. If navigation releases the destination Session while preparation is pending, automatic submission stops and its persisted draft remains available on reopening.
The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
+1 -1
View File
@@ -52,7 +52,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
Client 在发送时决定默认目录名和初始标题:中文使用 `默认工作区`,英文使用 `Default workspace`,其他语言使用目录 `default-workspace` 和标题 `Default workspace`。初始化期间,请求保留这些名称。初始化失败后的重试使用当前语言;初始化成功的 Workspace 在切换语言后仍保留原目录和标题。
首次使用时,同一个输入框允许在选择 Workspace 前输入文字。首条消息会准备[默认工作区](../../workspace/workspace/README.zh.md#first-use-workspace)、选中其 blank Session、转移草稿,再使用普通消息提交流程。准备期间锁定后续输入。失败时保留草稿,并提供“取消”或通过已有选择器“选择文件夹”。手动选择的文件夹接收未发送草稿,用户再次发送。Workspace 创建成功后,不会因提示词发送失败而消失。`conversation/prepare-first-send` 在自动提交已转移的首次使用草稿前等待暂存的 Session 设置完成;监听器拒绝时保留该草稿并显示错误。
首次使用时,同一个输入框允许在选择 Workspace 前输入文字。首条消息会准备[默认工作区](../../workspace/workspace/README.zh.md#first-use-workspace)、选中其 blank Session、转移草稿,再使用普通消息提交流程。准备期间锁定后续输入。失败时保留草稿,并提供“取消”或通过已有选择器“选择文件夹”。手动选择的文件夹接收未发送草稿,用户再次发送。Workspace 创建成功后,不会因提示词发送失败而消失。`conversation/prepare-first-send` 在自动提交已转移的首次使用草稿前等待暂存的 Session 设置完成;监听器拒绝时保留该草稿并显示错误。如果导航在准备期间释放了目标 Session,自动提交会停止,已持久化的草稿会在重新打开时保留。
本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
@@ -208,6 +208,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
const firstDraft = new FirstDraft(ctx, {
open: (beforeOpen, signal) => {
const language = ctx.locale.getSnapshot().active.toLowerCase().split('-')[0]
// Persisted Workspace names follow the send-time language, including the English fallback.
const title = (language === 'zh' ? zh : en)['defaultWorkspace.title']
return workspaceNavigation.openDefaultWorkspace({
directoryName: language === 'zh' || language === 'en' ? title : 'default-workspace',
@@ -215,6 +216,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}, beforeOpen, signal)
},
shell: id => inputHub.shell(id),
isCurrent: (id, shell) => sessions.binding(id) !== undefined && inputHub.shell(id) === shell,
})
const composerBlocks = new ComposerBlockRegistry()
@@ -10,13 +10,15 @@ import { SessionInputShell } from './facade.ts'
interface FirstDraftDeps {
open(beforeOpen: (id: SessionId) => void, signal: AbortSignal): Promise<SessionId | undefined>
shell(id: SessionId): SessionInputShell
/** Whether the borrowed input still belongs to the retained Session generation. */
isCurrent(id: SessionId, shell: SessionInputShell): boolean
}
/** Owns a root-lifetime text editor without allocating a Host Session. */
export class FirstDraft {
/** Preparation progress and the recoverable directory failure. */
readonly state = createSnapshotStore<FirstDraftState>({ busy: false, failed: false })
/** Local editor that stays independent of Host Session allocation. */
/** Local editor owns failed-submit restoration before transfer to a Host Session. */
readonly shell: SessionInputShell
private pending: { text: string; transferred: boolean } | undefined
@@ -63,6 +65,7 @@ export class FirstDraft {
target = this.deps.shell(id)
await this.ctx.serial('conversation/prepare-first-send', id)
signal.throwIfAborted()
if (!this.deps.isCurrent(id, target)) return { kind: 'success' }
target.submit(mode)
return { kind: 'success' }
} catch (error) {
@@ -139,11 +139,70 @@ async function bench() {
return {
runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi,
inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads, rootReference, references, opened,
locale, openDefaultWorkspace,
locale, openDefaultWorkspace, replaceMain,
}
}
describe('Conversation inject API', () => {
it.each([false, true])('keeps the saved first draft after its Session retires (reopened during preparation: %s)', async (reopenEarly) => {
const b = await bench()
onTestFinished(() => b.runtime.dispose())
const settings = Promise.withResolvers<undefined>()
const started = Promise.withResolvers<undefined>()
b.runtime.ctx.on('conversation/prepare-first-send', () => {
started.resolve(undefined)
return settings.promise
})
b.openDefaultWorkspace.mockImplementation(async (_request, beforeOpen) => {
b.replaceMain(ROOT, beforeOpen)
return ROOT
})
const body = b.conversationApi(ROOT)
const unmirror = body.injected.bindDraftMirror(body.instance.actions.setDraft)
const composer = b.composerApi(undefined)
let reopened: SessionReference | undefined
try {
composer.keyboard.paste('keep this first draft')
composer.keyboard.submit('queue')
await started.promise
expect(body.instance.store.getSnapshot().draft).toBe('keep this first draft')
const other = 'other-first-send' as SessionId
await b.runtime.sessions.add({ id: other, session: sessionFakeFor() })
unmirror()
b.rootReference.release()
b.replaceMain(other)
await vi.waitFor(() => { expect(b.runtime.sessions.binding(ROOT)).toBeUndefined() })
if (reopenEarly) reopened = b.runtime.sessions.retain(ROOT)
settings.resolve(undefined)
await vi.waitFor(() => { expect(b.residentApi(undefined).hooks.firstDraft.getSnapshot().busy).toBe(false) })
await b.runtime.flush()
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
reopened ??= b.runtime.sessions.retain(ROOT)
b.references.set(ROOT, reopened)
expect(b.conversationApi(ROOT).instance.store.getSnapshot().draft).toBe('keep this first draft')
expect(composer.hooks.composerInput.getSnapshot().draft).toBe('')
} finally {
settings.resolve(undefined)
reopened?.release()
unmirror()
}
})
it('submits the first draft through its retained destination input', async () => {
const b = await bench()
onTestFinished(() => b.runtime.dispose())
b.openDefaultWorkspace.mockImplementation(async (_request, beforeOpen) => {
b.replaceMain(ROOT, beforeOpen)
return ROOT
})
const composer = b.composerApi(undefined)
composer.keyboard.paste('first message')
composer.keyboard.submit('queue')
await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() })
expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([{ type: 'text', text: 'first message' }])
expect(composer.hooks.composerInput.getSnapshot().draft).toBe('')
})
it.each([
['zh', '默认工作区', '默认工作区'],
['en', 'Default workspace', 'Default workspace'],
@@ -27,7 +27,7 @@ function harness() {
beforeOpen = prepare
return opened.promise
})
const first = new FirstDraft(ctx, { open, shell: () => target })
const first = new FirstDraft(ctx, { open, shell: () => target, isCurrent: () => true })
return {
ctx, first, target, submitted, open, opened,
complete: () => {