Skip to content

feat: fork a conversation from any completed point - #9187

Closed
Nurozen wants to merge 5 commits into
pingdotgg:mainfrom
Nurozen:feat/thread-forking
Closed

Nurozen wants to merge 5 commits into
pingdotgg:mainfrom
Nurozen:feat/thread-forking

Conversation

@Nurozen

@Nurozen Nurozen commented Sep 2, 2026 •

Copy link
Copy Markdown

Adds conversation forking: continue any completed point of a thread in a new thread, the way Claude Code, Codex and ChatGPT branch sessions. The fork is a perfect reconstruction of the parent at the fork point — conversation, workspace context, model and mode, linked PR — and deviates only through user/agent action afterwards.

How it works. A server-materialized thread.fork command emits thread.created + a new thread.forked event on the child stream; the copied history lands as real projection rows (fresh ids, preserved timestamps) and the parent's checkpoint refs are aliased under the child, so diffs and revert work across the inherited turns. The provider session forks natively at the child's first message: Codex thread/fork {lastTurnId}, Claude resume + forkSession (+ resumeSessionAt, truncation verified against the live SDK), OpenCode session.fork with its exclusive message boundary. Cursor and Grok report the action as unsupported rather than starting an agent that hasn't seen the transcript. Per-turn provider anchors (providerRefs.providerTurnId → projection_turns.provider_turn_ref) make mid-thread forks durable across restarts; a T3CODE_THREAD_FORKING kill-switch drives the threadForking capability and is enforced server-side. Migration 045 adds the lineage/anchor columns (nullable, additive).

Surfaces. Web: "Fork from here" on message hover (both rails), thread header/sidebar/legacy-sidebar menus, command palette, /fork, an unbound chat.fork binding, a lineage chip ("Forked from … · after turn N") and an in-timeline divider; revert is locked on a fork until its first send. Mobile: fork from all thread-list menu variants and the thread header, with a lineage subtitle. Forks title as " (fork)" and auto-retitle after their first reply. Docs: docs/user/forking-threads.md, glossary entries, docs/internals/thread-forking.md, provider capability table.

