Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
4 changes: 1 addition & 3 deletions ui/src/components/ActivityBar.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
}
}

Expand Down Expand Up @@ -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: {} } },
],
},
{
Expand Down
64 changes: 34 additions & 30 deletions ui/src/components/ChatChannelListContainer.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 (
<div className="flex flex-col h-full">
<div className="py-0.5">
<div className="py-0.5 space-y-0.5">
<SidebarRow
label={
<span className="flex items-center gap-2">
<BellIcon />
<Bell size={14} strokeWidth={1.8} className="shrink-0" />
<span>Notifications</span>
</span>
}
Expand All @@ -47,9 +57,22 @@ export function ChatChannelListContainer() {
) : undefined
}
/>
<SidebarRow
label={
<span className="flex items-center gap-2">
<Notebook size={14} strokeWidth={1.8} className="shrink-0" />
<span>Diary</span>
</span>
}
active={diaryActive}
onClick={() => openOrFocus({ kind: 'diary', params: {} })}
/>
</div>

<div className="flex-1 overflow-y-auto min-h-0 mt-1">
<div className="mt-2 px-3 text-[10px] font-medium text-text-muted/60 uppercase tracking-wider">
Channels
</div>
<div className="flex-1 overflow-y-auto min-h-0 mt-0.5">
<ChatChannelList
channels={channels}
activeChannel={focusedChannelId}
Expand All @@ -61,22 +84,3 @@ export function ChatChannelListContainer() {
</div>
)
}

function BellIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
)
}
23 changes: 0 additions & 23 deletions ui/src/components/DiarySidebar.tsx

This file was deleted.

48 changes: 21 additions & 27 deletions ui/src/pages/DiaryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -129,7 +133,7 @@ function groupItemsByCycle(items: ChatHistoryItem[], cycles: DiaryCycle[]): Cycl
groups[cursor].items.push(item)
}

return groups
return groups.reverse()
}

// ==================== Page ====================
Expand All @@ -144,9 +148,11 @@ export function DiaryPage() {
const latestSeqRef = useRef(0)
latestSeqRef.current = latestSeq

const messagesEndRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(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 () => {
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -320,19 +315,18 @@ export function DiaryPage() {
<div key={node.key}>{node.render()}</div>
))}
</div>

<div ref={messagesEndRef} />
</div>

{showScrollBtn && (
<button
onClick={handleScrollToBottom}
className="absolute bottom-6 left-1/2 -translate-x-1/2 w-10 h-10 rounded-full bg-bg-secondary border border-border text-text-muted hover:text-text hover:border-accent/50 flex items-center justify-center transition-all shadow-lg"
aria-label="Scroll to bottom"
onClick={handleScrollToTop}
className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full bg-bg-secondary border border-border text-[12px] text-text-muted hover:text-text hover:border-accent/50 flex items-center gap-1.5 transition-all shadow-lg"
aria-label="Jump to newest"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12l7 7 7-7" />
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
<span>Newest</span>
</button>
)}
</div>
Expand Down
5 changes: 0 additions & 5 deletions ui/src/sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -67,10 +66,6 @@ const SECTION_BY_KEY: Record<ActivitySection, SidebarSection> = {
title: 'News',
Secondary: NewsSidebar,
},
diary: {
title: 'Diary',
Secondary: DiarySidebar,
},
}

/** Resolve the sidebar config for the currently selected ActivitySection. */
Expand Down
6 changes: 5 additions & 1 deletion ui/src/tabs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -47,7 +52,6 @@ export type ActivitySection =
| 'portfolio'
| 'automation'
| 'news'
| 'diary'

export interface Tab {
id: string
Expand Down
Loading