From 83e8650949705437939b0896f023495ed42875d2 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:05:21 -0400 Subject: [PATCH 1/4] fix(permissions): clear expired permission requests --- frontend/src/contexts/EventContext.test.tsx | 19 +++++++++++++++++++ frontend/src/contexts/EventContext.tsx | 8 +++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx index ad99aca99..811217369 100644 --- a/frontend/src/contexts/EventContext.test.tsx +++ b/frontend/src/contexts/EventContext.test.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from 'react' import { MemoryRouter, useLocation } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { FormInfo, PermissionRequest } from '@opencode-manager/shared/opencode' +import { FetchError } from '@opencode-manager/shared' import { useSessionStatus } from '@/stores/sessionStatusStore' import { changeWalkthroughQueryKey } from '@/hooks/useChangeWalkthrough' import { EventProvider, useEventContext, useForms, usePermissions, useSSEHealth } from './EventContext' @@ -248,6 +249,24 @@ describe('EventProvider permissions and forms', () => { }) }) + it('removes a permission the server no longer knows about when replying', async () => { + mocks.listPendingPermissions.mockResolvedValue([pendingPermission]) + mocks.replyPermission.mockRejectedValue(new FetchError('Permission request not found', 404, 'PermissionNotFoundError')) + + render(, { wrapper: createWrapper() }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync Permissions' })) + + await waitFor(() => expect(screen.getByTestId('permission-count')).toHaveTextContent('1')) + + await userEvent.click(screen.getByRole('button', { name: 'Reject Permission' })) + + await waitFor(() => { + expect(screen.getByTestId('permission-count')).toHaveTextContent('0') + expect(screen.getByTestId('permission-current')).toHaveTextContent('none') + }) + }) + it('forwards an optional rejection message to the facade', async () => { mocks.listPendingPermissions.mockResolvedValue([pendingPermission]) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index 926a6d850..1b8574a77 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -11,6 +11,7 @@ import { replyPermission, } from '@/api/opencode' import { listRepos } from '@/api/repos' +import { FetchError } from '@opencode-manager/shared' import type { FormAnswer, FormInfo, PermissionRequest, V2Event } from '@opencode-manager/shared/opencode' import type { PermissionResponse, SSHHostKeyRequest, Repo } from '@/api/types' import { showToast } from '@/lib/toast' @@ -284,7 +285,12 @@ export function EventProvider({ children }: { children: React.ReactNode }) { response: PermissionResponse, message?: string, ) => { - await replyPermission(sessionID, permissionID, response, message) + try { + await replyPermission(sessionID, permissionID, response, message) + } catch (error) { + if (!(error instanceof FetchError && error.statusCode === 404)) throw error + showToast.info('Permission request expired') + } removePermission(permissionID, sessionID) }, [removePermission]) From 2b12fc34cb06c83b3fc02ec7604d3956c5e52df3 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:05:33 -0400 Subject: [PATCH 2/4] style(worktree): drop the source badge corner radius --- frontend/src/components/repo/WorktreeSessionGroups.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/repo/WorktreeSessionGroups.tsx b/frontend/src/components/repo/WorktreeSessionGroups.tsx index caf97543d..6617639a3 100644 --- a/frontend/src/components/repo/WorktreeSessionGroups.tsx +++ b/frontend/src/components/repo/WorktreeSessionGroups.tsx @@ -179,7 +179,7 @@ export function WorktreeSessionGroups({
{label} {sourceLabel && source && ( - {sourceLabel} + {sourceLabel} )} {isInUse && ( From 041ca200f1f0545ef99634186c2380e32e19e440 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:22:50 -0400 Subject: [PATCH 3/4] fix(permissions): unify expired pending-action handling --- frontend/src/contexts/EventContext.test.tsx | 78 +++++++++++++++++++-- frontend/src/contexts/EventContext.tsx | 46 +++++++++--- 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx index 811217369..b0f775970 100644 --- a/frontend/src/contexts/EventContext.test.tsx +++ b/frontend/src/contexts/EventContext.test.tsx @@ -1,11 +1,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import type { ReactNode } from 'react' +import { useState, type ReactNode } from 'react' import { MemoryRouter, useLocation } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { FormInfo, PermissionRequest } from '@opencode-manager/shared/opencode' import { FetchError } from '@opencode-manager/shared' +import { showToast } from '@/lib/toast' import { useSessionStatus } from '@/stores/sessionStatusStore' import { changeWalkthroughQueryKey } from '@/hooks/useChangeWalkthrough' import { EventProvider, useEventContext, useForms, usePermissions, useSSEHealth } from './EventContext' @@ -96,9 +97,14 @@ function Harness() { const { current, pendingCount, syncForSession, navigateToCurrent, cancel, reply, getForSession } = useForms() const permissions = usePermissions() const location = useLocation() + const [rejection, setRejection] = useState('none') + const recordRejection = (action: Promise) => { + action.catch((error: unknown) => setRejection(error instanceof FetchError ? error.code ?? 'unknown' : 'unknown')) + } return (
+
{rejection}
{pendingCount}
{current?.id ?? 'none'}
{getForSession('session-1')?.id ?? 'none'}
@@ -117,9 +123,9 @@ function Harness() { - - - + + +
) @@ -265,6 +271,70 @@ describe('EventProvider permissions and forms', () => { expect(screen.getByTestId('permission-count')).toHaveTextContent('0') expect(screen.getByTestId('permission-current')).toHaveTextContent('none') }) + expect(showToast.info).toHaveBeenCalledWith('Permission request expired') + expect(screen.getByTestId('rejection')).toHaveTextContent('none') + }) + + it('keeps a permission and rejects when the reply fails for another reason', async () => { + mocks.listPendingPermissions.mockResolvedValue([pendingPermission]) + mocks.replyPermission.mockRejectedValue(new FetchError('Session not found', 404, 'SessionNotFoundError')) + + render(, { wrapper: createWrapper() }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync Permissions' })) + + await waitFor(() => expect(screen.getByTestId('permission-count')).toHaveTextContent('1')) + + await userEvent.click(screen.getByRole('button', { name: 'Reject Permission' })) + + await waitFor(() => expect(screen.getByTestId('rejection')).toHaveTextContent('SessionNotFoundError')) + expect(screen.getByTestId('permission-count')).toHaveTextContent('1') + expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-1') + expect(showToast.info).not.toHaveBeenCalled() + }) + + it.each([ + ['Reply', 'replyForm'], + ['Dismiss', 'cancelForm'], + ] as const)('removes a form the server no longer knows about on %s', async (button, mock) => { + mocks.listPendingForms.mockResolvedValue([pendingForm]) + mocks[mock].mockRejectedValue(new FetchError('Form not found', 404, 'FormNotFoundError')) + + render(, { wrapper: createWrapper() }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + + await waitFor(() => expect(screen.getByTestId('count')).toHaveTextContent('1')) + + await userEvent.click(screen.getByRole('button', { name: button })) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('0') + expect(screen.getByTestId('current')).toHaveTextContent('none') + }) + expect(showToast.info).toHaveBeenCalledWith('Form expired') + expect(screen.getByTestId('rejection')).toHaveTextContent('none') + }) + + it.each([ + ['Reply', 'replyForm'], + ['Dismiss', 'cancelForm'], + ] as const)('keeps a form and rejects when %s fails for another reason', async (button, mock) => { + mocks.listPendingForms.mockResolvedValue([pendingForm]) + mocks[mock].mockRejectedValue(new FetchError('Form already settled', 409, 'FormAlreadySettledError')) + + render(, { wrapper: createWrapper() }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + + await waitFor(() => expect(screen.getByTestId('count')).toHaveTextContent('1')) + + await userEvent.click(screen.getByRole('button', { name: button })) + + await waitFor(() => expect(screen.getByTestId('rejection')).toHaveTextContent('FormAlreadySettledError')) + expect(screen.getByTestId('count')).toHaveTextContent('1') + expect(screen.getByTestId('current')).toHaveTextContent('form-1') + expect(showToast.info).not.toHaveBeenCalled() }) it('forwards an optional rejection message to the facade', async () => { diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index 1b8574a77..bcef20aa2 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -95,6 +95,11 @@ interface EventContextValue { permissions: { current: PermissionRequest | null pendingCount: number + /** + * Replies to a permission request and removes it from the queue. Resolves, after showing an + * "expired" toast, when the server no longer knows the request (`PermissionNotFoundError`); + * rejects on any other failure and keeps the request queued. + */ respond: ( permissionID: string, sessionID: string, @@ -112,7 +117,15 @@ interface EventContextValue { forms: { current: FormInfo | null pendingCount: number + /** + * Answers a form and removes it from the queue. Resolves, after showing an "expired" toast, + * when the server no longer knows the form (`FormNotFoundError`); rejects on any other failure. + */ reply: (formID: string, answer: FormAnswer) => Promise + /** + * Dismisses a form on the server and removes it from the queue, with the same + * resolve-on-`FormNotFoundError` contract as `reply`. + */ cancel: (formID: string) => Promise dismiss: (formID: string, sessionID?: string) => void getForSession: (sessionID: string) => FormInfo | null @@ -124,6 +137,24 @@ interface EventContextValue { getRepoIdForSession: (sessionID: string) => number | null } +/** + * Sends a reply to a pending server request and treats the request as settled when the server + * reports it no longer exists (`goneCode`), showing `expiredMessage` instead of rejecting. + * Every other failure is rethrown. + */ +async function settlePendingReply( + reply: () => Promise, + goneCode: string, + expiredMessage: string, +): Promise { + try { + await reply() + } catch (error) { + if (!(error instanceof FetchError && error.code === goneCode)) throw error + showToast.info(expiredMessage) + } +} + const EventContext = createContext(null) export function EventProvider({ children }: { children: React.ReactNode }) { @@ -285,26 +316,25 @@ export function EventProvider({ children }: { children: React.ReactNode }) { response: PermissionResponse, message?: string, ) => { - try { - await replyPermission(sessionID, permissionID, response, message) - } catch (error) { - if (!(error instanceof FetchError && error.statusCode === 404)) throw error - showToast.info('Permission request expired') - } + await settlePendingReply( + () => replyPermission(sessionID, permissionID, response, message), + 'PermissionNotFoundError', + 'Permission request expired', + ) removePermission(permissionID, sessionID) }, [removePermission]) const replyToForm = useCallback(async (formID: string, answer: FormAnswer) => { const form = Object.values(formsBySession).flat().find(f => f.id === formID) if (!form) throw new Error('Form not found') - await replyForm(form.sessionID, formID, answer) + await settlePendingReply(() => replyForm(form.sessionID, formID, answer), 'FormNotFoundError', 'Form expired') removeForm(formID, form.sessionID) }, [formsBySession, removeForm]) const cancelPendingForm = useCallback(async (formID: string) => { const form = Object.values(formsBySession).flat().find(f => f.id === formID) if (!form) throw new Error('Form not found') - await cancelForm(form.sessionID, formID) + await settlePendingReply(() => cancelForm(form.sessionID, formID), 'FormNotFoundError', 'Form expired') removeForm(formID, form.sessionID) }, [formsBySession, removeForm]) From fa0cde5d515af2013bf8574a6cd396d3280d35e4 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:43:37 -0400 Subject: [PATCH 4/4] test(permissions): lock the declared error tag as FetchError.code --- frontend/src/api/opencode.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/src/api/opencode.test.ts b/frontend/src/api/opencode.test.ts index 87bb967c4..53026b53c 100644 --- a/frontend/src/api/opencode.test.ts +++ b/frontend/src/api/opencode.test.ts @@ -392,6 +392,21 @@ describe('OpenCode facade', () => { expect((failure as FetchError).message).toBe('Session not found') }) + it('preserves the declared error tag as FetchError.code', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ _tag: 'PermissionNotFoundError', sessionID: 'ses_1', requestID: 'per_1', message: 'Permission request not found' }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ), + ) + + const failure = await replyPermission('ses_1', 'per_1', 'once').catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(FetchError) + expect((failure as FetchError).statusCode).toBe(404) + expect((failure as FetchError).code).toBe('PermissionNotFoundError') + }) + it('maps a declared V2 conflict error to 409', async () => { fetchMock.mockResolvedValue( new Response(