Skip to content

fix(app-shell,react): converge the five three-rung titleField seams on the two-rung shape - #6573

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6557-objectview-titlefield-middle-leg
Aug 26, 2026
Merged

fix(app-shell,react): converge the five three-rung titleField seams on the two-rung shape#6573
os-support-ai merged 1 commit into
mainfrom
claude/issue-6557-objectview-titlefield-middle-leg

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#6557

ObjectView.tsx resolves a title field for seven view kinds and answered the same question two different ways. Two of them already used two rungs; five carried a three-rung chain with the object-level objectDef.titleField in the middle. This drops that middle rung, so all seven now read viewDef.<kind>?.titleField || 'name'. It is a convergence on a shape two sibling sites in the same object literal already had, not a removal.

The split, verified independently at 0a2918f3f

The dispatch order asked me to check its line numbers rather than trust them. Every one is exact:

linekindexpression before
164timelineviewDef?.timeline?.titleField || objectDef?.titleField || 'name'
2211kanbanviewDef.kanban?.titleField || objectDef.titleField || 'name'
2217calendarviewDef.calendar?.titleField || 'name' — already two-rung
2228mapviewDef.map?.titleField || objectDef.titleField || 'name'
2242galleryviewDef.gallery?.titleField || objectDef.titleField || 'name'
2256ganttviewDef.gantt?.titleField || 'name' — already two-rung
2264tree…tree?.labelField || …tree?.titleField || objectDef.titleField || 'name'

:2217 and :2256 are untouched: they are the evidence.

Why the middle rung could never fire legally

Re-measured here against @objectstack/spec@17.2.0 (the dist this repo installs), using ObjectSchema from @objectstack/spec/data:

ObjectSchema.safeParse({ name:'account', label:'Account', fields:{…} }) → accepted
ObjectSchema.safeParse({ …, titleField: 'name' }) → REJECTED
unrecognized_keys · Unrecognized key(s) on this object: `titleField`
ObjectSchema.safeParse({ …, nonsenseKeyXyz: 'zzz' }) → REJECTED unrecognized_keys
ObjectSchema.safeParse({ …, nameField / displayNameField / titleFormat }) → accepted

The object schema is a strictObject, so the key is not merely undeclared — it draws the same issue code a nonsense key gets, while the three declared pointers all parse. objectui#6531 established that measurement and dropped the twin read inside getRecordDisplayName (merged as c00bf2861).

