diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1f9edd6d..a56923a9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -13,9 +13,12 @@ import { useWorkspace } from './tabs/store' /** * Activity-bar pages — only items that appear as icons in the ActivityBar. * Each maps to one or more tab kinds via tabs/registry.ts (defaultSpecForActivity). + * + * Diary is intentionally absent — it lives under the Chat sidebar as a + * peer of Notifications (both are "Alice surfaces, not user actions"). */ export type Page = - | 'chat' | 'diary' | 'portfolio' | 'news' | 'automation' | 'market' + | 'chat' | 'portfolio' | 'news' | 'automation' | 'market' | 'trading-as-git' | 'settings' | 'dev' diff --git a/ui/src/components/ActivityBar.tsx b/ui/src/components/ActivityBar.tsx index d97832ed..fb3bb7b9 100644 --- a/ui/src/components/ActivityBar.tsx +++ b/ui/src/components/ActivityBar.tsx @@ -1,4 +1,4 @@ -import { type LucideIcon, MessageSquare, LineChart, GitBranch, BarChart3, Newspaper, Notebook, Zap, Settings, Code2 } from 'lucide-react' +import { type LucideIcon, MessageSquare, LineChart, GitBranch, BarChart3, Newspaper, Zap, Settings, Code2 } from 'lucide-react' import { type Page } from '../App' import { useWorkspace } from '../tabs/store' import type { ActivitySection, ViewSpec } from '../tabs/types' @@ -17,7 +17,6 @@ function activitySectionFor(page: Page): ActivitySection { case 'portfolio': return 'portfolio' case 'automation': return 'automation' case 'news': return 'news' - case 'diary': return 'diary' } } @@ -62,7 +61,6 @@ const NAV_SECTIONS: NavSection[] = [ { page: 'trading-as-git', label: 'Trading as Git', icon: GitBranch }, { page: 'market', label: 'Market', icon: BarChart3 }, { page: 'news', label: 'News', icon: Newspaper, defaultTab: { kind: 'news', params: {} } }, - { page: 'diary', label: 'Diary', icon: Notebook, defaultTab: { kind: 'diary', params: {} } }, ], }, { diff --git a/ui/src/components/ChatChannelListContainer.tsx b/ui/src/components/ChatChannelListContainer.tsx index 7d6348c2..5836c241 100644 --- a/ui/src/components/ChatChannelListContainer.tsx +++ b/ui/src/components/ChatChannelListContainer.tsx @@ -1,3 +1,4 @@ +import { Bell, Notebook } from 'lucide-react' import { useChannels } from '../contexts/ChannelsContext' import { useWorkspace } from '../tabs/store' import { getFocusedTab } from '../tabs/types' @@ -8,29 +9,38 @@ import { SidebarRow } from './SidebarRow' /** * Connects ChatChannelList to ChannelsContext + the workspace store. * - * Layout: a Notifications inbox row (with an unread badge) sits at the - * top of the chat sidebar — clicking it opens the notifications inbox - * as a tab. The user lives in this sidebar most of the time, so the - * red dot here is the primary "you have new system pushes" signal. + * Layout reflects the framing of "Chat" as the catch-all activity for + * interactions with Alice — not strictly chat: * - * Below: the channel list. Active channel is derived from the focused - * chat tab; when the focused tab isn't a chat tab, no row is highlighted. + * - Notifications (inbound system pushes; unread badge) + * - Diary (Alice's first-person output stream — read-only) + * ───── + * - Channels (the chat conversations the user opens) + * + * The two upper rows are "Alice surfaces"; the channel list is "user + * actions". They share this sidebar because the unifying mental model + * is "everything Alice-shaped" rather than "places to type messages". + * + * Active row tracking is derived from the focused tab — switching tabs + * naturally shifts the highlight without bespoke wiring. */ export function ChatChannelListContainer() { const { channels, openEditDialog, deleteChannel } = useChannels() const focused = useWorkspace((state) => getFocusedTab(state)?.spec) - const focusedChannelId = focused?.kind === 'chat' ? focused.params.channelId : '' - const inboxActive = focused?.kind === 'notifications-inbox' + const focusedKind = focused?.kind + const focusedChannelId = focusedKind === 'chat' ? focused.params.channelId : '' + const inboxActive = focusedKind === 'notifications-inbox' + const diaryActive = focusedKind === 'diary' const openOrFocus = useWorkspace((state) => state.openOrFocus) const unreadCount = useUnreadNotificationsCount() return (
-
+
- + Notifications } @@ -47,9 +57,22 @@ export function ChatChannelListContainer() { ) : undefined } /> + + + Diary + + } + active={diaryActive} + onClick={() => openOrFocus({ kind: 'diary', params: {} })} + />
-
+
+ Channels +
+
) } - -function BellIcon() { - return ( - - - - - ) -} diff --git a/ui/src/components/DiarySidebar.tsx b/ui/src/components/DiarySidebar.tsx deleted file mode 100644 index 68d3c487..00000000 --- a/ui/src/components/DiarySidebar.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useWorkspace } from '../tabs/store' -import { getFocusedTab } from '../tabs/types' -import { SidebarRow } from './SidebarRow' - -/** - * Diary sidebar — phase-2 placeholder. Single "All Entries" item that - * opens the existing DiaryPage as a tab. Phase 3+ replaces this with a - * date-organised navigator that opens per-day tabs. - */ -export function DiarySidebar() { - const focusedKind = useWorkspace((state) => getFocusedTab(state)?.spec.kind ?? null) - const openOrFocus = useWorkspace((state) => state.openOrFocus) - - return ( -
- openOrFocus({ kind: 'diary', params: {} })} - /> -
- ) -} diff --git a/ui/src/pages/DiaryPage.tsx b/ui/src/pages/DiaryPage.tsx index aac7fdc6..e8d5d109 100644 --- a/ui/src/pages/DiaryPage.tsx +++ b/ui/src/pages/DiaryPage.tsx @@ -107,7 +107,11 @@ interface CycleGroup { /** * Walk cycles ascending and attach each session item to the cycle whose timestamp - * window it falls into: (prev.ts, cycle.ts + slack]. + * window it falls into: (prev.ts, cycle.ts + slack]. The result is reversed + * before returning so the rendered feed reads newest-first — Diary is an + * observation surface (feed-shaped), not a chat (bottom-anchored conversation). + * Items inside a single cycle stay ascending: within one heartbeat tick the + * thought-then-tool-call order is the natural read. */ function groupItemsByCycle(items: ChatHistoryItem[], cycles: DiaryCycle[]): CycleGroup[] { const sorted = [...cycles].sort((a, b) => a.ts - b.ts) @@ -129,7 +133,7 @@ function groupItemsByCycle(items: ChatHistoryItem[], cycles: DiaryCycle[]): Cycl groups[cursor].items.push(item) } - return groups + return groups.reverse() } // ==================== Page ==================== @@ -144,9 +148,11 @@ export function DiaryPage() { const latestSeqRef = useRef(0) latestSeqRef.current = latestSeq - const messagesEndRef = useRef(null) const containerRef = useRef(null) - const userScrolledUp = useRef(false) + /** True once the user has scrolled away from the top — drives the + * "back to top" floating button. Top of list is newest, so the + * button is conceptually a "jump to newest", same UX as Twitter's + * "new tweets ↑". */ const [showScrollBtn, setShowScrollBtn] = useState(false) const fetchFull = useCallback(async () => { @@ -206,23 +212,13 @@ export function DiaryPage() { return () => clearInterval(id) }, [fetchDelta]) - // Auto-scroll to bottom on new content, unless user scrolled up - const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => { - if (!userScrolledUp.current) { - messagesEndRef.current?.scrollIntoView({ behavior }) - } - }, []) - useEffect(() => { scrollToBottom() }, [items, cycles, scrollToBottom]) - - // Track scroll position + // Track scroll position so we can offer a "back to top (newest)" + // floating button once the user has scrolled into history. useEffect(() => { const el = containerRef.current if (!el) return const onScroll = () => { - const { scrollTop, scrollHeight, clientHeight } = el - const isUp = scrollHeight - scrollTop - clientHeight > 80 - userScrolledUp.current = isUp - setShowScrollBtn(isUp) + setShowScrollBtn(el.scrollTop > 80) } el.addEventListener('scroll', onScroll) return () => el.removeEventListener('scroll', onScroll) @@ -235,10 +231,9 @@ export function DiaryPage() { fetchFull() }, [fetchFull]) - const handleScrollToBottom = useCallback(() => { - userScrolledUp.current = false + const handleScrollToTop = useCallback(() => { setShowScrollBtn(false) - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) }, []) // Render: walk groups and emit a date divider whenever the calendar day changes. @@ -320,19 +315,18 @@ export function DiaryPage() {
{node.render()}
))}
- -
{showScrollBtn && ( )}
diff --git a/ui/src/sections.tsx b/ui/src/sections.tsx index 1eec6179..45ea9740 100644 --- a/ui/src/sections.tsx +++ b/ui/src/sections.tsx @@ -21,7 +21,6 @@ import { MarketSidebar } from './components/MarketSidebar' import { PortfolioSidebar } from './components/PortfolioSidebar' import { AutomationSidebar } from './components/AutomationSidebar' import { NewsSidebar } from './components/NewsSidebar' -import { DiarySidebar } from './components/DiarySidebar' import type { ActivitySection } from './tabs/types' export interface SidebarSection { @@ -67,10 +66,6 @@ const SECTION_BY_KEY: Record = { title: 'News', Secondary: NewsSidebar, }, - diary: { - title: 'Diary', - Secondary: DiarySidebar, - }, } /** Resolve the sidebar config for the currently selected ActivitySection. */ diff --git a/ui/src/tabs/types.ts b/ui/src/tabs/types.ts index 299838f0..1d66f4ab 100644 --- a/ui/src/tabs/types.ts +++ b/ui/src/tabs/types.ts @@ -37,6 +37,11 @@ export type ViewKind = ViewSpec['kind'] * Note: trading-as-git has no associated tab kind — it's sidebar-only * (the approval queue lives in the sidebar; future commit-detail tabs will * be opened from there). + * + * Diary deliberately does NOT have its own activity section — it's a + * read-only Alice-output surface, conceptually grouped with Chat under + * "interactions with Alice" (chat sidebar carries an entry that opens + * the Diary tab). */ export type ActivitySection = | 'chat' @@ -47,7 +52,6 @@ export type ActivitySection = | 'portfolio' | 'automation' | 'news' - | 'diary' export interface Tab { id: string