diff --git a/apps/electron/src/renderer/styles/index.css b/apps/electron/src/renderer/styles/index.css index 2342b70..889e6da 100644 --- a/apps/electron/src/renderer/styles/index.css +++ b/apps/electron/src/renderer/styles/index.css @@ -875,7 +875,7 @@ button:disabled { cursor: not-allowed; } .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 { min-height: 48px; overflow-y: auto; } .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: ""; } @@ -884,16 +884,39 @@ button:disabled { cursor: not-allowed; } .trail-sidebar-nav-item:hover { background: var(--surface-muted); color: var(--foreground); } .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-section-label { color: var(--foreground-muted); font-size: .75rem; font-weight: 600; } +.trail-sidebar-context-heading { flex-shrink: 0; min-height: 2rem; color: var(--foreground); font-size: .8125rem; font-weight: 600; } .trail-sidebar-context-heading:hover { color: var(--foreground); } -.trail-sidebar-context { display: flex; flex: 0 0 auto; flex-direction: column; height: clamp(13rem, 30vh, 20rem); overflow: hidden; } +.trail-sidebar-brand, .trail-sidebar-new-analysis, .trail-sidebar-footer { flex-shrink: 0; } +.trail-sidebar-panels { display: flex; flex: 1; min-height: 0; flex-direction: column; overflow: hidden; } +.trail-sidebar-upper { display: flex; flex: 1; min-height: 0; flex-direction: column; overflow: hidden; } +.trail-sidebar-context { display: flex; flex: 0 1 auto; min-height: 0; max-height: calc(100% - 56px); flex-direction: column; margin-top: .5rem; padding-top: .5rem; overflow: hidden; } .trail-sidebar-context > div:first-child { flex-shrink: 0; } -.trail-sidebar-sessions { max-height: 3.5rem; flex-shrink: 0; } +.trail-sidebar-sessions { min-height: 0; max-height: 5.5rem; overflow-y: auto; } +.trail-sidebar-watchlist-panel { display: flex; flex: 0 0 auto; min-height: 0; flex-direction: column; overflow: hidden; } +.trail-sidebar-watchlist-count { margin-left: auto; padding: .1rem .4rem; border-radius: 4px; background: var(--surface-muted); color: var(--foreground-muted); font-size: .7rem; } +.trail-sidebar-watchlist .trail-watchlist { min-height: 0; } +.trail-sidebar-watchlist .trail-watchlist-list { min-height: 0; overscroll-behavior-y: contain; } .trail-sidebar-watchlist .trail-watchlist-header { flex-shrink: 0; } -.trail-sidebar-context-heading { flex-shrink: 0; } +.trail-sidebar-resize-handle { position: relative; display: flex; height: 12px; flex: 0 0 12px; align-items: center; justify-content: center; cursor: row-resize; touch-action: none; user-select: none; outline: none; } +.trail-sidebar-resize-handle::before { position: absolute; left: 0; right: 0; height: 1px; background: var(--border); content: ''; } +.trail-sidebar-resize-handle > span { z-index: 1; width: 30px; height: 4px; border-radius: 999px; background: var(--foreground-subtle); box-shadow: 0 0 0 4px var(--surface); } +.trail-sidebar-resize-handle:hover > span, .trail-sidebar-resize-handle:focus-visible > span, .trail-sidebar-panels--resizing .trail-sidebar-resize-handle > span { background: var(--accent); } +.trail-sidebar-resize-handle:focus-visible { border-radius: 4px; box-shadow: inset 0 0 0 2px var(--accent); } +.trail-sidebar-panels--resizing, .trail-sidebar-panels--resizing * { cursor: row-resize !important; user-select: none; } .trail-sidebar-settings-panel { left: 12px; top: auto; bottom: 74px; } .trail-sidebar-support { min-height: 2rem; } +@media (max-height: 700px) and (min-width: 900px) { + .trail-sidebar-content { padding-top: .75rem; padding-bottom: .75rem; } + .trail-sidebar-brand { gap: .75rem; padding-bottom: .5rem; margin-bottom: .75rem; } + .trail-sidebar-brand .trail-logo { width: 3.5rem; height: 3.5rem; } + .trail-sidebar-brand-name { font-size: 1.3rem; } + .trail-sidebar-brand-chinese { margin-top: .1rem; font-size: 1rem; } + .trail-sidebar-brand-kicker { margin-top: .2rem; font-size: .75rem; } + .trail-sidebar-new-analysis { margin-bottom: .75rem; } + .trail-sidebar-footer { padding-top: .5rem; } + .trail-sidebar-support { min-height: 1.75rem; padding-block: .25rem; } +} .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; } } @@ -947,9 +970,12 @@ button:disabled { cursor: not-allowed; } .trail-sidebar-nav-label, .trail-sidebar-new-analysis span, .trail-sidebar-support, - .trail-sidebar-context { display: none; } + .trail-sidebar-context, + .trail-sidebar-resize-handle, + .trail-sidebar-watchlist-panel { display: none; } .trail-sidebar-new-analysis { width: 2.25rem; margin: 0 0 1rem; padding: 0; } .trail-sidebar-nav { width: 100%; flex: 1; } + .trail-sidebar-panels, .trail-sidebar-upper { width: 100%; } .trail-sidebar-nav-item { justify-content: center; padding: .55rem 0; } .trail-sidebar-alert-dot { position: absolute; top: .35rem; right: .35rem; margin: 0; } .trail-sidebar-footer { width: 100%; } diff --git a/docs/screenshots/sidebar-watchlist-expanded.png b/docs/screenshots/sidebar-watchlist-expanded.png new file mode 100644 index 0000000..1c8f427 Binary files /dev/null and b/docs/screenshots/sidebar-watchlist-expanded.png differ diff --git a/docs/screenshots/sidebar-watchlist-short-window.png b/docs/screenshots/sidebar-watchlist-short-window.png new file mode 100644 index 0000000..d0dcfd6 Binary files /dev/null and b/docs/screenshots/sidebar-watchlist-short-window.png differ diff --git a/packages/i18n/src/locales/en-US/navigation.ts b/packages/i18n/src/locales/en-US/navigation.ts index 7c0a625..ab17a66 100644 --- a/packages/i18n/src/locales/en-US/navigation.ts +++ b/packages/i18n/src/locales/en-US/navigation.ts @@ -39,6 +39,8 @@ export const navigation = { news: 'News', reports: 'Reports', watchlist: 'Watchlist', + resizeWatchlist: 'Resize watchlist height', + resizeWatchlistHint: 'Drag up or down to resize; double-click to reset. Use arrow keys to resize or Home to reset.', sessions: 'Sessions', newSession: 'New Session', noSessions: 'No sessions yet', diff --git a/packages/i18n/src/locales/zh-CN/navigation.ts b/packages/i18n/src/locales/zh-CN/navigation.ts index 18ec8fe..6f01258 100644 --- a/packages/i18n/src/locales/zh-CN/navigation.ts +++ b/packages/i18n/src/locales/zh-CN/navigation.ts @@ -42,6 +42,8 @@ export const navigation = { news: '新闻', reports: '报告', watchlist: '自选', + resizeWatchlist: '调整自选区域高度', + resizeWatchlistHint: '上下拖动调整自选区域高度;双击恢复默认。也可使用上下方向键,Home 恢复默认。', sessions: '会话', newSession: '新建会话', noSessions: '暂无会话', diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index 76b64fe..56ed3cd 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -27,11 +27,13 @@ import { createSessionAtom, deleteSessionAtom, navSectionAtom, + watchlistAtom, type NavSection, } from '../../atoms'; import { useFinagentClient } from '../../client'; import { Watchlist } from '../stock/Watchlist'; import { TrailLogo } from '../brand/TrailIdentity'; +import { SidebarWorkspaceSplit } from './SidebarWorkspaceSplit'; type SidebarItem = { key: NavSection; labelKey: string; icon: LucideIcon }; @@ -79,6 +81,7 @@ export const Sidebar: React.FC = () => { const createSession = useSetAtom(createSessionAtom); const deleteSession = useSetAtom(deleteSessionAtom); const [navSection, setNavSection] = useAtom(navSectionAtom); + const [watchlist] = useAtom(watchlistAtom); const showWorkspaceContext = navSection === 'sessions' || navSection === 'watchlist'; return ( @@ -103,61 +106,70 @@ export const Sidebar: React.FC = () => { {t('navigation.newSession')} - + + - {showWorkspaceContext && ( -
-
- {t('navigation.sessions')} - {sessions.length} -
-
- {sessions.map((session) => ( -
- - -
- ))} - {sessions.length === 0 &&
{t('navigation.noSessions')}
} -
- -
- -
-
- )} + {showWorkspaceContext && ( +
+
+ {t('navigation.sessions')} + {sessions.length} +
+
+ {sessions.map((session) => ( +
+ + +
+ ))} + {sessions.length === 0 &&
{t('navigation.noSessions')}
} +
+
+ )} + + } + > + +
+ +
+
void; +let root: Root | undefined; +let container: HTMLDivElement; +let measuredHeight = 612; +let resize: () => void; +let disconnects = 0; +const originalObserver = Object.getOwnPropertyDescriptor(globalThis, 'ResizeObserver'); + +beforeAll(() => { + restore = installHappyDom().restore; + Object.defineProperty(globalThis, 'ResizeObserver', { configurable: true, value: class { + constructor(callback: () => void) { resize = callback; } + observe() {} + disconnect() { disconnects++; } + } }); + HTMLElement.prototype.getBoundingClientRect = () => ({ height: measuredHeight } as DOMRect); + HTMLElement.prototype.setPointerCapture = () => {}; + HTMLElement.prototype.releasePointerCapture = () => {}; +}); +beforeEach(() => { + window.localStorage.clear(); + measuredHeight = 612; + disconnects = 0; + container = document.createElement('div'); +}); +afterEach(async () => { await act(async () => root?.unmount()); root = undefined; }); +afterAll(() => { + if (originalObserver) Object.defineProperty(globalThis, 'ResizeObserver', originalObserver); + else Reflect.deleteProperty(globalThis, 'ResizeObserver'); + restore(); +}); + +async function render(enabled = true) { + root ??= createRoot(container); + await act(async () => root!.render( + + Navigation}> +
Watchlist
+
+
+ )); +} +function handle() { return container.querySelector('[role="separator"]') as HTMLDivElement; } +function height() { return Number(handle().getAttribute('aria-valuenow')); } +async function key(value: string) { + await act(async () => handle().dispatchEvent(new KeyboardEvent('keydown', { key: value, bubbles: true }))); +} +async function pointer(type: string, y: number) { + await act(async () => handle().dispatchEvent(new PointerEvent(type, { + pointerId: 1, isPrimary: true, button: 0, clientY: y, bubbles: true, + }))); +} + +describe('Sidebar watchlist resizing', () => { + it('saves a completed upward/downward drag, but never persists intermediate moves', async () => { + await render(); + expect(height()).toBe(340); + await pointer('pointerdown', 400); + await pointer('pointermove', 300); + expect(height()).toBe(440); + expect(window.localStorage.getItem(KEY)).toBeNull(); + await pointer('pointerup', 300); + await pointer('lostpointercapture', 300); + expect(JSON.parse(window.localStorage.getItem(KEY)!)).toBe(440); + await pointer('pointerdown', 300); + await pointer('pointermove', 380); + await pointer('pointerup', 380); + expect(height()).toBe(360); + expect(JSON.parse(window.localStorage.getItem(KEY)!)).toBe(360); + await act(async () => root!.unmount()); + root = undefined; + await render(); + expect(height()).toBe(360); + }); + + it('cancels interrupted drags and restores the prior preference', async () => { + window.localStorage.setItem(KEY, '340'); + await render(); + await pointer('pointerdown', 400); + await pointer('pointermove', 250); + await key('Escape'); + expect(height()).toBe(340); + expect(window.localStorage.getItem(KEY)).toBe('340'); + await pointer('pointerdown', 400); + await pointer('pointermove', 500); + await pointer('pointercancel', 500); + expect(height()).toBe(340); + }); + + it('supports keyboard bounds and resets, and double-click returns to the responsive default', async () => { + await render(); + await key('ArrowUp'); + expect(height()).toBe(356); + await key('ArrowDown'); + expect(height()).toBe(340); + await key('End'); + expect(height()).toBe(480); + await key('ArrowUp'); + expect(height()).toBe(480); + await key('Home'); + expect(height()).toBe(340); + expect(window.localStorage.getItem(KEY)).toBe('null'); + await pointer('pointerdown', 400); + await pointer('pointermove', 1000); + await pointer('pointerup', 1000); + expect(height()).toBe(180); + await act(async () => handle().dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))); + expect(height()).toBe(340); + }); + + it('clamps short-window layouts without overwriting the saved desktop height', async () => { + window.localStorage.setItem(KEY, '450'); + await render(); + expect(height()).toBe(450); + measuredHeight = 212; + await act(async () => resize()); + expect(height()).toBe(120); + expect(window.localStorage.getItem(KEY)).toBe('450'); + measuredHeight = 612; + await act(async () => resize()); + expect(height()).toBe(450); + await render(false); + expect(handle()).toBeNull(); + expect(container.textContent).toBe('Navigation'); + expect(disconnects).toBe(1); + await render(); + expect(height()).toBe(450); + }); + + it.each(['{"bad":1}', '"320"', '-10', 'null', 'broken'])('ignores invalid persisted height %s', async (value) => { + window.localStorage.setItem(KEY, value); + await render(); + expect(height()).toBe(340); + }); +}); diff --git a/packages/ui/src/components/layout/SidebarWorkspaceSplit.tsx b/packages/ui/src/components/layout/SidebarWorkspaceSplit.tsx new file mode 100644 index 0000000..9a44366 --- /dev/null +++ b/packages/ui/src/components/layout/SidebarWorkspaceSplit.tsx @@ -0,0 +1,113 @@ +import React, { useId, useLayoutEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { readPersisted, writePersisted } from '../../lib/persistedPrefs'; + +const HEIGHT_KEY = 'sidebarWatchlistHeight'; +const HANDLE_HEIGHT = 12; + +/** A local layout preference: resizing never changes navigation or market state. */ +export const SidebarWorkspaceSplit: React.FC<{ + enabled: boolean; + upper: React.ReactNode; + children: React.ReactNode; +}> = ({ enabled, upper, children }) => { + const { t } = useTranslation(); + const panelId = useId(); + const containerRef = useRef(null); + const dragRef = useRef<{ + pointerId: number; y: number; height: number; latest: number; previous: number | null; + } | null>(null); + const [available, setAvailable] = useState(0); + const [preferred, setPreferred] = useState(() => { + const saved = readPersisted(HEIGHT_KEY, null); + return typeof saved === 'number' && Number.isFinite(saved) && saved > 0 ? saved : null; + }); + const [dragging, setDragging] = useState(false); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container || !enabled) return; + const measure = () => setAvailable(Math.max(0, container.getBoundingClientRect().height - HANDLE_HEIGHT)); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(container); + return () => observer.disconnect(); + }, [enabled]); + + // On short windows both panes yield space; the fixed footer stays outside. + const minimum = Math.min(180, available * 0.6); + const maximum = Math.max(minimum, available - Math.min(120, available * 0.4)); + const clamp = (value: number) => Math.min(maximum, Math.max(minimum, value)); + const defaultHeight = clamp(Math.max(340, available * 0.4)); + const height = clamp(preferred ?? defaultHeight); + + const save = (value: number | null) => { + setPreferred(value); + writePersisted(HEIGHT_KEY, value); + }; + const cancelDrag = () => { + if (dragRef.current) setPreferred(dragRef.current.previous); + dragRef.current = null; + setDragging(false); + }; + + return ( +
+
{upper}
+ {enabled && <> +
save(null)} + onPointerDown={(event) => { + if (event.button !== 0 || !event.isPrimary || available <= 0) return; + event.preventDefault(); + event.currentTarget.focus(); + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { pointerId: event.pointerId, y: event.clientY, height, latest: height, previous: preferred }; + setDragging(true); + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag || event.pointerId !== drag.pointerId) return; + drag.latest = clamp(drag.height + drag.y - event.clientY); + setPreferred(drag.latest); + }} + onPointerUp={(event) => { + const drag = dragRef.current; + if (!drag || event.pointerId !== drag.pointerId) return; + save(clamp(drag.latest)); + dragRef.current = null; + setDragging(false); + event.currentTarget.releasePointerCapture(event.pointerId); + }} + onPointerCancel={cancelDrag} + onLostPointerCapture={cancelDrag} + onKeyDown={(event) => { + if (event.key === 'Escape' && dragRef.current) { + const pointerId = dragRef.current.pointerId; + cancelDrag(); + event.currentTarget.releasePointerCapture(pointerId); + } else if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { + event.preventDefault(); + if (dragRef.current) return; + const step = event.shiftKey ? 48 : 16; + save(event.key === 'Home' ? null : event.key === 'End' ? maximum : clamp(height + (event.key === 'ArrowUp' ? step : -step))); + } + }} + >
+
+ {children} +
+ } +
+ ); +};