diff --git a/src/core/config.spec.ts b/src/core/config.spec.ts index eba3249f8..a5eec9618 100644 --- a/src/core/config.spec.ts +++ b/src/core/config.spec.ts @@ -309,6 +309,26 @@ describe('writeConfigSection(trading)', () => { }) }) +describe('writeConfigSection(snapshot)', () => { + it('normalizes a valid snapshot interval before writing it', async () => { + const snapshot = await writeConfigSection('snapshot', { + enabled: true, + every: ' 2h15m ', + }) as { enabled: boolean; every: string } + + expect(snapshot).toEqual({ enabled: true, every: '2h15m' }) + const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string) + expect(written.every).toBe('2h15m') + }) + + it.each(['nonsense', '0m', ''])('rejects invalid snapshot interval %j without writing', async (every) => { + await expect( + writeConfigSection('snapshot', { enabled: true, every }), + ).rejects.toThrow(/positive duration/) + expect(mockWriteFile).not.toHaveBeenCalled() + }) +}) + // ==================== aiProviderSchema (Zod schema validation) ==================== describe('aiProviderSchema (credential vault)', () => { diff --git a/src/core/config.ts b/src/core/config.ts index 17f2eced6..ce357664e 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -6,6 +6,7 @@ import { newsCollectorSchema } from '../domain/news/config.js' import { runMigrations } from '../migrations/runner.js' import { dataPath } from '@/core/paths.js' import { withConfigBootstrapLock } from './config-bootstrap-lock.js' +import { parseDuration } from './duration.js' import { isSealedEnvelope, seal, unseal } from './sealing.js' const CONFIG_DIR = dataPath('config') @@ -353,7 +354,13 @@ const portsSchema = z.object({ const snapshotSchema = z.object({ enabled: z.boolean().default(true), - every: z.string().default('15m'), + every: z.string() + .transform((value) => value.trim()) + .refine( + (value) => parseDuration(value) !== null, + 'Expected a positive duration such as "15m", "1h", or "2h15m"', + ) + .default('15m'), }) export const keylessDataSourceSchema = z.enum(['binance', 'okx', 'bybit']) diff --git a/src/migrations/0027_repair_snapshot_interval/index.spec.ts b/src/migrations/0027_repair_snapshot_interval/index.spec.ts new file mode 100644 index 000000000..68bc340a6 --- /dev/null +++ b/src/migrations/0027_repair_snapshot_interval/index.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { MigrationContext } from '../types.js' +import { migration, repairSnapshotInterval } from './index.js' + +function context(initial: unknown) { + let value = initial + const writeJson = vi.fn(async (_filename: string, next: unknown) => { + value = next + }) + const ctx: MigrationContext = { + async readJson(): Promise { + return value as T | undefined + }, + writeJson, + removeJson: vi.fn(async () => {}), + configDir: () => '/tmp/openalice-migration-test', + } + return { ctx, writeJson, value: () => value } +} + +describe('0027 repair snapshot interval', () => { + it('replaces an invalid historical interval and is idempotent', async () => { + const state = context({ enabled: false, every: 'nonsense' }) + + await migration.up(state.ctx) + await migration.up(state.ctx) + + expect(state.value()).toEqual({ enabled: false, every: '15m' }) + expect(state.writeJson).toHaveBeenCalledOnce() + expect(state.writeJson).toHaveBeenCalledWith('snapshot.json', { + enabled: false, + every: '15m', + }) + }) + + it('normalizes valid whitespace without changing other settings', () => { + expect(repairSnapshotInterval({ + enabled: true, + every: ' 2h15m ', + futureSetting: true, + })).toEqual({ + value: { + enabled: true, + every: '2h15m', + futureSetting: true, + }, + updated: true, + }) + }) + + it('leaves missing and already-valid intervals untouched', () => { + const current = { enabled: true, every: '30m' } + expect(repairSnapshotInterval(current)).toEqual({ value: current, updated: false }) + expect(repairSnapshotInterval({ enabled: false })).toEqual({ + value: { enabled: false }, + updated: false, + }) + expect(repairSnapshotInterval(undefined)).toEqual({ value: undefined, updated: false }) + }) +}) diff --git a/src/migrations/0027_repair_snapshot_interval/index.ts b/src/migrations/0027_repair_snapshot_interval/index.ts new file mode 100644 index 000000000..7995a833e --- /dev/null +++ b/src/migrations/0027_repair_snapshot_interval/index.ts @@ -0,0 +1,35 @@ +import { parseDuration } from '../../core/duration.js' +import type { Migration } from '../types.js' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function repairSnapshotInterval(raw: unknown): { value: unknown; updated: boolean } { + if (!isRecord(raw) || !Object.prototype.hasOwnProperty.call(raw, 'every')) { + return { value: raw, updated: false } + } + + const every = raw.every + if (typeof every !== 'string' || parseDuration(every) === null) { + return { value: { ...raw, every: '15m' }, updated: true } + } + + const normalized = every.trim() + if (normalized === every) return { value: raw, updated: false } + return { value: { ...raw, every: normalized }, updated: true } +} + +export const migration: Migration = { + id: '0027_repair_snapshot_interval', + appVersion: '0.87.0-beta', + introducedAt: '2026-07-29', + affects: ['snapshot.json'], + summary: 'Repair invalid historical portfolio snapshot intervals before strict duration validation loads them.', + rationale: 'Older Portfolio UI versions persisted arbitrary strings even though the UTA snapshot pump accepts only positive h/m/s durations.', + up: async (ctx) => { + const snapshot = await ctx.readJson('snapshot.json') + const repaired = repairSnapshotInterval(snapshot) + if (repaired.updated) await ctx.writeJson('snapshot.json', repaired.value) + }, +} diff --git a/src/migrations/INDEX.md b/src/migrations/INDEX.md index 02edf801b..0ef3def5a 100644 --- a/src/migrations/INDEX.md +++ b/src/migrations/INDEX.md @@ -26,3 +26,4 @@ Each row corresponds to one migration in `src/migrations/`. The runner applies p | `0024_pi_native_workspace_config` | 0.82.0-beta | 2026-07-18 | workspaces/workspaces/*/.pi-agent, workspaces/departed-workspaces/*/.pi-agent, Pi user agent directory | Move redirected Pi Workspace homes into Pi's native global-provider and project-settings layers. | | `0025_retire_global_compaction_config` | 0.83.0-beta | 2026-07-19 | compaction.json, ai-provider-manager.json | Remove retired global context and compaction limits so model and native Agent runtime semantics remain authoritative. | | `0026_agent_conversation_log` | 0.87.0-beta | 2026-07-29 | workspaces/state/agent-conversations.jsonl | Create the private append-only cross-Agent conversation event log. | +| `0027_repair_snapshot_interval` | 0.87.0-beta | 2026-07-29 | snapshot.json | Repair invalid historical portfolio snapshot intervals before strict duration validation loads them. | diff --git a/src/migrations/registry.ts b/src/migrations/registry.ts index 970a25421..448708e73 100644 --- a/src/migrations/registry.ts +++ b/src/migrations/registry.ts @@ -14,7 +14,7 @@ * deletion + Workspace pivot turned the pre-0.40 data shapes over completely, so * pre-0.40 installs rebuild `data/` rather than migrate. The framework stays for * future upgrades. Numbering continues FORWARD from the highest id ever shipped - * (next: 0026) — never reuse a retired id, since existing installs' journals + * (next: 0028) — never reuse a retired id, since existing installs' journals * recorded the old ones. */ @@ -38,6 +38,7 @@ import { migration as migration_0023_google_native_credentials } from './0023_go import { migration as migration_0024_pi_native_workspace_config } from './0024_pi_native_workspace_config/index.js' import { migration as migration_0025_retire_global_compaction_config } from './0025_retire_global_compaction_config/index.js' import { migration as migration_0026_agent_conversation_log } from './0026_agent_conversation_log/index.js' +import { migration as migration_0027_repair_snapshot_interval } from './0027_repair_snapshot_interval/index.js' export const REGISTRY: Migration[] = [ migration_0008_disable_targetless_cron_jobs, @@ -59,4 +60,5 @@ export const REGISTRY: Migration[] = [ migration_0024_pi_native_workspace_config, migration_0025_retire_global_compaction_config, migration_0026_agent_conversation_log, + migration_0027_repair_snapshot_interval, ] diff --git a/src/services/uta-supervisor/restart-trigger.ts b/src/services/uta-supervisor/restart-trigger.ts index 6eb5ad230..a9eeaa1d8 100644 --- a/src/services/uta-supervisor/restart-trigger.ts +++ b/src/services/uta-supervisor/restart-trigger.ts @@ -5,12 +5,9 @@ * 1. Atomic-write `data/control/restart-uta.flag` (write to .tmp + rename) * with content = ISO timestamp of the request. * 2. Guardian's fs.watch fires (debounced 100ms), Guardian SIGTERMs UTA, - * waits exit, respawns with fresh `accounts.json`. + * waits exit, and respawns it with fresh boot-time configuration. * 3. Alice polls `${OPENALICE_UTA_URL}/__uta/health` until `startedAt` is * newer than the pre-trigger value, or until timeout. - * - * Step 5 wires this into `trading-config.ts` so broker setup saves trigger - * UTA reload automatically. Step 4 just exposes the helper. */ import { writeFile, rename, mkdir } from 'fs/promises' diff --git a/src/webui/routes/config-snapshot.spec.ts b/src/webui/routes/config-snapshot.spec.ts new file mode 100644 index 000000000..0540d962b --- /dev/null +++ b/src/webui/routes/config-snapshot.spec.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + writeConfigSection: vi.fn(), + triggerUTARestart: vi.fn(), +})) + +vi.mock('../../core/config.js', async () => { + const actual = await vi.importActual('../../core/config.js') + return { + ...actual, + writeConfigSection: mocks.writeConfigSection, + } +}) + +vi.mock('../../services/uta-supervisor/restart-trigger.js', () => ({ + triggerUTARestart: mocks.triggerUTARestart, +})) + +import { createConfigRoutes } from './config.js' + +beforeEach(() => { + vi.clearAllMocks() + mocks.writeConfigSection.mockImplementation(async (_section, body) => body) + mocks.triggerUTARestart.mockResolvedValue({ triggered: true, ready: true }) +}) + +describe('snapshot config route', () => { + it('requests a supervised UTA restart after persisting snapshot settings', async () => { + const routes = createConfigRoutes() + const response = await routes.request('/snapshot', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, every: '2h' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ enabled: true, every: '2h' }) + expect(mocks.writeConfigSection).toHaveBeenCalledWith('snapshot', { + enabled: true, + every: '2h', + }) + await vi.waitFor(() => expect(mocks.triggerUTARestart).toHaveBeenCalledOnce()) + }) +}) diff --git a/src/webui/routes/config.ts b/src/webui/routes/config.ts index e979e4334..32c835b35 100644 --- a/src/webui/routes/config.ts +++ b/src/webui/routes/config.ts @@ -376,11 +376,11 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { const fresh = await loadConfig() Object.assign(opts.ctx.config, fresh) } - // trading.json is consumed by the UTA process at boot (order-sync - // poller cadence) — bounce UTA via the Guardian flag protocol, same - // as broker config edits. Fire-and-forget: progress is visible - // through the health badges. - if (section === 'trading') { + // trading.json and snapshot.json are consumed by the UTA process at + // boot (order-sync and snapshot pump cadence) — bounce UTA via the + // Guardian flag protocol, same as broker config edits. + // Fire-and-forget: progress is visible through the health badges. + if (section === 'trading' || section === 'snapshot') { triggerUTARestart().catch(() => { /* surfaced via health badges */ }) } // marketData edits are picked up lazily by the provider resolver diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 129c1086c..ae9f9abfd 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -406,6 +406,8 @@ export interface WalletPushResult { operationCount: number submitted: Array<{ action: string; success: boolean; orderId?: string; status: string; error?: string }> rejected: Array<{ action: string; success: boolean; error?: string; status: string }> + /** Demo-only marker: the response exercises the result UI without contacting a broker. */ + simulated?: boolean } // ==================== Order / Trade History ==================== diff --git a/ui/src/components/IssuesBoard.spec.tsx b/ui/src/components/IssuesBoard.spec.tsx index adfd52eb0..46726692e 100644 --- a/ui/src/components/IssuesBoard.spec.tsx +++ b/ui/src/components/IssuesBoard.spec.tsx @@ -4,6 +4,7 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { IssueListItem, IssueSnapshot } from '../api/issues' +import { i18n } from '../i18n' import { IssuesBoard } from './IssuesBoard' const mocks = vi.hoisted(() => ({ @@ -52,7 +53,8 @@ function snapshot(issues: IssueListItem[]): IssueSnapshot { } } -beforeEach(() => { +beforeEach(async () => { + await i18n.changeLanguage('en') mocks.useIssues.mockReturnValue({ data: snapshot([]), error: null, @@ -147,4 +149,36 @@ describe('IssuesBoard', () => { expect(screen.getByText('Assign on first run')).toBeTruthy() expect(screen.queryByText('@new')).toBeNull() }) + + it('localizes board chrome, schedule health, and accessible metadata', async () => { + await i18n.changeLanguage('zh') + mocks.useIssues.mockReturnValue({ + data: snapshot([ + issue({ + id: 'weekday-scan', + title: '工作日扫描', + status: 'in_progress', + priority: 'high', + assignee: '@new', + agent: 'claude', + when: { kind: 'cron', cron: '30 8 * * 1-5' }, + automationHealth: { state: 'running', message: 'Run is active.' }, + }), + ]), + error: null, + loading: false, + }) + + render() + + expect(screen.getByText('进行中')).toBeTruthy() + expect(screen.getAllByText('运行中')).toHaveLength(2) + expect(screen.getAllByText('每工作日 08:30')).toHaveLength(2) + expect(screen.getByText('首次运行时指派')).toBeTruthy() + expect(screen.getByText('claude 覆盖')).toBeTruthy() + expect(screen.getByLabelText('高优先级')).toBeTruthy() + expect(screen.getByLabelText('折叠“进行中”议题')).toBeTruthy() + expect(screen.getByTitle('打开 weekday-scan')).toBeTruthy() + expect(screen.getByTitle('工作区:market-desk(ws-1)')).toBeTruthy() + }) }) diff --git a/ui/src/components/IssuesBoard.tsx b/ui/src/components/IssuesBoard.tsx index e0fcc0367..236df1855 100644 --- a/ui/src/components/IssuesBoard.tsx +++ b/ui/src/components/IssuesBoard.tsx @@ -1,4 +1,6 @@ import { useState } from 'react' +import type { TFunction } from 'i18next' +import { useTranslation } from 'react-i18next' import { Bot, ChevronDown, @@ -26,7 +28,7 @@ import { STATUS_META } from './issue-status-meta' // ==================== Cadence pill (lifted from AutomationSchedulesSection) ==================== -const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] +const WEEKDAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const function two(n: number): string { return String(n).padStart(2, '0') @@ -38,10 +40,10 @@ function cronInt(field: string, min: number, max: number): number | null { return n >= min && n <= max ? n : null } -function cronDayPhrase(field: string): string | null { - if (field === '*') return 'day' - if (field === '1-5') return 'weekday' - if (field === '0,6' || field === '6,0') return 'weekend' +function cronDayPhrase(field: string, t: TFunction): string | null { + if (field === '*') return t('issues.cadence.day') + if (field === '1-5') return t('issues.cadence.weekday') + if (field === '0,6' || field === '6,0') return t('issues.cadence.weekend') const out: string[] = [] for (const part of field.split(',')) { const range = part.match(/^(\d+)-(\d+)$/) @@ -49,47 +51,53 @@ function cronDayPhrase(field: string): string | null { const start = cronInt(range[1], 0, 7) const end = cronInt(range[2], 0, 7) if (start === null || end === null || start > end) return null - out.push(`${WEEKDAYS[start === 7 ? 0 : start]}-${WEEKDAYS[end === 7 ? 0 : end]}`) + const startKey = WEEKDAY_KEYS[start === 7 ? 0 : start] + const endKey = WEEKDAY_KEYS[end === 7 ? 0 : end] + out.push(`${t(`issues.weekday.${startKey}`)}-${t(`issues.weekday.${endKey}`)}`) continue } const n = cronInt(part, 0, 7) if (n === null) return null - out.push(WEEKDAYS[n === 7 ? 0 : n]) + out.push(t(`issues.weekday.${WEEKDAY_KEYS[n === 7 ? 0 : n]}`)) } return out.length > 0 ? Array.from(new Set(out)).join(', ') : null } -function cronLabel(expr: string): string { +function cronLabel(expr: string, t: TFunction): string { const parts = expr.trim().split(/\s+/) - if (parts.length !== 5) return 'Every custom schedule' + if (parts.length !== 5) return t('issues.cadence.custom') const [minute, hour, dayOfMonth, month, dayOfWeek] = parts if (minute === '*' && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { - return 'Every minute' + return t('issues.cadence.everyMinute') } const minuteStep = minute.match(/^\*\/(\d+)$/) if (minuteStep && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { - return `Every ${minuteStep[1]}m` + return t('issues.cadence.everyDuration', { duration: `${minuteStep[1]}m` }) } const hourStep = hour.match(/^\*\/(\d+)$/) if (minute === '0' && hourStep && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { - return `Every ${hourStep[1]}h` + return t('issues.cadence.everyDuration', { duration: `${hourStep[1]}h` }) } if (minute === '0' && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { - return 'Every hour' + return t('issues.cadence.everyHour') } const m = cronInt(minute, 0, 59) const h = cronInt(hour, 0, 23) - if (m === null || h === null) return 'Every custom schedule' + if (m === null || h === null) return t('issues.cadence.custom') const time = `${two(h)}:${two(m)}` if (month === '*' && dayOfMonth === '*') { - const dayPhrase = cronDayPhrase(dayOfWeek) - return dayPhrase ? `Every ${dayPhrase} ${time}` : `Every custom schedule ${time}` + const dayPhrase = cronDayPhrase(dayOfWeek, t) + return dayPhrase + ? t('issues.cadence.everyDayAt', { day: dayPhrase, time }) + : t('issues.cadence.customAt', { time }) } if (month === '*' && dayOfWeek === '*') { const dom = cronInt(dayOfMonth, 1, 31) - return dom === null ? `Every custom schedule ${time}` : `Every month on day ${dom} ${time}` + return dom === null + ? t('issues.cadence.customAt', { time }) + : t('issues.cadence.everyMonthDayAt', { day: dom, time }) } - return `Every custom schedule ${time}` + return t('issues.cadence.customAt', { time }) } function onceLabel(at: string): string { @@ -107,52 +115,53 @@ function onceLabel(at: string): string { } /** Short pill label: one-shots show their time; recurring schedules start with "Every". */ -function cadenceLabel(when: ScheduleWhen): string { +function cadenceLabel(when: ScheduleWhen, t: TFunction): string { switch (when.kind) { case 'at': return onceLabel(when.at) case 'every': - return `Every ${when.every}` + return t('issues.cadence.everyDuration', { duration: when.every }) case 'cron': - return cronLabel(when.cron) + return cronLabel(when.cron, t) } } -function cadenceTitle(when: ScheduleWhen): string { +function cadenceTitle(when: ScheduleWhen, t: TFunction): string { switch (when.kind) { case 'at': return `${onceLabel(when.at)} (${when.at})` case 'every': - return `Every ${when.every}` + return t('issues.cadence.everyDuration', { duration: when.every }) case 'cron': - return `${cronLabel(when.cron)} · ${when.timezone ?? 'local time'} (${when.cron})` + return `${cronLabel(when.cron, t)} · ${when.timezone ?? t('issues.cadence.localTime')} (${when.cron})` } } export function CadencePill({ when }: { when: ScheduleWhen }) { + const { t } = useTranslation() return ( - {cadenceLabel(when)} + {cadenceLabel(when, t)} {when.kind === 'cron' && ( - · {when.timezone ?? 'local'} + · {when.timezone ?? t('issues.cadence.local')} )} ) } -const AUTOMATION_HEALTH_META: Record = { - inactive: { label: 'Inactive', className: 'bg-muted text-muted-foreground' }, - not_started: { label: 'Not started', className: 'bg-muted text-muted-foreground' }, - due: { label: 'Due', className: 'bg-warning/15 text-warning' }, - running: { label: 'Running', className: 'bg-info/15 text-info' }, - healthy: { label: 'Healthy', className: 'bg-success/15 text-success' }, - interrupted: { label: 'Interrupted', className: 'bg-warning/15 text-warning' }, - failed: { label: 'Failed', className: 'bg-destructive/15 text-destructive' }, - blocked: { label: 'Blocked', className: 'bg-destructive/15 text-destructive' }, +const AUTOMATION_HEALTH_CLASS: Record = { + inactive: 'bg-muted text-muted-foreground', + not_started: 'bg-muted text-muted-foreground', + due: 'bg-warning/15 text-warning', + running: 'bg-info/15 text-info', + healthy: 'bg-success/15 text-success', + interrupted: 'bg-warning/15 text-warning', + failed: 'bg-destructive/15 text-destructive', + blocked: 'bg-destructive/15 text-destructive', } const BOARD_HEALTH_CLASS: Record = { @@ -167,14 +176,14 @@ const BOARD_HEALTH_CLASS: Record = { } export function AutomationHealthPill({ health }: { health: IssueAutomationHealth }) { - const meta = AUTOMATION_HEALTH_META[health.state] + const { t } = useTranslation() return ( - {meta.label} + {t(`issues.health.${health.state}`)} ) } @@ -187,11 +196,12 @@ export function AutomationHealthPill({ health }: { health: IssueAutomationHealth * `!` so it never reads as "just high". */ export function PriorityIndicator({ priority }: { priority: IssuePriority }) { + const { t } = useTranslation() if (priority === 'urgent') { return ( ! @@ -202,8 +212,8 @@ export function PriorityIndicator({ priority }: { priority: IssuePriority }) { const heights = [4, 7, 10] return ( {heights.map((h, i) => ( @@ -324,9 +334,9 @@ function boardRowOrder(a: BoardRow, b: BoardRow): number { } function BoardHealth({ issue }: { issue: IssueListItem }) { + const { t } = useTranslation() const health = issue.automationHealth if (!health) return null - const meta = AUTOMATION_HEALTH_META[health.state] const active = health.state === 'running' || health.state === 'due' const lastRun = issue.lastFiredAtMs ? formatRelativeTime(issue.lastFiredAtMs) : '' @@ -336,22 +346,23 @@ function BoardHealth({ issue }: { issue: IssueListItem }) { className={`inline-flex shrink-0 items-center gap-1.5 text-[11px] font-medium ${BOARD_HEALTH_CLASS[health.state]}`} > - {meta.label} + {t(`issues.health.${health.state}`)} {lastRun && · {lastRun}} ) } function BoardCadence({ issue }: { issue: IssueListItem }) { + const { t } = useTranslation() if (!issue.when) return null const nextRun = issue.nextDueAtMs ? formatRelativeTime(issue.nextDueAtMs) : '' return ( - {cadenceLabel(issue.when)} + {cadenceLabel(issue.when, t)} {nextRun && · {nextRun}} ) @@ -368,17 +379,18 @@ function BoardAutomationSummary({ issue, className = '' }: { issue: IssueListIte } function IssueRow({ wsId, wsTag, issue, agentRuntime, dupOthers, onOpen }: BoardRow & { onOpen: () => void }) { + const { t } = useTranslation() const terminal = issue.status === 'done' || issue.status === 'canceled' const titleMatchesId = issue.title.trim().toLowerCase() === issue.id.trim().toLowerCase() const explicitAssignee = issue.assignee !== '@workspace' const explicitAgent = agentRuntime?.source === 'override' && agentRuntime.distinctOverride !== false - const assigneeLabel = issue.assignee === '@new' ? 'Assign on first run' : issue.assignee + const assigneeLabel = issue.assignee === '@new' ? t('issues.assignOnFirstRun') : issue.assignee return (
  • {!collapsed && ( @@ -487,6 +502,7 @@ function StatusGroup({ // ==================== Invalid-workspace surface (loud failure) ==================== function InvalidWorkspaces({ workspaces }: { workspaces: IssueWorkspace[] }) { + const { t } = useTranslation() if (workspaces.length === 0) return null return (
    @@ -497,7 +513,7 @@ function InvalidWorkspaces({ workspaces }: { workspaces: IssueWorkspace[] }) { > {ws.tag}{' '} {ws.wsId.slice(0, 8)} -

    {ws.error ?? 'issues are unreadable for this workspace'}

    +

    {ws.error ?? t('issues.unreadableWorkspace')}

    ))} @@ -515,6 +531,7 @@ function InvalidWorkspaces({ workspaces }: { workspaces: IssueWorkspace[] }) { * and nothing to create here (Phase 1). */ export function IssuesBoard() { + const { t } = useTranslation() const { data, error, loading } = useIssues() const { agents, defaultAgent, issueDefaultAgent, workspaces: workspaceMetas } = useWorkspaces() const openOrFocus = useWorkspace((s) => s.openOrFocus) @@ -538,7 +555,7 @@ export function IssuesBoard() { // flipping to a loading/error screen on a transient refresh failure. if (!data) { if (loading) return - return
    Failed to load issues: {error}
    + return
    {t('issues.loadError', { error: error ?? t('issues.unknownError') })}
    } // Defensive: tolerate a malformed/empty payload (e.g. the demo catchAll's @@ -590,7 +607,7 @@ export function IssuesBoard() { const staleBanner = error ? (
    - Live refresh failing — showing the last known issues. + {t('issues.stale')}
    ) : null @@ -600,13 +617,13 @@ export function IssuesBoard() { {staleBanner}
    -

    No workspace has any issues yet.

    +

    {t('issues.emptyTitle')}

    - A workspace tracks an issue by writing{' '} + {t('issues.emptyPrefix')}{' '} .alice/issues/<id>.md - . Add a when field and it self-schedules. + {t('issues.emptySuffixBeforeWhen')}when{t('issues.emptySuffixAfterWhen')}

    diff --git a/ui/src/components/market/SearchBox.spec.tsx b/ui/src/components/market/SearchBox.spec.tsx new file mode 100644 index 000000000..7acdd1a3c --- /dev/null +++ b/ui/src/components/market/SearchBox.spec.tsx @@ -0,0 +1,57 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router-dom' + +import { i18n } from '../../i18n' + +const state = vi.hoisted(() => ({ + loading: false, +})) + +vi.mock('./useAssetSearch', () => ({ + useAssetSearch: () => ({ results: [], loading: state.loading }), +})) + +import { SearchBox } from './SearchBox' + +beforeEach(async () => { + state.loading = false + await i18n.changeLanguage('zh') +}) + +afterEach(cleanup) + +afterAll(async () => { + await i18n.changeLanguage('en') +}) + +function setup() { + render( + + + , + ) +} + +describe('Market SearchBox localization', () => { + it('localizes the input and empty result state', () => { + setup() + + const input = screen.getByPlaceholderText('搜索资产——AAPL、比特币、EUR、黄金…') + fireEvent.change(input, { target: { value: 'missing' } }) + + expect(screen.getByText('无匹配')).toBeTruthy() + }) + + it('localizes the pending result state', () => { + state.loading = true + setup() + + fireEvent.change( + screen.getByPlaceholderText('搜索资产——AAPL、比特币、EUR、黄金…'), + { target: { value: 'apple' } }, + ) + + expect(screen.getByText('搜索中…')).toBeTruthy() + }) +}) diff --git a/ui/src/components/market/SearchBox.tsx b/ui/src/components/market/SearchBox.tsx index 9ac168ed9..854167189 100644 --- a/ui/src/components/market/SearchBox.tsx +++ b/ui/src/components/market/SearchBox.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams } from 'react-router-dom' import { type BarSourceCandidate, type AssetClass } from '../../api/market' import { useAssetSearch } from './useAssetSearch' @@ -20,6 +21,7 @@ const CAPABILITY_COLOR: Record = { } export function SearchBox() { + const { t } = useTranslation() const navigate = useNavigate() const [searchParams] = useSearchParams() const [query, setQuery] = useState('') @@ -76,7 +78,7 @@ export function SearchBox() {
    { setQuery(e.target.value); setOpen(true) }} onFocus={() => setOpen(true)} @@ -85,10 +87,10 @@ export function SearchBox() { {open && query.trim() && (
    {loading && results.length === 0 && ( -
    Searching…
    +
    {t('market.searching')}
    )} {!loading && results.length === 0 && ( -
    No matches
    +
    {t('market.noMatches')}
    )} {results.map((r, i) => ( )} @@ -125,6 +124,7 @@ function byInstrumentFamiliarity(a: ContractSearchHit, b: ContractSearchHit): nu } function ContractRow({ hit }: { hit: ContractSearchHit }) { + const { t } = useTranslation() const c = hit.contract const aliceId = c.aliceId as string | undefined // Bridge to the UTA detail page's order entry — clicking jumps the @@ -159,9 +159,9 @@ function ContractRow({ hit }: { hit: ContractSearchHit }) { - Order → + {t('market.tradeableOrder')} → )}
  • diff --git a/ui/src/components/uta/OrderEntryDialog.tsx b/ui/src/components/uta/OrderEntryDialog.tsx index fdef6d057..3ac1373d0 100644 --- a/ui/src/components/uta/OrderEntryDialog.tsx +++ b/ui/src/components/uta/OrderEntryDialog.tsx @@ -420,13 +420,16 @@ function PushResultPanel({ result }: { result: WalletPushResult }) { const totalRejected = result.rejected.length const totalSubmitted = result.submitted.length const fullySubmitted = totalRejected === 0 && totalSubmitted > 0 + const simulated = result.simulated === true return (
    - {fullySubmitted + {simulated + ? `${totalSubmitted} operation${totalSubmitted > 1 ? 's' : ''} simulated` + : fullySubmitted ? `${totalSubmitted} operation${totalSubmitted > 1 ? 's' : ''} submitted to broker` : `${totalSubmitted} submitted, ${totalRejected} rejected`} @@ -444,16 +447,24 @@ function PushResultPanel({ result }: { result: WalletPushResult }) {
    {result.submitted.length > 0 && ( - + )} {result.rejected.length > 0 && ( )} -

    - Status Submitted means the broker accepted the order — fills happen async. - Refresh the positions / orders panels in a moment to see the order transition to Filled. -

    + {simulated + ? ( +

    + Demo simulation only — no order was sent to a broker and portfolio data was not changed. +

    + ) + : ( +

    + Status Submitted means the broker accepted the order — fills happen async. + Refresh the positions / orders panels in a moment to see the order transition to Filled. +

    + )}
    ) } diff --git a/ui/src/components/workspace/WebPiView.spec.tsx b/ui/src/components/workspace/WebPiView.spec.tsx index cbbe60dc2..f98ad1c76 100644 --- a/ui/src/components/workspace/WebPiView.spec.tsx +++ b/ui/src/components/workspace/WebPiView.spec.tsx @@ -1,10 +1,10 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WebPiSnapshot } from './api' -import { WebPiView } from './WebPiView' +import { isWebPiNearBottom, WebPiView } from './WebPiView' const mocks = vi.hoisted(() => ({ abortWebPiSession: vi.fn(), @@ -46,7 +46,69 @@ beforeEach(() => { mocks.abortWebPiSession.mockResolvedValue(snapshot('idle')) }) -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +describe('WebPi transcript scrolling', () => { + it('distinguishes a reader browsing history from one following the tail', () => { + expect(isWebPiNearBottom({ scrollTop: 100, clientHeight: 300, scrollHeight: 1_000 } as HTMLElement)).toBe(false) + expect(isWebPiNearBottom({ scrollTop: 650, clientHeight: 300, scrollHeight: 1_000 } as HTMLElement)).toBe(true) + }) + + it('does not force history readers back to the bottom and offers an explicit jump', async () => { + mocks.getWebPiSession.mockResolvedValue(snapshot('idle')) + const { container } = render( + , + ) + await waitFor(() => expect(mocks.getWebPiSession).toHaveBeenCalled()) + + const scroller = container.querySelector('.webpi-messages') as HTMLDivElement + Object.defineProperties(scroller, { + scrollTop: { configurable: true, writable: true, value: 120 }, + clientHeight: { configurable: true, value: 300 }, + scrollHeight: { configurable: true, value: 1_000 }, + }) + fireEvent.scroll(scroller) + + const jump = screen.getByRole('button', { name: 'Jump to latest' }) + fireEvent.click(jump) + + expect(scroller.scrollTo).toHaveBeenLastCalledWith({ top: 1_000, behavior: 'smooth' }) + expect(screen.queryByRole('button', { name: 'Jump to latest' })).toBeNull() + }) + + it('does not force history readers back down when a new snapshot revision arrives', async () => { + vi.useFakeTimers() + let current = snapshot('idle') + mocks.getWebPiSession.mockImplementation(async () => current) + const { container } = render( + , + ) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + const scroller = container.querySelector('.webpi-messages') as HTMLDivElement + Object.defineProperties(scroller, { + scrollTop: { configurable: true, writable: true, value: 120 }, + clientHeight: { configurable: true, value: 300 }, + scrollHeight: { configurable: true, value: 1_000 }, + }) + fireEvent.scroll(scroller) + const scrollCallsBeforeUpdate = vi.mocked(scroller.scrollTo).mock.calls.length + + current = { ...current, revision: current.revision + 1, phase: 'working' } + await act(async () => { + await vi.advanceTimersByTimeAsync(1_500) + }) + + expect(mocks.getWebPiSession).toHaveBeenCalledTimes(2) + expect(scroller.scrollTo).toHaveBeenCalledTimes(scrollCallsBeforeUpdate) + expect(screen.getByRole('button', { name: 'Jump to latest' })).toBeTruthy() + }) +}) describe('WebPiView compaction state', () => { it('explains the pause and keeps the stop action available while Pi compacts', async () => { diff --git a/ui/src/components/workspace/WebPiView.tsx b/ui/src/components/workspace/WebPiView.tsx index 9d3f06653..dbdbd0937 100644 --- a/ui/src/components/workspace/WebPiView.tsx +++ b/ui/src/components/workspace/WebPiView.tsx @@ -18,6 +18,15 @@ import { type WebPiTranscriptItem, } from './webpi-transcript' +const FOLLOW_LATEST_THRESHOLD_PX = 72 + +export function isWebPiNearBottom( + metrics: Pick, + threshold = FOLLOW_LATEST_THRESHOLD_PX, +): boolean { + return metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop <= threshold +} + interface Props { readonly wsId: string readonly sessionId: string @@ -32,8 +41,10 @@ export function WebPiView({ wsId, sessionId, label, onSessionLost }: Props): Rea const [snapshot, setSnapshot] = useState(null) const [draft, setDraft] = useState('') const [error, setError] = useState(null) + const [followingLatest, setFollowingLatest] = useState(true) const scrollerRef = useRef(null) const snapshotRef = useRef(null) + const followLatestRef = useRef(true) const acceptSnapshot = useCallback((next: WebPiSnapshot): void => { snapshotRef.current = next @@ -77,15 +88,25 @@ export function WebPiView({ wsId, sessionId, label, onSessionLost }: Props): Rea }, [snapshot]) const transcript = useMemo(() => groupWebPiTranscript(messages), [messages]) + const scrollToLatest = useCallback((behavior: ScrollBehavior = 'smooth'): void => { + const scroller = scrollerRef.current + if (!scroller) return + followLatestRef.current = true + setFollowingLatest(true) + scroller.scrollTo({ top: scroller.scrollHeight, behavior }) + }, []) + useEffect(() => { - scrollerRef.current?.scrollTo({ top: scrollerRef.current.scrollHeight, behavior: 'smooth' }) - }, [snapshot?.revision, transcript.length]) + if (followLatestRef.current) scrollToLatest('auto') + }, [scrollToLatest, snapshot?.revision, transcript.length]) const working = snapshot?.phase === 'working' || snapshot?.phase === 'compacting' || snapshot?.phase === 'retrying' const submit = async (): Promise => { const message = draft.trim() if (!message || working) return + followLatestRef.current = true + setFollowingLatest(true) setDraft('') setError(null) try { @@ -117,7 +138,15 @@ export function WebPiView({ wsId, sessionId, label, onSessionLost }: Props): Rea -
    +
    { + const follows = isWebPiNearBottom(event.currentTarget) + followLatestRef.current = follows + setFollowingLatest(follows) + }} + > {messages.length === 0 && !error && (
    This Pi conversation is ready in the browser.
    )} @@ -148,6 +177,12 @@ export function WebPiView({ wsId, sessionId, label, onSessionLost }: Props): Rea
    )} + {!followingLatest && ( + + )} +