diff --git a/web/src/App.tsx b/web/src/App.tsx index 7ab0798c2..1739cbb8a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -117,7 +117,7 @@ function AppInner() { } }, [goBack, pathname]) const queryClient = useQueryClient() - const sessionMatch = matchRoute({ to: '/sessions/$sessionId' }) + const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true }) const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null const { isSyncing, startSync, endSync } = useSyncingState() const [sseDisconnected, setSseDisconnected] = useState(false) @@ -230,14 +230,30 @@ function AppInner() { }, []) const handleSseEvent = useCallback(() => {}, []) + const isGridRoute = matchRoute({ to: '/grid' }) const handleToast = useCallback((event: ToastEvent) => { + // In the grid parent frame, notify GridView via CustomEvent then suppress the card + if (isGridRoute) { + window.dispatchEvent(new CustomEvent('grid-toast', { detail: { sessionId: event.data.sessionId } })) + return + } + // In grid view iframes, notify the parent frame and filter by session + const isInIframe = window.self !== window.top + if (isInIframe) { + if (event.data.sessionId && selectedSessionId && event.data.sessionId !== selectedSessionId) { + return + } + // Forward to parent GridView + window.parent.postMessage({ type: 'grid-cell-toast', sessionId: event.data.sessionId }, '*') + return + } addToast({ title: event.data.title, body: event.data.body, sessionId: event.data.sessionId, url: event.data.url }) - }, [addToast]) + }, [addToast, selectedSessionId, isGridRoute]) const eventSubscription = useMemo(() => { if (selectedSessionId) { diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 2777c9056..3d14342ac 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -1,6 +1,13 @@ +import { useMemo } from 'react' import { ComposerPrimitive } from '@assistant-ui/react' import type { ConversationStatus } from '@/realtime/types' import { useTranslation } from '@/lib/use-translation' +import type { AgentState, PermissionMode, CodexCollaborationMode } from '@/types/api' +import { getConnectionStatus, getContextWarning } from '@/components/AssistantChat/StatusBar' +import { getContextBudgetTokens } from '@/chat/modelConfig' +import { + getPermissionModeLabel, getPermissionModeTone, getCodexCollaborationModeLabel, isPermissionModeAllowedForFlavor +} from '@hapi/protocol' function VoiceAssistantIcon() { return ( @@ -319,10 +326,52 @@ export function ComposerButtons(props: { onVoiceToggle: () => void onVoiceMicToggle?: () => void onSend: () => void + // Status bar props + active?: boolean + thinking?: boolean + agentState?: AgentState | null + backgroundTaskCount?: number + contextSize?: number + model?: string | null + agentFlavor?: string | null + permissionMode?: PermissionMode + collaborationMode?: CodexCollaborationMode }) { const { t } = useTranslation() const isVoiceConnected = props.voiceStatus === 'connected' + const connectionStatus = useMemo( + () => getConnectionStatus( + props.active ?? true, + props.thinking ?? false, + props.agentState, + props.voiceStatus, + props.backgroundTaskCount ?? 0, + t + ), + [props.active, props.thinking, props.agentState, props.voiceStatus, props.backgroundTaskCount, t] + ) + + const contextWarning = useMemo(() => { + if (props.contextSize === undefined) return null + const max = getContextBudgetTokens(props.model, props.agentFlavor) + if (!max) return null + return getContextWarning(props.contextSize, max, t) + }, [props.contextSize, props.model, props.agentFlavor, t]) + + const permissionToneClasses: Record = { + neutral: 'text-[var(--app-hint)]', info: 'text-blue-500', warning: 'text-amber-500', danger: 'text-red-500' + } + const displayPermissionMode = props.permissionMode + && isPermissionModeAllowedForFlavor(props.permissionMode, props.agentFlavor) + ? props.permissionMode : null + const permissionLabel = displayPermissionMode ? getPermissionModeLabel(displayPermissionMode) : null + const permissionColor = displayPermissionMode && displayPermissionMode !== 'default' + ? (permissionToneClasses[getPermissionModeTone(displayPermissionMode)] ?? 'text-[var(--app-hint)]') + : 'text-[var(--app-hint)]' + const collaborationLabel = props.agentFlavor === 'codex' && props.collaborationMode === 'plan' + ? getCodexCollaborationModeLabel(props.collaborationMode) : null + return (
@@ -404,6 +453,25 @@ export function ComposerButtons(props: { ) : null}
+ {/* Status area: left=dot+status+context, right=mode labels */} +
+
+ + {connectionStatus.text} + {contextWarning ? ( + · {contextWarning.text} + ) : null} +
+
+ {collaborationLabel ? ( + {collaborationLabel} + ) : null} + {permissionLabel ? ( + {permissionLabel} + ) : null} +
+
+ ) => { const target = e.target as HTMLTextAreaElement @@ -754,19 +757,6 @@ export function HappyComposer(props: { {overlays} - -
{attachments.length > 0 ? (
@@ -814,6 +804,15 @@ export function HappyComposer(props: { onVoiceToggle={onVoiceToggle ?? (() => {})} onVoiceMicToggle={onVoiceMicToggle} onSend={handleSend} + active={active} + thinking={thinking} + agentState={agentState} + backgroundTaskCount={backgroundTaskCount} + contextSize={contextSize} + model={model} + agentFlavor={agentFlavor} + permissionMode={permissionMode} + collaborationMode={collaborationMode} />
diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 48e9a57ba..59cb58f84 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -160,6 +160,47 @@ export function HappyThread(props: { onFlushPendingRef.current() }, []) + // Alt+[/] — jump to prev/next message; Alt+Shift+[/] — scroll up/down + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!e.altKey || e.metaKey || e.ctrlKey) return + const isBracketLeft = e.code === 'BracketLeft' + const isBracketRight = e.code === 'BracketRight' + if (!isBracketLeft && !isBracketRight) return + e.preventDefault() + e.stopPropagation() + const viewport = viewportRef.current + if (!viewport) return + + if (e.shiftKey) { + // Jump to prev/next message + const messages = Array.from(viewport.querySelectorAll('.happy-thread-messages > *')) as HTMLElement[] + if (messages.length === 0) return + const scrollTop = viewport.scrollTop + if (isBracketLeft) { + let target: HTMLElement | null = null + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].offsetTop < scrollTop - 8) { target = messages[i]; break } + } + viewport.scrollTo({ top: target ? target.offsetTop : 0, behavior: 'smooth' }) + } else { + for (let i = 0; i < messages.length; i++) { + if (messages[i].offsetTop > scrollTop + 8) { + viewport.scrollTo({ top: messages[i].offsetTop, behavior: 'smooth' }) + break + } + } + } + } else { + // Scroll by ~40% of viewport height + const amount = viewport.clientHeight * 0.4 + viewport.scrollBy({ top: isBracketLeft ? -amount : amount, behavior: 'smooth' }) + } + } + document.addEventListener('keydown', handler, true) + return () => document.removeEventListener('keydown', handler, true) + }, []) + // Reset state when session changes useEffect(() => { setAutoScrollEnabled(true) @@ -280,7 +321,7 @@ export function HappyThread(props: {
-
+