Avoid killing an in-flight desktop SDK turn when runtime config changes during tool execution. Persist the requested runtime immediately, then restart after the turn emits its terminal result.
Tested:
- bun run check:server
- real provider tool-turn model switch smoke
- bun run check:coverage (changed-line coverage passed; unrelated global lanes still fail)
Scope-risk: narrow
Add Telegram command-menu sync plus /resume, /provider, /model, and /skills command handling with paginated inline selections.
Expose the adapter HTTP calls needed by those commands and harden stale WebSocket session cleanup during Telegram resume/reset flows.
Tested:
- cd adapters && bun test telegram/__tests__/commands.test.ts telegram/__tests__/menu.test.ts
- cd adapters && bunx tsc --noEmit -p tsconfig.json
- bun run check:adapters
- bun run check:persistence-upgrade
- bun run check:native
- bun run verify (coverage lane failed on existing root WebSocket Chat Integration timeout)
Not-tested:
- Live Telegram bot smoke, no bot token/session available.
Confidence: medium
Scope-risk: moderate
Tested:
- cd desktop && bun run test -- src/__tests__/pluginsSettings.test.tsx src/stores/pluginStore.test.ts --run --reporter=verbose --pool=forks --maxWorkers=1 --minWorkers=1
- bun run check:desktop
- git diff --check
Scope-risk: moderate
Confidence: high
Git writes upstream branch config when a worktree starts from origin/main. Use the already-resolved base SHA as the worktree start point so parallel agent worktrees do not race on shared .git/config.
Tested: bun test src/utils/__tests__/worktree.test.ts
Tested: real gpt-5.5 Sub2API run with four worktree-isolated agents
Not-tested: bun run check:server currently fails unrelated WebSocket restart/timeouts
Confidence: high
Scope-risk: narrow
Commit 449ff0b0 changed completed thinking blocks to render the
'thinking.labelDone' title ("Thought"/"已思考") instead of the static
"Thinking" label, and updated ThinkingBlock.test.tsx but not
chatBlocks.test.tsx. The three inactive/default-state cases there still
queried the toggle button by /Thinking/ and failed. Match the new
done-state label (/Thought/); the active-state case is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Main added two TranslationKeys after this branch's base — 'thinking.labelDone'
(the "Thought" label shown after thinking completes) and
'slashCmd.agent.description' — which the jp/kr/zh-TW locale files did not yet
cover, so the Record<TranslationKey, string> contract failed tsc after merging
main. Add both keys to each new locale:
- jp: '思考完了' / '選択した Agent でプロンプトを実行'
- kr: '사고 완료' / '선택한 Agent로 프롬프트 실행'
- zh-TW: '已思考' / '使用指定 Agent 執行提示'
en/zh are unchanged. `tsc --noEmit` is now clean and the i18n + timestamp
suites pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guard OpenAI-compatible proxy streaming bodies with the configured AI request timeout so a provider that emits partial SSE and then idles cannot leave proxy consumers waiting forever.
This is a proxy-level fix found while investigating #548; it does not claim to close the broader desktop interruption issue.
Tested: bun test src/server/__tests__/proxy-network-settings.test.ts
Tested: bun run check:server
Confidence: medium
Scope-risk: narrow
The desktop notification poller fired on mount, racing the bootstrap that
resolves the dynamic server URL and confirms /health. Its first requests hit
the uninitialized default base URL and failed with "Failed to fetch", logging
spurious client_api_request_failed warnings to the diagnostics panel.
Add a whenDesktopServerReady() signal resolved once initializeDesktopServerUrl
sets the base URL and the healthcheck passes, and gate the poller on it so it
only starts once the server is reachable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The thinking block title always rendered the static "思考中"/"Thinking"
label; isActive only toggled the animated dots. Once thinking finished
the dots disappeared but the in-progress text stayed. Switch the label
to "已思考"/"Thought" when the block is no longer active.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The models API derives its model list from ANTHROPIC_MODEL and the
ANTHROPIC_DEFAULT_{HAIKU,SONNET,OPUS}_MODEL env vars. A developer who exports
these for a custom provider (e.g. MiniMax) leaked them into the no-provider
fixture: the four collapsed to one model, the default model became the
custom one, and switched-model names fell back to the raw id — failing five
"available models" / "default model" assertions on that machine while passing
on clean CI.
Clear those four vars in the e2e setup and restore them in teardown, matching
the existing CLAUDE_CONFIG_DIR / CLAUDE_CLI_PATH isolation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The diagnostics panel filled with red ERROR/WARN entries during normal use
because the capture layer treated "wrote to console" as "something failed",
with no severity gatekeeping. Much of the noise was also test runs leaking
into the user's real ~/.claude/cc-haha/diagnostics.
- diagnosticsService: default unclassified events to info (not error);
drop writes under NODE_ENV=test when no CLAUDE_CONFIG_DIR is set; document
the console-capture contract (expected states use console.debug/info).
- index: don't install console/process capture under bun test.
- api/diagnostics: an ingested event with missing severity defaults to info.
- oauthRefreshLog (new): token refresh failure is gracefully handled and is
never an error — expected expiry (401/403/revoked) logs at debug, anything
else at warn. Wired into both Haha OAuth services.
- conversationService: classify cli_runtime_exit severity by exit code, so
clean/SIGTERM/SIGKILL exits are info and only abnormal codes stay error
(real "chat died" crashes remain perceivable).
- ws/handler: streaming partial tool-input JSON is normal — debug, not warn.
Tests: oauth-refresh-log + cli-exit-severity unit tests; diagnostics-service
covers the info default and the test-isolation guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Indent session rows under their project group and switch project folder icons between open and closed states when collapsed.
Tested: cd desktop && bun run test --run src/components/layout/Sidebar.test.tsx -t "groups sessions by project|collapses a project group"
Tested: cd desktop && bun run test --run src/components/layout/Sidebar.test.tsx
Tested: bun run check:desktop
Tested: Browser plugin smoke on http://127.0.0.1:3456/ confirmed first project open -> closed state, icon state, and pl-6 session indentation.
Confidence: high
Scope-risk: narrow
Normalize image blocks loaded from transcript history back into renderable data URLs and keep generated image metadata out of the visible user bubble. Image source metadata is applied in order so multi-image messages retain the correct paths and names.
Tested: bun run test -- --run src/stores/chatStore.test.ts
Tested: bun run check:desktop
Confidence: high
Scope-risk: narrow
(cherry picked from commit 82381e8d647fa19e20853a96b9681fb548c17f0a)
Close the native preview WebContentsView when the Electron renderer starts a top-level navigation so refreshed session pages cannot leave the in-app browser surface behind.
Fixes#680
Tested:
- bun run verify
Confidence: high
Scope-risk: narrow
When the signing secrets are absent, `CSC_LINK`/`APPLE_*`/`WIN_CSC_*` render
as empty strings. electron-builder treats a present (even empty) CSC_LINK as
"a certificate is configured", tries to load it, resolves the empty path to
the working dir and dies with "<workdir> not a file" — which broke the v0.4.0
release build.
Drop those env vars from the release build step so unsigned builds behave
exactly like the (already-green) dev build, which only sets
CSC_IDENTITY_AUTO_DISCOVERY=false. Add them back once an Apple Developer ID /
Windows cert is available.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Release is gated on the tag only. `bun run verify` runs on PRs and locally,
not at release time, so a failing quality gate no longer blocks publishing.
- release-desktop.yml: remove the quality-preflight job and its `needs`.
- release-workflow.test.ts: assert the release workflow does NOT run verify.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Intro now explains why we migrated: users reported macOS performance
issues and Windows behaviour diverging from the macOS dev environment;
Electron's bundled Chromium unifies the rendering layer at the cost of a
larger installer.
- Fixes section rebuilt from the real v0.3.2..HEAD commits, keeping the
upstream issue numbers (#665#653#351#672#678#671#681#658#651#620)
so they can be closed against this release.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real-world testing showed the unsigned app just needs the original 0.3.2
one-liner after the "damaged" prompt (System Settings "Open Anyway" also
works). Drop the Sequoia / "don't trash it" / clear-DMG-first / script
walkthrough noise and match the historical release-note style.
- release-notes/v0.4.0.md: macOS = drag in + `xattr -cr ...`, Windows one
line, Linux kept short.
- docs/desktop/04-installation.md: same trim, remove duplicated FAQ entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unsigned builds ship manual downloads only, so the macOS auto-update zip
(~187MB) and the blockmaps just clutter the release page and dev artifacts.
- release-desktop.yml: stop attaching *.zip / *.blockmap to the GitHub
Release; users now see DMG/exe/AppImage/deb + install-macos-unsigned.sh.
- build-desktop-dev.yml: stop collecting *.zip / *.blockmap into the dev
artifact for the same reason.
- electron-builder still produces dmg+zip and the whole updater-metadata
pipeline (latest*.yml merge/validate) is untouched, so auto-update can be
re-enabled later by restoring the two asset globs.
- sync release-workflow.test.ts dev-artifact assertion accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use frameless Electron chrome only on Windows and remove the native Windows application menu so the packaged app matches the previous Tauri-style desktop surface.
Add a Windows-only manual drag fallback for desktop drag regions, while excluding tab reorder targets and preserving macOS/Linux native chrome and menu behavior.
Tested: cd desktop && bun run test --run electron/services/menu.test.ts electron/services/windows.test.ts src/hooks/useElectronWindowDragRegions.test.tsx src/components/layout/TabBar.test.tsx src/components/layout/Sidebar.test.tsx src/lib/desktopHost/electronHost.test.ts electron/ipc/capabilities.test.ts
Tested: cd desktop && SKIP_INSTALL=1 bun run build:windows-x64
Tested: Computer Use Windows packaged app smoke verified no native menu, custom controls, drag regions, tab reorder, close-to-background, and deepseek-v4-pro provider response FINAL_WINDOWS_ELECTRON_REAL_PROVIDER_OK.
Not-tested: full bun run verify.
Constraint: keep custom frameless behavior scoped to win32 so macOS and Linux native chrome paths remain intact.
Confidence: high
Scope-risk: moderate
- Rewrite release-notes/v0.4.0.md to match the historical style (no emoji,
Highlights/Fixes/Notes), keep it user-facing only (drop release-process
notes), and correct Linux (supported since the Tauri builds, not new).
- README.md / README.en.md: replace Tauri references with Electron, list
macOS / Windows / Linux, and point first-launch approval to the guide.
- Rewrite docs/desktop/04-installation.md for Electron: Electron asset
names, Linux section, and the unsigned-macOS flow (clear the DMG quarantine
before double-clicking; drop the WebView2/right-click-open leftovers).
- install-macos-unsigned.sh: keep the same-folder DMG flow, no online download.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove maintainer-facing release workflow notes from the public release markdown and keep the macOS unsigned install guidance concise.
Tested: git diff --check
Tested: bun run check:docs
Confidence: high
Scope-risk: narrow
Bump the Electron desktop package version to 0.4.0, publish a macOS unsigned install helper, and let the release workflow continue when Developer ID signing is not configured.
Tested: bash -n desktop/scripts/install-macos-unsigned.sh
Tested: bun test scripts/pr/release-workflow.test.ts
Tested: bun run scripts/release.ts 0.4.0 --dry
Tested: git diff --check
Tested: bun run check:docs
Tested: bun run check:policy
Confidence: high
Scope-risk: moderate
Summarize the v0.3.2 to v0.4.0 Electron migration window and document the installer paths users should follow.
Tested: bun run scripts/release.ts 0.4.0 --dry
Tested: git diff --check
Tested: bun run check:docs
Confidence: high
Scope-risk: narrow
Keep development desktop artifacts limited to distributable archives and update
metadata so macOS workflow downloads do not duplicate the packaged app bundle.
The package smoke step still validates the unpacked app before upload.
Tested: bun test scripts/pr/release-workflow.test.ts
Tested: bun run check:policy
Confidence: high
Scope-risk: narrow
Match Electron Builder Linux output by accepting linux-*-unpacked directories,
treating AppImage blockmaps as optional, and validating release asset names
against the generated x86_64/amd64 and arm64 artifacts.
Tested: bun test scripts/quality-gate/package-smoke/index.test.ts
Tested: bun test scripts/pr/release-workflow.test.ts scripts/quality-gate/package-smoke/index.test.ts
Tested: bun run check:policy
Tested: bun run check:native
Tested: bun run verify
Confidence: high
Scope-risk: moderate
Ensure Electron Builder has the project URL and maintainer metadata required by Linux deb targets, and lock the fields with release workflow coverage.
Tested: bun test scripts/pr/release-workflow.test.ts
Tested: bun run check:policy
Tested: bun run check:native
Tested: bun run verify
Confidence: high
Scope-risk: narrow
Replace the local pre-push quality gate with a reminder-only hook, keep manual quality commands documented, and update the contract test to prevent reintroducing a blocking push gate.
Tested: bash .git/hooks/pre-push </dev/null
Tested: bun test scripts/git-hooks/install.test.ts scripts/pr/quality-contract.test.ts
Tested: bun run check:policy
Tested: bun run check:docs
Tested: git diff --check
Confidence: high
Scope-risk: narrow
Use OS-assigned ports for server integration tests and publish the actual Bun server port after startup, preventing local port collisions from failing server checks.
Tested: bun test src/server/__tests__/e2e/business-flow.test.ts
Tested: bun test src/server/__tests__/e2e/full-flow.test.ts src/server/__tests__/conversations.test.ts src/server/__tests__/tasks.test.ts src/server/__tests__/haha-openai-oauth-api.test.ts
Tested: bun run check:server
Confidence: high
Scope-risk: narrow
Use container queries for the activity summary grid so the Token usage panel responds to its own width instead of the full desktop viewport. This prevents loose medium-width layouts and over-compressed five-column cards when the settings page is shown with sidebars.
Tested:
- cd desktop && bun run test -- src/pages/ActivitySettings.test.tsx --run --reporter=verbose --pool=forks --maxWorkers=1 --minWorkers=1
- cd desktop && bun run test -- src/theme/globals.test.ts --run --reporter=verbose --pool=forks --maxWorkers=1 --minWorkers=1
- bun run check:desktop
- Electron smoke at 1180px and 900px against the local dev server
Not-tested:
- Coverage report; this is a presentation-only desktop UI change.
Confidence: high
Scope-risk: narrow
The sidebar title area also needs to participate in Electron's CSS app-region chrome. Mark only the sidebar title/header region as draggable and keep the full sidebar body out of the drag region so session rows, search controls, and project actions remain normal interactions.
Constraint: Electron window dragging is driven by CSS app-region regions after the migration
Rejected: Keep the sidebar-wide startDragging mouse handler | Electron does not use that as the active drag path and it misses the left title gap
Confidence: high
Scope-risk: narrow
Directive: Keep sidebar body interactions outside the drag region unless a real titlebar affordance is added there
Tested: cd desktop && bun run test src/components/layout/Sidebar.test.tsx --run
Tested: bun run check:desktop
Separate quarantine review enforcement from server and coverage file selection so expired review dates fail only the governance lane.
Refresh stale server quarantine suites and keep only the live provider test quarantined for non-live PR gates.
Tested: bun run check:policy
Tested: bun run check:server
Tested: bun run check:coverage
Tested: bun run verify
Tested: git diff --check
Confidence: high
Scope-risk: moderate
Drop the explanatory comment added earlier - notifications work as-is, so the
notification service stays byte-for-byte identical to main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a CSP meta tag that hardens default-src/object-src/base-uri while keeping
script/style 'unsafe-inline'+'unsafe-eval' (Emotion CSS-in-JS injects runtime
<style> tags, the startup watchdog is inline, shiki/mermaid/Vite use eval) and a
permissive connect-src (localhost sidecar + ws + https) so renderer fetches and
dev HMR keep working. Needs a runtime smoke on dev + packaged builds before any
tightening.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- serverRuntime injects CLAUDE_CODE_POWERSHELL_PATH from the user's chosen shell
(readDesktopTerminalConfig + resolveDesktopTerminalShell) on Windows, so the
agent PowerShellTool honors the same shell as the UI terminal. Best-effort:
never blocks startup, never overrides an explicit env var, only forwards
pwsh/powershell selections (not cmd/custom). Regression from the Tauri build.
- document that notificationPermissionState reflects OS capability, not macOS
authorization (no Electron API exists; the 'failed' lifecycle is the real
signal) instead of calling the non-existent systemPreferences.getNotificationSettings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
killSidecar gains a sync flag (spawnSync taskkill on Windows); serverRuntime
stopAll threads it through; before-quit now shuts down synchronously so the
Windows taskkill completes before the process exits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- applyWindowsAppUserModelId() so Windows attributes toast notifications to the
app (kept in sync with build.appId via a test); no-op on macOS/Linux
- main window: setWindowOpenHandler denies uncontrolled popups, routes http(s)
links to the system browser; intentionally no will-navigate guard so dev HMR
reloads keep working
- preview view: denies popups + blocks non-http(s) navigation (file:/custom
schemes) while allowing in-page http(s) browsing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- mac.notarize=true + hardenedRuntime + entitlements so a signed CI release
actually notarizes (gatekeeper smoke + Squirrel.Mac auto-update need it)
- entitlements grant disable-library-validation for the Bun sidecar/node-pty
- local unsigned build passes -c.mac.notarize=false so electron:package still
works without an Apple account
- release signing-preflight now hard-requires only the Apple secrets; Windows
cert is optional (unsigned NSIS still auto-updates, just SmartScreen warning)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bare absolute paths like /icons/bilibili.svg failed under Electron's
file:// protocol. Route them through publicAssetPath so they resolve
against the relative base URL, matching the GitHub and app icons.
The Electron packaging output directory is generated locally and should stay out of source control alongside the existing desktop build output ignores.
Constraint: desktop/electron-dist/ is local build output, not a product source artifact.
Confidence: high
Scope-risk: narrow
Tested: git check-ignore -v desktop/electron-dist/
Tested: git diff --check
Electron handles custom draggable chrome through CSS app-region rules, not the old runtime startDragging path. Mark the tab strip and empty scroll gutter as native drag regions while keeping tab items and controls explicitly no-drag so tab reordering and close/tool buttons keep receiving pointer events.
Constraint: Electron drag regions swallow pointer events unless interactive children are marked no-drag
Rejected: Keep calling startDragging from the empty gutter | Electron desktopHost does not expose that as the active migration path
Confidence: high
Scope-risk: narrow
Directive: Do not mark tab items themselves as drag regions without revalidating tab reorder behavior
Tested: cd desktop && bun run test src/components/layout/TabBar.test.tsx --run
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
/agent should behave like a normal session prompt that tells the main model to use the selected Agent tool. The previous forked command path buffered the subagent run behind local command output and forced desktop clients to wait for command completion instead of seeing the normal Agent interaction stream.
Constraint: Desktop /agent must preserve ordinary chat/session semantics and still require /agent <agent> <prompt>.
Rejected: Keep slash_agent SDK event bridging | it preserves the separate forked execution path instead of fixing the product flow.
Confidence: high
Scope-risk: moderate
Directive: Do not move /agent back to context: fork without proving the desktop normal Agent tool stream still works.
Tested: bun test src/commands/agent.test.ts src/utils/processUserInput/processSlashCommand.test.ts src/server/__tests__/ws-memory-events.test.ts
Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx
Tested: git diff --check
Not-tested: bun run check:server is blocked before source checks by expired quarantine entries: server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow
The chat reference action was still tied too closely to message-local
mouseup timing, and the floating fixed-position control stayed inside
virtualized message DOM. In Electron Chromium that made multi-line ranges
race selection settlement and let transformed ancestors offset the button
away from the viewport position we calculated.
This reads settled document selections after pointerup/selectionchange,
portals the action to document.body, and prefers right-side placement for
multi-line selections.
Constraint: Electron Chromium selectionchange can settle after message-local mouse events
Rejected: Keep the popover inside the message node | transformed/virtualized ancestors offset fixed positioning in real browser layout
Confidence: high
Scope-risk: narrow
Directive: Keep chat selection actions portaled; do not reparent under virtualized message rows without browser-coordinate verification
Tested: cd desktop && bun run test -- src/components/chat/MessageList.test.tsx
Tested: bun run check:desktop
Tested: Playwright Chromium smoke for multi-line selection, single-line selection, and outside-click dismissal on local desktop harness
The desktop /agent slash path runs before the normal query loop, so the old implementation waited for the forked command to finish and only surfaced the final local-command stdout. Emit foreground slash-agent task events from /agent, drain SDK events while QueryEngine waits for pre-query slash processing, and allow those current-turn events through the WebSocket pre-send mute gate.
Constraint: /agent is a forked slash command that does not produce ordinary assistant deltas until its final stdout is synthesized.
Rejected: Frontend-only loading state | would not expose real forked-agent progress or tool/tool-result updates.
Confidence: high
Scope-risk: moderate
Directive: Keep slash_agent task events scoped to /agent; ordinary slash command stdout and /goal event handling should remain on the existing local-command paths.
Tested: bun test src/QueryEngine.test.ts src/utils/processUserInput/processSlashCommand.test.ts src/server/__tests__/ws-memory-events.test.ts
Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx
Not-tested: bun run check:server is blocked by expired quarantine review entries: server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow
Async title refresh runs after assistant output, so provider prompts can be biased by assistant language and English title examples. The title language now resolves from the first meaningful user message before consulting the response-language fallback, then retries once when a generated title violates that language.
Constraint: Desktop response-language settings still control assistant prose, not automatic title metadata when the first user message has a clear language
Rejected: Follow response-language setting for every generated title | Chinese sessions flip to English when replies are configured in English
Rejected: Persist mismatched generated titles after retry failure | preserves a bad UX over the safer first-message placeholder
Confidence: high
Scope-risk: moderate
Directive: Do not change automatic title language precedence without re-testing image/text first-turn sessions and English-response-language sessions
Tested: bun test src/server/__tests__/title-service.test.ts
Tested: bun test src/server/__tests__/conversations.test.ts -t "refreshes the first-turn AI title"
Tested: git diff --check
Tested: real MiniMax-M3 /tmp session 58a47fcd-6f9d-4685-bc73-f9317e3f330e with temporary response language english; assistant replied English and final aiTitle was 标题语言验证
Not-tested: bun run check:server blocked before tests by expired quarantine entries server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow
Desktop title generation previously upgraded the first sidebar title from the raw user prompt before assistant output existed. That made image-plus-text sessions vulnerable to keeping a title based only on the user's initial wording. This moves polished title generation to completed turns, reuses the CLI title prompt and transcript extractor, and routes title calls through the configured haiku model with a retry when disabled thinking is rejected.
Constraint: The first user message still needs an immediate placeholder title so the sidebar is responsive.
Rejected: Generate the AI title before assistant output | image-derived context is only available after the assistant turn completes.
Confidence: high
Scope-risk: moderate
Directive: Keep polished automatic titles tied to completed transcript turns, not pre-response user prompts.
Tested: bun test src/server/__tests__/title-service.test.ts
Tested: bun test src/server/__tests__/conversations.test.ts -t "refreshes the first-turn AI title"
Tested: git diff --check
Tested: Real MiniMax-M3 /tmp session f89073ed-3467-492a-b63c-e1886c146a3a with image attachment wrote ai-title "Validate session title generation"
Not-tested: bun run check:server is blocked by existing expired quarantine manifest entries