Skip to content

Thread forking + session-based side chats + timeline scroll preservation#85

Open
brsbl wants to merge 40 commits into
mainfrom
otto/thread-forking-and-side-chats
Open

Thread forking + session-based side chats + timeline scroll preservation#85
brsbl wants to merge 40 commits into
mainfrom
otto/thread-forking-and-side-chats

Conversation

@brsbl

@brsbl brsbl commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Implements the approved spec thread-forking-and-side-chats-e159 end to end: fork a thread from any agent message, spawn context-aware side chats in a right-panel tab, and preserve timeline scroll position across thread navigation. Built and reviewed across five sessions (S1–S5) with per-batch and final adversarial reviews; all findings fixed.

What's in it

1. Fork (TrendingUpDownIcon, per-agent-message action) — creates a child thread, navigates to it, and renders the originating message as a "Message from {source}" anchor with the fork icon, composer focused. The forked agent does not auto-run — it waits for the user's first direction.

  • Fresh managed worktree branched from the source thread's branch HEAD; linked via parentThreadId + childOrigin: "fork"; provider/model/permission inherited.
  • A "Forks" lineage list in thread metadata (targeted parentThreadId+childOrigin query).
  • OQ1 resolved as Approach A: the server seeds the anchor turn without dispatching a provider run via requestThreadProvision + a seedWithoutRun flag.

2. Side chat (MessageAdd02Icon) — a child thread rendered in a kept-mounted right-panel tab, created lazily on the user's first message. Seeds the first turn with an agent-only snapshot of the main thread (spawning message + 3 preceding), runs readonly, and can "Send to main thread" (reuses cross-thread send).

3. Scroll preservation — in-memory per-thread anchor (by row id, not pixels); continuous throttled capture + restore that coordinates with the existing stick-to-bottom logic and survives the key={threadId} remount.

Contract / data changes

  • createThreadRequest: discriminated startedOnBehalfOf + childOrigin (server-boundary defaults), end-to-end through provisioning → thread-start dispatch.
  • Thread: nullable childOrigin column (migration 0024) + a server-derived canSpawnChild depth-cap field on the response.
  • A shared MessageActionBar (copy + fork + side chat) replacing the inline copy footer.

Security / correctness hardening (from review)

  • Boundary validation: startedOnBehalfOf ⇒ senderThreadId === parentThreadId; childOrigin ⇒ parentThreadId.
  • resolveMessageSenderThreadId now rejects cross-project senderThreadId (forgery/cross-project-write guard).
  • Fork/side-chat spawns excluded from project-execution-default remembering; childOrigin honored in optimistic list-cache inserts; double-submit re-entrancy guards.
  • Single-thread cache writers tightened to ThreadResponse so a future writer can't silently drop the required canSpawnChild/childOrigin.

Testing

  • Typecheck: 5/5 affected packages green.
  • @bb/app 149 files / ~1085 tests; @bb/db 281 (migration replay); @bb/server contract + send + seed-without-run + project-defaults suites green. (One unrelated AppTabContent iframe test flakes only under parallel load; passes in isolation.)

Notes

  • Side chats run in a same-project readonly worktree (their own checkout), a forced correction from the spec's original "personal workspace" idea, which 400s outside the personal project and would break the same-project parent/send-back invariants. Spec updated to record this.
  • The .otto/ directory (spec, tasks, per-session plans) is included as the work's audit trail and can be dropped from the PR if undesired.

🤖 Generated with Claude Code

brsbl and others added 15 commits June 10, 2026 05:30
Server/domain foundation, timeline message-action scaffolding, and
timeline scroll-position preservation for the thread-forking + side-chats
spec.

- S1: add startedOnBehalfOf to createThreadRequest; plumb it through
  provisioning so a forked thread-start turn renders as an agent-initiated
  "Message from {source}" and is seeded WITHOUT a provider run
  (OQ1 -> approach A via thread-provisioning). Add nullable childOrigin
  (domain + migration 0024 + persist + response + list filter) and a
  server-derived canSpawnChild depth-cap boolean. Foundation tests.
