Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b80ebc5
feat(web): add theme selector with Catpuccin as optional theme
gaius-codius Feb 18, 2026
67339d4
feat(web): add Gaius theme with Roman-inspired palette
gaius-codius Feb 18, 2026
7087fbe
fix(web): improve clipboard reliability and terminal paste fallback
gaius-codius Feb 18, 2026
cb399fb
fix(web): skip paste fallback dialog when clipboard is empty
gaius-codius Feb 18, 2026
cf7a7c1
fix(web): move file content copy action into file viewer
gaius-codius Feb 18, 2026
50501c3
feat(web): group sessions by machine and directory in sidebar
gaius-codius Feb 18, 2026
65caf12
fix(web): simplify session context labels in list
gaius-codius Feb 18, 2026
f93a499
feat(web): improve session list badges and permission mode display
gaius-codius Feb 18, 2026
7ec4cd7
fix(web): harden theme platform detection and theme label copy
gaius-codius Feb 18, 2026
afa34b2
refactor(web): simplify session badge rendering logic
gaius-codius Feb 18, 2026
b3a31ad
test(web): cover theme preference resolution and persistence
gaius-codius Feb 18, 2026
55bcb88
test(web): add clipboard fallback and terminal paste coverage
gaius-codius Feb 18, 2026
6d22b95
test(web): verify machine grouping and permission-mode badges
gaius-codius Feb 18, 2026
8423d82
fix(web): keep theme reactivity active outside settings page
gaius-codius Feb 18, 2026
0602869
fix(web): add baseline badge and flavor color tokens for session UI
gaius-codius Feb 19, 2026
e671094
chore(web): keep session-ui color token baseline in session-ui PR
gaius-codius Feb 19, 2026
2d7b5f8
merge: bring in PR186 theme system
gaius-codius Feb 19, 2026
78cffb0
merge: bring in PR187 clipboard/terminal fixes
gaius-codius Feb 19, 2026
a3f2e27
merge: bring in PR188 session list UI
gaius-codius Feb 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion shared/src/sessionSummary.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ModelMode } from './modes'
import type { ModelMode, PermissionMode } from './modes'
import type { Session, WorktreeMetadata } from './schemas'

export type SessionSummaryMetadata = {
Expand All @@ -19,6 +19,7 @@ export type SessionSummary = {
metadata: SessionSummaryMetadata | null
todoProgress: { completed: number; total: number } | null
pendingRequestsCount: number
permissionMode?: PermissionMode
modelMode?: ModelMode
}

Expand Down Expand Up @@ -48,6 +49,7 @@ export function toSessionSummary(session: Session): SessionSummary {
metadata,
todoProgress,
pendingRequestsCount,
permissionMode: session.permissionMode,
modelMode: session.modelMode
}
}
3 changes: 2 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Outlet, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram'
import { initializeTheme } from '@/hooks/useTheme'
import { initializeTheme, useTheme } from '@/hooks/useTheme'
import { useAuth } from '@/hooks/useAuth'
import { useAuthSource } from '@/hooks/useAuthSource'
import { useServerUrl } from '@/hooks/useServerUrl'
Expand Down Expand Up @@ -41,6 +41,7 @@ export function App() {
}

function AppInner() {
useTheme()
const { t } = useTranslation()
const { serverUrl, baseUrl, setServerUrl, clearServerUrl } = useServerUrl()
const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource(baseUrl)
Expand Down
41 changes: 32 additions & 9 deletions web/src/components/SessionHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useId, useMemo, useRef, useState } from 'react'
import type { Session } from '@/types/api'
import type { ApiClient } from '@/api/client'
import { getPermissionModeLabel, getPermissionModeTone, isPermissionModeAllowedForFlavor } from '@hapi/protocol'
import { isTelegramApp } from '@/hooks/useTelegram'
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
import { SessionActionMenu } from '@/components/SessionActionMenu'
import { RenameSessionDialog } from '@/components/RenameSessionDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { useTranslation } from '@/lib/use-translation'
import { getFlavorBadgeClass, PERMISSION_TONE_BADGE } from '@/lib/agentFlavorUtils'

