diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 344589c..6f2c92f 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -77,3 +77,4 @@ {"id":"int-c41493d4db55c60870fbed5d3b4e195e","kind":"field_change","created_at":"2026-07-28T23:27:54.965894418Z","actor":"halaprix","issue_id":"slotscope-pe7.12","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Docs + evidence + gates green (release:verify, both benchmarks); deployed to production."}} {"id":"int-70a07e495b05b829ee58102fb0b5cba5","kind":"field_change","created_at":"2026-07-29T00:05:10.765764819Z","actor":"halaprix","issue_id":"slotscope-pe7.13","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Codex reviewed v1.0 (9 findings), fixes verified in two follow-up rounds; final residual (prose pins) closed — all runtime items confirmed FIXED by the external verifier."}} {"id":"int-74d2e61abae11dd74f5866942307a3e0","kind":"field_change","created_at":"2026-07-29T05:47:53.640204834Z","actor":"halaprix","issue_id":"slotscope-zhv","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Stale copy replaced with per-reason honest explanations; e2e asserts the mapping wording and the absence of version claims."}} +{"id":"int-78d92a08ad438bc04c0142e555c53902","kind":"field_change","created_at":"2026-07-29T07:12:36.373056725Z","actor":"halaprix","issue_id":"slotscope-tkd","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"v2 identity applied (band+record, resolution treatment on runtime surfaces, contrast promotions, chassis lock restored); inventory doc written for the next design round; 209 unit + 57 e2e green."}} diff --git a/.playwright-screens/strip-closeup.png b/.playwright-screens/strip-closeup.png new file mode 100644 index 0000000..aa3ed69 Binary files /dev/null and b/.playwright-screens/strip-closeup.png differ diff --git a/.playwright-screens/variant-b.png b/.playwright-screens/variant-b.png new file mode 100644 index 0000000..9e696a3 Binary files /dev/null and b/.playwright-screens/variant-b.png differ diff --git a/apps/web/e2e/a11y-v1.spec.ts b/apps/web/e2e/a11y-v1.spec.ts index 24f2170..7778c8f 100644 --- a/apps/web/e2e/a11y-v1.spec.ts +++ b/apps/web/e2e/a11y-v1.spec.ts @@ -30,7 +30,9 @@ test('axe: executed instrument with events, revert forensics, and challenges', a // Success path: events panel + share size + settings visible. await page.getByLabel('set argument v (uint256)').fill('5'); await page.getByRole('button', { name: 'Call', exact: true }).click(); - await expect(page.locator('[data-testid="events-panel"]')).toBeVisible({ timeout: 60_000 }); + await expect(page.locator('[data-testid="trace-pane"]')).toBeVisible({ timeout: 60_000 }); + await page.getByRole('tab', { name: /Events · 1/ }).click(); + await expect(page.locator('[data-testid="events-panel"]')).toBeVisible(); await expectClean(page, 'success path'); // Revert path: forensics panel visible. diff --git a/apps/web/e2e/compile.spec.ts b/apps/web/e2e/compile.spec.ts index a6e2222..d72bdde 100644 --- a/apps/web/e2e/compile.spec.ts +++ b/apps/web/e2e/compile.spec.ts @@ -41,10 +41,12 @@ test('compiles the public fixture twice with a responsive main thread', async ({ }); await page.getByRole('button', { name: 'Compile' }).click(); - await expect(page.getByRole('region', { name: 'Contracts' })).toContainText( + await expect(page.getByRole('combobox', { name: 'Contract' })).toContainText( 'Main.sol:PackedInheritance', { timeout: 60_000 }, ); + // A clean compile keeps the diagnostics strip closed; opening it says so. + await page.getByRole('button', { name: 'DIAG · 0' }).click(); await expect(page.getByRole('region', { name: 'Diagnostics' })).toContainText('No diagnostics.'); const maxGap = await page.evaluate(() => Math.max(...window.__frameGaps.slice(1))); @@ -52,7 +54,7 @@ test('compiles the public fixture twice with a responsive main thread', async ({ // Second compile must reuse the same worker generation. await page.getByRole('button', { name: 'Compile' }).click(); - await expect(page.getByRole('region', { name: 'Contracts' })).toContainText( + await expect(page.getByRole('combobox', { name: 'Contract' })).toContainText( 'Main.sol:PackedInheritance', { timeout: 60_000 }, ); @@ -83,9 +85,8 @@ test('invalid source returns solc diagnostics and no artifacts', async ({ page } await page.locator('.cm-content').fill(invalidSource); await page.getByRole('button', { name: 'Compile' }).click(); + // Errors auto-open the diagnostics strip; no artifacts means no picker. const diagnostics = page.getByRole('region', { name: 'Diagnostics' }); await expect(diagnostics).toContainText('error 9574', { timeout: 60_000 }); - await expect(page.getByRole('region', { name: 'Contracts' })).toContainText( - 'No contract artifacts.', - ); + await expect(page.getByRole('combobox', { name: 'Contract' })).toHaveCount(0); }); diff --git a/apps/web/e2e/events.spec.ts b/apps/web/e2e/events.spec.ts index ca3ea93..f0450f7 100644 --- a/apps/web/e2e/events.spec.ts +++ b/apps/web/e2e/events.spec.ts @@ -18,8 +18,11 @@ test('emitted events decode in the panel and link to their step (V5)', async ({ await page.getByLabel('set argument v (uint256)').fill('9'); await page.getByRole('button', { name: 'Call', exact: true }).click(); + // The probe pane auto-enters Trace mode; events live behind their tab. + await expect(page.locator('[data-testid="trace-pane"]')).toBeVisible({ timeout: 60_000 }); + await page.getByRole('tab', { name: /Events · 1/ }).click(); const panel = page.locator('[data-testid="events-panel"]'); - await expect(panel).toBeVisible({ timeout: 60_000 }); + await expect(panel).toBeVisible(); await expect(panel).toContainText('Set('); await expect(panel).toContainText('value = 9'); await expect(panel).toContainText('[indexed]'); diff --git a/apps/web/e2e/lifecycle.spec.ts b/apps/web/e2e/lifecycle.spec.ts index 9503f54..beb8782 100644 --- a/apps/web/e2e/lifecycle.spec.ts +++ b/apps/web/e2e/lifecycle.spec.ts @@ -46,9 +46,10 @@ test('cancel during a long compile recovers to ready and compiles again', async // The compiler subsystem is reusable: the public fixture compiles after. await page.locator('.cm-content').fill(fixtureSource); await page.getByRole('button', { name: 'Compile' }).click(); - await expect(page.getByRole('region', { name: 'Contracts' })).toContainText( + await expect(page.getByRole('combobox', { name: 'Contract' })).toContainText( 'Main.sol:PackedInheritance', { timeout: 60_000 }, ); + await page.getByRole('button', { name: 'DIAG · 0' }).click(); await expect(page.getByRole('region', { name: 'Diagnostics' })).toContainText('No diagnostics.'); }); diff --git a/apps/web/e2e/semantics.spec.ts b/apps/web/e2e/semantics.spec.ts index 59474c6..2537489 100644 --- a/apps/web/e2e/semantics.spec.ts +++ b/apps/web/e2e/semantics.spec.ts @@ -101,7 +101,8 @@ test('nested mappings show the collapsed KeccakFlow and the runtime storage grou await expect(flow.locator('[data-depth="2"]').first()).toBeVisible(); await expect(write.locator('[data-testid="keccak-flow-collapsed"]')).toContainText('collapsed'); - // The Storage section gains the labeled runtime group. + // The runtime group lives in the probe pane's Storage mode. + await page.getByRole('tab', { name: 'Storage' }).click(); const runtime = page.getByRole('region', { name: 'Runtime storage' }); await expect(runtime).toBeVisible(); await expect(runtime.locator('[data-testid="runtime-group"]')).toContainText('allowance'); diff --git a/apps/web/e2e/trace.spec.ts b/apps/web/e2e/trace.spec.ts index 717ad48..97a7e85 100644 --- a/apps/web/e2e/trace.spec.ts +++ b/apps/web/e2e/trace.spec.ts @@ -19,6 +19,7 @@ async function callPositionValue(page: Page) { test('the trace pane shows machine state at the cursor (X5)', async ({ page }) => { await callPositionValue(page); + await page.getByRole('tab', { name: 'State' }).click(); const facts = page.locator('[data-testid="trace-cursor-facts"]'); // Lands on the final step with the run-level return data visible. @@ -120,6 +121,7 @@ test('storage observations show cold/warm and keccak provenance (X8)', async ({ test('replayed state is consistent while scrubbing back and forth', async ({ page }) => { await callPositionValue(page); + await page.getByRole('tab', { name: 'State' }).click(); const facts = page.locator('[data-testid="trace-cursor-facts"]'); await page.getByLabel('Trace cursor').fill('20'); diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index e2f4bf2..c644908 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -3,7 +3,6 @@ import { DEFAULT_COMPILE_SETTINGS, type CompilationResult, type CompileSettings, - type SemanticResolution, } from '@slotscope/domain'; import { CompileError, @@ -29,9 +28,11 @@ import { import { useEffectiveMotion } from '../accessibility/useEffectiveMotion'; import { AssemblyPane } from '../assembly/AssemblyPane'; import { SourceEditor } from '../editor/SourceEditor'; -import { ExecutePane } from '../execute/ExecutePane'; -import { RuntimeStorage } from '../storage/RuntimeStorage'; -import { StorageGrid } from '../storage/StorageGrid'; +import { ExecuteStrip } from '../execute/ExecuteStrip'; +import { useExecutionSession } from '../execute/useExecutionSession'; +import { CockpitBar } from '../cockpit/CockpitBar'; +import { ProbePane, type ProbeMode, type ProbeTab } from '../probe/ProbePane'; +import { ownerChipText } from '../storage/ownerChip'; import { InstrumentHeader } from './InstrumentHeader'; import { ChallengePane } from '../challenges/ChallengePane'; import { OnboardingTour } from '../onboarding/OnboardingTour'; @@ -115,7 +116,9 @@ export function App() { const [announcement, setAnnouncement] = useState(''); const [solveTimeMs, setSolveTimeMs] = useState(null); const [tracePc, setTracePc] = useState(null); - const [traceResolutions, setTraceResolutions] = useState([]); + const [probeMode, setProbeMode] = useState('storage'); + const [probeTab, setProbeTab] = useState('accesses'); + const [diagOpen, setDiagOpen] = useState(false); useEffect(() => { const client = createCompilerClient(() => new CompilerWorker()); @@ -324,6 +327,26 @@ export function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [result, selected, pinnedIndex, executeFacts]); + const session = useExecutionSession({ + artifact: selected !== null && selected.deployable ? selected : null, + replay: pendingReplay, + onReplayDone: () => setPendingReplay(null), + onTraceCursorPc: setTracePc, + onSessionFacts: setExecuteFacts, + onCallCompleted: (success) => { + setProbeMode('trace'); + setProbeTab(success ? 'accesses' : 'forensics'); + }, + }); + + // A fresh compile returns the probe to the static picture and surfaces + // diagnostics automatically when any of them is an error. + useEffect(() => { + setProbeMode('storage'); + setProbeTab('accesses'); + setDiagOpen(result !== null && result.diagnostics.some((d) => d.severity === 'error')); + }, [result]); + const relationIndex = useMemo( () => (selected !== null && selected.deployable ? buildRelationIndex(selected) : null), [selected], @@ -332,6 +355,11 @@ export function App() { // One contract, one hue — shared by the editor spine and the storage grid. const { ownerSuffixByContract, ownerSpans } = useOwnerHues(result); + const ownerClassByContract = useMemo( + () => new Map([...ownerSuffixByContract].map(([name, suffix]) => [name, `owner-${suffix}`])), + [ownerSuffixByContract], + ); + // Global shortcuts: Ctrl/Cmd+K palette, Ctrl/Cmd+Enter compile. useEffect(() => { function onKeyDown(event: KeyboardEvent) { @@ -497,7 +525,58 @@ export function App() { MAIN.SOL{result !== null ? ` · ${result.artifacts.length} UNITS` : ''} + {result !== null && result.artifacts.length > 0 && ( + + )} + {result !== null && ( + + )} + {result !== null && diagOpen && ( +
+ {result.diagnostics.length === 0 ? ( +

No diagnostics.

+ ) : ( +
    + {result.diagnostics.map((diagnostic, index) => ( +
  1. + {diagnostic.severity} {diagnostic.code ?? ''}: {diagnostic.message} + {diagnostic.location !== null + ? ` (bytes ${diagnostic.location.byteStart}–${ + diagnostic.location.byteStart + diagnostic.location.byteLength + })` + : ' (whole file)'} +
  2. + ))} +
+ )} +
+ )} - - )} + {result !== null && session.trace !== null && ( + + )} + + {result !== null && } + {result === null && (

Compile to light the instrument: the storage grid, execution, sharing, and challenges all @@ -543,60 +638,6 @@ export function App() { {result !== null && (

-
-

Diagnostics

- {result.diagnostics.length === 0 ? ( -

No diagnostics.

- ) : ( -
    - {result.diagnostics.map((diagnostic, index) => ( -
  1. - {diagnostic.severity} {diagnostic.code ?? ''}: {diagnostic.message} - {diagnostic.location !== null && - ` (bytes ${diagnostic.location.byteStart}–${ - diagnostic.location.byteStart + diagnostic.location.byteLength - })`} -
  2. - ))} -
- )} -
- -
-

Contracts

- {result.artifacts.length === 0 ? ( -

No contract artifacts.

- ) : ( - - )} -
- - setPendingReplay(null)} - /> -

Share

{shareSize !== null && ( @@ -630,15 +671,14 @@ export function App() { }} /> - {shareUrl !== null && ( - - )} +
void; +}) { + const boundedCursor = Math.min(Math.max(cursor, 0), trace.encoded.stepCount - 1); + return ( +
+ COCKPIT + + {outcome !== null && ( + + {outcome.fnName} {outcome.success ? 'succeeded' : 'reverted'} ·{' '} + {outcome.gasUsed.toLocaleString('en-US')} gas ·{' '} + {outcome.stepCount.toLocaleString('en-US')} steps + {outcome.success && ( + <> + {' '} + · returns{' '} + {outcome.decoded.length === 0 + ? '()' + : outcome.decoded.map((value, index) => ( + + {index > 0 && ', '} + {value.type}: {value.display} + + ))} + + )} + {!outcome.success && ' · see Forensics'} + + )} +
+ ); +} diff --git a/apps/web/src/execute/ExecuteStrip.tsx b/apps/web/src/execute/ExecuteStrip.tsx new file mode 100644 index 0000000..a45d237 --- /dev/null +++ b/apps/web/src/execute/ExecuteStrip.tsx @@ -0,0 +1,122 @@ +import type { ContractArtifact } from '@slotscope/domain'; +import { isSupportedStaticType, type ExecutionStatus } from '@slotscope/executor'; +import { abiFunctions, type useExecutionSession } from './useExecutionSession'; + +const STATUS_TEXT: Record = { + uninitialized: 'Starting executor…', + starting: 'Starting executor…', + ready: 'Ready', + executing: 'Executing…', + replacing: 'Aborting — replacing executor…', + failed: 'Executor failed', +}; + +/** + * The slim execute strip (Variant B): deployed-address chip, deploy/abort + * controls, and one compact form per ABI function. Trace, outcome, and + * forensics live in the cockpit bar and the probe pane. + */ +export function ExecuteStrip({ + artifact, + session, +}: { + artifact: ContractArtifact | null; + session: ReturnType; +}) { + const { status, progress, address, errorText, args, setArgs } = session; + + if (artifact === null || !artifact.deployable) { + return ( +
+ EXECUTE +

Select a deployable contract to run it locally.

+
+ ); + } + + const functions = abiFunctions(artifact); + + return ( +
+ EXECUTE + {address !== null && ( + + {artifact.contractName} @ 0x{address} · local + · cancun + + )} + + {status === 'executing' && ( + + )} + {status === 'failed' && ( + + )} + + {STATUS_TEXT[status]} + {status === 'executing' && progress > 0 + ? ` — ${progress.toLocaleString('en-US')} steps` + : ''} + + {errorText !== null && ( +

+ {errorText} +

+ )} + + {address !== null && functions.length > 0 && ( +
    + {functions.map((fn) => { + const unsupported = fn.inputs.find((input) => !isSupportedStaticType(input.type)); + return ( +
  • i.type).join(',')}> +
    + + {fn.name}({fn.inputs.map((input) => input.type).join(', ')}) + + {fn.inputs.map((input, index) => ( + + setArgs((previous) => ({ + ...previous, + [`${fn.name}:${index}`]: event.target.value, + })) + } + /> + ))} + {unsupported === undefined ? ( + + ) : ( + {`disabled — type ${unsupported.type} not callable in v0.2`} + )} +
    +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/execute/ExecutePane.tsx b/apps/web/src/execute/useExecutionSession.ts similarity index 53% rename from apps/web/src/execute/ExecutePane.tsx rename to apps/web/src/execute/useExecutionSession.ts index 9893e75..b61caf6 100644 --- a/apps/web/src/execute/ExecutePane.tsx +++ b/apps/web/src/execute/useExecutionSession.ts @@ -1,8 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { - LogObservation, ContractArtifact, EncodedTrace, + LogObservation, SemanticResolution, TraceObservations, } from '@slotscope/domain'; @@ -14,28 +14,16 @@ import { decodeOutputs, encodeCall, functionSignature, - isSupportedStaticType, + type AbiError, type AbiFunction, type DecodedValue, type ExecutionClient, - type AbiError, type ExecutionStatus, type RevertForensics, } from '@slotscope/executor'; import ExecutionWorker from '@slotscope/executor/worker?worker'; -import { EventsPanel } from './EventsPanel'; -import { TracePane } from '../trace/TracePane'; - -const STATUS_TEXT: Record = { - uninitialized: 'Starting executor…', - starting: 'Starting executor…', - ready: 'Ready', - executing: 'Executing…', - replacing: 'Aborting — replacing executor…', - failed: 'Executor failed', -}; -interface CallOutcome { +export interface CallOutcome { forensics: RevertForensics | null; fnName: string; success: boolean; @@ -46,7 +34,20 @@ interface CallOutcome { errorMessage: string | null; } -function abiFunctions(artifact: ContractArtifact): AbiFunction[] { +export interface SessionCall { + fn: string; + args: string[]; +} + +export interface TraceBundle { + encoded: EncodedTrace; + calldataHex: string; + returnDataHex: string; + observations: TraceObservations | null; + logs: readonly LogObservation[]; +} + +export function abiFunctions(artifact: ContractArtifact): AbiFunction[] { return artifact.abi .filter((item) => item.type === 'function') .map((item) => ({ @@ -59,24 +60,25 @@ function abiFunctions(artifact: ContractArtifact): AbiFunction[] { } /** - * Local deployment + ABI call forms (X4). Executions run in the execution - * worker; aborting replaces the worker generation, which by design drops the - * deployed state — the pane says so and offers redeploy. + * The execution session, lifted out of the old Execute pane so the Variant B + * layout can split its UI across the execute strip, the cockpit bar, and the + * probe pane. Behavior is unchanged: worker lifecycle, deploy/call/abort, + * Layer-3 resolution over the frozen observations, and restored-session + * replay all work exactly as before. */ -export interface SessionCall { - fn: string; - args: string[]; -} - -export function ExecutePane({ +export function useExecutionSession({ artifact, + replay = null, + onReplayDone, onTraceCursorPc, onResolutions, onSessionFacts, - replay = null, - onReplayDone, + onCallCompleted, }: { artifact: ContractArtifact | null; + /** A restored session's call sequence to replay after deploy. */ + replay?: { calls: SessionCall[]; cursor: number | null } | null; + onReplayDone?: () => void; /** PC of the trace cursor step (call traces run the deployed code of the * selected artifact, so PCs align with its decoded instructions). */ onTraceCursorPc?: (pc: number | null) => void; @@ -84,9 +86,8 @@ export function ExecutePane({ onResolutions?: (resolutions: readonly SemanticResolution[]) => void; /** Successful calls since deploy + trace cursor, for session sharing. */ onSessionFacts?: (facts: { calls: SessionCall[]; cursor: number | null }) => void; - /** A restored session's call sequence to replay after deploy. */ - replay?: { calls: SessionCall[]; cursor: number | null } | null; - onReplayDone?: () => void; + /** Fired when a traced call finishes (probe pane auto-enters Trace mode). */ + onCallCompleted?: (success: boolean) => void; }) { const clientRef = useRef(null); const [status, setStatus] = useState('starting'); @@ -96,15 +97,10 @@ export function ExecutePane({ const [errorText, setErrorText] = useState(null); const [progress, setProgress] = useState(0); const [args, setArgs] = useState>({}); - const [trace, setTrace] = useState<{ - encoded: EncodedTrace; - calldataHex: string; - returnDataHex: string; - observations: TraceObservations | null; - logs: readonly LogObservation[]; - } | null>(null); + const [trace, setTrace] = useState(null); const [cursor, setCursor] = useState(0); const [callLog, setCallLog] = useState([]); + const [callCount, setCallCount] = useState(0); const replayRunningRef = useRef(false); // Layer 3: pure naming over the frozen trace observations. Only outer- @@ -198,15 +194,6 @@ export function ExecutePane({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [replay, artifact, status]); - if (artifact === null || !artifact.deployable) { - return ( -
-

Execute

-

Select a deployable contract to run it locally.

-
- ); - } - async function handleDeploy(): Promise { const client = clientRef.current; if (client === null || artifact === null) return null; @@ -222,6 +209,7 @@ export function ExecutePane({ setAddress(result.createdAddressHex); setDeployedArtifactId(artifact.id); setCallLog([]); + setCallCount(0); return result.createdAddressHex; } setErrorText(`Deployment reverted: ${result.errorMessage ?? 'unknown error'}`); @@ -267,6 +255,7 @@ export function ExecutePane({ if (result.success) { setCallLog((log) => [...log, { fn: functionSignature(fn), args: values }]); } + setCallCount((count) => count + 1); setOutcome({ fnName: fn.name, success: result.success, @@ -284,6 +273,9 @@ export function ExecutePane({ stepCount: result.steps.length, errorMessage: result.errorMessage, }); + if (result.trace !== null && result.trace.stepCount > 0) { + onCallCompleted?.(result.success); + } return result.success; } catch (error) { if (error instanceof ExecutionError && error.code === 'cancelled') return false; @@ -304,180 +296,27 @@ export function ExecutePane({ } } - const functions = abiFunctions(artifact); - - return ( -
-

Execute

-
- - {status === 'executing' && ( - - )} - {status === 'failed' && ( - - )} - - {STATUS_TEXT[status]} - {status === 'executing' && progress > 0 - ? ` — ${progress.toLocaleString('en-US')} steps` - : ''} - -
- - {address !== null && ( -

- Deployed at 0x{address} - (local worker state — Abort or recompile resets it) -

- )} - - {errorText !== null &&

{errorText}

} - - {address !== null && functions.length > 0 && ( -
    - {functions.map((fn) => { - const unsupported = fn.inputs.find((input) => !isSupportedStaticType(input.type)); - return ( -
  • i.type).join(',')}> -
    - - {fn.name}({fn.inputs.map((input) => input.type).join(', ')}) - - {fn.inputs.map((input, index) => ( - - setArgs((previous) => ({ - ...previous, - [`${fn.name}:${index}`]: event.target.value, - })) - } - /> - ))} - {unsupported === undefined ? ( - - ) : ( - {`type ${unsupported.type} not callable in v0.2`} - )} -
    -
  • - ); - })} -
- )} - - {trace !== null && ( - <> - - - - )} + function handleRetry() { + void clientRef.current?.retry(); + } - {outcome !== null && ( -
-

- {outcome.fnName} {outcome.success ? 'succeeded' : 'reverted'} ·{' '} - {outcome.gasUsed.toLocaleString('en-US')} gas ·{' '} - {outcome.stepCount.toLocaleString('en-US')} steps -

- {outcome.success ? ( - outcome.decoded.length > 0 ? ( -
    - {outcome.decoded.map((value, index) => ( -
  • - - {value.type}: {value.display} - -
  • - ))} -
- ) : ( -

No return data.

- ) - ) : ( -
-

{outcome.errorMessage}

- {outcome.forensics !== null && ( - <> -

- {outcome.forensics.kind === 'error-string' && ( - <> - revert reason:{' '} - "{outcome.forensics.decoded}" - - )} - {outcome.forensics.kind === 'panic' && ( - {outcome.forensics.decoded} - )} - {outcome.forensics.kind === 'custom-error' && ( - <> - {outcome.forensics.decoded} - {outcome.forensics.decoded?.startsWith('unknown') === true && - ' — not declared in this ABI; raw data below'} - - )} - {outcome.forensics.kind === 'empty' && 'revert() with no returndata'} - {outcome.forensics.kind === 'halt' && - `halted without REVERT: ${outcome.forensics.decoded ?? 'unknown cause'}`} - {outcome.forensics.kind === 'raw' && 'unparseable returndata (raw below)'} -

- {(outcome.forensics.kind === 'custom-error' || - outcome.forensics.kind === 'raw') && ( - 0x{outcome.forensics.returnDataHex} - )} - {outcome.forensics.step !== null && ( -

- at step #{outcome.forensics.step.stepIndex} ·{' '} - {outcome.forensics.step.opcode} · pc 0x - {outcome.forensics.step.pc.toString(16).padStart(4, '0')} · frame depth{' '} - {outcome.forensics.step.depth} - {trace !== null && - outcome.forensics.step.stepIndex < trace.encoded.stepCount && ( - <> - {' '} - - - )} -

- )} - - )} -
- )} -
- )} -
- ); + return { + status, + progress, + address, + outcome, + errorText, + args, + setArgs, + trace, + cursor, + setCursor, + resolutions, + callLog, + callCount, + handleDeploy, + handleCall, + handleAbort, + handleRetry, + }; } diff --git a/apps/web/src/probe/ForensicsView.tsx b/apps/web/src/probe/ForensicsView.tsx new file mode 100644 index 0000000..93baea1 --- /dev/null +++ b/apps/web/src/probe/ForensicsView.tsx @@ -0,0 +1,68 @@ +import { toHex4 } from '@slotscope/domain'; +import type { CallOutcome } from '../execute/useExecutionSession'; + +/** The Forensics tab: revert decode for the last call, or the honest "no + * revert data" line after a success. Moved verbatim from the old pane. */ +export function ForensicsView({ + outcome, + onJump, + traceStepCount, +}: { + outcome: CallOutcome | null; + onJump: (stepIndex: number) => void; + traceStepCount: number; +}) { + if (outcome === null) { + return

run a call to collect forensics

; + } + if (outcome.success || outcome.forensics === null) { + return

call succeeded — no revert data to decode

; + } + const forensics = outcome.forensics; + return ( +
+

{outcome.errorMessage}

+

+ {forensics.kind === 'error-string' && ( + <> + revert reason: "{forensics.decoded}" + + )} + {forensics.kind === 'panic' && {forensics.decoded}} + {forensics.kind === 'custom-error' && ( + <> + {forensics.decoded} + {forensics.decoded?.startsWith('unknown') === true && + ' — not declared in this ABI; raw data below'} + + )} + {forensics.kind === 'empty' && 'revert() with no returndata'} + {forensics.kind === 'halt' && + `halted without REVERT: ${forensics.decoded ?? 'unknown cause'}`} + {forensics.kind === 'raw' && 'unparseable returndata (raw below)'} +

+ {(forensics.kind === 'custom-error' || forensics.kind === 'raw') && ( + 0x{forensics.returnDataHex} + )} + {forensics.step !== null && ( +

+ at step #{forensics.step.stepIndex} ·{' '} + {forensics.step.opcode} · pc{' '} + {toHex4(forensics.step.pc)} · frame depth {forensics.step.depth} + {forensics.step.stepIndex < traceStepCount && ( + <> + {' '} + + + )} +

+ )} +
+ ); +} diff --git a/apps/web/src/probe/LiveStorageStrip.tsx b/apps/web/src/probe/LiveStorageStrip.tsx new file mode 100644 index 0000000..8b775b4 --- /dev/null +++ b/apps/web/src/probe/LiveStorageStrip.tsx @@ -0,0 +1,207 @@ +import { useMemo } from 'react'; +import type { + ContractArtifact, + SemanticResolution, + StorageAccessObservation, + StorageValueCell, +} from '@slotscope/domain'; +import { padWord, trimHexZeros, wordToBigInt } from '@slotscope/domain'; +import { LayoutDecodeError, decodeStorageLayout, groupCellsBySlot } from '@slotscope/layout'; +import type { TraceBundle } from '../execute/useExecutionSession'; + +interface WriteFact { + /** Inclusive changed byte range, LSB-indexed (byte 0 = rightmost). */ + lo: number; + hi: number; + newValueHex: string; + stepIndex: number; +} + +/** Latest persistent write to the slot at or before the cursor, byte-diffed. */ +function writeUpToCursor( + accesses: readonly StorageAccessObservation[], + cursorStep: number, +): WriteFact | null { + let fact: WriteFact | null = null; + let previous: string | null = null; + for (const access of accesses) { + if (access.valueHex === null) continue; + if (access.kind === 'sstore' && access.stepIndex <= cursorStep) { + const next = padWord(access.valueHex); + const before = previous === null ? null : padWord(previous); + let lo = 0; + let hi = 31; + if (before !== null && before !== next) { + const changed: number[] = []; + for (let byte = 0; byte < 32; byte += 1) { + const at = (31 - byte) * 2; // byte 0 is the rightmost pair + if (before.slice(at, at + 2) !== next.slice(at, at + 2)) changed.push(byte); + } + if (changed.length > 0) { + lo = Math.min(...changed); + hi = Math.max(...changed); + } + } + // First touch has no recorded previous value: the whole word is + // honestly "written" — we cannot claim a narrower range. + fact = { lo, hi, newValueHex: access.valueHex, stepIndex: access.stepIndex }; + } + previous = access.valueHex; + } + return fact; +} + +/** grid column for a byte range: byte 31 renders in column 1 (leftmost). */ +function columnFor(lo: number, hi: number): string { + return `${32 - hi} / span ${hi - lo + 1}`; +} + +function shortHex(wordHex: string): string { + const trimmed = trimHexZeros(wordHex); + return trimmed.length > 10 ? `0x…${trimmed.slice(-4)}` : `0x${trimmed}`; +} + +/** + * The live storage strip (Variant B): one mini 32-byte band per slot the + * call touches, pinned above the trace tabs at every cursor position. Static + * layout cells carry owner hues; bytes written up to the cursor fill amber + * (measured change, so the accent is honest). Keccak-derived slots have no + * static cells — their band is the amber write plus the resolved label in + * the cell title. + */ +export function LiveStorageStrip({ + trace, + resolutions, + artifact, + cursor, + ownerClass, + ownerChipText, +}: { + trace: TraceBundle; + resolutions: readonly SemanticResolution[]; + artifact: ContractArtifact; + cursor: number; + ownerClass: ReadonlyMap; + ownerChipText: (contract: string) => string; +}) { + const cellsBySlot = useMemo(() => { + if (artifact.storageLayout === null) return new Map(); + try { + return groupCellsBySlot(decodeStorageLayout(artifact.storageLayout, () => null).items); + } catch (error) { + if (error instanceof LayoutDecodeError) return new Map(); + throw error; + } + }, [artifact.storageLayout]); + + const observations = trace.observations; + if (observations === null || observations.storage.length === 0) return null; + + const cursorStep = trace.encoded.steps[Math.min(cursor, trace.encoded.stepCount - 1)]; + const cursorStepIndex = cursorStep?.stepIndex ?? 0; + const resolutionByStep = new Map(resolutions.map((r) => [r.stepIndex, r])); + + // Persistent slots the call touches, in first-touch order. + const slots: { slotHex: string; accesses: StorageAccessObservation[] }[] = []; + for (const access of observations.storage) { + if (access.kind === 'tload' || access.kind === 'tstore') continue; + const existing = slots.find((slot) => slot.slotHex === access.slotHex); + if (existing !== undefined) existing.accesses.push(access); + else slots.push({ slotHex: access.slotHex, accesses: [access] }); + } + if (slots.length === 0) return null; + + let caption: string | null = null; + let captionStep: number | null = null; + + const bands = slots.map(({ slotHex, accesses }) => { + const slotValue = wordToBigInt(slotHex); + const slotLabel = `0x${trimHexZeros(slotHex)}`; + const staticCells = cellsBySlot.get(slotValue.toString()) ?? []; + const write = writeUpToCursor(accesses, cursorStepIndex); + if (write !== null) { + const resolution = accesses + .map((access) => resolutionByStep.get(access.stepIndex)) + .find((candidate) => candidate?.label != null); + const name = + resolution?.label ?? (staticCells.map((cell) => cell.path).join(' · ') || slotLabel); + const latestCaption = `${slotLabel} [${write.hi}:${write.lo}] ${name} ← ${shortHex( + padWord(write.newValueHex), + )}`; + if (captionStep === null || write.stepIndex > captionStep) { + caption = latestCaption; + captionStep = write.stepIndex; + } + } + return { slotHex, slotLabel, staticCells, write, accesses }; + }); + + return ( +
+
+ STORAGE · LIVE AT CURSOR + BYTE 31 ◄ ··· ► 0 (LSB) +
+
+ {bands.map((band) => ( +
+ + {band.slotLabel.length > 8 ? `${band.slotLabel.slice(0, 6)}…` : band.slotLabel} + +
+ {band.staticCells.map((cell, index) => { + const hi = cell.byteOffset + cell.byteLength - 1; + const owner = + cell.declaringContract !== null + ? (ownerClass.get(cell.declaringContract) ?? '') + : ''; + const chip = + cell.declaringContract !== null + ? ` · ${ownerChipText(cell.declaringContract)}` + : ''; + return ( +
+ ); + })} + {band.staticCells.length === 0 && ( +
+ )} + {band.write !== null && ( +
+ )} +
+
+ ))} +
+

+ amber = bytes written up to the cursor + {caption !== null && captionStep !== null && ( + <> + {' '} + · {caption} at step #{captionStep} + + )} + {caption === null && ' · no bytes written up to this cursor'} +

+
+ ); +} diff --git a/apps/web/src/probe/ProbePane.tsx b/apps/web/src/probe/ProbePane.tsx new file mode 100644 index 0000000..954009a --- /dev/null +++ b/apps/web/src/probe/ProbePane.tsx @@ -0,0 +1,172 @@ +import { useMemo } from 'react'; +import type { CompilationResult, ContractArtifact } from '@slotscope/domain'; +import { replayToCursor } from '@slotscope/executor'; +import type { useExecutionSession } from '../execute/useExecutionSession'; +import { EventsPanel } from '../execute/EventsPanel'; +import { RuntimeStorage } from '../storage/RuntimeStorage'; +import { StorageGrid } from '../storage/StorageGrid'; +import { MachineStatePanes } from '../trace/MachineStatePanes'; +import { ObservationList } from '../trace/ObservationList'; +import { ForensicsView } from './ForensicsView'; +import { LiveStorageStrip } from './LiveStorageStrip'; + +export type ProbeMode = 'storage' | 'trace'; +export type ProbeTab = 'accesses' | 'state' | 'events' | 'forensics'; + +/** + * The probe pane (Variant B): the third workbench pane, mode-switched + * between the static Storage view and the Trace view. In Trace mode the + * live storage strip stays pinned above the detail tabs at every cursor + * position — stepping never loses the storage picture. + */ +export function ProbePane({ + result, + artifact, + session, + mode, + onMode, + tab, + onTab, + ownerSuffixByContract, + ownerClass, + ownerChipText, +}: { + result: CompilationResult; + artifact: ContractArtifact | null; + session: ReturnType; + mode: ProbeMode; + onMode: (mode: ProbeMode) => void; + tab: ProbeTab; + onTab: (tab: ProbeTab) => void; + ownerSuffixByContract: ReadonlyMap; + ownerClass: ReadonlyMap; + ownerChipText: (contract: string) => string; +}) { + const { trace, cursor, setCursor, resolutions, outcome, callCount } = session; + const effectiveMode: ProbeMode = trace === null ? 'storage' : mode; + const boundedCursor = + trace === null ? 0 : Math.min(Math.max(cursor, 0), trace.encoded.stepCount - 1); + + const machineState = useMemo( + () => (trace === null ? null : replayToCursor(trace.encoded, boundedCursor)), + [trace, boundedCursor], + ); + + const accessCount = trace?.observations?.storage.length ?? 0; + const eventCount = trace?.logs.length ?? 0; + + const modeButton = (id: ProbeMode, label: string, disabled = false) => ( + + ); + + const tabButton = (id: ProbeTab, label: string) => ( + + ); + + return ( +
+
+
+ {modeButton('storage', 'Storage')} + {modeButton('trace', 'Trace', trace === null)} +
+ {outcome !== null && ( + + CALL #{Math.max(callCount, 1)} · {outcome.fnName}() + + )} +
+ + {effectiveMode === 'storage' && ( +
+ + +
+ )} + + {effectiveMode === 'trace' && trace !== null && artifact !== null && ( +
+ {trace.encoded.truncatedStepCount > 0 && ( +

+ Memory guard: showing the first {trace.encoded.stepCount.toLocaleString('en-US')}{' '} + steps; {trace.encoded.truncatedStepCount.toLocaleString('en-US')} further steps + executed but were not recorded. +

+ )} + +
+ {tabButton('accesses', `Accesses · ${accessCount}`)} + {tabButton('state', 'State')} + {tabButton('events', `Events · ${eventCount}`)} + {tabButton('forensics', 'Forensics')} +
+
+ {tab === 'accesses' && + (trace.observations !== null && trace.observations.storage.length > 0 ? ( + + ) : ( +

this call touched no storage

+ ))} + {tab === 'state' && machineState !== null && ( + + )} + {tab === 'events' && ( + <> + + {trace.logs.length === 0 && ( +

this call emitted no events

+ )} + + )} + {tab === 'forensics' && ( + + )} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/storage/StorageGrid.tsx b/apps/web/src/storage/StorageGrid.tsx index 1e97080..52f234d 100644 --- a/apps/web/src/storage/StorageGrid.tsx +++ b/apps/web/src/storage/StorageGrid.tsx @@ -2,6 +2,7 @@ import { useMemo, useRef, useState } from 'react'; import type { CollapsedStorageSpan, ContractArtifact } from '@slotscope/domain'; import { LayoutDecodeError, decodeStorageLayout } from '@slotscope/layout'; import { buildRows, type RowSegment } from './buildRows'; +import { ownerChipText } from './ownerChip'; export interface StorageGridProps { artifact: ContractArtifact | null; @@ -35,11 +36,6 @@ function gridColumn(byteOffset: number, byteLength: number): string { * contained horizontal scrolling, and text labels alongside owner colors. */ /** 3-letter chip from a contract name: its capitals/digits, e.g. PI, BA. */ -function ownerChipText(name: string): string { - const capitals = name.replace(/[^A-Z0-9]/g, ''); - return (capitals.length >= 2 ? capitals : name.toUpperCase()).slice(0, 3); -} - /** * Honest copy per collapsed reason. A reference type is not "unexpanded" — * its entries live at keccak-derived slots that exist only at runtime; the diff --git a/apps/web/src/storage/ownerChip.ts b/apps/web/src/storage/ownerChip.ts new file mode 100644 index 0000000..52fd960 --- /dev/null +++ b/apps/web/src/storage/ownerChip.ts @@ -0,0 +1,5 @@ +/** Short owner chip text for a contract name (shared by grid + strip). */ +export function ownerChipText(name: string): string { + const capitals = name.replace(/[^A-Z0-9]/g, ''); + return (capitals.length >= 2 ? capitals : name.toUpperCase()).slice(0, 3); +} diff --git a/apps/web/src/trace/TracePane.tsx b/apps/web/src/trace/TracePane.tsx deleted file mode 100644 index 8485bcc..0000000 --- a/apps/web/src/trace/TracePane.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useMemo } from 'react'; -import type { EncodedTrace, SemanticResolution, TraceObservations } from '@slotscope/domain'; -import { replayToCursor } from '@slotscope/executor'; -import { MachineStatePanes } from './MachineStatePanes.tsx'; -import { ObservationList } from './ObservationList.tsx'; -import { TraceScrubber } from './TraceScrubber.tsx'; - -export interface TracePaneProps { - trace: EncodedTrace; - /** Calldata of the traced call (hex, no 0x). Constant for the run. */ - calldataHex: string; - /** Final return data of the run (hex, no 0x). */ - returnDataHex: string; - observations?: TraceObservations | null; - resolutions?: readonly SemanticResolution[]; - cursor: number; - onCursor: (cursor: number) => void; -} - -/** - * Machine state at the cursor (X5): the state BEFORE the cursor step - * executes, replayed from keyframes + deltas. Stack is shown top-first. - * Calldata is constant per run; return data is a run-level fact shown at the - * final cursor — mid-run values after nested calls are not tracked in v0.2 - * and are labeled as such (honest degradation, no invented state). - */ -export function TracePane({ - trace, - calldataHex, - returnDataHex, - observations = null, - resolutions = [], - cursor, - onCursor, -}: TracePaneProps) { - // A restored session may carry any cursor the schema allows — clamp to - // the trace we actually have before ANY replay math can range-error. - const boundedCursor = Math.min(Math.max(cursor, 0), trace.stepCount - 1); - const step = trace.steps[boundedCursor]; - const state = useMemo(() => replayToCursor(trace, boundedCursor), [trace, boundedCursor]); - if (step === undefined) return null; - - return ( -
- {trace.truncatedStepCount > 0 && ( -

- Memory guard: showing the first {trace.stepCount.toLocaleString('en-US')} steps;{' '} - {trace.truncatedStepCount.toLocaleString('en-US')} further steps executed but were not - recorded. -

- )} - - - {observations !== null && observations.storage.length > 0 && ( - - )} -
- ); -} diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 2cd03f6..45d089b 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -76,6 +76,12 @@ --ss-glow-accent-strong: rgba(240, 169, 60, 0.7); --ss-color-scrim: rgb(0 0 0 / 55%); + /* Resolution-status tier (neutral by design: the border STYLE and the + = / ≈ / ? mark carry the status — owner hues stay identity-only and + pin cyan stays pin-only). */ + --ss-color-res-fg: #d5cec1; + --ss-color-res-border: #6a6152; + /* Spacing */ --ss-space-1: 0.25rem; --ss-space-2: 0.5rem; diff --git a/scripts/benchmark-report.ts b/scripts/benchmark-report.ts index 9144715..91b36c3 100644 --- a/scripts/benchmark-report.ts +++ b/scripts/benchmark-report.ts @@ -86,9 +86,11 @@ async function main() { if (!contracts?.includes('Main.sol:PackedInheritance')) { fail('compiled result does not contain the expected fixture contract'); } - const diagnostics = await page.locator('[aria-label="Diagnostics"]').textContent(); - if (!diagnostics?.includes('No diagnostics.')) { - fail('fixture compile unexpectedly produced diagnostics'); + // Variant B: diagnostics live behind the DIAG toggle in the source + // pane header; the count in its label is the assertion surface. + const diagToggle = await page.locator('.ss-diag-toggle').textContent(); + if (!diagToggle?.includes('DIAG · 0')) { + fail(`fixture compile unexpectedly produced diagnostics (${diagToggle ?? 'no toggle'})`); } await page.waitForFunction(