…nt authorable app (objectui#10926) (objectstack-ai#10950)
Fixes objectstack-ai#10926
Clause-②: no
The console's assistant dock now binds its **build** thread to the
current app, per the maintainer ruling 「绑定当前应用(推荐)」 (epic session,
2026-09-28). The ADR amendment that records the ruling is its own
governed draft PR, objectstack-ai#10947. This PR has code, tests and a changeset only.
## What changed
- **`layout/ChatDock.tsx`**, the dock's default body
(`ChatDockConversation`):
- When the resolved agent is `build` and the current app's package is
authorable, the dock passes that package as `editPackageId`. That makes
its scope `chatConversationScope({ appId: PKG, product: 'build' })`,
which is `app:PKG:build`: the key the Studio copilot and
`/ai/build?package=PKG` resolve.
- "Authorable" is the A1.b switcher's own predicate,
`isPlatformBuiltinApp` (the `com.objectstack.*` prefix). There is no
second resolver.
- `ChatPane` receives `editPackageId`, so the chip, the edit-mode empty
state and `context.packageId` name the app.
- `ask`, a built-in app and no app keep the product-only key.
- The dock runs the same A1.b migration read the `?package=` entry runs
(`legacyScope` + `adoptLegacy`). Without it, a dock that resolves first
would mint a fresh `app:PKG:build` thread, and the full page's adoption
of a product-only thread bound to PKG could then never happen.
- The pane receives only a scope-matched conversation, the same gate
`AiChatPage` uses. Moving between apps now changes the scope while the
dock stays mounted.
- The panel and the mobile sheet pass the bound package to `onMaximize`.
- **`layout/ConsoleLayout.tsx`**:
- Hands the dock `activeApp?._packageId` (both presentations).
- `openDockFullPage(boundPackageId)` navigates to
`/ai/build?package=PKG` with the Studio door's one-shot opt-out
(`objectstack:ai-full-page-requested`). Without the opt-out, a thread
that built the app would bounce straight to Studio on arrival
(objectui#5799).
- With no bound package it navigates to `/ai`, unchanged.
- **`console/ai/AiChatPage.tsx`**: the adopt predicate becomes the
exported `isThreadBoundToPackage(messages, packageId)`, so the full page
and the dock share one predicate. `adoptLegacyBuildThread` calls it, and
its behaviour is unchanged. No other region of the file is touched.
- **`hooks/chatScope.ts`**: module doc only (names the dock as a third
`app:X:build` surface).
- `.changeset/10926-dock-binds-build-thread-to-app.md`:
`@object-ui/app-shell` minor.
No new package-entry export and no new public prop. `ChatDockPanel` and
`ChatDockMobileSheet` are not exported from the package entry;
`isThreadBoundToPackage` is a module export of `AiChatPage.tsx`, like
its neighbour `deriveBoundPackageId`. No locale key moves.
## Deviation: one file outside the claim's declared surface
The claim's file surface did not list `layout/ConsoleLayout.tsx`. It
came from the dispatch assumption that "`editPackageId` is read in
`chatScope.ts` and `layout/ChatDock.tsx`". Measured on base `af2221d`,
`ChatDock.tsx` never read `editPackageId` and passed `appId: undefined`.
The current app (`activeApp`) and the maximize navigation
(`openDockFullPage`) live only in `ConsoleLayout.tsx`. The ruling cannot
be delivered without touching that file, short of a second current-app
resolver, which the dispatch ruled out.
What was touched there: two `appPackageId` prop lines and the maximize
target. PR objectstack-ai#10924 (landed) changed a different hunk of the same file,
the FAB line. `main` was merged twice (`7e8b3c0`, then `5c94589`), both
cleanly.
## Tests (head `8d4efd6`)
New `layout/__tests__/ChatDock.appBinding-10926.test.tsx` renders the
real dock body, the real `ChatPane`, the real `useChatConversation` and,
for the sharing pin, the real `AiChatPage`, over a `fetch` stub. It has
11 tests:
- The chip and the empty state read the app label, and
`context.packageId` is the app's package.
- The key is `app:app.crm:build`, and the product-only key stays empty.
- The dock and `/ai/build?package=X` settle on the same conversation id.
- A thread already keyed by another `app:X:build` surface is resumed.
- `ask` stays ambient.
- A `com.objectstack.*` app and no app keep `build`.
- A product-only thread bound to X is adopted, and its key is left in
place. One bound elsewhere is not adopted.
- Switching apps never pairs the old thread with the new package.
- Both maximize paths carry the bound package.
New `layout/__tests__/ConsoleLayout.dockBinding-10926.test.tsx` has 3
tests: the package reaches the rail and the sheet, a bound maximize goes
to `/ai/build?package=app.crm` with the opt-out, and an unbound maximize
goes to `/ai` with no opt-out.
Runs, from the repo root through the shared verify lock:
- `pnpm exec vitest run --maxWorkers=2` over `layout/__tests__/`,
`console/ai/__tests__/`, `console/__tests__/`,
`views/studio-design/__tests__/`, `chrome/`,
`hooks/__tests__/{chatScope,useChatConversation,surfaceAgent}` and two
`src/__tests__` pins: **Test Files 102 passed (102), Tests 750 passed
(750)**, exit 0.
- `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc
-p tsconfig.test.json`): exit 0. Both new test files are in the test
program (`--listFilesOnly`).
- The dependency closure was built first (`turbo run build
--filter=@object-ui/app-shell^...`): 28/28 successful.
Not run locally: the full 844-file app-shell suite. CI runs it.
**Ablations.** Each was committed first, then mutated through
`ablation-replace.mjs` with the counts expected before running. After
each, the restore was proven: blob equals HEAD and `git diff HEAD` is
empty. No `dist` is involved, because the subjects are imported by
relative path.
1. Binding disabled (anchor `product === 'build' && appPackageId` →
`product === 'ablated' && appPackageId`): expected 8 failed / 3 passed,
**observed 8 failed / 3 passed**. The ask, built-in and no-app cases
stay green. A first attempt was refused by the tool because the
replacement contained the anchor, so nothing ran.
2. Scope-match gate removed (`const scopeMatched = conversationScope ===
scope;` → `const scopeMatched = true;`): expected 1 failed / 10 passed,
**observed 1 failed / 10 passed** (the app-switch pin).
3. Bound maximize branch removed in `ConsoleLayout` (`if
(!boundPackageId) {` → `if (boundPackageId === boundPackageId) {`):
expected 1 failed / 2 passed, **observed 1 failed / 2 passed**.
## Gates (head `8d4efd6`, exit codes)
- `node scripts/check-changeset-presence.mjs`: 0
- `node scripts/check-changeset-no-major.mjs`: 0
- `node scripts/check-governed-queue-guard.mjs --test` over the 7
changed paths: 0, NOT GOVERNED
- `check:new-line-citations`: 0
- `check:control-bytes`: 0
- `check:vi-mock-specifiers`, `check:vi-mock-inherit`,
`check:vi-mock-override-shape`: 0 each
- `check:changeset-claims`, `check:pending-changeset-literals`: 0 each
- `check:i18n-keys`, `check:test-path-roots`,
`check:unreferenced-sources`, `check:self-import`, `check:phantom-deps`:
0 each
- `eslint --no-inline-config` on the 6 changed TS/TSX files: exit 0, **0
errors**. This narrowing is a measurement, not a skip:
- The population is `eslint.config.js`'s `**/*.{ts,tsx}` block.
- The file count comes from `--format json`: 6.
- No `parserOptions.project` or `projectService` is configured, and the
repo's one custom rule reads no files. So the diff cannot move the
verdict of an untouched file.
- Against the base versions of the same files (fed through
`--stdin-filename`), the diff adds one
`react-refresh/only-export-components` warning, for the new exported
predicate. The file already has 12 of that class, and there is no
warning ratchet.
## Acceptance notes
- **`sys_metadata`.** The ruling's authorable test is
`isPlatformBuiltinApp`, which does not exclude the DB-authored
`sys_metadata` pseudo-package that `appRoute.ts`'s `studioPackageId`
refuses. An app carrying that `_packageId` would bind to
`app:sys_metadata:build`, as the A1.b switcher already lists it. Not
measured whether any app list item carries it. Carrier: none; noted here
only.
- **Thread per app.** Each authorable app a maker opens with the dock
expanded mints its own build conversation on first open, as the Studio
copilot does per package. The rows are listed in the `/ai` sidebar.
- **Bare `/ai/build`** is untouched. It still resolves the product-only
key, and the code did not require changing it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Refs #10899 — six of its seven items land here; item 4 needs a maintainer decision and the replay-ordering half of item 5 is root-caused but not changed, so the card stays open.
Part of objectstack-ai/cloud#2440
Clause-②: no
One commit per item, each with its own test, ablation and changeset. Session
https://claude.ai/code/session_786273a0-0246-4bb2-b026-bb37ba5d7295(dispatch branchclaude/issue-10899-hosted-console-ux-defects).Verdict per item
ConsoleLayout)sys_commentwrite resolves; a failed write shows a localized toast and the composer keeps the draftqueryDatasetthrows a typedAnalyticsForbiddenErrorfor 403PERMISSION_DENIED;DatasetWidgetrenders a localized no-access state. The missing grant itself is cloud#2439What each commit does
versionPrecedence.tsorders two versions by SemVer 2.0.0 §11 (the grammar the package manifest declares forversion).cloudUpdateAvailableand the install dialog's per-environmenthasUpdateboth useisNewerVersioninstead of!==. Non-SemVer or equal pairs offer no update.ConsoleLayoutnow renders it only whiledock.expandedis false, which is the ruleChatDockLauncheralready follows.PageView) and the top-bar hammer (AppHeader) required only the workspace-admin role, and an org owner holds that role. Both now also requiremanage_metadatathroughuseCanAuthorMetadata, which readsGET /api/v1/auth/me/permissions. HomePage's builder CTAs already use the same check. If the server reports nosystemPermissions, the check fails open, so older servers keep the admin-only behaviour.cloud_control_plane_member(no system permissions) andorganization_admin_no_bypass=manage_org_users, setup.access, setup.write. Framework'sorganization_adminwithholdsmanage_metadataon purpose. So the server's answer was correct and the console did not read it.RecordDetailViewno longer adds optimistic rows. It awaits thecreate, then appends the row. On failure it showstoast.error(detail.commentFailed)(new key, all ten packs) and rejects back to the caller.RecordActivityTimeline,ThreadedReplies,RecordComments) now handle a rejectedonAddComment/onAddReplyexplicitly: they keep the draft. Before this, the throw escaped as an unhandled rejection.RecordDetailView.feedRecordScope.test.tsxhad a case that asserted the opposite of the ruling ("keeps an unpersisted comment when the write never lands"). It is rewritten to the ruled behaviour.queryDatasetgains a code-based branch for the analytics read admission's403+PERMISSION_DENIED(readAdmissionDeniedErrorin@objectstack/service-analytics). The newAnalyticsForbiddenErrorcarrieshttpStatus: 403andcode: 'PERMISSION_DENIED'.DatasetWidgetclassifies the error with the sharedclassifyLoadError, the same classifier the list view uses. Forforbiddenit rendersdashboard.widgetForbiddenTitle/widgetForbiddenMessage(new, all ten packs). A 403 with no code keeps the generic error.(called …)placeholder.ChatbotEnhancednow shows the 执行过程 note only when the turn shows no tool row and no build or design panel. The copy/regenerate bar renders only for a turn with visible prose. The chore(deps-dev): bump storybook from 8.6.15 to 8.6.17 #772 fallback, where a placeholder turn with nothing else keeps its note, is preserved and pinned.Item 4 — why it is not changed here
The in-app dock runs the build agent on an app-less, product-only conversation scope (
ChatDock.tsx,chatConversationScopewith noappId). It sends no package binding. So in ADR-0057 Amendment A1's terms the chip is telling the truth: the thread is unbound, and "New app" is the unbound label. A1.b also records the dock keeping the legacy product-only key as a decision. Making the chip name the current app means either:editPackageId+app:PKG:buildscope). The agent then gets a declared write target, and the dock,/ai/build?package=and the Studio copilot share one thread. This reverses the A1.b decision.Recommendation: A, for the build product only, and only when the active app is an authorable package. Details are in the report on the card. It changes a decided ADR line, so it waits for the maintainer.
Item 5 — replay ordering (measured, not changed)
I read the rig tenant DB (
ai_messages, read-only). The follow-up proposal turn (EFYj) was still persisting its lasttodo_writeand final prose at 04:36:42–44Z. The user's 「确认,开始搭建。」 (turn dh2L) was persisted at 04:36:39Z. The conversation store orders bycreated_at, idacross turns (cloudpackages/service-ai/src/conversation/objectql-conversation-service.ts,MESSAGE_ORDER). The replay therefore puts the confirm message inside the previous turn's steps, while the live run showed that turn whole.Two things contribute:
ChatbotEnhanced).Both are named in the report.
Tests
All runs are on HEAD
d8969d4bcunless noted.git diff HEADempty):versionPrecedence-10899.test.ts,MarketplacePackagePage.updateIsHigherVersion-10899.test.tsx(ablation: 1 failed)ConsoleLayout.fabYieldsToOpenDock-10899.test.tsx(1 failed)PageView.editInStudioNeedsAuthoring-10899.test.tsx,AppHeader.designInStudioNeedsAuthoring-10899.test.tsx(2 failed)RecordDetailView.commentWriteFailure-10899.test.tsx+ the rewritten feed-scope case (3 failed). Composer half:composerKeepsDraftOnRejection-10899.test.tsx— with thecatchremoved, the run exits 1 on unhandled rejections while the draft assertions still pass. Keeping the draft used to be an accident of the throw.queryDataset.forbidden-10899.test.ts,DatasetWidget.forbiddenState-10899.test.tsx(2 failed)ChatbotEnhanced.hydratedStepTurns-10899.test.tsx(2 failed)vitest runoverpackages/app-shell/ packages/plugin-chatbot/ packages/plugin-dashboard/ packages/data-objectstack/ packages/plugin-detail/ packages/i18n/—Test Files 1409 passed | 2 skipped (1411),Tests 14850 passed | 23 skipped (14873), exit 0turbo run buildfor the six touched packages plus the@object-ui/app-shell^...closure — 29/29 successful.turbo run type-checkfor the six packages — 6/6 successful.check:i18n-keys,check:i18n-drift,check:i18n-dead-keys,check:new-line-citations(0 new),check:control-bytes,check-changeset-presence(6 changesets for 6 packages),check-changeset-no-major— all exit 0.eslint --no-inline-configover the 33 changed.ts/.tsxfiles. For every pre-existing file, the error/warning counts at HEAD equal its counts at the merge base (the only errors are 4 pre-existingno-consoleindata-objectstack/src/index.ts, 4/127 → 4/127). New files have 0 errors. Type-aware linting is not enabled, so this diff cannot change the verdict on any untouched file. The repo-wide lint belongs to CI.Acceptance notes
UNLOCKED (declared) · no usable flock on this host. Build, type-check and suites ran unserialized.handleToggleReactionstill swallows a failed reaction write (same optimistic shape as item 2; no carrier).ThreadedReplies) renders only under an item withreplyCount > 0, and no feed read sets that, so a loaded discussion offers no reply affordance.ObjectView, the report editor,ObjectDataPage) do not readmanage_metadata. Same class as item 6, not measured on the control plane.ObjectChart's dataset path,DatasetReportRendererandDashboardFilterBarstill print the (now typed, English) refusal message instead of a localized state.patch.Generated by Claude Code