Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a92e7d8
feat(web): add multi-session grid view with keyboard navigation
YiwenZhu77 Apr 16, 2026
f21c0eb
feat(web): inline status and permission mode into composer bottom bar
YiwenZhu77 Apr 16, 2026
8253523
feat(web): add keyboard shortcuts for in-chat scroll and message jump
YiwenZhu77 Apr 16, 2026
0bf055d
fix(web): remove flavor icon from session list; focus window on push …
YiwenZhu77 Apr 16, 2026
50cef38
fix: resolve TypeScript typecheck errors for CI
YiwenZhu77 Apr 16, 2026
162d291
fix: toast isolation and draft storage migration
YiwenZhu77 Apr 16, 2026
31fcba7
feat(web): integrate toast notifications into composer status bar in …
YiwenZhu77 Apr 16, 2026
2198b9b
feat(web): toast notification dot in grid cell overlay
YiwenZhu77 Apr 16, 2026
c80a4c6
fix(test): update composer-drafts tests for localStorage migration
YiwenZhu77 Apr 16, 2026
1f3059f
feat(cli): add /btw and /branch to Claude builtin slash commands
YiwenZhu77 Apr 18, 2026
05e9e6e
fix(web): add /branch and /btw to Claude builtin slash commands in au…
YiwenZhu77 Apr 19, 2026
fa81ddc
fix(web): fix grid empty on load and allow duplicate sessions in repl…
YiwenZhu77 Apr 19, 2026
bfe95f0
feat(web): blue dot in grid overlay when session is outputting/thinking
YiwenZhu77 Apr 19, 2026
86b1e1a
fix(web): use instant scroll for Alt+[/] navigation to fix slow key-r…
YiwenZhu77 Apr 19, 2026
79af830
feat(web): tint grid cell overlay blue while session is thinking/outp…
YiwenZhu77 Apr 19, 2026
1eaaebe
fix(web): only tint text blue when thinking, keep overlay background …
YiwenZhu77 Apr 19, 2026
138f59a
fix(web): don't swallow Cmd+Shift+F/X and Cmd+' outside grid view
YiwenZhu77 Apr 19, 2026
e3d1d76
feat(web): add /effort slash command to change thinking effort level
YiwenZhu77 Apr 19, 2026
b148655
feat(web): double-click title to rename + stop auto-renaming from sum…
YiwenZhu77 Apr 19, 2026
da8a9e6
feat(web): unify dot size, flash orange on thinking→idle transition
YiwenZhu77 Apr 19, 2026
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
2 changes: 2 additions & 0 deletions cli/src/modules/common/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface ListSlashCommandsResponse {
*/
const BUILTIN_COMMANDS: Record<string, SlashCommand[]> = {
claude: [
{ name: 'branch', description: 'Create a new conversation branch', source: 'builtin' },
{ name: 'btw', description: 'Add a note without triggering a response', source: 'builtin' },
{ name: 'clear', description: 'Clear conversation history', source: 'builtin' },
{ name: 'compact', description: 'Compact conversation context', source: 'builtin' },
{ name: 'context', description: 'Show context information', source: 'builtin' },
Expand Down
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: 'instant' })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] behavior: 'instant' is not part of the DOM ScrollBehavior union (auto | smooth). In this strict TypeScript repo that should fail bun typecheck, and browsers only guarantee immediate jumps for behavior: 'auto'. The same invalid value is repeated on the next scrollTo and scrollBy calls below.

Suggested fix:

viewport.scrollTo({ top: target ? target.offsetTop : 0, behavior: 'auto' })
viewport.scrollTo({ top: messages[i].offsetTop, behavior: 'auto' })
viewport.scrollBy({ top: isBracketLeft ? -amount : amount, behavior: 'auto' })

} else {
for (let i = 0; i < messages.length; i++) {
if (messages[i].offsetTop > scrollTop + 8) {
viewport.scrollTo({ top: messages[i].offsetTop, behavior: 'instant' })
break
}
}
}
} else {
// Scroll by ~40% of viewport height
const amount = viewport.clientHeight * 0.4
viewport.scrollBy({ top: isBracketLeft ? -amount : amount, behavior: 'instant' })
}
}
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