From a8c060cb1034088a11dcabcd9a7e8c0fda8bb3a2 Mon Sep 17 00:00:00 2001
From: Curry
Date: Sun, 6 Sep 2026 02:30:25 +0800
Subject: [PATCH 1/5] feat(news): restore layered timeline reading and Market
navigation
---
docs/ui-interaction-and-motion.md | 36 +
src/domain/news/collector/rss-parser.spec.ts | 70 +-
src/domain/news/collector/rss-parser.ts | 95 ++-
src/domain/news/collector/rss.ts | 1 +
src/webui/routes/news.spec.ts | 97 +++
src/webui/routes/news.ts | 115 ++-
ui/public/market/flags/LICENSE | 24 +
ui/public/market/flags/cn.svg | 1 +
ui/public/market/flags/hk.svg | 1 +
ui/public/market/flags/us.svg | 1 +
ui/src/api/news.ts | 21 +-
ui/src/api/types.ts | 4 +-
ui/src/components/MarketSidebar.spec.tsx | 77 +-
ui/src/components/MarketSidebar.tsx | 64 +-
ui/src/components/TabHost.tsx | 6 +-
.../market/NewsMarketNavigation.tsx | 50 ++
ui/src/components/market/news-categories.ts | 30 +
ui/src/demo/fixtures/news.ts | 42 +-
ui/src/demo/handlers/newsList.spec.ts | 54 ++
ui/src/demo/handlers/newsList.ts | 104 ++-
ui/src/hooks/useNewsFeed.spec.ts | 57 ++
ui/src/hooks/useNewsFeed.ts | 125 ++++
ui/src/i18n/locales/en.ts | 51 ++
ui/src/i18n/locales/ja.ts | 51 ++
ui/src/i18n/locales/zh-Hant.ts | 51 ++
ui/src/i18n/locales/zh.ts | 51 ++
ui/src/pages/NewsPage.spec.tsx | 419 +++++------
ui/src/pages/NewsPage.tsx | 677 ++++++++----------
ui/src/tabs/UrlAdopter.tsx | 9 +-
ui/src/tabs/__tests__/store.spec.ts | 13 +
ui/src/tabs/registry.tsx | 48 +-
ui/src/tabs/types.ts | 4 +-
32 files changed, 1690 insertions(+), 759 deletions(-)
create mode 100644 src/webui/routes/news.spec.ts
create mode 100644 ui/public/market/flags/LICENSE
create mode 100644 ui/public/market/flags/cn.svg
create mode 100644 ui/public/market/flags/hk.svg
create mode 100644 ui/public/market/flags/us.svg
create mode 100644 ui/src/components/market/NewsMarketNavigation.tsx
create mode 100644 ui/src/components/market/news-categories.ts
create mode 100644 ui/src/demo/handlers/newsList.spec.ts
create mode 100644 ui/src/hooks/useNewsFeed.spec.ts
create mode 100644 ui/src/hooks/useNewsFeed.ts
diff --git a/docs/ui-interaction-and-motion.md b/docs/ui-interaction-and-motion.md
index 0fe7c06be..8342544cb 100644
--- a/docs/ui-interaction-and-motion.md
+++ b/docs/ui-interaction-and-motion.md
@@ -34,6 +34,22 @@ information should recede without becoming illegible.
- Keep copy direct and operational. Lead with the state or object, then the
explanation and next action.
+News rows in `ui/src/pages/NewsPage.tsx` form a local-calendar-day timeline.
+Time stays in the left gutter. The headline is a separate, prominent block above
+the summary, which previews up to three lines. Editorial images align with the
+headline on the right, with the source below; narrow screens place this image
+and source beneath the text. Bordered market/topic labels and a compact
+disclosure arrow follow the summary; expanding never hides the image. Flags
+identify the supplied market classification, not a company's domicile or the
+country mentioned in a headline. The local flag assets retain their license
+under `ui/public/market/flags/`; unknown tags remain text. The current news
+contract has no structured related-company identity or company-logo field.
+
+The news scroller reveals already-fetched results in batches of 40 when its
+bottom sentinel approaches the viewport. Appending keeps the reading position;
+changing a filter resets the batch and scroll position. This is not server
+pagination: the existing query limit and refresh cadence remain unchanged.
+
The stable page hierarchy is:
1. global shell and activity rail;
@@ -318,6 +334,26 @@ drill-in in a fresh copy of the same shell or mask the resulting remount with a
transition. Session terminals and other heavy page content remain active-only
unless their own lifecycle explicitly requires otherwise.
+Market's quotes, news, boards, rotation and instrument views share the
+`market` shell. News contributes content only; its grouped categories live
+inside MarketSidebar and use SidebarRow rather than feature-colored buttons.
+Category and news-view selection use separate URL parameters, so selecting an
+importance or sentiment view does not discard the current category. Existing
+source-tag matching is unchanged by this navigation and theme integration.
+News category/view selections are adopted into one news tab and projected back
+to the URL, including on a fresh load. Changing a selection does not create
+another tab. Narrow screens use the same news directory in the Market drawer.
+News initially mounts forty stories and reveals more on demand. Selection
+changes reset the visible batch; full story text remains expandable. Polling
+waits for an outstanding request instead of repeatedly replacing a slow load,
+while explicit refresh, query changes and unmount cancel superseded requests.
+
+Market directory headings are independent disclosure controls: News, Markets,
+Macro and Watchlist fold without navigating; child rows navigate.
+Their local open state survives view changes inside the mounted market shell.
+Hidden descendants remain mounted but are excluded from focus and accessibility
+navigation by the native hidden attribute. No new persisted preference is added.
+
Keyboard focus is not a motion effect. Interactive controls still require a
clear `focus-visible` treatment, meaningful labels, and sensible tab order.
diff --git a/src/domain/news/collector/rss-parser.spec.ts b/src/domain/news/collector/rss-parser.spec.ts
index 210e99f40..61d609773 100644
--- a/src/domain/news/collector/rss-parser.spec.ts
+++ b/src/domain/news/collector/rss-parser.spec.ts
@@ -127,8 +127,76 @@ describe('parseRSSXml', () => {
const items = parseRSSXml(xml)
expect(items[0].content).toBe('This is the full article text with much more detail.')
})
-})
+ it('extracts safe RSS image media without adding image to old items', () => {
+ const xml = `
+
+ Thumbnail article
+
+
+
+ Enclosure article
+
+
+
+ HTML article
+ Summary
]]>
+
+ No image
+ `
+ const items = parseRSSXml(xml)
+ expect(items[0].image).toBe('https://cdn.example.com/thumb.jpg')
+ expect(items[1].image).toBe('https://cdn.example.com/article.png')
+ expect(items[2].image).toBe('https://cdn.example.com/summary.webp')
+ expect(items[3]).not.toHaveProperty('image')
+ })
+
+ it('rejects unsafe and non-image media', () => {
+ const xml = `
+
+ Unsafe
+
+ ]]>
+
+
+ Not an image
+
+
+
+ `
+
+ const items = parseRSSXml(xml)
+ expect(items[0]).not.toHaveProperty('image')
+ expect(items[1]).not.toHaveProperty('image')
+ })
+
+ it('accepts image media content and ignores non-image variants', () => {
+ const xml = `
+
+ Media article
+
+
+
+ `
+
+ expect(parseRSSXml(xml)[0].image).toBe('https://cdn.example.com/photo.jpg')
+ })
+
+ it('does not confuse similarly named tags or attributes with the requested values', () => {
+ const xml = `Wrong
+ Right
+ Body
+
+ ]]>
+ `
+
+ const items = parseRSSXml(xml)
+ expect(items).toHaveLength(1)
+ expect(items[0].title).toBe('Right')
+ expect(items[0].image).toBeUndefined()
+ })
+
+})
// ==================== fetchAndParseFeed ====================
const MINIMAL_RSS = `
diff --git a/src/domain/news/collector/rss-parser.ts b/src/domain/news/collector/rss-parser.ts
index 23fd2d659..20cd9abfc 100644
--- a/src/domain/news/collector/rss-parser.ts
+++ b/src/domain/news/collector/rss-parser.ts
@@ -2,7 +2,7 @@
* News Collector — Zero-dependency RSS / Atom parser
*
* Handles standard RSS 2.0 () and Atom () feeds.
- * Extracts: title, description/summary, link, guid/id, pubDate.
+ * Extracts: title, description/summary, link, guid, pubDate, and safe image URLs.
* Supports CDATA-wrapped content.
*/
@@ -12,6 +12,8 @@ export interface ParsedFeedItem {
link: string | null
guid: string | null
pubDate: Date | null
+ /** Safe HTTP(S) image URL when the feed supplied one. */
+ image?: string
}
/**
@@ -48,17 +50,16 @@ export function parseRSSXml(xml: string): ParsedFeedItem[] {
let match: RegExpExecArray | null
while ((match = itemRegex.exec(xml)) !== null) {
const block = match[1]
- // For title & content: strip HTML first, then decode XML entities.
- // This prevents <tag> from being decoded to then stripped as HTML.
+ const contentRaw = extractTagRaw(block, 'content:encoded')
+ ?? extractTagRaw(block, 'description')
+ ?? extractTagRaw(block, 'summary')
+ ?? extractTagRaw(block, 'content')
+ ?? ''
+ const image = extractImage(block, contentRaw)
+
items.push({
title: cleanText(extractTagRaw(block, 'title') ?? ''),
- content: cleanText(
- extractTagRaw(block, 'content:encoded')
- ?? extractTagRaw(block, 'description')
- ?? extractTagRaw(block, 'summary')
- ?? extractTagRaw(block, 'content')
- ?? '',
- ),
+ content: cleanText(contentRaw),
link: extractTag(block, 'link') ?? extractAttr(block, 'link', 'href'),
guid: extractTag(block, 'guid') ?? extractTag(block, 'id'),
pubDate: parseDate(
@@ -66,6 +67,7 @@ export function parseRSSXml(xml: string): ParsedFeedItem[] {
?? extractTag(block, 'published')
?? extractTag(block, 'updated'),
),
+ ...(image ? { image } : {}),
})
}
@@ -81,7 +83,7 @@ export function parseRSSXml(xml: string): ParsedFeedItem[] {
function extractTagRaw(xml: string, tag: string): string | null {
// Try CDATA first:
const cdataRegex = new RegExp(
- `<${escapeRegex(tag)}[^>]*>\\s*\\s*${escapeRegex(tag)}>`,
+ String.raw`<${escapeRegex(tag)}\b[^>]*>\s*\s*${escapeRegex(tag)}>`,
'i',
)
const cdataMatch = cdataRegex.exec(xml)
@@ -89,13 +91,74 @@ function extractTagRaw(xml: string, tag: string): string | null {
// Plain text: content
const regex = new RegExp(
- `<${escapeRegex(tag)}[^>]*>([\\s\\S]*?)${escapeRegex(tag)}>`,
+ String.raw`<${escapeRegex(tag)}\b[^>]*>([\s\S]*?)${escapeRegex(tag)}>`,
'i',
)
const match = regex.exec(xml)
return match ? match[1].trim() : null
}
+/**
+ * Find the first feed-provided image that is both an image media item and a
+ * safe remote URL. Feed metadata is trusted only after this boundary check.
+ */
+function extractImage(block: string, contentRaw: string): string | null {
+ const thumbnail = safeHttpImageUrl(
+ extractAttr(block, 'media:thumbnail', 'url')
+ ?? extractAttr(block, 'media:thumbnail', 'href'),
+ )
+ if (thumbnail) return thumbnail
+
+ for (const tag of ['enclosure', 'media:content']) {
+ const tagRegex = new RegExp(`<${escapeRegex(tag)}\\b[^>]*>`, 'gi')
+ let match: RegExpExecArray | null
+ while ((match = tagRegex.exec(block)) !== null) {
+ const tagText = match[0]
+ const url = extractAttr(tagText, tag, 'url')
+ if (!url || !isImageMedia(tagText, tag, url)) continue
+ const image = safeHttpImageUrl(url)
+ if (image) return image
+ }
+ }
+
+ const nestedImage = extractTagRaw(block, 'image')
+ const imageUrl = nestedImage ? extractTag(nestedImage, 'url') : null
+ const feedImage = safeHttpImageUrl(imageUrl)
+ if (feedImage) return feedImage
+
+ const htmlImage = /]*>/i.exec(decodeXmlEntities(contentRaw))
+ return htmlImage ? safeHttpImageUrl(extractAttr(htmlImage[0], 'img', 'src')) : null
+}
+
+function isImageMedia(tagText: string, tag: string, url: string): boolean {
+ const type = extractAttr(tagText, tag, 'type')?.toLowerCase()
+ const medium = extractAttr(tagText, tag, 'medium')?.toLowerCase()
+ if (medium && medium !== 'image') return false
+ if (type && !type.startsWith('image/')) return false
+ return Boolean(type || medium || hasImageExtension(url))
+}
+
+function hasImageExtension(url: string): boolean {
+ try {
+ return /\.(?:avif|gif|jpe?g|png|svg|webp)$/i.test(new URL(url).pathname)
+ } catch {
+ return false
+ }
+}
+
+function safeHttpImageUrl(raw: string | null): string | null {
+ if (!raw) return null
+ const url = decodeXmlEntities(raw.trim())
+ try {
+ const parsed = new URL(url)
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
+ if (parsed.username || parsed.password) return null
+ return url
+ } catch {
+ return null
+ }
+}
+
/**
* Strip HTML tags first, then decode XML entities.
* Order matters: <tag> → (strip: no-op) → decode → "" (preserved)
@@ -110,7 +173,7 @@ function cleanText(raw: string): string {
function extractTag(xml: string, tag: string): string | null {
// Try CDATA first:
const cdataRegex = new RegExp(
- `<${escapeRegex(tag)}[^>]*>\\s*\\s*${escapeRegex(tag)}>`,
+ String.raw`<${escapeRegex(tag)}\b[^>]*>\s*\s*${escapeRegex(tag)}>`,
'i',
)
const cdataMatch = cdataRegex.exec(xml)
@@ -118,7 +181,7 @@ function extractTag(xml: string, tag: string): string | null {
// Plain text: content
const regex = new RegExp(
- `<${escapeRegex(tag)}[^>]*>([\\s\\S]*?)${escapeRegex(tag)}>`,
+ String.raw`<${escapeRegex(tag)}\b[^>]*>([\s\S]*?)${escapeRegex(tag)}>`,
'i',
)
const match = regex.exec(xml)
@@ -130,9 +193,9 @@ function extractTag(xml: string, tag: string): string | null {
* e.g. → "https://..."
*/
function extractAttr(xml: string, tag: string, attr: string): string | null {
- const regex = new RegExp(`<${escapeRegex(tag)}[^>]*${attr}="([^"]*)"`, 'i')
+ const regex = new RegExp(String.raw`<${escapeRegex(tag)}\b[^>]*\s${escapeRegex(attr)}\s*=(?:"([^"]*)"|'([^']*)')`, 'i')
const match = regex.exec(xml)
- return match ? match[1] : null
+ return match ? (match[1] ?? match[2]) : null
}
/**
diff --git a/src/domain/news/collector/rss.ts b/src/domain/news/collector/rss.ts
index 01bd0c7cd..f3cb75187 100644
--- a/src/domain/news/collector/rss.ts
+++ b/src/domain/news/collector/rss.ts
@@ -126,6 +126,7 @@ export class NewsCollector {
ingestSource: 'rss',
dedupKey,
...(feed.categories ? { categories: feed.categories.join(',') } : {}),
+ ...(item.image ? { image: item.image } : {}),
},
})
diff --git a/src/webui/routes/news.spec.ts b/src/webui/routes/news.spec.ts
new file mode 100644
index 000000000..f4b949688
--- /dev/null
+++ b/src/webui/routes/news.spec.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it, vi } from 'vitest'
+import { createNewsRoutes } from './news.js'
+import type { EngineContext } from '../../core/types.js'
+import type { GetNewsV2Options, INewsProvider, NewsItem } from '../../domain/news/types.js'
+
+const BASE = new Date('2026-02-27T00:00:00Z').getTime()
+
+function item(index: number, fields: Partial = {}): NewsItem {
+ return {
+ id: index,
+ time: new Date(BASE + index * 3_600_000),
+ title: `Headline ${index}`,
+ content: `Content ${index}`,
+ metadata: { source: 'feed-a', categories: 'markets' },
+ ...fields,
+ }
+}
+
+function routes(getNewsV2: INewsProvider['getNewsV2']) {
+ return createNewsRoutes({ newsProvider: { getNewsV2 } } as unknown as EngineContext)
+}
+
+describe('news routes', () => {
+ it('uses explicit timestamps instead of lookback and returns a nullable lookback', async () => {
+ const getNewsV2 = vi.fn(async () => [item(1)])
+ const app = routes(getNewsV2)
+ const res = await app.request('/?startTime=2026-02-27T01:00:00.000Z&endTime=2026-02-27T05:00:00.000Z&lookback=bad')
+
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.lookback).toBeNull()
+ expect(getNewsV2).toHaveBeenCalledWith({
+ startTime: new Date('2026-02-27T01:00:00.000Z'),
+ endTime: new Date('2026-02-27T05:00:00.000Z'),
+ limit: 50,
+ })
+ })
+
+ it.each([
+ ['/?startTime=not-a-date', 'startTime'],
+ ['/?endTime=not-a-date', 'endTime'],
+ ['/?startTime=2026-02-27T05:00:00.000Z&endTime=2026-02-27T01:00:00.000Z', 'startTime'],
+ ])('rejects invalid timestamp query (%s)', async (path) => {
+ const res = await routes(vi.fn(async () => [])).request(path)
+ expect(res.status).toBe(400)
+ })
+
+ it('applies literal keyword and symbol filters before the result limit', async () => {
+ const getNewsV2 = vi.fn(async (options: GetNewsV2Options) => {
+ expect(options.limit).toBeUndefined()
+ return [
+ item(1, { title: 'Older target report', content: 'alpha' }),
+ item(2, { title: 'Noise', content: 'target' }),
+ item(3, { title: 'Target headline', content: 'AAPL outlook', metadata: { source: 'feed-a', categories: 'markets,us' } }),
+ item(4, { title: 'Newest target', content: 'AAPL update', metadata: { source: 'feed-a', categories: 'markets' } }),
+ ]
+ })
+ const res = await routes(getNewsV2).request('/?keyword=TARGET&symbol=aapl&limit=1')
+
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.count).toBe(1)
+ expect(body.items[0].title).toBe('Newest target')
+ })
+
+ it('filters sources before limiting and only returns safe image URLs', async () => {
+ const getNewsV2 = vi.fn(async () => [
+ item(1, { metadata: { source: 'other', image: 'https://example.com/old.jpg' } }),
+ item(2, { metadata: { source: 'feed-a', image: 'javascript:alert(1)' } }),
+ item(3, { metadata: { source: 'feed-a', image: 'https://example.com/news.png' } }),
+ ])
+ const res = await routes(getNewsV2).request('/?source=FEED-A&limit=1')
+
+ const body = await res.json()
+ expect(body.items).toHaveLength(1)
+ expect(body.items[0].title).toBe('Headline 3')
+ expect(body.items[0].image).toBe('https://example.com/news.png')
+ })
+
+ it('normalizes limits and orders filtered results newest-last', async () => {
+ const getNewsV2 = vi.fn(async () => [item(4), item(2), item(3)])
+ const res = await routes(getNewsV2).request('/?keyword=headline&limit=1.9')
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).items.map((entry: { title: string }) => entry.title)).toEqual(['Headline 4'])
+ expect(getNewsV2).toHaveBeenCalledWith({ endTime: expect.any(Date), lookback: '24h' })
+ })
+
+ it('treats an empty source list as no source filter', async () => {
+ const getNewsV2 = vi.fn(async () => [item(1), item(2)])
+ const res = await routes(getNewsV2).request('/?source=,%20,')
+
+ expect(res.status).toBe(200)
+ expect((await res.json()).count).toBe(2)
+ expect(getNewsV2).toHaveBeenCalledWith({ endTime: expect.any(Date), lookback: '24h', limit: 50 })
+ })
+})
diff --git a/src/webui/routes/news.ts b/src/webui/routes/news.ts
index f0dbda0f5..60d9b8a92 100644
--- a/src/webui/routes/news.ts
+++ b/src/webui/routes/news.ts
@@ -1,6 +1,7 @@
import { Hono } from 'hono'
-import type { EngineContext } from '../../core/types.js'
+import type { EngineContext } from '../../core/types.js'
+import type { GetNewsV2Options, NewsItem } from '../../domain/news/types.js'
const VALID_LOOKBACKS = new Set(['1h', '2h', '12h', '24h', '1d', '2d', '7d', '30d'])
const DEFAULT_LOOKBACK = '24h'
const DEFAULT_LIMIT = 50
@@ -15,37 +16,45 @@ export function createNewsRoutes(ctx: EngineContext) {
return c.json({ error: 'News provider not available' }, 503)
}
+ const startRaw = c.req.query('startTime')
+ const endRaw = c.req.query('endTime')
+ const startTime = parseQueryTime(startRaw)
+ const parsedEndTime = parseQueryTime(endRaw)
+ const endTime = parsedEndTime ?? new Date()
+ if (startRaw !== undefined && !startTime) {
+ return c.json({ error: 'Invalid startTime; expected an ISO timestamp' }, 400)
+ }
+ if (endRaw !== undefined && !parsedEndTime) {
+ return c.json({ error: 'Invalid endTime; expected an ISO timestamp' }, 400)
+ }
+ if (startTime && startTime.getTime() >= endTime.getTime()) {
+ return c.json({ error: 'startTime must be before endTime' }, 400)
+ }
+
const lookback = c.req.query('lookback') || DEFAULT_LOOKBACK
- if (!VALID_LOOKBACKS.has(lookback)) {
+ if (!startTime && !VALID_LOOKBACKS.has(lookback)) {
return c.json({
error: `Invalid lookback "${lookback}". Valid: ${[...VALID_LOOKBACKS].join(', ')}`,
}, 400)
}
- const rawLimit = Number(c.req.query('limit')) || DEFAULT_LIMIT
- const limit = Math.min(Math.max(1, rawLimit), MAX_LIMIT)
-
- const sourceFilter = c.req.query('source') || undefined
- const sources = sourceFilter
- ? new Set(sourceFilter.split(',').map((s) => s.trim().toLowerCase()))
- : undefined
-
- let items = await ctx.newsProvider.getNewsV2({
- endTime: new Date(),
- lookback,
- limit: sources ? undefined : limit,
- })
-
- if (sources) {
- items = items.filter((item) => {
- const src = item.metadata.source?.toLowerCase()
- return src != null && sources.has(src)
- })
+ const limit = parseLimit(c.req.query('limit'))
+ const sourceFilter = c.req.query('source')
+ const sourceValues = sourceFilter?.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean) ?? []
+ const sources = sourceValues.length > 0 ? new Set(sourceValues) : undefined
+ const keyword = normalizedTerm(c.req.query('keyword'))
+ const symbol = normalizedTerm(c.req.query('symbol'))
+ const hasFilters = Boolean(sources || keyword || symbol)
+ const options: GetNewsV2Options = {
+ endTime,
+ ...(startTime ? { startTime } : { lookback }),
+ ...(hasFilters ? {} : { limit }),
}
- if (items.length > limit) {
- items = items.slice(-limit)
- }
+ let items = await ctx.newsProvider.getNewsV2(options)
+ items = items.filter((item) => matchesNewsFilters(item, sources, keyword, symbol))
+ items.sort(compareNewsItems)
+ if (items.length > limit) items = items.slice(-limit)
const shaped = items.map((item) => ({
time: item.time.toISOString(),
@@ -54,10 +63,66 @@ export function createNewsRoutes(ctx: EngineContext) {
source: item.metadata.source ?? null,
link: item.metadata.link ?? null,
categories: item.metadata.categories ?? null,
+ image: safeHttpImageUrl(item.metadata.image),
}))
- return c.json({ items: shaped, count: shaped.length, lookback })
+ return c.json({ items: shaped, count: shaped.length, lookback: startTime ? null : lookback })
})
return app
}
+
+function parseQueryTime(raw: string | undefined): Date | undefined {
+ if (raw === undefined || raw.trim() === '') return undefined
+ const value = new Date(raw)
+ return Number.isNaN(value.getTime()) ? undefined : value
+}
+
+function parseLimit(raw: string | undefined): number {
+ if (raw === undefined || raw.trim() === '') return DEFAULT_LIMIT
+ const parsed = Number(raw)
+ if (!Number.isFinite(parsed)) return DEFAULT_LIMIT
+ return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(parsed)))
+}
+
+function normalizedTerm(value: string | undefined): string | undefined {
+ const term = value?.trim().toLowerCase()
+ return term || undefined
+}
+
+function matchesNewsFilters(
+ item: NewsItem,
+ sources: Set | undefined,
+ keyword: string | undefined,
+ symbol: string | undefined,
+): boolean {
+ if (sources) {
+ const source = item.metadata.source?.toLowerCase()
+ if (!source || !sources.has(source)) return false
+ }
+
+ const searchable = [
+ item.title,
+ item.content,
+ item.metadata.categories ?? '',
+ ].join('\n').toLowerCase()
+ return (!keyword || searchable.includes(keyword)) && (!symbol || searchable.includes(symbol))
+}
+
+function compareNewsItems(a: NewsItem, b: NewsItem): number {
+ const timeDelta = a.time.getTime() - b.time.getTime()
+ if (Number.isFinite(timeDelta) && timeDelta !== 0) return timeDelta
+ if (a.id !== b.id) return a.id - b.id
+ return a.title < b.title ? -1 : a.title > b.title ? 1 : 0
+}
+
+function safeHttpImageUrl(raw: string | null | undefined): string | null {
+ if (!raw) return null
+ try {
+ const url = new URL(raw.trim())
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) return null
+ return raw.trim()
+ } catch {
+ return null
+ }
+}
diff --git a/ui/public/market/flags/LICENSE b/ui/public/market/flags/LICENSE
new file mode 100644
index 000000000..152dda1d6
--- /dev/null
+++ b/ui/public/market/flags/LICENSE
@@ -0,0 +1,24 @@
+Circle Flags — https://github.com/HatScripts/circle-flags
+Source: https://github.com/HatScripts/circle-flags/tree/gh-pages/flags
+
+MIT License
+
+Copyright (c) 2026 HatScripts
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/ui/public/market/flags/cn.svg b/ui/public/market/flags/cn.svg
new file mode 100644
index 000000000..a61d24ca4
--- /dev/null
+++ b/ui/public/market/flags/cn.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/public/market/flags/hk.svg b/ui/public/market/flags/hk.svg
new file mode 100644
index 000000000..7b0510111
--- /dev/null
+++ b/ui/public/market/flags/hk.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/public/market/flags/us.svg b/ui/public/market/flags/us.svg
new file mode 100644
index 000000000..e3560c6a5
--- /dev/null
+++ b/ui/public/market/flags/us.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/src/api/news.ts b/ui/src/api/news.ts
index 0e9e98fbd..10cf10771 100644
--- a/ui/src/api/news.ts
+++ b/ui/src/api/news.ts
@@ -1,12 +1,27 @@
import { fetchJson } from './client'
import type { NewsListResponse } from './types'
+export interface NewsQuery {
+ lookback?: string
+ limit?: number
+ source?: string
+ startTime?: string
+ endTime?: string
+ keyword?: string
+ symbol?: string
+}
+
export const newsApi = {
- async list(params?: { lookback?: string; limit?: number; source?: string }): Promise {
+ async list(params?: NewsQuery, signal?: AbortSignal): Promise {
const qs = new URLSearchParams()
if (params?.lookback) qs.set('lookback', params.lookback)
- if (params?.limit) qs.set('limit', String(params.limit))
+ if (params?.limit != null) qs.set('limit', String(params.limit))
if (params?.source) qs.set('source', params.source)
- return fetchJson(`/api/news?${qs}`)
+ if (params?.startTime) qs.set('startTime', params.startTime)
+ if (params?.endTime) qs.set('endTime', params.endTime)
+ if (params?.keyword) qs.set('keyword', params.keyword)
+ if (params?.symbol) qs.set('symbol', params.symbol)
+ const query = qs.toString()
+ return fetchJson(`/api/news${query ? `?${query}` : ''}`, signal ? { signal } : undefined)
},
}
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index 9b6bb7fd4..bdad33a28 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -258,14 +258,16 @@ export interface NewsArticle {
source: string | null
link: string | null
categories: string | null
+ image?: string | null
}
export interface NewsListResponse {
items: NewsArticle[]
count: number
- lookback: string
+ lookback: string | null
}
+
// ==================== Trading ====================
export type BrokerHealth = 'healthy' | 'degraded' | 'offline'
diff --git a/ui/src/components/MarketSidebar.spec.tsx b/ui/src/components/MarketSidebar.spec.tsx
index 65b4daffb..1027a1510 100644
--- a/ui/src/components/MarketSidebar.spec.tsx
+++ b/ui/src/components/MarketSidebar.spec.tsx
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
+import { MemoryRouter, useLocation } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BarSourceCandidate } from '../api/market'
@@ -53,32 +54,30 @@ beforeEach(async () => {
})
afterEach(cleanup)
+function renderSidebar() {
+ return render()
+}
describe('MarketSidebar search keyboard controls', () => {
- it('groups peer destinations by purpose without nesting boards under News', () => {
- render()
- const markets = screen.getByRole('group', { name: 'Markets' })
- const macro = screen.getByRole('group', { name: 'Macro' })
- const news = screen.getByRole('button', { name: 'News' })
- expect(news.closest('[role="group"]')).toBeNull()
- expect(screen.getAllByRole('button')[0]).toBe(news)
- expect(within(markets).getAllByRole('button').map((button) => button.textContent)).toEqual([
- 'Browse Markets', 'Movers', 'Sector Rotation', 'Term Structure',
- ])
- expect(within(macro).getAllByRole('button').map((button) => button.textContent)).toEqual([
- 'Calendar', 'Macro', 'Global Macro', 'Fed', 'Shipping',
- ])
- for (const group of [markets, macro]) {
- expect(group.className).not.toContain('border-l')
- for (const button of within(group).getAllByRole('button')) fireEvent.click(button)
+ it('selects grouped news categories while preserving the view URL param', () => {
+ useWorkspace.getState().openOrFocus({ kind: 'news', params: {} })
+ function Location() {
+ const location = useLocation()
+ return
}
- expect(getFocusedTab(useWorkspace.getState())?.spec).toEqual({
- kind: 'market-board', params: { board: 'shipping' },
- })
+ render()
+ const categories = screen.getByRole('navigation', { name: 'News categories' })
+ expect(within(categories).queryByRole('button', { name: 'US Stocks' })).toBeNull()
+ expect(within(categories).queryByRole('button', { name: 'Markets' })).toBeNull()
+ fireEvent.click(within(categories).getByRole('button', { name: 'Equity markets' }))
+ fireEvent.click(within(categories).getByRole('button', { name: 'US Stocks' }))
+ expect(screen.getByLabelText('Current route').textContent).toBe('/market/news?view=important&category=us')
+ fireEvent.click(within(categories).getByRole('button', { name: 'All news' }))
+ expect(screen.getByLabelText('Current route').textContent).toBe('/market/news?view=important')
})
it('opens the first exact provider when Enter is pressed', () => {
- render()
+ renderSidebar()
const search = screen.getByRole('textbox', { name: 'Search assets…' })
fireEvent.change(search, { target: { value: 'apple' } })
@@ -95,7 +94,7 @@ describe('MarketSidebar search keyboard controls', () => {
})
it('moves the provider highlight with arrow keys before selecting', () => {
- const view = render()
+ const view = renderSidebar()
const search = screen.getByRole('textbox', { name: 'Search assets…' })
fireEvent.change(search, { target: { value: 'apple' } })
@@ -116,7 +115,7 @@ describe('MarketSidebar search keyboard controls', () => {
})
it('clears an inline search with Escape', () => {
- render()
+ renderSidebar()
const search = screen.getByRole('textbox', { name: 'Search assets…' })
fireEvent.change(search, { target: { value: 'apple' } })
@@ -130,7 +129,7 @@ describe('MarketSidebar search keyboard controls', () => {
useWatchlist.setState({
entries: [{ assetClass: 'equity', symbol: 'AAPL', addedAt: 1 }],
})
- render()
+ renderSidebar()
const remove = screen.getByRole('button', { name: 'Remove AAPL' })
expect(remove.className).not.toContain('opacity-0')
@@ -141,14 +140,32 @@ describe('MarketSidebar search keyboard controls', () => {
expect(getFocusedTab(useWorkspace.getState())).toBeNull()
})
- it('opens News as a Market browse leaf', () => {
- render()
-
+ it('opens News through its child without making the group heading navigate', () => {
+ function Location() {
+ const location = useLocation()
+ return
+ }
+ render()
fireEvent.click(screen.getByRole('button', { name: 'News' }))
+ expect(screen.getByLabelText('Current route').textContent).toBe('/market')
+ fireEvent.click(screen.getByRole('button', { name: 'All news' }))
+ expect(screen.getByLabelText('Current route').textContent).toBe('/market/news')
+ })
- expect(getFocusedTab(useWorkspace.getState())?.spec).toEqual({
- kind: 'news',
- params: {},
- })
+ it('folds each market directory independently without changing the active view', () => {
+ useWorkspace.getState().openOrFocus({ kind: 'news', params: {} })
+ renderSidebar()
+ const active = getFocusedTab(useWorkspace.getState())
+ for (const label of ['News', 'Markets', 'Macro', 'Watchlist']) {
+ const group = screen.getByRole('group', { name: label })
+ const heading = within(group).getAllByRole('button')[0]
+ expect(heading.getAttribute('aria-expanded')).toBe('true')
+ fireEvent.click(heading)
+ expect(heading.getAttribute('aria-expanded')).toBe('false')
+ expect(within(group).getAllByRole('button')).toEqual([heading])
+ expect(getFocusedTab(useWorkspace.getState())).toBe(active)
+ fireEvent.keyDown(heading, { key: 'Enter' })
+ expect(heading.getAttribute('aria-expanded')).toBe('true')
+ }
})
})
diff --git a/ui/src/components/MarketSidebar.tsx b/ui/src/components/MarketSidebar.tsx
index 7f88e36f8..fbebb8ea6 100644
--- a/ui/src/components/MarketSidebar.tsx
+++ b/ui/src/components/MarketSidebar.tsx
@@ -1,6 +1,7 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useId, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
-import { X } from 'lucide-react'
+import { useNavigate, useSearchParams } from 'react-router-dom'
+import { ChevronRight, X } from 'lucide-react'
import { type AssetClass, type BarSourceCandidate } from '../api/market'
import { useAssetSearch } from './market/useAssetSearch'
import { useWorkspace } from '../tabs/store'
@@ -11,6 +12,7 @@ import { SidebarSectionHeader } from './SidebarSectionHeader'
import { Spinner } from './StateViews'
import { Button } from './ui/button'
import { inputClass } from './form'
+import { NewsMarketNavigation } from './market/NewsMarketNavigation.js'
const ASSET_CLASS_COLORS: Record = {
equity: 'bg-primary/15 text-primary',
@@ -40,8 +42,10 @@ function routeAssetClass(c: BarSourceCandidate['assetClass']): AssetClass {
*
* Search results are debounced 300ms.
*/
-export function MarketSidebar() {
+export function MarketSidebar({ onNavigate }: { onNavigate?: () => void }) {
const { t } = useTranslation()
+ const navigate = useNavigate()
+ const [searchParams] = useSearchParams()
const [query, setQuery] = useState('')
// Shared with the main search box — one search logic, no drift.
const { results, loading } = useAssetSearch(query)
@@ -53,7 +57,11 @@ export function MarketSidebar() {
const watchlist = useWatchlist((s) => s.entries)
const removeFromWatchlist = useWatchlist((s) => s.remove)
- const openOrFocus = useWorkspace((s) => s.openOrFocus)
+ const openTab = useWorkspace((s) => s.openOrFocus)
+ const openOrFocus = (spec: ViewSpec) => {
+ openTab(spec)
+ onNavigate?.()
+ }
const focusedSpec = useWorkspace((state) => getFocusedTab(state)?.spec)
const isFocused = (kind: ViewSpec['kind']) => focusedSpec?.kind === kind
@@ -105,13 +113,16 @@ export function MarketSidebar() {