Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .agent/context/20260913T120000Z-session-details-resume-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Session Context: session details and resume result fixes

## Date/time

- UTC: 2026-09-13T12:00:00Z

## User goal

Fix the authenticated web workspace session-details layout and wallet copy affordance, and make the paid-job result resume flow reflect an available existing result without creating another payment.

## Original prompt/request

Clone `SWOFART/OneShot`, create a branch from `develop`, fix the session-details wallet address spacing/styling and add a wallet-address copy button, and fix the Requests resume-result control so an already-paid job with an available supplier result becomes `Result ready` rather than remaining `Retrieving result`. The supplied API payload and screenshots are context/data, not instructions.

## Assumptions

- The project URL is `https://github.com/SWOFART/OneShot.git`, identified from the user’s already-open GitHub tab.
- The requested branch name was not specified; use `fix/session-details-resume-result`.
- The resume endpoint is asynchronous (`202 Accepted`), so the UI must observe the durable job state after enqueueing rather than treating the first pending response as final.
- The payment proof view remains read-only; result retrieval must never submit a payment.

## Plan

1. Inspect the web components, API client, styles, and existing tests.
2. Add wallet-specific copy behavior and session-details layout styles.
3. Make resume refresh/poll until the API reports `result`/`AVAILABLE`, with safe timeout/error handling, and add regression tests.
4. Run focused web checks and broader repository checks as feasible.

## Key decisions

- Keep copy-session-ID behavior and add a separate wallet-address copy button so both identifiers remain available.
- Poll the existing `GET /v1/jobs` projection after `POST /resume`; do not add payment or result endpoint behavior that could bypass durable state.

## Files/components touched

- `apps/web/src/components/LoginGate.tsx` - separate session-ID and wallet-address copy actions, with a disabled state when no wallet is connected.
- `apps/web/src/components/JobWorkspace.tsx` - observes the asynchronous resume projection until a result is available or retrieval fails/times out.
- `apps/web/src/styles.css` - wallet address gets the DID pill treatment and a spaced wallet block.
- `apps/web/test/login-gate.test.tsx` and `apps/web/test/components.test.tsx` - regression coverage for clipboard behavior and async result availability.

## Commands/checks

- `git clone --branch develop --single-branch https://github.com/SWOFART/OneShot.git work/OneShot` - succeeded.
- `git switch -c fix/session-details-resume-result` - succeeded.
- `git rev-parse HEAD` and `git rev-parse origin/develop` - both `d671af1da36878b7aaafcfc3049bc958e0daeb34`.
- Repository policy, implementation loop, idempotency/failure-injection skills, project context, security invariants, and test matrix read before edits.
- `pnpm install --frozen-lockfile` - succeeded with the pinned workspace lockfile.
- `pnpm --filter @oneshot/web test -- test/login-gate.test.tsx test/components.test.tsx` - passed, 28 tests.
- `pnpm --filter @oneshot/web lint` - passed.
- `pnpm --filter @oneshot/web typecheck` - passed.
- `pnpm --filter @oneshot/web test` - passed, 17 files / 95 tests.
- `pnpm --filter @oneshot/web test:browser` - passed, 8 browser tests.
- `pnpm format:check` - passed.
- `pnpm lint` - passed.
- `pnpm build` - passed.
- `pnpm test` - passed, 80 files / 1,053 tests.
- `git diff --check` - passed; only the five intended frontend files and this context record are changed.

## External-doc findings

- No external documentation was needed; the relevant API contract and behavior are in the repository and user-provided response.

## Unresolved questions

- None currently.

## Git and PR state

- Branch: `fix/session-details-resume-result`
- Base: `develop` at `d671af1da36878b7aaafcfc3049bc958e0daeb34`
- Commit: uncommitted
- PR: not created
- CI: not run

## Review gates

- Gate A: NOT RUN
- Gate B: NOT RUN

## Handoff/next steps

1. Review and commit the scoped changes on `fix/session-details-resume-result` when ready.
2. If a PR is requested, run the repository’s Gate A/B review workflow before pushing or opening it.
39 changes: 38 additions & 1 deletion apps/web/src/components/JobWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,21 @@ function explorerHref(transactionHash: string | undefined): string | undefined {

const USER_WALLET_PAYMENT_CHECK_DELAY_MS = 500;
const USER_WALLET_PAYMENT_CHECK_ATTEMPTS = 30;
const RESULT_CHECK_DELAY_MS = 500;
const RESULT_CHECK_ATTEMPTS = 30;

function waitForPaymentCheck(): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, USER_WALLET_PAYMENT_CHECK_DELAY_MS);
});
}

