Skip to content

Fix split-view sending and prioritize New session - #4491

Merged
benjaminshafii merged 5 commits into
devfrom
fix/split-message-actions
Sep 5, 2026
Merged

benjaminshafii merged 5 commits into
devfrom
fix/split-message-actions

Conversation

@benjaminshafii

@benjaminshafii benjaminshafii commented Sep 5, 2026 •

Copy link
Copy Markdown
Member

Overlapping messages in split view shared a workspace-level submission promise, allowing one draft to be reported as sent without submitting it. Ungated messages now send independently, and each pane tracks its own pending request so sending on one side does not make the other side queue messages.

Same-workspace split panes also now subscribe to live session updates immediately, preventing a new pane from staying on Thinking after its message completes.

Pending requests are scoped to the session, including when the user switches the session displayed in a pane before its previous send finishes. The desktop journey verifies that the replacement session can send and receive its own reply while the original request is held open.

Make New session the highlighted first sidebar action, followed by a quieter Split view action. Shorten Create new session to New session in the command palette.

Verification:

  • App typecheck passed.
  • 45 focused tests passed, covering send isolation, session subscription lifecycle, and run status; sidebar order and palette session selection tests also passed.
  • The existing new-split-session desktop journey now sends through both panes while holding the primary HTTP acknowledgement, verifies reply ownership, and checks session creation and focus. Passed: 1 test, 0 failures, 0 skips on commit bb65500b4. Placement: daytona (daytona CLI authenticated).
  • The broader evals typecheck is blocked by errors outside this change (including missing JSX and DOM configuration in imported workspace packages).

Reproduce: OPENWORK_EVAL_DAYTONA_REF=fix/split-message-actions OPENWORK_EVAL_REF=fix/split-message-actions pnpm evals:e2e new-split-session.

Warden reviewed bb65500b4: 0 findings, 0 blocking findings, clearance approved (run).

@vercel

vercel Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
openwork-app Ready Ready Preview Sep 5, 2026 2:22pm UTC
3 Skipped Deployments
Project Deployment Actions Updated
openwork-den Ignored Ignored Preview Sep 5, 2026 2:22pm UTC
openwork-diagnostics Ignored Ignored Preview Sep 5, 2026 2:22pm UTC
openwork-landing Ignored Ignored Preview Sep 5, 2026 2:22pm UTC

@benjaminshafii
benjaminshafii marked this pull request as ready for review September 5, 2026 06:44
@benjaminshafii
benjaminshafii enabled auto-merge (squash) September 5, 2026 14:16
auto-merge was automatically disabled September 5, 2026 14:17

Pull request was closed

@benjaminshafii benjaminshafii reopened this Sep 5, 2026
diff-warden[bot]
diff-warden Bot previously approved these changes Sep 5, 2026

@diff-warden diff-warden 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.

Warden security clearance: clear. No blocking findings (security or desktop↔den sync) in this diff (18ec83ec816c06cd930fecf355e30aca65eed522). Automated clearance satisfies the required-review gate only — a human still reviews and merges. Analysis run

@diff-warden diff-warden 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.

Warden security clearance: clear. No blocking findings (security or desktop↔den sync) in this diff (bb65500b47ac4e7f18e133e5123c602d3ebfb7ab). Automated clearance satisfies the required-review gate only — a human still reviews and merges. Analysis run

@benjaminshafii

Copy link
Copy Markdown
Member Author

Test evidence — new-split-creates-fresh-same-workspace-secondary-sessions-without-moving-the-pri — ✅ passed

SHA bb65500 · engine v1

✅ 0/2 screenshots passed · 4 assertions · 4 expectations passed · 0 failed · 0 pending

