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(