From 4d18789d500cb7de65b5e23f0bb3ec0367a4a53a Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 3 Jan 2026 22:49:40 +0800 Subject: [PATCH] refactor: extract permission mode utilities and consolidate styling logic Extract permission mode display logic into shared utilities for better reusability and maintainability. Add PermissionModeTone type and related helpers to centralize mode-based styling rules across components. Update components to use new PermissionModeOption type for consistent permission mode presentation. --- server/src/web/routes/sessions.ts | 9 ++--- shared/src/modes.ts | 34 +++++++++++++++++++ shared/src/types.ts | 4 ++- .../AssistantChat/HappyComposer.tsx | 28 ++++++++------- .../components/AssistantChat/StatusBar.tsx | 30 +++++++++------- web/src/components/SessionChat.tsx | 9 +++-- web/src/components/SessionHeader.tsx | 6 +++- web/src/components/SessionList.tsx | 6 +++- web/src/hooks/mutations/useSessionActions.ts | 11 +++++- 9 files changed, 102 insertions(+), 35 deletions(-) diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index c6f1f6c0..becc9fb1 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -1,4 +1,4 @@ -import { isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor } from '@hapi/protocol' +import { getPermissionModesForFlavor, isModelModeAllowedForFlavor } from '@hapi/protocol' import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas' import { Hono } from 'hono' import { z } from 'zod' @@ -185,11 +185,12 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho const flavor = sessionResult.session.metadata?.flavor ?? 'claude' const mode = parsed.data.mode - if (flavor === 'gemini') { - return c.json({ error: 'Permission mode not supported for Gemini sessions' }, 400) + const allowedModes = getPermissionModesForFlavor(flavor) + if (allowedModes.length === 0) { + return c.json({ error: 'Permission mode not supported for session flavor' }, 400) } - if (!isPermissionModeAllowedForFlavor(mode, flavor)) { + if (!allowedModes.includes(mode)) { return c.json({ error: 'Invalid permission mode for session flavor' }, 400) } diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 10294c47..d4c1696d 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -30,12 +30,38 @@ export const PERMISSION_MODE_LABELS: Record = { yolo: 'Yolo' } +export type PermissionModeTone = 'neutral' | 'info' | 'warning' | 'danger' + +export const PERMISSION_MODE_TONES: Record = { + default: 'neutral', + acceptEdits: 'warning', + plan: 'info', + bypassPermissions: 'danger', + 'read-only': 'warning', + 'safe-yolo': 'warning', + yolo: 'danger' +} + +export type PermissionModeOption = { + mode: PermissionMode + label: string + tone: PermissionModeTone +} + export const MODEL_MODE_LABELS: Record = { default: 'Default', sonnet: 'Sonnet', opus: 'Opus' } +export function getPermissionModeLabel(mode: PermissionMode): string { + return PERMISSION_MODE_LABELS[mode] +} + +export function getPermissionModeTone(mode: PermissionMode): PermissionModeTone { + return PERMISSION_MODE_TONES[mode] +} + export function getPermissionModesForFlavor(flavor?: string | null): readonly PermissionMode[] { if (flavor === 'codex') { return CODEX_PERMISSION_MODES @@ -46,6 +72,14 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe return CLAUDE_PERMISSION_MODES } +export function getPermissionModeOptionsForFlavor(flavor?: string | null): PermissionModeOption[] { + return getPermissionModesForFlavor(flavor).map((mode) => ({ + mode, + label: getPermissionModeLabel(mode), + tone: getPermissionModeTone(mode) + })) +} + export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: string | null): boolean { return getPermissionModesForFlavor(flavor).includes(mode) } diff --git a/shared/src/types.ts b/shared/src/types.ts index 7b7aec34..8cdaa04d 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -15,5 +15,7 @@ export type { ClaudePermissionMode, CodexPermissionMode, ModelMode, - PermissionMode + PermissionMode, + PermissionModeOption, + PermissionModeTone } from './modes' diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index f2fe6021..329d9852 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -1,4 +1,4 @@ -import { getPermissionModesForFlavor, MODEL_MODE_LABELS, MODEL_MODES, PERMISSION_MODE_LABELS } from '@hapi/protocol' +import { getPermissionModeOptionsForFlavor, MODEL_MODE_LABELS, MODEL_MODES } from '@hapi/protocol' import { ComposerPrimitive, useAssistantApi, useAssistantState } from '@assistant-ui/react' import { type ChangeEvent as ReactChangeEvent, @@ -207,10 +207,14 @@ export function HappyComposer(props: { } }, [switchDisabled, onSwitchToRemote, haptic]) - const permissionModes = useMemo( - () => getPermissionModesForFlavor(agentFlavor), + const permissionModeOptions = useMemo( + () => getPermissionModeOptionsForFlavor(agentFlavor), [agentFlavor] ) + const permissionModes = useMemo( + () => permissionModeOptions.map((option) => option.mode), + [permissionModeOptions] + ) const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { const key = e.key @@ -327,7 +331,7 @@ export function HappyComposer(props: { haptic('light') }, [onModelModeChange, controlsDisabled, haptic]) - const showPermissionSettings = Boolean(onPermissionModeChange && permissionModes.length > 0) + const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) const showModelSettings = Boolean(onModelModeChange && agentFlavor !== 'codex' && agentFlavor !== 'gemini') const showSettingsButton = Boolean(showPermissionSettings || showModelSettings) const showAbortButton = true @@ -342,9 +346,9 @@ export function HappyComposer(props: {
Permission Mode
- {permissionModes.map((mode) => ( + {permissionModeOptions.map((option) => ( ))} @@ -443,7 +447,7 @@ export function HappyComposer(props: { controlsDisabled, permissionMode, modelMode, - permissionModes, + permissionModeOptions, handlePermissionChange, handleModelChange, handleSuggestionSelect diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index dd21c8dd..0cb4928e 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -1,4 +1,5 @@ -import { PERMISSION_MODE_LABELS } from '@hapi/protocol' +import { getPermissionModeLabel, getPermissionModeTone, isPermissionModeAllowedForFlavor } from '@hapi/protocol' +import type { PermissionModeTone } from '@hapi/protocol' import { useMemo } from 'react' import type { AgentState, ModelMode, PermissionMode } from '@/types/api' import { getContextBudgetTokens } from '@/chat/modelConfig' @@ -22,6 +23,13 @@ const VIBING_MESSAGES = [ "Wibbling", "Wizarding", "Working", "Wrangling" ] +const PERMISSION_TONE_CLASSES: Record = { + neutral: 'text-[var(--app-hint)]', + info: 'text-blue-500', + warning: 'text-amber-500', + danger: 'text-red-500' +} + function getConnectionStatus( active: boolean, thinking: boolean, @@ -103,12 +111,16 @@ export function StatusBar(props: { ) const permissionMode = props.permissionMode - const displayPermissionMode = props.agentFlavor !== 'gemini' - && permissionMode + const displayPermissionMode = permissionMode && permissionMode !== 'default' + && isPermissionModeAllowedForFlavor(permissionMode, props.agentFlavor) ? permissionMode : null + const permissionModeLabel = displayPermissionMode ? getPermissionModeLabel(displayPermissionMode) : null + const permissionModeTone = displayPermissionMode ? getPermissionModeTone(displayPermissionMode) : null + const permissionModeColor = permissionModeTone ? PERMISSION_TONE_CLASSES[permissionModeTone] : 'text-[var(--app-hint)]' + return (
@@ -128,16 +140,8 @@ export function StatusBar(props: {
{displayPermissionMode ? ( - - {PERMISSION_MODE_LABELS[displayPermissionMode]} + + {permissionModeLabel} ) : null}
diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 24523949..f9d2eb9c 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -36,7 +36,12 @@ export function SessionChat(props: { const controlsDisabled = !props.session.active const normalizedCacheRef = useRef>(new Map()) const blocksByIdRef = useRef>(new Map()) - const { abortSession, switchSession, setPermissionMode, setModelMode } = useSessionActions(props.api, props.session.id) + const agentFlavor = props.session.metadata?.flavor ?? null + const { abortSession, switchSession, setPermissionMode, setModelMode } = useSessionActions( + props.api, + props.session.id, + agentFlavor + ) useEffect(() => { normalizedCacheRef.current.clear() @@ -179,7 +184,7 @@ export function SessionChat(props: { disabled={props.isSending || controlsDisabled} permissionMode={props.session.permissionMode} modelMode={props.session.modelMode} - agentFlavor={props.session.metadata?.flavor ?? 'claude'} + agentFlavor={agentFlavor} active={props.session.active} thinking={props.session.thinking} agentState={props.session.agentState} diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index da6f1b06..e111b69a 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -74,7 +74,11 @@ export function SessionHeader(props: { const [archiveOpen, setArchiveOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) - const { archiveSession, renameSession, deleteSession, isPending } = useSessionActions(api, session.id) + const { archiveSession, renameSession, deleteSession, isPending } = useSessionActions( + api, + session.id, + session.metadata?.flavor ?? null + ) const handleDelete = async () => { await deleteSession() diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 996da4b6..ddfbe192 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -177,7 +177,11 @@ function SessionItem(props: { const [archiveOpen, setArchiveOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) - const { archiveSession, renameSession, deleteSession, isPending } = useSessionActions(api, s.id) + const { archiveSession, renameSession, deleteSession, isPending } = useSessionActions( + api, + s.id, + s.metadata?.flavor ?? null + ) const longPressHandlers = useLongPress({ onLongPress: () => { diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 357d7219..62b832a6 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -1,9 +1,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' +import { isPermissionModeAllowedForFlavor } from '@hapi/protocol' import type { ApiClient } from '@/api/client' import type { ModelMode, PermissionMode } from '@/types/api' import { queryKeys } from '@/lib/query-keys' -export function useSessionActions(api: ApiClient | null, sessionId: string | null): { +export function useSessionActions( + api: ApiClient | null, + sessionId: string | null, + agentFlavor?: string | null +): { abortSession: () => Promise archiveSession: () => Promise switchSession: () => Promise @@ -56,6 +61,10 @@ export function useSessionActions(api: ApiClient | null, sessionId: string | nul if (!api || !sessionId) { throw new Error('Session unavailable') } + const isKnownFlavor = agentFlavor === 'claude' || agentFlavor === 'codex' || agentFlavor === 'gemini' + if (isKnownFlavor && !isPermissionModeAllowedForFlavor(mode, agentFlavor)) { + throw new Error('Invalid permission mode for session flavor') + } await api.setPermissionMode(sessionId, mode) }, onSuccess: () => void invalidateSession(),