[world] mock(mcp) · den(daytona) · desktop(as admin) · workspace(/tmp/openwork-new-split-session-1788618291701) · [seed:raw] evalIn() ×3 · session("Split switch target") · session("New split primary")
[probe] eventually(single layout on the seeded primary) · hash
[agent] listSessions
[user] see(label=New session) · see(label=Split view) · notSee(label=Create new session) · rightClick(text=New split primary) · see(label=New split) · click(label=New split) · see(text=Split view) · see(text=New session)
[probe] eventually(context-menu new split layout) · eventually(new secondary composer receives keyboard focus) · hash
[agent] listSessions
[probe] eventually(context-menu split session appears in the session list)
[user] screenshot
[probe:raw] [probe:raw] eval()
[user] type(testId=primary-split-composer, "Reply to the primary split message") · press(Enter)
[probe:raw] [probe:raw] eval()
[probe] eventually(primary request is still pending when the secondary sends)
[user] type(testId=secondary-split-composer, "Reply to the secondary split message") · press(Enter) · see(text=Secondary split received, timeoutMs=30000)
[probe:raw] [probe:raw] eval()
[agent] run(workbench.session.focus)
[probe] eventually(the primary pane switches while its previous request remains pending)
[user] type(composer, "Reply after switching the primary session") · press(Enter) · see(text=Switched session received, timeoutMs=15000)
[probe:raw] [probe:raw] eval() ×2
[agent] run(workbench.session.focus)
[probe] eventually(return to the original primary without replacing the secondary)
[probe:raw] [probe:raw] eval() ×2
[probe] eventually(each reply appears only in its owning split pane)
[probe:raw] [probe:raw] eval()
[user] press(Control+K) · see(placeholder=Search actions, settings, and sessions…) · type(placeholder=Search actions, settings, and sessions…, "new split", replace) · see(label=/^New split/) · press(Enter) · see(text=Split view) · see(text=New session)
[probe] eventually(palette new split replaces the secondary session) · eventually(new secondary composer receives keyboard focus) · hash
[agent] listSessions
[probe] eventually(palette split session appears in the session list)
[user] screenshot
[agent] run(workbench.session.focus)
[probe] eventually(secondary pane is focused)
[user] press(Control+K) · see(placeholder=Search actions, settings, and sessions…) · type(placeholder=Search actions, settings, and sessions…, "new task", replace) · see(label=/^New session/) · press(Enter)
[probe] eventually(new session replaces the focused secondary) · eventually(new secondary composer receives keyboard focus)
[agent] listSessions
[probe] eventually(focused-secondary new session appears in the session list)
[agent] run(workbench.session.focus)
[probe] eventually(primary pane is focused)
[user] press(Control+K) · see(placeholder=Search actions, settings, and sessions…) · type(placeholder=Search actions, settings, and sessions…, "new task", replace) · see(label=/^New session/) · press(Enter)
[probe] eventually(new session replaces the focused primary) · eventually(new primary composer receives keyboard focus)
[agent] listSessions
[probe] eventually(focused-primary new session appears in the session list)
steps 1 ✅ the seeded session is the single primary pane (0.7s) · 2 ✅ New session and Split view are directly available in the sidebar (3.5s) · 3 ✅ New split in the session context menu opens a fresh secondary (1.7s) · 4 ✅ the context menu creates one same-workspace split session (1.3s) · 5 ✅ both split composers send to their own conversation (6.3s) · 6 ✅ New split in the command palette replaces the secondary with another fresh session (2.1s) · 7 ✅ the palette creates one more session while preserving the primary route (1.2s) · 8 ✅ the agent's server path sees the same layout (0.2s) · 9 ✅ New session replaces the focused secondary pane (2.2s) · 10 ✅ the focused secondary is replaced while the primary route stays put (0.8s) · 11 ✅ New session replaces the focused primary pane (2.3s) · 12 ✅ the focused primary is replaced without changing the secondary (2.0s)
verdict passed · 18 user observations (see ×15, notSee ×1, screenshot ×2) · 30 probes · steps 12/12

ℹ️ ASSERTION — 1. Switching sessions during a pending send does not block the new session or cross replies

{"pendingSessionId":"ses_f8e0aa614ffeQI0kJY9XWHnERH","switchedSessionId":"ses_f8e0ab001ffejrTYyNaNnbx5WP"}

  • ✅ PASS Switching sessions during a pending send does not block the new session or cross replies — {"pendingSessionId":"ses_f8e0aa614ffeQI0kJY9XWHnERH","switchedSessionId":"ses_f8e0ab001ffejrTYyNaNnbx5WP"}

ℹ️ ASSERTION — 2. Both split composers send and receive replies without crossing sessions

{"primarySessionId":"ses_f8e0aa614ffeQI0kJY9XWHnERH","secondarySessionId":"ses_f8e0a8c62ffejxDSBl10jyi17y"}

  • ✅ PASS Both split composers send and receive replies without crossing sessions — {"primarySessionId":"ses_f8e0aa614ffeQI0kJY9XWHnERH","secondarySessionId":"ses_f8e0a8c62ffejxDSBl10jyi17y"}

ℹ️ ASSERTION — 3. Desktop split context reaches the agent through the server mailbox

The server returned ok=true with kind=split, primarySessionId=ses_f8e0aa614ffeQI0kJY9XWHnERH, secondarySessionId=ses_f8e0a665effe84wcK4KqzwlEDy, and focused=secondary, all matching the rendered split.

  • ✅ PASS Desktop split context reaches the agent through the server mailbox — The server returned ok=true with kind=split, primarySessionId=ses_f8e0aa614ffeQI0kJY9XWHnERH, secondarySessionId=ses_f8e0a665effe84wcK4KqzwlEDy, and focused=secondary, all matching the rendered split.

ℹ️ ASSERTION — 4. New splits preserve the primary, and New session replaces only the focused pane

Context-menu and command-palette splits each created one distinct same-workspace secondary session. The focused-secondary and focused-primary New session actions each preserved the opposite pane and created exactly one session.

  • ✅ PASS New splits preserve the primary, and New session replaces only the focused pane — Context-menu and command-palette splits each created one distinct same-workspace secondary session. The focused-secondary and focused-primary New session actions each preserved the opposite pane and created exactly one session.

⚪ UNVALIDATED — 5. new-split-creates-fresh-same-workspace-secondary-sessions-without-moving-the-pri artifact 1

  • ⚪ UNVALIDATED — no visual expectations recorded.