- S2: register Fork/SideChat icons; extract reusable MessageActionBar
  (reuses CopyButton, hover-reveal); forward row identity to assistant props.
- S5: in-memory per-thread scroll-anchor store; throttled capture +
  restore-by-row in BottomAnchoredScrollBody (converges on ResizeObserver
  settle, falls back to bottom); tests.

Typecheck 5/5 packages; @bb/db 281, @bb/app 1046, server-contract +
seed-without-run server tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- [P1] Boundary coherence + anti-forgery in createThreadFromRequest:
  startedOnBehalfOf requires parentThreadId and senderThreadId ===
  parentThreadId (transitively validated live + same-project); childOrigin
  requires parentThreadId. Side-chat combo (childOrigin + null
  startedOnBehalfOf) still valid. +5 tests.
- [P1] Scroll fallback-to-bottom now scrolls inline when the saved anchor
  row never appears (was relying on a later resize that may never fire);
  de-masked the test (mutation-verified).
- [P2] Log a warning when the seed-without-run provisioning->idle
  transition no-ops (stranded seed observable).
- [P2] Name the message hover-reveal group (group/message +
  group-focus-within) to match the disclosure/tab-pill convention.
- Correct the AppCreateThreadRequest comment: the server schema owns the
  null default at runtime; the client echoes null only to satisfy the
  output-typed wire body (input-typed contract is a noted follow-up).

Typecheck @bb/server, @bb/server-contract, @bb/app pass; boundary + scroll
+ message tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fork wiring and session-based side chats, built on the batch-1 foundation.

- S3 (fork): buildForkThreadRequest + useForkThreadFromMessage create the
  forked thread (anchor message; startedOnBehalfOf/childOrigin/parentThreadId
  all = source; managed worktree from the source branch HEAD; provider/model/
  permission inherited), navigate to it, composer auto-focuses on mount
  (Approach A: server seeds the anchor without a provider run). Fork button
  wired into the agent message-action footer with a canSpawnChild depth
  guard; forked anchor renders with the Fork icon via childOrigin; "Forks"
  lineage list in thread metadata via a targeted parentThreadId+childOrigin
  query. Consumes the row-identity props (resolves a batch-1 finding).
- S4 (side chat): side-chat panel tab kind; openSideChat opens a tab with a
  PromptBoxInternal composer (OQ4: it drives a non-route thread directly);
  lazy child-thread creation on first submit with an agent-only main-thread
  snapshot (spawning message + N=3 preceding), personal workspace, readonly;
  keep-mounted SideChatTabDeck (BrowserTabDeck pattern); send-back via the
  existing useSendThreadMessage (senderThreadId now forwarded). onSideChat
  wired next to onFork under the shared depth guard.

@bb/app typecheck pass; full app suite 149 files / 1075 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- [P0] Side chats now run in a same-project readonly worktree (shared
  child-thread-environment resolver, reused by fork + side chat) instead of
  a personal workspace, which 400'd in standard projects and broke the
  same-project parentThreadId/send-back invariants. Spec updated.
- [P1] Side-chat context snapshot anchors on the full source message text
  (persisted on the tab) instead of the truncated tab title, so the window
  centers on the real spawning message.
- [P1] Fork button is dropped (not inert) when the source has no host.
- [P1] Server validates senderThreadId is same-project as the target in
  resolveMessageSenderThreadId (rejects cross-project attribution forgery on
  the send/queued paths); +test.
- [P2] Fork icon only on the seed anchor row, not every cross-thread agent
  message; remove dead sourceTurnId field; synchronous re-entrancy guards
  against double-create (fork + side chat); deleted side-chat child shows a
  "no longer available" state instead of endless waiting.

@bb/app 149 files / 1084 tests; @bb/server typecheck + public-thread-data
55 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- [P1] Fork/side-chat child spawns no longer overwrite the project's stored
  execution defaults (childOrigin carve-out in
  shouldRememberProjectExecutionDefaults), so a side chat's forced readonly
  mode / inherited model can't silently become the project default. +test.