function getSessionTitle(session: Session): string {
if (session.metadata?.name) {
Expand Down Expand Up @@ -70,6 +72,19 @@ export function SessionHeader(props: {
const { session, api, onSessionDeleted } = props
const title = useMemo(() => getSessionTitle(session), [session])
const worktreeBranch = session.metadata?.worktree?.branch
const flavor = session.metadata?.flavor?.trim() ?? null
const flavorLabel = flavor || 'unknown'
const flavorBadgeClass = getFlavorBadgeClass(flavor)
const permMode = session.permissionMode
&& session.permissionMode !== 'default'
&& isPermissionModeAllowedForFlavor(session.permissionMode, flavor)
? session.permissionMode
: null
const permissionLabel = permMode ? getPermissionModeLabel(permMode).toLowerCase() : null
const permissionBadgeClass = permMode
? PERMISSION_TONE_BADGE[getPermissionModeTone(permMode)]
: null
const showModelModeBadge = !flavor || flavor === 'claude'

const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
Expand Down Expand Up @@ -128,21 +143,29 @@ export function SessionHeader(props: {
</svg>
</button>

{/* Session info - two lines: title and path */}
{/* Session info - two lines: title and badges */}
<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">
<span aria-hidden="true"></span>
{session.metadata?.flavor?.trim() || 'unknown'}
</span>
<span>
{t('session.item.modelMode')}: {session.modelMode || 'default'}
<div className="flex flex-wrap items-center gap-1.5 pt-1 text-xs">
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 font-medium ${flavorBadgeClass}`}>
{flavorLabel}
</span>
{permissionLabel && permissionBadgeClass ? (
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 font-medium ${permissionBadgeClass}`}>
{permissionLabel}
</span>
) : null}
{showModelModeBadge ? (
<span className="inline-flex items-center rounded-full border border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-2 py-0.5 font-medium text-[var(--app-fg)]">
{session.modelMode || 'default'}
</span>
) : null}
{worktreeBranch ? (
<span>{t('session.item.worktree')}: {worktreeBranch}</span>
<span className="inline-flex items-center rounded-full border border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-2 py-0.5 text-[var(--app-hint)]">
{worktreeBranch}
</span>
) : null}
</div>
</div>
Expand Down
117 changes: 117 additions & 0 deletions web/src/components/SessionList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import type { SessionSummary } from '@/types/api'
import { I18nProvider } from '@/lib/i18n-context'
import { SessionList } from './SessionList'

vi.mock('@/hooks/useLongPress', () => ({
useLongPress: ({ onClick }: { onClick: () => void }) => ({ onClick })
}))

vi.mock('@/hooks/usePlatform', () => ({
usePlatform: () => ({ haptic: { impact: vi.fn() } })
}))

vi.mock('@/hooks/mutations/useSessionActions', () => ({
useSessionActions: () => ({
archiveSession: vi.fn(),
renameSession: vi.fn(),
deleteSession: vi.fn(),
isPending: false
})
}))

vi.mock('@/components/SessionActionMenu', () => ({
SessionActionMenu: () => null
}))

vi.mock('@/components/RenameSessionDialog', () => ({
RenameSessionDialog: () => null
}))

vi.mock('@/components/ui/ConfirmDialog', () => ({
ConfirmDialog: () => null
}))

function makeSession(overrides: Partial<SessionSummary>): SessionSummary {
const id = overrides.id ?? 'session-1'
return {
id,
active: overrides.active ?? true,
thinking: overrides.thinking ?? false,
activeAt: overrides.activeAt ?? 1,
updatedAt: overrides.updatedAt ?? 1,
metadata: overrides.metadata ?? {
name: id,
path: '/repo/app',
machineId: 'machine-1',
flavor: 'claude',
summary: { text: id }
},
todoProgress: overrides.todoProgress ?? null,
pendingRequestsCount: overrides.pendingRequestsCount ?? 0,
permissionMode: overrides.permissionMode,
modelMode: overrides.modelMode
}
}

function renderList(sessions: SessionSummary[], machineLabelsById?: Record<string, string>) {
return render(
<I18nProvider>
<SessionList
sessions={sessions}
onSelect={vi.fn()}
onNewSession={vi.fn()}
onRefresh={vi.fn()}
isLoading={false}
renderHeader={false}
api={null}
machineLabelsById={machineLabelsById}
/>
</I18nProvider>
)
}

describe('SessionList', () => {
it('groups sessions by machine and directory', () => {
const sessions = [
makeSession({
id: 's1',
metadata: { path: '/repo/app', machineId: 'm1', flavor: 'claude' },
updatedAt: 100
}),
makeSession({
id: 's2',
metadata: { path: '/repo/app', machineId: 'm2', flavor: 'claude' },
updatedAt: 90
})
]

renderList(sessions, { m1: 'Laptop', m2: 'Server' })

expect(screen.getByText('Laptop')).toBeInTheDocument()
expect(screen.getByText('Server')).toBeInTheDocument()
})

it('shows permission badge only when mode allowed for flavor', () => {
const sessions = [
makeSession({
id: 'claude-plan',
metadata: { path: '/repo/claude', machineId: 'm1', flavor: 'claude' },
permissionMode: 'plan'
}),
makeSession({
id: 'codex-plan',
metadata: { path: '/repo/codex', machineId: 'm1', flavor: 'codex' },
permissionMode: 'plan'
})
]

renderList(sessions, { m1: 'Laptop' })

expect(screen.getByText('plan mode')).toBeInTheDocument()
const codexRow = screen.getAllByText('codex')[0]?.closest('button')
expect(codexRow).toBeTruthy()
expect(codexRow?.textContent?.toLowerCase()).not.toContain('plan mode')
})
})
Loading