Escalation clause (triage's, still binding): no live legal config depends on the middle rung. A repo-wide grep for object-shaped titleField producers found none — every titleField in examples/, packages/*/src and the JSON schema catalog is a view-level key (calendar / gantt / map / timeline / gallery / kanban config), which is real, declared, and untouched here. The only object-shaped occurrence in the repo was the phantom test fixture retired below.

Options 2 and 3 were rejected, with reasons

  • Option 2 — re-point at nameField. It adds a rung calendar and gantt do not have, so it increases the divergence this card exists to close; and unlike option 1 it changes behaviour for legal configs (an object declaring nameField: 'x' would resolve x on five kinds and 'name' on the other two).
  • Option 3 — hand the seam to the resolver. Wrong layer. ObjectMap's seam is render time (a title for a record); these five are config time — they decide what a view is configured with, and that value flows onward into published view-config types. A record-title resolver still has to produce a concrete field name, so this relocates the decision rather than removing it.

What changed

  • packages/app-shell/src/views/ObjectView.tsx — the five seams. timelineViewOptions also loses its now-dead objectDef parameter (the root tsconfig sets noUnusedParameters, and a retained-but-unread parameter would read as "the object still matters here"); its one call site is updated.
  • Prose — three sites, not one. The dispatch named :152. Two more describe the same deleted rung and would have gone stale in the same edit: the call-site comment at :2222 ("forwards what the view declared plus the object's title field") and the tree comment at :2261 ("labelField falls back to the object title"). All three corrected.
  • packages/react/src/hooks/useRecordSearch.ts — the candidate signature, the memo key that decides when the cross-object fanout re-runs, appended o?.titleField ?? '' to every entry. Because no legal object definition can carry the key, that half was permanently '': a constant suffix in a cache signature, and the last thing in the repo that read like evidence some producer ships the key. The signature is now the object name alone — the only field of an object definition the effect consumes (dataSource.find(obj.name, …) and the objects whitelist sent to searchAll; label / icon are read per hit at render time and never decide re-runs).
  • Fixture triage — two phantoms retired. Both spelled titleField: 'name', which is also the literal floor, so their assertions resolved identically with the rung deleted: they passed while measuring nothing. useRecordSearch.test.ts:11's account fixture (the one the card names), and — found while working — the shared objectDef in ObjectView.timelineBinding.test.tsx, whose comment credited the object with supplying the title. The removal is pinned with a distinguishable value ('headline') in the new suite instead.
  • A changeset (@object-ui/app-shell, @object-ui/react, both patch).

Nothing in packages/core.

Verification — both directions, as the dispatch required

packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx renders the real page with a capturing ListView and reads all seven resolved seams from one render, so the fix and its controls are measured on the same instrument:

  • THE FIX — an object carrying titleField: 'headline' is honoured by no kind; all seven answer 'name'.
  • CONTROL — a legal view that declares nothing still floors at 'name' (all seven).
  • CONTROL — a legal view's own declared titleField still wins on every kind, with a distinct value per kind so a seam reading the wrong kind's config fails instead of passing by coincidence. The object also carries the rejected key here, so this doubles as proof the removal did not simply hard-code the floor.
  • CONTROL — the tree's second view-declared rung (tree.titleField) still answers; dropping the object rung did not collapse the two view rungs into one.
  • SHAPE — a source-level block asserts there are exactly seven titleField: / labelField: assignments in the file, that none mentions objectDef, and that each is a chain of viewDef rungs ending || 'name',. That is the invariant this card really restores, and it is what stops a new view kind copied from a sibling reintroducing the rung. (Structural on purpose: four of the seams are closures inside ObjectViewInner, and "these five now have the same shape as those two" is a statement about expressions, not one pair of values — same posture as ObjectView.viewConfigGate.test.ts.)

useRecordSearch.test.ts gets the same treatment behaviourally: the signature feeds the effect's dependency array, so a rerender with a new array differing only by the rejected key must not re-fire the fanout (the fix), while a changed candidate name still does and an identical-content array still does not (the controls).

Reverse verification — direction predicted before running, then observed

Run from the committed fix, restoring both source files from the merge-base 0a2918f3f and putting them back with git checkout HEAD -- <paths>:

MUTATION LEG — on-disk confirmation before measuring
ObjectView objectDef.titleField reads 5 (expected 5)
ObjectView 'objectui#6557' marker 0 (expected 0)
useRecordSearch old memo half 1 (expected 1)
blob hashes differ from the HEAD blobs yes
vitest → exit 1 · Tests 3 failed | 24 passed (27)
× THE FIX: an object-level `titleField` is honoured by NO view kind
× none reads the object definition
× does NOT re-run when the only difference is the contract-rejected `titleField`
RESTORE LEG — on-disk confirmation
hash-object == HEAD blob for both files (29936e6d5…, b87f1b593…)
git diff HEAD -- <both paths> empty
vitest → exit 0 · Test Files 2 passed · Tests 27 passed (27)

Exactly the three fix-direction assertions go red and every control stays green — which is what separates "the fix works" from "the fallback chain is broken". No rebuild was needed for either leg: both suites import the edited modules by relative path, so vitest transforms the source directly.

Gates run locally, at eb5ddeb26

Every verdict below is the gate's own reported result, with the exit code captured before any pipe.

gateresult
pnpm turbo run build --filter='@object-ui/app-shell...' --filter='@object-ui/react...'29 successful, 29 total
targeted suite (107 files)Test Files 107 passed (107) · Tests 1361 passed (1361)
pnpm --filter @object-ui/app-shell type-checkexit 0 (tsc --noEmit && tsc -p tsconfig.test.json)
pnpm --filter @object-ui/react type-checkexit 0
pnpm --filter @object-ui/app-shell lint✖ 2746 problems (0 errors, 2746 warnings)
pnpm --filter @object-ui/react lint✖ 358 problems (0 errors, 358 warnings)
pnpm check:control-bytes✅ OK (scanned 5427 tracked text file(s))
pnpm check:vi-mock-specifiers✅ OK (3847 tracked source file(s), 459 carry a mock)
node scripts/check-changeset-presence.mjs✅ 5 source file(s) of 2 released package(s) changed … declares 1 changeset(s)
node scripts/check-changeset-no-major.mjsexit 0

type-check really does cover the new tests. The package tsconfig.json excludes **/*.test.ts*, so "typecheck is clean" would otherwise say nothing about them. tsc -p tsconfig.test.json --listFiles reports 1 hit each for ObjectView.titleFieldConvergence.test.tsx, ObjectView.timelineBinding.test.tsx and useRecordSearch.test.ts — they are in the program.

Declared narrowing

Two repo-wide runs were narrowed, and CI runs both in full regardless:

  • Tests.packages/react/ was run entire (60 files, no narrowing — it is the package whose hook changed). For app-shell (554 test files) the run set was measured, not guessed: a script computed the reverse-import closure of views/ObjectView.tsx, chrome/CommandPalette.tsx and views/SearchResultsPage.tsx over every relative import in packages/app-shell/src (31 modules → 24 test files), unioned with every app-shell test that reads the source tree via readFileSync / readdirSync / globSync (the ratchet-and-guard family, which can see the diff without importing it) and every views/ObjectView*.test.*. 47 app-shell files + the react package = the 107 above.
  • Lint.turbo run lint fans out to a per-package eslint .. The two changed packages were linted in full — --format json reports 995 files (app-shell) and 127 (react), 0 errors in both. The remaining packages are unaffected by construction: eslint.config.js configures no type-aware linting (no parserOptions.project / projectService), so a file's verdict is a function of that file plus the shared config, and this diff changes neither for any package other than these two.

Related, not touched

Filed unassigned while working this card: objectui#6572 — two doc comments in InterfaceListPage.tsx / InterfaceListPage.mapConfig.test.tsx still teach the precedence ladder as containing an objectDef.titleField rung. That drift comes from objectui#6531, not from this change, and lives outside this file face, so it is a separate card rather than a rider here.


Generated by Claude Code

… on the two-rung shape
`ObjectView.tsx` resolves a title field for seven view kinds and answered the
same question two ways. Calendar (`viewDef.calendar?.titleField || 'name'`) and
gantt already used two rungs; timeline, kanban, map, gallery and tree carried a
three-rung chain with the object-level `objectDef.titleField` in the middle.
This drops that middle rung, so all seven now read
`viewDef.<kind>?.titleField || 'name'` — a convergence on an existing shape,
not a removal.
No legal metadata could reach the rung: `@objectstack/spec`'s object schema is
a `strictObject`, so `ObjectSchema.safeParse({ …, titleField: 'x' })` is
rejected with `unrecognized_keys` — the same code a nonsense key gets — while
`nameField`, `displayNameField` and `titleFormat` all parse (re-measured here
against `@objectstack/spec@17.2.0`). objectui#6531 established that and dropped
the twin read inside `getRecordDisplayName`.
`useRecordSearch`'s candidate signature appended `o?.titleField ?? ''` to every
entry, a half that could never vary. The signature is now the object name
alone — the only field of an object definition the fanout effect consumes.
`timelineViewOptions` loses its now-dead `objectDef` parameter (root tsconfig
sets `noUnusedParameters`), and three doc comments that credited the object
with supplying the title field are corrected.
Fixture triage: two phantom fixtures spelled `titleField: 'name'`, which is
also the literal floor, so their assertions resolved identically with the rung
deleted. Both retired; the removal is pinned with a distinguishable value in
the new convergence suite instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM review of #6557, done from the tree.

The convergence

All seven seams now read viewDef.<kind>?.titleField || 'name':169 timeline, :2216 kanban, :2222 calendar, :2233 map, :2247 gallery, :2261 gantt, :2270 tree. Calendar and gantt untouched, as instructed; the other five joined them. Code reads of the object-level key: 5 → 0, with the single surviving mention at :153 inside past-tense prose ("used to sit in the middle of that") — the correct form, and the same discipline #6548 established.

A correction to my own dispatch order's arithmetic. My census said five sites, and it was right — but anyone re-checking it with grep -c "objectDef.titleField" gets 4 on main, because . is a regex wildcard that cannot match the two characters in objectDef?.titleField, and the timeline site at :164 is the one written with ?.. The pattern that answers correctly is objectDef\??\.titleField. Flagging it because the wrong pattern reads like a discrepancy with this PR rather than a fault in the reader.

You corrected the prose at three sites; I named one

:152 was in the order. :2222 ("forwards what the view declared plus the object's title field") and :2261 ("labelField falls back to the object title") were not — and both describe the rung being deleted, so both would have gone stale on merge. Finding the prose that a code change falsifies, beyond what the order lists, is the same discipline #6530 needed and is exactly what keeps this class of card from recurring.

Dropping timelineViewOptions' now-dead objectDef parameter and updating its call site is right too: noUnusedParameters would have caught it, but leaving a parameter nothing reads is how the next reader concludes the layer still consults the object.

⭐⭐⭐ Two phantom fixtures, and this is the third instance today

Both were spelled titleField: 'name'and 'name' is also the literal floor, so both assertions resolved identically with the rung deleted. They pinned nothing.

The one in ObjectView.timelineBinding.test.tsx you found while working, and its comment was actively teaching the wrong thing: "The object's declared title field is the one thing this layer still contributes." False before your change and false after. Your replacement tombstone says why the old fixture was vacuous and points at where the real distinction now lives — 'name' vs 'headline', "where 'name'-vs-'headline' can actually tell the two worlds apart."

This is the third fixture of this exact shape retired in this lane today (#6531 had honors objectDef.titleField when set with the same 'name' collision). The pattern is now named: a fixture whose chosen value coincides with the fallback pins nothing, and reads as coverage. In all three the surrounding comment claimed the object supplied the title, so the prose vouched for the vacuous assertion.

Verification

The new suite reads all seven seams from one render, with a distinct value per kind so a seam reading the wrong kind's config fails instead of passing by coincidence — and the object carries the rejected key in that same case, so it doubles as proof the floor is not hard-coded. That is the both-directions requirement met properly rather than nominally.

The ablation reds exactly the three fix-direction assertions with every control green, restored by blob-hash equality and an empty git diff HEAD rather than by an exit code.

⭐ And you reported that one annotation in your own ablation script was wrong — the restore-leg check expected 0 for the retired memo half and printed 1, because the restored file's prose quotes the retired expression. That is the "a string hit is not the claim" trap firing inside a verification script, and saying so rather than dropping it is what lets me weigh the rest.

Escalation clause

Not triggered, and established rather than assumed: ObjectSchema rejects titleField with unrecognized_keys while nameField / displayNameField / titleFormat parse, and a repo-wide sweep found every other titleField to be a view-level key. No legal config depended on the rung.

#6572 filed for the InterfaceListPage drift — correctly identified as drift from #6531 rather than this card, and left out of the file face.

Landing on green.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3235.1 KB3266.6 KB
Main entry chunk (gzip)157.0 KB350 KB
Entry fileindex-BlSeHZ_-.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.71KB4.46KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.01KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.91KB12.92KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.85KB20.79KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)63.21KB21.05KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-support-ai@claude