- [P2] optimisticallyInsertThread honors the childOrigin list filter, so a
  side chat no longer flashes in the source thread's "Forks" list before
  refetch. +test.
- [P2] Side-chat lazy create waits for a host-backed source's environment to
  resolve before building the request, avoiding a personal-workspace
  fallback that 400s in standard projects.

Deferred (noted in PR): tighten the single-thread cache write-side generics
to ThreadResponse (currently ThreadWithRuntime) — benign today, latent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the previously-deferred final-review P2: the threadQueryKey
(single-thread) cache slot is read as ThreadResponse but was written through
helpers typed ThreadWithRuntime, so a future writer could drop the required
canSpawnChild/childOrigin without a type error. Type updateCachedThread, the
create/send/stop transaction writers, and their previousThread fields against
ThreadResponse for that slot (list/sidebar entries stay ThreadWithRuntime).

Type-only change; typecheck clean and cache/mutation tests green (every
existing writer already supplied a full ThreadResponse).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the three P2 findings from the full-PR review:

- [P2] Couple startedOnBehalfOf and childOrigin at the create boundary:
  startedOnBehalfOf now requires childOrigin, so a seed-without-run thread is
  always a tagged child (can't be untagged or silently reshape project
  execution defaults). +test.
- [P2] hasAnyThreadMetadata accounts for the lazily-fetched Forks row (the
  gate now takes hasForks, computed via a deduped useThreads), so a forks-only
  thread no longer flashes the "No thread details" fallback while the
  environment query loads. +test.
- [P2] Gate side-chat "Send to main thread" on the child thread being idle, so
  a mid-stream partial can't be posted into the main thread.

@bb/server typecheck + boundary tests (9) green; @bb/app typecheck + full
suite (149 files / 1087 tests) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scroll-anchor capture effect's cleanup read scrollAnchorCaptureThrottleRef
.current directly, which eslint flags as possibly-stale. Capture the (stable,
never-reassigned) throttle-state object into a local inside the effect and use
it in the cleanup — same live trailingTimeout is cleared, lint clean.

@bb/app lint + typecheck pass; scroll-preservation tests (6) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve conflicts with 24 commits of main:
- ThreadMetadataContent.tsx: keep the Forks row + main's new
  environmentDisplayHost prop on EnvironmentRow.
- ThreadMetadataContent.test.tsx: combine the ForksRow/hasAnyThreadMetadata
  tests with main's EnvironmentRow tests and EnvironmentDisplayHostContext import.
- ProjectList.test.tsx: take main's deletion.
- environment-provisioning.test.ts: add the now-required childOrigin/
  startedOnBehalfOf to main's new createThreadFromRequest call.

Typecheck 6/6; @bb/app lint + full suite (150 files / 1093 tests); @bb/server
thread + environment suites (97) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Forking or opening a side chat from a personal-project thread failed with
"Personal project threads must use a personal workspace". resolveChildThread
Environment keyed on hostId !== null, but a personal-project thread has a host
running a *personal* workspace, so it built a managed worktree the server
rejects. Route to a personal workspace whenever the source's workspace is
personal (carrying the host), not only when there is no host.

Verified live: fork + side chat from a personal-project thread now 201
(was 400). +regression tests in both builders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the native title tooltips on the per-message copy / fork / side-chat
icon buttons with the Radix Tooltip primitive (faster, styled, consistent).
MessageActionBar wraps each button in a Tooltip under a local TooltipProvider.

CopyButton is now forwardRef + prop-spreading so it can be a Tooltip `asChild`
trigger; a caller can pass `title={undefined}` to drop the native tooltip when
supplying its own (its other 4 usages keep the default title). aria-labels are
retained on all three buttons.

@bb/app typecheck + lint + full suite (150 files / 1095 tests) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A side chat (or fork) is a child thread, so completing/failing a turn fired
bb's child->parent notification, posting a "child finished" system message into
the main thread (which the main agent then replied to). That's wrong for
user-initiated branches — the user drives them directly.