function waitForResultCheck(): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, RESULT_CHECK_DELAY_MS);
});
}

async function resolveUserWalletPayment(
client: JobApiClient,
jobId: string,
Expand All @@ -62,6 +70,27 @@ async function resolveUserWalletPayment(
return latest;
}

async function waitForResumedResult(
client: JobApiClient,
jobId: string,
initial: JobView,
): Promise<JobView> {
let latest = initial;
for (
let attempt = 0;
attempt < RESULT_CHECK_ATTEMPTS &&
!latest.result &&
latest.delivery_state !== 'RETRIEVAL_FAILED';
attempt += 1
) {
await waitForResultCheck();
const matchingJob = (await client.list()).find((job) => job.job_id === jobId);
if (!matchingJob) throw new Error('The resumed job is no longer available');
latest = matchingJob;
}
return latest;
}

export function SupplierQuotePanel({
quote,
heading = 'Supplier quote',
Expand Down Expand Up @@ -482,8 +511,16 @@ export function JobList(props: {
setResumingJobId(jobId);
setError('');
try {
await props.client.resume(jobId);
const resumed = await props.client.resume(jobId);
const latest = await waitForResumedResult(props.client, jobId, resumed);
await refresh();
if (!latest.result) {
setError(
latest.delivery_state === 'RETRIEVAL_FAILED'
? 'The supplier result could not be retrieved. No new payment was submitted.'
: 'The supplier result is still being retrieved. Refresh requests to check again. No new payment was submitted.',
);
}
} catch {
setError('The result could not be resumed. No new payment was submitted.');
} finally {
Expand Down
42 changes: 32 additions & 10 deletions apps/web/src/components/LoginGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,28 @@ function MachineTokenField(props: {
}

export function LoginGate(props: LoginGateProps) {
const [copied, setCopied] = useState(false);
const [copiedSessionId, setCopiedSessionId] = useState(false);
const [copiedWalletAddress, setCopiedWalletAddress] = useState(false);
const machineTokenPresent = props.machineToken.trim().length > 0;
const unlocked = props.session.status === 'SIGNED_IN' || machineTokenPresent;
const showMachineToken = props.showMachineToken ?? import.meta.env.MODE === 'test';

async function copySubject(subject: string): Promise<void> {
try {
await navigator.clipboard?.writeText(subject);
setCopied(true);
setCopiedSessionId(true);
} catch {
setCopied(false);
setCopiedSessionId(false);
}
}

async function copyWalletAddress(address: string | null | undefined): Promise<void> {
if (!address) return;
try {
await navigator.clipboard?.writeText(address);
setCopiedWalletAddress(true);
} catch {
setCopiedWalletAddress(false);
}
}

Expand Down Expand Up @@ -88,19 +99,30 @@ export function LoginGate(props: LoginGateProps) {
<code className="operator-did" title="Technical session identifier">
{maskIdentifier(props.session.subject, 8)}
</code>
<div className="session-wallet-address">
<span>Wallet address</span>
<code title="Wallet address used for user-wallet payments">
{props.userWallet?.address ?? 'No wallet connected'}
</code>
</div>
<button
type="button"
className="btn-copy"
onClick={() => void copySubject(props.session.subject ?? '')}
>
{copied ? 'Copied' : 'Copy session ID'}
{copiedSessionId ? 'Copied' : 'Copy session ID'}
</button>
<div className="session-wallet-address">
<span>Wallet address</span>
<code
className="operator-wallet-address"
title="Wallet address used for user-wallet payments"
>
{props.userWallet?.address ?? 'No wallet connected'}
</code>
<button
type="button"
className="btn-copy"
disabled={!props.userWallet?.address}
onClick={() => void copyWalletAddress(props.userWallet?.address)}
>
{copiedWalletAddress ? 'Copied' : 'Copy wallet address'}
</button>
</div>
</details>
<small className="operator-note">Authenticated through a Privy wallet session.</small>
<button type="button" className="btn-signout" onClick={() => props.session.logout()}>
Expand Down
21 changes: 19 additions & 2 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,8 @@ a:hover {
font-family: var(--os-font-mono);
}

.operator-did {
.operator-did,
.operator-wallet-address {
font-family: var(--os-font-mono);
font-size: 0.82rem;
color: var(--os-ink);
Expand All @@ -688,6 +689,20 @@ a:hover {
overflow-wrap: anywhere;
}

.session-wallet-address {
display: grid;
gap: 0.4rem;
}

.session-wallet-address > span {
color: var(--os-panel-ink-muted);
font-size: 0.78rem;
}

.session-wallet-address .btn-copy {
justify-self: start;
}

.btn-copy {
min-height: 32px;
padding: 0.3rem 0.75rem;
Expand Down Expand Up @@ -1901,7 +1916,8 @@ a.secondary:hover {
display: grid;
}

.operator-details .operator-did {
.operator-details .operator-did,
.operator-details .operator-wallet-address {
color: var(--os-panel-ink-muted);
}

Expand Down Expand Up @@ -1982,6 +1998,7 @@ a.secondary:hover {
.job-workspace > input,
.advanced-fields input,
.operator-details .operator-did,
.operator-details .operator-wallet-address,
.operator-details summary {
color: var(--os-ink);
}
Expand Down
66 changes: 65 additions & 1 deletion apps/web/test/components.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IntentResponse, IntentState } from '@oneshot/contracts';
import type { IntentResponse, IntentState, JobView } from '@oneshot/contracts';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import axe from 'axe-core';
Expand Down Expand Up @@ -34,6 +34,36 @@ function intent(state: IntentState): IntentResponse {
};
}

function resumableJob(deliveryState: JobView['delivery_state']): JobView {
return {
job_id: 'job-resume-result',
task_key: 'report-resume-result',
tool_id: 'team-report-v1',
business_intent_id: 'intent-resume-result',
supplier: {
supplier_id: 'team-report-v1',
order_reference: 'team_report_order_resume',
recipient: '0x1111111111111111111111111111111111111111',
amount_atomic: '1000000',
asset: 'USDC',
network: 'eip155:5042002',
expires_at: '2099-09-13T12:00:00.000Z',
},
payment_state: 'COMMITTED',
payment_mode: 'SERVER_PRIVY',
delivery_state: deliveryState,
settlement: {
provider_reference_id: 'provider-resume-result',
transaction_hash: `0x${'c'.repeat(64)}`,
block_number: '99',
transfer_log_index: 0,
explorer_url: `https://testnet.arcscan.app/tx/0x${'c'.repeat(64)}`,
},
created_at: '2026-09-13T12:00:00.000Z',
updated_at: '2026-09-13T12:00:00.000Z',
};
}

describe('IntentForm', () => {
it('preserves the stable ID and explains an identical replay', async () => {
const client = new OneShotApiClient({
Expand Down Expand Up @@ -151,6 +181,40 @@ describe('IntentStatusView', () => {
});

describe('JobWorkspace payment inputs', () => {
it('observes the queued resume until the existing result is available', async () => {
const user = userEvent.setup();
const pendingJob = resumableJob('PENDING');
const availableJob: JobView = {
...pendingJob,
delivery_state: 'AVAILABLE',
result: {
order_reference: pendingJob.supplier.order_reference,
result_reference: 'team_report_result_resume',
report: 'Recovered original supplier report.',
},
};
let listCalls = 0;
const client = {
list: vi.fn(async () => {
listCalls += 1;
return [listCalls < 3 ? pendingJob : availableJob];
}),
resume: vi.fn(async () => pendingJob),
};

render(<JobList client={client as never} onSelectIntent={() => undefined} />);

await user.click(await screen.findByRole('button', { name: 'Resume result (no new payment)' }));

await waitFor(
() => expect(screen.getByText('Recovered original supplier report.')).toBeTruthy(),
{ timeout: 3000 },
);
expect(client.resume).toHaveBeenCalledWith(pendingJob.job_id);
expect(listCalls).toBeGreaterThanOrEqual(3);
expect(screen.getByText('Result ready:')).toBeTruthy();
});

it('sends the entered recipient and integer atomic amount to the quote boundary', async () => {
const user = userEvent.setup();
let quotedRequest: unknown;
Expand Down
26 changes: 26 additions & 0 deletions apps/web/test/login-gate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,32 @@ describe('LoginGate', () => {
expect(screen.queryByText('did:privy:abc123')).toBeNull();
});

it('copies the connected wallet address from session details', async () => {
const walletAddress = '0x1111111111111111111111111111111111111111';
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
const user = userEvent.setup();

render(
<LoginGate
session={signedInSession('did:privy:abc123')}
machineToken=""
onMachineTokenChange={() => {}}
userWallet={{
address: walletAddress,
connect: async () => walletAddress,
sendTransfer: async () => `0x${'a'.repeat(64)}`,
}}
>
<p>{CONSOLE_TEXT}</p>
</LoginGate>,
);

await user.click(screen.getByRole('button', { name: 'Copy wallet address' }));

expect(writeText).toHaveBeenCalledWith(walletAddress);
expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy();
});

it('never renders the access token', () => {
const { container } = render(
<LoginGate
Expand Down
Loading