new-split-creates-fresh-same-workspace-secondary-sessions-without-moving-the-pri artifact 1

⚪ UNVALIDATED — 6. new-split-creates-fresh-same-workspace-secondary-sessions-without-moving-the-pri artifact 4

  • ⚪ UNVALIDATED — no visual expectations recorded.

new-split-creates-fresh-same-workspace-secondary-sessions-without-moving-the-pri artifact 4


Test run created 2026-09-05T14:21:08.438Z · Source: evals/results/test-runs/2026-09-05T14-21-08-437Z-new-split-creates-fresh-same-workspace-secondary-sessions-without-moving-the-pri/test-run.json · Repro: pnpm --dir evals artifacts:publish -- --pr 4491 --test-run 2026-09-05T14-21-08-437Z-new-split-creates-fresh-same-workspace-secondary-sessions-without-moving-the-pri

@benjaminshafii
benjaminshafii merged commit d25a936 into dev Sep 5, 2026
26 checks passed
yomgui added a commit that referenced this pull request Sep 9, 2026
- Isolate the standalone approval server's runtime DB so a host machine's
  persisted managed policy cannot short-circuit writes with 403.
- Target the sidebar control by its current label (renamed in #4491).
yomgui added a commit that referenced this pull request Sep 10, 2026
…ssion button

The sidebar button was renamed from "New task" to "New session" in #4491
(Sep 5) and the "new" scenario added in #4631 (Sep 8) still targeted the old
label, so it could never find the button. Same user action, current label.
yomgui added a commit that referenced this pull request Sep 11, 2026
…loads (#4757)

* fix(app): seed a created session's snapshot so its composer can send immediately

A freshly created session had no cached snapshot, so deriveSessionRenderModel
reported "switching" while the first snapshot round trip ran and the composer
disabled Run task. On Daytona that window is ~0.9 s and a click or Enter inside
it was silently dropped with the draft intact. Seed the snapshot cache from the
create response (stale, so the real fetch still runs as a background refresh).

* fix(app): let a send that settled with a server failure recover instead of wedging

A prompt_async answered with 5xx/408 is an uncertain admission (#4635), but
the hold it created could never end: the acceptance check treated absence as
no evidence, Stop refused while the admission was unknown, and the failure body
was dropped, so the person saw a paused composer with no explanation.

- Keep the settled response body on PromptAdmissionUnknownError and show it as
  the session error card right away; the hold still prevents any resend.
- readPromptAdmission: after the exact-message GET misses, list the idle
  conversation; when native lists it without the message the POST is settled
  and did not admit, so the drain receives admission_rejected and halts like
  any definite failure (explicit retry only, never an automatic resend).
- On release the pending row is removed and the submitted composer is kept as
  an unsent message to restore; on confirmed acceptance the stale failure card
  is cleared.

* test(evals): read an empty Lexical composer as an empty draft

Lexical keeps one empty paragraph in an empty editor, which innerText reports
as "\n". readComposerState now drops that trailing line break so draftText
matches what the person sees; user.see("composer", { text }) already trimmed.

* test(evals): open the fresh conversation through the sidebar's New session button

The sidebar button was renamed from "New task" to "New session" in #4491
(Sep 5) and the "new" scenario added in #4631 (Sep 8) still targeted the old
label, so it could never find the button. Same user action, current label.
yomgui added a commit that referenced this pull request Sep 11, 2026
…ollow-up (#4786)

* fix(app): seed the delegated-task eval with a running parent thread

The eval.task_activity.seed hook rendered an in-flight child under a parent
whose thread status stayed "ready": it only marked the activity store busy.
Since #4635 MessageList reads that as a stopped parent and the unobserved
child shows "Waiting for task result" instead of the Working shimmer, so
task-activity-shimmer and parent-child-permission-approval lost their
[data-subagent-activity="shimmer"] row. A delegating parent is mid-run;
mark the eval thread streaming like the chat-loading seed already does.

* fix(app): seed a created session's snapshot so its composer can send immediately

A freshly created session had no cached snapshot, so deriveSessionRenderModel
reported "switching" while the first snapshot round trip ran and the composer
disabled Run task. On Daytona that window is ~0.9 s and a click or Enter inside
it was silently dropped with the draft intact. Seed the snapshot cache from the
create response (stale, so the real fetch still runs as a background refresh).

* fix(app): let a send that settled with a server failure recover instead of wedging

A prompt_async answered with 5xx/408 is an uncertain admission (#4635), but
the hold it created could never end: the acceptance check treated absence as
no evidence, Stop refused while the admission was unknown, and the failure body
was dropped, so the person saw a paused composer with no explanation.

- Keep the settled response body on PromptAdmissionUnknownError and show it as
  the session error card right away; the hold still prevents any resend.
- readPromptAdmission: after the exact-message GET misses, list the idle
  conversation; when native lists it without the message the POST is settled
  and did not admit, so the drain receives admission_rejected and halts like
  any definite failure (explicit retry only, never an automatic resend).
- On release the pending row is removed and the submitted composer is kept as
  an unsent message to restore; on confirmed acceptance the stale failure card
  is cleared.

* test(evals): read an empty Lexical composer as an empty draft

Lexical keeps one empty paragraph in an empty editor, which innerText reports
as "\n". readComposerState now drops that trailing line break so draftText
matches what the person sees; user.see("composer", { text }) already trimmed.

* test(evals): open the fresh conversation through the sidebar's New session button

The sidebar button was renamed from "New task" to "New session" in #4491
(Sep 5) and the "new" scenario added in #4631 (Sep 8) still targeted the old
label, so it could never find the button. Same user action, current label.

* fix(app): re-read pending questions when a session reaches a terminal edge

An interrupted question tool leaves the engine's pending list through its
finalizer without a question.rejected event, so Stop on a delegated child
left its question (and option buttons) in the parent view. Reconcile
questions alongside permissions on terminal edges, for both engines.

* Revert "fix(app): re-read pending questions when a session reaches a terminal edge"

This reverts commit 9e70982.

* fix(app): withdraw the stopped tree's unanswered questions after Stop

OpenCode v1 runs tools on detached fibers: aborting a session marks the
question tool part as aborted but leaves its request in GET /question, so a
parent that stopped a delegated child kept showing the child's question and
its option buttons. After Stop confirms the tree idle, reject the pending
questions owned by the stopped sessions; other roots are left untouched.
yomgui added a commit that referenced this pull request Sep 11, 2026
… person's tab, not agent browser control (#4779)

* fix(app): seed a created session's snapshot so its composer can send immediately

A freshly created session had no cached snapshot, so deriveSessionRenderModel
reported "switching" while the first snapshot round trip ran and the composer
disabled Run task. On Daytona that window is ~0.9 s and a click or Enter inside
it was silently dropped with the draft intact. Seed the snapshot cache from the
create response (stale, so the real fetch still runs as a background refresh).

* fix(app): let a send that settled with a server failure recover instead of wedging

A prompt_async answered with 5xx/408 is an uncertain admission (#4635), but
the hold it created could never end: the acceptance check treated absence as
no evidence, Stop refused while the admission was unknown, and the failure body
was dropped, so the person saw a paused composer with no explanation.

- Keep the settled response body on PromptAdmissionUnknownError and show it as
  the session error card right away; the hold still prevents any resend.
- readPromptAdmission: after the exact-message GET misses, list the idle
  conversation; when native lists it without the message the POST is settled
  and did not admit, so the drain receives admission_rejected and halts like
  any definite failure (explicit retry only, never an automatic resend).
- On release the pending row is removed and the submitted composer is kept as
  an unsent message to restore; on confirmed acceptance the stale failure card
  is cleared.

* test(evals): read an empty Lexical composer as an empty draft

Lexical keeps one empty paragraph in an empty editor, which innerText reports
as "\n". readComposerState now drops that trailing line break so draftText
matches what the person sees; user.see("composer", { text }) already trimmed.

* test(evals): open the fresh conversation through the sidebar's New session button

The sidebar button was renamed from "New task" to "New session" in #4491
(Sep 5) and the "new" scenario added in #4631 (Sep 8) still targeted the old
label, so it could never find the button. Same user action, current label.

* test(evals): open the saved-app verification return tab as a person's new tab

returnVerification drove the agent browser entry point (browser.openUrl, the
IPC behind browser.open_url) with no requesting conversation. Since #4434 the
task host refuses that, and it never accepted an about:blank destination.
The tab only stands in for the person's own browser so the openwork:// return
link can be navigated without OS protocol registration, so create it the way a
person opens a new tab and find its CDP target by diffing the page targets.
yomgui added a commit that referenced this pull request Sep 11, 2026
…re the spec acts on it (#4794)

* fix(app): seed a created session's snapshot so its composer can send immediately

A freshly created session had no cached snapshot, so deriveSessionRenderModel
reported "switching" while the first snapshot round trip ran and the composer
disabled Run task. On Daytona that window is ~0.9 s and a click or Enter inside
it was silently dropped with the draft intact. Seed the snapshot cache from the
create response (stale, so the real fetch still runs as a background refresh).

* fix(app): let a send that settled with a server failure recover instead of wedging

A prompt_async answered with 5xx/408 is an uncertain admission (#4635), but
the hold it created could never end: the acceptance check treated absence as
no evidence, Stop refused while the admission was unknown, and the failure body
was dropped, so the person saw a paused composer with no explanation.

- Keep the settled response body on PromptAdmissionUnknownError and show it as
  the session error card right away; the hold still prevents any resend.
- readPromptAdmission: after the exact-message GET misses, list the idle
  conversation; when native lists it without the message the POST is settled
  and did not admit, so the drain receives admission_rejected and halts like
  any definite failure (explicit retry only, never an automatic resend).
- On release the pending row is removed and the submitted composer is kept as
  an unsent message to restore; on confirmed acceptance the stale failure card
  is cleared.

* test(evals): read an empty Lexical composer as an empty draft

Lexical keeps one empty paragraph in an empty editor, which innerText reports
as "\n". readComposerState now drops that trailing line break so draftText
matches what the person sees; user.see("composer", { text }) already trimmed.

* test(evals): open the fresh conversation through the sidebar's New session button

The sidebar button was renamed from "New task" to "New session" in #4491
(Sep 5) and the "new" scenario added in #4631 (Sep 8) still targeted the old
label, so it could never find the button. Same user action, current label.

* test(evals): open the saved-app verification return tab as a person's new tab

returnVerification drove the agent browser entry point (browser.openUrl, the
IPC behind browser.open_url) with no requesting conversation. Since #4434 the
task host refuses that, and it never accepted an about:blank destination.
The tab only stands in for the person's own browser so the openwork:// return
link can be navigated without OS protocol registration, so create it the way a
person opens a new tab and find its CDP target by diffing the page targets.

* test(evals): temporary Z2 diagnostics around dashboard delete route change

* test(evals): saved-apps world.open returns once the destination page root has rendered

* test(evals): remove temporary Z2 diagnostics from saved-app-creation
yomgui added a commit that referenced this pull request Sep 11, 2026
…-status flip on reload (#4797)

* fix(app): seed a created session's snapshot so its composer can send immediately

A freshly created session had no cached snapshot, so deriveSessionRenderModel
reported "switching" while the first snapshot round trip ran and the composer
disabled Run task. On Daytona that window is ~0.9 s and a click or Enter inside
it was silently dropped with the draft intact. Seed the snapshot cache from the
create response (stale, so the real fetch still runs as a background refresh).

* fix(app): let a send that settled with a server failure recover instead of wedging

A prompt_async answered with 5xx/408 is an uncertain admission (#4635), but
the hold it created could never end: the acceptance check treated absence as
no evidence, Stop refused while the admission was unknown, and the failure body
was dropped, so the person saw a paused composer with no explanation.

- Keep the settled response body on PromptAdmissionUnknownError and show it as
  the session error card right away; the hold still prevents any resend.
- readPromptAdmission: after the exact-message GET misses, list the idle
  conversation; when native lists it without the message the POST is settled
  and did not admit, so the drain receives admission_rejected and halts like
  any definite failure (explicit retry only, never an automatic resend).
- On release the pending row is removed and the submitted composer is kept as
  an unsent message to restore; on confirmed acceptance the stale failure card
  is cleared.

* test(evals): read an empty Lexical composer as an empty draft

Lexical keeps one empty paragraph in an empty editor, which innerText reports
as "\n". readComposerState now drops that trailing line break so draftText
matches what the person sees; user.see("composer", { text }) already trimmed.

* test(evals): open the fresh conversation through the sidebar's New session button

The sidebar button was renamed from "New task" to "New session" in #4491
(Sep 5) and the "new" scenario added in #4631 (Sep 8) still targeted the old
label, so it could never find the button. Same user action, current label.

* test(evals): open sidebar New session as the empty composer in workspace-engine-upgrade

Since #4577 the sidebar New session controls open the workspace's empty
New task composer at once and the session is created by the first send,
so waiting for a ses_ route on click never resolves. Re-express the three
sidebar steps: the click lands on the empty New task route without a
session surface and a prompt-ready composer; the send must land in a
session no earlier turn used (same claim shape as bench-openwork-app-v1
in #4738). Palette steps still create their session up front.

* fix(app): send the New task prompt under v2 chat routing

The empty New task composer creates its session on submit and hands the
prompt over through a scoped auto-send mark. The mark was keyed with the
workspace's default endpoint URL (always /opencode), while the session
surface under OpenCode v2 routing owns the session as /opencode2, so the
lookup missed and the prompt silently vanished: the person landed on an
empty new session. Key the mark with the endpoint that created the
session (createRouteSessionWithEndpoint), which is the URL the surface
will own it under.

* test(app): reproduce the snapshot owner flip after a v2 session reload

The surface mounts under the v1 URL before chat routing resolves; the owned
read 404s, parks for its retry, and the routing flip changes the owner while
it is in flight. The read rejects with 'Session snapshot owner changed' and
the (workspace, session) query never refetches, so the transcript stays empty.

* fix(app): re-read a session snapshot when its owning endpoint changes

The snapshot query is keyed on (workspace, session) with retry off on the
desktop loopback path. When chat routing resolves to /opencode2 after boot
(or the draft scope resolves once identity verifies) while the owned read is
in flight, the read rejects with 'Session snapshot owner changed' and nothing
refetched, leaving the error card over an empty transcript. Cancel the doomed
read and refetch under the new owner; a bare invalidate would only join the
in-flight promise because the first load has no data yet.
yomgui added a commit that referenced this pull request Sep 11, 2026
…ed permission requests after Stop (#4798)

* fix(app): seed the delegated-task eval with a running parent thread

The eval.task_activity.seed hook rendered an in-flight child under a parent
whose thread status stayed "ready": it only marked the activity store busy.
Since #4635 MessageList reads that as a stopped parent and the unobserved
child shows "Waiting for task result" instead of the Working shimmer, so
task-activity-shimmer and parent-child-permission-approval lost their
[data-subagent-activity="shimmer"] row. A delegating parent is mid-run;
mark the eval thread streaming like the chat-loading seed already does.

* fix(app): seed a created session's snapshot so its composer can send immediately

A freshly created session had no cached snapshot, so deriveSessionRenderModel
reported "switching" while the first snapshot round trip ran and the composer
disabled Run task. On Daytona that window is ~0.9 s and a click or Enter inside
it was silently dropped with the draft intact. Seed the snapshot cache from the
create response (stale, so the real fetch still runs as a background refresh).

* fix(app): let a send that settled with a server failure recover instead of wedging

A prompt_async answered with 5xx/408 is an uncertain admission (#4635), but
the hold it created could never end: the acceptance check treated absence as
no evidence, Stop refused while the admission was unknown, and the failure body
was dropped, so the person saw a paused composer with no explanation.

- Keep the settled response body on PromptAdmissionUnknownError and show it as
  the session error card right away; the hold still prevents any resend.
- readPromptAdmission: after the exact-message GET misses, list the idle
  conversation; when native lists it without the message the POST is settled
  and did not admit, so the drain receives admission_rejected and halts like
  any definite failure (explicit retry only, never an automatic resend).
- On release the pending row is removed and the submitted composer is kept as
  an unsent message to restore; on confirmed acceptance the stale failure card
  is cleared.

* test(evals): read an empty Lexical composer as an empty draft

Lexical keeps one empty paragraph in an empty editor, which innerText reports
as "\n". readComposerState now drops that trailing line break so draftText
matches what the person sees; user.see("composer", { text }) already trimmed.

* test(evals): open the fresh conversation through the sidebar's New session button

The sidebar button was renamed from "New task" to "New session" in #4491
(Sep 5) and the "new" scenario added in #4631 (Sep 8) still targeted the old
label, so it could never find the button. Same user action, current label.

* test(evals): create the empty-session workspace at its declared tmp path

The emptySession world declared /tmp/openwork-empty-session-<ts> but
seed.workspace() adopted the existing first-launch default workspace
instead ("OpenWork Chat" under the dev profile home). On Daytona that
profile lives inside the repo checkout, so the engine merged
/workspace/.opencode/opencode.json ("permission": "allow") over the
workspace's own catch-all and composer-run-mode never saw the workspace
MCP rule take effect.

* test(evals): name the server error when a run-mode read is not 200

* fix(app): reuse the running OpenWork server when the selected workspace is server-owned

Workspaces created from the app live only in the server registry, so a
renderer reload reaches startServerWithoutDesktopWorkspace and restarted
the embedded server (and its engine) although it was healthy and already
serving that workspace. Reuse it when it is ready with the preferred
remote-access mode; only restart when nothing usable is running.

* fix(app): re-read pending questions when a session reaches a terminal edge

An interrupted question tool leaves the engine's pending list through its
finalizer without a question.rejected event, so Stop on a delegated child
left its question (and option buttons) in the parent view. Reconcile
questions alongside permissions on terminal edges, for both engines.

* Revert "fix(app): re-read pending questions when a session reaches a terminal edge"

This reverts commit 9e70982.

* fix(app): withdraw the stopped tree's unanswered questions after Stop

OpenCode v1 runs tools on detached fibers: aborting a session marks the
question tool part as aborted but leaves its request in GET /question, so a
parent that stopped a delegated child kept showing the child's question and
its option buttons. After Stop confirms the tree idle, reject the pending
questions owned by the stopped sessions; other roots are left untouched.

* test(evals): create the permission-stop-recovery workspace at its declared tmp path

Test 2 of parent-child-permission-approval ran bash without asking although the
world wrote permission.bash="ask" to the workspace opencode.json. The world
called seed.workspace() without create, so it adopted the first-launch default
workspace under the dev profile, which on Daytona lives inside the repo
checkout; the engine merges /workspace/.opencode/opencode.json
("permission": "allow") after the workspace file and the agent ruleset ends
as [* allow, bash ask, * allow] (last match wins; permissions/effective reported
shell allow, source engine, workspace file .../home/OpenWork Chat/opencode.jsonc).

Same trap and same remedy as #4747 for emptySession; opt-in so the other
splitPaneQuestions worlds keep their arrangement.

* fix(app): withdraw the stopped tree's unanswered permission requests after Stop

OpenCode v1 runs tools on detached fibers: aborting a session marks the tool
part as aborted but leaves its Permission.ask pending in /permission, exactly
as #4786 found for questions. The stopped tree would keep showing Allow once /
Deny in every pane and after reload although nothing can consume the answer.

After stopForegroundTree confirms every stopped session idle, also list pending
permissions and reject the ones owned by the stopped tree; other roots are
never touched and each withdrawal kind is best-effort on its own.
yomgui added a commit that referenced this pull request Sep 11, 2026
… the composer recovers (attachment-upload-loading-state) (#4811)

* fix(app): seed a created session's snapshot so its composer can send immediately

A freshly created session had no cached snapshot, so deriveSessionRenderModel
reported "switching" while the first snapshot round trip ran and the composer
disabled Run task. On Daytona that window is ~0.9 s and a click or Enter inside
it was silently dropped with the draft intact. Seed the snapshot cache from the
create response (stale, so the real fetch still runs as a background refresh).

* fix(app): let a send that settled with a server failure recover instead of wedging

A prompt_async answered with 5xx/408 is an uncertain admission (#4635), but
the hold it created could never end: the acceptance check treated absence as
no evidence, Stop refused while the admission was unknown, and the failure body
was dropped, so the person saw a paused composer with no explanation.

- Keep the settled response body on PromptAdmissionUnknownError and show it as
  the session error card right away; the hold still prevents any resend.
- readPromptAdmission: after the exact-message GET misses, list the idle
  conversation; when native lists it without the message the POST is settled
  and did not admit, so the drain receives admission_rejected and halts like
  any definite failure (explicit retry only, never an automatic resend).
- On release the pending row is removed and the submitted composer is kept as
  an unsent message to restore; on confirmed acceptance the stale failure card
  is cleared.

* test(evals): read an empty Lexical composer as an empty draft

Lexical keeps one empty paragraph in an empty editor, which innerText reports
as "\n". readComposerState now drops that trailing line break so draftText
matches what the person sees; user.see("composer", { text }) already trimmed.

* test(evals): open the fresh conversation through the sidebar's New session button

The sidebar button was renamed from "New task" to "New session" in #4491
(Sep 5) and the "new" scenario added in #4631 (Sep 8) still targeted the old
label, so it could never find the button. Same user action, current label.

* test(evals): open sidebar New session as the empty composer in workspace-engine-upgrade

Since #4577 the sidebar New session controls open the workspace's empty
New task composer at once and the session is created by the first send,
so waiting for a ses_ route on click never resolves. Re-express the three
sidebar steps: the click lands on the empty New task route without a
session surface and a prompt-ready composer; the send must land in a
session no earlier turn used (same claim shape as bench-openwork-app-v1
in #4738). Palette steps still create their session up front.

* fix(app): send the New task prompt under v2 chat routing

The empty New task composer creates its session on submit and hands the
prompt over through a scoped auto-send mark. The mark was keyed with the
workspace's default endpoint URL (always /opencode), while the session
surface under OpenCode v2 routing owns the session as /opencode2, so the
lookup missed and the prompt silently vanished: the person landed on an
empty new session. Key the mark with the endpoint that created the
session (createRouteSessionWithEndpoint), which is the URL the surface
will own it under.

* test(app): reproduce the snapshot owner flip after a v2 session reload

The surface mounts under the v1 URL before chat routing resolves; the owned
read 404s, parks for its retry, and the routing flip changes the owner while
it is in flight. The read rejects with 'Session snapshot owner changed' and
the (workspace, session) query never refetches, so the transcript stays empty.

* fix(app): re-read a session snapshot when its owning endpoint changes

The snapshot query is keyed on (workspace, session) with retry off on the
desktop loopback path. When chat routing resolves to /opencode2 after boot
(or the draft scope resolves once identity verifies) while the owned read is
in flight, the read rejects with 'Session snapshot owner changed' and nothing
refetched, leaving the error card over an empty transcript. Cancel the doomed
read and refetch under the new owner; a bare invalidate would only join the
in-flight promise because the first load has no data yet.
yomgui added a commit that referenced this pull request Sep 11, 2026
… route-commit wait in world open() (#4810)

* fix(app): seed the delegated-task eval with a running parent thread

The eval.task_activity.seed hook rendered an in-flight child under a parent
whose thread status stayed "ready": it only marked the activity store busy.
Since #4635 MessageList reads that as a stopped parent and the unobserved
child shows "Waiting for task result" instead of the Working shimmer, so
task-activity-shimmer and parent-child-permission-approval lost their
[data-subagent-activity="shimmer"] row. A delegating parent is mid-run;
mark the eval thread streaming like the chat-loading seed already does.

* fix(app): seed a created session's snapshot so its composer can send immediately

A freshly created session had no cached snapshot, so deriveSessionRenderModel
reported "switching" while the first snapshot round trip ran and the composer
disabled Run task. On Daytona that window is ~0.9 s and a click or Enter inside
it was silently dropped with the draft intact. Seed the snapshot cache from the
create response (stale, so the real fetch still runs as a background refresh).

* fix(app): let a send that settled with a server failure recover instead of wedging

A prompt_async answered with 5xx/408 is an uncertain admission (#4635), but
the hold it created could never end: the acceptance check treated absence as
no evidence, Stop refused while the admission was unknown, and the failure body
was dropped, so the person saw a paused composer with no explanation.

- Keep the settled response body on PromptAdmissionUnknownError and show it as
  the session error card right away; the hold still prevents any resend.
- readPromptAdmission: after the exact-message GET misses, list the idle
  conversation; when native lists it without the message the POST is settled
  and did not admit, so the drain receives admission_rejected and halts like
  any definite failure (explicit retry only, never an automatic resend).
- On release the pending row is removed and the submitted composer is kept as
  an unsent message to restore; on confirmed acceptance the stale failure card
  is cleared.

* test(evals): read an empty Lexical composer as an empty draft

Lexical keeps one empty paragraph in an empty editor, which innerText reports
as "\n". readComposerState now drops that trailing line break so draftText
matches what the person sees; user.see("composer", { text }) already trimmed.

* test(evals): open the fresh conversation through the sidebar's New session button

The sidebar button was renamed from "New task" to "New session" in #4491
(Sep 5) and the "new" scenario added in #4631 (Sep 8) still targeted the old
label, so it could never find the button. Same user action, current label.

* test(evals): create the empty-session workspace at its declared tmp path

The emptySession world declared /tmp/openwork-empty-session-<ts> but
seed.workspace() adopted the existing first-launch default workspace
instead ("OpenWork Chat" under the dev profile home). On Daytona that
profile lives inside the repo checkout, so the engine merged
/workspace/.opencode/opencode.json ("permission": "allow") over the
workspace's own catch-all and composer-run-mode never saw the workspace
MCP rule take effect.

* test(evals): name the server error when a run-mode read is not 200

* fix(app): reuse the running OpenWork server when the selected workspace is server-owned

Workspaces created from the app live only in the server registry, so a
renderer reload reaches startServerWithoutDesktopWorkspace and restarted
the embedded server (and its engine) although it was healthy and already
serving that workspace. Reuse it when it is ready with the preferred
remote-access mode; only restart when nothing usable is running.

* test(evals): open the saved-app verification return tab as a person's new tab

returnVerification drove the agent browser entry point (browser.openUrl, the
IPC behind browser.open_url) with no requesting conversation. Since #4434 the
task host refuses that, and it never accepted an about:blank destination.
The tab only stands in for the person's own browser so the openwork:// return
link can be navigated without OS protocol registration, so create it the way a
person opens a new tab and find its CDP target by diffing the page targets.

* fix(app): re-read pending questions when a session reaches a terminal edge

An interrupted question tool leaves the engine's pending list through its
finalizer without a question.rejected event, so Stop on a delegated child
left its question (and option buttons) in the parent view. Reconcile
questions alongside permissions on terminal edges, for both engines.

* Revert "fix(app): re-read pending questions when a session reaches a terminal edge"

This reverts commit 9e70982.

* fix(app): withdraw the stopped tree's unanswered questions after Stop

OpenCode v1 runs tools on detached fibers: aborting a session marks the
question tool part as aborted but leaves its request in GET /question, so a
parent that stopped a delegated child kept showing the child's question and
its option buttons. After Stop confirms the tree idle, reject the pending
questions owned by the stopped sessions; other roots are left untouched.

* test(evals): temporary Z2 diagnostics around dashboard delete route change

* test(evals): saved-apps world.open returns once the destination page root has rendered

* test(evals): create the permission-stop-recovery workspace at its declared tmp path

Test 2 of parent-child-permission-approval ran bash without asking although the
world wrote permission.bash="ask" to the workspace opencode.json. The world
called seed.workspace() without create, so it adopted the first-launch default
workspace under the dev profile, which on Daytona lives inside the repo
checkout; the engine merges /workspace/.opencode/opencode.json
("permission": "allow") after the workspace file and the agent ruleset ends
as [* allow, bash ask, * allow] (last match wins; permissions/effective reported
shell allow, source engine, workspace file .../home/OpenWork Chat/opencode.jsonc).

Same trap and same remedy as #4747 for emptySession; opt-in so the other
splitPaneQuestions worlds keep their arrangement.

* fix(app): withdraw the stopped tree's unanswered permission requests after Stop

OpenCode v1 runs tools on detached fibers: aborting a session marks the tool
part as aborted but leaves its Permission.ask pending in /permission, exactly
as #4786 found for questions. The stopped tree would keep showing Allow once /
Deny in every pane and after reload although nothing can consume the answer.

After stopForegroundTree confirms every stopped session idle, also list pending
permissions and reject the ones owned by the stopped tree; other roots are
never touched and each withdrawal kind is best-effort on its own.

* test(evals): remove temporary Z2 diagnostics from saved-app-creation

* test(evals): make locate() misses list every candidate of the requested role plus route and page roots

A miss used to quote only the first 8 visible buttons in DOM order, which
are always the shell rail, so every miss read as "the page has no
buttons" (#4794's diagnosis detour). The browser-side report now lists
every rendered element of the requested role (buttons/links for
role-less targets), the current route/hash, and whether the
[data-app-header] / [data-dashboard-page] roots are mounted. The Node
side caps the list at 40 and says how many the page really had.
Matching is unchanged.

* test(evals): run the serialized locate() miss callback against a fake page DOM

Proves the browser-side branch the way Chromium will execute it: the
report lists every rendered menuitem on a dashboard whose shell rail has
nine buttons, names the route and mounted page roots, and a role-less
miss lists all nine buttons instead of the first eight.

This branch was successfully deployed

1 active deployment
Preview – openwork-app — bb65500b Deployed Sep 5, 2026 by vercel[bot]
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