From 7666b3fbe8b5727691ab83f45adfbbf64139bec7 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 4 Sep 2026 23:47:11 -0700 Subject: [PATCH 1/2] feat(webapp): reconcile optimistic credential rows --- packages/webapp/src/GrantApprovalDialog.tsx | 15 +- .../src/InlineComputeCredentialSetup.tsx | 7 +- .../webapp/src/WorkspaceConnectionsPanel.tsx | 39 +++- .../webapp/src/WorkspaceCredentialsTab.tsx | 37 ++- .../src/connections/WorkspaceProviderRows.tsx | 39 +++- .../src/settings/ComputeCredentialsPanel.tsx | 28 ++- .../webapp/src/settings/ConnectionsPanel.tsx | 9 +- packages/webapp/src/settings/InvitesPanel.tsx | 23 ++ packages/webapp/src/settings/MembersPanel.tsx | 36 ++- .../src/settings/OrgCredentialImport.tsx | 10 +- .../src/settings/OrgCredentialsPanel.tsx | 90 ++++++- .../webapp/src/settings/RequestsPanel.tsx | 7 +- packages/webapp/src/settings/UsagePanel.tsx | 9 +- .../test/WorkspaceDetailsDialog.test.tsx | 57 ++++- .../webapp/test/admin-connections.test.tsx | 39 +++- .../webapp/test/compute-credentials.test.tsx | 91 +++++++- .../test/credentials-surfaces-v2.test.tsx | 220 ++++++++++++++++++ .../test/grant-approval-dialog.test.tsx | 43 +++- .../test/org-credentials-panel.test.tsx | 125 +++++++++- packages/webapp/test/recipes.test.tsx | 25 ++ packages/webapp/test/seat-paywall.test.tsx | 29 +++ 21 files changed, 921 insertions(+), 57 deletions(-) diff --git a/packages/webapp/src/GrantApprovalDialog.tsx b/packages/webapp/src/GrantApprovalDialog.tsx index 94775342..351bfeae 100644 --- a/packages/webapp/src/GrantApprovalDialog.tsx +++ b/packages/webapp/src/GrantApprovalDialog.tsx @@ -161,6 +161,9 @@ export function GrantApprovalDialog({ workspaces, onClose, onResolved, + onResolveStarted, + onResolveFailed, + initialError, }: { client: Pick; 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 (