Extract isAgentDelegatedChildThread(thread) = has a parent AND childOrigin is
null, and gate all three child->parent notification sites on it
(turn-completion in internal/events, command-failure in thread-lifecycle,
needs-attention in internal/interactive-requests). Agent-delegated children
(childOrigin null) still notify their parent; forks/side chats do not.

@bb/server typecheck + thread-parent (incl. new helper tests) + child-thread-
notifications / interactive-requests / parenting suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nd-side-chats

# Conflicts:
#	apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx
#	apps/app/src/components/secondary-panel/useThreadFileTabs.ts
#	apps/app/src/components/thread/timeline/ConversationMessageContent.tsx
#	apps/app/src/components/ui/icon.tsx
#	apps/app/src/lib/api.ts
#	apps/app/src/lib/fixed-panel-tabs-state.test.ts
#	apps/app/src/lib/fixed-panel-tabs-state.ts
#	apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx
#	apps/app/src/views/thread-detail/ThreadDetailView.tsx
#	apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts
#	apps/server/src/services/threads/thread-provisioning.ts
#	packages/db/drizzle/meta/0024_snapshot.json
#	packages/db/drizzle/meta/_journal.json
The loopback/LAN firewall allows a same-origin subresource only when the
requesting frame's origin matches the committed main-frame origin, derived from
`details.frame.origin`. Electron reports a blank origin for a document's initial
subresource requests (before it has run script and committed its origin), so a
loopback SPA dev server's own JS modules were cancelled and the page rendered
blank.

Add resolveRequestingFrameLocalOriginKey: prefer the frame origin, and for a
top frame with a blank origin fall back to the frame's committed URL. The
fallback is top-frame-only, so a sub-iframe presenting a blank origin still
cannot be mistaken for the trusted main frame. Use it in the onBeforeRequest
firewall.

@bb/desktop typecheck + browser policy/view-manager/main-ipc suites green
(incl. new resolver unit tests + a same-origin-SPA-subresource regression that
also asserts a blank-origin sub-iframe stays blocked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tab-strip useMemo body uses main's consolidated activeFixedSecondaryTabId
(plus our activeSideChatTabId); drop the now-unused granular active*Path/Id
fields from its dependency list. Those fields are still consumed elsewhere for
file-preview content, so the hook destructuring keeps them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
brsbl and others added 14 commits June 11, 2026 01:36
…nd-side-chats

# Conflicts:
#	apps/app/src/components/thread/timeline/ConversationMessageContent.tsx
#	apps/app/src/views/thread-detail/ThreadDetailView.tsx
The parent thread's prompt-context banner counted every active child thread,
including user-opened forks and side chats, so opening a side chat showed
"1 active child thread" on the main thread. Filter the banner's child list to
agent-delegated children (childOrigin === null) — the same distinction used for
parent notifications — so only delegated work the parent is waiting on appears.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the side chat's single bottom "Send to main thread" button (which always
sent the latest reply, ambiguously) with a per-message action: each side-chat
agent reply now carries a "send to main thread" icon in its action bar, so the
user hands back exactly the answer they want.

Threads a ThreadTimelineSendToMainMessageHandler from the side-chat timeline host
(SideChatTabContent) through ThreadTimelineRows → ConversationMessageContent →
MessageActionBar, mirroring the existing fork/side-chat per-message wiring. The
action is supplied only inside a side chat (the main timeline has none) and is
gated on the child being idle so a mid-stream partial can't be posted. Delivery
still uses the cross-thread senderThreadId transport. Drops the now-dead
lastAssistantText/canSendBack/bottom-button plumbing.

@bb/app typecheck + lint clean; new MessageActionBar test covers the action
rendering, firing, and that it is not gated by the fork/side-chat depth flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four changes from the design discussion:

- Shared conversation-context snapshot. Generalize the side-chat snapshot into
  buildConversationContextSnapshot (fork + side-chat scopes). Its header now
  carries the parent thread id + total message count + a `bb thread show <id>`
  pointer, so a child agent can pull the full parent on demand instead of being
  limited to the bounded window. Wider default window for forks (10) than side
  chats (6).
