{embedded ? (
@@ -3900,20 +3829,6 @@ export default function ChatView({
data-key={dataKey}
data-cid={userCid || undefined}
data-ts={ownerUserMessage && msg.ts ? String(msg.ts) : undefined}
- onPointerDown={continuationMarker
- ? undefined
- : (event) => handleMessagePointerDown(event, msg, dataKey)}
- onPointerMove={continuationMarker ? undefined : handleMessagePointerMove}
- onPointerUp={continuationMarker ? undefined : cancelMessageHold}
- onPointerCancel={continuationMarker ? undefined : cancelMessageHold}
- onContextMenu={_isTouchPrimary && !continuationMarker
- ? (event) => {
- if (event.target?.closest?.('button, a, input, textarea, summary, pre, code')) return
- event.preventDefault()
- cancelMessageHold()
- void copyMessage(msg, dataKey)
- }
- : undefined}
onClick={msg.ts && ownerUserMessage
? (event) => showTimestamp(event, dataKey)
: undefined}
diff --git a/frontend/src/components/ChatView/ToolBlock.jsx b/frontend/src/components/ChatView/ToolBlock.jsx
index dbf834ce4..aa1a02f52 100644
--- a/frontend/src/components/ChatView/ToolBlock.jsx
+++ b/frontend/src/components/ChatView/ToolBlock.jsx
@@ -320,7 +320,11 @@ export default function ToolBlock({ t, chatId, compact = false, disclosureKey })
{label}{t.status === 'running' ? '…' : ''}
- {failed && (
+ {/* A direct compact row IS the collapsed transcript overview, so its
+ technical code waits inside the disclosed result. A grouped child is
+ already behind the activity disclosure and can carry the diagnostic
+ here for quick scanning. */}
+ {failed && !compact && (
exit {exitCode}
)}
>
diff --git a/frontend/src/components/ChatView/__tests__/activityStretch.test.js b/frontend/src/components/ChatView/__tests__/activityStretch.test.js
index 069b1badd..e46e0a93b 100644
--- a/frontend/src/components/ChatView/__tests__/activityStretch.test.js
+++ b/frontend/src/components/ChatView/__tests__/activityStretch.test.js
@@ -18,12 +18,11 @@ const tool = (extra = {}) => ({ type: 'tool', ...extra })
const think = (extra = {}) => ({ type: 'thinking', ...extra })
const e = item => ({ item })
-// A failed shell result — the only failure signal a tool block carries.
const failOutput = JSON.stringify({ stdout: '', stderr: 'boom', exit_code: 1 })
test('activityStreamState: a live thinking tail forces running (running-wins)', () => {
- // While the agent is actively reasoning the line reads in-progress, even if an
- // earlier tool already failed — the failure surfaces at settle, not mid-run.
+ // While the agent is actively reasoning the line reads in-progress, regardless
+ // of the diagnostic result on an earlier command.
assert.equal(activityStreamState([], { liveThinkingTail: true }), 'running')
assert.equal(
activityStreamState([tool({ status: 'done', output: failOutput })], { liveThinkingTail: true }),
@@ -31,10 +30,10 @@ test('activityStreamState: a live thinking tail forces running (running-wins)',
)
})
-test('activityStreamState: settles to done/error/running from the tools when not live-thinking', () => {
+test('activityStreamState: settled command failures stay inside expansion', () => {
assert.equal(activityStreamState([]), 'done')
assert.equal(activityStreamState([tool({ status: 'done', output: '{}' })]), 'done')
- assert.equal(activityStreamState([tool({ status: 'done', output: failOutput })]), 'error')
+ assert.equal(activityStreamState([tool({ status: 'done', output: failOutput })]), 'done')
assert.equal(activityStreamState([tool({ status: 'running' })]), 'running')
})
@@ -123,7 +122,6 @@ test('settled activity labels and icons have neutral unknown-tool fallbacks', ()
})
test('collapsed label — a live thinking tail after a failed tool still reads "Thinking"', () => {
- // running-wins: the danger chip waits for settle.
const entries = [
e(tool({ tool: 'Bash', status: 'done', output: failOutput })),
e(think({ content: 'recovering', duration_ms: 1000, lastAt: 2_000_000 })),
@@ -140,32 +138,21 @@ test('thoughtDurationLabel: whole seconds, clamps sub-second to 1s, bare "Though
test('activityDisplayState: a live stretch stays in-progress through the tool→tool gap', () => {
// In the gap between one tool ending and the next event no tool is
// 'running', but the trailing live stretch must keep its in-progress face —
- // spinner + progressive copy — never the settled glyph, and never the
- // failure triangle beside present-tense text (failure waits for settle).
+ // spinner + progressive copy — never the settled glyph. Any legacy/internal
+ // error state also projects to the calm settled overview once the turn ends.
assert.equal(activityDisplayState('done', { live: true }), 'running')
assert.equal(activityDisplayState('error', { live: true }), 'running')
assert.equal(activityDisplayState('running', { live: true }), 'running')
assert.equal(activityDisplayState('done', { live: false }), 'done')
- assert.equal(activityDisplayState('error', { live: false }), 'error')
+ assert.equal(activityDisplayState('error', { live: false }), 'done')
})
-test('activityMemoSig: an equal-length output replacement that flips the exit code changes the sig', () => {
- // Claude's plain-text failure marker is START-anchored, so the head slice
- // must catch it; a JSON envelope can serialize exit_code first or last, so
- // head + tail together cover both. Same length everywhere by construction.
+test('activityMemoSig: command output and thinking text do not churn the overview', () => {
const sigOf = output => activityMemoSig([e(tool({ tool: 'Bash', status: 'done', output }))])
const okJson = '{"exit_code":0,"stdout":"abcdefghijklmnop"}'
const failJson = '{"exit_code":1,"stdout":"abcdefghijklmnop"}'
- assert.equal(okJson.length, failJson.length)
- assert.notEqual(sigOf(okJson), sigOf(failJson))
+ assert.equal(sigOf(okJson), sigOf(failJson))
- const okText = 'all good here padded to length!!'
- const failText = 'Exit code 1\nboom padded to len!!'
- assert.equal(okText.length, failText.length)
- assert.notEqual(sigOf(okText), sigOf(failText))
-
- // Thinking content stays OUT of the sig: a typewriter delta on the tail
- // thinking entry must not bust the memo.
const base = [e(tool({ tool: 'Read', status: 'done' })), e(think({ content: 'a' }))]
const grown = [e(tool({ tool: 'Read', status: 'done' })), e(think({ content: 'a much longer thought' }))]
assert.equal(activityMemoSig(base), activityMemoSig(grown))
diff --git a/frontend/src/components/ChatView/__tests__/chatUiPolish.test.js b/frontend/src/components/ChatView/__tests__/chatUiPolish.test.js
index 1ed2d4898..ee1a1a838 100644
--- a/frontend/src/components/ChatView/__tests__/chatUiPolish.test.js
+++ b/frontend/src/components/ChatView/__tests__/chatUiPolish.test.js
@@ -48,17 +48,15 @@ test('stop action has no visible circular shell', () => {
'Stop keyboard focus should move to the square glyph')
})
-test('mobile hold copies immediately without opening an action menu', () => {
+test('mobile messages preserve native text selection and its action menu', () => {
const css = stripComments(chatCss)
assert.doesNotMatch(css, /\.chat__copy-menu|\.chat__copy-overlay/,
- 'instant copy should not render a menu or modal backdrop')
- assert.match(chatView, /void copyMessage\(message, key\)/,
- 'the completed hold should copy the message directly')
- assert.match(chatView, /navigator\.vibrate\?\.\(8\)/,
- 'successful copy should offer subtle haptic confirmation where supported')
- assert.match(chatView, /event\.pointerType !== 'touch'/,
- 'desktop text interaction should stay unchanged')
+ 'messages should not render a custom copy menu or modal backdrop')
+ assert.doesNotMatch(chatView, /handleMessagePointerDown|cancelMessageHold|copyMessage/,
+ 'messages must not intercept the long press used for native text selection')
+ assert.doesNotMatch(chatView, /onContextMenu=/,
+ 'messages must not suppress the native selection action menu')
})
test('web tool activity uses the assistant reading width', () => {
diff --git a/frontend/src/components/ChatView/__tests__/groupBlocks.test.js b/frontend/src/components/ChatView/__tests__/groupBlocks.test.js
index 109940aae..fd8a1a283 100644
--- a/frontend/src/components/ChatView/__tests__/groupBlocks.test.js
+++ b/frontend/src/components/ChatView/__tests__/groupBlocks.test.js
@@ -96,34 +96,25 @@ test('empty input yields no nodes', () => {
assert.deepEqual(groupActivityRuns([]), [])
})
-// A failed shell result — the ONLY failure signal a tool block carries, since
-// the stream never sets a tool status beyond running→done.
-const failOutput = JSON.stringify({ stdout: '', stderr: 'boom', exit_code: 1 })
-const okOutput = JSON.stringify({ stdout: 'ok', exit_code: 0 })
-
-test('toolGroupState: running wins, then a nonzero exit is error, else done', () => {
- // A finished tool with a nonzero exit code marks the whole group failed,
- // even though its status is the usual 'done'.
- assert.equal(
- toolGroupState([tool({ status: 'done', output: okOutput }), tool({ status: 'done', output: failOutput })]),
- 'error'
- )
- // A still-running tool has no final output yet, so it can't be "failed".
+test('toolGroupState: collapsed overviews communicate only running or settled', () => {
+ // An individual command's failure is diagnostic detail, not a verdict on the
+ // surrounding turn. It remains inside ToolBlock after expansion.
assert.equal(
- toolGroupState([tool({ status: 'done', output: okOutput }), tool({ status: 'running' })]),
- 'running'
+ toolGroupState([
+ tool({ status: 'done', output: '{"exit_code":0}' }),
+ tool({ status: 'done', output: '{"exit_code":1}' }),
+ ]),
+ 'done'
)
- // Running WINS over an already-failed sibling — while live the header reads
- // in-progress (spinner); the failure surfaces once the run settles to 'error'.
assert.equal(
- toolGroupState([tool({ status: 'done', output: failOutput }), tool({ status: 'running' })]),
+ toolGroupState([tool({ status: 'done' }), tool({ status: 'running' })]),
'running'
)
+ // Explicit reduced-output metadata is equally private to the expanded child.
assert.equal(
- toolGroupState([tool({ status: 'done', output: okOutput }), tool({ status: 'done', output: okOutput })]),
+ toolGroupState([tool({ status: 'done', output_exit_code: 4 })]),
'done'
)
- // A running tool whose (partial) output isn't a failed terminal stays running.
assert.equal(toolGroupState([tool({ status: 'running', output: '' })]), 'running')
})
@@ -292,18 +283,6 @@ test('coalesceThinkingEntries: groupActivityRuns after coalesce yields one unifi
assert.deepEqual(nodes[0].group.map(x => x.idx), [0, 2, 3])
})
-test('toolGroupState: reads output_exit_code field on a reduced block', () => {
- // Contract rule 6: a failed tool whose output is only a carved excerpt (that
- // may not re-parse) still resolves the group to 'error' via the explicit
- // output_exit_code field.
- const carved = { type: 'tool', status: 'done', output: 'head…[9 B]…tail', output_exit_code: 1 }
- const ok = { type: 'tool', status: 'done', output: 'fine', output_exit_code: 0 }
- assert.equal(toolGroupState([ok, carved]), 'error')
- assert.equal(toolGroupState([ok, ok]), 'done')
- // A still-running tool keeps the group 'running' even with a failed sibling.
- assert.equal(toolGroupState([carved, { type: 'tool', status: 'running' }]), 'running')
-})
-
test('a distinctive tool (image view) breaks out into its own line', () => {
// A Read of an image is a ViewImage activity — a notable beat that stands on
// its own line instead of folding into the surrounding mundane run (owner ref
diff --git a/frontend/src/components/ChatView/__tests__/imageGallery.test.js b/frontend/src/components/ChatView/__tests__/imageGallery.test.js
index 857f5d29c..ad31692dc 100644
--- a/frontend/src/components/ChatView/__tests__/imageGallery.test.js
+++ b/frontend/src/components/ChatView/__tests__/imageGallery.test.js
@@ -19,6 +19,10 @@ const markdownCss = readFileSync(
new URL('../markdown.css', import.meta.url),
'utf8',
)
+const lightboxCss = readFileSync(
+ new URL('../lightbox.css', import.meta.url),
+ 'utf8',
+)
const image = (href, text) => ({ type: 'image', href, text })
const paragraph = (...tokens) => ({
@@ -114,6 +118,26 @@ test('gallery navigation has explicit keyboard and lightbox alternatives', () =>
assert.match(lightboxSource, /\{index \+ 1\} \/ \{galleryItems\.length\}/)
})
+test('lightbox fills its actual overlay and dismisses from every backdrop edge', () => {
+ assert.match(
+ lightboxSource,
+ /className="lightbox-overlay" role="presentation" onClick=\{onClose\}/,
+ 'the full overlay owns backdrop dismissal, including space outside the dialog stage',
+ )
+ assert.match(
+ lightboxCss,
+ /\.lightbox-content\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;/s,
+ 'the stage should inherit the fixed overlay bounds instead of recalculating a zoomed viewport',
+ )
+ assert.match(lightboxCss, /max-width:\s*calc\(100% - 32px\)/)
+ assert.match(lightboxCss, /max-height:\s*calc\(100% - 32px\)/)
+ assert.doesNotMatch(
+ lightboxCss,
+ /\.lightbox-(?:content|image)\s*\{[^}]*(?:100vw|100vh|100dvh)/s,
+ 'root-level desktop density makes viewport units smaller than the already-correct fixed overlay',
+ )
+})
+
test('zoomed touch pan keeps its gesture snapshot through a queued render', () => {
assert.match(
lightboxSource,
diff --git a/frontend/src/components/ChatView/__tests__/messageCopy.test.js b/frontend/src/components/ChatView/__tests__/messageCopy.test.js
index d918883ee..c2c1bd964 100644
--- a/frontend/src/components/ChatView/__tests__/messageCopy.test.js
+++ b/frontend/src/components/ChatView/__tests__/messageCopy.test.js
@@ -1,35 +1,8 @@
import test from 'node:test'
import assert from 'node:assert/strict'
-import { copyableMessageText } from '../messageCopy.js'
+import { copyPlainText } from '../messageCopy.js'
-test('copyableMessageText copies visible prose and excludes tool chrome', () => {
- assert.equal(copyableMessageText({
- role: 'assistant',
- blocks: [
- { type: 'text', content: 'First paragraph.' },
- { type: 'tool', tool: 'Bash', input: 'secret command' },
- { type: 'text', content: 'Second paragraph.' },
- ],
- }), 'First paragraph.\n\nSecond paragraph.')
-})
-
-test('copyableMessageText removes hidden user augmentation', () => {
- assert.equal(copyableMessageText({
- role: 'user',
- content: 'Visible request\n\n
hidden context',
- }), 'Visible request')
-})
-
-test('copyableMessageText ignores hidden transcript rows', () => {
- assert.equal(copyableMessageText({ hidden: true, content: 'do not copy' }), '')
-})
-
-test('copyableMessageText ignores product-owned continuation markers', () => {
- assert.equal(copyableMessageText({
- role: 'user',
- content: 'continue',
- kind: 'auto_continuation',
- continuation_reason: 'restart',
- }), '')
+test('copyPlainText treats empty output as not copied', async () => {
+ assert.equal(await copyPlainText(''), false)
})
diff --git a/frontend/src/components/ChatView/__tests__/toolBlockPresentation.test.js b/frontend/src/components/ChatView/__tests__/toolBlockPresentation.test.js
index d89189083..666f9e2cb 100644
--- a/frontend/src/components/ChatView/__tests__/toolBlockPresentation.test.js
+++ b/frontend/src/components/ChatView/__tests__/toolBlockPresentation.test.js
@@ -34,6 +34,18 @@ test('tool detail is a third nested level with labeled command and output', () =
'output aligns beneath the child label')
})
+test('technical command failures stay behind the top-level disclosure', () => {
+ assert.doesNotMatch(activityHeader, /exitCode|chat__activity-chip|displayState === 'error'/,
+ 'a collapsed activity overview must not present a command exit as a turn-level alarm')
+ assert.match(toolBlock, /\{failed && !compact && \(/,
+ 'only a child already revealed by an expanded activity may show the header exit chip')
+ assert.match(toolBlock,
+ /r\.exitCode != null && r\.exitCode !== 0[\s\S]*className="chat__tool-exit"/,
+ 'the exact code remains available in the disclosed command output')
+ assert.doesNotMatch(chatCss, /\.chat__activity--error|\.chat__activity-chip/,
+ 'collapsed activity chrome stays visually neutral')
+})
+
test('a lone tool activity uses the borderless compact disclosure surface', () => {
assert.match(toolBlock, /compact = false/,
'ToolBlock exposes an explicit compact surface instead of styling every tool globally')
diff --git a/frontend/src/components/ChatView/groupBlocks.js b/frontend/src/components/ChatView/groupBlocks.js
index fddb1a761..a3d55201a 100644
--- a/frontend/src/components/ChatView/groupBlocks.js
+++ b/frontend/src/components/ChatView/groupBlocks.js
@@ -1,4 +1,3 @@
-import { toolBlockFailed } from './toolResultFormat.js'
import {
toolActivityLabel,
toolActivityPastLabel,
@@ -100,31 +99,15 @@ export function coalesceThinkingEntries(entries) {
return out
}
-// Derive the collapsed status of a tool group from its children: while any tool
-// is still running the group reads in-progress; once settled, a failed child
-// shows error (so a broken step is visible without expanding), else done.
-// Shared by ActivityStretch (via activityStreamState), which
-// maps this to the header status — label shimmer while 'running', a danger
-// triangle + exit chip on 'error', the muted type glyph on 'done' — all
-// readable WITHOUT expanding,
-// since the stretch stays collapsed by default (see ActivityStretch).
-//
-// "Failed" comes from the result's exit code, NOT a tool status — the stream
-// contract only sets 'running' → 'done' (streamReducers.js), so a failed bash
-// still ends 'done'. toolBlockFailed reads the explicit output_exit_code field
-// when a reduced block carries one (contract rule 6), else the nonzero exit out
-// of the parsed terminal envelope — the same signal ToolBlock shows on the
-// block header. A
-// still-running tool has no final output yet, so it can't be "failed" here.
-//
-// `running` wins over `error`: while ANY child is still live the header reads
-// as in-progress (the shimmer), even if an earlier child already failed — the
-// failure surfaces once the run settles and the state resolves to 'error'.
-// Checking running first also short-circuits the parse-heavy failure scan on
-// every streaming frame while the run is in flight.
+// Derive only the state a COLLAPSED activity overview can honestly communicate:
+// in progress while a child runs, settled otherwise. A shell exit is a
+// low-level diagnostic, not a reliable verdict on the turn — agents commonly
+// recover from optional probes, guarded test invocations, or stale patch
+// attempts. Exact failures remain on the individual ToolBlock after the owner
+// expands the activity. Keeping them out of this overview also avoids parsing
+// every command output merely to paint a misleading alarm.
export function toolGroupState(tools) {
if (tools.some(t => t?.status === 'running')) return 'running'
- if (tools.some(t => toolBlockFailed(t))) return 'error'
return 'done'
}
@@ -215,48 +198,31 @@ export function thoughtDurationLabel(durationMs) {
return secondsText ? `Thought for ${secondsText}` : 'Thought'
}
-// Collapsed status of a whole activity stretch: reuses toolGroupState (running >
-// error > done, failure read from the exit code) but a LIVE thinking tail forces
-// 'running' — while the agent is actively reasoning the line reads in-progress
-// (the shimmering bare "Thinking"), and an earlier nonzero exit stays quiet until the run
-// settles (running-wins). Empty tools + no live tail settles 'done'.
+// Collapsed status of a whole activity stretch. A LIVE thinking tail forces
+// 'running'; otherwise the overview is running while a tool runs and settled
+// when the stretch is done. Command-level diagnostics belong inside expansion.
export function activityStreamState(tools, { liveThinkingTail = false } = {}) {
if (liveThinkingTail) return 'running'
return toolGroupState(tools)
}
-// The SINGLE presentation authority for the collapsed line's icon, danger
-// chip, and state class, applied on top of an already-derived stream state
-// (so the caller's memo keeps owning the parse-heavy failure scan). A live
-// trailing stretch is in-progress for its WHOLE life — including the gap
-// between one tool ending and the next event, where no tool is 'running' —
-// so tense (activityCollapsedLabel's live||running branch) and the shimmer
-// stay in agreement instead of a settled face or the
-// failure triangle flashing in mid-turn beside present-tense copy. Failure
-// surfaces when the stretch actually
-// settles (live=false), consistent with running-wins.
+// The SINGLE presentation authority for a collapsed line's settled/running
+// face. A live trailing stretch stays in progress through the gap between tool
+// events, so tense and shimmer never contradict each other.
export function activityDisplayState(state, { live = false } = {}) {
- if (live && state !== 'running') return 'running'
- return state
+ return live || state === 'running' ? 'running' : 'done'
}
-// The memo signature ActivityStretch keys its parse-heavy derivations on.
-// Pure and exported so the staleness contract is unit-testable. Per tool:
-// name + status + output length + the output's HEAD and TAIL slices + the
-// explicit exit-code field. The two slices cover both places a failure marker
-// can live in replace-semantics output — Claude's plain-text "Exit code N"
-// head is START-anchored and a JSON envelope may serialize exit_code first or
-// last — so an equal-length replacement that flips the exit code cannot leave
-// a stale success line. Thinking entries contribute a
-// constant: their content length must NOT bust the memo on typewriter frames.
+// The memo signature ActivityStretch keys its overview derivations on. The
+// overview depends on tool identity/status, never command output; ToolBlock owns
+// output and failure rendering after expansion. Thinking content likewise stays
+// out so typewriter frames do not rebuild every settled overview above them.
export function activityMemoSig(entries, { liveThinkingTail = false } = {}) {
return entries
.map(e => {
const it = e?.item
if (it?.type === 'tool') {
- return `t:${it.tool || ''}:${it.status || ''}:${it.output?.length || 0}`
- + `:${it.output?.slice(0, 16) || ''}:${it.output?.slice(-14) || ''}`
- + `:${it.output_exit_code ?? ''}`
+ return `t:${it.tool || ''}:${it.status || ''}`
}
return 'k'
})
@@ -275,7 +241,7 @@ export function activityMemoSig(entries, { liveThinkingTail = false } = {}) {
// aria-label instead
// - thinking-only → "Thought for Ns" (the reasoning duration IS the content)
// Cheap on every call (Map lookups + a duration sum), so it runs each render
-// without a memo; the parse-heavy failure state lives in activityStreamState.
+// without a memo.
export function activityCollapsedLabel(entries, { live = false } = {}) {
const tools = entries
.filter(e => e?.item?.type === 'tool')
diff --git a/frontend/src/components/ChatView/lightbox.css b/frontend/src/components/ChatView/lightbox.css
index 7f8e0e50b..8170f30a4 100644
--- a/frontend/src/components/ChatView/lightbox.css
+++ b/frontend/src/components/ChatView/lightbox.css
@@ -13,18 +13,15 @@
to { opacity: 1 }
}
.lightbox-content {
- position: relative;
- width: 100vw;
- height: 100vh;
- height: 100dvh;
+ position: absolute;
+ inset: 0;
display: grid;
place-items: center;
overflow: hidden;
}
.lightbox-image {
- max-width: calc(100vw - 32px);
- max-height: calc(100vh - 32px);
- max-height: calc(100dvh - 32px);
+ max-width: calc(100% - 32px);
+ max-height: calc(100% - 32px);
object-fit: contain;
border-radius: 8px;
touch-action: none;
@@ -128,8 +125,8 @@
@media (max-width: 34rem) {
.lightbox-image {
- max-width: calc(100vw - 16px);
- max-height: calc(100dvh - 96px);
+ max-width: calc(100% - 16px);
+ max-height: calc(100% - 96px);
}
.lightbox-nav {
diff --git a/frontend/src/components/ChatView/markdown/ImageLightbox.jsx b/frontend/src/components/ChatView/markdown/ImageLightbox.jsx
index 95518edd4..ae6ec6fbd 100644
--- a/frontend/src/components/ChatView/markdown/ImageLightbox.jsx
+++ b/frontend/src/components/ChatView/markdown/ImageLightbox.jsx
@@ -326,7 +326,7 @@ export default function ImageLightbox({
}
return (
-
+
![]()
{
- const answer = block.answers?.[question.question]
- return answer
- ? `${question.question}\n${answer}`
- : question.question
- }).filter(Boolean).join('\n\n')
-}
-
-/** Resolve the owner-visible prose in one transcript row. Tool chrome and
- * hidden prompt augmentation stay out of copied text. */
-export function copyableMessageText(message) {
- if (!message || message.hidden || isAutoContinuationMessage(message)) return ''
- const parts = []
- if (Array.isArray(message.blocks) && message.blocks.length > 0) {
- for (const block of message.blocks) {
- if (block?.type === 'text' && block.content) parts.push(block.content)
- else if (block?.type === 'error' && block.message) parts.push(block.message)
- else if (block?.type === 'question') {
- const text = questionText(block)
- if (text) parts.push(text)
- }
- }
- }
- if (parts.length === 0 && typeof message.content === 'string') {
- parts.push(message.role === 'user'
- ? stripAugmentation(message.content)
- : message.content)
- }
- return parts.join('\n\n').trim()
-}
-
/** Clipboard API first, textarea fallback for older/iOS PWA contexts. */
export async function copyPlainText(text) {
if (!text) return false
diff --git a/frontend/src/components/Shell/Shell.jsx b/frontend/src/components/Shell/Shell.jsx
index 7f3536133..8cadd5653 100644
--- a/frontend/src/components/Shell/Shell.jsx
+++ b/frontend/src/components/Shell/Shell.jsx
@@ -187,10 +187,20 @@ export default function Shell() {
// workspace boundary can then own every edge into an empty single screen without
// making early navigation hooks depend on a callback declared later in the render.
const requestEmptySingleNewChatRef = useRef(null)
- // Ephemeral presentation state only. Focusing one pane must never rewrite the
- // persisted split tree or ratios, so this id lives outside the workspace blob.
- const [focusedPaneViewId, setFocusedPaneViewIdState] = useState(null)
- const focusedPaneViewIdRef = useRef(null)
+ // Presentation state: which pane (if any) is maximized full-screen. It lives
+ // OUTSIDE the workspace blob so focusing a pane never rewrites the split tree or
+ // ratios — but it IS persisted to its own key and re-seeded here, so a maximize
+ // survives the apply-on-idle reload that fires while the tab is backgrounded
+ // (which otherwise dropped it, un-maximizing the pane on return). Seed the STATE
+ // and the REF together: dispatchWorkspace's reconcile reads the ref and short-
+ // circuits when it is null, so a state-only seed would fail to retarget/collapse
+ // the maximize on the first workspace mutation after boot.
+ const [focusedPaneViewId, setFocusedPaneViewIdState] = useState(
+ () => paneModel.resolveInitialFocusedPaneView(
+ workspace, paneModel.readFocusedPaneView(sessionStorage),
+ ),
+ )
+ const focusedPaneViewIdRef = useRef(focusedPaneViewId)
const setFocusedPaneViewId = useCallback((paneId) => {
focusedPaneViewIdRef.current = paneId
setFocusedPaneViewIdState(paneId)
@@ -389,6 +399,15 @@ export default function Shell() {
}
}, [workspace.viewMode, modeState.transition, setFocusedPaneViewId])
+ // Persist the maximized-pane presentation to its own key on every change so it
+ // survives the apply-on-idle reload (and a browser tab discard). Removing the
+ // key when nothing is maximized keeps a dismissed maximize from resurrecting on
+ // a later reload. Kept separate from the workspace-blob dual-write so focusing a
+ // pane never rewrites the tree/ratios.
+ useEffect(() => {
+ paneModel.writeFocusedPaneView(focusedPaneViewId, sessionStorage)
+ }, [focusedPaneViewId])
+
const toggleFocusedPaneView = useCallback((paneId) => {
const ws = workspaceStateRef.current.ws
if (!ws.panes[paneId] || Object.keys(ws.panes).length <= 1) {
@@ -807,6 +826,10 @@ export default function Shell() {
paneModel.STORAGE_KEY,
paneModel.serializeWorkspace(workspaceStateRef.current.ws),
)
+ // Capture the maximize from the REF (like the ws above): this runs after an
+ // await, so the render-closure focusedPaneViewId is stale. Keeping the (ws,
+ // maximize) pair from refs makes the persisted snapshot atomic.
+ paneModel.writeFocusedPaneView(focusedPaneViewIdRef.current, sessionStorage)
} catch { /* private mode / quota — the in-memory workspace still boots */ }
sessionStorage.setItem('shell-reload', JSON.stringify(shellReloadState()))
// Match the manifest scope so the post-reload page lands inside
diff --git a/frontend/src/components/Shell/__tests__/paneModel.test.js b/frontend/src/components/Shell/__tests__/paneModel.test.js
index 63d247806..5154d442b 100644
--- a/frontend/src/components/Shell/__tests__/paneModel.test.js
+++ b/frontend/src/components/Shell/__tests__/paneModel.test.js
@@ -1473,3 +1473,85 @@ test('reconcileRoutePanes returns the SAME array when nothing changed', () => {
const routes = [{ view: 'chat', chatId: 'b', appId: null, paneId: bPane }] // already correct
assert.equal(paneModel.reconcileRoutePanes(routes, ws, ws), routes)
})
+
+// ── Maximized-pane ("full-screen pane") persistence (survives apply-on-idle reload)
+
+// A storage stub with removeItem so the clear-on-null path is exercised.
+function focusStorage(initial = null) {
+ let value = initial
+ return {
+ getItem: () => value,
+ setItem: (_k, v) => { value = String(v) },
+ removeItem: () => { value = null },
+ peek: () => value,
+ }
+}
+
+const maxPaneWs = (focusedPaneId = 'p1') => ({
+ viewMode: 'panes',
+ panes: { p0: {}, p1: {} },
+ focusedPaneId,
+})
+
+test('resolveInitialFocusedPaneView restores a valid maximized pane', () => {
+ assert.equal(
+ paneModel.resolveInitialFocusedPaneView(maxPaneWs('p1'), 'p1'), 'p1',
+ 'a multi-pane panes-world with the stored id === focusedPaneId restores the maximize',
+ )
+})
+
+test('resolveInitialFocusedPaneView returns null for a single-pane workspace', () => {
+ const ws = { viewMode: 'panes', panes: { p0: {} }, focusedPaneId: 'p0' }
+ assert.equal(paneModel.resolveInitialFocusedPaneView(ws, 'p0'), null)
+})
+
+test('resolveInitialFocusedPaneView returns null in single (Standard) viewMode', () => {
+ assert.equal(
+ paneModel.resolveInitialFocusedPaneView({ ...maxPaneWs('p1'), viewMode: 'single' }, 'p1'),
+ null, 'single mode has no maximize presentation to restore',
+ )
+})
+
+test('resolveInitialFocusedPaneView returns null for a vanished pane id', () => {
+ assert.equal(paneModel.resolveInitialFocusedPaneView(maxPaneWs('p1'), 'p9'), null)
+})
+
+test('resolveInitialFocusedPaneView enforces the id === focusedPaneId lockstep', () => {
+ // p0 exists but is NOT the focused pane; restoring it would maximize one pane's
+ // rectangle while a different pane's content is active. Reject it.
+ assert.equal(paneModel.resolveInitialFocusedPaneView(maxPaneWs('p1'), 'p0'), null)
+})
+
+test('resolveInitialFocusedPaneView returns null when nothing was stored', () => {
+ assert.equal(paneModel.resolveInitialFocusedPaneView(maxPaneWs('p1'), null), null)
+})
+
+test('writeFocusedPaneView round-trips through readFocusedPaneView and clears on null', () => {
+ const storage = focusStorage()
+ paneModel.writeFocusedPaneView('p1', storage)
+ assert.equal(paneModel.readFocusedPaneView(storage), 'p1', 'a maximize persists')
+ paneModel.writeFocusedPaneView(null, storage)
+ assert.equal(paneModel.readFocusedPaneView(storage), null, 'un-maximizing removes the key')
+ assert.equal(storage.peek(), null, 'the key is removed, not left stale')
+})
+
+test('readFocusedPaneView is forgiving of a throwing storage', () => {
+ const throwing = { getItem: () => { throw new Error('SecurityError') } }
+ assert.equal(paneModel.readFocusedPaneView(throwing), null)
+})
+
+test('resolveInitialFocusedPaneView round-trips a real maximized 2-pane workspace', () => {
+ // Build a genuine 2-pane workspace through the model, focus the second pane
+ // (what toggleFocusedPaneView does before maximizing), persist + restore.
+ const seeded = paneModel.seedFromFlatTabs([makeTab('chat', 'a'), makeTab('chat', 'b')])
+ const split = paneModel.moveTab(seeded, 'chat:b', { paneId: 'p0', edge: 'right' })
+ const bPane = paneModel.paneOf(split, 'chat:b').id
+ const focused = paneModel.workspaceReducer({ ws: split }, { type: 'FOCUS', paneId: bPane }).ws
+ const storage = focusStorage()
+ paneModel.writeFocusedPaneView(bPane, storage)
+ const restored = paneModel.resolveInitialFocusedPaneView(
+ paneModel.parseWorkspace(paneModel.serializeWorkspace(focused)),
+ paneModel.readFocusedPaneView(storage),
+ )
+ assert.equal(restored, bPane, 'a maximized pane survives a serialize→parse→resolve round-trip')
+})
diff --git a/frontend/src/components/Shell/paneModel.js b/frontend/src/components/Shell/paneModel.js
index 839aa7205..b249b932b 100644
--- a/frontend/src/components/Shell/paneModel.js
+++ b/frontend/src/components/Shell/paneModel.js
@@ -103,6 +103,14 @@ export const MIN_PANE_H = 200
// still finds its tabs.
export const STORAGE_KEY = 'mobius-workspace'
+// sessionStorage key for the maximized ("focus one pane full-screen") presentation.
+// Deliberately SEPARATE from STORAGE_KEY: focusing a pane must never rewrite the
+// persisted split tree or ratios (design §2), so the maximize is a thin presentation
+// overlay stored beside the blob and re-seeded on mount. Without this, the reload
+// that apply-on-idle fires while the tab is backgrounded dropped the maximize, so a
+// full-screen pane came back tiled ("minimized") on return. Colon-style like the flags.
+export const FOCUSED_PANE_VIEW_KEY = 'mobius:workspace-focused-pane'
+
// The stable synthetic pane id the single-world SLOT (chat or app) mounts + owns
// its history under when the item is ABSENT from the builder pane tree (two-worlds
// design: a stable single-world owner rather than assuming paneOf() succeeds). It
@@ -1592,6 +1600,43 @@ export function readWorkspaceRaw(storage) {
}
}
+// Forgiving read/write for the maximized-pane overlay (FOCUSED_PANE_VIEW_KEY),
+// mirroring readWorkspaceRaw's throwing-getItem posture. writeFocusedPaneView
+// REMOVES the key when nothing is maximized so a dismissed maximize can never
+// resurrect on a later reload.
+export function readFocusedPaneView(storage) {
+ try {
+ const raw = storage.getItem(FOCUSED_PANE_VIEW_KEY)
+ return typeof raw === 'string' && raw.length > 0 ? raw : null
+ } catch {
+ return null
+ }
+}
+
+export function writeFocusedPaneView(paneId, storage) {
+ try {
+ if (paneId == null) storage.removeItem(FOCUSED_PANE_VIEW_KEY)
+ else storage.setItem(FOCUSED_PANE_VIEW_KEY, String(paneId))
+ } catch { /* private mode / quota — the maximize just won't survive a reload */ }
+}
+
+// Re-seed the maximized-pane presentation on boot from a persisted id, but ONLY
+// when it is still valid for the rehydrated workspace. Mirrors the runtime reset
+// guards EXACTLY so a restored value can never desync the render (which reads the
+// maximized geometry from this id but the active surface from ws.focusedPaneId):
+// - splits on + a multi-pane 'panes' world (single/immersive have no maximize),
+// - the pane still exists in the tree,
+// - and it IS the focused pane (the lockstep invariant toggle/reconcile keep).
+// Any miss returns null → the workspace boots in its normal tiled view.
+export function resolveInitialFocusedPaneView(ws, rawId) {
+ if (!WORKSPACE_SPLITS_ENABLED || rawId == null || !ws || !ws.panes) return null
+ if (ws.viewMode === 'single') return null
+ if (Object.keys(ws.panes).length <= 1) return null
+ if (!ws.panes[rawId]) return null
+ if (rawId !== ws.focusedPaneId) return null
+ return rawId
+}
+
// Forgiving read: any structural failure — bad JSON, wrong version, or an
// invariant that survives normalize (a too-deep/too-wide corrupt blob) — falls
// back to a fresh flat seed. Never throws.
diff --git a/scripts/wt-pytest.sh b/scripts/wt-pytest.sh
index 76799178b..91a3d8909 100755
--- a/scripts/wt-pytest.sh
+++ b/scripts/wt-pytest.sh
@@ -38,15 +38,63 @@ fi
VENV="$MAIN/backend/.venv/bin/python"
WORKTREE_NODE_MODULES="$ROOT/frontend/node_modules"
SHARED_NODE_MODULES="$MAIN/frontend/node_modules"
+CONTRIB_ROOT="$(dirname "$MAIN")/contrib"
-if [ -d "$WORKTREE_NODE_MODULES" ] \
- && (cd "$ROOT/frontend" && npm ls --depth=0 >/dev/null 2>&1); then
+complete_frontend_deps() {
+ local frontend="$1"
+ [ -d "$frontend/node_modules" ] \
+ && (cd "$frontend" && npm ls --depth=0 >/dev/null 2>&1)
+}
+
+backend_test_node_deps() {
+ local frontend="$1"
+ local modules="$frontend/node_modules"
+ [ -x "$modules/.bin/esbuild" ] || return 1
+ NODE_PATH="$modules" node -e \
+ "require.resolve('acorn'); require.resolve('eslint-scope')" \
+ >/dev/null 2>&1
+}
+
+# An integration worktree may carry a lockfile newer than main while another
+# reviewed worktree already has that exact dependency tree installed. Reuse
+# only an exact lockfile match. Prefer a complete frontend tree; a review in
+# progress can temporarily make `npm ls` reject the root metadata even though
+# the exact-lock tree still has the compiler/imports backend tests actually use,
+# so retain one narrowly verified backend-test fallback.
+matching_contrib_node_modules() {
+ local fallback=""
+ local frontend
+ [ "$ROOT" != "$MAIN" ] || return 1
+ for frontend in "$CONTRIB_ROOT"/*/worktree/frontend; do
+ [ "$frontend" != "$ROOT/frontend" ] || continue
+ [ -f "$frontend/package-lock.json" ] || continue
+ cmp -s "$ROOT/frontend/package-lock.json" "$frontend/package-lock.json" \
+ || continue
+ if complete_frontend_deps "$frontend"; then
+ printf '%s\n' "$frontend/node_modules"
+ return 0
+ fi
+ if [ -z "$fallback" ] && backend_test_node_deps "$frontend"; then
+ fallback="$frontend/node_modules"
+ fi
+ done
+ [ -n "$fallback" ] || return 1
+ printf '%s\n' "$fallback"
+}
+
+if complete_frontend_deps "$ROOT/frontend"; then
NODE_MODULES="$WORKTREE_NODE_MODULES"
elif [ "$ROOT" != "$MAIN" ] \
&& cmp -s "$ROOT/frontend/package-lock.json" "$MAIN/frontend/package-lock.json" \
- && [ -d "$SHARED_NODE_MODULES" ] \
- && (cd "$MAIN/frontend" && npm ls --depth=0 >/dev/null 2>&1); then
+ && complete_frontend_deps "$MAIN/frontend"; then
NODE_MODULES="$SHARED_NODE_MODULES"
+elif NODE_MODULES="$(matching_contrib_node_modules)"; then
+ if complete_frontend_deps "$(dirname "$NODE_MODULES")"; then
+ echo "wt-pytest: reusing exact-match dependencies from $(dirname "$NODE_MODULES")" >&2
+ else
+ echo "wt-pytest: reusing exact-lock backend-test dependencies from $(dirname "$NODE_MODULES")" >&2
+ echo " (verified esbuild/acorn/eslint-scope; not claiming a complete frontend tree)" >&2
+ fi
else
echo "wt-pytest: no complete frontend dependencies match this worktree" >&2
echo " install them with: (cd \"$ROOT/frontend\" && npm ci)" >&2
@@ -54,9 +102,17 @@ else
fi
ESB_DIR="$NODE_MODULES/.bin"
-if [ ! -x "$VENV" ]; then
- echo "wt-pytest: no shared venv at $VENV" >&2
- echo " create it once with:" >&2
+if [ -x "$VENV" ]; then
+ PYTHON="$VENV"
+elif python3 -c 'import pytest' >/dev/null 2>&1; then
+ # The running image already carries the backend dependencies. The explicit
+ # MOBIUS_TEST_RUNTIME environment below is the safety boundary; using this
+ # interpreter through the wrapper is not the guarded direct-pytest path.
+ PYTHON="$(command -v python3)"
+ echo "wt-pytest: shared venv absent; using the image's Python test runtime" >&2
+else
+ echo "wt-pytest: neither shared venv nor image pytest is available" >&2
+ echo " create the shared venv once with:" >&2
echo " python3 -m venv \"$MAIN/backend/.venv\" \\" >&2
echo " && \"$MAIN/backend/.venv/bin/pip\" install -r \"$MAIN/backend/requirements.txt\"" >&2
exit 1
@@ -85,4 +141,4 @@ exec env \
PATH="$ESB_DIR:${PATH:-}" \
NODE_PATH="$NODE_MODULES${NODE_PATH:+:$NODE_PATH}" \
SECRET_KEY="${SECRET_KEY:-$(python3 -c 'import secrets;print(secrets.token_hex(32))')}" \
- "$VENV" -m pytest -p no:cacheprovider "$@"
+ "$PYTHON" -m pytest -p no:cacheprovider "$@"