diff --git a/packages/webapp/src/CloudApp.tsx b/packages/webapp/src/CloudApp.tsx index 4c3d7361..eabe48d6 100644 --- a/packages/webapp/src/CloudApp.tsx +++ b/packages/webapp/src/CloudApp.tsx @@ -9,7 +9,7 @@ import { type MouseEvent as ReactMouseEvent, } from 'react'; import { createClient, type WebDAVClient } from 'webdav'; -import { ApiAdapter, ApiError, type TenantMe } from './api-adapter'; +import { ApiAdapter, ApiError, workspaceFromWire, type TenantMe } from './api-adapter'; import type { ControlPlaneClient } from './api'; import type { CredentialRequestView } from '@blitzos/schema'; import { SPAWN_SESSION_LABELS, type SpawnSessionType } from './NewTabMenu'; @@ -78,11 +78,13 @@ import { } from './sessions-page-state'; import { clampDrawerWidth, + defaultGlobalWebAppState, defaultWorkspaceFiles, isManagedWorkspaceTab, tabRegion, withPreviewTabPath, type StorageNamespace, + type GlobalWebAppStateV1, type WorkspaceDrawerSegment, type WorkspaceRegion, type WorkspaceTab, @@ -215,6 +217,8 @@ function CloudAppContent({ client, resolver }: CloudAppProps) { const [terminalSignInUrl, setTerminalSignInUrl] = useState(null); const [showPasteCodeModal, setShowPasteCodeModal] = useState(false); const [pendingRequests, setPendingRequests] = useState([]); + const pendingRequestsRef = useRef(pendingRequests); + pendingRequestsRef.current = pendingRequests; const [pendingRequestsError, setPendingRequestsError] = useState(null); // The grant-approval feed (plans/ORG-CREDENTIALS.md §7a): a pending // proposal addressed to this member pops the dialog on whichever page they @@ -224,6 +228,10 @@ function CloudAppContent({ client, resolver }: CloudAppProps) { !signedOut && store.viewer !== null, store.viewer?.membership.id ?? null, ); + const [grantProposalError, setGrantProposalError] = useState<{ + proposalId: string; + message: string; + } | null>(null); // The latest `blitz connections open` focus for the active workspace; a // fresh object per event so the panel re-selects on a repeat ask. const [connectionsFocus, setConnectionsFocus] = useState(null); @@ -260,6 +268,17 @@ function CloudAppContent({ client, resolver }: CloudAppProps) { // authority for their own rows, so such a request is discarded rather than // briefly (or permanently) rolling the UI backward. const workspaceMutationEpoch = useRef(0); + const pendingMachineStarts = useRef(new Set()); + const pendingWorkspaceRenames = useRef(new Map()); + const acknowledgedGlobalDoc = useRef<{ + namespace: string; + value: GlobalWebAppStateV1; + json: string; + } | null>(null); + const attemptedGlobalDoc = useRef<{ namespace: string; json: string } | null>(null); + const currentGlobalDoc = useRef<{ namespace: string; json: string } | null>(null); + const globalSaveRequest = useRef(null); + const [globalSaveVersion, setGlobalSaveVersion] = useState(0); const firstWorkspacePrompted = useRef(false); // Visit once, then retain: tab switches preserve live state without eagerly // opening every saved terminal and WebGL surface. @@ -333,6 +352,18 @@ function CloudAppContent({ client, resolver }: CloudAppProps) { if (cause instanceof ApiError && cause.status === 401) return; setError(caughtErrorMessage(cause, 'Could not save webApp state.')); }, []); + const rememberGlobalState = useCallback(( + doc: GlobalWebAppStateV1, + namespaceValue: StorageNamespace, + ) => { + const json = JSON.stringify(doc); + const namespace = `${namespaceValue.orgId}:${namespaceValue.membershipId}`; + // A save from the organization we just left may still settle. It cannot + // acknowledge or reconcile this namespace. + globalSaveRequest.current = null; + acknowledgedGlobalDoc.current = { namespace, value: doc, json }; + attemptedGlobalDoc.current = { namespace, json }; + }, []); const { transitionStage: organizationTransitionStage, createOrgName, @@ -381,7 +412,11 @@ function CloudAppContent({ client, resolver }: CloudAppProps) { const records = await api.listWorkspaces(); if (mutationEpoch !== workspaceMutationEpoch.current) return; rememberWorkspaceEndpoints(workspaceEndpoints.current, records, resolver, true); - dispatch({ type: 'workspace_records_refreshed', records }); + dispatch({ + type: 'workspace_records_refreshed', + records, + heldWorkspaceIds: [...pendingMachineStarts.current], + }); } catch (refreshError) { if (!(refreshError instanceof ApiError && refreshError.status === 401)) { console.warn('Unable to refresh workspace status', refreshError); @@ -600,6 +635,7 @@ function CloudAppContent({ client, resolver }: CloudAppProps) { dispatch, setLoaded, setError, + onGlobalStateLoaded: rememberGlobalState, }); useEffect(() => { @@ -719,22 +755,6 @@ function CloudAppContent({ client, resolver }: CloudAppProps) { refreshWorkspaceRecords, }); - useEffect(() => { - if ( - !loaded - || !storageNamespace - || store.workspaces.some(({ pendingCreate }) => pendingCreate) - ) return; - const timer = window.setTimeout(() => { - void api.putGlobalWebAppState({ - version: 1, - activeWorkspaceId, - order: store.workspaces.map(({ id }) => id), - }).catch(handlePersistenceError); - }, 150); - return () => window.clearTimeout(timer); - }, [activeWorkspaceId, api, handlePersistenceError, loaded, storageNamespace, store.workspaces]); - const navigateToWorkspacePage = useCallback((workspaceId: string) => { // COME BACK WHERE THE MEMBER LEFT. Without this the switch pushes a path // with no chat segment and sets `chat: null`, so returning to a workspace @@ -747,6 +767,101 @@ function CloudAppContent({ client, resolver }: CloudAppProps) { setRoute(remembered === null ? { workspaceId, page: 'webApp', chat: null } : parseAppRoute(path)); }, []); + const applyGlobalState = useCallback((doc: GlobalWebAppStateV1) => { + const workspaces = storeRef.current.workspaces; + const liveIds = new Set(workspaces.map(({ id }) => id)); + const order = [ + ...doc.order.filter((id, index) => liveIds.has(id) && doc.order.indexOf(id) === index), + ...workspaces.map(({ id }) => id).filter((id) => !doc.order.includes(id)), + ]; + commitWorkspaceMutation({ type: 'workspace_order_reconciled', order }); + const active = selectControllableWorkspaceId(workspaces, doc.activeWorkspaceId); + const activeChanged = activeWorkspaceIdRef.current !== active; + activeWorkspaceIdRef.current = active; + setActiveWorkspaceId(active); + if (!activeChanged || parseAppRoute(window.location.pathname).page !== 'webApp') return; + const path = active ? workspacePath(active) : '/'; + window.history.replaceState({}, '', path); + setRoute(active + ? { workspaceId: active, page: 'webApp', chat: null } + : { workspaceId: null, page: 'drive' }); + }, [commitWorkspaceMutation]); + + if (loaded && storageNamespace !== null) { + currentGlobalDoc.current = { + namespace: `${storageNamespace.orgId}:${storageNamespace.membershipId}`, + json: JSON.stringify({ + version: 1, + activeWorkspaceId, + order: store.workspaces.map(({ id }) => id), + } satisfies GlobalWebAppStateV1), + }; + } + + useEffect(() => { + if ( + !loaded + || !storageNamespace + || store.workspaces.some(({ pendingCreate }) => pendingCreate) + || globalSaveRequest.current !== null + ) return; + const namespace = `${storageNamespace.orgId}:${storageNamespace.membershipId}`; + const doc: GlobalWebAppStateV1 = { + version: 1, + activeWorkspaceId, + order: store.workspaces.map(({ id }) => id), + }; + const json = JSON.stringify(doc); + if (attemptedGlobalDoc.current?.namespace === namespace + && attemptedGlobalDoc.current.json === json) return; + const timer = window.setTimeout(() => { + const request = Symbol(namespace); + globalSaveRequest.current = request; + attemptedGlobalDoc.current = { namespace, json }; + void api.putGlobalWebAppState(doc) + .then((response) => { + if (globalSaveRequest.current !== request) return; + const canonical = response.doc ?? defaultGlobalWebAppState(); + const canonicalJson = JSON.stringify(canonical); + acknowledgedGlobalDoc.current = { + namespace, + value: canonical, + json: canonicalJson, + }; + attemptedGlobalDoc.current = { namespace, json: canonicalJson }; + // A later selection or reorder waits behind this write. Do not let + // the older response erase it before its own save begins. + if (currentGlobalDoc.current?.namespace !== namespace + || currentGlobalDoc.current.json !== json) return; + applyGlobalState(canonical); + }) + .catch((cause: Error) => { + if (globalSaveRequest.current !== request) return; + const acknowledged = acknowledgedGlobalDoc.current; + if (acknowledged?.namespace === namespace) { + attemptedGlobalDoc.current = { namespace, json: acknowledged.json }; + applyGlobalState(acknowledged.value); + } + handlePersistenceError(cause); + }) + .finally(() => { + if (globalSaveRequest.current !== request) return; + globalSaveRequest.current = null; + setGlobalSaveVersion((version) => version + 1); + }); + }, 150); + return () => window.clearTimeout(timer); + }, [ + activeWorkspaceId, + api, + applyGlobalState, + globalSaveVersion, + handlePersistenceError, + loaded, + storageNamespace, + store.workspaces, + ]); + // WHERE EACH WORKSPACE IS BEING LEFT. Recorded only for a path that carries a // real chat address: the bare `/workspaces/:id` is what the restore above // exists to improve on, so writing it would erase the memory on the way out. @@ -877,10 +992,38 @@ function CloudAppContent({ client, resolver }: CloudAppProps) { * so it goes through the same PATCH the settings tab writes; the tile shows * the new name at once and the next poll agrees. */ const renameWorkspace = useCallback((workspaceId: string, name: string) => { - dispatch({ type: 'workspace_renamed', workspaceId, title: name }); + const preceding = storeRef.current.workspaces.find(({ id }) => id === workspaceId); + if (preceding === undefined) return; + const attempt = Symbol(workspaceId); + pendingWorkspaceRenames.current.set(workspaceId, attempt); + setError(null); + commitWorkspaceMutation({ type: 'workspace_renamed', workspaceId, title: name }); void client.updateWorkspace(workspaceId, { name }) - .catch((caught: Error) => setError(caught.message)); - }, [client]); + .then(({ workspace }) => { + if (pendingWorkspaceRenames.current.get(workspaceId) !== attempt) return; + pendingWorkspaceRenames.current.delete(workspaceId); + const canonical = workspaceFromWire(workspace); + commitWorkspaceMutation({ type: 'workspace_record_updated', record: canonical }); + commitWorkspaceMutation({ + type: 'workspace_renamed', + workspaceId, + title: canonical.name, + }); + }) + .catch((cause: unknown) => { + if (pendingWorkspaceRenames.current.get(workspaceId) !== attempt) return; + pendingWorkspaceRenames.current.delete(workspaceId); + commitWorkspaceMutation({ + type: 'workspace_renamed', + workspaceId, + title: preceding.title, + }); + setError(`Could not rename “${preceding.title}”: ${caughtErrorMessage( + cause, + 'The control plane request failed.', + )}`); + }); + }, [client, commitWorkspaceMutation]); const retryWorkspace = useCallback(async (workspaceId: string) => { const workspace = storeRef.current.workspaces.find(({ id }) => id === workspaceId); @@ -898,18 +1041,56 @@ function CloudAppContent({ client, resolver }: CloudAppProps) { /** * Start the viewer's own machine in the active workspace, from the pane that * replaces the loading spinner while it is stopped (`WorkspaceStoppedState`). - * The refresh is what moves the pane on: the record comes back `creating`, - * the poll hurries for a transitioning workspace, and `running` follows. + * The local row moves the pane on while the start response is pending. Its + * answer commits the machine, then the poll follows later transitions. */ const startMyMachine = useCallback(async () => { const workspace = storeRef.current.workspaces.find(({ id }) => id === activeWorkspaceId); + if (workspace === undefined) throw new Error('The active workspace is unavailable.'); const membershipId = storeRef.current.viewer?.membership.id ?? null; - const machine = workspace?.members + const machine = workspace.members .find((member) => member.membershipId === membershipId)?.machine ?? null; if (machine === null) throw new Error('You have no machine in this workspace to start.'); - await client.startMachine(machine.id); - await refreshWorkspaceRecords(); - }, [activeWorkspaceId, client, refreshWorkspaceRecords]); + pendingMachineStarts.current.add(workspace.id); + commitWorkspaceMutation({ + type: 'workspace_member_machine_updated', + workspaceId: workspace.id, + membershipId: machine.membershipId, + machine: { ...machine, state: 'provisioning', error: null }, + lifecycleStatus: 'creating', + }); + try { + const { machine: canonical } = await client.startMachine(machine.id); + commitWorkspaceMutation({ + type: 'workspace_member_machine_updated', + workspaceId: workspace.id, + membershipId: machine.membershipId, + machine: canonical, + lifecycleStatus: canonical.state === 'running' ? 'running' + : canonical.state === 'stopped' ? 'stopped' + : canonical.state === 'error' ? 'error' + : canonical.state === 'destroying' || canonical.state === 'destroyed' + ? 'destroying' + : 'creating', + }); + pendingMachineStarts.current.delete(workspace.id); + void refreshWorkspaceRecords(); + } catch (caught) { + commitWorkspaceMutation({ + type: 'workspace_member_machine_updated', + workspaceId: workspace.id, + membershipId: machine.membershipId, + machine, + lifecycleStatus: 'stopped', + }); + pendingMachineStarts.current.delete(workspace.id); + setError(`Could not start your machine in “${workspace.title}”: ${caughtErrorMessage( + caught, + 'The control plane request failed.', + )}`); + throw caught; + } + }, [activeWorkspaceId, client, commitWorkspaceMutation, refreshWorkspaceRecords]); const cancelConfirmation = useCallback(() => { setConfirmation(null); @@ -1348,9 +1529,20 @@ function CloudAppContent({ client, resolver }: CloudAppProps) { request: CredentialRequestView, action: 'approve' | 'deny', ) => { - if (action === 'approve') await client.approveCredentialRequest(request.id); - else await client.denyCredentialRequest(request.id); + const index = pendingRequestsRef.current.findIndex(({ id }) => id === request.id); setPendingRequests((current) => current.filter(({ id }) => id !== request.id)); + try { + if (action === 'approve') await client.approveCredentialRequest(request.id); + else await client.denyCredentialRequest(request.id); + } catch (caught) { + setPendingRequests((current) => { + if (current.some(({ id }) => id === request.id)) return current; + const restored = [...current]; + restored.splice(Math.max(index, 0), 0, request); + return restored; + }); + throw caught; + } }, [client]); const activePendingRequests = useMemo( () => pendingRequests.filter(({ workspace_id }) => workspace_id === activeWorkspaceId), @@ -1783,10 +1975,24 @@ function CloudAppContent({ client, resolver }: CloudAppProps) { orgName: store.viewer.org.name || store.viewer.org.slug, }} workspaces={store.workspaces.map(({ id, title, members }) => ({ id, name: title, members }))} + initialError={grantProposalError?.proposalId === grantProposals.active.id + ? grantProposalError.message + : null} onClose={() => { if (grantProposals.active !== null) grantProposals.dismiss(grantProposals.active.id); }} - onResolved={grantProposals.settled} + onResolveStarted={(proposalId) => { + setGrantProposalError(null); + grantProposals.dismiss(proposalId); + }} + onResolveFailed={(proposalId, message) => { + setGrantProposalError({ proposalId, message }); + grantProposals.reopen(proposalId); + }} + onResolved={(proposal) => { + setGrantProposalError(null); + grantProposals.settled(proposal); + }} /> )} {dialogViewer !== null && ; proposal: GrantProposalView; @@ -170,13 +173,18 @@ export function GrantApprovalDialog({ onClose: () => void; /** The server's answer, once the person approved or rejected. */ onResolved: (proposal: GrantProposalView) => void; + /** Hide the pending proposal while its decision is in flight. */ + onResolveStarted?: (proposalId: string) => void; + /** Restore a rejected optimistic decision with its visible refusal. */ + onResolveFailed?: (proposalId: string, message: string) => void; + initialError?: string | null; }) { const closeButton = useRef(null); const [credentials, setCredentials] = useState(null); const [members, setMembers] = useState([]); const [edits, setEdits] = useState(() => initialEdits(proposal)); const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(initialError ?? null); useEffect(() => { closeButton.current?.focus(); }, []); useEffect(() => { @@ -217,6 +225,7 @@ export function GrantApprovalDialog({ if (busy) return; setBusy(true); setError(null); + onResolveStarted?.(proposal.id); try { const response = await client.resolveGrantProposal(proposal.id, { approve, @@ -224,7 +233,9 @@ export function GrantApprovalDialog({ }); onResolved(response.proposal); } catch (caught) { - setError(caughtErrorMessage(caught, approve ? 'Approval failed.' : 'Rejection failed.')); + const message = caughtErrorMessage(caught, approve ? 'Approval failed.' : 'Rejection failed.'); + if (onResolveFailed === undefined) setError(message); + else onResolveFailed(proposal.id, message); } finally { setBusy(false); } diff --git a/packages/webapp/src/InlineComputeCredentialSetup.tsx b/packages/webapp/src/InlineComputeCredentialSetup.tsx index c315ad4f..262cf392 100644 --- a/packages/webapp/src/InlineComputeCredentialSetup.tsx +++ b/packages/webapp/src/InlineComputeCredentialSetup.tsx @@ -54,7 +54,12 @@ function InlineProviderCredential({
-

Add your {computeCredentialProviderTitle(provider)} key

+
+

Add your {computeCredentialProviderTitle(provider)} key

+ {saving && ( + validating + )} +

{details?.detail}

diff --git a/packages/webapp/src/WorkspaceConnectionsPanel.tsx b/packages/webapp/src/WorkspaceConnectionsPanel.tsx index 28c95e52..9b1171de 100644 --- a/packages/webapp/src/WorkspaceConnectionsPanel.tsx +++ b/packages/webapp/src/WorkspaceConnectionsPanel.tsx @@ -50,15 +50,29 @@ export function WorkspaceRequestsPanel({ onConnect?: (connectionName: string) => void; }) { const [resolving, setResolving] = useState(null); + const [pendingRemovals, setPendingRemovals] = useState>(() => new Set()); const [error, setError] = useState(null); + const requestKey = requests.map(({ id }) => id).join(' '); + useEffect(() => { + const serverIds = new Set(requestKey === '' ? [] : requestKey.split(' ')); + setPendingRemovals((current) => new Set( + [...current].filter((id) => serverIds.has(id)), + )); + }, [requestKey]); const resolve = async (request: CredentialRequestView, action: 'approve' | 'deny') => { if (resolving !== null) return; setResolving(request.id); + setPendingRemovals((current) => new Set([...current, request.id])); setError(null); try { await onResolve(request, action); } catch (caught) { + setPendingRemovals((current) => { + const next = new Set(current); + next.delete(request.id); + return next; + }); setError(caughtErrorMessage(caught, `Request ${action} failed.`)); } finally { setResolving(null); @@ -70,7 +84,7 @@ export function WorkspaceRequestsPanel({ {(error ?? loadError) &&

{error ?? loadError}

} {requests.length > 0 && (
- {requests.map((request) => ( + {requests.filter(({ id }) => !pendingRemovals.has(id)).map((request) => (
@@ -227,6 +241,7 @@ export function WorkspaceConnectionsPanel({ ) => Promise; }) { const [connected, noteConnected] = useConnectedProviders(workspaceConnections ?? []); + const [autoResolveError, setAutoResolveError] = useState(null); // Which provider row to open, and a version so asking twice for the same // one re-opens a row the person closed. Two things ask: an agent's // `blitz connections open`, and Connect on a request in the inbox. @@ -239,17 +254,23 @@ export function WorkspaceConnectionsPanel({ ask(connectionsFocus.provider); }, [ask, connectionsFocus]); - /** A provider just became usable here. The inbox entry that asked for it is - * the same question, so answering one answers the other. Nothing is pushed - * at the box: the next token ask reads the allow-list this write just - * changed. */ + /** The local allow-list follows the press while the request is in flight. */ const onConnected = useCallback((connectionName: string) => { noteConnected(connectionName, true); + }, [noteConnected]); + + /** The mint is now authoritative, so the inbox entry can be approved. */ + const onConnectionAcknowledged = useCallback((connectionName: string) => { + setAutoResolveError(null); const pending = pendingRequests.find( (request) => request.connection_name === connectionName, ); - if (pending !== undefined) void onResolveRequest(pending, 'approve'); - }, [noteConnected, onResolveRequest, pendingRequests]); + if (pending !== undefined) { + void onResolveRequest(pending, 'approve').catch((caught) => { + setAutoResolveError(caughtErrorMessage(caught, 'Request approval failed.')); + }); + } + }, [onResolveRequest, pendingRequests]); const onDisconnected = useCallback((connectionName: string) => { noteConnected(connectionName, false); @@ -261,6 +282,9 @@ export function WorkspaceConnectionsPanel({ const wanted = pendingRequests.length > 0 || (pendingRequestsError ?? null) !== null; return (
+ {autoResolveError !== null && ( +

{autoResolveError}

+ )} {wanted && ( <>

Wanted here

@@ -290,6 +314,7 @@ export function WorkspaceConnectionsPanel({ focusVersion={opened?.version ?? 0} readOnly={readOnly} onConnected={onConnected} + onConnectionAcknowledged={onConnectionAcknowledged} onDisconnected={onDisconnected} />
diff --git a/packages/webapp/src/WorkspaceCredentialsTab.tsx b/packages/webapp/src/WorkspaceCredentialsTab.tsx index 35262615..1859654a 100644 --- a/packages/webapp/src/WorkspaceCredentialsTab.tsx +++ b/packages/webapp/src/WorkspaceCredentialsTab.tsx @@ -43,6 +43,7 @@ export function WorkspaceCredentialsTab({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [form, setForm] = useState<{ kind: 'add' } | { kind: 'rotate'; name: string } | null>(null); + const [pendingPut, setPendingPut] = useState(null); const reload = useCallback(async (signal?: AbortSignal) => { try { @@ -77,11 +78,25 @@ export function WorkspaceCredentialsTab({ { credential, path: workspaceReadPath(credential, workspaceId, viewerMembershipId) })); const put = async (input: PutOrgCredentialRequest) => { - await client.putOrgCredential(input); - setForm(null); - await reload(); + setPendingPut(input); + try { + const { credential } = await client.putOrgCredential(input); + setCredentials((current) => { + const index = current.findIndex(({ name }) => name === credential.name); + if (index < 0) return [credential, ...current]; + const next = [...current]; + next[index] = credential; + return next; + }); + setForm(null); + } finally { + setPendingPut(null); + } }; + const pendingAddsRow = pendingPut !== null + && !credentials.some(({ name }) => name === pendingPut.name); + return (