diff --git a/backend/scripts/seed-skills/theming.md b/backend/scripts/seed-skills/theming.md index f7a5d79c4..1470f4839 100644 --- a/backend/scripts/seed-skills/theming.md +++ b/backend/scripts/seed-skills/theming.md @@ -55,7 +55,25 @@ Keep experimental overlays bounded and cheap. Full-viewport animated gradients a Read source first, then save your edits under `/data/platform/frontend/src/`. A file watcher runs `vite build` into the served `dist/` on every source change (debounced, atomic swap) — there is NO manual rebuild step and NO restart. Just reload the page to see the change. Batch all edits so the watcher rebuilds once instead of on every save. For CSS-only changes, prefer `theme.css` above (hot-reloaded, no build at all). If the shell breaks, direct the partner to `/recover` → "Restore platform" (see `recovery.md`). -After finishing a burst of shell edits, wait for the watcher build to land before POSTing `{"type":"shell_apply_now"}` to `/api/notify` with the same authenticated call shape as `notify_theme.sh`. The watcher builds within a few seconds of the last save; the rebuild events ride the shell's own system event stream (not the chat), so you won't see them here — give the build a few seconds, then confirm the build actually carried your change: `grep` the served bundle (`/data/platform/frontend/dist/assets/index-*.js`) for a distinctive string you just added. A fresh `dist/` mtime alone can mislead (an incremental/cached build can rewrite the file without your change), which is how a "rebuilt" shell can still serve the old code — grep for the change, don't trust the timestamp. +After finishing a burst of shell edits, wait for the watcher build to land, then +request the apply. This endpoint deliberately returns an empty `204` success, so +discard its body — do **not** pipe it to a JSON parser: + +```bash +curl -fsS -o /dev/null -X POST "$API_BASE_URL/api/notify" \ + -H "Authorization: Bearer $AGENT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"type":"shell_apply_now"}' +``` + +The watcher builds within a few seconds of the last save; the rebuild events +ride the shell's own system event stream (not the chat), so you won't see them +here — give the build a few seconds, then confirm the build actually carried +your change: `grep` the served bundle +(`/data/platform/frontend/dist/assets/index-*.js`) for a distinctive string you +just added. A fresh `dist/` mtime alone can mislead (an incremental/cached build +can rewrite the file without your change), which is how a "rebuilt" shell can +still serve the old code — grep for the change, don't trust the timestamp. After a git/platform update, not a normal save, the watcher sees no edit event; kick it explicitly by touching a changed file under `/data/platform/frontend/src`, then restart if prompted. The updater does not auto-detect frontend changes by design, so run the step explicitly after frontend-touching platform updates. diff --git a/frontend/src/components/ChatView/ActivityLineHeader.jsx b/frontend/src/components/ChatView/ActivityLineHeader.jsx index 738849676..a6bd46a3f 100644 --- a/frontend/src/components/ChatView/ActivityLineHeader.jsx +++ b/frontend/src/components/ChatView/ActivityLineHeader.jsx @@ -81,7 +81,6 @@ const ActivityLineHeader = forwardRef(function ActivityLineHeader({ text, displayState, iconKind, - exitCode = null, interactive = false, open = false, ariaLabel, @@ -110,18 +109,10 @@ const ActivityLineHeader = forwardRef(function ActivityLineHeader({ > {text} @@ -134,9 +125,6 @@ const ActivityLineHeader = forwardRef(function ActivityLineHeader({ // owns it so it reads at a glance without expanding the line. {count} )} - {displayState === 'error' && exitCode != null && ( - exit {exitCode} - )} ) }) diff --git a/frontend/src/components/ChatView/ActivityStretch.jsx b/frontend/src/components/ChatView/ActivityStretch.jsx index efd549cdc..79fe461c2 100644 --- a/frontend/src/components/ChatView/ActivityStretch.jsx +++ b/frontend/src/components/ChatView/ActivityStretch.jsx @@ -9,7 +9,6 @@ import { activityCollapsedLabel, thoughtDurationLabel, } from './groupBlocks.js' -import { toolBlockExitCode } from './toolResultFormat.js' import { toolActivityIcon, effectiveToolName } from './toolActivityLabel.js' import { thinkingContentForDisplay } from './streamReducers.js' import { assistantBlockKey } from './streamPromotion.js' @@ -24,11 +23,11 @@ import { useDisclosureState } from './disclosureState.js' // quiet ~32px line instead of alternating rows — the answer keeps the screen. // A lone thought/tool renders as its own disclosure (see SingleActivity below): // wrapping one row in an identical parent adds hierarchy without information. -// Collapsed, the borderless dim header carries live -// status (a periodic shimmer over the label — bare "Thinking", or the muted -// type glyph + progressive activities while tools run) and a FAILED step's -// danger triangle + exit chip, all readable -// WITHOUT expanding. Expanded, it renders the chronological timeline: mixed +// Collapsed, the borderless dim header carries only live status (a periodic +// shimmer over the label — bare "Thinking", or the muted type glyph + +// progressive activities while tools run). A command exit is diagnostic detail, +// not a verdict on the turn, so it stays inside expansion. Expanded, the line +// renders the chronological timeline: mixed // thinking entries and tools become independently collapsed child rows, so // opening the overview never spills a full reasoning trace or tool output into // the transcript. A thought keeps this same child component as tools arrive, @@ -284,10 +283,9 @@ function GroupedActivityStretch({ failedHelpers > 0 ? `${failedHelpers} failed` : null, ].filter(Boolean).join(' · ') : null - // Deriving the state parses each tool's output for its exit code, so memoize - // on a cheap signature (see activityMemoSig for the exact staleness contract: - // head+tail output slices catch an equal-length exit-code flip; thinking - // content never busts the memo on typewriter frames). + // The overview depends only on tool identity/status. Command output and exact + // failures stay with ToolBlock after expansion, so typewriter/output frames do + // not churn every collapsed activity summary above the live turn. const sig = activityMemoSig(entries, { liveThinkingTail }) const meta = useMemo(() => { @@ -295,19 +293,8 @@ function GroupedActivityStretch({ .filter(e => e?.item?.type === 'tool') .map(e => e.item) const state = activityStreamState(tools, { liveThinkingTail }) - // The collapsed exit chip shows the most-recent failed tool's code (the same - // "exit N" the ToolBlock header carries), so a failed step is legible without - // opening. Only computed once the stretch has settled to 'error'. - let exitCode = null - if (state === 'error') { - for (const t of tools) { - const code = toolBlockExitCode(t) - if (code != null && code !== 0) exitCode = code - } - } return { state, - exitCode, toolCount: Number.isInteger(summaryToolCount) ? summaryToolCount : tools.length, @@ -315,8 +302,8 @@ function GroupedActivityStretch({ } }, [sig, summaryToolCount]) // eslint-disable-line react-hooks/exhaustive-deps - const { state, exitCode, toolCount, thinkingOnly } = meta - // The one presentation authority for icon, chip, and state class: a live + const { state, toolCount, thinkingOnly } = meta + // The one presentation authority for icon and state class: a live // stretch reads in-progress for its whole life — the tool→tool gap included — // so icon and tense can never contradict (see activityDisplayState). Applied // OUTSIDE the memo because `live` is not part of the signature. @@ -333,17 +320,14 @@ function GroupedActivityStretch({ // header comment). While collapsed, the header status carries liveness. const open = userOpen - // The step count and failure detail ride in the accessible name only (the - // visible line stays a calm activity summary); the one-second clock is not in - // an aria-live region, so a screen reader is not re-announced every tick. + // The step count rides in the accessible name. Command diagnostics do not: + // screen-reader users get the same calm overview and can inspect the same + // expanded child rows. The one-second clock is not in an aria-live region, + // so it is not re-announced every tick. const stepNote = toolCount > 0 ? ` (${toolCount} ${toolCount === 1 ? 'step' : 'steps'})` : '' - const stateNote = displayState === 'error' - ? `, a step failed${exitCode != null ? ` with exit ${exitCode}` : ''}` - : displayState === 'running' - ? ', in progress' - : '' + const stateNote = displayState === 'running' ? ', in progress' : '' const iconKind = thinkingOnly ? 'reasoning' : leadToolIcon const timelineEntries = detailRef ? detailEntries : entries @@ -357,7 +341,6 @@ function GroupedActivityStretch({ text={text} displayState={displayState} iconKind={iconKind} - exitCode={exitCode} interactive open={open} ariaLabel={`${text}${stepNote}${stateNote}`} diff --git a/frontend/src/components/ChatView/ChatView.css b/frontend/src/components/ChatView/ChatView.css index 9005b5655..ec1db1dcf 100644 --- a/frontend/src/components/ChatView/ChatView.css +++ b/frontend/src/components/ChatView/ChatView.css @@ -362,36 +362,6 @@ } .chat__ts--visible { opacity: 1; } -/* Holding a mobile message copies immediately. The only visible UI is this - brief confirmation, kept above the composer so it never shifts content. */ -.chat__copy-toast { - position: absolute; - left: 50%; - bottom: calc(var(--composer-h, 72px) + 18px); - z-index: 230; - transform: translateX(-50%); - max-width: calc(100% - 32px); - display: flex; - align-items: center; - gap: 6px; - padding: 8px 12px; - border: 1px solid var(--border); - border-radius: 999px; - background: color-mix(in srgb, var(--surface) 92%, transparent); - backdrop-filter: blur(16px) saturate(140%); - color: var(--text); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22); - font-size: 12px; - font-weight: 600; - white-space: nowrap; - animation: chat-copy-toast-in 0.18s var(--ease-out-soft, ease-out); -} -.chat__copy-toast--success svg { color: var(--accent); } - -@keyframes chat-copy-toast-in { - from { opacity: 0; transform: translate(-50%, 6px); } -} - /* ── Evolving chat summary ─────────────────────────── */ .chat-summary__overlay { position: absolute; @@ -810,8 +780,9 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - /* The type icon leads this label. The exit chip hugs the text instead of - drifting to the far edge of a wide activity row. */ + /* The type icon leads this label. When this is a child row already revealed + by an expanded activity, its exit chip hugs the text instead of drifting + to the far edge. */ flex: 0 1 auto; } @@ -1155,17 +1126,17 @@ border: 1px solid color-mix(in srgb, var(--danger) 30%, var(--border-light)); /* CONTRACT: danger-tinted hairline */ } -/* The same exit chip on the COLLAPSED tool header, so a failed command shows - its nonzero exit without expanding (a tool never carries an 'error' status — - the exit code is the only failure signal). */ +/* Inside an expanded multi-step activity, a failed child row keeps the same + compact exit chip. A direct top-level compact row waits until its own output + is expanded, where `.chat__tool-exit` above renders the exact code. */ .chat__tool-exit--head { flex-shrink: 0; align-self: auto; } -/* A failed tool's header name tints toward danger so the whole row reads as a - failure at a glance, not just the chip. */ -.chat__tool--failed .chat__tool-name { +/* A failed child inside an already-expanded activity tints toward danger. + Direct compact transcript rows stay neutral until explicitly opened. */ +.chat__tool:not(.chat__tool--compact).chat__tool--failed .chat__tool-name { color: color-mix(in srgb, var(--danger) 70%, var(--text)); /* CONTRACT: danger-leaning tool name on a failed row */ } @@ -1217,8 +1188,9 @@ borderless dim line, so a build turn's pre-prose burst stops burying the answer. A lone thought or tool uses its own disclosure directly: hierarchy is added only when there is something to group. Structure is neutral; the - only hue is --danger for a failed step and --accent for keyboard focus. - Collapsed by default; status remains readable without expanding. See + only accent hue is the keyboard focus ring. Command-level diagnostics stay + inside expansion because a nonzero shell exit is not a turn-level verdict. + Collapsed by default; liveness remains readable without expanding. See ActivityStretch.jsx + groupBlocks.js. */ .chat__activity { margin: 0; @@ -1268,15 +1240,9 @@ color: var(--muted); /* CONTRACT: low-contrast status glyph */ } -.chat__activity--error .chat__activity-icon { - color: var(--danger); /* CONTRACT: vivid danger — a step in the stretch failed */ -} - .chat__activity-label { - /* No stretch: the label and its trailing chip group left as one compact - unit (the ChatGPT idiom) rather than spreading across the row. The header - button is still full-width — only the visual grouping is left-aligned. - Long labels truncate before the danger chip clips. */ + /* No stretch: the calm label stays left-aligned rather than spreading across + the row. The header button remains full-width. */ flex: 0 1 auto; min-width: 0; overflow: hidden; @@ -1342,17 +1308,6 @@ 100% { -webkit-mask-position: -50% 0; mask-position: -50% 0; } } -.chat__activity-chip { - flex-shrink: 0; - padding: 0 6px; - border-radius: 8px; - font-family: var(--mono); /* CONTRACT: monospace exit-code chip, matches the tool block */ - font-size: 11px; - color: var(--danger); /* CONTRACT: vivid danger text — a nonzero exit failed */ - background: color-mix(in srgb, var(--danger) 12%, transparent); /* CONTRACT: faint danger tint on the opaque message-list fill */ - border: 1px solid color-mix(in srgb, var(--danger) 30%, var(--border-light)); /* CONTRACT: danger-tinted hairline */ -} - /* Rotating disclosure chevron for the MARKER card (the activity line dropped its chevron — the quiet line itself is the affordance), matching the queued-tray idiom: the SVG points down when open and rotates to point right @@ -2645,8 +2600,7 @@ } @media (prefers-reduced-motion: reduce) { - .chat__build-rail, - .chat__copy-toast { animation: none; } + .chat__build-rail { animation: none; } .chat__send, .chat__steer, .chat__stop { animation: none; } diff --git a/frontend/src/components/ChatView/ChatView.jsx b/frontend/src/components/ChatView/ChatView.jsx index d94e66c76..2d9f0082c 100644 --- a/frontend/src/components/ChatView/ChatView.jsx +++ b/frontend/src/components/ChatView/ChatView.jsx @@ -8,7 +8,6 @@ import { useSyncExternalStore, } from 'react' import { useQueryClient } from '@tanstack/react-query' -import Check from 'lucide-react/dist/esm/icons/check.mjs' import mobiusLogoUrl from '../../assets/moebius.png' import { apiFetch, getAuthHeaders, jsonOrThrow, BASE } from '../../api/client.js' import { chatMessagesQueryKey } from '../../hooks/queries.js' @@ -59,7 +58,6 @@ import { resolveStopResend } from './resolveStopResend.js' import { focusComposerElement, shouldApplyComposerFocusRequest } from './composerFocusPolicy.js' import { shouldDismissComposerKeyboardOnSubmit } from './composerKeyboardPolicy.js' import { sameMessageList } from './chatMessageList.js' -import { copyableMessageText, copyPlainText } from './messageCopy.js' import { composerHistoryFromMessages } from './composerHistory.js' import { sendFailureMessage } from './sendFailure.js' import { assistantStreamCoversMessage, chooseActiveAssistantDataKey, findTrailingAssistantPartialIndex, promoteAssistantStream, streamItemsHaveRenderableContent } from './streamPromotion.js' @@ -427,11 +425,7 @@ export default function ChatView({ const [showInspector, setShowInspector] = useState(false) const [showSummary, setShowSummary] = useState(false) const [visibleTimestampKey, setVisibleTimestampKey] = useState(null) - const [copyStatus, setCopyStatus] = useState('') const timestampTimerRef = useRef(null) - const messageHoldRef = useRef(null) - const suppressMessageClickRef = useRef(null) - const copyStatusTimerRef = useRef(null) const [previewReadyStatus, setPreviewReadyStatus] = useState('') // The app id whose CTA is mid recompile-pulse (label swapped to "Preview // updated ✓" for ~2s), or null. @@ -454,64 +448,9 @@ export default function ChatView({ useEffect(() => () => { if (timestampTimerRef.current) clearTimeout(timestampTimerRef.current) - if (messageHoldRef.current?.timer) clearTimeout(messageHoldRef.current.timer) - if (copyStatusTimerRef.current) clearTimeout(copyStatusTimerRef.current) }, []) - const cancelMessageHold = useCallback(() => { - if (messageHoldRef.current?.timer) { - clearTimeout(messageHoldRef.current.timer) - } - messageHoldRef.current = null - }, []) - - const copyMessage = useCallback(async (message, key) => { - const text = copyableMessageText(message) - if (!text) return - suppressMessageClickRef.current = key - const copied = await copyPlainText(text) - if (copied) { - try { navigator.vibrate?.(8) } catch { /* haptics are optional */ } - } - setCopyStatus(copied ? 'Copied' : 'Couldn’t copy') - if (copyStatusTimerRef.current) clearTimeout(copyStatusTimerRef.current) - copyStatusTimerRef.current = setTimeout(() => { - copyStatusTimerRef.current = null - setCopyStatus('') - }, 1800) - }, []) - - const handleMessagePointerDown = useCallback((event, message, key) => { - if ( - !_isTouchPrimary - || event.pointerType !== 'touch' - || event.button !== 0 - || event.target?.closest?.('button, a, input, textarea, summary, pre, code') - ) return - cancelMessageHold() - const startX = event.clientX - const startY = event.clientY - const timer = setTimeout(() => { - messageHoldRef.current = null - void copyMessage(message, key) - }, 520) - messageHoldRef.current = { timer, startX, startY, key } - }, [cancelMessageHold, copyMessage]) - - const handleMessagePointerMove = useCallback((event) => { - const hold = messageHoldRef.current - if (!hold) return - if ( - Math.abs(event.clientX - hold.startX) > 10 - || Math.abs(event.clientY - hold.startY) > 10 - ) cancelMessageHold() - }, [cancelMessageHold]) - const showTimestamp = useCallback((event, key) => { - if (suppressMessageClickRef.current === key) { - suppressMessageClickRef.current = null - return - } if (window.getSelection?.()?.toString()) return if (timestampTimerRef.current) clearTimeout(timestampTimerRef.current) setVisibleTimestampKey(key) @@ -3758,16 +3697,6 @@ export default function ChatView({ onClose={() => setShowSummary(false)} /> )} - {copyStatus && ( -
- {copyStatus === 'Copied' &&
- )} {showEmpty && (
{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\nhidden 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 "$@"