diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts
index 7d2270d317..c4b74b6208 100644
--- a/web/src/components/SessionList.test.ts
+++ b/web/src/components/SessionList.test.ts
@@ -9,7 +9,8 @@ import {
normalizeSearch,
prepareSidebarSessions,
sessionMatchesQuery,
- shouldShowSessionInSidebar
+ shouldShowSessionInSidebar,
+ sortPreviewSessions
} from './SessionList'
function makeSession(overrides: Partial
& { id: string }): SessionSummary {
@@ -295,6 +296,24 @@ describe('getVisibleSessionPreview', () => {
})
})
+describe('sortPreviewSessions', () => {
+ it('prioritizes permission-needed, then live, then recent sessions', () => {
+ const sessions = [
+ makeSession({ id: 'old-live', active: true, updatedAt: 10 }),
+ makeSession({ id: 'recent-inactive', updatedAt: 100 }),
+ makeSession({ id: 'needs-input', pendingRequestsCount: 1, updatedAt: 1 }),
+ makeSession({ id: 'new-live', active: true, updatedAt: 20 }),
+ ]
+
+ expect(sortPreviewSessions(sessions).map(session => session.id)).toEqual([
+ 'needs-input',
+ 'new-live',
+ 'old-live',
+ 'recent-inactive',
+ ])
+ })
+})
+
describe('expandSelectedSessionCollapseOverrides', () => {
it('expands collapsed project and machine, but preserves session preview folding', () => {
diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx
index a173e7aebc..27b63e4cf6 100644
--- a/web/src/components/SessionList.tsx
+++ b/web/src/components/SessionList.tsx
@@ -18,6 +18,7 @@ import { getSessionLastSeenAt } from '@/lib/sessionLastSeen'
import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator'
import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions'
import { formatReopenError } from '@/lib/reopenError'
+import { isPreviewUiMode } from '@/lib/runtime-config'
type SessionGroup = {
key: string
@@ -493,6 +494,20 @@ export function getVisibleSessionPreview(
return visible
}
+export function sortPreviewSessions(sessions: SessionSummary[]): SessionSummary[] {
+ return [...sessions].sort((a, b) => {
+ const rank = (session: SessionSummary) => {
+ if (session.pendingRequestsCount > 0) return 0
+ if (session.active) return 1
+ return 2
+ }
+ const rankA = rank(a)
+ const rankB = rank(b)
+ if (rankA !== rankB) return rankA - rankB
+ return b.updatedAt - a.updatedAt
+ })
+}
+
function SessionListSearch(props: {
value: string
onChange: (value: string) => void
@@ -594,9 +609,18 @@ function SessionItem(props: {
api: ApiClient | null
selected?: boolean
showDetailedStatus?: boolean
+ simplified?: boolean
}) {
const { t } = useTranslation()
- const { session: s, onSelect, showPath = true, api, selected = false, showDetailedStatus = false } = props
+ const {
+ session: s,
+ onSelect,
+ showPath = true,
+ api,
+ selected = false,
+ showDetailedStatus = false,
+ simplified = false
+ } = props
const { haptic } = usePlatform()
const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
@@ -640,7 +664,7 @@ function SessionItem(props: {
})
const sessionName = getSessionTitle(s)
- const todoProgress = getTodoProgress(s)
+ const todoProgress = simplified ? null : getTodoProgress(s)
const attention = useMemo(
() => showDetailedStatus
? classifySessionAttention(s, {
@@ -654,6 +678,7 @@ function SessionItem(props: {
const scheduledLabel = s.futureScheduledMessageCount > 1
? t('session.item.scheduledMessages', { count: s.futureScheduledMessageCount })
: t('session.item.scheduledMessage')
+ const metadataLabel = s.metadata?.worktree?.basePath ?? s.metadata?.path ?? s.id
return (
<>
@@ -781,9 +806,10 @@ export function SessionList(props: {
}) {
const { t } = useTranslation()
const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, onNewSessionInDirectory } = props
+ const isPreviewMode = isPreviewUiMode()
const { sessionPreviewLimit } = useSessionPreviewLimit()
const { sessionListStatusMode } = useSessionListStatusMode()
- const showDetailedStatus = sessionListStatusMode === 'detailed'
+ const showDetailedStatus = isPreviewMode || sessionListStatusMode === 'detailed'
const [searchQuery, setSearchQuery] = useState('')
const [, setCodexImportedSessionsVersion] = useState(0)
const normalizedQuery = normalizeSearch(searchQuery)
@@ -949,6 +975,62 @@ export function SessionList(props: {
})
}, [allGroups])
+ if (isPreviewMode) {
+ return (
+
+ {renderHeader ? (
+
+
+ {isSearching
+ ? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length })
+ : t('sessions.count', { n: allSessions.length, m: allGroups.length })}
+
+
+
+ ) : null}
+
+ {props.sessions.length > 0 ? (
+
+ ) : null}
+
+ {props.sessions.length === 0 ? (
+
+ ) : null}
+
+ {props.sessions.length > 0 && isSearching && visibleSessions.length === 0 ? (
+
+ {t('sessions.search.noResults')}
+
+ ) : null}
+
+
+ {sortPreviewSessions(visibleSessions).map((session) => (
+
+ ))}
+
+
+ )
+ }
+
return (
{renderHeader ? (
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts
index 5a0aba4e82..4c56466b7a 100644
--- a/web/src/lib/locales/en.ts
+++ b/web/src/lib/locales/en.ts
@@ -205,6 +205,7 @@ export default {
'newSession.type.worktree.desc': 'Create a new worktree next to repo',
'newSession.type.worktree.placeholder': 'feature-x (default 1228-xxxx)',
'newSession.agent': 'Agent',
+ 'newSession.advanced': 'Advanced',
'newSession.model': 'Model',
'newSession.effort': 'Effort',
'newSession.model.optional': 'optional',
diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts
index d3cecec503..0755f73fba 100644
--- a/web/src/lib/locales/zh-CN.ts
+++ b/web/src/lib/locales/zh-CN.ts
@@ -209,6 +209,7 @@ export default {
'newSession.type.worktree.desc': '在仓库旁创建新工作树',
'newSession.type.worktree.placeholder': 'feature-x (默认 1228-xxxx)',
'newSession.agent': '代理',
+ 'newSession.advanced': '高级',
'newSession.model': '模型',
'newSession.effort': '思考强度',
'newSession.model.optional': '可选',
diff --git a/web/src/lib/runtime-config.test.ts b/web/src/lib/runtime-config.test.ts
new file mode 100644
index 0000000000..d2069a5574
--- /dev/null
+++ b/web/src/lib/runtime-config.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+import { getPreviewBasepath, isPreviewUiMode, normalizeBaseUrl } from './runtime-config'
+
+describe('preview runtime config', () => {
+ it('normalizes vite base urls', () => {
+ expect(normalizeBaseUrl(undefined)).toBe('/')
+ expect(normalizeBaseUrl('/')).toBe('/')
+ expect(normalizeBaseUrl('new')).toBe('/new/')
+ expect(normalizeBaseUrl('/new')).toBe('/new/')
+ expect(normalizeBaseUrl('/new/')).toBe('/new/')
+ })
+
+ it('detects only the /new preview basepath', () => {
+ expect(getPreviewBasepath('/new/')).toBe('/new')
+ expect(isPreviewUiMode('/new/')).toBe(true)
+ expect(getPreviewBasepath('/')).toBeUndefined()
+ expect(isPreviewUiMode('/')).toBe(false)
+ })
+})
diff --git a/web/src/lib/runtime-config.ts b/web/src/lib/runtime-config.ts
index 09a48c463c..1ff48e810e 100644
--- a/web/src/lib/runtime-config.ts
+++ b/web/src/lib/runtime-config.ts
@@ -10,3 +10,20 @@ function parseBooleanFlag(value: string | undefined): boolean {
export function requireHubUrlForLogin(): boolean {
return parseBooleanFlag(import.meta.env.VITE_REQUIRE_HUB_URL)
}
+
+export function normalizeBaseUrl(value: string | undefined): string {
+ if (!value || value === '/') {
+ return '/'
+ }
+ const withLeadingSlash = value.startsWith('/') ? value : `/${value}`
+ return withLeadingSlash.endsWith('/') ? withLeadingSlash : `${withLeadingSlash}/`
+}
+
+export function getPreviewBasepath(baseUrl: string = import.meta.env.BASE_URL): string | undefined {
+ const normalized = normalizeBaseUrl(baseUrl)
+ return normalized === '/new/' ? '/new' : undefined
+}
+
+export function isPreviewUiMode(baseUrl: string = import.meta.env.BASE_URL): boolean {
+ return getPreviewBasepath(baseUrl) !== undefined
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 569e77efd0..393cca3fce 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -8,7 +8,7 @@ import { registerSW } from 'virtual:pwa-register'
import { initializeFontScale } from '@/hooks/useFontScale'
import { getTelegramWebApp, isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram'
import { queryClient } from './lib/query-client'
-import { createAppRouter } from './router'
+import { createAppRouter, getAppRouterBasepath } from './router'
import { I18nProvider } from './lib/i18n-context'
import { restoreSpaRedirect } from './lib/spaRedirect'
import { installScrollRestorationGuard } from './lib/scrollStorageGuard'
@@ -76,7 +76,9 @@ async function bootstrap() {
const history = isTelegram
? createMemoryHistory({ initialEntries: [getInitialPath()] })
: undefined
- const router = createAppRouter(history)
+ const router = createAppRouter(history, {
+ basepath: isTelegram ? '/' : getAppRouterBasepath()
+ })
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/web/src/router.test.ts b/web/src/router.test.ts
new file mode 100644
index 0000000000..8cf0db4d79
--- /dev/null
+++ b/web/src/router.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import { createMemoryHistory } from '@tanstack/react-router'
+import { createAppRouter, getAppRouterBasepath } from './router'
+
+describe('createAppRouter', () => {
+ it('keeps root routes unprefixed by default', () => {
+ const router = createAppRouter(createMemoryHistory({ initialEntries: ['/sessions'] }))
+
+ expect(router.options.basepath).toBe('/')
+ })
+
+ it('uses /new as the browser basepath for preview builds', () => {
+ const router = createAppRouter(createMemoryHistory({ initialEntries: ['/new/sessions'] }), {
+ basepath: '/new',
+ })
+
+ expect(router.options.basepath).toBe('/new')
+ expect(getAppRouterBasepath('/new/')).toBe('/new')
+ })
+})
diff --git a/web/src/router.tsx b/web/src/router.tsx
index 84e030a531..aa59bcec53 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -40,6 +40,7 @@ import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend'
import { inactiveSessionCanResume } from '@/lib/sessionResume'
import { markSessionSeen } from '@/lib/sessionLastSeen'
import { clearCodexImportedSession, markCodexSessionsImported } from '@/lib/codexImportedSessions'
+import { getPreviewBasepath } from '@/lib/runtime-config'
import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary } from '@/types/api'
import FilesPage from '@/routes/sessions/files'
import FilePage from '@/routes/sessions/file'
@@ -1103,15 +1104,20 @@ export const routeTree = rootRoute.addChildren([
type RouterHistory = Parameters[0]['history']
-export function createAppRouter(history?: RouterHistory) {
+export function createAppRouter(history?: RouterHistory, options: { basepath?: string } = {}) {
return createRouter({
routeTree,
history,
+ basepath: options.basepath ?? '/',
scrollRestoration: true,
getScrollRestorationKey,
})
}
+export function getAppRouterBasepath(baseUrl: string = import.meta.env.BASE_URL): string {
+ return getPreviewBasepath(baseUrl) ?? '/'
+}
+
export type AppRouter = ReturnType
declare module '@tanstack/react-router' {