feat(web): multi-session grid view, composer status bar, in-chat keyboard shortcuts#484
feat(web): multi-session grid view, composer status bar, in-chat keyboard shortcuts#484YiwenZhu77 wants to merge 20 commits intotiann:mainfrom
Conversation
- Add GridView component: 1-6 sessions displayed in adaptive grid (1×1, 2×1, 3×1, 2×2, 3+2, 3×2) or strip mode (all in one row) - Add SessionSearchModal for quickly adding/replacing sessions in grid - Add useGlobalKeyboard hook for global shortcuts (Cmd+;, Cmd+K, Cmd+Shift+F, Cmd+Shift+X, Cmd+1-9, Cmd+', Alt+hjkl) - Grid iframes share localStorage composer drafts (was sessionStorage) - Hide SessionHeader inside iframes to save vertical space - Floating overlay pill in each cell: title, folder, flavor, close btn - Toast notifications filtered per-session in grid iframes - Sidebar forced hidden in grid iframes regardless of viewport width - Route /grid added; grid icon + shortcut hint in sessions header via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
Move the status dot (online/thinking/offline), context window percentage, and permission mode label from the standalone StatusBar above the composer into the composer's bottom icon row. Status info sits between the action icons and the send button; permission/collaboration mode labels appear on the right side of that area. This saves a line of vertical space and keeps all composer meta-info in one place, which is especially beneficial in grid-view iframes. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
Alt+[ / Alt+] — scroll up/down ~40% of viewport height Alt+Shift+[ / Alt+Shift+] — jump to previous/next message Uses e.code (not e.key) to avoid macOS Option-key special characters. Registered with capture:true so it intercepts while typing in the composer textarea. Works in both standalone and grid-iframe contexts. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
…click - Remove the colored flavor badge (Cl/Cx/Gm…) from each session row in the sessions list — reduces visual noise, info is available elsewhere - Service worker: on push notification click, navigate and focus an existing PWA window instead of always opening a new one via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Review mode: initial
Findings
- [Major] Grid iframe toast filtering falls back to all-session toasts on nested session routes because
selectedSessionIdis matched withoutfuzzy: trueinweb/src/App.tsx:120, but the new filter inweb/src/App.tsx:239depends on it staying populated. - [Major] The draft-storage switch reads and writes only
localStorageinweb/src/lib/composer-drafts.ts:23andweb/src/lib/composer-drafts.ts:63, so any existing unsent drafts still stored insessionStoragedisappear after deploy.
Summary
Review mode: initial
Found 2 issues on the latest head: one per-session toast isolation regression in grid iframes, and one draft data-loss regression from the storage migration.
Testing
Not run (automation): bun is not available in this runner. Existing coverage in web/src/lib/composer-drafts.test.ts still targets sessionStorage, so the new storage path is not validated here.
HAPI Bot
| if (isGridRoute) return | ||
| // In grid view iframes, only show toasts for the session this iframe is displaying | ||
| const isInIframe = window.self !== window.top | ||
| if (isInIframe && event.data.sessionId && selectedSessionId && event.data.sessionId !== selectedSessionId) { |
There was a problem hiding this comment.
[MAJOR] This filter depends on selectedSessionId, but the route match above is exact-only (matchRoute({ to: '/sessions/$sessionId' })). Inside a grid iframe, navigating to /sessions/:id/files or /sessions/:id/terminal makes selectedSessionId go back to null, so this branch stops filtering and the iframe starts showing toasts for every session again.
Suggested fix:
const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true })
const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new'
? sessionMatch.sessionId
: null| } | ||
| try { | ||
| const raw = sessionStorage.getItem(STORAGE_KEY) | ||
| const raw = localStorage.getItem(STORAGE_KEY) |
There was a problem hiding this comment.
[MAJOR] This switches the draft store to localStorage without migrating the existing sessionStorage payload. On the first load after deploy, localStorage is empty, hydrate() initializes an empty cache, and the next save overwrites the old draft set instead of carrying it forward. That drops unsent user drafts.
Suggested fix:
const localRaw = localStorage.getItem(STORAGE_KEY)
const sessionRaw = sessionStorage.getItem(STORAGE_KEY)
const raw = localRaw ?? sessionRaw
if (!localRaw && sessionRaw) {
localStorage.setItem(STORAGE_KEY, sessionRaw)
sessionStorage.removeItem(STORAGE_KEY)
}- GridView/SessionSearchModal: use SessionSummary[] instead of Session[]
(useSessions returns SessionSummary, not full Session)
- useGlobalKeyboard: widen sessions param to { id: string }[]
(only session.id is accessed in the hook)
- Fix actionsRef initial shape: rename closeCurrentCell→closeCell,
add optional idx param to openReplaceModal
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
- App.tsx: use fuzzy:true in matchRoute so selectedSessionId stays populated on nested session routes (/files, /terminal, etc.) - composer-drafts.ts: migrate sessionStorage→localStorage on first load so existing unsent drafts are not dropped after deploy via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Major] Grid state is initialized only from the first render, so a direct visit or refresh on
/gridnever auto-pins the active sessions afteruseSessions()resolves. The page stays stuck on "No sessions pinned" even when active sessions exist. Evidenceweb/src/components/GridView.tsx:58,web/src/hooks/queries/useSessions.ts:20.
Suggested fix:const initializedPinnedRef = useRef(false) useEffect(() => { if (initializedPinnedRef.current || sessions.length === 0) return initializedPinnedRef.current = true setPinnedIds(sessions.filter(s => s.active).slice(0, 4).map(s => s.id)) }, [sessions])
- [Major] The replace modal passes the full session list, so it can pin a session ID that is already present in another cell. Once that happens,
removeSession/closeCellremove by ID and drop every duplicate cell at once. Evidenceweb/src/components/GridView.tsx:125,web/src/components/GridView.tsx:137,web/src/components/GridView.tsx:390.
Suggested fix:const replaceCandidates = sessions.filter((s) => s.id === pinnedIds[replaceTargetIdx ?? -1] || !pinnedIds.includes(s.id) ) <SessionSearchModal sessions={replaceCandidates} ... />
Summary
Review mode: follow-up after new commits
Found 2 issues on the latest head: fresh /grid loads do not seed active sessions after the async fetch, and the replace flow can duplicate pinned session IDs, which makes close/remove collapse multiple cells at once.
Testing
Not run (automation): bun is not installed in this runner, so repo tests/typecheck could not be executed.
HAPI Bot
|
|
||
| export function GridView({ sessions, baseUrl, token }: Props) { | ||
| const navigate = useNavigate() | ||
| const [pinnedIds, setPinnedIds] = useState<string[]>(() => |
There was a problem hiding this comment.
[MAJOR] pinnedIds is seeded only from the first render. useSessions() returns an empty array until the query resolves, so a direct visit or refresh on /grid leaves this state permanently empty and the new grid feature comes up blank even when there are active sessions.
Suggested fix:
const initializedPinnedRef = useRef(false)
useEffect(() => {
if (initializedPinnedRef.current || sessions.length === 0) return
initializedPinnedRef.current = true
setPinnedIds(sessions.filter(s => s.active).slice(0, 4).map(s => s.id))
}, [sessions])|
|
||
| {/* Cmd+Shift+F: replace focused cell */} | ||
| <SessionSearchModal | ||
| sessions={sessions} |
There was a problem hiding this comment.
[MAJOR] This replace flow still offers sessions that are already pinned elsewhere, so selecting one duplicates its ID in pinnedIds. After that, removeSession / closeCell remove by filter(p => p !== id), so closing either duplicate cell drops every copy of that session.
Suggested fix:
const replaceCandidates = sessions.filter((s) =>
s.id === pinnedIds[replaceTargetIdx ?? -1] || !pinnedIds.includes(s.id)
)
<SessionSearchModal
sessions={replaceCandidates}
...
/>…grid mode In grid iframes, suppress floating toasts and show them inline in the composer status bar instead: - ToastContainer: skip rendering inside iframes - HappyComposer: watch useToast() and set bannerToast on new arrivals; clear on send (button or Enter) - ComposerButtons: when toastBanner present, replace status line with amber-highlighted title+body; reverts on next send - index.css: add animate-pulse-once for banner flash via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Major] Grid auto-pinning still only runs during the first render, so a direct visit or refresh on
/gridstays stuck on "No sessions pinned" afteruseSessions()resolves. Evidenceweb/src/components/GridView.tsx:58,web/src/hooks/queries/useSessions.ts:24.
Suggested fix:const initializedPinnedRef = useRef(false) useEffect(() => { if (initializedPinnedRef.current || sessions.length === 0) return initializedPinnedRef.current = true setPinnedIds(sessions.filter((s) => s.active).slice(0, 4).map((s) => s.id)) }, [sessions])
- [Major] The replace modal still allows choosing a session that is already pinned in another cell. That duplicates the ID in
pinnedIds, and bothremoveSessionandcloseCellthen remove every copy at once because they filter by ID. Evidenceweb/src/components/GridView.tsx:125,web/src/components/GridView.tsx:137,web/src/components/GridView.tsx:390.
Suggested fix:const replaceCandidates = sessions.filter((s) => s.id === pinnedIds[replaceTargetIdx ?? -1] || !pinnedIds.includes(s.id) ) <SessionSearchModal sessions={replaceCandidates} ... />
- [Minor] The new inline iframe toast banner never clears when the toast expires.
ToastContaineris disabled inside iframes, butbannerToastis only updated on length increases and only cleared on send, so one toast can permanently hide the live status/context row. Evidenceweb/src/components/AssistantChat/HappyComposer.tsx:150,web/src/components/ToastContainer.tsx:10,web/src/components/AssistantChat/ComposerButtons.tsx:459.
Suggested fix:useEffect(() => { if (!isInIframe) return const latest = toasts.at(-1) setBannerToast(latest ? { title: latest.title, body: latest.body } : null) }, [toasts, isInIframe])
Summary
Review mode: follow-up after new commits
Found 3 issues on the latest head: two unresolved grid-state regressions are still present, and the follow-up toast-banner change leaves stale inline notifications stuck over the composer status row in iframe sessions.
Testing
Not run (automation). Not found in repo/docs: automated web coverage for /grid hydration after async session fetch, replace-flow deduping, or iframe toast expiry/restoration.
HAPI Bot
|
|
||
| export function GridView({ sessions, baseUrl, token }: Props) { | ||
| const navigate = useNavigate() | ||
| const [pinnedIds, setPinnedIds] = useState<string[]>(() => |
There was a problem hiding this comment.
[MAJOR] pinnedIds still initializes only from the first render. useSessions() returns [] until the query resolves, so a direct visit or refresh on /grid leaves the grid permanently blank even when active sessions exist.
Suggested fix:
const initializedPinnedRef = useRef(false)
useEffect(() => {
if (initializedPinnedRef.current || sessions.length === 0) return
initializedPinnedRef.current = true
setPinnedIds(sessions.filter((s) => s.active).slice(0, 4).map((s) => s.id))
}, [sessions])|
|
||
| {/* Cmd+Shift+F: replace focused cell */} | ||
| <SessionSearchModal | ||
| sessions={sessions} |
There was a problem hiding this comment.
[MAJOR] This replace flow still offers sessions that are already pinned elsewhere, so selecting one duplicates its ID in pinnedIds. After that, removeSession / closeCell remove by filter((p) => p !== id), so closing either duplicate cell drops every copy at once.
Suggested fix:
const replaceCandidates = sessions.filter((s) =>
s.id === pinnedIds[replaceTargetIdx ?? -1] || !pinnedIds.includes(s.id)
)
<SessionSearchModal
sessions={replaceCandidates}
...
/>| const isInIframe = window.self !== window.top | ||
| const { toasts } = useToast() | ||
| const prevToastsLenRef = useRef(0) | ||
| useEffect(() => { |
There was a problem hiding this comment.
[MINOR] Inside grid iframes this banner becomes the only toast surface, but this effect only updates when the toast list grows. When the toast expires after TOAST_DURATION_MS, bannerToast is left behind, so the stale notification keeps replacing the live status/context row until the user sends another message.
Suggested fix:
useEffect(() => {
if (!isInIframe) return
const latest = toasts.at(-1)
setBannerToast(latest ? { title: latest.title, body: latest.body } : null)
}, [toasts, isInIframe])When a session completes in grid view, the cell's title overlay dot: - flashes 3× (animate-toast-alert) then stays orange+larger - reverts to green on first keystroke in that cell's composer Mechanism: - iframe App.tsx forwards toast via postMessage to parent GridView - parent GridView also listens for CustomEvent from SSE path - HappyComposer sends grid-cell-typing postMessage on textarea change - GridView handles both message types to set/clear notifiedIds via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
Stub localStorage (primary) and sessionStorage (migration source), add migration test, rename "sessionStorage" references in test names. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Major] Grid auto-pinning still only runs during the first render, so a direct visit or refresh on
/gridstays stuck on "No sessions pinned" afteruseSessions()resolves. Evidenceweb/src/components/GridView.tsx:58,web/src/hooks/queries/useSessions.ts:24.
Suggested fix:const initializedPinnedRef = useRef(false) useEffect(() => { if (initializedPinnedRef.current || sessions.length === 0) return initializedPinnedRef.current = true setPinnedIds(sessions.filter((s) => s.active).slice(0, 4).map((s) => s.id)) }, [sessions])
- [Major] The replace modal still allows choosing a session that is already pinned in another cell. That duplicates the ID in
pinnedIds, and both remove paths then drop every copy at once because they filter by ID. Evidenceweb/src/components/GridView.tsx:158,web/src/components/GridView.tsx:175,web/src/components/GridView.tsx:437.
Suggested fix:const replaceCandidates = sessions.filter((s) => s.id === pinnedIds[replaceTargetIdx ?? -1] || !pinnedIds.includes(s.id) ) <SessionSearchModal sessions={replaceCandidates} ... />
Summary
Review mode: follow-up after new commits
The stale iframe banner issue from the previous bot pass is gone, but these two grid-state regressions are still present on c80a4c6db5c431a969b9e7932b4c831935bd76cf.
Testing
Not run (automation: bun is not installed in this runner). Not found in repo/docs: automated web coverage for /grid hydration after async session fetch or replace-flow deduping.
HAPI Bot
|
|
||
| export function GridView({ sessions, baseUrl, token }: Props) { | ||
| const navigate = useNavigate() | ||
| const [pinnedIds, setPinnedIds] = useState<string[]>(() => |
There was a problem hiding this comment.
[MAJOR] pinnedIds still initializes only from the first render. useSessions() returns [] until the query resolves, so a direct visit or refresh on /grid leaves the page stuck on "No sessions pinned" even when active sessions exist.
Suggested fix:
const initializedPinnedRef = useRef(false)
useEffect(() => {
if (initializedPinnedRef.current || sessions.length === 0) return
initializedPinnedRef.current = true
setPinnedIds(sessions.filter((s) => s.active).slice(0, 4).map((s) => s.id))
}, [sessions])|
|
||
| {/* Cmd+Shift+F: replace focused cell */} | ||
| <SessionSearchModal | ||
| sessions={sessions} |
There was a problem hiding this comment.
[MAJOR] This replace flow still offers sessions that are already pinned elsewhere, so selecting one duplicates its ID in pinnedIds. After that, removeSession / closeCell remove by filter((p) => p !== id), so closing either duplicate cell drops every copy at once.
Suggested fix:
const replaceCandidates = sessions.filter((s) =>
s.id === pinnedIds[replaceTargetIdx ?? -1] || !pinnedIds.includes(s.id)
)
<SessionSearchModal
sessions={replaceCandidates}
...
/>Adds autocomplete entries for two Claude Code built-in commands that were missing from HAPI's suggestion list. Execution is handled by the Claude CLI itself. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Major] Grid auto-pinning still runs only during the first render, so opening or refreshing
/gridbeforeuseSessions()resolves leaves the page on "No sessions pinned" even when active sessions exist. Evidenceweb/src/components/GridView.tsx:58,web/src/hooks/queries/useSessions.ts:24.
Suggested fix:const initializedPinnedRef = useRef(false) useEffect(() => { if (initializedPinnedRef.current || sessions.length === 0) return initializedPinnedRef.current = true setPinnedIds(sessions.filter((s) => s.active).slice(0, 4).map((s) => s.id)) }, [sessions])
- [Major] The replace modal still offers sessions that are already pinned elsewhere, so replacing a cell can duplicate an ID in
pinnedIds. Both removal paths delete byfilter((p) => p !== id), so closing either duplicate removes every copy at once. Evidenceweb/src/components/GridView.tsx:158,web/src/components/GridView.tsx:175,web/src/components/GridView.tsx:437.
Suggested fix:const replaceCandidates = sessions.filter((s) => s.id === pinnedIds[replaceTargetIdx ?? -1] || !pinnedIds.includes(s.id) ) <SessionSearchModal sessions={replaceCandidates} ... />
Summary
Review mode: follow-up after new commits
These two grid-state regressions still reproduce on 1f3059f6643921f735027bf54fd8c13ec8986f18. I did not find new higher-confidence issues elsewhere in the updated diff.
Testing
Not run (review automation). Not found in repo/docs: automated web coverage for /grid hydration after async session fetch or replace-flow deduping.
HAPI Bot
|
|
||
| export function GridView({ sessions, baseUrl, token }: Props) { | ||
| const navigate = useNavigate() | ||
| const [pinnedIds, setPinnedIds] = useState<string[]>(() => |
There was a problem hiding this comment.
[MAJOR] pinnedIds still initializes only from the first render. useSessions() returns [] until the query resolves, so a direct visit or refresh on /grid leaves the page stuck on "No sessions pinned" even when active sessions exist.
Suggested fix:
const initializedPinnedRef = useRef(false)
useEffect(() => {
if (initializedPinnedRef.current || sessions.length === 0) return
initializedPinnedRef.current = true
setPinnedIds(sessions.filter((s) => s.active).slice(0, 4).map((s) => s.id))
}, [sessions])|
|
||
| {/* Cmd+Shift+F: replace focused cell */} | ||
| <SessionSearchModal | ||
| sessions={sessions} |
There was a problem hiding this comment.
[MAJOR] This replace flow still offers sessions that are already pinned elsewhere, so selecting one duplicates its ID in pinnedIds. After that, removeSession / closeCell remove by filter((p) => p !== id), so closing either duplicate cell drops every copy at once.
Suggested fix:
const replaceCandidates = sessions.filter((s) =>
s.id === pinnedIds[replaceTargetIdx ?? -1] || !pinnedIds.includes(s.id)
)
<SessionSearchModal
sessions={replaceCandidates}
...
/>…tocomplete The web frontend has its own builtin command list (codexSlashCommands.ts) separate from the CLI's slashCommands.ts. Both need to be updated. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Major] Grid starts empty when sessions load after first render —
pinnedIdsis initialized fromsessionsonly once, whileuseSessions()returns[]until the query resolves, so a direct visit/refresh of/gridcan stay on “No sessions pinned” even with active sessions. Evidenceweb/src/components/GridView.tsx:58,web/src/hooks/queries/useSessions.ts:24.
Suggested fix:const initializedPinnedRef = useRef(false) useEffect(() => { if (initializedPinnedRef.current || sessions.length === 0) return initializedPinnedRef.current = true setPinnedIds(sessions.filter((s) => s.active).slice(0, 4).map((s) => s.id)) }, [sessions])
- [Major] Replace still allows duplicate pinned sessions — the replace modal receives all sessions, so selecting a session already pinned in another cell duplicates that ID; the close/remove paths then remove by ID with
filter, so closing either copy removes both cells. Evidenceweb/src/components/GridView.tsx:437,web/src/components/GridView.tsx:158,web/src/components/GridView.tsx:170.
Suggested fix:const replaceCandidates = sessions.filter((s) => s.id === pinnedIds[replaceTargetIdx ?? -1] || !pinnedIds.includes(s.id) ) <SessionSearchModal sessions={replaceCandidates} ... />
Summary
Review mode: follow-up after new commits
The current head still contains the two grid-state regressions from the previous HAPI Bot review. I did not find additional higher-confidence issues in the follow-up slash-command commit.
Testing
Not run (automation; bun run typecheck:web failed because bun is not installed in this runner). Not found in repo/docs: automated web coverage for /grid hydration after async session fetch or replace-flow deduping.
HAPI Bot
|
|
||
| export function GridView({ sessions, baseUrl, token }: Props) { | ||
| const navigate = useNavigate() | ||
| const [pinnedIds, setPinnedIds] = useState<string[]>(() => |
There was a problem hiding this comment.
[MAJOR] pinnedIds is initialized from sessions only once. useSessions() returns [] until the query resolves, so a direct visit or refresh on /grid can stay on “No sessions pinned” even when active sessions exist.
Suggested fix:
const initializedPinnedRef = useRef(false)
useEffect(() => {
if (initializedPinnedRef.current || sessions.length === 0) return
initializedPinnedRef.current = true
setPinnedIds(sessions.filter((s) => s.active).slice(0, 4).map((s) => s.id))
}, [sessions])|
|
||
| {/* Cmd+Shift+F: replace focused cell */} | ||
| <SessionSearchModal | ||
| sessions={sessions} |
There was a problem hiding this comment.
[MAJOR] The replace modal still receives every session, including sessions already pinned in other cells. Selecting one duplicates its ID in pinnedIds; then removeSession / closeCell remove by filter((p) => p !== id), so closing either duplicate removes both cells.
Suggested fix:
const replaceCandidates = sessions.filter((s) =>
s.id === pinnedIds[replaceTargetIdx ?? -1] || !pinnedIds.includes(s.id)
)
<SessionSearchModal
sessions={replaceCandidates}
...
/>…ace modal - pinnedIds now initializes lazily via useEffect once sessions arrive, fixing "No sessions pinned" on direct /grid visits before query resolves - Replace modal filters out already-pinned sessions (except current cell), preventing duplicate IDs that caused both cells to close simultaneously via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Minor] Grid-only shortcuts are intercepted even outside
/grid—SessionsPageonly passesonOpenSearch, butuseGlobalKeyboardstill callspreventDefault()forCmd+Shift+F,Cmd+Shift+X, andCmd+'when their callbacks are absent, so those keys become no-ops on the normal sessions UI. Evidenceweb/src/router.tsx:144,web/src/hooks/useGlobalKeyboard.ts:42,web/src/hooks/useGlobalKeyboard.ts:49,web/src/hooks/useGlobalKeyboard.ts:56.
Suggested fix:if (options.onReplaceCell && (e.key === 'f' || e.key === 'F') && e.shiftKey) { e.preventDefault() options.onReplaceCell() return } if (options.onCloseCell && (e.key === 'x' || e.key === 'X') && e.shiftKey) { e.preventDefault() options.onCloseCell() return } if (options.onToggleStrip && e.key === "'") { e.preventDefault() options.onToggleStrip() return }
Summary
Review mode: follow-up after new commits
The prior grid hydration and replace-dedup issues look fixed on fa81ddc6304b1215a6280b0a1f9b75a30c8cb890. One remaining regression is that the shared keyboard hook now swallows several grid-only shortcuts on the regular sessions UI, where they have no effect.
Testing
Not run (automation; bun is not installed in this runner). Not found in repo/docs: automated coverage for useGlobalKeyboard behavior outside /grid.
HAPI Bot
| } | ||
|
|
||
| // Cmd+Shift+F — search to replace current focused grid cell | ||
| if ((e.key === 'f' || e.key === 'F') && e.shiftKey) { |
There was a problem hiding this comment.
[MINOR] This shared hook is mounted on the normal sessions page with only onOpenSearch wired (web/src/router.tsx:144), but this block still calls preventDefault() even when onReplaceCell is undefined. The same pattern below also swallows Cmd+Shift+X and Cmd+', so those shortcuts become no-ops outside /grid.
Suggested fix:
if (options.onReplaceCell && (e.key === 'f' || e.key === 'F') && e.shiftKey) {
e.preventDefault()
options.onReplaceCell()
return
}Dot priority: orange (notification) > blue (thinking/outputting) > green (idle) Uses session.thinking from SessionSummary which is already synced via SSE. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
…epeat smooth scroll animations queue up during key-repeat, making navigation feel sluggish. instant scroll responds immediately to each keydown. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Minor] Grid-only shortcuts are still swallowed outside
/grid—SessionsPagemountsuseGlobalKeyboardwith onlyonOpenSearch, but the hook still callspreventDefault()forCmd+Shift+F,Cmd+Shift+X, andCmd+'even when the grid callbacks are absent, so those keys become dead shortcuts on the normal sessions UI. Evidenceweb/src/router.tsx:144,web/src/hooks/useGlobalKeyboard.ts:42,web/src/hooks/useGlobalKeyboard.ts:49,web/src/hooks/useGlobalKeyboard.ts:56.
Suggested fix:if (options.onReplaceCell && (e.key === 'f' || e.key === 'F') && e.shiftKey) { e.preventDefault() options.onReplaceCell() return } if (options.onCloseCell && (e.key === 'x' || e.key === 'X') && e.shiftKey) { e.preventDefault() options.onCloseCell() return } if (options.onToggleStrip && e.key === "'") { e.preventDefault() options.onToggleStrip() return }
Summary
Review mode: follow-up after new commits
The latest head still leaves the previously reported keyboard regression in place. I did not find a new higher-severity issue in the updated grid/toast changes.
Testing
Not run (automation; bun is not installed in this runner). Not found in repo/docs: automated coverage for useGlobalKeyboard behavior outside /grid.
HAPI Bot
| } | ||
|
|
||
| // Cmd+Shift+F — search to replace current focused grid cell | ||
| if ((e.key === 'f' || e.key === 'F') && e.shiftKey) { |
There was a problem hiding this comment.
[MINOR] SessionsPage wires this shared hook with only onOpenSearch (web/src/router.tsx:144), but this branch still calls preventDefault() even when onReplaceCell is undefined. The same pattern below also swallows Cmd+Shift+X and Cmd+', so those shortcuts become no-ops outside /grid.
Suggested fix:
if (options.onReplaceCell && (e.key === 'f' || e.key === 'F') && e.shiftKey) {
e.preventDefault()
options.onReplaceCell()
return
}…utting When thinking: background becomes deep blue, title turns light blue (#93c5fd), subtitle and close button also shift to blue tones. Idle stays white on dark. All color transitions are smooth (0.3s). via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
…unchanged via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Minor] Grid-only shortcuts are still swallowed outside
/gridbecauseSessionsPagewiresuseGlobalKeyboardwith onlyonOpenSearch, whileCmd+Shift+F,Cmd+Shift+X, andCmd+'still callpreventDefault()unconditionally. That turns them into dead shortcuts on the normal sessions UI. Evidenceweb/src/router.tsx:144,web/src/hooks/useGlobalKeyboard.ts:42,web/src/hooks/useGlobalKeyboard.ts:49,web/src/hooks/useGlobalKeyboard.ts:56.
Suggested fix:if (options.onReplaceCell && (e.key === 'f' || e.key === 'F') && e.shiftKey) { e.preventDefault() options.onReplaceCell() return } if (options.onCloseCell && (e.key === 'x' || e.key === 'X') && e.shiftKey) { e.preventDefault() options.onCloseCell() return } if (options.onToggleStrip && e.key === "'") { e.preventDefault() options.onToggleStrip() return }
Summary
Review mode: follow-up after new commits
The latest head still leaves the shared keyboard regression in place. I did not find a new higher-confidence issue in the updated grid/toast changes.
Testing
Not run (automation; bun is not installed in this runner). Not found in repo/docs: automated coverage for useGlobalKeyboard behavior outside /grid.
HAPI Bot
| } | ||
|
|
||
| // Cmd+Shift+F — search to replace current focused grid cell | ||
| if ((e.key === 'f' || e.key === 'F') && e.shiftKey) { |
There was a problem hiding this comment.
[MINOR] SessionsPage still mounts this shared hook with only onOpenSearch (web/src/router.tsx:144), but this branch and the two shortcut branches below still call preventDefault() even when the grid callbacks are absent. On non-grid routes, Cmd+Shift+F, Cmd+Shift+X, and Cmd+' therefore become swallowed no-ops.
Suggested fix:
if (options.onReplaceCell && (e.key === 'f' || e.key === 'F') && e.shiftKey) {
e.preventDefault()
options.onReplaceCell()
return
}
if (options.onCloseCell && (e.key === 'x' || e.key === 'X') && e.shiftKey) {
e.preventDefault()
options.onCloseCell()
return
}
if (options.onToggleStrip && e.key === "'") {
e.preventDefault()
options.onToggleStrip()
return
}Only call preventDefault() when the corresponding grid callback is wired. Outside /grid, these shortcuts now pass through correctly. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Major]
scrollTo/scrollBynow passbehavior: 'instant', which is not a valid DOMScrollBehavior. In this strict TypeScript repo that should fail typechecking, and browsers only guarantee immediate jumps forbehavior: 'auto'. Evidenceweb/src/components/AssistantChat/HappyThread.tsx:185,web/src/components/AssistantChat/HappyThread.tsx:189,web/src/components/AssistantChat/HappyThread.tsx:197.
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' })
- [Minor] The new grid iframe messaging trusts any
messageevent and sends updates with'*', so an embedding parent can observe session notification activity and any sender with a window reference can spoofgrid-cell-toast/grid-cell-typing. Not found in repo/docs: a frame policy that would make this safe. Evidenceweb/src/components/GridView.tsx:88,web/src/App.tsx:247,web/src/components/AssistantChat/HappyComposer.tsx:404.
Suggested fix:const sameOrigin = window.location.origin const msgHandler = (e: MessageEvent) => { if (e.origin !== sameOrigin) return if (!iframeRefs.current.some((frame) => frame?.contentWindow === e.source)) return // handle trusted grid-cell-* messages } window.parent.postMessage({ type: 'grid-cell-toast', sessionId: event.data.sessionId }, sameOrigin) window.parent.postMessage({ type: 'grid-cell-typing', sessionId }, sameOrigin)
Summary
Review mode: follow-up after new commits
Two issues remain on the latest head: the new chat navigation shortcuts use an invalid scroll behavior value that should fail typechecking, and the grid iframe messaging path introduces an avoidable cross-window trust leak. The earlier shared keyboard-hook regression from the previous bot review looks fixed in this head.
Testing
Not run (automation; bun is not installed in this runner). Not found in repo/docs: coverage for grid iframe messaging security/typing constraints.
HAPI Bot
| 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' }) |
There was a problem hiding this comment.
[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' })| setFlashingIds(prev => new Set([...prev, sessionId])) | ||
| } | ||
| // postMessage from iframes (most reliable path) | ||
| const msgHandler = (e: MessageEvent) => { |
There was a problem hiding this comment.
[MINOR] This listener accepts any message event, while the paired senders use postMessage(..., '*') in web/src/App.tsx:247 and web/src/components/AssistantChat/HappyComposer.tsx:404. That lets an embedding parent observe sessionId updates and also lets any untrusted sender with a window reference spoof notification dots. Not found in repo/docs: a frame policy that would prevent cross-origin embedding.
Suggested fix:
const sameOrigin = window.location.origin
const msgHandler = (e: MessageEvent) => {
if (e.origin !== sameOrigin) return
if (!iframeRefs.current.some((frame) => frame?.contentWindow === e.source)) return
// handle trusted grid-cell-* messages
}and send with window.parent.postMessage(..., sameOrigin) from the iframe pages.
Intercepts /effort [auto|medium|high|max] in handleSend before sending to AI, calls handleEffortChange directly. Takes effect on next message. Also adds /effort to builtin autocomplete list. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Minor] Grid iframe messaging trusts any
messageevent and sends updates with'*', which leaks session notification activity to an embedding parent and lets any window with a reference spoofgrid-cell-toast/grid-cell-typing. Evidenceweb/src/components/GridView.tsx:88,web/src/App.tsx:247,web/src/components/AssistantChat/HappyComposer.tsx:404.
Summary
Review mode: follow-up after new commits
One issue remains on the latest head: the new grid iframe notification channel accepts and emits cross-window messages without origin/source validation. Not found in repo/docs: a frame policy that would make that trust boundary safe.
Testing
Not run (automation; bun is not installed in this runner).
HAPI Bot
| setFlashingIds(prev => new Set([...prev, sessionId])) | ||
| } | ||
| // postMessage from iframes (most reliable path) | ||
| const msgHandler = (e: MessageEvent) => { |
There was a problem hiding this comment.
[MINOR] This listener trusts any message event, while the paired senders use postMessage(..., '*') in web/src/App.tsx:247 and web/src/components/AssistantChat/HappyComposer.tsx:404. That lets an embedding parent observe sessionId activity and lets any sender with a window reference spoof notification dots. Not found in repo/docs: a frame policy that would make this safe.
Suggested fix:
const sameOrigin = window.location.origin
const msgHandler = (e: MessageEvent) => {
if (e.origin !== sameOrigin) return
if (!iframeRefs.current.some((frame) => frame?.contentWindow === e.source)) return
const sessionId: string | undefined = e.data?.sessionId
if (!sessionId) return
// handle trusted grid-cell-* messages
}and send with:
window.parent.postMessage({ type: 'grid-cell-toast', sessionId: event.data.sessionId }, sameOrigin)
window.parent.postMessage({ type: 'grid-cell-typing', sessionId }, sameOrigin)…mary - Remove summary.text fallback in getSessionTitle across 4 components. User-set name (metadata.name) takes priority; if none, fall back to folder basename or session ID (stable). No more summary-driven title churn that changes every few messages. - Grid cell title: double-click opens RenameSessionDialog. Uses existing api.renameSession + invalidates session queries so all views update. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
- All dots now 8px (blue/green no longer smaller than orange) - Detect thinking→idle transitions on active sessions; trigger orange flash + notification when a session finishes outputting. Dot stays orange until the user types in that cell (existing clear path). via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Major] Removing the
metadata.summary.textfallback regresses titles for unnamed sessions across the session list, header, grid, and search modal.SessionSummaryMetadata.summaryis still part of the shared contract and other code still treats it as a valid title source, so sessions without an explicit name now fall back to a folder basename or raw id instead of the generated summary. Evidenceshared/src/sessionSummary.ts:7,web/src/components/SessionList.tsx:259,web/src/components/SessionHeader.tsx:12,web/src/components/SessionSearchModal.tsx:4,web/src/components/GridView.tsx:11. - [Minor] Grid iframe messaging still trusts any
messageevent and still emits updates with'*', which leaks session activity to an embedding parent and lets any window with a reference spoofgrid-cell-toast/grid-cell-typing. Evidenceweb/src/components/GridView.tsx:124,web/src/App.tsx:247,web/src/components/AssistantChat/HappyComposer.tsx:404.
Summary
Review mode: follow-up after new commits
Two issues on the latest head: the follow-up commit regresses session titles by dropping the existing summary fallback, and the previously reported iframe messaging trust boundary is still open.
Testing
Not run (automation; bun is not installed in this runner).
HAPI Bot
| if (session.metadata?.summary?.text) { | ||
| return session.metadata.summary.text | ||
| } | ||
| if (session.metadata?.path) { |
There was a problem hiding this comment.
[MAJOR] This helper no longer falls back to metadata.summary.text, but SessionSummaryMetadata.summary is still populated in the shared model (shared/src/sessionSummary.ts) and other code still relies on it as a display title source. For sessions that only have an auto-generated summary, the list/header/grid/search modal now regress to a folder basename or raw id.
Suggested fix:
function getSessionTitle(session: SessionSummary): string {
if (session.metadata?.name) return session.metadata.name
if (session.metadata?.summary?.text) return session.metadata.summary.text
if (session.metadata?.path) {
const parts = session.metadata.path.split('/').filter(Boolean)
return parts.length > 0 ? parts[parts.length - 1] : session.id.slice(0, 8)
}
return session.id.slice(0, 8)
}Apply the same fallback in the mirrored helpers in SessionHeader, SessionSearchModal, and GridView.
| setFlashingIds(prev => new Set([...prev, sessionId])) | ||
| } | ||
| // postMessage from iframes (most reliable path) | ||
| const msgHandler = (e: MessageEvent) => { |
There was a problem hiding this comment.
[MINOR] This listener still accepts any message event, while the paired senders still use postMessage(..., '*') in web/src/App.tsx:247 and web/src/components/AssistantChat/HappyComposer.tsx:404. That lets an embedding parent observe sessionId activity and lets any sender with a window reference spoof notification dots. Not found in repo/docs: a frame policy that would make this trust boundary safe.
Suggested fix:
const sameOrigin = window.location.origin
const msgHandler = (e: MessageEvent) => {
if (e.origin !== sameOrigin) return
if (!iframeRefs.current.some((frame) => frame?.contentWindow === e.source)) return
const sessionId: string | undefined = e.data?.sessionId
if (!sessionId) return
// handle trusted grid-cell-* messages
}and send with:
window.parent.postMessage({ type: 'grid-cell-toast', sessionId: event.data.sessionId }, sameOrigin)
window.parent.postMessage({ type: 'grid-cell-typing', sessionId }, sameOrigin)
Summary
This PR adds several productivity features to the web UI, primarily focused on power users who run multiple Claude sessions simultaneously.
1. Multi-session grid view (
feat(web): add multi-session grid view)/gridroute renders up to 6 pinned sessions as same-origin iframes in an adaptive layout (1×1, 2×1, 3×1, 2×2, 3+2 for 5, 3×2)Cmd+'): forces all sessions into a single horizontal rowCmd+;— toggle grid ↔ sessions listCmd+K— search & add session to gridCmd+Shift+F— search & replace focused cellCmd+Shift+X— close focused cellCmd+1-9— focus nth cell (also focuses textarea)Alt+h/j/k/l— move focus between cells (vim-style)Cmd+'— toggle strip / adaptive-grid layoutsessionStorage→localStorageso iframes share drafts with the parent2. Status bar inlined into composer (
feat(web): inline status and permission mode)StatusBarabove the composer box into the composer's bottom icon row3. In-chat keyboard navigation (
feat(web): keyboard shortcuts for scroll/jump)Alt+[/Alt+]— scroll chat up/down ~40% of viewportAlt+Shift+[/Alt+Shift+]— jump to previous/next messagee.codeto avoid macOS Option-key dead-key interferencecapture: true; works while typing in the composer4. Misc fixes (
fix(web): ...)Test plan
Cmd+;), add 2–6 sessions, verify adaptive layoutCmd+;returns to sessions list when focus is inside an iframeAlt+[/]scrolls andAlt+Shift+[/]jumps messages in a long chatCmd+') lays all sessions in one row🤖 Generated with Claude Code