From b7292570c03db63e7467335055b479a5d3533ae5 Mon Sep 17 00:00:00 2001 From: Hermes Coder Date: Sat, 13 Jun 2026 14:23:36 +0000 Subject: [PATCH 1/2] feat(web): add /new preview UI --- .gitignore | 3 + hub/src/web/previewStatic.test.ts | 56 +++++ hub/src/web/server.ts | 53 ++++ package.json | 1 + plan.md | 307 ++++++++++++++++++++++++ web/package.json | 1 + web/src/components/NewSession/index.tsx | 136 +++++++---- web/src/components/SessionList.test.ts | 21 +- web/src/components/SessionList.tsx | 90 ++++++- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + web/src/lib/runtime-config.test.ts | 19 ++ web/src/lib/runtime-config.ts | 17 ++ web/src/main.tsx | 6 +- web/src/router.test.ts | 20 ++ web/src/router.tsx | 8 +- 16 files changed, 689 insertions(+), 51 deletions(-) create mode 100644 hub/src/web/previewStatic.test.ts create mode 100644 plan.md create mode 100644 web/src/lib/runtime-config.test.ts create mode 100644 web/src/router.test.ts diff --git a/.gitignore b/.gitignore index 1ab099e267..15447ee2b8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # Build output **/dist/ +**/dist-new/ **/.vitepress/cache/ **/dist-exe/ **/*.tsbuildinfo @@ -42,6 +43,8 @@ execplan/ # Generated npm bundle output (local) cli/npm/main/ .ace-tool/ +.hermes/ +.hermes-prompts/ # Playwright e2e artifacts test-results/ diff --git a/hub/src/web/previewStatic.test.ts b/hub/src/web/previewStatic.test.ts new file mode 100644 index 0000000000..6eb7c783f2 --- /dev/null +++ b/hub/src/web/previewStatic.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'bun:test' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Hono } from 'hono' +import { mountMissingPreviewRoutes, mountPreviewStaticRoutes } from './server' + +function createPreviewDist(): string { + const distDir = mkdtempSync(join(tmpdir(), 'hapi-preview-dist-')) + mkdirSync(join(distDir, 'assets'), { recursive: true }) + writeFileSync(join(distDir, 'index.html'), '
preview
') + writeFileSync(join(distDir, 'assets', 'app.js'), 'console.log("preview")') + writeFileSync(join(distDir, 'manifest.webmanifest'), '{"name":"HAPI Preview"}') + return distDir +} + +describe('preview static routes', () => { + it('serves preview assets and deep links under /new without shadowing root', async () => { + const app = new Hono() + mountPreviewStaticRoutes(app, createPreviewDist()) + app.get('/', (c) => c.text('root')) + + const asset = await app.request('/new/assets/app.js') + expect(asset.status).toBe(200) + expect(await asset.text()).toBe('console.log("preview")') + + const manifest = await app.request('/new/manifest.webmanifest') + expect(manifest.status).toBe(200) + expect(await manifest.text()).toContain('HAPI Preview') + + const deepLink = await app.request('/new/sessions/session-1') + expect(deepLink.status).toBe(200) + expect(await deepLink.text()).toContain('preview') + + const root = await app.request('/') + expect(root.status).toBe(200) + expect(await root.text()).toBe('root') + }) + + it('does not mount preview routes when index.html is missing', async () => { + const distDir = mkdtempSync(join(tmpdir(), 'hapi-preview-missing-')) + const app = new Hono() + expect(mountPreviewStaticRoutes(app, distDir)).toBe(false) + }) + + it('returns an explicit 503 for /new when the preview artifact is missing', async () => { + const app = new Hono() + mountMissingPreviewRoutes(app) + app.get('*', (c) => c.text('root fallback')) + + const response = await app.request('/new/sessions/session-1') + + expect(response.status).toBe(503) + expect(await response.text()).toContain('Preview app is not built') + }) +}) diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index b0cf0592c5..0abe235a20 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -190,6 +190,55 @@ function findWebappDistDir(): { distDir: string; indexHtmlPath: string } { return { distDir, indexHtmlPath: join(distDir, 'index.html') } } +function findPreviewWebappDistDir(): string | null { + const configured = process.env.HAPI_WEB_PREVIEW_DIST_DIR?.trim() + const candidates = [ + ...(configured ? [configured] : []), + join(process.cwd(), '..', 'web', 'dist-new'), + join(import.meta.dir, '..', '..', '..', 'web', 'dist-new'), + join(process.cwd(), 'web', 'dist-new') + ] + + for (const distDir of candidates) { + if (existsSync(join(distDir, 'index.html'))) { + return distDir + } + } + + return null +} + +export function mountPreviewStaticRoutes(app: Hono, distDir: string | null): boolean { + if (!distDir || !existsSync(join(distDir, 'index.html'))) { + return false + } + + app.use('/new/*', async (c, next) => { + if (c.req.method !== 'GET' && c.req.method !== 'HEAD') { + await next() + return + } + + return await serveStatic({ + root: distDir, + rewriteRequestPath: (path) => path.replace(/^\/new\//, '/') + })(c, next) + }) + app.get('/new', async (c, next) => { + return await serveStatic({ root: distDir, path: 'index.html' })(c, next) + }) + app.get('/new/*', async (c, next) => { + return await serveStatic({ root: distDir, path: 'index.html' })(c, next) + }) + + return true +} + +export function mountMissingPreviewRoutes(app: Hono): void { + app.get('/new', (c) => c.text('Preview app is not built.\n\nRun:\n cd web\n bun run build:preview\n', 503)) + app.get('/new/*', (c) => c.text('Preview app is not built.\n\nRun:\n cd web\n bun run build:preview\n', 503)) +} + function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response { return new Response(Bun.file(asset.sourcePath), { headers: { @@ -316,6 +365,10 @@ from GitHub Pages instead of through the relay tunnel. return app } + if (!mountPreviewStaticRoutes(app, findPreviewWebappDistDir())) { + mountMissingPreviewRoutes(app) + } + const { distDir, indexHtmlPath } = findWebappDistDir() if (!existsSync(indexHtmlPath)) { diff --git a/package.json b/package.json index c33c2acc89..c1b62af594 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "download:tunwg": "bun run hub/scripts/download-tunwg.ts", "build:hub": "cd hub && bun run build", "build:web": "cd web && bun run build", + "build:web:preview": "cd web && bun run build:preview", "dev:hub": "cd hub && bun run dev", "dev:web": "cd web && bun run dev", "typecheck": "bun run typecheck:cli && bun run typecheck:web && bun run typecheck:hub", diff --git a/plan.md b/plan.md new file mode 100644 index 0000000000..750a6977fd --- /dev/null +++ b/plan.md @@ -0,0 +1,307 @@ +# Phase 1 Plan: `/new` UI Preview + +## Background and Goals + +Current production app is served at `https://tgbackup.030.qzz.io/`. The next phase is a frontend UI refactor focused on reducing visual clutter and making common remote-control tasks faster to understand. + +Goals: +- Ship the refactored frontend first at `https://tgbackup.030.qzz.io/new` for user testing. +- Do not change existing `/` behavior during preview. +- Keep all existing API/backend behavior intact unless explicitly replaced later. +- Make simplification reversible: hide, de-emphasize, or move secondary UI before deleting code. + +Non-goals for Phase 1: +- No direct production replacement at `/`. +- No backend/API feature removal. +- No automatic promotion from `/new` to `/`. + +## Current Frontend Structure Findings + +Frontend location: +- Main app: `web/` +- Entry: `web/src/main.tsx` +- Root app shell/auth/SSE/providers: `web/src/App.tsx` +- Router: `web/src/router.tsx` +- Global styles: `web/src/index.css` +- Main UI components: `web/src/components/` +- Session pages: `web/src/routes/sessions/` +- Settings page: `web/src/routes/settings/index.tsx` + +Routing: +- TanStack Router route tree in `web/src/router.tsx`. +- Current routes include `/`, `/sessions`, `/sessions/new`, `/sessions/$sessionId`, `/sessions/$sessionId/files`, `/sessions/$sessionId/file`, `/sessions/$sessionId/terminal`, `/browse`, `/settings`. +- `/` redirects to `/sessions`. +- Telegram mode uses memory history in `web/src/main.tsx`; browser mode uses default browser history. +- Current router has no explicit `/new` basepath handling. + +Build config: +- Vite config: `web/vite.config.ts`. +- `VITE_BASE_URL` controls Vite `base`, PWA manifest `scope`, and PWA `start_url`; default `/`. +- Web build script: `web/package.json` -> `vite build && cp dist/index.html dist/404.html`. +- Root build script: `bun run build:web`. +- Built files land in `web/dist`. +- PWA service worker: `web/src/sw.ts`; VitePWA injectManifest configured in `web/vite.config.ts`. + +Hub/static deploy config: +- Hub serves built web assets in `hub/src/web/server.ts`. +- Non-compiled mode finds `web/dist` and serves `/assets/*`, then static files, then `index.html` fallback for all non-API GET paths. +- Compiled mode embeds `web/dist` via `hub/scripts/generate-embedded-web-assets.ts` and serves embedded assets by exact request path, then falls back to root `index.html`. +- API routes are under `/api/*`; Socket.IO is under `/socket.io/`. +- Current hub server is root-SPA oriented; it does not provide a separate `/new` artifact mount out of the box. + +## UI Simplification Principles + +Prioritize the 80% path: +- Open session list. +- Identify sessions needing attention. +- Read/send messages. +- Approve/deny permission requests. +- Start a new session. + +Simplify by priority: +- Keep state and actions that affect live sessions visible. +- Move secondary controls into menus/drawers. +- Hide noisy diagnostics unless actionable. +- Prefer progressive disclosure over permanent panels. +- Preserve backend-used features and route handlers even when hidden from the default UI. + +Concrete candidates to hide/remove/de-emphasize: +- `web/src/components/SessionList.tsx`: de-emphasize machine/project grouping density, copy-path controls, inactive/empty session details, model/flavor labels, todo progress unless incomplete/actionable, and per-group expand noise. +- `web/src/components/SessionHeader.tsx`: keep title/back/primary status; move files, outline, rename/export/archive/delete/reopen into a single overflow or secondary area. +- `web/src/components/AssistantChat/StatusBar.tsx`: keep connection/permission-required state; hide or compress context token counts, cache hit labels, reasoning labels, fast-mode label, and goal label unless warning/actionable. +- `web/src/components/AssistantChat/ComposerButtons.tsx`: keep send, attach if needed, permission/model settings if common; move terminal, schedule send, scratchlist, voice mic controls, switch-to-remote, and abort into clearer contextual affordances. +- `web/src/components/NewSession/`: keep machine, directory, agent, create/cancel; move model, reasoning effort, YOLO, worktree/session type, Cursor/OpenCode advanced pickers behind an "Advanced" section. +- `web/src/routes/settings/index.tsx`: keep settings reachable but avoid prominent navigation unless required. +- `web/src/components/CodexSessionSyncDialog.tsx`, `CursorMigrationBanner.tsx`, voice banners, install/offline/reconnect banners: keep only when actionable; avoid permanent educational text. + +Do not delete in Phase 1 unless proved unused: +- API client methods and query/mutation hooks. +- Permission approval UI and tool-result rendering. +- File, terminal, voice, scratchlist, export, migration, Codex import logic. +- Routes needed for deep links, existing users, or backend callbacks. + +## `/new` Preview Strategy + +Required behavior: +- `https://tgbackup.030.qzz.io/` keeps serving the current UI unchanged. +- `https://tgbackup.030.qzz.io/new` serves the preview UI. +- Preview UI should call the same hub API using `/api/*` and `/socket.io/` on the same origin. +- Preview must not rewrite, redirect, or shadow `/`. + +Recommended deployment approach: +- Build a separate preview artifact from the same `web/` app with `VITE_BASE_URL=/new/`. +- Configure router browser history/basepath so browser routes resolve under `/new`: + - `/new` and `/new/` should land on the preview app and redirect internally to `/new/sessions`. + - `/new/sessions/...` should match the same logical app routes without requiring root `/sessions`. +- Serve preview static files under `/new/` with SPA fallback to the preview `index.html`. +- Keep `/api/*` and `/socket.io/*` routed to the hub, not the preview static directory. + +Reverse-proxy implications: +- If using nginx/Caddy/Cloudflare in front of the existing hub, prefer serving the preview static directory directly at `/new/`. +- Do not proxy `/new/assets/*` to the current hub root unless the hub is updated to serve a second dist directory; current hub only serves root `/assets/*` from `web/dist`. +- Ensure deep links like `/new/sessions/` return the preview `index.html`. +- Ensure cache headers distinguish preview assets from current root assets. + +Hub-native alternative: +- Add explicit hub support for a second preview dist directory, e.g. `HAPI_WEB_PREVIEW_DIST_DIR`, mounted at `/new`. +- In non-compiled mode, serve `/new/assets/*`, `/new/*.webmanifest`, `/new/sw.js`, and fallback `/new/*` to preview `index.html`. +- In compiled mode, either skip `/new` support initially or add a separate embedded preview asset manifest. Keep this out of the first deployment unless single-exe preview is required. + +PWA/base-path notes: +- `VITE_BASE_URL=/new/` should set asset URLs, manifest `scope`, and `start_url` to `/new/`. +- Validate service worker registration does not control `/`; scope must remain `/new/`. +- Existing root PWA/service worker behavior must remain unchanged. + +## Step-by-Step Future Implementation Plan + +### Step 1: Add Preview Build/Serve Path + +Files: +- `web/vite.config.ts` +- `web/src/router.tsx` +- `web/src/main.tsx` +- `web/package.json` +- Optional hub-native path: `hub/src/web/server.ts` +- Optional deploy docs/config: deployment reverse-proxy config outside repo, if present + +Tasks: +- Add a preview build command, e.g. `build:preview`, that sets `VITE_BASE_URL=/new/` and outputs to a separate directory such as `web/dist-new` or a deploy artifact directory. +- Add router basepath handling for browser history when base is `/new/`; keep Telegram memory history behavior unchanged. +- Verify `/new`, `/new/`, `/new/sessions`, and `/new/sessions/` resolve in preview. +- Decide deploy mechanism: + - Preferred: reverse proxy serves `web/dist-new` at `/new/`. + - Alternative: hub serves a configured preview dist at `/new/`. + +Validation: +- `bun run typecheck:web` +- Build normal app: `bun run build:web`; confirm root output still works. +- Build preview app with `/new/`; confirm generated asset URLs use `/new/`. +- Manual smoke: `/` unchanged, `/new` preview loads, `/api/health` still hub. + +Suggested commit: +- `web: add isolated /new preview build path` + +### Step 2: Create UI Simplification Flags/Structure + +Files: +- `web/src/lib/runtime-config.ts` +- `web/src/App.tsx` +- New small UI config file if useful, e.g. `web/src/lib/ui-mode.ts` + +Tasks: +- Add a simple frontend-only UI mode derived from base path or env, e.g. preview mode when `import.meta.env.BASE_URL === '/new/'`. +- Keep default/root mode unchanged. +- Expose a small typed helper such as `isPreviewUiMode()` or `useUiMode()` for components. +- Avoid plumbing large config objects through many components unless needed. + +Validation: +- `bun run typecheck:web` +- Existing tests should not require behavior changes in root mode. + +Suggested commit: +- `web: gate simplified UI to preview mode` + +### Step 3: Simplify Session List + +Files: +- `web/src/components/SessionList.tsx` +- Related tests in `web/src/components/SessionList*.test.*` + +Tasks: +- In preview mode, reduce each session row to title, attention/status, latest activity, and primary metadata only. +- Hide or de-emphasize path copy buttons, duplicate machine/project metadata, inactive empty stubs, model labels, and non-actionable counts. +- Keep search and new-session entry. +- Keep long-press/context menu actions available. + +Validation: +- `bun run test:web -- SessionList` +- `bun run typecheck:web` +- Manual smoke with active session, inactive session, pending permission session, empty state. + +Suggested commit: +- `web: simplify preview session list` + +### Step 4: Simplify Chat Header and Status + +Files: +- `web/src/components/SessionHeader.tsx` +- `web/src/components/AssistantChat/StatusBar.tsx` +- `web/src/components/SessionChat.tsx` + +Tasks: +- In preview mode, show only the title, back action, and status that changes user decisions. +- Move file/outline/export/archive/delete/reopen actions behind overflow if not already there. +- Keep permission-required and disconnected states visible. +- Hide context/cache/reasoning/goal labels unless they cross warning thresholds or are needed for current action. + +Validation: +- `bun run test:web -- SessionChat` +- `bun run test:web -- StatusBar` +- `bun run typecheck:web` +- Manual smoke for online, offline, thinking, permission-required, reconnecting. + +Suggested commit: +- `web: simplify preview chat chrome` + +### Step 5: Simplify Composer Controls + +Files: +- `web/src/components/AssistantChat/HappyComposer.tsx` +- `web/src/components/AssistantChat/ComposerButtons.tsx` +- Existing composer tests + +Tasks: +- In preview mode, keep send and primary input actions clear. +- Move secondary controls such as schedule, scratchlist, terminal, voice, settings, switch remote, and abort into contextual UI or overflow where safe. +- Do not remove handlers; only change default visibility/placement. +- Preserve permission mode/model controls somewhere reachable. + +Validation: +- `bun run test:web -- ComposerButtons` +- `bun run test:web -- HappyComposer` +- `bun run typecheck:web` +- Manual smoke send message, attach file, abort active run, switch remote/local, schedule if still exposed. + +Suggested commit: +- `web: streamline preview composer controls` + +### Step 6: Simplify New Session Flow + +Files: +- `web/src/components/NewSession/index.tsx` +- `web/src/components/NewSession/*` +- Existing NewSession tests + +Tasks: +- In preview mode, default visible fields to machine, directory, agent, create/cancel. +- Put model, reasoning effort, YOLO, worktree/session type, Cursor variants, and OpenCode model discovery behind Advanced. +- Preserve stored preferences and spawn payload behavior. +- Keep directory existence warnings and runner spawn errors visible. + +Validation: +- `bun run test:web -- NewSession` +- `bun run typecheck:web` +- Manual smoke create basic session, create with advanced options, missing directory warning, runner unavailable case. + +Suggested commit: +- `web: simplify preview new-session form` + +### Step 7: End-to-End Preview Validation + +Files: +- Existing Playwright config/tests if adding coverage: `playwright.config.ts`, `e2e/` +- Deployment notes if repo contains them + +Tasks: +- Add minimal smoke coverage for preview path if practical: + - `/new` loads. + - `/` still loads existing app. + - `/new` does not redirect to `/`. + - Preview API calls target `/api/*`. +- Document exact deployment command and reverse-proxy route for `tgbackup.030.qzz.io/new`. + +Validation: +- `bun typecheck` +- `bun run test` +- `bun run build:web` +- Preview build command +- Manual browser smoke on deployed URL. + +Suggested commit: +- `test: add /new preview smoke coverage` + +## PR Acceptance Criteria + +- `https://tgbackup.030.qzz.io/` behavior and visual UI remain unchanged. +- `https://tgbackup.030.qzz.io/new` loads the preview UI. +- `/new` supports SPA deep links and refreshes. +- Preview static assets load from `/new/...`, not root `/assets/...`. +- Preview service worker scope is `/new/` and does not control `/`. +- Preview uses the existing hub API and Socket.IO endpoints. +- No backend-used API, route, hook, schema, or RPC behavior is deleted. +- UI simplification is gated to preview mode or otherwise proven not to affect `/`. +- `bun typecheck` passes. +- `bun run test` passes, or failures are documented as unrelated with evidence. +- PR description includes deployment steps and rollback steps for `/new`. + +## Risks and Guardrails + +Do not break `/`: +- Keep root build, root routes, root service worker, and root static serving unchanged. +- Test `/` after every preview routing/build change. + +Do not delete backend-used functionality: +- Prefer hiding/moving UI controls before removing logic. +- Keep API clients, hooks, routes, and permission/tool rendering code unless usage is audited. + +Do not auto-promote test UI: +- No redirect from `/` to `/new`. +- No shared deploy artifact that silently replaces root `web/dist`. +- Promotion to `/` must be a separate explicit plan/PR after user testing. + +Preview path risk: +- Vite `base=/new/` alone is not enough; router basepath and server/reverse-proxy SPA fallback must also be handled. +- Current hub static serving is root-oriented; serving two UIs from the same hub needs reverse-proxy support or explicit hub changes. + +PWA risk: +- Wrong service worker scope can affect root production UI. Validate registration URL and scope in browser devtools before sharing preview broadly. diff --git a/web/package.json b/web/package.json index ba28bffca1..9210d05da7 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "vite", "build": "vite build && cp dist/index.html dist/404.html", + "build:preview": "VITE_BASE_URL=/new/ vite build --outDir dist-new && cp dist-new/index.html dist-new/404.html", "typecheck": "tsc --noEmit", "preview": "vite preview", "test": "vitest run" diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index f19e311a81..514fe902d6 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -48,6 +48,7 @@ import { import { SessionTypeSelector } from './SessionTypeSelector' import { YoloToggle } from './YoloToggle' import { formatRunnerSpawnError } from '../../utils/formatRunnerSpawnError' +import { isPreviewUiMode } from '@/lib/runtime-config' export function NewSession(props: { api: ApiClient @@ -61,6 +62,7 @@ export function NewSession(props: { }) { const { haptic } = usePlatform() const { t } = useTranslation() + const isPreviewMode = isPreviewUiMode() const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api) const { sessions } = useSessions(props.api) const isFormDisabled = Boolean(isPending || props.isLoading) @@ -594,49 +596,8 @@ export function NewSession(props: { const canCreate = Boolean(machineId && trimmedDirectory && !isFormDisabled && !missingWorktreeDirectory) - return ( -
- - {runnerSpawnError ? ( -
- Runner last spawn error: {runnerSpawnError} -
- ) : null} - - - + const modelControls = ( + <> {agent === 'opencode' ? ( ) )} + + ) + + const sessionTypeControl = ( + + ) + + const agentControl = ( + + ) + + const tuningControls = ( + <> + + ) + + const advancedControls = ( + <> + {sessionTypeControl} + {modelControls} + {tuningControls} + + ) + + return ( +
+ + {runnerSpawnError ? ( +
+ Runner last spawn error: {runnerSpawnError} +
+ ) : null} + + {isPreviewMode ? ( + <> + {agentControl} +
+ + {t('newSession.advanced')} + +
+ {advancedControls} +
+
+ + ) : ( + <> + {sessionTypeControl} + {agentControl} + {modelControls} + {tuningControls} + + )} {(error ?? spawnError) ? (
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' { From de3a860667895f1765527be370238dbee4ee2375 Mon Sep 17 00:00:00 2001 From: Hermes Coder Date: Sat, 13 Jun 2026 14:32:50 +0000 Subject: [PATCH 2/2] fix(web): type preview static route helper --- hub/src/web/server.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 0abe235a20..4949787288 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -1,4 +1,4 @@ -import { Hono } from 'hono' +import { Hono, type Env, type Schema } from 'hono' import { cors } from 'hono/cors' import { logger } from 'hono/logger' import { join } from 'node:path' @@ -208,7 +208,10 @@ function findPreviewWebappDistDir(): string | null { return null } -export function mountPreviewStaticRoutes(app: Hono, distDir: string | null): boolean { +export function mountPreviewStaticRoutes( + app: Hono, + distDir: string | null +): boolean { if (!distDir || !existsSync(join(distDir, 'index.html'))) { return false } @@ -234,7 +237,9 @@ export function mountPreviewStaticRoutes(app: Hono, distDir: string | null): boo return true } -export function mountMissingPreviewRoutes(app: Hono): void { +export function mountMissingPreviewRoutes( + app: Hono +): void { app.get('/new', (c) => c.text('Preview app is not built.\n\nRun:\n cd web\n bun run build:preview\n', 503)) app.get('/new/*', (c) => c.text('Preview app is not built.\n\nRun:\n cd web\n bun run build:preview\n', 503)) }