From ff207b5b22920e32f300f454e90f82f8e0c66b22 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Mon, 19 Oct 2026 11:57:17 +0200 Subject: [PATCH] Sidebar: filter recent chats by who started them, yours by default --- .../src/components/layout/recent-chats.tsx | 149 ++++++++++++++++-- apps/desktop/src/stores/ui.ts | 8 + 2 files changed, 145 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/components/layout/recent-chats.tsx b/apps/desktop/src/components/layout/recent-chats.tsx index 704bf11b..735cb2db 100644 --- a/apps/desktop/src/components/layout/recent-chats.tsx +++ b/apps/desktop/src/components/layout/recent-chats.tsx @@ -2,9 +2,9 @@ import { Link, useLocation, useMatch, useNavigate } from "react-router"; import { useQuery } from "@tanstack/react-query"; import { differenceInCalendarDays, formatDistanceToNowStrict } from "date-fns"; import type { Agent, Conversation, Workspace } from "@godmode/shared"; -import { Fragment, type KeyboardEvent, useEffect, useRef, useState } from "react"; +import { Fragment, type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react"; import { motion } from "motion/react"; -import { AlarmClock, Archive, ChevronDown, CircleCheck, MessagesSquare, Pin, Trash2, Zap } from "lucide-react"; +import { AlarmClock, Archive, Bot, ChevronDown, CircleCheck, MessagesSquare, Pin, Trash2, Workflow, Zap } from "lucide-react"; import { SidebarGroup, SidebarGroupContent, @@ -24,7 +24,7 @@ import { useProjectIndex } from "@/components/projects/project-utils"; import { qk } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { useLive } from "@/stores/live"; -import { type RecentTab, useUi } from "@/stores/ui"; +import { type RecentSource, type RecentTab, useUi } from "@/stores/ui"; const PAGE = 25; @@ -72,8 +72,12 @@ export function RecentChats() { const recentTab = useUi((s) => s.recentTab); const setRecentTab = useUi((s) => s.setRecentTab); + const recentSource = useUi((s) => s.recentSource); + const setRecentSource = useUi((s) => s.setRecentSource); - const visible = conversations.filter((c) => !c.archived); + const unarchived = conversations.filter((c) => !c.archived); + const sources = sourceStats(unarchived); + const visible = unarchived.filter((c) => recentSource === "all" || chatSource(c) === recentSource); const bucketOf = (c: Conversation) => chatBucket(c, runningConversations.has(c.id) || queuedConversations.has(c.id)); const counts = { all: visible.length, running: 0, scheduled: 0, done: 0 }; let needsYou = 0; @@ -82,7 +86,7 @@ export function RecentChats() { if (c.paused?.reason === "question") needsYou++; } const [limit, setLimit] = useState(PAGE); - useEffect(() => setLimit(PAGE), [recentTab, scope]); + useEffect(() => setLimit(PAGE), [recentTab, recentSource, scope]); const matching = visible .filter((c) => recentTab === "all" || bucketOf(c) === recentTab) .sort((a, b) => @@ -96,7 +100,7 @@ export function RecentChats() { const dated = recentTab === "all" || recentTab === "done"; const dayOf = (c: Conversation) => (c.pinned ? "Pinned" : dayLabel(c.lastMessageAt ?? c.createdAt)); - if (visible.length === 0 && !hasArchived && !scopeName) return deleteDialog; + if (unarchived.length === 0 && !hasArchived && !scopeName) return deleteDialog; return ( @@ -112,10 +116,12 @@ export function RecentChats() { Mark all read )} - + + + - {items.length === 0 && } + {items.length === 0 && } {items.map((c, i) => { const day = dated && (i === 0 || dayOf(items[i - 1]!) !== dayOf(c)) ? dayOf(c) : null; const agent = agents.find((a) => a.id === c.agentId); @@ -275,6 +281,114 @@ function WorkspaceChip({ workspace, kind = "Workspace" }: { workspace: Pick; + +/** Mine: chats the human started, here or from Slack, Telegram or Teams. Automations: started by a trigger. Agents: an agent started it itself. */ +function chatSource(c: Conversation): ChatSource { + switch (c.origin) { + case "routine": + case "api": + return "automations"; + case "delegation": + case "task": + case "heartbeat": + case "dream": + return "agents"; + default: + return "mine"; + } +} + +type SourceStat = { unread: number; needsYou: number }; + +function sourceStats(chats: Conversation[]): Record { + const stats = Object.fromEntries(SOURCES.map((s) => [s.value, { unread: 0, needsYou: 0 }])) as Record; + for (const c of chats) { + for (const key of [chatSource(c), "all"] as const) { + if (c.unread) stats[key].unread++; + if (c.paused?.reason === "question") stats[key].needsYou++; + } + } + return stats; +} + +const SOURCES: { value: RecentSource; label: string; hint: string }[] = [ + { value: "mine", label: "Mine", hint: "Chats you started" }, + { value: "automations", label: "Automations", hint: "Started by an automation or the API" }, + { value: "agents", label: "Agents", hint: "Started by an agent: handovers, board tasks, heartbeats" }, + { value: "all", label: "All", hint: "Every chat" }, +]; + +function SourceTabs({ + value, + onChange, + stats, +}: { + value: RecentSource; + onChange: (source: RecentSource) => void; + stats: Record; +}) { + const refs = useRef<(HTMLButtonElement | null)[]>([]); + const onKeyDown = (e: KeyboardEvent) => { + const delta = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; + if (!delta) return; + e.preventDefault(); + const next = (SOURCES.findIndex((t) => t.value === value) + delta + SOURCES.length) % SOURCES.length; + onChange(SOURCES[next].value); + refs.current[next]?.focus(); + }; + + return ( +
+ {SOURCES.map((t, i) => { + const active = t.value === value; + const { unread, needsYou } = stats[t.value]; + const signal = !active && t.value !== "all" && (needsYou > 0 ? "needs" : unread > 0 ? "unread" : null); + return ( + + + + + + {t.hint} + {needsYou > 0 ? ` · ${needsYou} need${needsYou === 1 ? "s" : ""} you` : unread > 0 ? ` · ${unread} unread` : ""} + + + ); + })} +
+ ); +} + type ChatBucket = Exclude; /** Running: working, queued or standing still until someone acts. Scheduled: continues by itself later. Done: the rest. */ @@ -300,11 +414,13 @@ function RecentTabs({ onChange, counts, needsYou, + children, }: { value: RecentTab; onChange: (tab: RecentTab) => void; counts: Record; needsYou: number; + children?: ReactNode; }) { const refs = useRef<(HTMLButtonElement | null)[]>([]); const onKeyDown = (e: KeyboardEvent) => { @@ -318,6 +434,7 @@ function RecentTabs({ return (
+ {children}
{TABS.map((t, i) => { const active = t.value === value; @@ -388,11 +505,19 @@ const EMPTY: Record, { icon: typeof Zap; title: string; text: string }> = { + mine: { icon: MessagesSquare, title: "No chats from you yet", text: "Chats you start show up here. Automations and agents have their own tabs." }, + automations: { icon: Workflow, title: "No automation chats", text: "When an automation runs, its chat lands here." }, + agents: { icon: Bot, title: "No agent chats", text: "Handovers, board tasks and heartbeats agents start themselves land here." }, +}; + +function EmptyTab({ tab, source, scopeName, hasArchived }: { tab: RecentTab; source: RecentSource; scopeName: string | null; hasArchived: boolean }) { const { icon: Icon, title, text } = - tab === "all" && scopeName - ? { ...EMPTY.all, title: `No chats in ${scopeName} yet`, text: hasArchived ? "Its archived chats are below." : "New chats you start here land in this list." } - : EMPTY[tab]; + tab === "all" && source !== "all" + ? EMPTY_SOURCE[source] + : tab === "all" && scopeName + ? { ...EMPTY.all, title: `No chats in ${scopeName} yet`, text: hasArchived ? "Its archived chats are below." : "New chats you start here land in this list." } + : EMPTY[tab]; return (
  • diff --git a/apps/desktop/src/stores/ui.ts b/apps/desktop/src/stores/ui.ts index b56b3f22..04436aa5 100644 --- a/apps/desktop/src/stores/ui.ts +++ b/apps/desktop/src/stores/ui.ts @@ -6,6 +6,9 @@ export type AgentsView = "grid" | "list" | "chart"; export type RecentTab = "all" | "running" | "scheduled" | "done"; +/** Who started the chats the sidebar lists: the human, an automation or an agent on its own. */ +export type RecentSource = "mine" | "automations" | "agents" | "all"; + export type WorkspacesView = "grid" | "list"; export type WorkspacesSort = "name" | "active" | "attention"; @@ -38,6 +41,7 @@ interface UiState { collapsedAgentGroups: string[]; /** Which chats the sidebar's Recent list shows. */ recentTab: RecentTab; + recentSource: RecentSource; /** Workspace ids picked lately, newest first (the switcher lists them on top). */ recentWorkspaces: string[]; /** The Workspaces page shows cards or rows, in this order. */ @@ -62,6 +66,7 @@ interface UiState { setAgentsView: (v: AgentsView) => void; toggleAgentGroup: (key: string) => void; setRecentTab: (tab: RecentTab) => void; + setRecentSource: (source: RecentSource) => void; toggleNavSection: (key: string) => void; setWorkspacesView: (v: WorkspacesView) => void; setWorkspacesSort: (v: WorkspacesSort) => void; @@ -86,6 +91,7 @@ export const useUi = create()( agentsView: "grid", collapsedAgentGroups: [], recentTab: "all", + recentSource: "mine", recentWorkspaces: [], expandedNav: [], workspacesView: "grid", @@ -116,6 +122,7 @@ export const useUi = create()( collapsedAgentGroups: s.collapsedAgentGroups.includes(key) ? s.collapsedAgentGroups.filter((k) => k !== key) : [...s.collapsedAgentGroups, key], })), setRecentTab: (recentTab) => set({ recentTab }), + setRecentSource: (recentSource) => set({ recentSource }), setWorkspacesView: (workspacesView) => set({ workspacesView }), setWorkspacesSort: (workspacesSort) => set({ workspacesSort }), toggleNavSection: (key) => @@ -139,6 +146,7 @@ export const useUi = create()( agentsView: s.agentsView, collapsedAgentGroups: s.collapsedAgentGroups, recentTab: s.recentTab, + recentSource: s.recentSource, recentWorkspaces: s.recentWorkspaces, expandedNav: s.expandedNav, workspacesView: s.workspacesView,