From d042d0778a23d00675341ad9a5ba4de4a1c4ecb8 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Mon, 5 Oct 2026 21:32:45 +0200 Subject: [PATCH 1/2] Recent chats: tabs for running, scheduled and done chats The sidebar's Recent list gets a sticky segmented tab bar (All, Running, Scheduled, Done) with live counts. Running covers working, queued and paused chats, Scheduled covers follow-ups and auto-resuming limit pauses sorted by when they continue. The selected tab is remembered. --- .../src/components/layout/recent-chats.tsx | 158 +++++++++++++++++- apps/desktop/src/stores/ui.ts | 8 + 2 files changed, 159 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/components/layout/recent-chats.tsx b/apps/desktop/src/components/layout/recent-chats.tsx index bd79b0cd..41ee197a 100644 --- a/apps/desktop/src/components/layout/recent-chats.tsx +++ b/apps/desktop/src/components/layout/recent-chats.tsx @@ -2,7 +2,9 @@ import { Link, useLocation, useMatch, useNavigate } from "react-router"; import { useQuery } from "@tanstack/react-query"; import { formatDistanceToNowStrict } from "date-fns"; import type { Agent, Conversation } from "@godmode/shared"; -import { AlarmClock, Archive, Hourglass, MessageCircleQuestion, Pause, Pin, Trash2 } from "lucide-react"; +import { type KeyboardEvent, useRef } from "react"; +import { motion } from "motion/react"; +import { AlarmClock, Archive, CircleCheck, Hourglass, MessageCircleQuestion, MessagesSquare, Pause, Pin, Trash2, Zap } from "lucide-react"; import { SidebarGroup, SidebarGroupContent, @@ -21,6 +23,7 @@ import { useAllAgents, useConversations } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { useLive } from "@/stores/live"; +import { type RecentTab, useUi } from "@/stores/ui"; const ROW_ACTION = "top-1/2! size-6 -translate-y-1/2 text-muted-foreground after:inset-x-0 hover:bg-background hover:text-foreground hover:shadow-card [&>svg]:size-3.5"; @@ -41,17 +44,32 @@ export function RecentChats() { const navigate = useNavigate(); const { askDelete, deleteDialog } = useDeleteChat((id) => id === conversationId && navigate("/", { replace: true })); - const items = [...conversations] - .filter((c) => !c.archived) - .sort((a, b) => Number(b.pinned) - Number(a.pinned) || (b.lastMessageAt ?? b.createdAt).localeCompare(a.lastMessageAt ?? a.createdAt)) + const recentTab = useUi((s) => s.recentTab); + const setRecentTab = useUi((s) => s.setRecentTab); + + const visible = conversations.filter((c) => !c.archived); + 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; + for (const c of visible) { + counts[bucketOf(c)]++; + if (c.paused?.reason === "question") needsYou++; + } + const items = visible + .filter((c) => recentTab === "all" || bucketOf(c) === recentTab) + .sort((a, b) => + recentTab === "scheduled" + ? continuesAt(a).localeCompare(continuesAt(b)) + : Number(b.pinned) - Number(a.pinned) || (b.lastMessageAt ?? b.createdAt).localeCompare(a.lastMessageAt ?? a.createdAt), + ) .slice(0, 30); - if (items.length === 0 && !hasArchived) return deleteDialog; + if (visible.length === 0 && !hasArchived) return deleteDialog; return ( Recent - {items.some((c) => c.unread) && ( + {visible.some((c) => c.unread) && ( )} - + + + {items.length === 0 && } {items.map((c) => { const agent = agents.find((a) => a.id === c.agentId); const running = runningConversations.has(c.id) || (c.running && !queuedConversations.has(c.id)); @@ -191,3 +211,127 @@ function originLine(c: Conversation, agents: Agent[]): string | null { if (c.origin === "routine") return "Automation"; return null; } + +type ChatBucket = Exclude; + +/** Running: working, queued or standing still until someone acts. Scheduled: continues by itself later. Done: the rest. */ +function chatBucket(c: Conversation, live: boolean): ChatBucket { + if (c.paused) return c.paused.auto && c.paused.resumeAt ? "scheduled" : "running"; + if (live || c.running) return "running"; + return c.followup ? "scheduled" : "done"; +} + +function continuesAt(c: Conversation): string { + return c.followup?.dueAt ?? c.paused?.resumeAt ?? "9999"; +} + +const TABS: { value: RecentTab; label: string; hint: string }[] = [ + { value: "all", label: "All", hint: "Every recent chat" }, + { value: "running", label: "Running", hint: "Working, queued or waiting for you" }, + { value: "scheduled", label: "Scheduled", hint: "Continue on their own later" }, + { value: "done", label: "Done", hint: "Finished chats" }, +]; + +function RecentTabs({ + value, + onChange, + counts, + needsYou, +}: { + value: RecentTab; + onChange: (tab: RecentTab) => void; + counts: Record; + needsYou: number; +}) { + 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 = (TABS.findIndex((t) => t.value === value) + delta + TABS.length) % TABS.length; + onChange(TABS[next].value); + refs.current[next]?.focus(); + }; + + return ( +
+
+ {TABS.map((t, i) => { + const active = t.value === value; + const count = t.value === "running" || t.value === "scheduled" ? counts[t.value] : 0; + const urgent = t.value === "running" && needsYou > 0; + return ( + + + + + + {t.hint} + {urgent && ` · ${needsYou} need${needsYou === 1 ? "s" : ""} you`} + + + ); + })} +
+
+ ); +} + +const EMPTY: Record = { + all: { icon: MessagesSquare, title: "No chats yet", text: "Your archived chats are below." }, + running: { icon: Zap, title: "Nothing running", text: "Chats that work or wait for you show up here." }, + scheduled: { icon: AlarmClock, title: "Nothing scheduled", text: "When an agent plans to check back later, the chat waits here." }, + done: { icon: CircleCheck, title: "Nothing finished yet", text: "Finished chats land here." }, +}; + +function EmptyTab({ tab }: { tab: RecentTab }) { + const { icon: Icon, title, text } = EMPTY[tab]; + return ( +
  • + + {title} + {text} +
  • + ); +} diff --git a/apps/desktop/src/stores/ui.ts b/apps/desktop/src/stores/ui.ts index f5e4ed8e..44f70c37 100644 --- a/apps/desktop/src/stores/ui.ts +++ b/apps/desktop/src/stores/ui.ts @@ -2,6 +2,8 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { storageKey } from "@/lib/core"; +export type RecentTab = "all" | "running" | "scheduled" | "done"; + interface UiState { /** Workspace scope selected in the sidebar: "all" | "global" | workspace id */ workspace: string; @@ -22,6 +24,8 @@ interface UiState { collapsedColumns: string[]; /** The Agents page shows cards or the org chart. */ agentsView: "grid" | "chart"; + /** Which chats the sidebar's Recent list shows. */ + recentTab: RecentTab; /** The human came back after a while: from when to when they were away (Home sums up what happened). Not kept. */ away: { since: string; until: string } | null; setWorkspace: (id: string) => void; @@ -35,6 +39,7 @@ interface UiState { skipClaudeVersion: (version: string | null) => void; toggleColumn: (status: string) => void; setAgentsView: (v: "grid" | "chart") => void; + setRecentTab: (tab: RecentTab) => void; setAway: (away: { since: string; until: string } | null) => void; } @@ -52,6 +57,7 @@ export const useUi = create()( skippedClaudeVersion: null, collapsedColumns: ["cancelled"], agentsView: "grid", + recentTab: "all", away: null, setWorkspace: (workspace) => set({ workspace }), setCommandOpen: (commandOpen) => set({ commandOpen }), @@ -67,6 +73,7 @@ export const useUi = create()( collapsedColumns: s.collapsedColumns.includes(status) ? s.collapsedColumns.filter((c) => c !== status) : [...s.collapsedColumns, status], })), setAgentsView: (agentsView) => set({ agentsView }), + setRecentTab: (recentTab) => set({ recentTab }), setAway: (away) => set({ away }), }), { @@ -82,6 +89,7 @@ export const useUi = create()( skippedClaudeVersion: s.skippedClaudeVersion, collapsedColumns: s.collapsedColumns, agentsView: s.agentsView, + recentTab: s.recentTab, }), }, ), From 011ec198160b5bfbd3b9e40e0c69405b2d783aec Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Mon, 5 Oct 2026 21:34:27 +0200 Subject: [PATCH 2/2] Recent chats: show which workspace a chat belongs to A small chip in the workspace's colour, with its icon and name, leads the second line of every chat row. Global chats go without. --- .../src/components/layout/recent-chats.tsx | 106 +++++++++++------- 1 file changed, 65 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/components/layout/recent-chats.tsx b/apps/desktop/src/components/layout/recent-chats.tsx index 41ee197a..e89207c7 100644 --- a/apps/desktop/src/components/layout/recent-chats.tsx +++ b/apps/desktop/src/components/layout/recent-chats.tsx @@ -1,7 +1,7 @@ import { Link, useLocation, useMatch, useNavigate } from "react-router"; import { useQuery } from "@tanstack/react-query"; import { formatDistanceToNowStrict } from "date-fns"; -import type { Agent, Conversation } from "@godmode/shared"; +import type { Agent, Conversation, Workspace } from "@godmode/shared"; import { type KeyboardEvent, useRef } from "react"; import { motion } from "motion/react"; import { AlarmClock, Archive, CircleCheck, Hourglass, MessageCircleQuestion, MessagesSquare, Pause, Pin, Trash2, Zap } from "lucide-react"; @@ -15,11 +15,11 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { AgentAvatar } from "@/components/common"; +import { AgentAvatar, colorGradient } from "@/components/common"; import { useArchiveChat, useDeleteChat } from "@/components/chat/chat-actions"; import { followupWhen } from "@/components/chat/followup"; import { api } from "@/lib/api"; -import { useAllAgents, useConversations } from "@/lib/hooks"; +import { useAllAgents, useConversations, useWorkspaces } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { useLive } from "@/stores/live"; @@ -35,6 +35,7 @@ export function RecentChats() { queryFn: async () => (await api.conversations.list({ archived: true, limit: 1 })).length > 0, }); const { data: agents = [] } = useAllAgents(); + const { data: workspaces = [] } = useWorkspaces(); const conversationId = useMatch("/chat/:conversationId")?.params.conversationId; const { pathname } = useLocation(); const liveRuns = useLive((s) => s.runs); @@ -84,6 +85,8 @@ export function RecentChats() { {items.length === 0 && } {items.map((c) => { const agent = agents.find((a) => a.id === c.agentId); + const workspaceId = c.workspaceId ?? agent?.workspaceId; + const workspace = workspaceId ? workspaces.find((w) => w.id === workspaceId) : undefined; const running = runningConversations.has(c.id) || (c.running && !queuedConversations.has(c.id)); const queued = !running && queuedConversations.has(c.id); return ( @@ -107,46 +110,49 @@ export function RecentChats() { )} {c.pinned && } - - {running && !c.paused ? ( - Working… - ) : queued && !c.paused ? ( - Queued — waiting for a free slot - ) : c.paused?.reason === "question" ? ( - - - - {c.paused.question?.kind === "approval" ? "Needs your OK" : "Needs your answer"} - {c.paused.question?.title ? ` · ${c.paused.question.title}` : ""} + + {workspace && } + + {running && !c.paused ? ( + Working… + ) : queued && !c.paused ? ( + Queued — waiting for a free slot + ) : c.paused?.reason === "question" ? ( + + + + {c.paused.question?.kind === "approval" ? "Needs your OK" : "Needs your answer"} + {c.paused.question?.title ? ` · ${c.paused.question.title}` : ""} + - - ) : c.paused ? ( - - {c.paused.reason === "limit" ? ( - - ) : ( - - )} - - {c.paused.reason === "user" - ? "Paused" - : c.paused.reason === "budget" - ? "Held — budget used up" - : c.paused.auto && c.paused.resumeAt - ? `Continues ${followupWhen(c.paused.resumeAt)}` - : "Waiting for the limit"} + ) : c.paused ? ( + + {c.paused.reason === "limit" ? ( + + ) : ( + + )} + + {c.paused.reason === "user" + ? "Paused" + : c.paused.reason === "budget" + ? "Held — budget used up" + : c.paused.auto && c.paused.resumeAt + ? `Continues ${followupWhen(c.paused.resumeAt)}` + : "Waiting for the limit"} + - - ) : c.followup ? ( - - - Continues {followupWhen(c.followup.dueAt)} - - ) : ( - <> - {originLine(c, agents) ?? agent?.name ?? "Agent"} · {formatDistanceToNowStrict(new Date(c.lastMessageAt ?? c.createdAt), { addSuffix: false })} - - )} + ) : c.followup ? ( + + + Continues {followupWhen(c.followup.dueAt)} + + ) : ( + <> + {originLine(c, agents) ?? agent?.name ?? "Agent"} · {formatDistanceToNowStrict(new Date(c.lastMessageAt ?? c.createdAt), { addSuffix: false })} + + )} + @@ -212,6 +218,24 @@ function originLine(c: Conversation, agents: Agent[]): string | null { return null; } +/** Which workspace a chat belongs to, in the workspace's colour. Global chats go without. */ +function WorkspaceChip({ workspace }: { workspace: Workspace }) { + const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(workspace.color); + return ( + + {workspace.icon && {workspace.icon}} + {workspace.name} + + ); +} + type ChatBucket = Exclude; /** Running: working, queued or standing still until someone acts. Scheduled: continues by itself later. Done: the rest. */