- Seed forks with prior context. Thread the source timeline rows into
  buildForkThreadRequest and prepend an agent-only lead-up snapshot (the messages
  before the anchor; the anchor itself stays the visible seed) so a fork isn't
  amnesiac about how it reached the branch point.
- Drop the "(fork)" title suffix. Omit the title so a fork auto-titles from its
  first real turn (distinct from the source) rather than echoing the parent name.
- Hide side chats from the sidebar tree. projectThreadGroups skips
  childOrigin === "side-chat" so only forks (and agent-delegated children) nest
  there; side chats stay panel-only.

@bb/app typecheck + lint clean; new conversation-context-snapshot tests (both
scopes + the bb-thread-show pointer) and updated fork-thread-request tests green.
(projectThreadGroups.test.ts can't run locally — pre-existing @pierre/diffs
navigator load issue on main — but the filter typechecks and is exercised in CI.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…filter

The sidebar-filter test (and 4 other node-environment test files) couldn't load:
they transitively import @pierre/diffs, which reads navigator.userAgent at module
load, and Node < 21 has no global navigator. Add a minimal navigator stub to the
shared vitest setup (only when one isn't already present, so jsdom/browser envs
are untouched).

With the file loadable again, refactor buildProjectThreadGroups to drop
childOrigin === "side-chat" threads up front (so they neither nest nor resurface
as orphan roots in the cycle-recovery pass) and add a test asserting a side chat
is excluded from the tree while a fork stays nested under its parent.

@bb/app full suite now green: 160 files / 1218 tests (previously 5 files failed to
load). Typecheck + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fork threads now read "Forked from <source>" in the prompt-context banner
  (and "This thread was forked from …" in the expanded body) instead of the
  generic "Parent <source>"; driven by a new relationship: "parent" | "fork" on
  ThreadPromptParentThreadSection, set from thread.childOrigin. Also drop the
  redundant "Parent " prefix from the id-based loading fallback.
- Thread-header pill shows "fork" for forks (was always "child"); the header
  prop becomes childPillLabel: "child" | "fork" | null.
- Pill padding bumped (px-1.5 py-0 rounded-sm → px-2 py-0.5 rounded) so it reads
  like a tag rather than a tight chip.
- Stories: pill story shows the child/fork pills; banner story adds a
  "Forked from …" variant. New ThreadDetailHeader pill tests.

@bb/app typecheck + lint clean; header + banner suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fork threads now show a small Fork icon before their title in the sidebar tree
(thread.childOrigin === "fork"), so a fork reads as a fork at a glance alongside
its nesting under the source. Adds a "fork" row to the ThreadRow story.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A turn-backward arrow reads as "return/hand this reply back to the parent" —
truer to the action than the prior vague ArrowUpRight, and a clean pair with the
ArrowTurnForward the message renders with when it lands in the parent. Registers
the ArrowTurnBackward icon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d builders

The feature made childOrigin (on Thread) and startedOnBehalfOf/childOrigin (on
CreateThreadRequest) required-nullable, but main's CLI fixture, CLI spawn, and
integration request helpers — reintroduced by the catch-up merge — built those
objects without the fields, so @bb/cli and @bb/integration-tests failed
typecheck in CI (the frontend already handles this via AppCreateThreadRequest;
these typed Hono/SDK clients can't omit defaulted fields, so they pass the
explicit null a normal thread actually has).

- command-output-fixtures.makeThread: childOrigin: null
- thread spawn command + integration createHostThread/createReuseThread:
  startedOnBehalfOf: null, childOrigin: null
- update thread-spawn command-output expectations to match the request body

Root cause was only typechecking @bb/app locally; full `turbo run typecheck`
across all 32 packages is now green, as are lint and @bb/cli (226) / @bb/app
(1218) tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…llowlist

A later merge dropped the allowlist entry our own earlier fix added (commits
1a44eb0/514f807), so the contract guard test went red once typecheck stopped
masking it. Restore the documented entry for the main-inherited optional field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- [P1] Document why CLI/integration thread builders pass explicit
  startedOnBehalfOf/childOrigin nulls (the typed $post client requires the
  schema-defaulted fields; z.input would re-optionalize the SDK arg type but the
  underlying $post still requires them, so the null lives at the call site).
- [P2] Remove the dead `windowSize` override from buildConversationContextSnapshot
  (only tests ever set it); boundary tests now exercise the real per-scope
  default windows.
- [P2] Document resolveAnchorIndex's newest-first match + the fork tail-as-anchor
  fallback + the duplicate-text limitation (mitigated by the bb-thread-show
  pointer).
- [P2] Model childOrigin "side-chat" explicitly in the detail view: a side-chat
  thread opened directly now shows a "side chat" pill and a "Side chat of …"
  banner instead of a misleading "child"/"Parent". Consolidated the banner's
  per-relationship copy into one PARENT_SECTION_COPY source of truth; added a
  side-chat pill test + banner story.

@bb/app full suite (1222) + @bb/cli typecheck + lint green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e path)

