Skip to content

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

Open
YiwenZhu77 wants to merge 20 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 20 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

Comment thread web/src/components/GridView.tsx Outdated

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

Comment thread web/src/components/GridView.tsx Outdated

{/* 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

Comment thread web/src/components/GridView.tsx Outdated

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

Comment thread web/src/components/GridView.tsx Outdated

{/* 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

Comment thread web/src/components/GridView.tsx Outdated

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

Comment thread web/src/components/GridView.tsx Outdated

{/* 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}
    ...
/>

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>
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 runs only during the first render, so opening or refreshing /grid before useSessions() resolves leaves the page on "No sessions pinned" even when active sessions exist. 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 offers sessions that are already pinned elsewhere, so replacing a cell can duplicate an ID in pinnedIds. Both removal paths delete by filter((p) => p !== id), so closing either duplicate removes every copy at once. 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
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

Comment thread web/src/components/GridView.tsx Outdated

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

Comment thread web/src/components/GridView.tsx Outdated

{/* 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}
    ...
/>

…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>
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 starts empty when sessions load after first render — pinnedIds is initialized from sessions only once, while useSessions() returns [] until the query resolves, so a direct visit/refresh of /grid can stay on “No sessions pinned” even with active sessions. 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] 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. Evidence web/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

Comment thread web/src/components/GridView.tsx Outdated

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

Comment thread web/src/components/GridView.tsx Outdated

{/* 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] 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>
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

  • [Minor] Grid-only shortcuts are intercepted even outside /gridSessionsPage only passes onOpenSearch, but useGlobalKeyboard still calls preventDefault() for Cmd+Shift+F, Cmd+Shift+X, and Cmd+' when their callbacks are absent, so those keys become no-ops on the normal sessions UI. Evidence web/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) {
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] 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
}

YiwenZhu77 and others added 2 commits April 18, 2026 21:14
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>
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

  • [Minor] Grid-only shortcuts are still swallowed outside /gridSessionsPage mounts useGlobalKeyboard with only onOpenSearch, but the hook still calls preventDefault() for Cmd+Shift+F, Cmd+Shift+X, and Cmd+' even when the grid callbacks are absent, so those keys become dead shortcuts on the normal sessions UI. Evidence web/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) {
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] 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
}

YiwenZhu77 and others added 2 commits April 18, 2026 21:40
…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>
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

  • [Minor] Grid-only shortcuts are still swallowed outside /grid because SessionsPage wires useGlobalKeyboard with only onOpenSearch, while Cmd+Shift+F, Cmd+Shift+X, and Cmd+' still call preventDefault() unconditionally. That turns them into dead shortcuts on the normal sessions UI. Evidence web/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) {
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] 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>
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] scrollTo/scrollBy now pass behavior: 'instant', which is not a valid DOM ScrollBehavior. In this strict TypeScript repo that should fail typechecking, and browsers only guarantee immediate jumps for behavior: 'auto'. Evidence web/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 message event and sends updates with '*', so an embedding parent can observe session notification activity and any sender with a window reference can spoof grid-cell-toast / grid-cell-typing. Not found in repo/docs: a frame policy that would make this safe. Evidence web/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' })
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' })

setFlashingIds(prev => new Set([...prev, sessionId]))
}
// postMessage from iframes (most reliable path)
const msgHandler = (e: MessageEvent) => {
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] 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>
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

  • [Minor] Grid iframe messaging trusts any message event and sends updates with '*', which leaks session notification activity to an embedding parent and lets any window with a reference spoof grid-cell-toast / grid-cell-typing. Evidence web/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) => {
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] 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>
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] Removing the metadata.summary.text fallback regresses titles for unnamed sessions across the session list, header, grid, and search modal. SessionSummaryMetadata.summary is 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. Evidence shared/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 message event and still emits updates with '*', which leaks session activity to an embedding parent and lets any window with a reference spoof grid-cell-toast / grid-cell-typing. Evidence web/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) {
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 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) => {
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] 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)

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