Evidence (real app, real Codex; the fork answered from conversation memory — "hello" — while the file on disk had already been changed to "goodbye" by the parent's later turn):

Parent (two turns) Fork at turn 1 Fork context proof
parent fork proof

The first commit here is #9185 (the fork flow makes shared worktrees a first-class case, so it rides in front). Verified with focused suites across contracts/server/web/mobile (~600 tests touching the feature), all six package typechecks, a live Claude truncation smoke, and the end-to-end browser pass above.

Built with Claude Fable 5 on Claude Code.


Note

Medium Risk
Fork touches orchestration projections, checkpoint aliasing, and attachment copying alongside new client navigation paths; mistakes could corrupt inherited history or lineage, though changes are heavily tested.

Overview
Adds mobile conversation forking at the latest completed turn, wired through shared useForkThread / resolveThreadForkAvailability gates (connection, threadForking capability, native conversationFork, completed turn). Fork actions appear in compact and v2 thread-list menus, the iPad sidebar, and the thread screen header (iOS menu + Android action); success navigates to the child immediately via a narrowed onSelectThread that only needs environmentId and id, with loading until the shell arrives. The open thread shows a “Forked from …” subtitle when lineage is known.

On the server, this diff extends fork support beyond dispatch: T3CODE_THREAD_FORKING drives the advertised threadForking capability; thread.forked is projected into messages, turns, activities, plans, and thread lineage (forkedFrom / forkSource); session metadata now stamps provider_turn_ref on completed turns (including when diff-completion settled the turn first). Checkpoint refs can be aliased to child threads; attachments get deterministic child-thread copies via copyClaimedAttachment. Snapshot queries expose fork context, full turn/activity lists for assembly, and worktree path lookups that include archived threads—used so branch-drift adoption does not ignore archived siblings on a shared worktree.

Mobile outbox delivery passes a titleSeed for fork children still titled … (fork) so the first reply can auto-retitle. Tests cover fork menu behavior, attachment copy plans, checkpoint aliasing, projection replay/idempotency, and lineage on wire shapes.

Reviewed by Cursor Bugbot for commit a814d61. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add conversation forking from any completed turn across server, web, and mobile

Adds a full-stack feature to fork a conversation thread from any completed turn, creating a child thread with inherited history, checkpoints, attachments, and workspace context.

  • Server materializes thread.fork commands over WebSocket: loads source projections, assembles inherited history (messages, turns, activities, plans), aliases checkpoint refs, copies attachments, and dispatches a materialized fork command to the orchestration engine. The normalizer rejects raw client-shaped fork commands so only the WebSocket path can create forks.
  • Provider adapters declare fork capability: Claude, Codex, and OpenCode support native provider-side forking; Cursor and Grok reject fork input with ProviderAdapterValidationError. Claude requires a provider turn anchor; Codex and OpenCode do not.
  • Orchestration decider validates fork commands and emits thread.created + thread.forked events. Projection pipeline copies inherited history into the child thread and stamps provider turn anchors on completed turns.
  • Web and mobile expose fork entry points via sidebar menus, command palette, message-level buttons, /fork slash command, and a chat.fork keybinding. Forked threads show a lineage chip and a timeline divider marking the fork boundary.
  • Migration 045 adds forked_from_json, fork_source_json, and provider_turn_ref columns to the projection tables. Server config gains a T3CODE_THREAD_FORKING kill switch (default enabled).
  • Risk: normalizeDispatchCommand now rejects raw thread.fork commands with OrchestrationDispatchCommandError; any code path sending client-shaped fork commands outside the WebSocket dispatcher will fail. CheckpointReactor branch-drift check now queries listThreadIdsByWorktreePath instead of scanning shells, so archived threads sharing a worktree path prevent branch adoption.

Macroscope summarized a814d61.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Sep 2, 2026
@macroscopeapp

macroscopeapp Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Macroscope skipped reviewing this pull request. Per-review cost limit exceeded (workspace setting).

This review would cost an estimated $12.54, which exceeds your per-review limit of $10.00.

The top 3 files driving up this estimate:

File Diff Size Estimate
apps/server/src/ws.ts 22.35KB $1.12
apps/server/src/orchestration/threadFork.ts 16.60KB $0.83
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts 11.79KB $0.59

Tip

To get this pull request reviewed, you can:

  1. Comment @macroscope-app on this PR to request a manual review (monthly spend limits still apply).
  2. Exclude the file(s) above from review by adding a pattern to your .macroscope/ignore.md — note that creating this file replaces Macroscope's built-in default ignores rather than extending them.
  3. Raise your cost limit in your workspace billing settings.

Turn off this reminder going forward

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three convention violations in the Effect service code touched here: two new static-analysis suppressions without an explanation, and one error translation that discards its cause. Details inline.

Posted via Macroscope — Effect Service Conventions

Comment thread packages/shared/src/uuid.ts Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New @effect-diagnostics nodeBuiltinImport:off directive with no adjacent explanation of why the diagnostic must be disabled here. Consider stating the reason (a synchronous digest is needed to derive the id outside any Effect runtime), as other suppressions in this package do.

Suggested change
// @effect-diagnostics nodeBuiltinImport:off
// @effect-diagnostics nodeBuiltinImport:off - deterministic v5 ids need node:crypto's synchronous sha1 digest, which the Effect Crypto service does not expose.

Posted via Macroscope — Effect Service Conventions

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f39fba0 — the suppression now records the reason (deterministic v5 ids need node:crypto's synchronous sha1 digest, which the Effect Crypto service does not expose).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment on lines +603 to +610
() =>
new ProviderAdapterRequestError({
provider: providerErrorLabelFromInstanceHint({
instanceId: String(forkSource.providerInstanceId),
}),
method: "thread.turn.start",
detail: `Thread '${threadId}' cannot resolve fork capabilities for provider instance '${forkSource.providerInstanceId}'.`,
}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This translation drops the underlying getCapabilities failure: the wrapper is built from a zero-arg lambda, so the original error and its stack are lost. ProviderAdapterRequestError already declares an optional cause; preserving it keeps the error chain intact.

Suggested change
() =>
new ProviderAdapterRequestError({
provider: providerErrorLabelFromInstanceHint({
instanceId: String(forkSource.providerInstanceId),
}),
method: "thread.turn.start",
detail: `Thread '${threadId}' cannot resolve fork capabilities for provider instance '${forkSource.providerInstanceId}'.`,
}),
(cause) =>
new ProviderAdapterRequestError({
provider: providerErrorLabelFromInstanceHint({
instanceId: String(forkSource.providerInstanceId),
}),
method: "thread.turn.start",
detail: `Thread '${threadId}' cannot resolve fork capabilities for provider instance '${forkSource.providerInstanceId}'.`,
cause,
}),

Posted via Macroscope — Effect Service Conventions

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f39fba0 — the mapError lambda now takes the failure and passes it as cause, so the original getCapabilities error and stack survive the translation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment thread apps/server/src/ws.ts Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New @effect-diagnostics nodeBuiltinImport:off directive with no adjacent explanation. This file already acquires effect/Crypto (used for crypto.randomUUIDv4), so it is worth recording why the fork path reaches for node:crypto instead — the pure assembler's mintUuid needs a synchronous () => string.

Suggested change
// @effect-diagnostics nodeBuiltinImport:off
// @effect-diagnostics nodeBuiltinImport:off - assembleThreadFork's mintUuid is a synchronous callback, which the Effect Crypto service cannot satisfy.

Posted via Macroscope — Effect Service Conventions

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f39fba0 — the suppression now records the reason (assembleThreadFork mints ids through a synchronous callback, which the Effect Crypto service cannot satisfy).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"git-fork" has no entry in ICON_PATHS (apps/web/src/contextMenuFallback.ts), which is the icon registry the browser context-menu fallback uses. createIconElement returns null for unknown names, so in the web build the new "Fork thread" row renders with no icon while every sibling row (new-thread-on-branch, pin, settle, rename, …) has one — and because the row is flex … gap-2 with a flex-1 label, its label shifts left out of the shared icon column in both the sidebar row menu and the chat header thread menu.

Smallest fix: register the Lucide git-fork paths alongside the other names in ICON_PATHS, e.g.

"git-fork": [
  { tag: "circle", attrs: { cx: "12", cy: "18", r: "3" } },
  { tag: "circle", attrs: { cx: "6", cy: "6", r: "3" } },
  { tag: "circle", attrs: { cx: "18", cy: "6", r: "3" } },
  { tag: "path", attrs: { d: "M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9" } },
  { tag: "path", attrs: { d: "M12 12v3" } },
],

(alternatively reuse an already-registered name such as git-branch).

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in f39fba0 by registering the Lucide git-fork paths in ICON_PATHS, so the browser context-menu fallback renders the icon and the label stays in the shared icon column.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment thread apps/server/src/orchestration/threadFork.ts
@macroscopeapp

macroscopeapp Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR adds a large, cross-cutting conversation-forking capability spanning UI, orchestration, provider adapters, persistence, filesystem/Git side effects, and schema migrations. It also enables the new behavior by default and adds static-analysis suppression directives, making human review necessary.

Not approved because:

  • Per-review cost limit exceeded (workspace setting). Approvability relies on correctness review in order to determine eligibility

Review your spending limits in Billing settings, or comment @macroscope-app review this PR to bypass the limit and review now. You can add or adjust custom eligibility rules. Learn more.

Nurozen and others added 2 commits September 1, 2026 23:47
Worktree removal and shared-worktree detection only consulted the active
shell, so archived threads sharing a worktree were treated as gone: deleting
a sibling could remove a worktree an archived thread still points at, and
checkpoint branch-drift adoption ignored archived co-owners. Removal now
refuses while any non-deleted thread references the path — resolved by a
single atomic query so a thread can never fall between separate active and
archived reads — drift adoption counts archived siblings, and the legacy
sidebar's bulk delete no longer pre-seeds its deleted-set with the whole
batch (which made the first deletion treat still-alive batch mates as gone).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds thread forking: any completed turn of a thread can be continued in a
new thread that carries the conversation, workspace context, model and
mode, and the linked pull request. The fork is a new aggregate — a
server-materialized thread.fork command emits thread.created plus a new
thread.forked event whose copied history lands as real projection rows
(fresh ids, preserved timestamps, checkpoint refs aliased under the child)
— and the provider session forks natively at the child's first message:
Codex thread/fork, Claude resume+forkSession(+resumeSessionAt), OpenCode
session.fork with an exclusive boundary. Cursor and Grok report the action
as unsupported rather than starting an agent that has not seen the
transcript. Per-turn provider anchors are recorded so mid-thread forks
survive restarts; a T3CODE_THREAD_FORKING kill-switch drives the
threadForking capability and is enforced server-side.

Web gets fork-from-message on every hover rail plus thread menus, palette,
/fork and an unbound chat.fork binding, a lineage chip and an in-timeline
divider; mobile forks from thread menus and headers. Forks retitle
themselves after their first reply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Nurozen
Nurozen force-pushed the feat/thread-forking branch from 9edfe97 to f39fba0 Compare September 2, 2026 07:48
The null-turn row cutoff compared ISO strings; canonical timestamps are
UTC so this held, but an offset-form timestamp would misorder. Parse to
epoch milliseconds instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
>
<GitForkIcon className="size-3" />
</TooltipTrigger>
<TooltipPopup side="top">{ctx.forkWarning ?? "Fork from here"}</TooltipPopup>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ctx.forkWarning can never be shown here. It is non-null exactly when activeThread.session.status === "running" (ChatView), and activity.isWorking is phase === "running" || … with phase = derivePhase(session) — so whenever the warning exists this trigger is disabled, and Button carries disabled:pointer-events-none, which suppresses the hover that would open the tooltip. The "shares files with an agent that is still working" caution therefore never reaches the user, and the row action is silently unavailable instead.

Suggest picking one: keep the button hoverable while blocked (aria-disabled + aria-disabled:opacity-64 aria-disabled:cursor-not-allowed, guarding onClick) so the caution reads, or drop forkWarning and its plumbing if forking is meant to be simply unavailable while the agent works.

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee806cd — and this caught a real behavior bug beyond the tooltip: forking earlier completed turns while the agent is working is intended (the per-row forkTurnId gating already excludes the running turn), so the button no longer disables on isWorking at all. The warning tooltip now renders on hover exactly when the caution applies, and the action goes through.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee806cd (this check run reviewed the previous commit). Went with the first option: the fork trigger only sets disabled on isRevertingCheckpoint, so earlier completed turns stay forkable while the agent works and the "shares files with an agent that is still working" warning is actually reachable on hover.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

<Undo2Icon className="size-3" />
</TooltipTrigger>
<TooltipPopup side="top">Revert to this message</TooltipPopup>
<TooltipPopup side="top">{ctx.revertDisabledReason ?? "Revert to this message"}</TooltipPopup>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revertDisabledReason is only rendered inside this tooltip, but the same non-null value also sets disabled on the trigger just above, and Button's base classes include disabled:pointer-events-none. A natively disabled button receives no hover or focus events, so this explanation can never appear — in an unsent fork the user just sees a greyed revert icon with no reason.

The repo's convention for explaining a blocked control on hover is to keep it hoverable: aria-disabled plus a dimmed style (see ThemeEditorPanel.tsx, whose comment states "a real disabled attribute would swallow the pointer events"), or wrap the trigger in <span className="inline-flex"> as ConnectionsSettings and the compact-context action in ChatView do.

-            disabled={
-              ctx.revertDisabledReason !== null ||
-              activity.isRevertingCheckpoint ||
-              activity.isWorking
-            }
-            onClick={() => ctx.onRevertUserMessage(messageId)}
+            aria-disabled={
+              ctx.revertDisabledReason !== null ||
+              activity.isRevertingCheckpoint ||
+              activity.isWorking
+            }
+            className="aria-disabled:cursor-not-allowed aria-disabled:opacity-64"
+            onClick={() => {
+              if (ctx.revertDisabledReason !== null) return;
+              if (activity.isRevertingCheckpoint || activity.isWorking) return;
+              ctx.onRevertUserMessage(messageId);
+            }}

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee806cd — the revert trigger now follows the ThemeEditorPanel convention: aria-disabled with a dimmed style and a guarded onClick instead of a native disabled attribute, so the explanation tooltip is reachable on an unsent fork.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee806cd (our pushes crossed with this check run — it reviewed the previous commit). The revert trigger now follows the ThemeEditorPanel pattern: aria-disabled={revertBlocked} with a dimmed class and a guarded onClick, no native disabled, so the tooltip explaining revertDisabledReason is reachable on hover/focus.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

type="button"
aria-label={forkChipLabel}
onClick={openParentThread}
className="ml-2 inline-flex max-w-48 shrink-0 cursor-pointer items-center gap-1 rounded-full border border-border/70 px-1.5 py-px text-xs font-normal text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This chip is a raw <button> that reconstructs Button size="micro" (small height, gap-1, muted text, pointer cursor, focus ring), but it does not inherit the primitive's pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hit target or focus-visible:ring-offset-1. On touch the chip is a ~18px tap target in the header, where every neighbouring control meets the 44px minimum.

A comparable tooltip-triggered chip already goes through the primitive — PullRequestDetailPanel.tsx renders <Button size="micro" variant="outline" className="… gap-1 font-normal text-muted-foreground" />. Suggest rendering this trigger as <Button size="micro" variant="ghost" className="ml-2 max-w-48 rounded-full border-border/70 font-normal text-muted-foreground …" /> (needs a Button import in this file) and keeping only the pill geometry/colour at the call site.

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee806cd — the chip renders through the Button primitive (size="micro" variant="ghost", pill geometry kept at the call site), picking up the coarse-pointer 44px hit target and focus-ring behavior like the PullRequestDetailPanel chip.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ee806cd (this check run reviewed the previous commit). The lineage chip now renders through the Button primitive (size="micro" variant="ghost"), inheriting the coarse-pointer 44px hit target and focus ring, with only the pill geometry/colour kept at the call site — mirroring the PullRequestDetailPanel chip.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

… lineage chip

The fork button disabled itself whenever the agent was working, which both
hid the shared-worktree warning tooltip (disabled buttons swallow pointer
events) and blocked forking earlier completed turns mid-run — the per-row
gating already excludes the running turn. The revert button now stays
hoverable while blocked (aria-disabled, guarded click) so its reason reads,
and the lineage chip renders through the Button primitive for the coarse
pointer hit target and focus ring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
return (
<Tooltip>
<TooltipTrigger
render={

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ctx.forkWarning can never reach the popup on line 1389: it is only non-null when the thread session is running, and derivePhase maps that status to phase === "running", which makes activity.isWorking true — so this disabled prop is set whenever the warning exists, and Button's disabled:pointer-events-none suppresses the hover that would open the tooltip. The row therefore always reads "Fork from here", and the "shares files with an agent that is still working" warning (which the docs promise) is unreachable dead plumbing.

Since forkTurnIdByMessageId already excludes the active running turn, earlier completed turns are still forkable while the agent works — which is the case the warning is for. Smallest fix is to stop gating on isWorking so the warning actually shows; otherwise drop the forkWarning prop chain.

-            disabled={activity.isRevertingCheckpoint || activity.isWorking}
+            disabled={activity.isRevertingCheckpoint}

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already the shipped state in ee806cd (the check-run appears to have analyzed the prior revision): the fork button's disabled prop is exactly disabled={activity.isRevertingCheckpoint} — no isWorking gate — so earlier completed turns fork while the agent works and the shared-worktree warning tooltip renders. See MessagesTimeline.tsx ~:1386 at this head.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate of the thread at line 1394 — fixed in ee806cd (this check run reviewed the previous commit). isWorking was dropped from the fork trigger's disabled, exactly as suggested, so the warning tooltip shows while the agent is running.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revertDisabledReason is non-null exactly when this Button is disabled, and the shared Button base classes include disabled:pointer-events-none — so the trigger never receives hover or focus and the explanatory popup below can never open. On an unsent fork the user just sees a dimmed revert icon with no reason.

The established pattern here is to keep the control hoverable with aria-disabled and guard the handler (see ThemeEditorPanel.tsx: "A locked mode stays hoverable so the tooltip can say why it is off; a real disabled attribute would swallow the pointer events", and the aria-disabled hover popover in GitActionsControl.tsx):

-            disabled={
-              ctx.revertDisabledReason !== null ||
-              activity.isRevertingCheckpoint ||
-              activity.isWorking
-            }
-            onClick={() => ctx.onRevertUserMessage(messageId)}
+            aria-disabled={ctx.revertDisabledReason !== null || undefined}
+            className={
+              ctx.revertDisabledReason !== null ? "cursor-not-allowed opacity-64" : undefined
+            }
+            disabled={activity.isRevertingCheckpoint || activity.isWorking}
+            onClick={() => {
+              if (ctx.revertDisabledReason !== null) return;
+              ctx.onRevertUserMessage(messageId);
+            }}

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already the shipped state in ee806cd: the revert trigger uses the ThemeEditorPanel pattern — revertBlocked computed once, aria-disabled={revertBlocked}, dimmed class, and a guarded onClick — so the reason tooltip is hoverable on an unsent fork. See MessagesTimeline.tsx ~:1343-1360 at this head; no native disabled attribute remains on this button.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same finding as the thread at line 1367 — fixed in ee806cd (this check run reviewed the previous commit). The trigger is aria-disabled + guarded onClick rather than natively disabled, so the explanatory tooltip opens.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ee806cd. Configure here.

Comment thread apps/server/src/orchestration/threadFork.ts
A later running or interrupted turn can already hold content in the
provider's native session, so an anchorless whole-session fork taken
"behind" it would give the child agent history its transcript does not
show. atEnd is now true only when the fork turn is the source's final
turn row; anchor-requiring providers reject otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@maria-rcks

Copy link
Copy Markdown
Collaborator

wait for julius orchestration pr

@maria-rcks maria-rcks closed this Sep 2, 2026
@Nurozen

Nurozen commented Sep 2, 2026

Copy link
Copy Markdown
Author

wait for julius orchestration pr

Sounds good, let me know if you have any questions.
Thank you.

Nurozen added a commit to Nurozen/lecturn that referenced this pull request Sep 3, 2026
Merges the conversation-forking system (feat/thread-forking) into the
fork's main. Upstream PR pingdotgg#9187 was parked pending an
upstream orchestration change; Lecturn carries the feature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	apps/server/src/provider/Layers/CodexAdapter.ts
#	apps/server/src/provider/Layers/ProviderService.test.ts
#	apps/server/src/provider/Services/ProviderAdapter.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants