diff --git a/web/README.md b/web/README.md index fb16f6a..90ae48a 100644 --- a/web/README.md +++ b/web/README.md @@ -308,6 +308,22 @@ non-engineers, with engineering words only under 「詳しい人向け」 successful data for the same key during failed polls; build/draft pages show a load-error card only when no data has loaded, never in place of cached work. Data from another draft, app or project is not used as placeholder data. +- API requests use manual redirects: an edge 401, OIDC redirect (including an + opaque cross-origin redirect), or successful HTML login page ends the local + session instead of masquerading as an API result. Platform and Console/Directory + requests share this check. The page shows 「セッションが切れました。再サインインしてください」 + and a 「再サインイン」 action; query observers are removed and further API calls + are blocked until sign-in. The action navigates to the same URL through the edge, + preserving the draft and its session storage. There is no automatic redirect + loop or never-settling authentication promise. +- A successful build kickoff resumes plan generation from the draft even when + session storage is absent. A queued kickoff also persists its plan reservation + atomically. An idle conversation without a plan has a visible 「この内容で提案して」 + primary action rather than a fake progress state. The handoff reads the draft + under the existing mutation lock and uses an idempotency key bound to the latest + turn and prior generation; an already accepted/current plan is not recreated. + Active draft polling continues while the tab is hidden (subject to browser + suspension); normal focus refetch catches up after resume. - A source repository kept from a failed create attempt is reused only when its origin (URL name, entry, picked repository, template) matches the retry. - AI build sessions default an absent/disabled plan publish to the wizard's diff --git a/web/src/api/build-queries.ts b/web/src/api/build-queries.ts index cd9cae4..418facd 100644 --- a/web/src/api/build-queries.ts +++ b/web/src/api/build-queries.ts @@ -32,6 +32,8 @@ export function buildDraftOptions(projectId: string, draftId: string) { queryFn: projectId && draftId ? () => getDraft(projectId, draftId) : skipToken, retry: retryReadQuery, retryDelay: readRetryDelay, + // This page drives the turn → plan handoff; hiding its tab must not suspend that writer. + refetchIntervalInBackground: true, refetchInterval: (query) => draftPending(query.state.data) ? BUILD_POLL_MS : false, }) } diff --git a/web/src/api/build.test.ts b/web/src/api/build.test.ts index aac3a73..b1c51a7 100644 --- a/web/src/api/build.test.ts +++ b/web/src/api/build.test.ts @@ -57,6 +57,12 @@ test('all build API adapters use contract routes, payloads and idempotency keys expect(new Set(keys).size).toBe(requests.length) }) +test('plan handoff reuses its attempt key across interrupted responses', async () => { + await api.createGeneration('project-1', 'draft-1', {}, 'build-plan-draft-1-kickoff-initial') + await api.createGeneration('project-1', 'draft-1', {}, 'build-plan-draft-1-kickoff-initial') + expect(requests.map((request) => request.headers.get('Idempotency-Key'))).toEqual(['build-plan-draft-1-kickoff-initial', 'build-plan-draft-1-kickoff-initial']) +}) + test('workbook set and structured turns use the draft contract routes, payloads and the caller key', async () => { const workbook = { fileName: '台帳.xlsx', sheets: [{ name: '台帳', rows: [['名前'], ['案件']] }] } await api.addWorkbook('project-1', 'draft-1', workbook, 'file-key-1') @@ -146,6 +152,15 @@ test('operation polling is 3 seconds only while pending/running, including lapse expect(listOptions.refetchInterval({ state: { data: { data: [draft] } } } as never)).toBe(3000) }) +test('draft handoff keeps polling in background, and succeeded turns without a lease are idle', () => { + const options = buildDraftOptions('p1', 'd1') + expect(options.refetchIntervalInBackground).toBe(true) + if (typeof options.refetchInterval !== 'function') throw new Error('Draft polling callback missing') + const completed = { ...draft, creationExecutions: [], conversationTurns: [{ status: 'succeeded' }] } + expect(options.refetchInterval({ state: { data: completed } } as never)).toBe(false) + expect(options.refetchInterval({ state: { data: { ...completed, conversationTurns: [{ status: 'running' }] } } } as never)).toBe(BUILD_POLL_MS) +}) + test('implementation mutations use the run envelope and leave task execution to the server', async () => { const run = { id: 'impl-1', status: 'pending' as const, tasks: [], taskIds: [], devWorkspaceId: '', startedAt } globalThis.fetch = mock(async (input: RequestInfo | URL) => { diff --git a/web/src/api/build.ts b/web/src/api/build.ts index b606b54..08fc37e 100644 --- a/web/src/api/build.ts +++ b/web/src/api/build.ts @@ -5,8 +5,8 @@ import type { } from '@appthrust/api-schema/platform-api' import { client } from './client' -// Keep the stable mutation envelope intact. Draft mutations get a fresh -// Idempotency-Key per attempt; publish repair explicitly reuses its key. +// Keep the stable mutation envelope intact. Ordinary mutations get a fresh key; +// resumable plan handoffs and publish repair explicitly reuse their attempt key. export function createDraft(projectId: string, payload: CreateApplicationDraftRequest) { return client.platformApiCreateApplicationDraft({ projectId, ...payload }) } @@ -52,8 +52,8 @@ export function getTurn(projectId: string, draftId: string, turnId: string) { return client.platformApiGetApplicationDraftConversationTurn({ projectId, id: draftId, turnId }) } -export function createGeneration(projectId: string, draftId: string, payload: CreateApplicationDraftCreationPlanGenerationRequest = {}) { - return client.platformApiCreateApplicationDraftCreationPlanGeneration({ projectId, id: draftId, ...payload }) +export function createGeneration(projectId: string, draftId: string, payload: CreateApplicationDraftCreationPlanGenerationRequest = {}, idempotencyKey?: string) { + return client.platformApiCreateApplicationDraftCreationPlanGeneration({ projectId, id: draftId, ...payload }, { context: { idempotencyKey } }) } export function getGeneration(projectId: string, draftId: string, generationId: string) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9ff74ad..8e75cc6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -8,20 +8,15 @@ import { platformApiRouter } from '@appthrust/api-schema/platform-api-router' import { createORPCClient, ORPCError } from '@orpc/client' import type { ContractRouterClient } from '@orpc/contract' import { OpenAPILink } from '@orpc/openapi-client/fetch' +import { assertSessionActive, checkSessionResponse } from './session-expiry' type PlatformClientContext = { idempotencyKey?: string } export type PlatformApiClient = ContractRouterClient const READ_TIMEOUT_MS = 8_000 -// A 401 first sends the browser to `/` so the edge re-runs sign-in. Another 401 -// within this window of that round-trip means the edge forwards a token the -// Platform API rejects (e.g. AudienceNotAllowed); redirecting again would loop, -// so the 401 reaches the screens, which show `copy.common.accessDenied`. -const REAUTH_WINDOW_MS = 60_000 -const REAUTH_AT_KEY = 'builder-console:reauth-at' - export async function platformFetch(request: Request): Promise { + assertSessionActive() const headers = new Headers(request.headers) const read = request.method === 'GET' || request.method === 'HEAD' if (!read && !headers.has('Idempotency-Key')) { @@ -30,17 +25,7 @@ export async function platformFetch(request: Request): Promise { const signal = read ? AbortSignal.any([request.signal, AbortSignal.timeout(READ_TIMEOUT_MS)]) : request.signal - const response = await fetch(new Request(request, { headers, signal })) - if (response.status !== 401) { - if (response.ok) sessionStorage.removeItem(REAUTH_AT_KEY) - return response - } - const lastReauthAt = Number(sessionStorage.getItem(REAUTH_AT_KEY)) - if (Date.now() - lastReauthAt < REAUTH_WINDOW_MS) return response - sessionStorage.setItem(REAUTH_AT_KEY, String(Date.now())) - window.location.assign('/') - // The page is unloading; keep the call pending so screens do not flash an error. - return new Promise(() => {}) + return checkSessionResponse(await fetch(new Request(request, { headers, signal, redirect: 'manual' }))) } // Contract paths already start with `/api/v1`, so the base is the bare origin diff --git a/web/src/api/console.test.ts b/web/src/api/console.test.ts index e8042aa..20ed1ba 100644 --- a/web/src/api/console.test.ts +++ b/web/src/api/console.test.ts @@ -48,7 +48,6 @@ function recorder(responses: Response[]) { calls.push({ input, init }) return responses.shift() ?? new Response(null, { status: 500 }) }, - () => false, ) return { api, calls } } @@ -105,7 +104,7 @@ describe('createConsoleApi', () => { const { api } = recorder([Response.json({ error: 'upstream_unavailable', message: 'platform down' }, { status: 502 })]) await expect(api.me()).rejects.toMatchObject({ status: 502, code: 'upstream_unavailable', transient: false, message: 'platform down' }) - const offline = createConsoleApi(async () => Promise.reject(new TypeError('Failed to fetch')), () => false) + const offline = createConsoleApi(async () => Promise.reject(new TypeError('Failed to fetch'))) await expect(offline.me()).rejects.toMatchObject({ status: 0, transient: true, message: copy.errors.transient }) }) diff --git a/web/src/api/console.ts b/web/src/api/console.ts index 8c00d5d..77d0bd9 100644 --- a/web/src/api/console.ts +++ b/web/src/api/console.ts @@ -4,6 +4,7 @@ // same-origin under `/api/console/directory/*` (→ `/api/directory/v1/*`). Same-origin // JSON; the OIDC edge attaches Authorization, the SPA never sees a token. import { copy } from '../copy' +import { assertSessionActive, checkSessionResponse } from './session-expiry' /** A Directory person. `avatarVersion`: null without a profile picture, else when it last changed (the image URL's cache-buster). */ export type Person = { id: string; subject: string | null; pending: boolean; email: string; displayName: string; avatarVersion: string | null } @@ -200,11 +201,6 @@ export function isConsoleError(error: unknown, code?: string): error is ConsoleA } const READ_TIMEOUT_MS = 8_000 -// Shared with the Platform API client (api/client.ts): a 401 sends the browser -// to `/` once so the edge re-runs sign-in; a second 401 inside the window -// surfaces to the screens instead of looping. -const REAUTH_WINDOW_MS = 60_000 -const REAUTH_AT_KEY = 'builder-console:reauth-at' type Fetcher = (input: string, init: RequestInit) => Promise export type AppListParams = { scope?: AppScope; groupId?: string; q?: string } @@ -274,8 +270,9 @@ export interface ConsoleApi { } } -export function createConsoleApi(fetcher: Fetcher = (input, init) => fetch(input, init), reauth = browserReauth): ConsoleApi { +export function createConsoleApi(fetcher: Fetcher = (input, init) => fetch(input, init)): ConsoleApi { async function send(method: string, path: string, body: BodyInit | undefined, contentType: string | undefined): Promise { + assertSessionActive() const read = method === 'GET' let response: Response try { @@ -284,13 +281,14 @@ export function createConsoleApi(fetcher: Fetcher = (input, init) => fetch(input headers: contentType === undefined ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': contentType }, body, credentials: 'same-origin', + redirect: 'manual', signal: read ? AbortSignal.timeout(READ_TIMEOUT_MS) : undefined, }) } catch { // Network failure or read timeout: nothing answered (status 0). throw new ConsoleApiError(0, undefined) } - if (response.status === 401 && reauth()) return new Promise(() => {}) + checkSessionResponse(response) if (!response.ok) { let parsed: Partial | undefined try { @@ -384,14 +382,6 @@ export function createConsoleApi(fetcher: Fetcher = (input, init) => fetch(input } } -function browserReauth(): boolean { - const lastReauthAt = Number(sessionStorage.getItem(REAUTH_AT_KEY)) - if (Date.now() - lastReauthAt < REAUTH_WINDOW_MS) return false - sessionStorage.setItem(REAUTH_AT_KEY, String(Date.now())) - window.location.assign('/') - return true -} - export const consoleApi = createConsoleApi() export const screenshotUrl = (appId: string, screenshotId: string) => diff --git a/web/src/api/session-expiry.test.tsx b/web/src/api/session-expiry.test.tsx new file mode 100644 index 0000000..468f95d --- /dev/null +++ b/web/src/api/session-expiry.test.tsx @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import type { ApplicationDraft } from '@appthrust/api-schema/platform-api' +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { copy } from '../copy' +import { SessionExpiryBoundary } from '../ui/session-expired' +import { getDraft } from './build' +import { buildDraftOptions } from './build-queries' +import { platformFetch } from './client' +import { createConsoleApi } from './console' +import { retryQuery, retryReadQuery } from './queries' +import { checkSessionResponse, isSessionExpiredError, sessionExpiryStore, signInAgain } from './session-expiry' + +const originalFetch = globalThis.fetch +const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window') +beforeEach(() => { sessionExpiryStore.setState(() => false) }) +afterEach(() => { + globalThis.fetch = originalFetch + sessionExpiryStore.setState(() => false) + if (windowDescriptor) Object.defineProperty(globalThis, 'window', windowDescriptor) +}) + +const expiredResponses: Array<[string, () => Response]> = [ + ['401', () => Response.json({ error: 'unauthorized' }, { status: 401 })], + ['302 to IAM', () => new Response(null, { status: 302, headers: { location: 'https://iam.example.test/sign-in' } })], + ['opaque redirect', () => Object.defineProperty(new Response(null), 'type', { value: 'opaqueredirect' })], + ['followed IAM redirect', () => Object.defineProperty(Response.json({}), 'redirected', { value: true })], + ['HTML login page', () => new Response('Sign in', { headers: { 'content-type': 'text/html; charset=utf-8' } })], +] + +test.each(expiredResponses)('Platform API %s settles as session expiry and blocks subsequent polling', async (_name, response) => { + const fetch = mock(async () => response()) + globalThis.fetch = fetch as unknown as typeof globalThis.fetch + const request = new Request('https://console.example.test/api/v1/projects/p/application-drafts/d') + const error = await platformFetch(request).catch((error: unknown) => error) + expect(isSessionExpiredError(error)).toBe(true) + expect(error).toMatchObject({ status: 401, code: 'SESSION_EXPIRED' }) + expect(retryReadQuery(0, error)).toBe(false) + expect(retryQuery(0, error)).toBe(false) + expect(sessionExpiryStore.state).toBe(true) + expect((fetch.mock.calls[0] as unknown as [Request])[0].redirect).toBe('manual') + await expect(platformFetch(request)).rejects.toMatchObject({ code: 'SESSION_EXPIRED' }) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test.each(expiredResponses)('Console/Directory %s uses the same session expiry latch', async (_name, response) => { + const fetcher = mock(async (_input: string, _init: RequestInit) => response()) + const api = createConsoleApi(fetcher) + await expect(api.me()).rejects.toMatchObject({ status: 401, code: 'SESSION_EXPIRED' }) + await expect(api.directory.groups()).rejects.toMatchObject({ code: 'SESSION_EXPIRED' }) + expect(fetcher).toHaveBeenCalledTimes(1) + expect(fetcher.mock.calls[0]?.[1].redirect).toBe('manual') +}) + +test('gateway HTML, forbidden JSON and successful JSON are not mistaken for expired sessions', () => { + for (const response of [new Response('Unavailable', { status: 503, headers: { 'content-type': 'text/html' } }), Response.json({ error: 'forbidden' }, { status: 403 }), Response.json({ id: 'draft' })]) { + expect(checkSessionResponse(response)).toBe(response) + expect(sessionExpiryStore.state).toBe(false) + } +}) + +test('the real oRPC adapter preserves the typed session failure rather than parsing an IAM page', async () => { + globalThis.fetch = mock(async () => new Response(null, { status: 302, headers: { location: 'https://iam.example.test/sign-in' } })) as unknown as typeof fetch + const error = await getDraft('project', 'draft').catch((error: unknown) => error) + expect(isSessionExpiredError(error)).toBe(true) + expect(retryReadQuery(0, error)).toBe(false) +}) + +test('cached running draft becomes a visible sign-in notice, removes polling observers and retains data', async () => { + const at = '2026-11-09T01:55:44Z' + const draft: ApplicationDraft = { id: 'draft', projectId: 'project', title: 'App', intent: 'Build app', phase: 'specifying', specMarkdown: '', architectureMarkdown: '', creationExecutions: [], ownerUserId: 'owner', createdAt: at, updatedAt: at, conversationTurns: [{ id: 'kickoff', kind: 'conversation', userMessage: 'この内容で提案して', status: 'running', startedAt: at, devWorkspaceId: 'workspace' }] } + const options = buildDraftOptions('project', 'draft') + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }) + client.setQueryData(options.queryKey, draft) + const fetch = mock(async () => new Response(null, { status: 302 })) + globalThis.fetch = fetch as unknown as typeof globalThis.fetch + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + function Poll() { + useQuery({ ...options, refetchOnMount: false }) + return

Thinking

+ } + try { + await act(async () => { root.render() }) + expect(container.textContent).toContain('Thinking') + await act(async () => { await client.refetchQueries({ queryKey: options.queryKey }) }) + expect(container.textContent).toContain(copy.common.sessionExpired) + expect(container.querySelector('button')?.textContent).toBe(copy.common.signInAgain) + expect(container.textContent).not.toContain('Thinking') + expect(client.getQueryCache().find({ queryKey: options.queryKey })?.getObserversCount()).toBe(0) + expect(client.getQueryData(options.queryKey)).toEqual(draft) + await client.refetchQueries({ queryKey: options.queryKey, type: 'active' }) + expect(fetch).toHaveBeenCalledTimes(1) + } finally { + await act(async () => { root.unmount() }) + container.remove() + client.clear() + } +}) + +test('sign-in action navigates to the same draft URL without clearing its reservation', () => { + const href = 'https://console.example.test/-/build/draft-41cb28ef' + const assign = mock() + Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { href, assign } } }) + sessionExpiryStore.setState(() => true) + signInAgain() + expect(assign).toHaveBeenCalledWith(href) + expect(sessionExpiryStore.state).toBe(true) +}) diff --git a/web/src/api/session-expiry.ts b/web/src/api/session-expiry.ts new file mode 100644 index 0000000..c167a79 --- /dev/null +++ b/web/src/api/session-expiry.ts @@ -0,0 +1,34 @@ +import { ORPCError } from '@orpc/client' +import { Store } from '@tanstack/react-store' + +/** Latches until a full-page sign-in; never discard draft/query/session data here. */ +export const sessionExpiryStore = new Store(false) + +function expiredError() { + return new ORPCError('SESSION_EXPIRED', { status: 401, message: 'Session expired' }) +} + +export function isSessionExpiredError(error: unknown): boolean { + return error instanceof ORPCError && error.code === 'SESSION_EXPIRED' +} + +export function assertSessionActive(): void { + if (sessionExpiryStore.state) throw expiredError() +} + +/** JSON APIs never redirect to a page. Manual redirects also catch cross-origin IAM without CORS. */ +export function checkSessionResponse(response: Response): Response { + const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (response.status === 401 || response.type === 'opaqueredirect' || response.redirected + || (response.status >= 300 && response.status < 400 && response.status !== 304) + || (response.ok && (contentType === 'text/html' || contentType === 'application/xhtml+xml'))) { + sessionExpiryStore.setState(() => true) + throw expiredError() + } + return response +} + +/** Re-enter the edge's OIDC flow at the same draft URL; no tokens or API redirects enter the SPA. */ +export function signInAgain(): void { + window.location.assign(window.location.href) +} diff --git a/web/src/copy/shell.en.ts b/web/src/copy/shell.en.ts index 1c8d8a2..6c1c19f 100644 --- a/web/src/copy/shell.en.ts +++ b/web/src/copy/shell.en.ts @@ -211,6 +211,8 @@ export const shellEn: typeof shell = { retry: 'Reload', loadFailed: 'We could not load this information. Wait a moment, then reload.', accessDenied: 'Your sign-in was not accepted. Please contact your admin.', + sessionExpired: 'Your session has expired. Please sign in again.', + signInAgain: 'Sign in again', actionFailed: 'Something went wrong.', cancel: 'Cancel', close: 'Close', diff --git a/web/src/copy/shell.ts b/web/src/copy/shell.ts index 584211b..5e6a166 100644 --- a/web/src/copy/shell.ts +++ b/web/src/copy/shell.ts @@ -214,6 +214,8 @@ export const shell = { loadFailed: '情報を読み込めませんでした。しばらくしてから再読み込みしてください。', /** 401 that survives a fresh sign-in: the token is valid but Platform API rejects it (e.g. AudienceNotAllowed). */ accessDenied: 'サインイン情報が基盤に受け付けられませんでした。管理者に連絡してください。', + sessionExpired: 'セッションが切れました。再サインインしてください', + signInAgain: '再サインイン', actionFailed: '操作に失敗しました。', cancel: 'キャンセル', close: '閉じる', diff --git a/web/src/features/build/bind-created-console-app.test.ts b/web/src/features/build/bind-created-console-app.test.ts index 7c777a0..fa4b7b2 100644 --- a/web/src/features/build/bind-created-console-app.test.ts +++ b/web/src/features/build/bind-created-console-app.test.ts @@ -31,7 +31,7 @@ beforeEach(async () => { afterEach(async () => { await fetchConsole('/api/console/__mock/reset', { method: 'POST' }) }) function harness(createdAppId?: string) { - const api = createConsoleApi(fetchConsole, () => false) + const api = createConsoleApi(fetchConsole) const apps = { ...api.apps, list: mock(api.apps.list), get: mock(api.apps.get), create: mock(api.apps.create), diff --git a/web/src/features/build/build-page.tsx b/web/src/features/build/build-page.tsx index 81c489b..9a801bc 100644 --- a/web/src/features/build/build-page.tsx +++ b/web/src/features/build/build-page.tsx @@ -101,6 +101,7 @@ function Session({ draft, groupId }: { draft: ApplicationDraft; groupId: string } const onPrimary = () => { if (primary.action === 'publish') { setSharing(true); setTab('artifact') } + else if (primary.action === 'plan') build.requestPlan() else if (primary.action === 'continue') { build.patch({ continuedAfterPublishAt: draft.publishedAt }); setTab('conversation') } } const useName = (next: string) => { diff --git a/web/src/features/build/copy.test.tsx b/web/src/features/build/copy.test.tsx index b127562..b48c5ae 100644 --- a/web/src/features/build/copy.test.tsx +++ b/web/src/features/build/copy.test.tsx @@ -18,7 +18,7 @@ import { projectImplementation, type ImplementationState } from './implement/sta import { NameChoice } from './name-choice' import { ProposalBody } from './proposal' import { stageLabels, titleFromIntent } from './session' -import { StageRail, stateWord } from './session-header' +import { StageAction, StageRail, stateWord } from './session-header' let restoreLanguage: () => void beforeEach(() => { @@ -57,6 +57,15 @@ test('imported stage and state labels switch both ways without reloading modules expect(implementationCopy.preview).toBe('アプリのプレビュー') }) +test.each(['ja', 'en'] as const)('idle plan handoff shows a primary action, not a spinner, in %s', (language) => { + setLanguage(language) + const markup = renderToStaticMarkup( {}} />) + expect(markup).toContain(' { setLanguage('ja') const japanese = renderToStaticMarkup() diff --git a/web/src/features/build/group-gate.test.tsx b/web/src/features/build/group-gate.test.tsx index 81a941f..ff52353 100644 --- a/web/src/features/build/group-gate.test.tsx +++ b/web/src/features/build/group-gate.test.tsx @@ -25,7 +25,7 @@ async function fixture(path: string, init?: RequestInit) { return response } -const consoleApi = createConsoleApi(fixture, () => false) +const consoleApi = createConsoleApi(fixture) const localStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') let restoreLanguage: () => void beforeEach(() => { diff --git a/web/src/features/build/publish-step.test.tsx b/web/src/features/build/publish-step.test.tsx index f05317c..3ee81b3 100644 --- a/web/src/features/build/publish-step.test.tsx +++ b/web/src/features/build/publish-step.test.tsx @@ -13,7 +13,7 @@ async function fixture(path: string, init?: RequestInit) { } test.each(['private', 'group', 'company'] as const)('a fresh publish dialog uses draft name and audience %s, not stale console metadata', async (shareScope) => { - const api = createConsoleApi(fixture, () => false) + const api = createConsoleApi(fixture) const [me, groups, apps] = await Promise.all([api.me(), api.directory.groups(), api.apps.list({ scope: 'mine' })]) const app = apps[0]! const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }) diff --git a/web/src/features/build/session-header.tsx b/web/src/features/build/session-header.tsx index bc0534b..ca8f832 100644 --- a/web/src/features/build/session-header.tsx +++ b/web/src/features/build/session-header.tsx @@ -15,12 +15,12 @@ export function StageRail({ stage }: { stage: BuildStage }) { export const stateWord: Record = localized({ ja: textJa.header.stateWord, en: textEn.header.stateWord }) /** - * The stage's page-level action: 公開する / 続けて直す. 「この内容で作る」 belongs to the + * The stage's page-level action: 提案して / 公開する / 続けて直す. 「この内容で作る」 belongs to the * AI's proposal in the conversation (it is read there first), so it is not repeated here. * During preparation there is nothing to press; the header says what is happening. */ export function StageAction({ stage, primary, progress, onPrimary }: { stage: BuildStage; primary: BuildPrimary; progress?: ProgressCard; onPrimary(): void }) { - if (primary.action === 'publish' || primary.action === 'continue') return + if (primary.action === 'plan' || primary.action === 'publish' || primary.action === 'continue') return if (stage === 'build' && progress) return

{progress.title}{progress.elapsedLabel ? {progress.elapsedLabel} : null}

return null } diff --git a/web/src/features/build/session.test.ts b/web/src/features/build/session.test.ts index 082fd43..cf7c979 100644 --- a/web/src/features/build/session.test.ts +++ b/web/src/features/build/session.test.ts @@ -103,6 +103,13 @@ describe('build session persistence', () => { expect(queued.pending).toHaveLength(2) expect(queueReply(initial, ' ', 'empty')).toBe(initial) }) + test('queuing a kickoff persists its plan reservation through acknowledgment and reload', () => { + const target = storage() + const queued = queueReply(initial, ` ${buildCopy.questions.kickoffReply} `, 'now') + expect(queued.planRequested).toBe(true) + saveBuildSession('kickoff', acknowledgeReply(queued, 'now'), target) + expect(readBuildSession('kickoff', target)).toMatchObject({ planRequested: true, pending: [] }) + }) test('reload retains preparation reservation, queued messages, binding, template release and continuation', () => { const target = storage() const session = { ...queueReply(initial, '変更したい', 'now'), kickoffSent: true, lastPlannedIntent: '見積を作りたい', prepareRequested: true, createdAppId: 'app1', continuedAfterPublishAt: '2026-10-29T00:00:00Z', initialReleaseId: 'template-release' } diff --git a/web/src/features/build/session.ts b/web/src/features/build/session.ts index 2e43913..8060332 100644 --- a/web/src/features/build/session.ts +++ b/web/src/features/build/session.ts @@ -2,7 +2,7 @@ import type { ApplicationDraft } from '@appthrust/api-schema/platform-api' import { z } from 'zod' import { isBuildPending } from '../../model/build/progress' import { buildCopy } from '../../model/build/copy' -import { decomposeBuildIntent, isPrepareTurn } from '../../model/build/questions' +import { decomposeBuildIntent, isKickoffReply, isPrepareTurn } from '../../model/build/questions' import type { BuildMessage, BuildStage } from '../../model/build/types' import { localized } from '../../shell/language' import { text, textJa } from './copy' @@ -75,7 +75,7 @@ export function queueReply(session: BuildSession, text: string, at: string): Bui const reply = { text: text.trim(), at } if (!reply.text) return session const message: BuildMessage = { role: 'user', ...reply } - return { ...session, pending: [...session.pending, reply], messages: [...session.messages, message] } + return { ...session, planRequested: session.planRequested || isKickoffReply(reply.text), pending: [...session.pending, reply], messages: [...session.messages, message] } } export function acknowledgeReply(session: BuildSession, at: string): BuildSession { diff --git a/web/src/features/build/use-build-session.test.tsx b/web/src/features/build/use-build-session.test.tsx index 057aaf3..363d40b 100644 --- a/web/src/features/build/use-build-session.test.tsx +++ b/web/src/features/build/use-build-session.test.tsx @@ -8,6 +8,7 @@ import * as api from '../../api/build' import { buildKeys } from '../../api/build-queries' import { consoleKeys, type BusinessApp, type Me } from '../../api/console' import { keys } from '../../api/queries' +import { sessionExpiryStore } from '../../api/session-expiry' import { buildCopy } from '../../model/build/copy' import { shareScopeLabel } from '../../model/access' import { composeBuildIntent } from '../../model/build/questions' @@ -46,6 +47,7 @@ let build: { session: BuildSession; failure?: BuildFailure; prepareOpen: boolean; app?: BusinessApp; messages: BuildMessage[] result: { stage: BuildStage; primary: BuildPrimary } send(text: string): void + requestPlan(): void retry(context?: 'turn' | 'plan' | 'prepare' | 'implement' | 'publish'): void patch(next: Partial): void published(app: BusinessApp): Promise @@ -123,6 +125,7 @@ beforeEach(() => { mutationResponse({ ...client.getQueryData(queryKey)!, ...payload }, 'ApplicationDraft', 'application-draft.update')) createTurn = spyOn(api, 'createTurn').mockImplementation(async (_projectId, _draftId, payload) => mutationResponse(turn('share', payload.message, 2), 'ApplicationDraftConversationTurn', 'application-draft-conversation-turn.create')) + const getDraft = spyOn(api, 'getDraft').mockImplementation(async () => client.getQueryData(queryKey)!) timers = new Map() let nextTimer = 0 const timeoutSpy = spyOn(window, 'setTimeout').mockImplementation(((handler: TimerHandler, delay?: number) => { @@ -132,18 +135,132 @@ beforeEach(() => { return id }) as typeof window.setTimeout) const clearSpy = spyOn(window, 'clearTimeout').mockImplementation((id) => { timers.delete(Number(id)) }) - restore = [implementationSpy, viewsSpy, update, createTurn, timeoutSpy, clearSpy, clockSpy].map((spy) => () => spy.mockRestore()) + restore = [implementationSpy, viewsSpy, update, createTurn, getDraft, timeoutSpy, clearSpy, clockSpy].map((spy) => () => spy.mockRestore()) }) afterEach(async () => { await act(async () => { root.unmount() }) client.clear() container.remove() forgetBuildSession(draft.id) + sessionExpiryStore.setState(() => false) for (const reset of restore.reverse()) reset() if (storageDescriptor) Object.defineProperty(globalThis, 'sessionStorage', storageDescriptor) else Reflect.deleteProperty(globalThis, 'sessionStorage') }) +describe('kickoff to plan handoff', () => { + const kickoff = turn('kickoff', buildCopy.questions.kickoffReply, 4) + const unplanned = { ...draft, conversationTurns: [kickoff] } + + function mockGeneration() { + const generation = spyOn(api, 'createGeneration').mockImplementation(async () => + mutationResponse({ id: 'plan-1', status: 'pending' as const, startedAt: '2026-10-26T00:05:00Z', devWorkspaceId: 'workspace-1' }, 'ApplicationDraftCreationPlanGeneration', 'application-draft-creation-plan-generation.create')) + restore.push(() => generation.mockRestore()) + return generation + } + + test('accepted kickoff waits for succeeded, then requests exactly one generation without a lease', async () => { + const generation = mockGeneration() + createTurn.mockImplementation(async (_projectId, _draftId, payload) => + mutationResponse({ ...kickoff, userMessage: payload.message, status: 'pending' as const }, 'ApplicationDraftConversationTurn', 'application-draft-conversation-turn.create')) + await mount({ ...unplanned, conversationTurns: [turn('opening', buildCopy.questions.firstTurn, 0)] }) + await act(async () => { build.send(buildCopy.questions.kickoffReply) }) + expect(createTurn).toHaveBeenCalledTimes(1) + expect(build.session).toMatchObject({ pending: [], planRequested: true }) + expect(generation).not.toHaveBeenCalled() + await act(async () => { client.setQueryData(queryKey, { ...client.getQueryData(queryKey)!, conversationTurns: [kickoff] }) }) + expect(generation).toHaveBeenCalledTimes(1) + expect(build.session.planRequested).toBe(false) + await render() + expect(generation).toHaveBeenCalledTimes(1) + }) + + test.each(['saved', 'missing', 'invalid'] as const)('reload with %s session recovers succeeded kickoff from the draft', async (storage) => { + const generation = mockGeneration() + if (storage === 'saved') saveBuildSession(draft.id, initial) + else if (storage === 'invalid') sessionStorage.setItem(`builder-console:build:${draft.id}`, '{') + else forgetBuildSession(draft.id) + client.setQueryData(queryKey, unplanned) + await render() + expect(generation).toHaveBeenCalledTimes(1) + expect(createTurn).not.toHaveBeenCalled() + await act(async () => { root.render(null) }) + await render() + expect(generation).toHaveBeenCalledTimes(1) + }) + + test.each(['pending', 'succeeded'] as const)('reload observes an existing %s generation instead of sending another', async (status) => { + const generation = mockGeneration() + const current: ApplicationDraft = { + ...unplanned, phase: status === 'succeeded' ? 'planned' : 'specifying', + creationPlan: status === 'succeeded' ? { summary: '', markdown: '', representation: [] } : undefined, + creationPlanGenerations: [{ id: 'existing', status, startedAt: '2026-10-26T00:05:00Z', devWorkspaceId: 'workspace-1' }], + } + await mount(current, { ...initial, planRequested: true }) + expect(generation).not.toHaveBeenCalled() + }) + + test('a generation accepted before reload is read back instead of duplicated', async () => { + const generation = mockGeneration() + const getDraft = spyOn(api, 'getDraft').mockResolvedValue({ + ...unplanned, creationPlanGenerations: [{ id: 'already-accepted', status: 'pending', startedAt: '2026-10-26T00:05:00Z', devWorkspaceId: 'workspace-1' }], + }) + restore.push(() => getDraft.mockRestore()) + await mount(unplanned, { ...initial, planRequested: true }) + expect(generation).not.toHaveBeenCalled() + expect(client.getQueryData(queryKey)?.creationPlanGenerations?.[0]?.id).toBe('already-accepted') + }) + + test('the primary action sends one kickoff and one plan, even on a double click', async () => { + const generation = mockGeneration() + await mount({ ...unplanned, conversationTurns: [turn('opening', buildCopy.questions.firstTurn, 0)] }) + expect(build.result.primary).toMatchObject({ action: 'plan', enabled: true }) + await act(async () => { build.requestPlan(); build.requestPlan() }) + expect(createTurn).toHaveBeenCalledTimes(1) + expect(generation).toHaveBeenCalledTimes(1) + }) + + test('turn admission conflict retains the kickoff reservation and retries after its block', async () => { + const generation = mockGeneration() + let tick: (() => void) | undefined + const interval = spyOn(window, 'setInterval').mockImplementation(((handler: TimerHandler) => { + tick = handler as () => void + return 987 + }) as typeof window.setInterval) + restore.push(() => interval.mockRestore()) + createTurn.mockRejectedValueOnce(conflict) + await mount({ ...unplanned, conversationTurns: [turn('opening', buildCopy.questions.firstTurn, 0)] }) + await act(async () => { build.send(buildCopy.questions.kickoffReply) }) + expect(build.session.planRequested).toBe(true) + expect(build.session.pending).toHaveLength(1) + expect(generation).not.toHaveBeenCalled() + spyOn(Date, 'now').mockReturnValue(Date.parse('2026-10-26T00:05:04Z')) + if (!tick) throw new Error('Pending reply clock missing') + await act(async () => { tick!() }) + expect(createTurn).toHaveBeenCalledTimes(2) + expect(build.session.pending).toEqual([]) + expect(generation).toHaveBeenCalledTimes(1) + }) + + test('session expiry during handoff settles without losing intent and sign-in resumes once', async () => { + const generation = mockGeneration() + generation.mockImplementationOnce(async () => { + sessionExpiryStore.setState(() => true) + throw new ORPCError('SESSION_EXPIRED', { status: 401 }) + }) + await mount(unplanned, { ...initial, planRequested: true }) + expect(build.failure).toBeUndefined() + expect(readBuildSession(draft.id)?.planRequested).toBe(true) + expect(generation).toHaveBeenCalledTimes(1) + await act(async () => { root.render(null) }) + sessionExpiryStore.setState(() => false) + await render() + expect(generation).toHaveBeenCalledTimes(2) + expect(generation.mock.calls[0]?.[3]).toBe(generation.mock.calls[1]?.[3]) + expect(build.session.planRequested).toBe(false) + }) +}) + describe('preparation name synchronization', () => { test.each([ [true, 'idle'], [false, 'starting-workspace'], [false, 'running'], [false, 'pushing'], diff --git a/web/src/features/build/use-build-session.ts b/web/src/features/build/use-build-session.ts index 15f9154..268ff3b 100644 --- a/web/src/features/build/use-build-session.ts +++ b/web/src/features/build/use-build-session.ts @@ -7,11 +7,13 @@ import { buildDraftOptions } from '../../api/build-queries' import { consoleApi, consoleKeys, isConsoleError, type BusinessApp, type Me } from '../../api/console' import { platformErrorEnvelope } from '../../api/platform-error' import { isPublishPreparationPending, publishPreparationRetryDelay } from '../../api/publish-retry' +import { isSessionExpiredError, sessionExpiryStore } from '../../api/session-expiry' import { classifyBuildFailure } from '../../model/build/failure' +import { buildCopy } from '../../model/build/copy' import { messagesFromDraft, planProposal } from '../../model/build/messages' -import { composeBuildIntent, conversationTurns, decomposeBuildIntent, isKickoffReply, isPrepareTurn, prepareAnswers } from '../../model/build/questions' +import { composeBuildIntent, conversationTurns, decomposeBuildIntent, isPrepareTurn, prepareAnswers } from '../../model/build/questions' import { parseSpec, serializeSpec } from '../../model/build/spec' -import { deriveStage, draftPublished, needsPlanRefresh } from '../../model/build/stage' +import { deriveStage, draftPublished, hasUnplannedKickoff, needsPlanRefresh } from '../../model/build/stage' import type { BuildFailure, BuildMessage } from '../../model/build/types' import { pickCreateEnvironment, summarizeCreationPlan } from '../../model/create' import { withDefaultPublish } from '../../model/build/plan' @@ -141,10 +143,10 @@ export function useBuildSession(draft: ApplicationDraft, me: Me, groupId: string }) } async function perform(context: 'turn' | 'plan' | 'prepare' | 'implement' | 'publish', action: () => Promise) { - if (lock.current) return + if (lock.current || sessionExpiryStore.state) return lock.current = true setBusy(true) - try { await action() } catch (error) { fail(error, context) } finally { lock.current = false; setBusy(false) } + try { await action() } catch (error) { if (!isSessionExpiredError(error)) fail(error, context) } finally { lock.current = false; setBusy(false) } } useEffect(() => { saveBuildSession(draft.id, store.state) }, [draft.id, store]) useEffect(() => { @@ -180,7 +182,6 @@ export function useBuildSession(draft: ApplicationDraft, me: Me, groupId: string patch((current) => queueReply({ ...current, continuedAfterPublishAt: draft.publishedAt }, reply, new Date().toISOString())) return } - if (isKickoffReply(reply)) patch({ planRequested: true }) patch((current) => queueReply({ ...current, reusePlan: false }, reply, new Date().toISOString())) setFailure(undefined) } @@ -247,11 +248,23 @@ export function useBuildSession(draft: ApplicationDraft, me: Me, groupId: string }, [session.pending, session.messages, conversing, preparing, answers.open, busy, failure, now, implementation.pending, implementation.state.failure]) async function generate() { - const current = draftRef.current + const previousGenerationId = draftRef.current.creationPlanGenerations?.at(-1)?.id + // A reload/another tab may have already accepted this handoff. Read before writing. + const current = await api.getDraft(projectId, draft.id) + receive(current) + const latestGeneration = current.creationPlanGenerations?.at(-1) + if (current.createdApplicationStackId || current.creationExecutions.length + || latestGeneration?.id !== previousGenerationId + || !needsPlanRefresh(current, store.state.lastPlannedIntent)) { + patch({ planRequested: false }) + return + } + if ([latestGeneration, current.conversationTurns?.at(-1)].some(isBuildPending)) return const intent = composeBuildIntent(decomposeBuildIntent(session.baseIntent), conversationTurns(current.conversationTurns).map((item) => item.userMessage), true) const updated = current.intent === intent ? current : await api.updateDraft(projectId, draft.id, { intent }) receive(updated) - const created = await api.createGeneration(projectId, draft.id) + const attemptKey = `build-plan-${draft.id}-${current.conversationTurns?.at(-1)?.id ?? 'initial'}-${previousGenerationId ?? 'initial'}` + const created = await api.createGeneration(projectId, draft.id, {}, attemptKey) patch({ lastPlannedIntent: intent, planRequested: false, reusePlan: false }) receive({ ...draftRef.current, creationPlanGenerations: [...(draftRef.current.creationPlanGenerations ?? []), created] }) } @@ -270,7 +283,7 @@ export function useBuildSession(draft: ApplicationDraft, me: Me, groupId: string useEffect(() => { if (running || busy || lock.current || session.pending.length || failure || draft.createdApplicationStackId) return if ([generation, turn, execution].some((operation) => operation?.status === 'failed' && !observedFailures.current.has(operation.id))) return - if ((session.planRequested && turn?.status === 'succeeded') || (session.prepareRequested && !session.reusePlan && needsPlanRefresh(draft, session.lastPlannedIntent))) { + if ((turn?.status === 'succeeded' && (session.planRequested || hasUnplannedKickoff(draft))) || (session.prepareRequested && !session.reusePlan && needsPlanRefresh(draft, session.lastPlannedIntent))) { void perform('plan', generate) return } @@ -426,6 +439,11 @@ export function useBuildSession(draft: ApplicationDraft, me: Me, groupId: string waitingForAi: session.pending.length > 0 && unavailableRetries.current > 0 && now < blockedUntil.current + 1000, waitingForPublish: publishRetries.current >= 4, send, retry, patch, receive, perform, + requestPlan: () => { + if (lock.current || running || store.state.pending.length || result.primary.action !== 'plan') return + if (hasUnplannedKickoff(draft)) retry('plan') + else send(buildCopy.questions.kickoffReply) + }, prepare: () => { bindingFailed.current = false; setFailure(undefined); patch({ prepareRequested: true }) }, published: (updated: BusinessApp) => { queryClient.setQueryData(consoleKeys.app(updated.id), updated); return markPublished() }, } diff --git a/web/src/features/create/domain-readiness.test.tsx b/web/src/features/create/domain-readiness.test.tsx index 171f0f9..30aa0e5 100644 --- a/web/src/features/create/domain-readiness.test.tsx +++ b/web/src/features/create/domain-readiness.test.tsx @@ -39,7 +39,7 @@ afterEach(() => { }) test.each([true, false])('new app distinguishes pending domain and recovers on its scheduled poll: admin=%s', async (admin) => { - const api = createConsoleApi(fixture, () => false) + const api = createConsoleApi(fixture) const [me, groups] = await Promise.all([api.me(), api.directory.groups()]) const project = `/api/v1/projects/${me.projectId}` const [templates, definitions, sources, registries, domains, tools] = await Promise.all([ diff --git a/web/src/main.tsx b/web/src/main.tsx index de8a6fa..82a03e9 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -5,6 +5,7 @@ import { createRoot } from 'react-dom/client' import { queryClient } from './api/queries' import { router } from './router' import { useLanguage } from './shell/language' +import { SessionExpiryBoundary } from './ui/session-expired' import './styles.css' // A stale deployment chunk gets one full navigation per tab session, never a reload loop. @@ -55,7 +56,7 @@ if (!root) throw new Error('#root is missing from index.html') createRoot(root).render( - + {Devtools ? ( diff --git a/web/src/model/build/stage.test.ts b/web/src/model/build/stage.test.ts index 0d7d9c9..09a1682 100644 --- a/web/src/model/build/stage.test.ts +++ b/web/src/model/build/stage.test.ts @@ -25,6 +25,14 @@ describe('design section 4 stage table', () => { expect(deriveStage({ ...base, draft: { ...unplanned, creationPlan: draft.creationPlan } }).stage).toBe('decide') expect(deriveStage({ ...base, draft: undefined }).stage).toBe('ask') }) + test('an idle unplanned conversation offers a plan action without pretend progress', () => { + const current = { ...unplanned, conversationTurns: [{ ...turn, status: 'succeeded' as const, userMessage: buildCopy.questions.kickoffReply }] } + expect(deriveStage({ ...base, draft: current })).toEqual({ + stage: 'ask', primary: { action: 'plan', label: buildCopy.questions.kickoffReply, enabled: true }, + }) + expect(deriveStage({ ...base, draft: current, turn }).primary.action).toBe('none') + expect(deriveStage({ ...base, draft: current, generation: { ...generation, status: 'pending' } }).primary.action).toBe('none') + }) test('decides with a ready plan and enables preparation', () => { expect(deriveStage(base)).toEqual({ stage: 'decide', primary: { action: 'prepare', label: buildCopy.primary.prepare, enabled: true } }) }) diff --git a/web/src/model/build/stage.ts b/web/src/model/build/stage.ts index d756bc8..dd67d61 100644 --- a/web/src/model/build/stage.ts +++ b/web/src/model/build/stage.ts @@ -3,7 +3,7 @@ import type { } from '@appthrust/api-schema/platform-api' import { buildCopy } from './copy' import { isBuildPending, progressCard } from './progress' -import { prepareAnswers } from './questions' +import { conversationTurns, isKickoffReply, prepareAnswers } from './questions' import type { BuildPhase, BuildPrimary, BuildStage, ProgressCard } from './types' /** Publication belongs to the draft; a later implementation invalidates that published version. */ @@ -33,6 +33,13 @@ export function needsPlanRefresh(draft: ApplicationDraft | undefined, lastPlanne && Date.parse(turn.startedAt) < executedAt && !turnIds.includes(turn.id)) } +/** The server's accepted kickoff survives missing session storage and OIDC round trips. */ +export function hasUnplannedKickoff(draft: ApplicationDraft): boolean { + return !draft.creationPlan && !draft.creationPlanGenerations?.length && !draft.creationExecutions?.length + && !draft.createdApplicationStackId && draft.phase !== 'created' + && conversationTurns(draft.conversationTurns).some((turn) => turn.status === 'succeeded' && isKickoffReply(turn.userMessage)) +} + export function deriveStage(input: { draft?: ApplicationDraft generation?: ApplicationDraftCreationPlanGeneration @@ -77,7 +84,14 @@ export function deriveStage(input: { : execution?.status === 'failed' ? progressCard({ kind: 'prepare', operation: execution, execution, now }) : generation?.status === 'failed' ? progressCard({ kind: 'plan', operation: generation, now }) : undefined const hasPlan = draft?.creationPlan || generation?.status === 'succeeded' || draft?.creationPlanGenerations?.some((item) => item.status === 'succeeded') - if (!hasPlan && execution?.status !== 'failed') return { stage: 'ask', primary: none, ...(progress ? { progress } : {}) } + if (!hasPlan && execution?.status !== 'failed') { + const canPlan = !generation && turn?.status === 'succeeded' + return { + stage: 'ask', + primary: canPlan ? { action: 'plan', label: buildCopy.questions.kickoffReply, enabled: true } : none, + ...(progress ? { progress } : {}), + } + } // The separately polled generation may reach success before the draft query catches up. const planningDraft = draft && generation && !draft.creationPlanGenerations?.some((item) => item.id === generation.id && item.status === generation.status) ? { ...draft, creationPlanGenerations: [...(draft.creationPlanGenerations ?? []).filter((item) => item.id !== generation.id), generation] } : draft diff --git a/web/src/model/build/types.ts b/web/src/model/build/types.ts index 5fad2cf..4198e72 100644 --- a/web/src/model/build/types.ts +++ b/web/src/model/build/types.ts @@ -55,7 +55,7 @@ export interface ProgressCard { delayHint?: string } export interface BuildPrimary { - action: 'prepare' | 'prepare-after-plan' | 'publish' | 'continue' | 'none' + action: 'plan' | 'prepare' | 'prepare-after-plan' | 'publish' | 'continue' | 'none' label: string enabled: boolean } diff --git a/web/src/ui/session-expired.tsx b/web/src/ui/session-expired.tsx new file mode 100644 index 0000000..9618423 --- /dev/null +++ b/web/src/ui/session-expired.tsx @@ -0,0 +1,15 @@ +import { useSelector } from '@tanstack/react-store' +import type { ReactNode } from 'react' +import { sessionExpiryStore, signInAgain } from '../api/session-expiry' +import { copy } from '../copy' +import { Button } from './button' +import { Notice } from './feedback' + +/** Remove query observers while signed out, rather than showing stale progress or polling IAM. */ +export function SessionExpiryBoundary({ children }: { children: ReactNode }) { + const expired = useSelector(sessionExpiryStore, (state) => state) + if (!expired) return children + return
+ {copy.common.signInAgain}} /> +
+}