From 9cc2d22433ea856d5de1ac5c10c5f3231c09142f Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:08:46 +0800 Subject: [PATCH] feat: polish Trail workbench and fix desktop integrations --- README.md | 9 + apps/electron/src/main/index.ts | 9 +- apps/electron/src/main/kernelHost.test.ts | 86 +++- apps/electron/src/main/kernelHost.ts | 96 ++-- apps/electron/src/renderer/styles/index.css | 436 ++++++------------ packages/core/src/index.ts | 2 + packages/i18n/src/locales/en-US/agent.ts | 7 +- .../i18n/src/locales/en-US/connections.ts | 7 +- packages/i18n/src/locales/en-US/demo.ts | 9 +- packages/i18n/src/locales/en-US/navigation.ts | 10 + packages/i18n/src/locales/en-US/research.ts | 4 + packages/i18n/src/locales/zh-CN/agent.ts | 17 +- .../i18n/src/locales/zh-CN/connections.ts | 7 +- packages/i18n/src/locales/zh-CN/demo.ts | 9 +- packages/i18n/src/locales/zh-CN/navigation.ts | 12 +- packages/i18n/src/locales/zh-CN/research.ts | 4 + packages/i18n/src/locales/zh-CN/today.ts | 4 +- packages/longbridge-tools/src/errors.ts | 1 + packages/longbridge-tools/src/executor.ts | 3 + packages/longbridge-tools/src/parser.test.ts | 7 + packages/longbridge-tools/src/parser.ts | 4 +- packages/pi-extension/src/index.test.ts | 30 ++ packages/pi-extension/src/index.ts | 16 +- packages/shared/src/agent/llm-rpc.test.ts | 22 + packages/shared/src/agent/pi-rpc-client.ts | 31 +- .../agent/pi-runtime-agent-backend.test.ts | 22 + .../src/agent/workspace-context.test.ts | 2 +- .../shared/src/capabilities/manifests.test.ts | 8 + .../capabilities/manifests/market-quote.ts | 6 +- .../src/providers/longbridge/adapter.ts | 2 + .../shared/src/providers/longbridge/auth.ts | 59 ++- .../shared/src/providers/longbridge/health.ts | 2 +- .../providers/longbridge/longbridge.test.ts | 79 +++- packages/ui/src/atoms/portfolioAtoms.ts | 6 +- packages/ui/src/atoms/sessionAtoms.test.ts | 27 ++ packages/ui/src/atoms/sessionAtoms.ts | 26 +- packages/ui/src/atoms/workspaceAtoms.ts | 2 + packages/ui/src/client/connections.ts | 16 + .../ui/src/components/agent/AgentPanel.tsx | 118 ++--- .../ui/src/components/agent/ModelSelector.tsx | 2 +- .../src/components/agent/ThinkingSelector.tsx | 4 +- .../ui/src/components/brand/TrailIdentity.tsx | 21 + .../ui/src/components/chat/MessageList.tsx | 17 +- .../ui/src/components/chat/TurnCard.test.tsx | 15 + packages/ui/src/components/chat/TurnCard.tsx | 79 ++-- .../ui/src/components/layout/AppShell.tsx | 26 +- packages/ui/src/components/layout/Sidebar.tsx | 17 +- .../src/components/layout/WorkbenchShell.tsx | 11 +- .../layout/WorkspaceTopbar.test.tsx | 52 +++ .../src/components/layout/WorkspaceTopbar.tsx | 60 ++- .../components/portfolio/AssetPieChart.tsx | 24 +- .../components/portfolio/PortfolioCard.tsx | 5 +- .../ui/src/components/primitives/Button.tsx | 2 +- .../components/primitives/DemoBadge.test.tsx | 24 + .../src/components/primitives/DemoBadge.tsx | 20 +- .../ui/src/components/primitives/Dialog.tsx | 2 +- .../ui/src/components/primitives/Input.tsx | 2 +- .../research/ResearchMarketWorkspace.tsx | 6 +- .../research/ResearchPanel.test.tsx | 16 + .../src/components/research/ResearchPanel.tsx | 13 +- .../components/settings/ConnectionCard.tsx | 41 +- .../settings/ConnectionsCenter.test.tsx | 41 ++ .../src/components/today/TodayView.test.tsx | 2 +- .../ui/src/components/today/TodayView.tsx | 18 +- packages/ui/src/components/ui/select.tsx | 4 +- packages/ui/src/components/ui/tabs.tsx | 4 +- .../ui/src/components/workspace/ChartView.tsx | 5 +- .../components/workspace/FinancialsView.tsx | 5 +- .../components/workspace/NewsView.test.tsx | 40 ++ .../ui/src/components/workspace/NewsView.tsx | 17 +- .../src/components/workspace/OverviewView.tsx | 5 +- packages/ui/src/lib/portfolioFailure.test.ts | 4 + packages/ui/src/lib/portfolioFailure.ts | 6 + packages/ui/src/styles/chat.css | 62 +++ packages/ui/src/styles/index.css | 132 +----- packages/ui/src/styles/theme.css | 124 +++++ start-trail.cmd | 19 + 77 files changed, 1439 insertions(+), 725 deletions(-) create mode 100644 packages/ui/src/components/brand/TrailIdentity.tsx create mode 100644 packages/ui/src/components/layout/WorkspaceTopbar.test.tsx create mode 100644 packages/ui/src/components/primitives/DemoBadge.test.tsx create mode 100644 packages/ui/src/components/workspace/NewsView.test.tsx create mode 100644 packages/ui/src/styles/chat.css create mode 100644 packages/ui/src/styles/theme.css create mode 100644 start-trail.cmd diff --git a/README.md b/README.md index 50a37d7..30a73ce 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,15 @@ FINAGENT_AGENT_PROVIDER=local bun run dev FINAGENT_AGENT_PROVIDER=local FINAGENT_DEMO_DATA=1 bun run dev ``` +Windows CMD 打开已构建的桌面工作台: + +```cmd +bun.cmd run build:electron +start-trail.cmd +``` + +`start-trail.cmd` 会优先使用 npm 安装的原生 `bunx.exe`,避免 Windows 的 `bunx.cmd` 包装脚本无法被 Pi 子进程启动;若长桥安装在标准用户目录,也会补齐当前进程的 PATH。Pi CLI、模型凭据及长桥 CLI / 账户授权仍需单独配置。 + ### 命令 | 命令 | 说明 | diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index 98e694c..9cb8692 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -25,8 +25,13 @@ loadFinagentEnv({ if (!app.requestSingleInstanceLock()) app.exit(0); app.on('second-instance', () => { if (process.env.FINAGENT_E2E_HIDDEN === '1') return; - if (mainWindow?.isMinimized()) mainWindow.restore(); - mainWindow?.focus(); + void app.whenReady().then(() => { + if (!mainWindow || mainWindow.isDestroyed()) createWindow(); + if (mainWindow?.isMinimized()) mainWindow.restore(); + // focus() alone does not reveal a window hidden by an earlier launch. + mainWindow?.show(); + mainWindow?.focus(); + }); }); const agentKernelHost = new AgentKernelHost(); registerAboutIpc(); diff --git a/apps/electron/src/main/kernelHost.test.ts b/apps/electron/src/main/kernelHost.test.ts index 0be901d..ba0f126 100644 --- a/apps/electron/src/main/kernelHost.test.ts +++ b/apps/electron/src/main/kernelHost.test.ts @@ -1,14 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import { join } from 'node:path'; import type { AgentEvent, AutomationRule } from '@finagent/core'; +import type { AnyProvider, ConnectionState } from '@finagent/shared'; +import type { LoginOutcome, StartLoginOptions } from '../../../../packages/shared/src/providers/longbridge/auth.ts'; let lastKernelOptions: Record | null = null; let lastMarketData: FakeMarketDataService | null = null; let lastAutomationContext: unknown = null; let forwardedEvents: unknown[] = []; +let loginHandler: (options: StartLoginOptions) => Promise = async () => ({ status: 'cancelled' }); +let connectionUpdates: Array> = []; +let listedProviders: AnyProvider[] = []; +let storedConnection: ConnectionState | undefined; const routerFetchers = { getQuote: async () => ({ symbol: 'AAPL.US' }) }; class FakeMarketDataService { + clear() {} quoteSymbols: string[] = []; constructor(readonly options?: { fetchers?: unknown }) {} @@ -177,14 +184,16 @@ mock.module('@finagent/shared', () => ({ register = () => undefined; setRouting = () => undefined; get = () => undefined; - list = () => []; + list = () => listedProviders; coverage = () => []; + lastResultFor = () => undefined; }, ConnectionStore: class { list = async () => []; - get = async () => undefined; + get = async (providerId: string) => storedConnection?.providerId === providerId ? storedConnection : undefined; getConfig = async () => undefined; - update = async () => undefined; + getRouting = async (fallback: unknown) => fallback; + update = async (state: Record) => { connectionUpdates.push(state); }; setConfig = async () => undefined; subscribe = () => () => undefined; }, @@ -341,6 +350,11 @@ mock.module('@finagent/skill-hub', () => ({ skillCapabilityMap: {}, })); +const longbridgeProviders = await import('@finagent/shared/providers/longbridge'); +mock.module('@finagent/shared/providers/longbridge', () => ({ + ...longbridgeProviders, + startLogin: (options: StartLoginOptions) => loginHandler(options), +})); const { AgentKernelHost } = await import('./kernelHost.ts'); // The constructor sets FINAGENT_PI_EXTENSION as a process-wide side effect; @@ -352,6 +366,10 @@ beforeEach(() => { lastMarketData = null; lastAutomationContext = null; forwardedEvents = []; + connectionUpdates = []; + listedProviders = []; + storedConnection = undefined; + loginHandler = async () => ({ status: 'cancelled' }); }); afterEach(() => { @@ -363,6 +381,68 @@ afterEach(() => { }); describe('AgentKernelHost', () => { + it('shows Connect after installation despite a persisted not-installed result', async () => { + const host = new AgentKernelHost(); + storedConnection = { + providerId: 'longbridge', status: 'not-installed', lastCheck: 1, + error: { code: 'LONGBRIDGE_NOT_INSTALLED', message: 'Old missing CLI result' }, + }; + listedProviders = [new longbridgeProviders.LongbridgeFinancialDataProvider({ + exec: async (args) => args[0] === '--version' ? 'longbridge 0.30.0' + : JSON.stringify({ token: { status: 'not_found' } }), + })]; + const entries = await host.listConnections(); + expect(entries[0]).toMatchObject({ status: 'not-connected', error: null }); + host.dispose(); + }); + + it('ends a failed login startup and persists not-installed instead of connecting', async () => { + const host = new AgentKernelHost(); + loginHandler = async () => ({ status: 'error', error: { + code: 'LONGBRIDGE_NOT_INSTALLED', message: 'CLI not found', + } }); + await expect(host.connectProvider({ providerId: 'longbridge' })).rejects.toMatchObject({ + code: 'LONGBRIDGE_NOT_INSTALLED', + }); + expect(connectionUpdates.at(-1)).toMatchObject({ status: 'not-installed' }); + // The failed attempt must release the in-memory login lock too. + loginHandler = async () => ({ status: 'connected' }); + await expect(host.connectProvider({ providerId: 'longbridge' })).resolves.toEqual({ status: 'connected' }); + host.dispose(); + }); + + it('cancels authorization while waiting for the first URL', async () => { + const host = new AgentKernelHost(); + let started!: () => void; + const ready = new Promise((resolve) => { started = resolve; }); + loginHandler = async ({ signal }) => { + started(); + return new Promise((resolve) => signal?.addEventListener('abort', () => resolve({ status: 'cancelled' }), { once: true })); + }; + const pending = host.connectProvider({ providerId: 'longbridge' }); + await ready; + await host.cancelConnectProvider({ providerId: 'longbridge' }); + await expect(pending).rejects.toMatchObject({ code: 'LOGIN_CANCELLED' }); + expect(connectionUpdates.at(-1)).toMatchObject({ status: 'not-connected' }); + host.dispose(); + }); + + it('returns the manual URL while the device flow continues, then cancels it', async () => { + const host = new AgentKernelHost(); + let finished!: (outcome: LoginOutcome) => void; + loginHandler = async ({ onVerificationUri }) => { + onVerificationUri?.('https://example.test/authorize'); + return new Promise((resolve) => { finished = resolve; }); + }; + await expect(host.connectProvider({ providerId: 'longbridge' })).resolves.toEqual({ + status: 'connecting', verificationUrl: 'https://example.test/authorize', + }); + finished({ status: 'cancelled' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(connectionUpdates.at(-1)).toMatchObject({ status: 'not-connected' }); + host.dispose(); + }); + it('builds the kernel on the electron userData store', () => { const host = new AgentKernelHost(); diff --git a/apps/electron/src/main/kernelHost.ts b/apps/electron/src/main/kernelHost.ts index 7ac5177..696d603 100644 --- a/apps/electron/src/main/kernelHost.ts +++ b/apps/electron/src/main/kernelHost.ts @@ -1011,7 +1011,7 @@ export class AgentKernelHost { }); } for (const info of credentialInfos) { - if (info.custom || byProvider.has(info.provider)) continue; + if (info.custom || info.provider.startsWith('custom:') || info.provider === 'runtime' || byProvider.has(info.provider)) continue; statuses.push({ provider: info.provider, displayName: info.provider, @@ -1738,6 +1738,10 @@ export class AgentKernelHost { this.providerRouter.coverage().find((c) => c.providerId === provider.id) ?? null; const config = await this.connectionStore.getConfig(provider.id); const routing = await this.connectionStore.getRouting(DEFAULT_PROVIDER_ROUTING); + // Installation and interrupted-login state can change outside this process. + // A saved not-installed result must not hide a newly installed CLI. + const useLiveHealth = (state?.status === 'connecting' && !this.activeLogin) + || (state?.status === 'not-installed' && health !== null && health.status !== 'not-installed'); let hasAccount = false; let accountLabel: string | null = null; if (provider.kind === 'broker-account') { @@ -1755,7 +1759,9 @@ export class AgentKernelHost { providerId: provider.id, kind: provider.kind, name: provider.name, - status: state?.status ?? health?.status ?? 'not-connected', + status: useLiveHealth + ? health?.status ?? 'not-connected' + : state?.status ?? health?.status ?? 'not-connected', health, coverage, configurable: provider.id === 'massive', @@ -1765,7 +1771,7 @@ export class AgentKernelHost { : health?.status === 'connected', hasAccount, accountLabel, - error: state?.error ?? null, + error: useLiveHealth ? null : state?.error ?? null, enabled: config?.enabled !== false, endpoint: config?.endpoint ?? null, region: config?.region ?? health?.region ?? null, @@ -1792,14 +1798,19 @@ export class AgentKernelHost { if (this.activeLogin) { throw createCodeError('LOGIN_IN_PROGRESS', 'A Longbridge login is already in progress.'); } - await this.connectionStore.update({ providerId, status: 'connecting', lastCheck: Date.now() }); - const controller = new AbortController(); + const login = { cancel: () => controller.abort() }; + this.activeLogin = login; + await this.connectionStore.update({ providerId, status: 'connecting', lastCheck: Date.now() }); let resolveUrl: (uri: string | undefined) => void; const urlPromise = new Promise((resolve) => { resolveUrl = resolve; }); - const urlTimer = setTimeout(() => resolveUrl(undefined), 30_000); + let startupTimedOut = false; + const urlTimer = setTimeout(() => { + startupTimedOut = true; + controller.abort(); + }, 30_000); const outcomePromise = startLogin({ exec: executeLongBridgeCli, @@ -1811,21 +1822,17 @@ export class AgentKernelHost { }, signal: controller.signal, }); - this.activeLogin = { - cancel: () => controller.abort(), - }; - - const verificationUrl = await urlPromise; - if (!verificationUrl) { - controller.abort(); - this.activeLogin = null; - throw createCodeError('LOGIN_START_FAILED', 'Could not start the Longbridge authorization flow.'); - } - // The device flow continues in the background; completion updates the // connection store and pushes `connections:changed` to the renderer. - void outcomePromise + const completion = outcomePromise .then(async (outcome) => { + this.marketData.clear(); + if (startupTimedOut) { + outcome = { status: 'error', error: { + code: 'LOGIN_START_FAILED', + message: 'Longbridge did not provide an authorization URL. Check the CLI and network, then retry.', + } }; + } if (outcome.status === 'connected') { await this.connectionStore.update({ providerId, @@ -1838,25 +1845,42 @@ export class AgentKernelHost { } else { await this.connectionStore.update({ providerId, - status: 'error', + status: outcome.status === 'error' && outcome.error.code === 'LONGBRIDGE_NOT_INSTALLED' + ? 'not-installed' : 'error', lastCheck: Date.now(), - error: { code: 'LOGIN_TIMEOUT', message: 'Authorization timed out. Try again.' }, + error: outcome.status === 'error' ? outcome.error + : { code: 'LOGIN_TIMEOUT', message: 'Authorization timed out. Try again.' }, }); } - this.activeLogin = null; - void this.pushConnections(); + return outcome; }) - .catch(() => { - this.activeLogin = null; + .finally(() => { + clearTimeout(urlTimer); + resolveUrl(undefined); + if (this.activeLogin === login) this.activeLogin = null; void this.pushConnections(); }); + // Subscribe to completion immediately: missing binaries / CLI failures + // must resolve the request instead of leaving a persisted Connecting card. + void completion.catch(() => undefined); + const verificationUrl = await urlPromise; + if (!verificationUrl) { + const outcome = await completion; + if (outcome.status === 'connected') return { status: 'connected' }; + const error = outcome.status === 'error' ? outcome.error + : { code: 'LOGIN_CANCELLED', message: 'Longbridge authorization was cancelled.' }; + throw createCodeError(error.code, error.message); + } return { status: 'connecting', verificationUrl }; } async cancelConnectProvider(input: unknown): Promise { - requireObject(input); - this.activeLogin?.cancel(); + const request = requireObject(input); + const providerId = requireString(request.providerId, 'providerId'); + if (providerId !== 'longbridge') throw createCodeError('CONFIG_UNSUPPORTED', 'Unsupported login provider.'); + if (this.activeLogin) this.activeLogin.cancel(); + else await this.connectionStore.update({ providerId, status: 'not-connected', lastCheck: Date.now() }); } async disconnectProvider(input: unknown): Promise { @@ -2531,11 +2555,17 @@ export class AgentKernelHost { baseUrl: provider.baseUrl, apiKey: provider.apiKey, api: provider.api, - models: provider.models, + // Most third-party OpenAI-compatible gateways still reject the + // newer `developer` role. Use `system` unless a provider explicitly + // opts into developer messages. + models: provider.models.map((model) => ({ + ...model, + compat: { supportsDeveloperRole: false, ...(model.compat ?? {}) }, + })), }); } for (const info of await this.credentials.listCredentials()) { - if (info.custom) continue; + if (info.custom || info.provider.startsWith('custom:') || info.provider === 'runtime') continue; const apiKey = await this.credentials.getCredential(info.provider); if (!apiKey) continue; overrides.push({ provider: info.provider, apiKey }); @@ -2649,10 +2679,14 @@ const executeLongBridgeCli: LongbridgeExec = (args, options) => executeLongBridge(args, { timeout: options?.timeout }); /** Spawn the Longbridge CLI; stdout chunks stream to the login flow. */ -const spawnLongbridge: SpawnFn = (args, onStdout) => { +const spawnLongbridge: SpawnFn = (args, onOutput, onError) => { const child = spawn('longbridge', args, { stdio: ['ignore', 'pipe', 'pipe'] }); - child.stdout.on('data', (chunk: Buffer) => onStdout(chunk.toString())); - child.stderr.resume(); + child.stdout.on('data', (chunk: Buffer) => onOutput(chunk.toString())); + child.stderr.on('data', (chunk: Buffer) => onOutput(chunk.toString())); + child.on('error', onError); + child.on('close', (code) => { + if (code !== 0) onError(new Error('Longbridge login exited before authorization completed.')); + }); return { kill: () => child.kill() }; }; diff --git a/apps/electron/src/renderer/styles/index.css b/apps/electron/src/renderer/styles/index.css index a1b9ec0..2342b70 100644 --- a/apps/electron/src/renderer/styles/index.css +++ b/apps/electron/src/renderer/styles/index.css @@ -1,110 +1,8 @@ @import "tailwindcss"; @source "../../../../../packages/ui/src"; @source "../"; - -/* Trail UI V2 — Quiet Financial Workspace. - Keep the palette intentionally restrained: surfaces and spacing carry the - hierarchy; blue is reserved for interaction and focus. */ -:root { - /* One bilingual type system across the app. SF Mono is the research/data - face users see in the market workspace; CJK fallbacks keep Chinese text - in the same deliberate, compact rhythm. */ - --font-global-stack: "Inter", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", "Microsoft YaHei", system-ui, sans-serif; - --font-sans-stack: var(--font-global-stack); - --font-display-stack: "Geist", "Inter", "PingFang SC", "Hiragino Sans GB", system-ui, sans-serif; - --font-numeric-stack: "JetBrains Mono", "SF Mono", ui-monospace, monospace; - --background: #f8fafc; - --surface: #ffffff; - --surface-muted: #f2f4f6; - --surface-hover: #e9eef6; - --foreground: #0f172a; - --foreground-muted: #45464d; - --foreground-subtle: #76777d; - --primary: #0f172a; - --primary-foreground: #ffffff; - --accent: #0284c7; - --accent-foreground: #ffffff; - --positive: #10b981; - --negative: #ef4444; - --warning: #b45309; - --info: #0284c7; - --border: #e2e8f0; - --border-strong: #c6c6cd; - --input: #c6c6cd; - --ring: rgba(2, 132, 199, .4); - --radius: 4px; - --foreground-rgb: 15, 23, 42; - --accent-rgb: 2, 132, 199; - --positive-rgb: 16, 185, 129; - --negative-rgb: 239, 68, 68; - --warning-rgb: 180, 83, 9; - - /* Motion language: preserve the existing 160ms micro interaction rhythm. */ - --motion-fast: 120ms; - --motion-base: 160ms; - --motion-content: 220ms; - --motion-slow: 600ms; - --motion-ambient: 11s; - - /* Compatibility aliases for feature slices that still use the old names. */ - --mac-window: var(--background); - --mac-titlebar: var(--surface); - --mac-sidebar: var(--surface-muted); - --mac-sidebar-hover: color-mix(in srgb, var(--foreground) 6%, transparent); - --mac-border: var(--border); - --mac-border-strong: var(--border-strong); - --mac-control: var(--surface); - --mac-control-hover: var(--surface-hover); - --mac-surface: var(--surface); - --mac-surface-solid: var(--surface); - --mac-blue: var(--accent); - --mac-blue-hover: color-mix(in srgb, var(--accent) 88%, black); - --mac-blue-soft: color-mix(in srgb, var(--accent) 12%, transparent); - --mac-accent: var(--accent); - --mac-green: var(--positive); - --mac-red: var(--negative); - --mac-yellow: var(--warning); - - /* Legacy scalar tokens used by a few existing charts/import views. */ - --bg-primary: 0.975 0.004 260; - --bg-secondary: 0.955 0.006 260; - --accent-primary: 0.56 0.16 252; - --text-primary: 0.19 0.012 260; - --text-secondary: 0.48 0.012 260; -} - -.dark { - --background: #0b0d12; - --surface: #12161d; - --surface-muted: #0f131a; - --surface-hover: #1b2430; - --foreground: #f3f6fa; - --foreground-muted: #aab4c2; - --foreground-subtle: #738092; - --primary: #4c8dff; - --primary-foreground: #08101f; - --accent: #4c8dff; - --accent-foreground: #08101f; - --positive: #42c995; - --negative: #ff6b76; - --warning: #e3b45b; - --info: #4c8dff; - --border: rgba(235, 241, 248, .13); - --border-strong: rgba(235, 241, 248, .22); - --input: rgba(235, 241, 248, .18); - --ring: rgba(76, 141, 255, .58); - --foreground-rgb: 236, 236, 240; - --accent-rgb: 121, 162, 231; - --positive-rgb: 92, 182, 110; - --negative-rgb: 229, 116, 102; - --warning-rgb: 229, 178, 78; - - --bg-primary: 0.16 0.012 260; - --bg-secondary: 0.135 0.012 260; - --accent-primary: 0.72 0.14 250; - --text-primary: 0.93 0.008 260; - --text-secondary: 0.68 0.01 260; -} +@import "../../../../../packages/ui/src/styles/theme.css"; +@import "../../../../../packages/ui/src/styles/chat.css"; @theme inline { --color-background: var(--background); @@ -156,7 +54,7 @@ body { font-feature-settings: "kern" 1, "liga" 1; } h1, h2, h3, h4, h5, h6 { font-family: var(--font-display-stack); } -button, input, textarea, select { font: inherit; } +button, input, textarea, select { font-family: inherit; } button { cursor: pointer; } button:disabled { cursor: not-allowed; } :focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } @@ -256,12 +154,12 @@ button:disabled { cursor: not-allowed; } .trail-pilot-report-section { border-bottom: 1px solid var(--border); padding: 0 0 1rem; } .trail-pilot-report-section header { display: flex; align-items: baseline; justify-content: space-between; gap: .75rem; } .trail-pilot-report-section h4 { color: var(--foreground); font-size: .9rem; font-weight: 700; } -.trail-pilot-report-section p { margin-top: .4rem; color: var(--foreground-muted); font-size: .8rem; line-height: 1.6; } -.trail-pilot-report-section-summary { margin-top: .4rem; color: var(--foreground-muted); font-size: .8rem; line-height: 1.6; } +.trail-pilot-report-section p { margin-top: .4rem; color: var(--foreground-muted); font-size: .92rem; line-height: 1.6; } +.trail-pilot-report-section-summary { margin-top: .4rem; color: var(--foreground-muted); font-size: .92rem; line-height: 1.6; } .trail-pilot-case { border-top: 1px solid var(--border); padding-top: 1rem; } .trail-pilot-case h4, .trail-pilot-evidence h4 { color: var(--foreground); font-size: .78rem; font-weight: 700; letter-spacing: .04em; } .trail-pilot-case ul { display: grid; gap: .45rem; margin: .55rem 0 0; padding: 0; list-style: none; } -.trail-pilot-case li { color: var(--foreground-muted); font-size: .8rem; line-height: 1.55; } +.trail-pilot-case li { color: var(--foreground-muted); font-size: .92rem; line-height: 1.55; } .trail-pilot-case li::before { content: '•'; margin-right: .45rem; color: var(--accent); } .trail-pilot-evidence { border-top: 1px solid var(--border); padding-top: 1rem; } .trail-pilot-evidence-note { margin-top: .3rem; color: var(--foreground-subtle); font-size: .72rem; line-height: 1.5; } @@ -417,17 +315,17 @@ button:disabled { cursor: not-allowed; } .trail-research-asset-header { position: relative; min-height: 7.4rem; border-bottom-color: var(--border); padding: 1.12rem 1.3rem 1rem; } .trail-research-asset-header::before { position: absolute; inset: 0 auto auto 0; height: 2px; width: 100%; background: var(--accent); content: ''; } .trail-research-asset-copy { padding-top: .05rem; } -.trail-research-asset-overline { display: flex; align-items: center; gap: .38rem; margin-bottom: .35rem; color: #7b8798; font-size: .62rem; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; } -.trail-research-market-dot { height: .4rem; width: .4rem; border-radius: 50%; background: #0ba26b; box-shadow: 0 0 0 3px rgba(11, 162, 107, .12); } +.trail-research-asset-overline { display: flex; align-items: center; gap: .38rem; margin-bottom: .35rem; color: var(--foreground-muted); font-size: .62rem; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; } +.trail-research-market-dot { height: .4rem; width: .4rem; border-radius: 50%; background: var(--positive); box-shadow: 0 0 0 3px rgba(11, 162, 107, .12); } .trail-research-asset-identity { gap: .72rem; } .trail-research-asset-identity h1 { font-family: var(--font-display-stack); font-size: 1.58rem; font-weight: 700; letter-spacing: -.045em; } -.trail-research-asset-company { font-size: .8rem !important; color: #647084 !important; } +.trail-research-asset-company { font-size: .8rem !important; color: var(--foreground-muted) !important; } .trail-research-price-line { gap: .82rem; margin-top: .52rem; } .trail-research-price-line strong { font-family: var(--font-numeric-stack); font-size: 1.48rem; font-weight: 650; letter-spacing: -.045em; } .trail-research-price-change { display: inline-flex; align-items: center; border-radius: 999px; padding: .2rem .48rem; font-size: .75rem !important; font-weight: 700; } -.trail-research-price-change.text-positive { background: #e6f7ef; color: #08744a; } -.trail-research-price-change.text-negative { background: #fff0f1; color: #ba1e2e; } -.trail-research-market-meta { margin-top: .48rem; color: #8792a3; font-size: .64rem; } +.trail-research-price-change.text-positive { background: color-mix(in srgb, var(--positive) 10%, var(--surface)); color: var(--positive); } +.trail-research-price-change.text-negative { background: color-mix(in srgb, var(--negative) 10%, var(--surface)); color: var(--negative); } +.trail-research-market-meta { margin-top: .48rem; color: var(--foreground-subtle); font-size: .64rem; } .trail-research-asset-actions { padding-top: .2rem; } .trail-research-outline-button, .trail-research-toolbar-control { min-height: 2.1rem; border-color: var(--border); border-radius: var(--radius); background: var(--surface); padding: .34rem .7rem; color: var(--foreground-muted); font-size: .68rem; font-weight: 700; } .trail-research-outline-button:hover, .trail-research-toolbar-control:hover { border-color: var(--border-strong); background: var(--surface-hover); color: var(--foreground); } @@ -439,28 +337,28 @@ button:disabled { cursor: not-allowed; } .trail-research-tab:hover { background: var(--surface-hover); color: var(--foreground); } .trail-research-tab--active { border: 0; background: var(--surface-muted); color: var(--accent); } .trail-research-chart-panel { border-bottom-color: var(--border); } -.trail-research-chart-toolbar { min-height: 3.1rem; border-bottom: 1px solid #eef1f5; padding: .48rem 1.05rem; } +.trail-research-chart-toolbar { min-height: 3.1rem; border-bottom: 1px solid var(--border); padding: .48rem 1.05rem; } .trail-research-chart-toolbar-left { display: flex; align-items: center; gap: .8rem; min-width: 0; } -.trail-research-chart-mode { display: inline-flex; align-items: center; gap: .35rem; color: #536074; font-size: .66rem; font-weight: 700; white-space: nowrap; } +.trail-research-chart-mode { display: inline-flex; align-items: center; gap: .35rem; color: var(--foreground-muted); font-size: .66rem; font-weight: 700; white-space: nowrap; } .trail-research-periods { display: flex; align-items: center; gap: .12rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-muted); padding: .16rem; } -.trail-research-period { min-height: 1.55rem; border-radius: 5px; padding: .18rem .48rem; color: #778398; font-size: .62rem; font-weight: 750; } -.trail-research-period:hover { background: #e9eef6; color: #1d2735; } +.trail-research-period { min-height: 1.55rem; border-radius: 5px; padding: .18rem .48rem; color: var(--foreground-muted); font-size: .62rem; font-weight: 750; } +.trail-research-period:hover { background: var(--surface-muted); color: var(--foreground); } .trail-research-period--active { background: var(--primary); color: var(--primary-foreground); box-shadow: 0 1px 2px rgba(var(--foreground-rgb), .12); } .trail-research-chart-canvas { height: 19rem; padding: .25rem .55rem .45rem; } -.trail-research-chart-empty { color: #8994a5; } -.trail-research-chart-skeleton { border-top-color: rgba(0, 82, 255, .35); background: repeating-linear-gradient(165deg, transparent 0 1.9rem, rgba(0, 82, 255, .055) 2rem 2.05rem); } +.trail-research-chart-empty { color: var(--foreground-subtle); } +.trail-research-chart-skeleton { border-top-color: rgba(var(--accent-rgb), .35); background: repeating-linear-gradient(165deg, transparent 0 1.9rem, rgba(var(--accent-rgb), .055) 2rem 2.05rem); } .trail-research-stat-strip { grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0; border-top-color: var(--border); background: var(--surface-muted); padding: .72rem 1.05rem .82rem; } -.trail-research-stat { min-height: 2.1rem; border-left: 1px solid #e7ebf1; padding-left: .78rem; } +.trail-research-stat { min-height: 2.1rem; border-left: 1px solid var(--border); padding-left: .78rem; } .trail-research-stat:first-child { border-left: 0; padding-left: 0; } -.trail-research-stat span { color: #8792a3; font-size: .58rem; letter-spacing: .08em; } +.trail-research-stat span { color: var(--foreground-subtle); font-size: .58rem; letter-spacing: .08em; } .trail-research-stat strong { margin-top: .3rem; font-family: var(--font-numeric-stack); font-size: .7rem; font-weight: 650; } .trail-research-evidence-panel { background: var(--surface); } .trail-research-section-heading { padding: .95rem 1.05rem .78rem; } -.trail-research-section-kicker { color: #7f8b9d; font-size: .6rem; letter-spacing: .12em; } +.trail-research-section-kicker { color: var(--foreground-muted); font-size: .6rem; letter-spacing: .12em; } .trail-research-section-heading h2 { font-family: var(--font-display-stack); font-size: .98rem; letter-spacing: -.02em; } -.trail-research-section-heading h2 span { border: 1px solid #dce3ed; border-radius: 999px; background: #f5f7fa; padding: .1rem .4rem; } +.trail-research-section-heading h2 span { border: 1px solid var(--border); border-radius: 999px; background: var(--surface-muted); padding: .1rem .4rem; } .trail-research-evidence-table { border-top-color: var(--border); } -.trail-research-evidence-head { min-height: 2.15rem; background: var(--surface-muted); color: var(--foreground-subtle); font-size: .57rem; letter-spacing: .1em; } +.trail-research-evidence-head { min-height: 2.65rem; background: var(--surface-muted); color: var(--foreground-subtle); font-size: .57rem; letter-spacing: .1em; } .trail-research-evidence-table-row { min-height: 3.2rem; border-top-color: var(--border); font-size: .67rem; transition: background-color .15s ease; } .trail-research-evidence-table-row:hover { background: var(--surface-hover); } .trail-research-source-mark { height: 1.4rem; width: 1.4rem; border-radius: var(--radius); background: color-mix(in srgb, var(--accent) 9%, var(--surface)); color: var(--accent); } @@ -469,22 +367,22 @@ button:disabled { cursor: not-allowed; } .trail-research-decision-rail { border-left-color: var(--border); background: var(--surface-muted); padding: 1.15rem 1.05rem; } .trail-research-rail-heading { align-items: flex-start; } .trail-research-rail-heading > div { min-width: 0; } -.trail-research-rail-kicker { display: block; margin-bottom: .28rem; color: #7f8b9d; font-size: .58rem; font-weight: 750; letter-spacing: .12em; text-transform: uppercase; } +.trail-research-rail-kicker { display: block; margin-bottom: .28rem; color: var(--foreground-muted); font-size: .58rem; font-weight: 750; letter-spacing: .12em; text-transform: uppercase; } .trail-research-rail-heading h2 { font-family: var(--font-display-stack); font-size: 1.02rem; letter-spacing: -.025em; } .trail-research-decision-card { margin-top: .95rem; border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); border-radius: var(--radius); background: color-mix(in srgb, var(--accent) 6%, var(--surface)); padding: .85rem; box-shadow: 0 1px 2px rgba(var(--foreground-rgb), .03); } .trail-research-decision--negative .trail-research-decision-card { border-color: color-mix(in srgb, var(--negative) 30%, var(--border)); background: color-mix(in srgb, var(--negative) 5%, var(--surface)); } .trail-research-decision--neutral .trail-research-decision-card, .trail-research-decision--empty .trail-research-decision-card { border-color: var(--border); background: var(--surface-muted); } .trail-research-decision-card strong { font-family: var(--font-display-stack); font-size: .86rem; letter-spacing: -.01em; } -.trail-research-decision-card p { color: #617087; font-size: .66rem; } +.trail-research-decision-card p { color: var(--foreground-muted); font-size: .66rem; } .trail-research-confidence-row { padding: 1rem 0; } .trail-research-confidence-ring { height: 3.55rem; width: 3.55rem; background: conic-gradient(var(--accent) var(--confidence), var(--border) 0); } .trail-research-confidence-ring::before { height: 2.8rem; width: 2.8rem; background: var(--surface-muted); } -.trail-research-decision-row { min-height: 3rem; border-bottom-color: #e7ebf1; } -.trail-research-decision-row span { color: #7a8799; font-size: .67rem; } +.trail-research-decision-row { min-height: 3rem; border-bottom-color: var(--border); } +.trail-research-decision-row span { color: var(--foreground-muted); font-size: .67rem; } .trail-research-decision-row strong { font-family: var(--font-numeric-stack); font-size: .7rem; } .trail-research-decision-row--multiline strong { font-family: var(--font-sans-stack); font-size: .67rem; } .trail-research-next-action-block { padding-top: 1rem; } -.trail-research-next-action-label { color: #7f8b9d; font-size: .61rem; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; } +.trail-research-next-action-label { color: var(--foreground-muted); font-size: .61rem; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; } .trail-research-next-action-button { border-radius: var(--radius); background: var(--primary); padding: .72rem .78rem; box-shadow: 0 2px 6px rgba(var(--foreground-rgb), .1); } .trail-research-next-action-button:hover { background: color-mix(in srgb, var(--primary) 88%, black); } .trail-research-source-count { border-top-color: var(--border); margin-top: 1rem; padding-top: .82rem; } @@ -508,46 +406,46 @@ button:disabled { cursor: not-allowed; } } /* Shared Coinbase-like dashboard language for non-Research surfaces. */ -.trail-finance-workspace { background: #f6f8fb; } -.trail-today-view, .trail-discover-view, .trail-portfolio-view, .trail-compare-view { color: #101114; background: #f6f8fb; scrollbar-gutter: stable; } -.trail-today-hero { position: relative; overflow: hidden; border-color: #dfe5ed !important; border-radius: 16px !important; background: #fff !important; box-shadow: 0 8px 24px rgba(16, 24, 40, .05), 0 1px 2px rgba(16, 24, 40, .04); } -.trail-today-hero::before { position: absolute; inset: 0 0 auto; height: 3px; background: #0052ff; content: ''; } +.trail-finance-workspace { background: var(--background); } +.trail-today-view, .trail-discover-view, .trail-portfolio-view, .trail-compare-view { color: var(--foreground); background: var(--background); scrollbar-gutter: stable; } +.trail-today-hero { position: relative; overflow: hidden; border-color: var(--border) !important; border-radius: 16px !important; background: var(--surface) !important; box-shadow: 0 8px 24px rgba(16, 24, 40, .05), 0 1px 2px rgba(16, 24, 40, .04); } +.trail-today-hero::before { position: absolute; inset: 0 0 auto; height: 3px; background: var(--accent); content: ''; } .trail-today-hero h1 { font-family: var(--font-display-stack); font-size: 1.75rem !important; letter-spacing: -.045em !important; } -.trail-today-hero input { border-color: #dce3ed !important; border-radius: 999px !important; background: #f6f8fb !important; font-size: .75rem !important; } -.trail-today-hero input:focus { border-color: #7ca5ff !important; background: #fff !important; box-shadow: 0 0 0 3px rgba(0, 82, 255, .10); } -.trail-quick-action { min-height: 4rem; border-color: #e0e6ee !important; border-radius: 11px !important; background: #fbfcfe !important; padding: .78rem .85rem !important; transition: border-color .16s ease, background-color .16s ease, transform .16s ease, box-shadow .16s ease; } -.trail-quick-action:hover { border-color: #b9cdfd !important; background: #f7faff !important; box-shadow: 0 4px 12px rgba(0, 82, 255, .06); transform: translateY(-1px); } +.trail-today-hero input { border-color: var(--border) !important; border-radius: 999px !important; background: var(--background) !important; font-size: .75rem !important; } +.trail-today-hero input:focus { border-color: var(--accent) !important; background: var(--surface) !important; box-shadow: 0 0 0 3px rgba(var(--accent-rgb), .10); } +.trail-quick-action { min-height: 4rem; border-color: var(--border) !important; border-radius: 11px !important; background: var(--surface) !important; padding: .78rem .85rem !important; transition: border-color .16s ease, background-color .16s ease, transform .16s ease, box-shadow .16s ease; } +.trail-quick-action:hover { border-color: var(--border-strong) !important; background: var(--surface-hover) !important; box-shadow: 0 4px 12px rgba(var(--accent-rgb), .06); transform: translateY(-1px); } .trail-quick-action:active { transform: scale(.985); } -.trail-today-section { border: 1px solid #dfe5ed !important; border-top-width: 1px !important; border-radius: 13px; background: #fff; padding: 1rem !important; box-shadow: 0 1px 2px rgba(16, 24, 40, .025); } +.trail-today-section { border: 1px solid var(--border) !important; border-top-width: 1px !important; border-radius: 13px; background: var(--surface); padding: 1rem !important; box-shadow: 0 1px 2px rgba(16, 24, 40, .025); } .trail-today-section header { margin-bottom: .8rem !important; } -.trail-today-section header h3 { color: #7b8798 !important; font-size: .59rem !important; letter-spacing: .12em !important; } -.trail-today-section li { border-top: 1px solid #eef1f5; padding: .62rem 0 !important; } +.trail-today-section header h3 { color: var(--foreground-muted) !important; font-size: .95rem !important; letter-spacing: 0 !important; } +.trail-today-section li { border-top: 1px solid var(--border); padding: .62rem 0 !important; } .trail-today-section li:first-child { border-top: 0; padding-top: .1rem !important; } .trail-today-section .text-foreground { font-family: var(--font-sans-stack); } /* Daily Brief — a compact attention feed, not a stack of repeated cards. */ .trail-daily-brief-section { border-radius: 16px !important; padding: 1rem 1.05rem 1.05rem !important; box-shadow: 0 4px 14px rgba(16, 24, 40, .035) !important; } .trail-daily-brief-section > header { align-items: center; margin-bottom: .75rem !important; } -.trail-daily-brief-section > header h3 { color: #526174 !important; font-size: .62rem !important; letter-spacing: .13em !important; } +.trail-daily-brief-section > header h3 { color: var(--foreground-muted) !important; font-size: .95rem !important; letter-spacing: 0 !important; } .trail-daily-brief-section > header button { min-height: 1.95rem; border-radius: 8px; font-size: .7rem; } -.trail-daily-brief-overview { display: flex; align-items: center; justify-content: space-between; gap: 1rem; border: 1px solid #e2e8f0; border-radius: 11px; background: #f7f9fc; padding: .65rem .75rem; } -.trail-daily-brief-count { display: inline-flex; height: 2.35rem; width: 2.35rem; align-items: center; justify-content: center; border-radius: 9px; background: #eaf1ff; color: #0052ff; font-family: var(--font-numeric-stack); font-size: 1.05rem; font-weight: 750; } -.trail-daily-brief-overview-label { color: #334155; font-size: .72rem; font-weight: 750; } -.trail-daily-brief-overview-copy { margin-top: .12rem; color: #8994a5; font-size: .63rem; } -.trail-daily-brief-updated { display: inline-flex; align-items: center; gap: .35rem; color: #7b8798; font-size: .62rem; white-space: nowrap; } -.trail-daily-brief-list { overflow: hidden; border-top: 1px solid #e7ebf1; } -.trail-daily-brief-row { position: relative; display: grid; grid-template-columns: .2rem minmax(0, 1fr) auto; align-items: center; gap: .7rem; min-height: 3.65rem; border-top: 0 !important; border-bottom: 1px solid #edf0f4; padding: .68rem .15rem !important; transition: background-color .15s ease; } -.trail-daily-brief-row:hover { background: #f8fbff; } +.trail-daily-brief-overview { display: flex; align-items: center; justify-content: space-between; gap: 1rem; border: 1px solid var(--border); border-radius: 11px; background: var(--surface-muted); padding: .65rem .75rem; } +.trail-daily-brief-count { display: inline-flex; height: 2.35rem; width: 2.35rem; align-items: center; justify-content: center; border-radius: 9px; background: var(--surface-muted); color: var(--accent); font-family: var(--font-numeric-stack); font-size: 1.05rem; font-weight: 750; } +.trail-daily-brief-overview-label { color: var(--foreground); font-size: .72rem; font-weight: 750; } +.trail-daily-brief-overview-copy { margin-top: .12rem; color: var(--foreground-subtle); font-size: .63rem; } +.trail-daily-brief-updated { display: inline-flex; align-items: center; gap: .35rem; color: var(--foreground-muted); font-size: .62rem; white-space: nowrap; } +.trail-daily-brief-list { overflow: hidden; border-top: 1px solid var(--border); } +.trail-daily-brief-row { position: relative; display: grid; grid-template-columns: .2rem minmax(0, 1fr) auto; align-items: center; gap: .7rem; min-height: 3.65rem; border-top: 0 !important; border-bottom: 1px solid var(--border); padding: .68rem .15rem !important; transition: background-color .15s ease; } +.trail-daily-brief-row:hover { background: var(--surface-hover); } .trail-daily-brief-row:first-child { padding-top: .68rem !important; } .trail-daily-brief-row:last-child { border-bottom: 0; } .trail-daily-brief-severity { align-self: stretch; min-height: 2.1rem; border-radius: 999px; } .trail-daily-brief-row .text-foreground { font-family: var(--font-sans-stack); } .trail-daily-brief-meta { display: flex; min-width: 5.2rem; flex-direction: column; align-items: flex-end; gap: .3rem; } -.trail-daily-brief-source { border: 1px solid #dfe5ed; border-radius: 999px; background: #fff; padding: .18rem .45rem; color: #68778c; font-size: .58rem; font-weight: 700; white-space: nowrap; } -.trail-daily-brief-details { color: #0052ff; font-size: .59rem; font-weight: 700; white-space: nowrap; } +.trail-daily-brief-source { border: 1px solid var(--border); border-radius: 999px; background: var(--surface); padding: .18rem .45rem; color: var(--foreground-muted); font-size: .58rem; font-weight: 700; white-space: nowrap; } +.trail-daily-brief-details { color: var(--accent); font-size: .59rem; font-weight: 700; white-space: nowrap; } .trail-daily-brief-details:hover { text-decoration: underline; text-underline-offset: 2px; } -.trail-daily-brief-quiet { display: flex; align-items: center; gap: .4rem; border-top: 1px solid #e7ebf1; padding: .7rem .15rem .05rem; color: #778398; font-size: .62rem; } -.trail-daily-brief-quiet-dot { height: .38rem; width: .38rem; border: 1px solid #98a2b3; border-radius: 50%; } +.trail-daily-brief-quiet { display: flex; align-items: center; gap: .4rem; border-top: 1px solid var(--border); padding: .7rem .15rem .05rem; color: var(--foreground-muted); font-size: .62rem; } +.trail-daily-brief-quiet-dot { height: .38rem; width: .38rem; border: 1px solid var(--foreground-subtle); border-radius: 50%; } @media (max-width: 760px) { .trail-daily-brief-overview { align-items: flex-start; flex-direction: column; gap: .55rem; } @@ -557,42 +455,42 @@ button:disabled { cursor: not-allowed; } } .trail-discover-view { padding: 1.1rem 1.2rem 2rem !important; } -.trail-discover-view .trail-pilot-page-header { border: 1px solid #dfe5ed; border-radius: 14px; background: #fff; padding: 1.05rem 1.15rem .95rem; box-shadow: 0 4px 14px rgba(16, 24, 40, .035); } +.trail-discover-view .trail-pilot-page-header { border: 1px solid var(--border); border-radius: 14px; background: var(--surface); padding: 1.05rem 1.15rem .95rem; box-shadow: 0 4px 14px rgba(16, 24, 40, .035); } .trail-discover-view .trail-pilot-title { font-family: var(--font-display-stack); font-size: 1.55rem; letter-spacing: -.045em; } .trail-discover-view .trail-pilot-eyebrow, .trail-discover-view .trail-pilot-table-header, .trail-discover-view h2 { letter-spacing: .12em; } -.trail-discover-view .trail-pilot-table { border-color: #dfe5ed; border-radius: 14px; box-shadow: 0 4px 16px rgba(16, 24, 40, .04); } -.trail-discover-view .trail-pilot-table-header { background: #f7f9fc; color: #8792a3; } -.trail-discover-view .trail-pilot-row { min-height: 5rem; border-bottom-color: #edf0f4; } -.trail-discover-view .trail-pilot-row:hover { background: #f8fbff; } -.trail-discover-view .trail-pilot-toolbar-button { border-color: #dce3ed; border-radius: 999px; background: #fff; } -.trail-discover-view .trail-pilot-toolbar-button:hover { border-color: #a9bce1; background: #f4f7fc; } -.trail-discover-view .trail-pilot-status { border-color: #dfe5ed; border-radius: 11px; background: #fff; } -.trail-discover-view .trail-pilot-status--warning { border-color: #eed9aa; background: #fffaf0; } -.trail-discover-view .trail-pilot-status--partial { border-color: #cfe0ff; background: #eef4ff; } -.trail-discover-task { border-color: #dfe5ed !important; border-radius: 12px !important; background: #fff !important; padding: .9rem !important; box-shadow: 0 1px 2px rgba(16, 24, 40, .025); transition: border-color .16s ease, box-shadow .16s ease, transform .16s ease; } -.trail-discover-task:hover { border-color: #b9cdfd !important; background: #fbfdff !important; box-shadow: 0 5px 14px rgba(0, 82, 255, .07); transform: translateY(-1px); } +.trail-discover-view .trail-pilot-table { border-color: var(--border); border-radius: 14px; box-shadow: 0 4px 16px rgba(16, 24, 40, .04); } +.trail-discover-view .trail-pilot-table-header { background: var(--surface-muted); color: var(--foreground-subtle); } +.trail-discover-view .trail-pilot-row { min-height: 5rem; border-bottom-color: var(--border); } +.trail-discover-view .trail-pilot-row:hover { background: var(--surface-hover); } +.trail-discover-view .trail-pilot-toolbar-button { border-color: var(--border); border-radius: 999px; background: var(--surface); } +.trail-discover-view .trail-pilot-toolbar-button:hover { border-color: var(--border-strong); background: var(--surface-hover); } +.trail-discover-view .trail-pilot-status { border-color: var(--border); border-radius: 11px; background: var(--surface); } +.trail-discover-view .trail-pilot-status--warning { border-color: color-mix(in srgb, var(--warning) 35%, var(--border)); background: color-mix(in srgb, var(--warning) 8%, var(--surface)); } +.trail-discover-view .trail-pilot-status--partial { border-color: var(--border); background: var(--surface-muted); } +.trail-discover-task { border-color: var(--border) !important; border-radius: 12px !important; background: var(--surface) !important; padding: .9rem !important; box-shadow: 0 1px 2px rgba(16, 24, 40, .025); transition: border-color .16s ease, box-shadow .16s ease, transform .16s ease; } +.trail-discover-task:hover { border-color: var(--border-strong) !important; background: var(--surface) !important; box-shadow: 0 5px 14px rgba(var(--accent-rgb), .07); transform: translateY(-1px); } .trail-discover-task button { border-radius: 999px; } .trail-discover-view .trail-pilot-symbol, .trail-discover-view .trail-pilot-score, .trail-discover-view .trail-pilot-financial, .trail-discover-view .trail-pilot-change { font-family: var(--font-numeric-stack); } .trail-portfolio-view { padding: 1.1rem 1.2rem 2rem !important; } .trail-portfolio-view > div:first-child { gap: 1rem; } .trail-portfolio-view > div:first-child > div:first-child { font-family: var(--font-display-stack); font-size: 1.1rem; letter-spacing: -.025em; } -.trail-portfolio-view .mac-stock-tile { border-color: #dfe5ed; border-radius: 13px; background: #fff; box-shadow: 0 2px 8px rgba(16, 24, 40, .035); } -.trail-portfolio-view h3 { color: #7b8798 !important; font-size: .59rem !important; letter-spacing: .12em !important; } +.trail-portfolio-view .mac-stock-tile { border-color: var(--border); border-radius: 13px; background: var(--surface); box-shadow: 0 2px 8px rgba(16, 24, 40, .035); } +.trail-portfolio-view h3 { color: var(--foreground-muted) !important; font-size: .95rem !important; letter-spacing: 0 !important; } .trail-portfolio-view select, .trail-portfolio-view button { border-radius: 999px; } -.trail-portfolio-view .border-t { border-color: #e2e7ef; } +.trail-portfolio-view .border-t { border-color: var(--border); } .trail-compare-view { padding: 1.1rem 1.2rem 2rem !important; } -.trail-compare-view > div:first-child { margin-bottom: 1rem; border-bottom: 1px solid #dfe5ed; padding-bottom: .8rem; } -.trail-compare-view > div:first-child h3 { font-family: var(--font-display-stack); color: #101114 !important; font-size: 1.1rem !important; letter-spacing: -.025em; text-transform: none; } -.trail-compare-view input { min-height: 2.35rem; border-color: #dce3ed !important; border-radius: 999px !important; background: #fff !important; } -.trail-compare-view input:focus { border-color: #7ca5ff !important; box-shadow: 0 0 0 3px rgba(0, 82, 255, .10); } +.trail-compare-view > div:first-child { margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: .8rem; } +.trail-compare-view > div:first-child h3 { font-family: var(--font-display-stack); color: var(--foreground) !important; font-size: 1.1rem !important; letter-spacing: -.025em; text-transform: none; } +.trail-compare-view input { min-height: 2.35rem; border-color: var(--border) !important; border-radius: 999px !important; background: var(--surface) !important; } +.trail-compare-view input:focus { border-color: var(--accent) !important; box-shadow: 0 0 0 3px rgba(var(--accent-rgb), .10); } .trail-compare-view > div:first-child + div > button { min-height: 2.35rem; border-radius: 999px; } -.trail-compare-view table { border-collapse: separate; border-spacing: 0; background: #fff; font-variant-numeric: tabular-nums; } -.trail-compare-view table thead { background: #f7f9fc; } -.trail-compare-view table th { color: #8792a3; font-size: .58rem; letter-spacing: .1em; } -.trail-compare-view table td, .trail-compare-view table th { border-bottom-color: #edf0f4 !important; padding: .72rem .85rem !important; } -.trail-compare-view table tbody tr:hover { background: #f8fbff; } +.trail-compare-view table { border-collapse: separate; border-spacing: 0; background: var(--surface); font-variant-numeric: tabular-nums; } +.trail-compare-view table thead { background: var(--surface-muted); } +.trail-compare-view table th { color: var(--foreground-muted); font-size: .78rem; letter-spacing: 0; } +.trail-compare-view table td, .trail-compare-view table th { border-bottom-color: var(--border) !important; padding: .72rem .85rem !important; } +.trail-compare-view table tbody tr:hover { background: var(--surface-hover); } /* ------------------------------------------------------------------------- Motion foundation — decorative layers only. These layers are clipped by @@ -967,46 +865,75 @@ button:disabled { cursor: not-allowed; } } /* Stitch fixed-fluid-fixed shell geometry. */ -.trail-sidebar { position: relative; border-right: 1px solid var(--border); } -.trail-sidebar-content { background: var(--surface); } -.trail-sidebar-brand-name { color: var(--foreground); font-family: var(--font-display-stack); font-size: 1.85rem; font-weight: 700; letter-spacing: -.06em; line-height: 1; } -.trail-sidebar-brand-kicker { margin-top: .35rem; color: var(--foreground-subtle); font-family: var(--font-numeric-stack); font-size: .49rem; font-weight: 500; letter-spacing: .12em; line-height: 1.3; text-transform: uppercase; } -.trail-sidebar-new-analysis { background: var(--primary); color: var(--primary-foreground); transition: background-color var(--motion-base) ease; } +.trail-sidebar { position: relative; container-type: inline-size; border-right: 1px solid var(--border); } +.trail-sidebar-content { background: var(--surface); padding: 1.5rem .8rem 1rem; } +.trail-sidebar-brand { position: relative; display: flex; align-items: center; gap: 1rem; padding: 0 .6rem 1.35rem; margin-bottom: 1.3rem; } +.trail-sidebar-brand::after { position: absolute; bottom: 0; left: .8rem; width: 1.8rem; height: 2px; background: var(--brand-gold); content: ""; } +.trail-sidebar-brand .trail-logo { width: 4.2rem; height: 4.2rem; border-radius: 14px; } +.trail-sidebar-brand-chinese { margin-top: .25rem; font-size: 1.1rem; font-weight: 600; line-height: 1.3; } +.trail-sidebar-brand-name { color: var(--foreground); font-family: var(--font-display-stack); font-size: 1.55rem; font-weight: 650; letter-spacing: -.02em; line-height: 1; } +.trail-sidebar-brand-kicker { margin-top: .35rem; color: var(--foreground-subtle); font-family: var(--font-numeric-stack); font-size: .82rem; font-weight: 400; letter-spacing: 0; line-height: 1.3; text-transform: uppercase; } +.trail-sidebar-new-analysis { min-height: 2.7rem; font-size: .9rem; border-radius: var(--radius); background: var(--primary); color: var(--primary-foreground); transition: background-color var(--motion-base) ease; } .trail-sidebar-new-analysis:hover { background: color-mix(in srgb, var(--primary) 82%, var(--foreground-muted)); } .trail-sidebar-nav { min-height: 0; overflow-y: auto; } -.trail-sidebar-nav-item { position: relative; display: flex; min-height: 2.15rem; flex: 0 0 auto; align-items: center; gap: .7rem; border-radius: 4px 0 0 4px; padding: .42rem .7rem; color: var(--foreground-muted); font-size: .76rem; font-weight: 450; text-align: left; transition: background-color var(--motion-base) ease, color var(--motion-base) ease; } +.trail-sidebar-nav-item { position: relative; display: flex; min-height: 2.65rem; flex: 0 0 auto; align-items: center; gap: .7rem; border-radius: var(--radius); padding: .55rem 1rem; color: var(--foreground-muted); font-size: .93rem; font-weight: 400; text-align: left; transition: background-color var(--motion-base) ease, color var(--motion-base) ease; } +.trail-sidebar-nav-item:nth-child(7) { margin-top: 1.1rem; } +.trail-sidebar-nav-item:nth-child(7)::before { position: absolute; top: -.65rem; left: .6rem; right: .6rem; height: 1px; background: var(--border); content: ""; } +.trail-sidebar-nav-item > svg { width: 1.2rem; height: 1.2rem; } +.trail-sidebar-support { font-size: .88rem; } .trail-sidebar-nav-item:hover { background: var(--surface-muted); color: var(--foreground); } -.trail-sidebar-nav-item--active { border-right: 2px solid var(--primary); background: var(--surface-muted); color: var(--primary); font-weight: 650; } +.trail-sidebar-nav-item--active { box-shadow: inset 3px 0 0 var(--accent); background: var(--surface-muted); color: var(--accent); font-weight: 650; } .trail-sidebar-alert-dot { margin-left: auto; height: .4rem; width: .4rem; border-radius: 999px; background: var(--negative); } .trail-sidebar-section-label { color: var(--foreground-subtle); font-size: .57rem; font-weight: 700; letter-spacing: .11em; text-transform: uppercase; } .trail-sidebar-context-heading { color: var(--foreground-muted); font-size: .68rem; } .trail-sidebar-context-heading:hover { color: var(--foreground); } -.trail-sidebar-context { flex: 0 0 auto; max-height: 11rem; overflow: hidden; } -.trail-sidebar-context > div { max-height: 3.5rem; } +.trail-sidebar-context { display: flex; flex: 0 0 auto; flex-direction: column; height: clamp(13rem, 30vh, 20rem); overflow: hidden; } +.trail-sidebar-context > div:first-child { flex-shrink: 0; } +.trail-sidebar-sessions { max-height: 3.5rem; flex-shrink: 0; } +.trail-sidebar-watchlist .trail-watchlist-header { flex-shrink: 0; } +.trail-sidebar-context-heading { flex-shrink: 0; } .trail-sidebar-settings-panel { left: 12px; top: auto; bottom: 74px; } .trail-sidebar-support { min-height: 2rem; } -.trail-workspace-topbar { border-bottom-color: var(--border); } -.trail-workspace-topbar-title { color: var(--foreground); font-family: var(--font-display-stack); font-size: 1rem; font-weight: 600; letter-spacing: -.025em; } +.trail-workspace-topbar { height: 3.1rem; gap: .8rem; padding-inline: 1.4rem; border-bottom-color: var(--border); } +.trail-workspace-center { container-type: inline-size; } +@container (max-width: 600px) { .trail-workspace-topbar-tabs { display: none; } } +@container (max-width: 225px) { + .trail-sidebar-brand { gap: .65rem; } + .trail-sidebar-brand .trail-logo { width: 3.2rem; height: 3.2rem; border-radius: 11px; } + .trail-sidebar-brand-kicker { font-size: .73rem; white-space: nowrap; } +} +.trail-workspace-topbar-title { color: var(--foreground); font-family: var(--font-display-stack); font-size: 1.25rem; font-weight: 650; letter-spacing: 0; } .trail-workspace-topbar-tab { position: relative; height: 100%; color: var(--foreground-muted); font-size: .7rem; white-space: nowrap; } .trail-workspace-topbar-tab:hover { color: var(--foreground); } .trail-workspace-topbar-tab--active { color: var(--foreground); font-weight: 650; } .trail-workspace-topbar-tab--active::after { position: absolute; right: 0; bottom: 0; left: 0; height: 2px; background: var(--primary); content: ''; } -.trail-workspace-status { display: inline-flex; align-items: center; gap: .35rem; color: var(--foreground-muted); font-family: var(--font-numeric-stack); font-size: .56rem; letter-spacing: .04em; text-transform: uppercase; } +.trail-workspace-status { display: inline-flex; align-items: center; gap: .35rem; color: var(--foreground-muted); font-family: var(--font-numeric-stack); font-size: .73rem; letter-spacing: 0; text-transform: uppercase; } .trail-workspace-status span { height: .35rem; width: .35rem; border-radius: 999px; background: var(--accent); } +.trail-toolbar-icon { display: inline-flex; width: 1.85rem; height: 1.85rem; align-items: center; justify-content: center; border-radius: var(--radius); color: var(--foreground-muted); } +.trail-toolbar-icon:hover, .trail-toolbar-agent:hover { background: var(--surface-hover); color: var(--accent); } +.trail-toolbar-icon:disabled { cursor: not-allowed; opacity: .4; } +.trail-toolbar-agent { min-height: 1.85rem; border-radius: var(--radius); padding-inline: .4rem; } +.trail-theme-sun { display: none; } +.dark .trail-theme-moon { display: none; } +.dark .trail-theme-sun { display: block; } .trail-agent-header { background: var(--surface); } .trail-agent-title { color: var(--foreground); font-family: var(--font-display-stack); font-size: .82rem; font-weight: 600; white-space: nowrap; } -.trail-today-content { width: 100%; } -.trail-today-heading h1 { margin: 0; font-size: 1.5rem; font-weight: 500; letter-spacing: -.025em; line-height: 1.3; } -.trail-today-heading p { margin-top: .35rem; color: var(--foreground-muted); font-size: .8rem; line-height: 1.5; } +.trail-today-content { width: 100%; container-type: inline-size; } +.trail-today-bento { grid-template-columns: minmax(0, 1.65fr) minmax(0, 1fr); gap: .8rem; } +.trail-today-bento > section:first-child { grid-column: auto; } +.trail-today-bento > section:last-child { grid-column: 1 / -1; } +@container (max-width: 610px) { .trail-today-bento { grid-template-columns: minmax(0, 1fr); } } +.trail-today-heading h1 { margin: 0; font-size: 1.85rem; font-weight: 650; letter-spacing: -.025em; line-height: 1.3; } +.trail-today-heading p { margin-top: .35rem; color: var(--foreground-muted); font-size: .92rem; line-height: 1.5; } .trail-stitch-card { min-width: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); padding: 1.25rem; box-shadow: 0 4px 24px rgba(var(--foreground-rgb), .03); } .trail-stitch-card-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: 1rem; } -.trail-stitch-card-heading h2 { color: var(--foreground-muted); font-size: .61rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } +.trail-stitch-card-heading h2 { color: var(--foreground); font-size: 1.05rem; font-weight: 650; letter-spacing: 0; } .trail-card-menu { color: var(--foreground-subtle); font-size: .7rem; letter-spacing: .1em; } .trail-stitch-card .trail-portfolio-summary-card { border: 0; padding: 0; box-shadow: none; } .trail-stitch-card .trail-portfolio-summary-card > div:first-child > div:first-child > div:last-child { font-size: 2rem; } -.trail-today-mover-columns h3 { margin-bottom: .5rem; color: var(--foreground-subtle); font-size: .57rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } -.trail-today-mini-row { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .22rem 0; color: var(--foreground); font-size: .72rem; } -.trail-today-mini-row strong { font-family: var(--font-numeric-stack); font-size: .67rem; font-weight: 500; } +.trail-today-mover-columns h3 { margin-bottom: .5rem; color: var(--foreground); font-size: .85rem; font-weight: 600; } +.trail-today-mini-row { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .22rem 0; color: var(--foreground); font-size: .85rem; } +.trail-today-mini-row strong { font-family: var(--font-numeric-stack); font-size: .85rem; font-weight: 500; } .trail-today-muted { color: var(--foreground-subtle); font-family: var(--font-numeric-stack); font-size: .75rem; } .trail-stitch-secondary-button { min-height: 2rem; border: 1px solid var(--border); border-radius: 4px; color: var(--foreground); font-size: .7rem; font-weight: 500; } .trail-stitch-secondary-button:hover { background: var(--surface-muted); } @@ -1112,11 +1039,10 @@ button:disabled { cursor: not-allowed; } .trail-pilot-shell th { background: var(--surface-muted); color: var(--foreground-subtle); - font-family: var(--font-numeric-stack); - font-size: .62rem; + font-family: var(--font-sans-stack); + font-size: .75rem; font-weight: 600; - letter-spacing: .08em; - text-transform: uppercase; + letter-spacing: 0; } .trail-finance-workspace td, @@ -1176,8 +1102,8 @@ button:disabled { cursor: not-allowed; } } .trail-banner { - border-color: color-mix(in srgb, var(--info) 26%, transparent); - background: color-mix(in srgb, var(--info) 10%, var(--surface)); + border-color: color-mix(in srgb, var(--warning) 20%, var(--border)); + background: color-mix(in srgb, var(--warning) 6%, var(--surface)); color: var(--foreground); font-size: .7rem; line-height: 1.35; @@ -1500,90 +1426,22 @@ button:disabled { cursor: not-allowed; } color: var(--accent) !important; } -.trail-agent-panel { - background: var(--surface-muted) !important; -} - -.trail-agent-panel .trail-agent-header { - flex-wrap: nowrap; - min-height: 3.5rem; - padding: .65rem .85rem; - background: var(--surface) !important; -} - -.trail-agent-panel .trail-agent-title { - flex: 0 0 auto; - font-size: .78rem; - letter-spacing: -.01em; -} - -.trail-agent-panel .trail-agent-header > button:nth-child(2), -.trail-agent-panel .trail-agent-header > button:nth-child(3) { - min-width: 0; - flex: 1 1 auto; - overflow: hidden; - padding-inline: .55rem; - font-size: .68rem; -} - -.trail-agent-panel .trail-agent-header > button:nth-child(2) { max-width: 9.5rem; } -.trail-agent-panel .trail-agent-header > button:nth-child(3) { max-width: 8rem; } -.trail-agent-panel .trail-agent-header > div.flex-1 { display: none; } - -.trail-agent-panel .trail-agent-header > button:last-child { - flex: 0 0 1.85rem; -} - -.trail-agent-context { - background: var(--surface) !important; - padding: .65rem .85rem; -} - -.trail-agent-body { - background: var(--surface-muted); -} - -.trail-agent-body > div { - padding: .85rem; -} -.trail-agent-suggestions { - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface); - padding: .75rem !important; -} - -.trail-agent-suggestions button { - border-color: var(--border) !important; - background: var(--surface-muted) !important; - color: var(--foreground-muted) !important; - padding: .4rem .6rem !important; -} - -.trail-agent-suggestions button:hover { - background: var(--surface-hover) !important; - color: var(--foreground) !important; -} - -.trail-agent-composer { - background: var(--surface) !important; - padding: .75rem !important; -} - -/* Composer textarea: 12px radius matching the panel's card language; the - inline circular send button overlays the bottom-right corner. */ -.trail-agent-composer .trail-agent-textarea { - min-height: 3.5rem; - border-color: var(--input) !important; - border-radius: 12px !important; - background: var(--surface) !important; - box-shadow: inset 0 1px 2px rgba(var(--foreground-rgb), .025); -} - -.trail-agent-composer .trail-agent-send { - border-radius: 9999px !important; - background: var(--primary) !important; - color: var(--primary-foreground) !important; - box-shadow: 0 1px 3px rgba(var(--foreground-rgb), .18); -} +/* The approved overview uses readable section labels and quiet green action icons. */ +.trail-today-heading { margin-bottom: 1rem; } +.trail-today-bento li { padding-block: .8rem; } +.trail-today-bento li .text-foreground { font-size: .9rem; } +.trail-today-bento li > span { font-size: .75rem; } +.trail-today-bento .trail-portfolio-summary-card { border-radius: 0; } +.trail-stitch-card-heading { margin-bottom: .85rem; } +.trail-quick-action > span:first-child { width: 2.8rem; height: 2.8rem; background: var(--surface-muted); color: var(--accent); } +.trail-quick-action > span:first-child svg { width: 1.35rem; height: 1.35rem; } +.trail-quick-action > span:nth-child(2) > span:first-child { font-size: .85rem; } +.trail-quick-action > span:nth-child(2) > span:last-child { font-size: .75rem; } +.trail-today-section { container-type: inline-size; } +[data-testid='pulse-indices'] { grid-template-columns: minmax(0, 1fr); } +[data-testid='pulse-index'] { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: .5rem; border-bottom: 1px solid var(--border); } +[data-testid='pulse-index'] > span:nth-child(2) { grid-column: 1; grid-row: 2; font-size: .68rem; } +[data-testid='pulse-index'] > span:nth-child(3) { grid-column: 2; grid-row: 1 / 3; align-self: center; } +[data-testid='pulse-index'] > span:nth-child(4) { grid-column: 3; grid-row: 1 / 3; align-self: center; } +@container (max-width: 340px) { [data-testid='pulse-movers'] { grid-template-columns: minmax(0, 1fr); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3a74860..1a9cc29 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -484,6 +484,8 @@ export interface CustomProviderModel { contextWindow?: number; maxTokens?: number; reasoning?: boolean; + /** Compatibility flags for OpenAI-compatible gateways. */ + compat?: { supportsDeveloperRole?: boolean; supportsReasoningEffort?: boolean; maxTokensField?: 'max_tokens' | 'max_completion_tokens' }; } /** diff --git a/packages/i18n/src/locales/en-US/agent.ts b/packages/i18n/src/locales/en-US/agent.ts index 12bbbf6..d9b8ca0 100644 --- a/packages/i18n/src/locales/en-US/agent.ts +++ b/packages/i18n/src/locales/en-US/agent.ts @@ -3,7 +3,9 @@ import type { NamespaceResource } from '../keys.ts'; /** Agent panel + chat chrome (spec §113). Lifetime values are runtime data, not chrome. */ export const agent = { panel: { - title: 'Agent Copilot', + title: 'Trail Assistant', + assistantName: 'Trail', + inputHint: 'Enter to send · Shift + Enter for a new line', collapsePanel: 'Collapse agent panel', inputPlaceholder: 'Ask the copilot…', inputRunningPlaceholder: 'Agent is running…', @@ -26,6 +28,8 @@ export const agent = { detailsLabel: 'Advanced details', }, empty: { + heading: 'Research starts with a question', + subtitle: 'Let Trail help you connect information and insight.', body: 'Start a new session to explore markets with your agent.', createSession: 'Create New Session', }, @@ -34,6 +38,7 @@ export const agent = { clear: 'Clear security context', }, model: { + unavailable: 'Model controls unavailable', label: 'Model', loading: 'Loading models…', select: 'Select model', diff --git a/packages/i18n/src/locales/en-US/connections.ts b/packages/i18n/src/locales/en-US/connections.ts index 4eccbc0..2e158f5 100644 --- a/packages/i18n/src/locales/en-US/connections.ts +++ b/packages/i18n/src/locales/en-US/connections.ts @@ -24,8 +24,13 @@ export const connections = { kindFinancialData: 'Financial Data', kindBrokerAccount: 'Broker Account', waitingAuthorization: 'Waiting for authorization…', + preparingAuthorization: 'Getting the authorization link…', + preparingAuthorizationHint: 'Waiting for the Longbridge CLI to provide a link. The browser has not opened yet. Cancel and retry if this stalls.', openVerificationPage: 'Open verification page', - authorizeHint: 'Authorize in your browser, then return here — this page will update automatically.', + authorizeHint: 'If the browser did not open, use the link above. This page updates automatically after authorization.', + cliNotFound: 'Longbridge CLI was not found in the launch environment. Install it or fix PATH, then fully restart Trail.', + loginStartFailed: 'Could not obtain a Longbridge authorization link. Check the CLI and network, then retry.', + authorizationTimedOut: 'Longbridge authorization timed out. Please reconnect.', enterApiKey: 'Enter your API key', apiKey: 'API key', saving: 'Saving…', diff --git a/packages/i18n/src/locales/en-US/demo.ts b/packages/i18n/src/locales/en-US/demo.ts index a393d16..c63dc81 100644 --- a/packages/i18n/src/locales/en-US/demo.ts +++ b/packages/i18n/src/locales/en-US/demo.ts @@ -1,7 +1,14 @@ /** Built-in sample ("demo") content labels — shown with a `DemoBadge` wherever sample data renders. */ export const demo = { badge: 'Sample data', - hint: 'Built-in sample content. Connect a market-data provider and an AI model in Settings to see your real data.', + hint: 'Built-in sample content for previewing the workspace. These are not your actual holdings or live market values.', + about: 'About sample data', + realDataHint: 'Quotes and portfolio cards switch to real data when their data source returns successfully. Refresh the page data after fixing the connection or permissions.', + quotesUnavailable: 'The live quote request failed, so this section uses sample prices.', + portfolioUnavailable: 'The account data request failed, so this section uses a sample portfolio.', + portfolioPermission: 'The current authorization does not include account-data access. Portfolio access must be authorized before real holdings can be shown.', + portfolioEmpty: 'The account returned no assets or holdings. The amounts shown here belong to a sample portfolio.', + eventsUnavailable: 'This page does not currently have a real calendar feed. These events are layout examples; logging in will not turn them into live events.', brief: { summary: '2 things need your attention today', quiet: '2 monitored securities stayed below the materiality bar.', diff --git a/packages/i18n/src/locales/en-US/navigation.ts b/packages/i18n/src/locales/en-US/navigation.ts index ad09269..7c0a625 100644 --- a/packages/i18n/src/locales/en-US/navigation.ts +++ b/packages/i18n/src/locales/en-US/navigation.ts @@ -1,5 +1,15 @@ /** Global navigation rail + shell chrome (spec §24). */ export const navigation = { + toggleTheme: 'Toggle light or dark theme', + refreshData: 'Refresh current data', + refreshComplete: 'Data refreshed', + refreshFailed: 'Refresh failed; check the current page', + refreshUnavailable: 'No market data to refresh on this page', + + brandTagline: 'Leave a trail of insight', + workbenchTitle: 'Trail Workbench', + marketSetupNeeded: 'Market data is not connected', + marketPaused: 'Market data is paused', today: 'Today', discover: 'Discover', workspace: 'Workspace', diff --git a/packages/i18n/src/locales/en-US/research.ts b/packages/i18n/src/locales/en-US/research.ts index 92fd9f1..5a37632 100644 --- a/packages/i18n/src/locales/en-US/research.ts +++ b/packages/i18n/src/locales/en-US/research.ts @@ -10,6 +10,10 @@ export const research = { deepResearch: 'Deep Research', workspace: { title: 'Research', + currencyLabel: 'Currency', + currencyHint: 'Quote currency for the selected security. Informational; currency conversion is not available.', + providerQuote: 'Provider quote', + quoteTimeUnknown: 'Price received; the provider did not supply a quote timestamp.', searchPlaceholder: 'Search securities, topics, or notes', addToWatchlist: 'Add to watchlist', inWatchlist: 'In watchlist', diff --git a/packages/i18n/src/locales/zh-CN/agent.ts b/packages/i18n/src/locales/zh-CN/agent.ts index 619f53d..c4658a1 100644 --- a/packages/i18n/src/locales/zh-CN/agent.ts +++ b/packages/i18n/src/locales/zh-CN/agent.ts @@ -4,13 +4,15 @@ import type { agent as enAgent } from '../en-US/agent.ts'; /** Agent panel + chat chrome — Simplified Chinese. */ export const agent = { panel: { - title: 'Agent Copilot', + title: '研迹助手', + assistantName: '研迹', + inputHint: 'Enter 发送 · Shift + Enter 换行', collapsePanel: '收起面板', - inputPlaceholder: '向 Copilot 提问…', - inputRunningPlaceholder: 'Agent 运行中…', + inputPlaceholder: '向研迹提问……', + inputRunningPlaceholder: '研迹正在研究…', sendMessage: '发送消息', stop: '停止', - agentRunning: 'Agent 运行中', + agentRunning: '研迹正在研究', thinking: '思考中…', }, runtime: { @@ -26,6 +28,8 @@ export const agent = { detailsLabel: '高级详情', }, empty: { + heading: '研究,从一个问题开始', + subtitle: '让研迹帮你梳理信息与判断。', body: '开始新会话,让 Agent 为你探索市场。', createSession: '创建新会话', }, @@ -34,14 +38,15 @@ export const agent = { clear: '清除证券上下文', }, model: { + unavailable: '模型控制暂不可用', label: '模型', loading: '加载模型中…', select: '选择模型', none: '暂无可用模型', }, reasoning: { - label: '推理', - withLevel: '推理:{{level}}', + label: '思考级别', + withLevel: '思考:{{level}}', unavailable: '当前运行时不可用思考等级', }, tool: { diff --git a/packages/i18n/src/locales/zh-CN/connections.ts b/packages/i18n/src/locales/zh-CN/connections.ts index 8af3c18..3486a9a 100644 --- a/packages/i18n/src/locales/zh-CN/connections.ts +++ b/packages/i18n/src/locales/zh-CN/connections.ts @@ -25,8 +25,13 @@ export const connections = { kindFinancialData: '金融数据', kindBrokerAccount: '券商账户', waitingAuthorization: '等待授权…', + preparingAuthorization: '正在获取授权链接…', + preparingAuthorizationHint: '正在等待长桥 CLI 返回链接,此时尚未打开浏览器。如果长时间没有响应,请取消后重试。', openVerificationPage: '打开验证页面', - authorizeHint: '在浏览器中授权后返回此处——本页会自动更新。', + authorizeHint: '若浏览器未自动打开,请点击上方链接。授权后本页会自动更新。', + cliNotFound: '启动环境中找不到长桥 CLI。请安装或修复 PATH 后,完全退出并重新启动 Trail。', + loginStartFailed: '未能获取长桥授权链接。请检查 CLI 和网络后重试。', + authorizationTimedOut: '长桥授权已超时,请重新连接。', enterApiKey: '输入你的 API 密钥', apiKey: 'API 密钥', saving: '正在保存…', diff --git a/packages/i18n/src/locales/zh-CN/demo.ts b/packages/i18n/src/locales/zh-CN/demo.ts index 706319c..cc78393 100644 --- a/packages/i18n/src/locales/zh-CN/demo.ts +++ b/packages/i18n/src/locales/zh-CN/demo.ts @@ -4,7 +4,14 @@ import type { demo as enDemo } from '../en-US/demo.ts'; /** 内置示例数据文案 — 凡展示示例数据处均配有「示例数据」徽标。 */ export const demo = { badge: '示例数据', - hint: '内置示例内容。在「设置」中接入行情数据源与 AI 模型后,即可查看你的真实数据。', + hint: '这是用于预览工作台的内置样例,不代表你的实际持仓或实时行情。', + about: '了解示例数据', + realDataHint: '行情和投资组合会在数据源成功返回后自动切换为真实结果。修复连接或权限后,请刷新页面数据。', + quotesUnavailable: '真实行情请求未成功,因此此区域暂时展示示例价格。', + portfolioUnavailable: '账户数据请求未成功,因此此区域暂时展示示例投资组合。', + portfolioPermission: '当前授权未包含账户数据读取权限。需要补充投资组合访问授权,才能显示真实持仓。', + portfolioEmpty: '账户没有返回资产或持仓,当前金额属于示例投资组合。', + eventsUnavailable: '此页面目前尚未接入真实日历数据源。这些事件仅用于展示排版,登录后也不会自动变成真实事件。', brief: { summary: '今天有 2 项内容需要你关注', quiet: '2 只监控标的未超过关注阈值。', diff --git a/packages/i18n/src/locales/zh-CN/navigation.ts b/packages/i18n/src/locales/zh-CN/navigation.ts index b895c24..18ec8fe 100644 --- a/packages/i18n/src/locales/zh-CN/navigation.ts +++ b/packages/i18n/src/locales/zh-CN/navigation.ts @@ -3,6 +3,16 @@ import type { navigation as enNavigation } from '../en-US/navigation.ts'; /** Global navigation rail + shell chrome — Simplified Chinese (glossary-aligned §61). */ export const navigation = { + toggleTheme: '切换深浅主题', + refreshData: '刷新当前数据', + refreshComplete: '数据已刷新', + refreshFailed: '刷新失败,请查看当前页面提示', + refreshUnavailable: '当前页面无需刷新行情', + + brandTagline: '研究留下痕迹', + workbenchTitle: '研迹工作台', + marketSetupNeeded: '行情尚未连接', + marketPaused: '行情访问暂缓', today: '今日', discover: '机会发现', workspace: '工作台', @@ -18,7 +28,7 @@ export const navigation = { preferences: '偏好设置', settings: '设置', configure: '在不离开金融工作台的情况下配置研迹。', - agentPanel: 'Agent', + agentPanel: '研迹助手', // ── Sidebar rail / shell chrome (spec §24) ────────────────────────────── globalNavAria: '全局导航', homeAria: '研迹首页', diff --git a/packages/i18n/src/locales/zh-CN/research.ts b/packages/i18n/src/locales/zh-CN/research.ts index ce0ee78..7361980 100644 --- a/packages/i18n/src/locales/zh-CN/research.ts +++ b/packages/i18n/src/locales/zh-CN/research.ts @@ -6,6 +6,10 @@ export const research = { deepResearch: '深度研究', workspace: { title: '研究', + currencyLabel: '币种', + currencyHint: '当前证券的报价币种,仅作信息展示;现有功能不提供币种换算。', + providerQuote: '数据源报价', + quoteTimeUnknown: '已获取价格,数据源未提供报价时间。', searchPlaceholder: '搜索标的、主题或笔记', addToWatchlist: '加入自选', inWatchlist: '已在自选', diff --git a/packages/i18n/src/locales/zh-CN/today.ts b/packages/i18n/src/locales/zh-CN/today.ts index 368ddfd..65e8aeb 100644 --- a/packages/i18n/src/locales/zh-CN/today.ts +++ b/packages/i18n/src/locales/zh-CN/today.ts @@ -5,8 +5,8 @@ import type { today as enToday } from '../en-US/today.ts'; export const today = { // hero quietWorkspace: '安静工作区', - greeting: '早上好', - heroSubtitle: '今天想了解关于你投资组合的什么?', + greeting: '今日概览', + heroSubtitle: '从市场变化出发,回到你的判断。', searchPlaceholder: '搜索证券,例如 NVDA.US', searchAria: '搜索证券', quickActionDeepResearch: '深度研究', diff --git a/packages/longbridge-tools/src/errors.ts b/packages/longbridge-tools/src/errors.ts index d2f7d59..9b61abd 100644 --- a/packages/longbridge-tools/src/errors.ts +++ b/packages/longbridge-tools/src/errors.ts @@ -1,6 +1,7 @@ export type ErrorCode = | 'LONGBRIDGE_NOT_INSTALLED' | 'LONGBRIDGE_NOT_AUTHED' + | 'LONGBRIDGE_PERMISSION_DENIED' | 'LONGBRIDGE_RATE_LIMITED' | 'LONGBRIDGE_TIMEOUT' | 'INVALID_SYMBOL' diff --git a/packages/longbridge-tools/src/executor.ts b/packages/longbridge-tools/src/executor.ts index 33010e7..64261e1 100644 --- a/packages/longbridge-tools/src/executor.ts +++ b/packages/longbridge-tools/src/executor.ts @@ -43,6 +43,9 @@ export function normalizeLongBridgeError(error: unknown): LongBridgeError { if (execaError.timedOut || details.includes('timed out') || details.includes('timeout')) { return new LongBridgeError('LongBridge command timed out', 'LONGBRIDGE_TIMEOUT'); } + if (details.includes('403308') || details.includes('scope is not in authorized scopes')) { + return new LongBridgeError('Longbridge authorization does not include the required account-data scope.', 'LONGBRIDGE_PERMISSION_DENIED'); + } if ( details.includes('429002') || details.includes('rate limited') || diff --git a/packages/longbridge-tools/src/parser.test.ts b/packages/longbridge-tools/src/parser.test.ts index ce72ac3..5b248cf 100644 --- a/packages/longbridge-tools/src/parser.test.ts +++ b/packages/longbridge-tools/src/parser.test.ts @@ -25,6 +25,13 @@ function quote(overrides: Record = {}): string { } describe('parseQuoteResponse empty-value markers', () => { + it('keeps a real CLI 0.30 quote with no source timestamp without inventing one', () => { + const parsed = parseQuoteResponse(JSON.stringify({ symbol: 'AAPL.US', last: '342.000', prev_close: '342.960', volume: 1234 })); + expect(parsed.lastPrice).toBe(342); + expect(parsed.timestamp).toBe(0); + expect(parsed.change).toBeCloseTo(-0.96); + }); + it('fails a blank last price instead of reporting 0', () => { expect(() => parseQuoteResponse(quote({ last_price: '', last: '' }))).toThrow(LongBridgeError); expect(() => parseQuoteResponse(quote({ last_price: ' ' }))).toThrow(LongBridgeError); diff --git a/packages/longbridge-tools/src/parser.ts b/packages/longbridge-tools/src/parser.ts index cc96465..94e2b8a 100644 --- a/packages/longbridge-tools/src/parser.ts +++ b/packages/longbridge-tools/src/parser.ts @@ -82,7 +82,9 @@ export function parseQuoteResponse(output: string): Quote { change, changePercent: changeRatio * 100, volume: toOptionalNumber(data.volume) ?? 0, - timestamp: toTimestamp(data.timestamp), + // CLI 0.30 omits the quote time entirely. Keep the real price usable, + // with 0 meaning unknown; never invent an exchange timestamp from now(). + timestamp: data.timestamp === undefined ? 0 : toTimestamp(data.timestamp), high: toOptionalNumber(data.high) ?? lastPrice, low: toOptionalNumber(data.low) ?? lastPrice, open: toOptionalNumber(data.open) ?? lastPrice, diff --git a/packages/pi-extension/src/index.test.ts b/packages/pi-extension/src/index.test.ts index bb80671..bfe2a1c 100644 --- a/packages/pi-extension/src/index.test.ts +++ b/packages/pi-extension/src/index.test.ts @@ -3,9 +3,39 @@ import { registerProviderOverrides, registerTools, tools } from './index.ts'; afterEach(() => { delete process.env.ANTHROPIC_BASE_URL; + delete process.env.FINAGENT_PROVIDER_OVERRIDES; }); describe('pi extension registration', () => { + it('completes custom model descriptors required by the actual Pi providers', () => { + process.env.FINAGENT_PROVIDER_OVERRIDES = JSON.stringify([{ + provider: 'custom-model', baseUrl: 'http://127.0.0.1:1/v1', api: 'openai-completions', + models: [{ id: 'text-model', reasoning: true, contextWindow: 64000, maxTokens: 4096 }], + }]); + const models: Array> = []; + registerProviderOverrides({ registerTool: () => {}, registerProvider: (_name, config) => { + models.push(...(config.models ?? []) as unknown as Array>); + } }); + expect(models[0]).toMatchObject({ + id: 'text-model', name: 'text-model', input: ['text'], reasoning: true, + contextWindow: 64000, maxTokens: 4096, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }); + }); + + it('uses system messages for reasoning models on compatible gateways by default', () => { + process.env.FINAGENT_PROVIDER_OVERRIDES = JSON.stringify([{ + provider: 'deepseek-flash', api: 'openai-completions', + models: [{ id: 'deepseek-flash', reasoning: true }, { id: 'opt-in', compat: { supportsDeveloperRole: true } }], + }]); + const registered: Array<{ compat?: { supportsDeveloperRole?: boolean } }> = []; + registerProviderOverrides({ registerTool: () => {}, registerProvider: (_name, config) => { + registered.push(...(config.models ?? [])); + } }); + expect(registered[0]?.compat?.supportsDeveloperRole).toBe(false); + expect(registered[1]?.compat?.supportsDeveloperRole).toBe(true); + }); + it('registers all finance tools', () => { const registered: string[] = []; diff --git a/packages/pi-extension/src/index.ts b/packages/pi-extension/src/index.ts index cc89ea6..e2f37ad 100644 --- a/packages/pi-extension/src/index.ts +++ b/packages/pi-extension/src/index.ts @@ -22,6 +22,9 @@ interface ProviderModelConfig { reasoning?: boolean; contextWindow?: number; maxTokens?: number; + input?: Array<'text' | 'image'>; + cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; + compat?: { supportsDeveloperRole?: boolean; supportsReasoningEffort?: boolean; maxTokensField?: 'max_tokens' | 'max_completion_tokens' }; } interface ProviderConfig { @@ -203,7 +206,18 @@ export function registerProviderOverrides(agent: AgentApi) { if (entry.baseUrl !== undefined) config.baseUrl = entry.baseUrl; if (entry.apiKey !== undefined) config.apiKey = entry.apiKey; if (entry.api !== undefined) config.api = entry.api; - if (entry.models !== undefined) config.models = entry.models; + // Extension registrations require complete descriptors (unlike models.json, + // where Pi fills these defaults). In particular, Pi calls input.includes(). + if (entry.models !== undefined) config.models = entry.models.map((model) => ({ + ...model, + name: model.name ?? model.id, + reasoning: model.reasoning ?? false, + input: model.input ?? ['text'], + cost: model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: model.contextWindow ?? 128000, + maxTokens: model.maxTokens ?? 16384, + compat: { supportsDeveloperRole: false, ...(model.compat ?? {}) }, + })); agent.registerProvider(entry.provider, config); } } diff --git a/packages/shared/src/agent/llm-rpc.test.ts b/packages/shared/src/agent/llm-rpc.test.ts index 350cd06..c693376 100644 --- a/packages/shared/src/agent/llm-rpc.test.ts +++ b/packages/shared/src/agent/llm-rpc.test.ts @@ -63,6 +63,28 @@ function scriptedClient(handler: (line: Record, proc: FakePiPro } describe('PiRpcClient LLM control plane', () => { + it('ignores delayed exit and output from a process replaced by restart', async () => { + const processes: FakePiProcess[] = []; + const client = new PiRpcClient({ spawnProcess: () => { + const proc = new FakePiProcess((line, child) => { + if (line.type !== 'get_state') return; + if (processes.length === 1) child.respond(line, { type: 'response', success: true, data: { sessionId: 'old' } }); + else queueMicrotask(() => { + processes[0]?.stdout.write('old process invalid JSON\n'); + processes[0]?.emit('exit', null, 'SIGTERM'); + child.respond(line, { type: 'response', success: true, data: { sessionId: 'new' } }); + }); + }); + proc.kill = () => { proc.killed = true; return true; }; + processes.push(proc); return proc as never; + } }); + try { + await client.getState(); await client.restart(); + expect((await client.getState()).sessionId).toBe('new'); + expect(client.getLastExitInfo()).toBeNull(); + } finally { await client.dispose(); } + }); + it('lists models from get_available_models', async () => { const client = scriptedClient((line, proc) => { if (line.type === 'get_available_models') { diff --git a/packages/shared/src/agent/pi-rpc-client.ts b/packages/shared/src/agent/pi-rpc-client.ts index 21458c5..37030fc 100644 --- a/packages/shared/src/agent/pi-rpc-client.ts +++ b/packages/shared/src/agent/pi-rpc-client.ts @@ -492,10 +492,11 @@ export class PiRpcClient { this.stderrBuffer = ''; this.stderrHistory = ''; - proc.stdout.on('data', (chunk) => this.consumeStdout(String(chunk))); - proc.stderr.on('data', (chunk) => this.consumeStderr(String(chunk))); - proc.on('error', (error) => this.handleExit(normalizeSpawnError(error, this.command))); + proc.stdout.on('data', (chunk) => { if (this.process === proc) this.consumeStdout(String(chunk)); }); + proc.stderr.on('data', (chunk) => { if (this.process === proc) this.consumeStderr(String(chunk)); }); + proc.on('error', (error) => { if (this.process === proc) this.handleExit(normalizeSpawnError(error, this.command)); }); proc.on('exit', (code, signal) => { + if (this.process !== proc) return; this.lastExitInfo = { code, signal }; this.handleExit(createCodeError( 'PI_RUNTIME_EXITED', @@ -620,6 +621,18 @@ export class PiRpcClient { return; } + const assistantFailure = extractAssistantFailure(event); + if (assistantFailure && !pending.abortRequested) { + this.finishPrompt(pending, createCodeError('PI_MODEL_ERROR', assistantFailure)); + return; + } + + if (event.type === 'agent_end' && !pending.abortRequested + && !extractFinalAnswer(event) && !pending.finalAnswer && pending.answerParts.join('').trim() === '') { + this.finishPrompt(pending, createCodeError('PI_EMPTY_RESPONSE', 'The model returned no answer. Check the model configuration and provider response.')); + return; + } + const text = extractEventText(event); if (text) pending.answerParts.push(text); @@ -833,6 +846,18 @@ function extractEventText(event: Record) { return ''; } +function extractAssistantFailure(event: Record): string | undefined { + let message = readRecord(event.message); + if (event.type === 'agent_end' && Array.isArray(event.messages)) { + message = [...event.messages].reverse().map(readRecord).find((item) => item.role === 'assistant') ?? {}; + } + if (message.role === 'assistant' && message.stopReason === 'error') { + return typeof message.errorMessage === 'string' && message.errorMessage.trim() + ? message.errorMessage : 'The model request failed without an answer.'; + } + return undefined; +} + function extractFinalAnswer(event: Record) { if (event.type === 'agent_end') { return extractAssistantTextFromMessages(event.messages); diff --git a/packages/shared/src/agent/pi-runtime-agent-backend.test.ts b/packages/shared/src/agent/pi-runtime-agent-backend.test.ts index e49cbba..67410a1 100644 --- a/packages/shared/src/agent/pi-runtime-agent-backend.test.ts +++ b/packages/shared/src/agent/pi-runtime-agent-backend.test.ts @@ -82,6 +82,28 @@ async function readFileOrNull(path: string) { } describe('PiRpcClient', () => { + for (const eventType of ['message_end', 'agent_end']) { + it(`rejects a model error inside ${eventType} instead of completing an empty answer`, async () => { + const client = new PiRpcClient({ spawnProcess: createSpawn(() => new FakePiProcess((line, proc) => { + if (line.type !== 'prompt') return; + const failed = { role: 'assistant', stopReason: 'error', errorMessage: "Cannot read properties of undefined (reading 'includes')", content: [] }; + proc.writeEvent({ id: line.id, type: eventType, ...(eventType === 'agent_end' ? { messages: [failed] } : { message: failed }) }); + proc.writeEvent({ id: line.id, type: 'agent_end', messages: [failed] }); + })) }); + try { + await expect(client.prompt('hello')).rejects.toMatchObject({ code: 'PI_MODEL_ERROR', message: expect.stringContaining('includes') }); + } finally { await client.dispose(); } + }); + } + + it('reports a genuinely empty final answer as a visible failure', async () => { + const client = new PiRpcClient({ spawnProcess: createSpawn(() => new FakePiProcess((line, proc) => { + if (line.type === 'prompt') proc.writeEvent({ id: line.id, type: 'agent_end', messages: [{ role: 'assistant', content: [] }] }); + })) }); + try { await expect(client.prompt('hello')).rejects.toMatchObject({ code: 'PI_EMPTY_RESPONSE' }); } + finally { await client.dispose(); } + }); + it('uses the project default pi runtime command and args', () => { process.env.ANTHROPIC_MODEL = 'MiniMax-M2.7'; diff --git a/packages/shared/src/agent/workspace-context.test.ts b/packages/shared/src/agent/workspace-context.test.ts index 03fd915..22a69f2 100644 --- a/packages/shared/src/agent/workspace-context.test.ts +++ b/packages/shared/src/agent/workspace-context.test.ts @@ -55,7 +55,7 @@ class FakePiProcess extends EventEmitter { })}\n`); } else if (line.type === 'prompt') { this.stdout.write(`${JSON.stringify({ id: line.id, type: 'response', command: 'prompt', success: true })}\n`); - this.stdout.write(`${JSON.stringify({ type: 'agent_end', messages: [] })}\n`); + this.stdout.write(`${JSON.stringify({ type: 'agent_end', messages: [{ role: 'assistant', content: [{ type: 'text', text: 'Context received.' }] }] })}\n`); } } diff --git a/packages/shared/src/capabilities/manifests.test.ts b/packages/shared/src/capabilities/manifests.test.ts index dcc308c..717311e 100644 --- a/packages/shared/src/capabilities/manifests.test.ts +++ b/packages/shared/src/capabilities/manifests.test.ts @@ -57,6 +57,14 @@ function fetchers(overrides: Partial = {}): CapabilityFetche } describe('market.quote manifest', () => { + it('marks an unknown source time without emitting a false 1970 market date', async () => { + const result = await createMarketQuoteCapability(fetchers({ getQuote: async () => ({ ...quote, timestamp: 0 }) })).execute({ symbol: 'AAPL.US' }, {}); + expect(result.provenance.marketTime).toBeUndefined(); + expect(result.provenance.stale).toBe(true); + expect(result.summary).toContain('Not supplied by the provider'); + expect(result.summary).not.toContain('1970'); + }); + it('validates input via TypeBox and returns provenance + summary', async () => { const cap = createMarketQuoteCapability(fetchers()); diff --git a/packages/shared/src/capabilities/manifests/market-quote.ts b/packages/shared/src/capabilities/manifests/market-quote.ts index 2746324..efae8d3 100644 --- a/packages/shared/src/capabilities/manifests/market-quote.ts +++ b/packages/shared/src/capabilities/manifests/market-quote.ts @@ -48,8 +48,8 @@ export function createMarketQuoteCapability( providerName: 'Longbridge', ...(instrumentId ? { instrumentId } : {}), fetchedAt: (ctx?.now ?? Date.now)(), - marketTime: quote.timestamp * 1000, - stale: false, + ...(quote.timestamp > 0 ? { marketTime: quote.timestamp * 1000 } : {}), + stale: quote.timestamp <= 0, }, summary: formatQuote(quote), }; @@ -67,7 +67,7 @@ function formatQuote(quote: Quote) { const changeStr = quote.change >= 0 ? `+${quote.change.toFixed(2)} (+${quote.changePercent.toFixed(2)}%)` : `${quote.change.toFixed(2)} (${quote.changePercent.toFixed(2)}%)`; - const asOf = new Date(quote.timestamp * 1000).toLocaleString(); + const asOf = quote.timestamp > 0 ? new Date(quote.timestamp * 1000).toLocaleString() : 'Not supplied by the provider'; return [ `${changeIcon} ${quote.symbol}: $${quote.lastPrice.toFixed(2)}`, diff --git a/packages/shared/src/providers/longbridge/adapter.ts b/packages/shared/src/providers/longbridge/adapter.ts index 34793b9..d114366 100644 --- a/packages/shared/src/providers/longbridge/adapter.ts +++ b/packages/shared/src/providers/longbridge/adapter.ts @@ -81,6 +81,8 @@ class AbortedRequest extends Error { export function toProviderError(error: unknown): ProviderError { if (isLongBridgeError(error)) { switch (error.code) { + case 'LONGBRIDGE_PERMISSION_DENIED': + return { code: 'PERMISSION_DENIED', message: 'Longbridge authorization does not include the required account-data scope.' }; case 'LONGBRIDGE_NOT_AUTHED': return { code: 'AUTH_EXPIRED', diff --git a/packages/shared/src/providers/longbridge/auth.ts b/packages/shared/src/providers/longbridge/auth.ts index ea280ea..dbfddb8 100644 --- a/packages/shared/src/providers/longbridge/auth.ts +++ b/packages/shared/src/providers/longbridge/auth.ts @@ -1,6 +1,6 @@ import type { ProviderHealth } from '@finagent/core'; import { isRecord } from '../../guards.ts'; -import { parseAuthStatus, stringField, type LongbridgeExec } from './health.ts'; +import { isNotInstalledError, parseAuthStatus, stringField, type LongbridgeExec } from './health.ts'; /** * Main-process Longbridge auth orchestration (docs/longbridge-auth.md). @@ -11,14 +11,20 @@ import { parseAuthStatus, stringField, type LongbridgeExec } from './health.ts'; * kernel. */ -export type LoginOutcome = { status: 'connected' | 'timeout' | 'cancelled' }; +export type LoginOutcome = + | { status: 'connected' | 'timeout' | 'cancelled' } + | { status: 'error'; error: { code: string; message: string } }; export interface SpawnedProcess { kill(): void; } /** Launch a process; `onStdout` receives stdout chunks as they arrive. */ -export type SpawnFn = (args: string[], onStdout: (chunk: string) => void) => SpawnedProcess; +export type SpawnFn = ( + args: string[], + onOutput: (chunk: string) => void, + onError: (error: unknown) => void +) => SpawnedProcess; export interface StartLoginOptions { exec: LongbridgeExec; @@ -131,29 +137,50 @@ export async function startLogin(options: StartLoginOptions): Promise { - accumulated += chunk; - if (uriOpened) return; - const uri = extractVerificationUri(accumulated); - if (uri) { - uriOpened = true; - onVerificationUri?.(uri); - void openUrl(uri); - } + const stopped = Promise.withResolvers(); + const cleanup = new AbortController(); + const onAbort = () => stopped.resolve({ status: 'cancelled' }); + signal?.addEventListener('abort', onAbort, { once: true }); + const onError = (error: unknown) => stopped.resolve({ + status: 'error', + error: isNotInstalledError(error) + ? { code: 'LONGBRIDGE_NOT_INSTALLED', message: 'Longbridge CLI was not found. Install it and restart Trail.' } + : { code: 'LOGIN_START_FAILED', message: 'Longbridge authorization could not start. Check the CLI and network, then retry.' }, }); + let child: SpawnedProcess | undefined; + try { + child = spawn(['auth', 'login', '--format', 'json'], (chunk) => { + accumulated += chunk; + if (uriOpened) return; + const uri = extractVerificationUri(accumulated); + if (uri) { + uriOpened = true; + onVerificationUri?.(uri); + // Keep the manual URL available if the system browser cannot open. + void Promise.resolve().then(() => openUrl(uri)).catch(() => undefined); + } + }, onError); + } catch (error) { + onError(error); + } + const startedAt = Date.now(); try { while (true) { if (signal?.aborted) return { status: 'cancelled' }; if (Date.now() - startedAt >= timeoutMs) return { status: 'timeout' }; - if ((await pollTokenStatus(exec)) === 'valid') return { status: 'connected' }; - await sleep(pollMs, signal); + const result = await Promise.race([pollTokenStatus(exec), stopped.promise]); + if (typeof result === 'object' && result !== null) return result; + if (result === 'valid') return { status: 'connected' }; + const paused = await Promise.race([sleep(pollMs, cleanup.signal), stopped.promise]); + if (paused) return paused; } } finally { - child.kill(); + cleanup.abort(); + signal?.removeEventListener('abort', onAbort); + child?.kill(); } } diff --git a/packages/shared/src/providers/longbridge/health.ts b/packages/shared/src/providers/longbridge/health.ts index 6333752..7f502b6 100644 --- a/packages/shared/src/providers/longbridge/health.ts +++ b/packages/shared/src/providers/longbridge/health.ts @@ -166,7 +166,7 @@ export function healthFromAuthStatus(output: string, latencyMs?: number): Provid const label = accountLabel(account); const region = inferRegion(account); - if (!token || !tokenStatus) { + if (!token || !tokenStatus || tokenStatus === 'not_found') { return { status: 'not-connected', lastCheck, diff --git a/packages/shared/src/providers/longbridge/longbridge.test.ts b/packages/shared/src/providers/longbridge/longbridge.test.ts index c348e69..e26cec2 100644 --- a/packages/shared/src/providers/longbridge/longbridge.test.ts +++ b/packages/shared/src/providers/longbridge/longbridge.test.ts @@ -1,5 +1,6 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; import { beforeEach, describe, expect, it, mock } from 'bun:test'; import type { LongbridgeExec } from './health.ts'; import type { SpawnFn } from './auth.ts'; @@ -18,13 +19,10 @@ const execaMock = mock((command: string, args: string[], options?: Record ({ execa: execaMock })); // Dynamic import: the execa mock must be registered before the adapter module @@ -137,6 +135,17 @@ describe('LongbridgeFinancialDataProvider', () => { } }); + it('keeps missing OAuth scopes distinct from expired authentication', async () => { + execaHandler = async () => { throw Object.assign(new Error('Request failed'), { stderr: "API error (code 403308): Target API's scope is not in authorized scopes; private trace", code: '1' }); }; + const result = await new LongbridgeBrokerAccountProvider().getPortfolio(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('PERMISSION_DENIED'); + expect(result.error.message).toContain('scope'); + expect(result.error.message).not.toContain('private trace'); + } + }); + it('maps auth failures to AUTH_EXPIRED with a user-safe message', async () => { const authError = Object.assign(new Error('not authenticated'), { stderr: 'please login', code: '1' }); execaHandler = async () => { @@ -240,6 +249,12 @@ describe('LongbridgeHealthProbe', () => { expect(health.status).toBe('expired'); }); + it('treats CLI 0.30 not_found as a fresh installation awaiting login', async () => { + const { exec } = buildExec({ authStatus: () => JSON.stringify({ token: { status: 'not_found' } }) }); + const health = await new LongbridgeHealthProbe({ exec, cacheTtlMs: 0 }).status(); + expect(health.status).toBe('not-connected'); + }); + it('reports connected with permissions and region', async () => { const { exec } = buildExec({ authStatus: () => authJson(CN_REALTIME_QUOTE_LEVEL) }); const health = await new LongbridgeHealthProbe({ exec, cacheTtlMs: 0 }).status(); @@ -339,6 +354,56 @@ describe('LongbridgeBrokerAccountProvider', () => { // ── Auth orchestration ────────────────────────────────────────────────────── describe('auth orchestration', () => { + it('reports a missing CLI immediately without waiting for token polling', async () => { + let killed = false; + const outcome = await startLogin({ + exec: () => new Promise(() => {}), + spawn: (_args, _output, fail) => { + queueMicrotask(() => fail(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }))); + return { kill: () => { killed = true; } }; + }, + openUrl: () => { throw new Error('Must not open a browser without a URL'); }, + }); + expect(outcome).toMatchObject({ status: 'error', error: { code: 'LONGBRIDGE_NOT_INSTALLED' } }); + expect(killed).toBe(true); + }); + + it('handles synchronous launch failures without leaking vendor output', async () => { + const outcome = await startLogin({ + exec: () => new Promise(() => {}), + spawn: () => { throw new Error('private vendor diagnostic'); }, + openUrl: () => {}, + }); + expect(outcome).toMatchObject({ status: 'error', error: { code: 'LOGIN_START_FAILED' } }); + expect(JSON.stringify(outcome)).not.toContain('private vendor diagnostic'); + }); + + it('preserves the manual URL when opening the system browser rejects', async () => { + const urls: string[] = []; + const outcome = await startLogin({ + exec: async () => JSON.stringify({ token: { status: 'valid' } }), + spawn: (_args, output) => { + output(JSON.stringify({ verification_uri: 'https://example.test/authorize' })); + return { kill: () => {} }; + }, + onVerificationUri: (uri) => { urls.push(uri); }, + openUrl: async () => { throw new Error('No default browser'); }, + }); + expect(outcome.status).toBe('connected'); + expect(urls).toEqual(['https://example.test/authorize']); + }); + + it('cancels even while an auth-status command is stalled', async () => { + const controller = new AbortController(); + const pending = startLogin({ + exec: () => new Promise(() => {}), + spawn: () => ({ kill: () => {} }), + openUrl: () => {}, signal: controller.signal, + }); + controller.abort(); + expect(await pending).toEqual({ status: 'cancelled' }); + }); + it('startLogin opens the verification_uri and completes on a valid token', async () => { const spawnedArgs: string[][] = []; const opened: string[] = []; diff --git a/packages/ui/src/atoms/portfolioAtoms.ts b/packages/ui/src/atoms/portfolioAtoms.ts index 35bb9ac..13dc3ea 100644 --- a/packages/ui/src/atoms/portfolioAtoms.ts +++ b/packages/ui/src/atoms/portfolioAtoms.ts @@ -30,12 +30,14 @@ export const portfolioCacheAtom = atom({ export const fetchPortfolioAtom = atom( null, async (_get, set, client: FinagentClient) => { + let requestFailure: PortfolioFailure | null = null; set(portfolioCacheAtom, (cache) => ({ ...cache, loading: true, error: null })); try { const result = await client.market.getPortfolio(); if (!result.ok) { const failure = portfolioFailureFromError(result.error); + requestFailure = failure; set(portfolioCacheAtom, (cache) => ({ ...cache, loading: false, @@ -54,7 +56,7 @@ export const fetchPortfolioAtom = atom( const demo = demoPortfolioSnapshot(); set(portfolioCacheAtom, { data: demo, - failure: null, + failure, isDemo: true, timestamp: Date.now(), loading: false, @@ -78,7 +80,7 @@ export const fetchPortfolioAtom = atom( const demo = demoPortfolioSnapshot(); set(portfolioCacheAtom, { data: demo, - failure: null, + failure: requestFailure ?? portfolioFailureFromError(error), isDemo: true, timestamp: Date.now(), loading: false, diff --git a/packages/ui/src/atoms/sessionAtoms.test.ts b/packages/ui/src/atoms/sessionAtoms.test.ts index 3bfe501..5d031f4 100644 --- a/packages/ui/src/atoms/sessionAtoms.test.ts +++ b/packages/ui/src/atoms/sessionAtoms.test.ts @@ -98,6 +98,33 @@ function makeClient(): FinagentClient { } describe('session atoms', () => { + it('retains a complete live answer until its persisted replacement is ready', async () => { + const store = createStore(); + const client = makeClient(); + const user = { id: 'u', role: 'user' as const, content: 'question', timestamp: 1 }; + const live = { id: 'live', role: 'assistant' as const, content: 'answer', timestamp: 2 }; + const saved = { ...live, id: 'saved' }; + store.set(messagesAtomFamily('completion'), [user, live]); + let reads = 0; + client.kernel.getMessages = async () => { + reads += 1; + expect(store.get(messagesAtomFamily('completion'))).toEqual([user, live]); + return { ok: true, data: reads < 3 ? [user] : [user, saved] }; + }; + await store.set(loadMessagesAtom, client, 'completion', 2); + expect(reads).toBe(3); + expect(store.get(messagesAtomFamily('completion'))).toEqual([user, saved]); + }); + + it('does not erase a complete answer if persistence stays behind the refresh deadline', async () => { + const store = createStore(); + const client = makeClient(); + const live = { id: 'live', role: 'assistant' as const, content: 'answer', timestamp: 2 }; + store.set(messagesAtomFamily('delayed'), [live]); + client.kernel.getMessages = async () => ({ ok: true, data: [] }); + await store.set(loadMessagesAtom, client, 'delayed', 1); + expect(store.get(messagesAtomFamily('delayed'))).toEqual([live]); + }); beforeEach(() => { sessionCounter = 0; savedSessions = []; diff --git a/packages/ui/src/atoms/sessionAtoms.ts b/packages/ui/src/atoms/sessionAtoms.ts index ca195a4..38a73a6 100644 --- a/packages/ui/src/atoms/sessionAtoms.ts +++ b/packages/ui/src/atoms/sessionAtoms.ts @@ -47,15 +47,23 @@ export const hydrateSessionsAtom = atom( export const loadMessagesAtom = atom( null, - async (_get, set, client: FinagentClient, sessionId: string) => { - const result = await client.kernel.getMessages(sessionId); - if (!result.ok) return; - set(messagesAtomFamily(sessionId), result.data); - set(loadedSessionIdsAtom, (loaded) => { - const next = new Set(loaded); - next.add(sessionId); - return next; - }); + async (get, set, client: FinagentClient, sessionId: string, minimumCount: number = 0) => { + // The terminal event can precede transcript persistence. Keep the complete + // live turn until the saved answer (including its evidence) is available. + // Ordinary hydration remains a single read; only completion refreshes poll. + for (let attempt = 0; attempt < (minimumCount > 0 ? 8 : 1); attempt += 1) { + const result = await client.kernel.getMessages(sessionId); + if (!result.ok) return; + const requiredCount = minimumCount > 0 + ? Math.max(minimumCount, get(messagesAtomFamily(sessionId)).length) + : 0; + if (result.data.length >= requiredCount) { + set(messagesAtomFamily(sessionId), result.data); + set(loadedSessionIdsAtom, (loaded) => new Set([...loaded, sessionId])); + return; + } + if (attempt < 7) await new Promise((resolve) => setTimeout(resolve, 75)); + } } ); diff --git a/packages/ui/src/atoms/workspaceAtoms.ts b/packages/ui/src/atoms/workspaceAtoms.ts index 7f9ee81..e438893 100644 --- a/packages/ui/src/atoms/workspaceAtoms.ts +++ b/packages/ui/src/atoms/workspaceAtoms.ts @@ -16,6 +16,8 @@ export const activeSymbolAtom = atom(null); /** Which workspace view is visible: security views or app sections. */ export const activeViewAtom = atom('overview'); +/** Re-fetch the visible read-only security view without reloading the app. */ +export const workspaceRefreshAtom = atom(0); /** Selected portfolio position (portfolio view). */ export const selectedPositionAtom = atom(null); diff --git a/packages/ui/src/client/connections.ts b/packages/ui/src/client/connections.ts index 39a9058..6eb0148 100644 --- a/packages/ui/src/client/connections.ts +++ b/packages/ui/src/client/connections.ts @@ -187,6 +187,22 @@ export async function connectProvider( } } +/** Cancel a pending device-authorization flow without logging out. */ +export async function cancelProviderConnect( + client: FinagentClient, + providerId: string +): Promise> { + const cancel = client.connections?.cancelConnect; + if (typeof cancel !== 'function') return { ok: false, data: null, error: UNAVAILABLE }; + try { + const result = await cancel(providerId); + return result.ok ? { ok: true, data: result.data, error: null } + : { ok: false, data: null, error: result.error }; + } catch (error) { + return { ok: false, data: null, error: asError(error) }; + } +} + /** Disconnect a provider. */ export async function disconnectProvider( client: FinagentClient, diff --git a/packages/ui/src/components/agent/AgentPanel.tsx b/packages/ui/src/components/agent/AgentPanel.tsx index 322ae86..096b25d 100644 --- a/packages/ui/src/components/agent/AgentPanel.tsx +++ b/packages/ui/src/components/agent/AgentPanel.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ArrowUp, ChevronLeft, ChevronRight, Square, Sparkles } from 'lucide-react'; +import { ArrowUp, ChevronDown, ChevronRight, Square } from 'lucide-react'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import type { ApiError, TrailTrace, PortfolioSnapshot, Quote, ToolCall } from '@finagent/core'; import { @@ -30,8 +30,8 @@ import { loadSessionTraceSources, projectSessionTrace } from '../../lib/traceDat import { QuoteCard } from './structured/QuoteCard'; import { PortfolioRiskCard } from './structured/PortfolioRiskCard'; import { AgentAmbientField, type AgentMotionState } from '../motion/AgentAmbientField'; +import { AssistantIdentity, TrailLogo } from '../brand/TrailIdentity'; -const trailLogoUrl = new URL('../../assets/trail-logo.png', import.meta.url).href; // --------------------------------------------------------------------------- // Defensive parsing of structured tool results (get_quote / get_portfolio). @@ -116,7 +116,7 @@ export const AgentPanel: React.FC = () => { const wasRunningRef = useRef(false); useEffect(() => { if (!isRunning && wasRunningRef.current && activeSessionId) { - void loadMessages(client, activeSessionId); + void loadMessages(client, activeSessionId, messages.length); } wasRunningRef.current = isRunning; }, [isRunning, activeSessionId, client, loadMessages]); @@ -217,34 +217,6 @@ export const AgentPanel: React.FC = () => { } }; - if (!activeSessionId) { - return ( - - ); - } - const toolCalls = runView?.toolCalls ?? []; const quote = extractQuote(toolCalls); const portfolio = extractPortfolio(toolCalls); @@ -255,20 +227,27 @@ export const AgentPanel: React.FC = () => { className="trail-agent-panel mac-sidebar flex h-full w-full flex-col border-l mac-section-divider" > {/* Header: model + thinking selectors and collapse affordance */} -
-
{t('agent.panel.title')}
- - -
- +
+
+ +
+
{t('agent.panel.title')}
+
TRAIL
+
+ +
+
+ + +
{/* Workspace context chip */} @@ -280,9 +259,9 @@ export const AgentPanel: React.FC = () => {
-
+
{runView?.infraError && ( { {quote && } {portfolio && } {messages.length === 0 && !isRunning && ( - { - setInput(text); - void handleSend(); - }} - /> +
+ +
)} {isRunning && } @@ -333,7 +319,7 @@ export const AgentPanel: React.FC = () => { onChange={(e: React.ChangeEvent) => setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder={isRunning ? t('agent.panel.inputRunningPlaceholder') : t('agent.panel.inputPlaceholder')} - disabled={isRunning} + disabled={isRunning || !activeSessionId} rows={2} className="mac-input trail-agent-textarea w-full resize-none px-3 py-2.5 pr-11 text-[13px] leading-relaxed text-foreground placeholder:text-foreground/38 focus:border-[rgba(var(--accent-rgb),0.34)] focus:outline-none focus:ring-2 focus:ring-accent/25 disabled:opacity-50" /> @@ -341,7 +327,7 @@ export const AgentPanel: React.FC = () => { )}
+
{t('agent.panel.inputHint')}
{sendError &&
{sendError}
} {isRunning && (
@@ -435,12 +422,8 @@ const SuggestionChips: React.FC<{ onPick: (text: string) => void }> = ({ onPick const prompts = (t(groupKey, { returnObjects: true }) as string[]) ?? []; if (prompts.length === 0) return null; return ( -
-
- - {t('agent.suggestions.title')} -
-
+
+
{prompts.slice(0, 3).map((prompt) => ( ))}
@@ -460,16 +443,15 @@ const SuggestionChips: React.FC<{ onPick: (text: string) => void }> = ({ onPick const StreamingBlock: React.FC<{ answer: string }> = ({ answer }) => { const { t } = useTranslation(); return ( -
-
- - {t('agent.panel.agentRunning')} -
+
+ +
{answer.length > 0 ? ( - + ) : (
{t('agent.panel.thinking')}
)} +
); }; diff --git a/packages/ui/src/components/agent/ModelSelector.tsx b/packages/ui/src/components/agent/ModelSelector.tsx index 02e443c..757f1b2 100644 --- a/packages/ui/src/components/agent/ModelSelector.tsx +++ b/packages/ui/src/components/agent/ModelSelector.tsx @@ -53,6 +53,6 @@ export const ModelSelector: React.FC = ({ disabled = false } {error &&
{error}
} - {!error && state.error &&
{state.error}
} + {!error && state.error &&
{t('agent.model.unavailable')}
}
; }; diff --git a/packages/ui/src/components/agent/ThinkingSelector.tsx b/packages/ui/src/components/agent/ThinkingSelector.tsx index 0b8d498..350c3aa 100644 --- a/packages/ui/src/components/agent/ThinkingSelector.tsx +++ b/packages/ui/src/components/agent/ThinkingSelector.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Check, ChevronDown, Brain, LoaderCircle } from 'lucide-react'; +import { Check, ChevronDown, LoaderCircle } from 'lucide-react'; import { useAtom, useSetAtom } from 'jotai'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui/dropdown-menu'; import { hydrateLlmAtom, llmStateAtom, setLlmThinkingAtom } from '../../atoms'; @@ -31,7 +31,7 @@ export const ThinkingSelector: React.FC = ({ disabled = f diff --git a/packages/ui/src/components/brand/TrailIdentity.tsx b/packages/ui/src/components/brand/TrailIdentity.tsx new file mode 100644 index 0000000..71bd45f --- /dev/null +++ b/packages/ui/src/components/brand/TrailIdentity.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +const trailLogoUrl = new URL('../../assets/trail-logo.png', import.meta.url).href; + +export const TrailLogo: React.FC<{ className?: string }> = ({ className = '' }) => ( + +); + +/** Shared by live and persisted answers: one identity per assistant turn. */ +export const AssistantIdentity: React.FC<{ timestamp?: number; streaming?: boolean }> = ({ timestamp, streaming }) => { + const { t } = useTranslation(); + return ( +
+ + {t('agent.panel.assistantName')} + {timestamp != null && } + {streaming && {t('agent.panel.thinking')}} +
+ ); +}; diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index df08632..65f04c7 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useTranslation } from 'react-i18next'; import type { Message } from '@finagent/core'; import { TurnCard } from './TurnCard'; @@ -8,26 +7,14 @@ interface MessageListProps { isLoading: boolean; } -export const MessageList: React.FC = ({ messages, isLoading }) => { - const { t } = useTranslation(); +export const MessageList: React.FC = ({ messages }) => { return ( -
+
{/* Inside the 400px Copilot panel the shell already pads the body; no max-width rail. */}
{messages.map((message) => ( ))} - {isLoading && ( -
-
-
{t('agent.panel.thinking')}
-
- )} - {messages.length === 0 && !isLoading && ( -
-

{t('agent.chat.noMessages')}

-
- )}
); diff --git a/packages/ui/src/components/chat/TurnCard.test.tsx b/packages/ui/src/components/chat/TurnCard.test.tsx index fb78845..6b75cef 100644 --- a/packages/ui/src/components/chat/TurnCard.test.tsx +++ b/packages/ui/src/components/chat/TurnCard.test.tsx @@ -34,6 +34,21 @@ const assistantMessage: Message = { }; describe('TurnCard', () => { + it('shows one assistant identity above a full multi-section answer, and none on user turns', async () => { + const container = document.createElement('div'); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.querySelectorAll('[data-testid="assistant-identity"]').length).toBe(1); + expect(container.querySelector('.trail-answer-name')?.textContent).toBe('研迹'); + expect(container.querySelector('.trail-answer-body')?.querySelectorAll('h2').length).toBe(2); + await act(async () => { + root.render(); + }); + expect(container.querySelector('[data-testid="assistant-identity"]')).toBeNull(); + await act(async () => root.unmount()); + }); it('renders persisted tool calls with the shared expandable activity timeline', async () => { const container = document.createElement('div'); const root = createRoot(container); diff --git a/packages/ui/src/components/chat/TurnCard.tsx b/packages/ui/src/components/chat/TurnCard.tsx index a1f20e0..a546aa6 100644 --- a/packages/ui/src/components/chat/TurnCard.tsx +++ b/packages/ui/src/components/chat/TurnCard.tsx @@ -6,6 +6,7 @@ import { AnswerContent } from './AnswerContent'; import { SourceInspector } from './SourceInspector'; import { ToolActivity } from '../agent/ToolActivity'; import { collectCitationSources } from '../../lib/citations'; +import { AssistantIdentity } from '../brand/TrailIdentity'; interface TurnCardProps { message: Message; @@ -32,10 +33,10 @@ export const TurnCard: React.FC = ({ message }) => { return (
= ({ message }) => { : 'mac-message-assistant rounded-bl-[4px] text-foreground' }`} > - {isTool && message.toolName && ( -
- {t('agent.tool.label', { name: message.toolName })} -
- )} - {isUser ? ( -
{message.content}
- ) : ( - openInspector(sourceId) : undefined} - /> - )} - {!isUser && toolCalls.length > 0 && ( -
- -
- )} - {isAssistant && ( -
- - {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - - {citationIndex.sources.length > 0 && ( - - )} -
- )} + {isAssistant && } +
+ {isTool && message.toolName && ( +
+ {t('agent.tool.label', { name: message.toolName })} +
+ )} + {isUser ? ( +
{message.content}
+ ) : ( + openInspector(sourceId) : undefined} + /> + )} + {!isUser && toolCalls.length > 0 && ( +
+ +
+ )} + {isAssistant && ( +
+ {citationIndex.sources.length > 0 && ( + + )} +
+ )} +
{isAssistant && inspectorOpen && ( = ({ client = fallbackClient }) = }; const LongBridgeBanner: React.FC = () => { + const { t } = useTranslation(); const [status, setStatus] = useState(null); const [error, setError] = useState(null); const client = useFinagentClient(); + const refreshRevision = useAtomValue(workspaceRefreshAtom); useEffect(() => { let mounted = true; - client.longbridge.getStatus().then((result) => { + const refresh = () => client.longbridge.getStatus().then((result) => { if (!mounted) return; if (result.ok) { setStatus(result.data); @@ -59,10 +65,16 @@ const LongBridgeBanner: React.FC = () => { setError(result.error.message); } }); + void refresh(); + const unsubscribe = client.connections?.onChanged(() => { void refresh(); }); + const onFocus = () => { void refresh(); }; + window.addEventListener('focus', onFocus); return () => { mounted = false; + unsubscribe?.(); + window.removeEventListener('focus', onFocus); }; - }, [client]); + }, [client, refreshRevision]); if (!error && (!status || status.available)) { return null; @@ -70,13 +82,13 @@ const LongBridgeBanner: React.FC = () => { const message = error ?? status?.message ?? 'LongBridge CLI status is unavailable.'; const action = status?.action ?? 'Install LongBridge CLI and run longbridge auth login.'; - const title = status?.status === 'rate_limited' ? 'LongBridge paused: ' : 'LongBridge setup needed: '; + const title = t(status?.status === 'rate_limited' ? 'navigation.marketPaused' : 'navigation.marketSetupNeeded'); return ( -
- {title} - {message} - {action} +
+
); }; diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index ef7db78..76b64fe 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -14,7 +14,7 @@ import { FileText, FlaskConical, GitCompareArrows, - LayoutDashboard, + House, Settings, Trash2, UserRound, @@ -31,11 +31,12 @@ import { } from '../../atoms'; import { useFinagentClient } from '../../client'; import { Watchlist } from '../stock/Watchlist'; +import { TrailLogo } from '../brand/TrailIdentity'; type SidebarItem = { key: NavSection; labelKey: string; icon: LucideIcon }; const SIDEBAR_ITEMS: SidebarItem[] = [ - { key: 'today', labelKey: 'today', icon: LayoutDashboard }, + { key: 'today', labelKey: 'today', icon: House }, { key: 'discover', labelKey: 'discover', icon: Compass }, { key: 'watchlist', labelKey: 'workspace', icon: ChartNoAxesCombined }, { key: 'portfolio', labelKey: 'portfolio', icon: BriefcaseBusiness }, @@ -84,8 +85,12 @@ export const Sidebar: React.FC = () => {