Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
68 changes: 68 additions & 0 deletions web/src/components/AssistantChat/ComposerButtons.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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<string, string> = {
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 (
<div className="flex items-center justify-between px-2 pb-2">
<div className="flex items-center gap-1">
Expand Down Expand Up @@ -404,6 +453,25 @@ export function ComposerButtons(props: {
) : null}
</div>

{/* Status area: left=dot+status+context, right=mode labels */}
<div className="flex items-center justify-between mx-2 min-w-0 flex-1">
<div className="flex items-center gap-1 min-w-0">
<span className={`h-1.5 w-1.5 rounded-full flex-shrink-0 ${connectionStatus.dotColor} ${connectionStatus.isPulsing ? 'animate-pulse' : ''}`} />
<span className={`text-[10px] leading-none truncate ${connectionStatus.color}`}>{connectionStatus.text}</span>
{contextWarning ? (
<span className={`text-[10px] leading-none whitespace-nowrap flex-shrink-0 ${contextWarning.color}`}>· {contextWarning.text}</span>
) : null}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
{collaborationLabel ? (
<span className="text-[10px] leading-none text-blue-500">{collaborationLabel}</span>
) : null}
{permissionLabel ? (
<span className={`text-[10px] leading-none ${permissionColor}`}>{permissionLabel}</span>
) : null}
</div>
</div>

<UnifiedButton
canSend={props.canSend}
voiceStatus={props.voiceStatus}
Expand Down
29 changes: 14 additions & 15 deletions web/src/components/AssistantChat/HappyComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { markSkillUsed } from '@/lib/recent-skills'
import { useComposerDraft } from '@/hooks/useComposerDraft'
import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay'
import { Autocomplete } from '@/components/ChatInput/Autocomplete'
import { StatusBar } from '@/components/AssistantChat/StatusBar'
import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons'
import { AttachmentItem } from '@/components/AssistantChat/AttachmentItem'
import { useTranslation } from '@/lib/use-translation'
Expand Down Expand Up @@ -400,7 +399,11 @@ export function HappyComposer(props: {
end: e.target.selectionEnd
}
setInputState({ text: e.target.value, selection })
}, [])
// Notify parent GridView to clear notification dot when user starts typing
if (window.self !== window.top && sessionId) {
window.parent.postMessage({ type: 'grid-cell-typing', sessionId }, '*')
}
}, [sessionId])

const handleSelect = useCallback((e: ReactSyntheticEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement
Expand Down Expand Up @@ -754,19 +757,6 @@ export function HappyComposer(props: {
<ComposerPrimitive.Root className="relative" onSubmit={handleSubmit}>
{overlays}

<StatusBar
active={active}
thinking={thinking}
agentState={agentState}
backgroundTaskCount={backgroundTaskCount}
contextSize={contextSize}
model={model}
permissionMode={permissionMode}
collaborationMode={collaborationMode}
agentFlavor={agentFlavor}
voiceStatus={voiceStatus}
/>

<div className="overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)]">
{attachments.length > 0 ? (
<div className="flex flex-wrap gap-2 px-4 pt-3">
Expand Down Expand Up @@ -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}
/>
</div>
</ComposerPrimitive.Root>
Expand Down
43 changes: 42 additions & 1 deletion web/src/components/AssistantChat/HappyThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -280,7 +321,7 @@ export function HappyThread(props: {
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col relative">
<ThreadPrimitive.Viewport asChild autoScroll={autoScrollEnabled}>
<div ref={viewportRef} className="app-scroll-y min-h-0 flex-1 overflow-x-hidden">
<div className="mx-auto w-full max-w-content min-w-0 p-3">
<div className={`w-full min-w-0 p-3 ${window.self !== window.top ? '' : 'mx-auto max-w-content'}`}>
<div ref={topSentinelRef} className="h-px w-full" aria-hidden="true" />
{showSkeleton ? (
<MessageSkeleton />
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/AssistantChat/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const PERMISSION_TONE_CLASSES: Record<PermissionModeTone, string> = {
danger: 'text-red-500'
}

function getConnectionStatus(
export function getConnectionStatus(
active: boolean,
thinking: boolean,
agentState: AgentState | null | undefined,
Expand Down Expand Up @@ -102,7 +102,7 @@ function getConnectionStatus(
}
}

function getContextWarning(contextSize: number, maxContextSize: number, t: (key: string, params?: Record<string, string | number>) => string): { text: string; color: string } | null {
export function getContextWarning(contextSize: number, maxContextSize: number, t: (key: string, params?: Record<string, string | number>) => string): { text: string; color: string } | null {
const percentageUsed = (contextSize / maxContextSize) * 100
const percentageRemaining = Math.max(0, 100 - percentageUsed)

Expand Down
Loading
Loading