mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback (#893) Promotes scratchlist persistence from per-device localStorage to a hub- backed typed table so entries follow the operator across devices. v1 panel UI / FUE / shortcut / styling are deliberately unchanged - this is a backend + sync-layer feature. Hub side - New `session_scratchlist` typed table (sessionId, entryId, text, createdAt, updatedAt) with composite PK and FK ON DELETE CASCADE from sessions. Schema bumped V9 -> V10; idempotent migration added to the legacy + step ladders. - REST CRUD under `/api/sessions/:id/scratchlist[/:entryId]`, all routed through the existing `requireSessionFromParam` guard so namespace / ownership enforcement is identical to other session-scoped routes. - Per-session 200-entry cap enforced on POST. Duplicate entryId reported idempotently (200) so the migration retry path is safe. - `SessionPatchSchema` extended with `scratchlistUpdatedAt?: number`; every successful mutation emits a `session-updated` SSE patch with the token. (Following operator's piggyback decision; aligns with the parallel #884 patch-shape extension.) Web side - Hub becomes source of truth via TanStack Query (`queryKeys.scratchlist(sessionId)`); localStorage demoted to offline cache. Add / delete / update mutations are optimistic with rollback on error. - Silent first-load migration: existing localStorage entries are pushed to the hub preserving id + createdAt, and a one-time banner (mirroring `CursorMigrationBanner`) tells the operator their notes are now in the hub. Banner dismissal is per-session and persistent. - SSE handler queues a `scratchlist` invalidation when the patch carries `scratchlistUpdatedAt`, so cross-device + cross-tab updates land within an SSE round-trip. - Delete-session confirm copy now includes a count of scratchlist entries that will be cascade-deleted. Out of scope (separate tracking issue #894): "delete with summarize-and- migrate" UX flow. Tests - Hub: V9->V10 migration (fresh + multi-hop legacy + idempotent reopen + cascade-delete), `ScratchlistStore` CRUD + ordering, REST routes (happy path + 400/403/404/409), SyncEngine SSE emission. - Web: hook covers initial fetch, optimistic add/delete/update with rollback, localStorage migration + banner, cap enforcement, local-only reorder. Banner component renders only on `'completed'`. - Existing Playwright e2e (10 tests, panel UI regression) all pass unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): address HAPI Bot Major findings on PR #896 Two real data-correctness paths the bot caught on the initial review. 1. Migration partial-failure data loss The migration loop swallowed each failed POST and still wrote the `migrated` flag, while the offline-cache effect mirrored the (partial) hub state back into `hapi.scratchlist.v1.<sessionId>` - so a transient error or cap rejection could leave entries neither on the hub nor in localStorage. Fix: - Track failed entries during migration and persist them back to localStorage; do NOT advance the flag if any entry failed, so a future mount retries. - Gate the offline-cache effect on the migration flag. Pre- migration, localStorage holds the v1 entries the migration reads; mirroring an empty hub fetch over them was the wipe. - Drop the "skip migration when hub is non-empty" gate. Combined with the duplicate-idempotent POST short-circuit (below), a retry against a session that another device already populated is a safe union. 2. Duplicate POST returned 409 at cap The route checked `count >= SCRATCHLIST_MAX_ENTRIES` BEFORE asking the store whether the supplied `entryId` already existed, so an idempotent migration retry against a 200-row session returned 409 instead of 200. Fix: check duplicate first via a new `SyncEngine.getScratchlistEntry`, return the existing row with 200, and only run the cap check for genuinely new ids. Tests added: - hub/routes: at-cap + duplicate entryId returns 200 (not 409); at-cap + new entryId still 409. - web/hook: partial-failure persists the failed entries back to localStorage and leaves the flag unset; offline-cache effect does not wipe pre-migration localStorage. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web/scratchlist): per-entry age indicator (clock icon + tooltip) Surfaces the smart-relative time the entry was last saved on every scratchlist row, mirroring the bucketing used in the session list: just-now -> Nm -> Nh -> Nd -> absolute date. Implementation: - Extract the existing `formatRelativeTime` helper out of SessionList into `web/src/lib/relative-time.ts` so the panel can reuse the same buckets and i18n keys (no copy-paste drift between surfaces). Also add `formatAbsoluteDateTime` for the precise-stamp tooltip line. - Add `updatedAt?: number` to the local `ScratchlistEntry` shape. v1-only callers stay valid (the field is optional and `isEntry` now accepts rows that omit it). The hub hook forwards the hub's `updatedAt` so the indicator reflects edits, not just creation. - New `EntryAgeIndicator` component: clock SVG in the same style as the existing action icons, rendered inside both panel surfaces (the older `ScratchlistList` and the drawer variant). Falls back to `createdAt` when `updatedAt` is missing (legacy v1 rows during the migration window) and renders nothing if neither timestamp is usable. - Tooltip carries the relative bucket plus the absolute timestamp on a second line; aria-label carries the relative bucket only so screen readers stay terse. - Mirror `updatedAt` into the localStorage offline cache so an offline reload still has accurate ages. Tests: - `relative-time.test.ts`: bucket math, seconds-vs-ms detection, non-finite guard. - `ScratchlistPanel.test.tsx`: indicator renders with the right smart-relative bucket, falls back to `createdAt` when `updatedAt` is absent, and renders nothing when both timestamps are zero. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): bound client-supplied entryId length (HAPI Bot, PR #896) The POST /api/sessions/:id/scratchlist body validator left `entryId` unbounded (`z.string().min(1)`), but that string is persisted as part of the SQLite primary key. An authenticated/direct client could grow the table and its index well beyond the intended scratchlist limits by submitting oversized keys. Adds `SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128` (comfortably fits a UUID's 36 chars plus any prefix scheme we might layer on later) and applies `.max(...)` to the optional `entryId` in `ScratchlistEntryCreateRequestSchema`. Anything longer is rejected with 400 before the row hits SQLite. Test pins the new behavior: a 129-char id returns 400 and never reaches the engine. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): banner state machine - 'completed' is sticky until dismissed (HAPI Bot, PR #896) The previous state machine swallowed the migration banner if the operator reloaded the page before clicking dismiss: the migration flag was set on success, and on remount the init logic mapped a flag-set/dismiss-not-set session to 'pre-migrated', a state the banner explicitly refuses to render. Net effect: a migrated session never prompted for affirmative dismissal. Fixes: - Drop the 'pre-migrated' state. The dismissal flag is now the only signal that suppresses the banner; the migration flag alone means 'banner shows until dismissed' (now or after a reload). - Sessions that had nothing to migrate (no v1 entries in localStorage) pre-emptively write BOTH flags - migrated AND dismissed - so the bot's banner-stickiness fix doesn't surface a banner that has nothing to announce on freshly-created v2 sessions. Tests: - New `reload-before-dismiss leaves the banner visible` test pins the fix end-to-end: mount #1 migrates -> 'completed', unmount, mount #2 on the same session reads the localStorage flags and stays 'completed'. - New `opts fresh sessions out of the banner pre-emptively` test pins the no-v1-entries shortcut. - Existing `does not re-migrate on a mount where the migrated flag is already set` updated to assert 'completed' (not the dropped 'pre-migrated'). - Existing `skips migration when localStorage is empty` updated to assert the new 'dismissed' status + the banner-dismissed flag. - Banner test for the 'pre-migrated -> nothing' case removed (the state no longer exists). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): transfer rows during session merge so cascade-delete does not strand them (closes #920 for v2.0) `mergeSessionData` in `sessionCache.ts` ends every merge codepath with `deleteSession(oldSessionId)`, which fires `ON DELETE CASCADE` on every FK-tied table. `session_scratchlist.session_id` is FK'd with cascade, so without an explicit transfer step every dedup (#448 agent-id collision) and every resume-of-inactive (`syncEngine.resumeSession` -> mergeSessions) silently destroys the operator's per-session notes. This is the gap upstream-discovery agent flagged on #920 against PR #896. With the 2026-06-15 hub-restart cascade incident as evidence (23 sessions auto-archived in a single bounce, 4 confirmed HAPI-id rotations across 2 bounces), unmitigated this would violate v2.0's "survives reloads / second laptop / clear-site-data" promise the first time the operator hits a hub bounce. Fix: - New `transferScratchlistEntries(db, fromSessionId, toSessionId)` in `hub/src/store/scratchlist.ts`. Atomic via BEGIN/COMMIT. Uses `UPDATE OR IGNORE` so rows that would collide on PRIMARY KEY (session_id, entry_id) simply do not move - the dedup target's copy wins, matching the operator's mental model that the consolidated session is authoritative. Cleans up any collision-loser rows so the no-delete codepath (`mergeSessionHistory`) is symmetric with the delete path. - Wired into `mergeSessionData` BEFORE the `deleteSession()` call, alongside the existing message-merge step. Both `mergeSessions` (deleteOld=true) and `mergeSessionHistory` (deleteOld=false) get coverage because both can rotate the visible session id. - Emits `session-updated{scratchlistUpdatedAt}` on the new session so any web client looking at the consolidated id invalidates and refetches; for the keep-old codepath the emit also fires on the old id since it stays alive but is now empty of scratchlist. Tests (`sessionCache-merge-scratchlist.test.ts`, 7 cases): - mergeSessions (deleteOld=true): rows move, old is gone, no stranded rows. - mergeSessions PK collision: dedup target wins, unique-to-old rows still come across. - mergeSessions SSE: exactly one scratchlist patch on the new id. - mergeSessions no-op: zero rows -> zero emits. - mergeSessionHistory (deleteOld=false): rows move, old session stays alive but empty of scratchlist. - mergeSessionHistory SSE: emits on BOTH old and new ids. - Cascade-delete safety smoke: post-merge, an explicit operator delete of the new session DOES cascade-delete its scratchlist (i.e. the FK cascade we want is intact; the bug was triggering it on the wrong id). Web layer note: v1 localStorage is keyed by HAPI session id; on rotation the old key is orphaned but no longer represents data loss because the hub now holds the canonical state and the offline-cache mirror re-populates `hapi.scratchlist.v1.<newId>` on first read of the consolidated session. Documented as a known limitation; not a blocker for v2.0 because the hub is the source of truth. #894 (v2.1 migrate-on-delete) inherits a related concern about operator-Delete vs merge-Delete consent flow - flagged in the upstream-discovery handoff, separate scope. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): rebase onto upstream/main - scratchlist migration is V10→V11 Upstream landed V9→V10 as sessions.service_tier (#898/#904). Scratchlist v2 moves to V10→V11 so both migrations coexist without clobbering each other. - mergeSessionData conflict resolved: keep upstream migrateFromV9ToV10 (service_tier) and add migrateFromV10ToV11 (session_scratchlist) - SCHEMA_VERSION bumped 10 → 11 - Rename migration-v10.test.ts → migration-v11.test.ts with updated multi-hop coverage (V9→V10→V11) - Add serviceTier: null to scratchlist route test session fixture (required by upstream Session type after #898) Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): emit SSE on all-collision merge when old session stays alive (HAPI Bot, PR #896) When mergeSessionHistory deletes every old scratchlist row via PK collision (moved=0, collided>0) the still-alive old session kept showing stale cached entries until an unrelated refetch. Emit scratchlistUpdatedAt on the old id whenever collided>0 on the keep-old codepath, not only when moved>0. New-session emit stays gated on moved>0 since the target row is unchanged on full collision. Test pins the all-collision mergeSessionHistory case. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): stabilize migration queryKey to stop POST retry loop (HAPI Bot, PR #896) useMemo on queryKeys.scratchlist(sessionId) so the migration effect does not re-fire every render after a failed POST clears migrationAttemptedRef. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): dedupe optimistic add when SSE refetch wins race (HAPI Bot, PR #896) onSuccess now drops both the temporary optimistic id and any existing row with the canonical entryId so a fast SSE invalidation cannot leave twins. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): treat 404 on update/delete as stale cache, not rollback (HAPI Bot, PR #896) When another client already removed an entry, keep it gone locally and invalidate instead of restoring previousData from optimistic rollback. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): drop optimistic add ghost when previousData missing (HAPI Bot, PR #896) onError now filters by optimisticEntryId if the initial fetch never populated cache, so a rejected POST cannot leave an unsaved note. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
394 lines
16 KiB
TypeScript
394 lines
16 KiB
TypeScript
import { useId, useMemo, useRef, useState } from 'react'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import type { Session } from '@/types/api'
|
|
import type { ApiClient } from '@/api/client'
|
|
import { isTelegramApp } from '@/hooks/useTelegram'
|
|
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
|
|
import { SessionActionMenu } from '@/components/SessionActionMenu'
|
|
import { SessionExportDialog } from '@/components/SessionExportDialog'
|
|
import { RenameSessionDialog } from '@/components/RenameSessionDialog'
|
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
|
import { useScratchlistCount } from '@/lib/use-scratchlist-count'
|
|
import { formatReopenError } from '@/lib/reopenError'
|
|
import { formatCodexReasoningLabel, shouldShowCodexReasoningLabel } from '@/lib/codexStatusLabels'
|
|
import { getSessionModelLabel } from '@/lib/sessionModelLabel'
|
|
import { useTranslation } from '@/lib/use-translation'
|
|
import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
|
|
import { isFastServiceTier } from '@/components/AssistantChat/codexFastMode'
|
|
import { getSessionTitle } from '@/lib/sessionTitle'
|
|
import { useToast } from '@/lib/toast-context'
|
|
import { queryKeys } from '@/lib/query-keys'
|
|
import { markCodexSessionsImported } from '@/lib/codexImportedSessions'
|
|
|
|
function FilesIcon(props: { className?: string }) {
|
|
return (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={props.className}
|
|
>
|
|
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
|
|
<path d="M14 2v6h6" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function OutlineIcon(props: { className?: string }) {
|
|
return (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={props.className}
|
|
>
|
|
<path d="M8 6h13" />
|
|
<path d="M8 12h13" />
|
|
<path d="M8 18h13" />
|
|
<path d="M3 6h.01" />
|
|
<path d="M3 12h.01" />
|
|
<path d="M3 18h.01" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function headerToggleClass(active: boolean): string {
|
|
return `flex h-8 w-8 items-center justify-center rounded-full transition-colors ${
|
|
active
|
|
? 'bg-[var(--app-button)] text-[var(--app-button-text)] hover:opacity-90'
|
|
: 'text-[var(--app-hint)] hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]'
|
|
}`
|
|
}
|
|
|
|
function MoreVerticalIcon(props: { className?: string }) {
|
|
return (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
className={props.className}
|
|
>
|
|
<circle cx="12" cy="5" r="2" />
|
|
<circle cx="12" cy="12" r="2" />
|
|
<circle cx="12" cy="19" r="2" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function SessionHeader(props: {
|
|
session: Session
|
|
serviceTier?: string | null
|
|
onBack: () => void
|
|
onToggleFiles?: () => void
|
|
filesActive?: boolean
|
|
onToggleOutline?: () => void
|
|
outlineActive?: boolean
|
|
api: ApiClient | null
|
|
canReopen?: boolean
|
|
reopenDisabledReason?: string
|
|
onSessionDeleted?: () => void
|
|
onSessionReopened?: (newSessionId: string) => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const queryClient = useQueryClient()
|
|
const { addToast } = useToast()
|
|
const { session, api, onSessionDeleted, onSessionReopened } = props
|
|
const title = useMemo(() => getSessionTitle(session), [session])
|
|
const worktreeBranch = session.metadata?.worktree?.branch
|
|
const modelLabel = getSessionModelLabel(session)
|
|
const agentFlavor = session.metadata?.flavor ?? null
|
|
const reasoningLabel = shouldShowCodexReasoningLabel(agentFlavor)
|
|
? formatCodexReasoningLabel(session.modelReasoningEffort)
|
|
: null
|
|
// Match expected Fast badge semantics (#1004): only explicit service tier, no effort/model heuristics.
|
|
const showFastBadge = agentFlavor === 'codex' && isFastServiceTier(props.serviceTier ?? session.serviceTier)
|
|
const codexSessionId = session.metadata?.flavor === 'codex'
|
|
? session.metadata.codexSessionId?.trim() || null
|
|
: null
|
|
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
|
|
const menuId = useId()
|
|
const menuAnchorRef = useRef<HTMLButtonElement | null>(null)
|
|
const [renameOpen, setRenameOpen] = useState(false)
|
|
const [exportOpen, setExportOpen] = useState(false)
|
|
const [archiveOpen, setArchiveOpen] = useState(false)
|
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
const [isSyncingCodex, setIsSyncingCodex] = useState(false)
|
|
|
|
const { archiveSession, reopenSession, renameSession, deleteSession, isPending } = useSessionActions(
|
|
api,
|
|
session.id,
|
|
session.metadata?.flavor ?? null
|
|
)
|
|
const [reopenError, setReopenError] = useState<string | null>(null)
|
|
// tiann/hapi#893: surface the scratchlist entry count in the
|
|
// delete-confirm copy so the operator knows what cascades when they
|
|
// confirm. Read-only hook reuses the cache filled by SessionChat -
|
|
// no extra network when both components are mounted.
|
|
const scratchlistCount = useScratchlistCount(session.id, api)
|
|
|
|
const handleDelete = async () => {
|
|
await deleteSession()
|
|
onSessionDeleted?.()
|
|
}
|
|
|
|
const handleReopen = async () => {
|
|
setReopenError(null)
|
|
try {
|
|
const result = await reopenSession()
|
|
if (result.sessionId && result.sessionId !== session.id) {
|
|
onSessionReopened?.(result.sessionId)
|
|
}
|
|
} catch (error) {
|
|
setReopenError(formatReopenError(error))
|
|
}
|
|
}
|
|
|
|
const handleSyncCodex = async () => {
|
|
if (!api || !codexSessionId || isSyncingCodex) return
|
|
|
|
setIsSyncingCodex(true)
|
|
try {
|
|
// 中文注释:手动同步必须携带当前会话归属机器和目录;多台 runner 在线时后端不能靠猜。
|
|
const result = await api.syncCodexSession({
|
|
sessionIds: [codexSessionId],
|
|
cwd: typeof session.metadata?.path === 'string' ? session.metadata.path : undefined,
|
|
machineId: typeof session.metadata?.machineId === 'string' ? session.metadata.machineId : undefined
|
|
})
|
|
if (!result.success) {
|
|
throw new Error(result.error || t('codexSync.failed.body'))
|
|
}
|
|
|
|
markCodexSessionsImported([codexSessionId])
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.session(session.id) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.messages(session.id) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.sessions })
|
|
])
|
|
addToast({
|
|
title: t('codexSync.manual.success.title'),
|
|
body: (result.syncedCount ?? 1) === 0
|
|
? t('codexSync.manual.success.noNewMessages')
|
|
: t('codexSync.manual.success.body', { n: result.syncedCount ?? 1 }),
|
|
sessionId: session.id,
|
|
url: `/sessions/${session.id}`
|
|
})
|
|
} catch (error) {
|
|
addToast({
|
|
title: t('codexSync.manual.failed.title'),
|
|
body: error instanceof Error ? error.message : t('codexSync.failed.body'),
|
|
sessionId: session.id,
|
|
url: `/sessions/${session.id}`
|
|
})
|
|
} finally {
|
|
setIsSyncingCodex(false)
|
|
}
|
|
}
|
|
|
|
const handleMenuToggle = () => {
|
|
if (!menuOpen && menuAnchorRef.current) {
|
|
const rect = menuAnchorRef.current.getBoundingClientRect()
|
|
setMenuAnchorPoint({ x: rect.right, y: rect.bottom })
|
|
}
|
|
setMenuOpen((open) => !open)
|
|
}
|
|
|
|
// In Telegram, don't render header (Telegram provides its own)
|
|
if (isTelegramApp()) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
|
|
<div className="mx-auto w-full max-w-content flex items-center gap-2 p-3">
|
|
{/* Back button */}
|
|
<button
|
|
type="button"
|
|
onClick={props.onBack}
|
|
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<polyline points="15 18 9 12 15 6" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Session info - two lines: title and path */}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate font-semibold">
|
|
{title}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-[var(--app-hint)]">
|
|
<span className="inline-flex items-center gap-1">
|
|
<AgentFlavorIcon flavor={session.metadata?.flavor} className="h-3.5 w-3.5 shrink-0 -translate-y-px" />
|
|
{session.metadata?.flavor?.trim() || 'unknown'}
|
|
</span>
|
|
{modelLabel ? (
|
|
<span>
|
|
{t(modelLabel.key)}: {modelLabel.value}
|
|
</span>
|
|
) : null}
|
|
{reasoningLabel ? (
|
|
<span data-testid="session-header-reasoning" className="hidden sm:inline">
|
|
{reasoningLabel}
|
|
</span>
|
|
) : null}
|
|
{showFastBadge ? (
|
|
<span data-testid="session-header-fast" className="text-[#34C759]">
|
|
fast
|
|
</span>
|
|
) : null}
|
|
{worktreeBranch ? (
|
|
<span>{t('session.item.worktree')}: {worktreeBranch}</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{props.onToggleFiles ? (
|
|
<button
|
|
type="button"
|
|
onClick={props.onToggleFiles}
|
|
className={headerToggleClass(props.filesActive ?? false)}
|
|
title={props.filesActive ? t('session.view.returnToChat') : t('session.title')}
|
|
aria-label={props.filesActive ? t('session.view.returnToChat') : t('session.title')}
|
|
aria-pressed={props.filesActive ?? false}
|
|
>
|
|
<FilesIcon />
|
|
</button>
|
|
) : null}
|
|
|
|
{props.onToggleOutline ? (
|
|
<button
|
|
type="button"
|
|
onClick={props.onToggleOutline}
|
|
className={headerToggleClass(props.outlineActive ?? false)}
|
|
title={props.outlineActive ? t('session.outline.close') : t('session.outline.open')}
|
|
aria-label={props.outlineActive ? t('session.outline.close') : t('session.outline.open')}
|
|
aria-pressed={props.outlineActive ?? false}
|
|
>
|
|
<OutlineIcon />
|
|
</button>
|
|
) : null}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleMenuToggle}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
ref={menuAnchorRef}
|
|
aria-haspopup="menu"
|
|
aria-expanded={menuOpen}
|
|
aria-controls={menuOpen ? menuId : undefined}
|
|
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
|
|
title={t('session.more')}
|
|
>
|
|
<MoreVerticalIcon />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<SessionActionMenu
|
|
isOpen={menuOpen}
|
|
onClose={() => setMenuOpen(false)}
|
|
sessionId={session.id}
|
|
sessionTitle={title}
|
|
sessionActive={session.active}
|
|
onRename={() => setRenameOpen(true)}
|
|
onExport={() => setExportOpen(true)}
|
|
onSyncCodex={api && codexSessionId ? handleSyncCodex : undefined}
|
|
onArchive={() => setArchiveOpen(true)}
|
|
onReopen={props.canReopen === false ? undefined : handleReopen}
|
|
reopenDisabledReason={props.reopenDisabledReason}
|
|
onDelete={() => setDeleteOpen(true)}
|
|
anchorPoint={menuAnchorPoint}
|
|
menuId={menuId}
|
|
/>
|
|
|
|
{reopenError ? (
|
|
<ConfirmDialog
|
|
isOpen={true}
|
|
onClose={() => setReopenError(null)}
|
|
title={t('dialog.reopen.errorTitle')}
|
|
description={reopenError}
|
|
confirmLabel={t('dialog.reopen.dismiss')}
|
|
confirmingLabel={t('dialog.reopen.dismiss')}
|
|
onConfirm={async () => setReopenError(null)}
|
|
isPending={false}
|
|
/>
|
|
) : null}
|
|
|
|
<RenameSessionDialog
|
|
isOpen={renameOpen}
|
|
onClose={() => setRenameOpen(false)}
|
|
currentName={title}
|
|
onRename={renameSession}
|
|
isPending={isPending}
|
|
/>
|
|
|
|
<SessionExportDialog
|
|
isOpen={exportOpen}
|
|
onClose={() => setExportOpen(false)}
|
|
sessionId={session.id}
|
|
api={api}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
isOpen={archiveOpen}
|
|
onClose={() => setArchiveOpen(false)}
|
|
title={t('dialog.archive.title')}
|
|
description={t('dialog.archive.description', { name: title })}
|
|
confirmLabel={t('dialog.archive.confirm')}
|
|
confirmingLabel={t('dialog.archive.confirming')}
|
|
onConfirm={archiveSession}
|
|
isPending={isPending}
|
|
destructive
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
isOpen={deleteOpen}
|
|
onClose={() => setDeleteOpen(false)}
|
|
title={t('dialog.delete.title')}
|
|
description={
|
|
scratchlistCount > 0
|
|
? `${t('dialog.delete.description', { name: title })} ${t(
|
|
scratchlistCount === 1
|
|
? 'dialog.delete.scratchlist.one'
|
|
: 'dialog.delete.scratchlist.other',
|
|
{ n: String(scratchlistCount) }
|
|
)}`
|
|
: t('dialog.delete.description', { name: title })
|
|
}
|
|
confirmLabel={t('dialog.delete.confirm')}
|
|
confirmingLabel={t('dialog.delete.confirming')}
|
|
onConfirm={handleDelete}
|
|
isPending={isPending}
|
|
destructive
|
|
/>
|
|
</>
|
|
)
|
|
}
|