Skip to content

feat(web): multi-session grid view, composer status bar, in-chat keyboard shortcuts#484

Open
YiwenZhu77 wants to merge 9 commits intotiann:mainfrom
YiwenZhu77:feat/grid-view-and-ux-improvements
Open

feat(web): multi-session grid view, composer status bar, in-chat keyboard shortcuts#484
YiwenZhu77 wants to merge 9 commits intotiann:mainfrom
YiwenZhu77:feat/grid-view-and-ux-improvements

Conversation

@YiwenZhu77
Copy link
Copy Markdown

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)

  • New /grid route 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)
  • Strip mode (Cmd+'): forces all sessions into a single horizontal row
  • Floating overlay pill on each cell: session title, folder, flavor, close button
  • Keyboard shortcuts (all work from within iframe focus too):
    • Cmd+; — toggle grid ↔ sessions list
    • Cmd+K — search & add session to grid
    • Cmd+Shift+F — search & replace focused cell
    • Cmd+Shift+X — close focused cell
    • Cmd+1-9 — focus nth cell (also focuses textarea)
    • Alt+h/j/k/l — move focus between cells (vim-style)
    • Cmd+' — toggle strip / adaptive-grid layout
  • Toast notifications filtered per-session (each iframe only sees its own)
  • Sidebar hidden in iframes; SessionHeader hidden in iframes
  • Composer drafts migrated from sessionStoragelocalStorage so iframes share drafts with the parent

2. Status bar inlined into composer (feat(web): inline status and permission mode)

  • Moves the online/thinking status dot + context window % + permission mode label from the standalone StatusBar above the composer box into the composer's bottom icon row
  • Saves one line of vertical space; especially beneficial in grid iframes

3. In-chat keyboard navigation (feat(web): keyboard shortcuts for scroll/jump)

  • Alt+[ / Alt+] — scroll chat up/down ~40% of viewport
  • Alt+Shift+[ / Alt+Shift+] — jump to previous/next message
  • Uses e.code to avoid macOS Option-key dead-key interference
  • Registered with capture: true; works while typing in the composer

4. Misc fixes (fix(web): ...)

  • Remove colored flavor badge (Cl/Cx/Gm…) from session list rows
  • Service worker: focus & navigate existing PWA window on push notification click instead of always opening a new tab

Test plan

  • Open grid view (Cmd+;), add 2–6 sessions, verify adaptive layout
  • Test all keyboard shortcuts listed above from within an active iframe
  • Verify Cmd+; returns to sessions list when focus is inside an iframe
  • Check status dot, context %, and permission mode appear in composer bottom bar
  • Verify Alt+[/] scrolls and Alt+Shift+[/] jumps messages in a long chat
  • Confirm toast notifications appear only in the correct grid cell
  • Strip mode (Cmd+') lays all sessions in one row

🤖 Generated with Claude Code

YiwenZhu77 and others added 4 commits April 16, 2026 16:47
- 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>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Review mode: initial

Findings

  • [Major] Grid iframe toast filtering falls back to all-session toasts on nested session routes because selectedSessionId is matched without fuzzy: true in web/src/App.tsx:120, but the new filter in web/src/App.tsx:239 depends on it staying populated.
  • [Major] The draft-storage switch reads and writes only localStorage in web/src/lib/composer-drafts.ts:23 and web/src/lib/composer-drafts.ts:63, so any existing unsent drafts still stored in sessionStorage disappear 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

Comment thread web/src/App.tsx Outdated
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) {
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] 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

Comment thread web/src/lib/composer-drafts.ts Outdated
}
try {
const raw = sessionStorage.getItem(STORAGE_KEY)
const raw = localStorage.getItem(STORAGE_KEY)
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] 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)
}

YiwenZhu77 and others added 2 commits April 16, 2026 16:56
- 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>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Grid state is initialized only from the first render, so a direct visit or refresh on /grid never auto-pins the active sessions after useSessions() resolves. The page stays stuck on "No sessions pinned" even when active sessions exist. Evidence web/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 / closeCell remove by ID and drop every duplicate cell at once. Evidence web/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[]>(() =>
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] 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}
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] 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>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Grid auto-pinning still only runs during the first render, so a direct visit or refresh on /grid stays stuck on "No sessions pinned" after useSessions() resolves. Evidence web/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 removeSession and closeCell then remove every copy at once because they filter by ID. Evidence web/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. ToastContainer is disabled inside iframes, but bannerToast is only updated on length increases and only cleared on send, so one toast can permanently hide the live status/context row. Evidence web/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[]>(() =>
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] 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}
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] 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(() => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[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])

YiwenZhu77 and others added 2 commits April 16, 2026 17:42
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>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Grid auto-pinning still only runs during the first render, so a direct visit or refresh on /grid stays stuck on "No sessions pinned" after useSessions() resolves. Evidence web/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. Evidence web/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[]>(() =>
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] 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}
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] 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}
    ...
/>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant