From 263795ff6375c58fe2ae1297b54564cf7ab9370a Mon Sep 17 00:00:00 2001 From: rike Date: Mon, 7 Sep 2026 20:38:41 +0800 Subject: [PATCH 1/7] Improve Codex workspace hierarchy and interaction feedback --- farhelm-console/e2e/codex.spec.ts | 112 +++++++- farhelm-console/e2e/layout.spec.ts | 2 + farhelm-console/src/App.tsx | 24 +- farhelm-console/src/api/events.test.ts | 35 +++ farhelm-console/src/api/events.ts | 12 +- farhelm-console/src/components/CodexPage.tsx | 51 ++-- .../src/components/ConnectionStatus.tsx | 15 ++ .../src/components/codex/Conversation.tsx | 56 ++-- .../src/components/codex/SessionGroups.tsx | 51 ++++ .../src/components/codex/Transcript.tsx | 10 +- .../src/components/codex/Welcome.tsx | 27 ++ .../src/components/codex/markdown.css | 26 ++ .../src/components/codex/workspace.css | 240 ++++++++++++++++++ farhelm-console/src/styles.css | 224 +++++----------- farhelm-console/src/theme.ts | 1 + 15 files changed, 667 insertions(+), 219 deletions(-) create mode 100644 farhelm-console/src/api/events.test.ts create mode 100644 farhelm-console/src/components/ConnectionStatus.tsx create mode 100644 farhelm-console/src/components/codex/SessionGroups.tsx create mode 100644 farhelm-console/src/components/codex/Welcome.tsx create mode 100644 farhelm-console/src/components/codex/markdown.css create mode 100644 farhelm-console/src/components/codex/workspace.css diff --git a/farhelm-console/e2e/codex.spec.ts b/farhelm-console/e2e/codex.spec.ts index 8e3e232..ccb5ad3 100644 --- a/farhelm-console/e2e/codex.spec.ts +++ b/farhelm-console/e2e/codex.spec.ts @@ -38,6 +38,116 @@ async function composerFits(page: Page) { if (await navigation.isVisible()) { const nav = await navigation.boundingBox(); expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(nav!.y + 1) } } +test('welcome leads to a real session and suggestions only prepare a focused draft', async ({ page }) => { + const model = await setup(page) + model.history.turns = [] + let sends = 0 + await page.route('**/ses-a/messages', (route) => { sends++; return route.fulfill({ json: { state: 'accepted' } }) }) + await page.goto('/codex') + await expect(page.getByRole('heading', { name: '继续你的工作' })).toBeVisible() + await expect(page.getByRole('button', { name: '开启新会话' })).toBeVisible() + await expect(page.locator('.composer')).toHaveCount(0) + await page.locator('.recent-session').filter({ hasText: '训练结果分析' }).click() + await expect(page).toHaveURL(/session=ses-a/) + await expect(page.getByRole('heading', { name: '准备好,开始下一步' })).toBeVisible() + await page.getByRole('button', { name: '检查代码问题' }).click() + const input = page.getByLabel('给 Codex 发送指令') + await expect(input).toBeFocused() + await expect(input).toHaveValue(/检查这个项目可能存在的问题/) + expect(sends).toBe(0) + await choose(page, '另一个会话') + await choose(page, '训练结果分析') + await expect(input).toHaveValue(/检查这个项目可能存在的问题/) + await expect(page.getByRole('button', { name: '检查代码问题' })).toHaveCount(0) + await composerFits(page) +}) + +test('server and project disclosure preserves targets and skips hidden rows with the keyboard', async ({ page }) => { + const model = await setup(page) + model.sessions.push({ ...session('ses-c'), agent_id: 'gpu-b', title: '另一台服务器的会话' }) + await page.goto('/codex?session=ses-a') + await expect(page.locator('.conversation-title')).toContainText('训练结果分析') + if (await page.getByRole('button', { name: '打开会话列表' }).isVisible()) await page.getByRole('button', { name: '打开会话列表' }).click() + const rail = page.getByRole('complementary', { name: '项目和会话' }).filter({ visible: true }) + const first = rail.locator('.server-group').filter({ has: page.locator('.server-heading', { hasText: 'gpu-a' }) }) + await first.locator('.ant-collapse-header').click() + await expect(first.getByRole('button', { name: '训练结果分析' })).toBeHidden() + const remote = rail.getByRole('button', { name: '另一台服务器的会话' }) + await remote.focus(); await page.keyboard.press('Home') + await expect(remote).toBeFocused() + await first.locator('.ant-collapse-header').click() + await first.getByRole('button', { name: '项目 cc08', exact: true }).click() + await expect(first.getByRole('button', { name: '训练结果分析' })).toBeHidden() + await remote.focus(); await page.keyboard.press('ArrowUp') + await expect(remote).toBeFocused() + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/session=ses-c/) +}) + +test('search shortcut opens the collapsed rail without changing the active draft', async ({ page }, info) => { + test.skip(info.project.name === 'mobile', 'Desktop keyboard shortcut') + await setup(page); await page.goto('/codex?session=ses-a') + await page.getByLabel('给 Codex 发送指令').fill('未发送的草稿') + await page.getByRole('button', { name: '折叠会话列表' }).click() + await page.keyboard.press('Control+k') + await expect(page.getByLabel('搜索全部会话')).toBeFocused() + await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('未发送的草稿') + await expect(page.locator('.app-sider')).toBeVisible() +}) + +test('short desktop windows keep system navigation reachable above the account footer', async ({ page }, info) => { + test.skip(info.project.name === 'mobile', 'Desktop navigation geometry') + await setup(page); await page.setViewportSize({ width: 1440, height: 500 }) + await page.goto('/codex?session=ses-a') + const settings = page.getByRole('navigation', { name: '系统导航' }).getByRole('menuitem', { name: /设置/ }) + await settings.scrollIntoViewIfNeeded() + const item = await settings.boundingBox(), footer = await page.locator('.sider-footer').boundingBox() + expect(item!.y).toBeGreaterThanOrEqual(0) + expect(item!.y + item!.height).toBeLessThanOrEqual(footer!.y) + await settings.click() + await expect(page.getByRole('heading', { name: '设置', exact: true })).toBeVisible() +}) + +test('connection status follows the shared stream and keeps the draft during reconnect', async ({ page }) => { + await setup(page); await page.goto('/codex?session=ses-a') + await page.getByLabel('给 Codex 发送指令').fill('断线后继续编辑') + await emit(page, 'open', {}) + await expect(page.getByRole('status', { name: '实时连接' })).toBeVisible() + await emit(page, 'error', {}) + await expect(page.getByRole('status', { name: '正在重连' })).toBeVisible() + await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('断线后继续编辑') + await emit(page, 'open', {}) + await expect(page.getByRole('status', { name: '实时连接' })).toBeVisible() +}) + +test('motion communicates work and submission while reduced motion keeps the same controls', async ({ page }) => { + const model = await setup(page) + model.sessions[0].active_turn_id = 'turn-a'; model.sessions[0].state = 'running' + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await page.goto('/codex?session=ses-a') + await expect(page.locator('.response-activity')).toContainText('Codex 正在处理') + expect(await page.locator('.activity-indicator i').first().evaluate((node) => node.getAnimations().some((animation) => animation.playState === 'running'))).toBe(true) + const input = page.getByLabel('给 Codex 发送指令') + await input.fill('下一轮继续检查') + await expect.poll(() => page.locator('.composer').evaluate((node) => getComputedStyle(node, '::after').opacity)).toBe('1') + let release: () => void = () => {} + const gate = new Promise((resolve) => { release = resolve }) + await page.route('**/ses-a/messages', async (route) => { await gate; return route.fulfill({ json: { command_id: 'cmd-motion', state: 'accepted' } }) }) + await page.getByRole('button', { name: '发送指令' }).click() + await expect(input).toBeFocused() + await expect(page.locator('.pending-message')).toContainText('正在提交') + expect(await page.locator('.pending-message').evaluate((node) => getComputedStyle(node).animationName)).toBe('message-submit') + expect(await page.locator('.codex-message.assistant').first().evaluate((node) => node.getAnimations().length)).toBe(0) + release() + await expect(page.locator('.submission-status')).toContainText('Agent 已保存') + await page.emulateMedia({ reducedMotion: 'reduce' }) + await expect.poll(() => page.locator('.activity-indicator i').first().evaluate((node) => node.getAnimations().length)).toBe(0) + await expect(page.locator('.response-activity')).toContainText('Codex 正在处理') + await expect(page.getByRole('button', { name: '中断', exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '定时发送', exact: true })).toBeVisible() + await composerFits(page) +}) + for (const theme of ['light', 'dark'] as const) for (const size of [{ width: 390, height: 844 }, { width: 1440, height: 900 }, { width: 2550, height: 1233 }]) { test(`Markdown and fixed workspace ${size.width} ${theme}`, async ({ page }, info) => { test.skip(info.project.name !== 'desktop', 'Explicit viewport matrix runs once') @@ -161,7 +271,7 @@ test('failed send belongs to its original session while switching', async ({ pag test('interleaved and repeated deltas retain item identity after a failed terminal refresh', async ({ page }) => { const model = await setup(page); model.history = { session_id: 'ses-a', turns: [] } - await page.goto('/codex?session=ses-a'); await expect(page.getByText(/这个会话还没有对话/)).toBeVisible() + await page.goto('/codex?session=ses-a'); await expect(page.getByRole('heading', { name: '准备好,开始下一步' })).toBeVisible() const delta = async (item_id: string, text_offset: number, delta: string) => emit(page, 'codex.message.delta', { session_id: 'ses-a', data: { turn_id: 'stream-turn', item_id, text_offset, delta } }) await delta('a', 0, '第一条🙂'); await delta('b', 0, '第二条'); await delta('a', 4, '完成'); await delta('a', 4, '完成') await expect(page.locator('.codex-message.assistant')).toHaveCount(2) diff --git a/farhelm-console/e2e/layout.spec.ts b/farhelm-console/e2e/layout.spec.ts index 561a7b6..540a9a2 100644 --- a/farhelm-console/e2e/layout.spec.ts +++ b/farhelm-console/e2e/layout.spec.ts @@ -74,6 +74,8 @@ test('responsive navigation and validated status are visible', async ({ page }, }) test('keyboard navigation reaches visible controls', async ({ page }) => { + await expect(page.getByRole('heading', { name: '运行总览' })).toBeVisible() + if (page.viewportSize()!.width >= 768) await expect(page.locator('.app-sider')).toBeVisible() await page.keyboard.press('Tab') const focused = page.locator(':focus-visible') await expect(focused).toHaveCount(1) diff --git a/farhelm-console/src/App.tsx b/farhelm-console/src/App.tsx index 160608e..5497472 100644 --- a/farhelm-console/src/App.tsx +++ b/farhelm-console/src/App.tsx @@ -6,6 +6,7 @@ import { DashboardOutlined, DesktopOutlined, FileSearchOutlined, + LogoutOutlined, MenuOutlined, MoonOutlined, MoreOutlined, @@ -15,13 +16,14 @@ import { } from '@ant-design/icons' import { QueryClientProvider } from '@tanstack/react-query' import { queryClient, connectCodexCache } from './api/cache' -import { Button, ConfigProvider, Drawer, Grid, Layout, Menu, Space, Spin, Typography } from 'antd' +import { Avatar, Button, ConfigProvider, Drawer, Grid, Layout, Menu, Space, Spin, Tooltip, Typography } from 'antd' import { lazy, Suspense, useEffect, useState } from 'react' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { AuditPage } from './components/AuditPage' import { SettingsPage } from './components/SettingsPage' import { LiveNotifications } from './components/LiveNotifications' +import { ConnectionStatus } from './components/ConnectionStatus' import type { ColorPreference } from './hooks/useColorMode' import { AgentListPage } from './components/AgentListPage' import { ExperimentPage } from './components/ExperimentPage' @@ -93,6 +95,7 @@ function AppContent() { const mobileSelection = ['/agents', '/notifications', '/audit', '/settings'].includes(location.pathname) ? '/more' : location.pathname + const currentPage = desktopItems.find((item) => item.key === location.pathname)?.label ?? '控制台' const go = (key: string) => { if (key === '/more') { @@ -114,24 +117,31 @@ function AppContent() {
-
FarHelm远程训练控制台
+
FarHelm远程工作空间
- go(key)} /> -
V0.8.0 · Codex workspace
+ +
{session.user.slice(0, 1).toUpperCase()}
{session.user}个人控制台
FarHelm V0.8.0
)}
- {!isDesktop && FarHelm} + {isDesktop ?
{desktopItems.slice(4).some((item) => item.key === location.pathname) ? '管理' : '工作空间'}{currentPage}
: FarHelm} + + } + + {isDesktop &&
diff --git a/farhelm-console/src/api/events.test.ts b/farhelm-console/src/api/events.test.ts new file mode 100644 index 0000000..ba8c890 --- /dev/null +++ b/farhelm-console/src/api/events.test.ts @@ -0,0 +1,35 @@ +import { expect, test, vi } from 'vitest' +import { readEventConnection, subscribeEventConnection, subscribeEvents } from './events' + +test('connection status shares the live stream and resets after the authenticated page closes', () => { + const sources: Source[] = [] + class Source extends EventTarget { + closed = false + constructor() { super(); sources.push(this) } + close() { this.closed = true } + } + vi.stubGlobal('EventSource', Source) + const business = vi.fn(), changed = vi.fn() + const unsubscribeBusiness = subscribeEvents(['codex.message.delta'], business) + const unsubscribeStatus = subscribeEventConnection(changed) + try { + expect(sources).toHaveLength(1) + expect(readEventConnection()).toBe('connecting') + sources[0].dispatchEvent(new Event('open')) + expect(readEventConnection()).toBe('connected') + sources[0].dispatchEvent(new MessageEvent('codex.message.delta', { data: '{}' })) + expect(business).toHaveBeenCalledOnce() + sources[0].dispatchEvent(new Event('error')) + expect(readEventConnection()).toBe('reconnecting') + sources[0].dispatchEvent(new Event('open')) + expect(readEventConnection()).toBe('connected') + expect(changed).toHaveBeenCalledTimes(3) + unsubscribeStatus() + expect(sources[0].closed).toBe(false) + unsubscribeBusiness() + expect(sources[0].closed).toBe(true) + expect(readEventConnection()).toBe('connecting') + } finally { + unsubscribeStatus(); unsubscribeBusiness(); vi.unstubAllGlobals() + } +}) diff --git a/farhelm-console/src/api/events.ts b/farhelm-console/src/api/events.ts index e1ee4e8..2e88ba4 100644 --- a/farhelm-console/src/api/events.ts +++ b/farhelm-console/src/api/events.ts @@ -3,8 +3,16 @@ type Listener = (event: MessageEvent) => void const listeners = new Map>() let stream: EventSource | undefined const dispatchers = new Map() +let connectionState: 'connecting' | 'connected' | 'reconnecting' = 'connecting' +export function readEventConnection() { return connectionState } +export function subscribeEventConnection(listener: () => void) { return subscribeEvents(['open', 'error'], listener) } export function subscribeEvents(names: string[], listener: Listener): () => void { - stream ??= new EventSource('/api/v1/events/stream') + if (!stream) { + connectionState = 'connecting' + stream = new EventSource('/api/v1/events/stream') + stream.addEventListener('open', () => { connectionState = 'connected' }) + stream.addEventListener('error', () => { connectionState = 'reconnecting' }) + } for (const name of names) { if (!listeners.has(name)) { listeners.set(name, new Set()) @@ -21,7 +29,7 @@ export function subscribeEvents(names: string[], listener: Listener): () => void return () => { for (const name of names) listeners.get(name)?.delete(listener) if ([...listeners.values()].every((set) => set.size === 0)) { - stream?.close(); stream = undefined; listeners.clear(); dispatchers.clear() + stream?.close(); stream = undefined; connectionState = 'connecting'; listeners.clear(); dispatchers.clear() } } } diff --git a/farhelm-console/src/components/CodexPage.tsx b/farhelm-console/src/components/CodexPage.tsx index 402f653..933fc14 100644 --- a/farhelm-console/src/components/CodexPage.tsx +++ b/farhelm-console/src/components/CodexPage.tsx @@ -1,23 +1,21 @@ -import { ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, FolderOpenOutlined, LoadingOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons' +import { PlusOutlined, SearchOutlined } from '@ant-design/icons' import { Alert, Button, Drawer, Empty, Input, Segmented, Select, Skeleton } from 'antd' import { useEffect, useMemo, useState } from 'react' -import { Conversations } from '@ant-design/x' +import { SessionGroups } from './codex/SessionGroups' import { useQuery } from '@tanstack/react-query' import { flushSync } from 'react-dom' -import { useNavigate, useSearchParams } from 'react-router-dom' +import { useSearchParams } from 'react-router-dom' import { fetchProjects, type CodexSession } from '../api/features' import { Conversation } from './codex/Conversation' import { CreateDialog, ScheduleDialog, SchedulesDrawer } from './codex/Dialogs' -import { errorText, sessionName, stateNames } from './codex/presentation' +import { errorText } from './codex/presentation' import { useOperations } from './codex/useOperations' import { useSessions, type ArchiveFilter } from './codex/useSessions' +import './codex/workspace.css' +import './codex/markdown.css' -function StateIcon({ state }: { state: string }) { - return state === 'running' ? : ['failed', 'orphaned'].includes(state) ? : ['queued', 'creating'].includes(state) ? : -} export function CodexPage({ csrf }: { csrf: string }) { - const navigate = useNavigate(); const [params, setParams] = useSearchParams(); const id = params.get('session') ?? undefined - const [closedGroups, setClosedGroups] = useState>({}) + const [params, setParams] = useSearchParams(); const id = params.get('session') ?? undefined const [query, setQuery] = useState(''); const [archive, setArchive] = useState('false') const [scope, setScope] = useState(); const [railOpen, setRailOpen] = useState(false); const [collapsed, setCollapsed] = useState(false) const projectQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects }); const projects = useMemo(() => projectQuery.data ?? [], [projectQuery.data]); const projectError = projectQuery.error ? errorText(projectQuery.error) : undefined @@ -35,14 +33,25 @@ export function CodexPage({ csrf }: { csrf: string }) { resize(); viewport?.addEventListener('resize', resize); viewport?.addEventListener('scroll', resize); window.addEventListener('resize', resize) return () => { viewport?.removeEventListener('resize', resize); viewport?.removeEventListener('scroll', resize); window.removeEventListener('resize', resize); document.documentElement.style.removeProperty('--codex-height'); document.documentElement.style.removeProperty('--codex-top') } }, []) - const groups = useMemo(() => { - const groups = new Map() - for (const session of sessions.rows) { const key = `${session.agent_id} / ${session.project_id}`; groups.set(key, [...(groups.get(key) ?? []), session]) } - return [...groups] - }, [sessions.rows]) + useEffect(() => { + const search = (event: KeyboardEvent) => { + if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'k' || event.isComposing || innerWidth < 768) return + event.preventDefault(); setCollapsed(false) + requestAnimationFrame(() => document.querySelector('.codex-desktop-rail input[aria-label="搜索全部会话"]')?.focus()) + } + window.addEventListener('keydown', search) + return () => window.removeEventListener('keydown', search) + }, []) const select = (sessionId?: string) => { const next = new URLSearchParams(params); if (sessionId) next.set('session', sessionId); else next.delete('session'); flushSync(() => setParams(next)); setRailOpen(false) } - const rail = - return
{rail}
setRailOpen(false)} size={Math.min(320, window.innerWidth - 24)} closable={false}>{rail} + return
{rail}
setRailOpen(false)} size={Math.min(320, window.innerWidth - 24)} closable={false}>{rail} s.session_id === id)} draft={operations.get(id ?? '')} onDraft={(change) => { if (id) operations.update(id, change) }} onSend={(turn) => { if (id) void operations.send(id, turn) }} onRail={() => setRailOpen(true)} onCollapse={() => setCollapsed((old) => !old)} collapsed={collapsed} onSchedule={setSchedule} onSchedules={setSchedules} /> {creating && setCreating(false)} onCreated={(sessionId) => { if (sessionId) select(sessionId); void sessions.refresh() }} />} {schedule && setSchedule(undefined)} />} diff --git a/farhelm-console/src/components/SettingsPage.tsx b/farhelm-console/src/components/SettingsPage.tsx index 7e54738..cc72040 100644 --- a/farhelm-console/src/components/SettingsPage.tsx +++ b/farhelm-console/src/components/SettingsPage.tsx @@ -1,9 +1,9 @@ -import { Button, Card, Segmented, Space, Typography } from 'antd' -import type { ColorPreference } from '../hooks/useColorMode' +import { Button, Card, Space, Typography } from 'antd' +import { AppearanceSettings, type AppearanceProps } from './AppearanceSettings' import { NotificationSettings } from './NotificationSettings' -export function SettingsPage({ csrf, preference, onPreference, onLogout }: { csrf: string; preference: ColorPreference; onPreference: (value: ColorPreference) => void; onLogout: () => void }) { - return
设置 - +export function SettingsPage({ csrf, onLogout, ...appearance }: AppearanceProps & { csrf: string; onLogout: () => void }) { + return
设置

让 FarHelm 更合你的习惯。

+ FarHelm V0.8.0 -
+
} diff --git a/farhelm-console/src/components/appearance.css b/farhelm-console/src/components/appearance.css new file mode 100644 index 0000000..a83bdbf --- /dev/null +++ b/farhelm-console/src/components/appearance.css @@ -0,0 +1,51 @@ +.settings-page { max-width: 920px; } +.settings-heading { margin: 4px 0 28px; } +.settings-heading h1.ant-typography { margin: 0 0 8px; font-size: 32px; line-height: 42px; font-weight: 600; } +.settings-heading p { margin: 0; color: var(--farhelm-muted); } +.settings-sections { display: grid; gap: 24px; } +.appearance-card.ant-card { border: 0; } +.appearance-card .ant-card-head { padding: 24px 32px 0; border: 0; } +.appearance-card .ant-card-head-title { font-size: 20px; font-weight: 500; } +.appearance-card .ant-card-body { padding: 28px 32px 32px; } +.appearance-local { color: var(--farhelm-muted); font-size: 12px; } +.appearance-layout { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(240px, 1fr); gap: 32px; align-items: start; } +.appearance-controls { display: grid; gap: 24px; min-width: 0; } +.appearance-field { display: grid; gap: 12px; } +.appearance-field > span, .custom-accent > label { color: var(--farhelm-text); font-size: 14px; font-weight: 500; } +.appearance-field .ant-segmented { padding: 3px; border-radius: var(--radius-control); } +.appearance-field .ant-segmented-item-label { min-height: 44px; line-height: 44px; padding-inline: 8px; } +.appearance-field .ant-segmented-item, .appearance-field .ant-segmented-thumb { border-radius: 11px; } +.accent-choices { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px; } +.accent-choice.ant-btn { display: flex; flex-direction: column; gap: 8px; min-width: 44px; min-height: 80px; height: auto; padding: 12px 4px; border: 1px solid transparent; border-radius: var(--radius-control); font-size: 12px; color: var(--farhelm-text); } +.accent-choice.ant-btn[aria-pressed='true'] { background: var(--farhelm-selection); border-color: var(--farhelm-text); } +.accent-swatch { display: block; width: 28px; height: 28px; border-radius: var(--radius-pill); } +.custom-accent { display: grid; gap: 12px; min-width: 0; } +.custom-accent-controls { display: flex; gap: 12px; align-items: center; } +.custom-accent-controls > .ant-input { min-width: 0; font-variant-numeric: tabular-nums; } +.custom-accent-picker.ant-btn { width: 44px; min-width: 44px; padding: 5px; border-color: var(--farhelm-border); } +.custom-accent-picker > span { width: 32px; height: 32px; border-radius: 9px; } +.apply-accent.ant-btn { flex: none; border-radius: var(--radius-pill); } +.appearance-help { margin: 0; color: var(--farhelm-muted); font-size: 12px; line-height: 20px; } +.appearance-error { color: var(--farhelm-danger); } +.appearance-preview { min-width: 0; display: flex; flex-direction: column; gap: 24px; padding: 24px; border-radius: var(--radius-panel); background: var(--farhelm-canvas); } +.appearance-preview-label { color: var(--farhelm-muted); font-size: 12px; } +.preview-user { align-self: flex-end; padding: 12px 16px; border-radius: var(--radius-panel); background: var(--farhelm-selection); font-size: 13px; } +.preview-response { font-size: 14px; line-height: 24px; } +.preview-response strong { font-weight: 600; } +.preview-response p { margin: 8px 0; } +.preview-link { color: var(--farhelm-accent); } +.preview-composer { min-width: 0; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 16px; border-radius: var(--radius-composer); background: var(--farhelm-surface); color: var(--farhelm-muted); font-size: 12px; } +.preview-send { display: grid; place-items: center; width: 32px; height: 32px; border-radius: var(--radius-pill); background: var(--farhelm-text); color: var(--farhelm-surface); font-size: 18px; } +@media (max-width: 1100px) { + .appearance-layout { grid-template-columns: minmax(0, 1fr); } + .appearance-preview { max-width: 460px; width: 100%; } +} +@media (max-width: 767px) { + .appearance-card .ant-card-head { padding: 20px 20px 0; } + .appearance-card .ant-card-body { padding: 20px; } + .appearance-card .ant-card-head-title { font-size: 18px; } + .accent-choices { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } + .custom-accent-controls { gap: 8px; } + .apply-accent.ant-btn { padding-inline: 12px; } + .settings-heading h1.ant-typography { font-size: 28px; } +} diff --git a/farhelm-console/src/components/codex/Conversation.tsx b/farhelm-console/src/components/codex/Conversation.tsx index 2fb87d7..638fd24 100644 --- a/farhelm-console/src/components/codex/Conversation.tsx +++ b/farhelm-console/src/components/codex/Conversation.tsx @@ -115,13 +115,18 @@ export function Conversation({ recent, onSelect, onNew, onBrowse, csrf, id, sess const lastReceipt = draft.pending.at(-1) const receiptFailed = !!lastReceipt && ['failed', 'orphaned', 'unknown', 'rejected', 'expired', 'unconfirmed'].includes(lastReceipt.state) const prompt = (text: string) => { onDraft((old) => old.text.trim() ? old : { ...old, text }); sender.current?.focus({ preventScroll: true, cursor: 'end' }) } - const submit = () => { onSend(visibleTurn); sender.current?.focus({ preventScroll: true }) } + const submit = () => { + // An explicit send returns to the latest turn; background deltas keep the reading anchor. + follow.current = true; anchor.current = undefined; anchorLocked.current = false + setAway(false); setNewMessages(false) + onSend(visibleTurn); sender.current?.focus({ preventScroll: true }) + } return
@@ -130,15 +135,15 @@ export function Conversation({ recent, onSelect, onNew, onBrowse, csrf, id, sess {!id ? : loading && !page ?
正在读取对话历史…
: failure && !turns.length ?

{unavailable}

{failure.message}

}>
: page && !turns.length && session ? : null} {page?.next_cursor && !needsMessage && } -
{pending.map((p) =>
{p.state === 'submitting' ? : ['failed', 'orphaned', 'unknown', 'rejected', 'expired', 'unconfirmed'].includes(p.state) ? : p.command_id ? : } 你 · {stateNames[p.state] ?? '处理中'}
{p.text}
)}
+
{pending.map((p) =>
{p.state === 'submitting' ? : ['failed', 'orphaned', 'unknown', 'rejected', 'expired', 'unconfirmed'].includes(p.state) ? : p.command_id ? : } 你 · {stateNames[p.state] ?? '处理中'}
{p.text}
)}
{visibleTurn &&
{turns.some((turn) => turn.items.some((item) => item.streaming)) ? 'Codex 正在回复' : 'Codex 正在处理'}你可以继续编写下一条指令
} -
+ {id &&