The sidebar worktree icon was GitBranch, which reads as "branch" (the same glyph
used for the branch name in env summaries) and doesn't match the info page. Point
getEnvironmentWorkspaceDisplayIconName at FolderGit (FolderGitTwoIcon): a worktree
is a git-backed folder, it matches the convention PR #86 adds to the info-page
workspace-path row, and it's clearly distinct from the fork glyph. The registry
entry is placed to match PR #86 exactly so the two branches merge cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…convention

Pills are all one component with color variants (uniform padding); the only
casing outlier was the story's hard-coded "Failed". Lowercase it ("failed") to
match how real status/type pills render (the workflow-run status enum is all
lowercase; thread-type pills are child/fork/side chat). Descriptive multi-word
labels (e.g. "Invalid local path") intentionally stay sentence case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
brsbl and others added 11 commits June 11, 2026 05:02
The pill set leading-none (line-height = font size), and the inner truncate span
(overflow:hidden) then clipped descenders (the g in "managed"/"manager"). Drop
leading-none so the text uses text-xs's natural 16px line-height; descenders fit
and the pill reads a touch less tight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The earlier change only touched headerTitle (the row's tooltip/aria). The visible
group label is built separately and rendered `{environmentName ?? "Worktree"}` +
a ": " separator, so a no-name worktree group still showed "Worktree: <branch>".
Render the branch alone when there's no custom env name (the folder-git icon
already signals "worktree"); keep "name · branch" when named and the "Worktree"
fallback when there's neither.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…omposer)

Delete the bare side-chat composer and render the side chat through the same
FollowUpPromptBox the main thread uses — one composer to keep in sync, not two.

The footer (model + workspace + permission) renders read-only for the side chat
by extending the existing locked-provider pattern: ExecutionControls now renders
the MODEL as a static OptionDisplay label when model.onChange is omitted, and
FollowUpPromptBox renders the PERMISSION as a static label when permission.onChange
is omitted. ExecutionModelConfig.onChange, ExecutionControlsProps.reasoning, and
ExecutionPermissionConfig.onChange become optional; the main thread still passes
them (ThreadDetailPromptArea), so its pickers stay interactive.

Side chats inherit the parent's provider/model and are always read-only, so they
pass locked execution + { value: "readonly" } permission + a ThreadEnvironmentSummary
workspace strip, matching the main thread's layout. NewThreadPromptBox guards on
the now-optional permission.onChange. New tests cover the locked model + permission
rendering; locked-footer story added.

@bb/app typecheck + lint + full suite (1224) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the border-t separator above the side-chat composer (it had a border the
main thread's doesn't), and render the "Ask a question…" empty state with the
same centered icon + sm muted text layout as the new-tab empty state, instead of
a top-aligned panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The composer's container query (@container promptbox-shell, max-width 34rem)
hides data-promptbox-full-label and shows data-promptbox-compact-label when
narrow. The locked model/permission OptionDisplays (side-chat read-only footer)
only set `value` (the full label) with no `compactValue`, so in the < 34rem side
panel the full label hid and nothing rendered. Pass compactValue so the locked
model + permission show at the same positions as the main thread composer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The compactValue addition makes OptionDisplay render the locked label in both the
full and compact spans (CSS shows one per container width); jsdom has no
container query, so getByText now matches twice. Use getAllByText.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…static labels)

The read-only side-chat footer rendered custom static OptionDisplay labels for
model/permission instead of the actual pickers, so the two composers diverged:
the side chat showed "GPT-5.5" vs the main thread's "5.5", "Read only" vs "Read",
and the model landed in the wrong spot — each divergence patched with another
spot fix (compactValue, custom options). The composer is supposed to be ONE shared
component; the only difference should be that the side chat is read-only.

Render the SAME controls, just disabled:
- Add `disabled` to OptionPicker → ModelReasoningPicker / PermissionModePicker:
  the trigger renders as a non-interactive (disabled) button with the identical
  selected label, no menu/popover. Dimmed via the existing muted treatment.
- ExecutionControls: drop the static locked-model OptionDisplay + compactValue;
  render the real ModelReasoningPicker with `disabled`. onChange/reasoning are
  required again (the relaxed-to-optional signalling is replaced by `disabled`).
- FollowUpPromptBox: drop the permission OptionDisplay + compactValue; render the
  real PermissionModePicker with `disabled`, gated by a new `readOnly` prop.
- SideChatTabContent: build execution + permission configs via the same
  useThreadCreationOptions the main thread uses, so the disabled pickers show
  identical labels ("5.5", "Read"); pass `readOnly`. Removed the custom
  SIDE_CHAT_PERMISSION_OPTIONS / options:[] hacks.
- NewThreadPromptBox: revert the EditablePermissionConfig + onChange guard (the
  shared onChange is required again).
- Keep the elastic-min-height fix (stack === null → default) so the side chat
  input matches the main thread's height.

Resolves the review's P1 (dead permission.onChange optionality) and P2 (three
hand-rolled locked-label paths). Main thread composer unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking edit on a queued message restored its text into the draft but left the
composer unfocused, so you had to click in before typing. Add a declarative
`focusEndKey` to the prompt box (PromptBoxInternal → FollowUpPromptBox): when it
changes, the editor focuses with the caret at the end. Unlike the passive scope
autofocus, it is NOT gated by the coarse-pointer soft-keyboard guard, since it
follows a deliberate click. ThreadDetailPromptArea bumps it after the edit
restores the draft.

Test: focusEndKey change focuses the editor even under a coarse pointer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… config

- [P1] Editing a queued message put the caret at the START of the restored text.
  The focusEndKey focus ran (useLayoutEffect) before the passive content-sync
  setContent in the same commit, so focus("end") targeted the pre-edit content
  and setContent then mapped the caret to the start. Move the focus to a passive
  effect defined AFTER the content-sync effect; React's definition order runs it
  after setContent. The test now changes content + key in one commit and asserts
  the caret lands at the end.
- [P2] The side chat wired the creation hook's setters as picker onChange even
  though the pickers render disabled (unreachable). Pass noop, drop the unused
  setters, correct the comment.
- [P2] The side-chat permission label could drift from the actually-created
  read-only reach for a hypothetical non-readonly multi-mode provider. Source the
  displayed value and the create request from one SIDE_CHAT_PERMISSION_MODE
  constant.

@bb/app typecheck + lint + full suite (1224) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The side-chat composer wrapper used px-2/pb-2 (8px) while the main thread
composer sits in a px-4 wrapper with a 16px bottom gap, so the side chat box
rendered 8px lower and 8px narrower per side. Match px-4/pb-4: measured against
the main composer the two now share identical box width (548px), input-box top,
height, model-row baseline, and bottom edge — only the pane x differs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A reviewer empirically showed the caret test passed identically with the old
buggy effect ordering: jsdom's ProseMirror always places the caret at the end
after setContent, regardless of focus order, so the focusOffset assertion could
not distinguish the fix from the regression (a "passes when broken" test). Drop
the selection/offset assertions and keep only focus + content (what jsdom can
prove); the caret-at-end guarantee is documented as resting on the focus effect
being declared after the content-sync effect, covered by manual QA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant