From dea2c759f79c4602883f2f6bf8bacfba21998a92 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Wed, 7 Oct 2026 08:04:08 +0200 Subject: [PATCH 1/3] Searchable workspace switcher, compact sidebar sections, Recent by day, Workspaces list view --- .../src/components/layout/app-shell.tsx | 88 +++- .../src/components/layout/recent-chats.tsx | 58 ++- .../components/layout/workspace-switcher.tsx | 328 +++++++++++--- .../workspaces/workspace-activity.ts | 73 ++++ .../src/pages/workspaces/workspaces-page.tsx | 412 +++++++++++++++--- apps/desktop/src/stores/ui.ts | 32 +- 6 files changed, 846 insertions(+), 145 deletions(-) create mode 100644 apps/desktop/src/components/workspaces/workspace-activity.ts diff --git a/apps/desktop/src/components/layout/app-shell.tsx b/apps/desktop/src/components/layout/app-shell.tsx index 84566772..30b45746 100644 --- a/apps/desktop/src/components/layout/app-shell.tsx +++ b/apps/desktop/src/components/layout/app-shell.tsx @@ -7,6 +7,7 @@ import { ArrowLeft, Bot, Box, + ChevronRight, Globe, MonitorUp, Inbox, @@ -166,18 +167,20 @@ export function AppShell({ children }: { children: ReactNode }) { }, [navigate, setCommandOpen]); const workNav: NavItem[] = [ + { to: "/inbox", label: "Inbox", icon: , badge: inboxCount || undefined }, { to: "/tasks", label: "Tasks", icon: , badge: (attention?.review ?? 0) + (attention?.blocked ?? 0) || undefined }, { to: "/agents", label: "Agents", icon: , badge: runningCount || undefined }, { to: "/automations", label: "Automations", icon: , badge: attention?.automation || undefined }, - { to: "/mods", label: "Mods", icon: , badge: modsWaiting || undefined }, { to: "/activity", label: "Activity", icon: }, - { to: "/inbox", label: "Inbox", icon: , badge: inboxCount || undefined }, + { to: "/mods", label: "Mods", icon: , badge: modsWaiting || undefined }, ]; const accessNav: NavItem[] = [ - { to: "/vault/logins", label: "Logins", icon: , badge: undefined }, + { to: "/vault/logins", label: "Logins", icon: }, { to: "/vault/2fa", label: "2FA Codes", icon: }, { to: "/integrations", label: "Integrations", icon: }, { to: "/messaging", label: "Messaging", icon: , badge: boot?.counts.messagingRequests || undefined }, + ]; + const machineNav: NavItem[] = [ { to: "/browser", label: "Browser", icon: }, { to: "/computer", label: "Computer", icon: }, { to: "/vms", label: "Virtual machines", icon: }, @@ -237,16 +240,8 @@ export function AppShell({ children }: { children: ReactNode }) { - - Access - - - {accessNav.map((item) => ( - - ))} - - - + + @@ -359,6 +354,73 @@ function DesktopDragStrip() { return
; } +/** A sidebar section that folds to its label and a row of icons, so chats keep the room. The rail always lists every item. */ +function NavSection({ id, label, items }: { id: string; label: string; items: NavItem[] }) { + const expanded = useUi((s) => s.expandedNav.includes(id)); + const toggle = useUi((s) => s.toggleNavSection); + const { state, isMobile } = useSidebar(); + const railed = state === "collapsed" && !isMobile; + const { pathname } = useLocation(); + const listId = `nav-section-${id}`; + const open = expanded || railed; + const header = ( + + ); + if (open) + return ( + + {header} + + + {items.map((item) => ( + + ))} + + + + ); + return ( + + {header} +
+ {items.map((item) => { + const active = pathname.startsWith(item.to); + return ( + + + svg]:size-4", + active && "bg-card text-foreground shadow-card ring-1 ring-border", + )} + > + {item.icon} + {item.badge ? : null} + + + + {item.label} + {item.badge ? ` · ${item.badge}` : ""} + + + ); + })} +
+
+ ); +} + function NavMenuItem({ item }: { item: NavItem }) { const location = useLocation(); const active = item.end ? location.pathname === item.to || location.pathname.startsWith("/chat/") : location.pathname.startsWith(item.to); diff --git a/apps/desktop/src/components/layout/recent-chats.tsx b/apps/desktop/src/components/layout/recent-chats.tsx index ad3fd582..e33722de 100644 --- a/apps/desktop/src/components/layout/recent-chats.tsx +++ b/apps/desktop/src/components/layout/recent-chats.tsx @@ -1,10 +1,10 @@ import { Link, useLocation, useMatch, useNavigate } from "react-router"; import { useQuery } from "@tanstack/react-query"; -import { formatDistanceToNowStrict } from "date-fns"; +import { differenceInCalendarDays, formatDistanceToNowStrict } from "date-fns"; import type { Agent, Conversation, Workspace } from "@godmode/shared"; -import { type KeyboardEvent, useRef } from "react"; +import { Fragment, type KeyboardEvent, useEffect, useRef, useState } from "react"; import { motion } from "motion/react"; -import { AlarmClock, Archive, CircleCheck, Hourglass, MessageCircleQuestion, MessagesSquare, Pause, Pin, Trash2, Zap } from "lucide-react"; +import { AlarmClock, Archive, ChevronDown, CircleCheck, Hourglass, MessageCircleQuestion, MessagesSquare, Pause, Pin, Trash2, Zap } from "lucide-react"; import { SidebarGroup, SidebarGroupContent, @@ -25,6 +25,19 @@ import { cn } from "@/lib/utils"; import { useLive } from "@/stores/live"; import { type RecentTab, useUi } from "@/stores/ui"; +const PAGE = 25; + +/** "Today", "Yesterday", "Previous 7 days", "Previous 30 days", then the month. */ +function dayLabel(iso: string): string { + const date = new Date(iso); + const days = differenceInCalendarDays(new Date(), date); + if (days <= 0) return "Today"; + if (days === 1) return "Yesterday"; + if (days < 7) return "Previous 7 days"; + if (days < 30) return "Previous 30 days"; + return date.toLocaleDateString(undefined, { month: "long", year: date.getFullYear() === new Date().getFullYear() ? undefined : "numeric" }); +} + 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"; @@ -59,14 +72,20 @@ export function RecentChats() { counts[bucketOf(c)]++; if (c.paused?.reason === "question") needsYou++; } - const items = visible + const [limit, setLimit] = useState(PAGE); + useEffect(() => setLimit(PAGE), [recentTab, scope]); + const matching = 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); + ); + const items = matching.slice(0, limit); + const more = matching.length - items.length; + // Long lists read by day; the running and scheduled tabs are short and ordered by what comes next. + 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; @@ -88,14 +107,21 @@ export function RecentChats() { {items.length === 0 && } - {items.map((c) => { + {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); const workspaceId = c.workspaceId ?? agent?.workspaceId; const workspace = scope === "all" && 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 ( - + + {day && ( +
  • 0 ? "pt-3" : "pt-1")}> + {day} +
  • + )} + Delete +
    ); })} + {more > 0 && ( + + setLimit((n) => n + PAGE)} + className="mt-1 gap-2 text-[12.5px] text-muted-foreground [&>svg]:size-3.5" + > + + + Show {Math.min(more, PAGE)} more + of {more} + + + + )} {hasArchived && ( s.workspace); const setScope = useUi((s) => s.setWorkspace); + const recentIds = useUi((s) => s.recentWorkspaces); const navigate = useNavigate(); + const { of: activity, total: totals } = useWorkspaceActivity(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const { state, isMobile } = useSidebar(); + const railed = state === "collapsed" && !isMobile; + + const sorted = useMemo(() => [...workspaces].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })), [workspaces]); + const recent = useMemo( + () => (sorted.length > 6 ? recentIds.flatMap((id) => sorted.find((w) => w.id === id) ?? []).slice(0, 3) : []), + [sorted, recentIds], + ); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const mod = isMac ? e.metaKey : e.ctrlKey; + if (!mod || e.shiftKey || e.altKey || !/^Digit[0-9]$/.test(e.code) || busyTarget(e.target)) return; + const n = Number(e.code.slice(5)); + const next = n === 0 ? "all" : sorted[n - 1]?.id; + if (!next) return; + e.preventDefault(); + setScope(next); + setOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [sorted, setScope]); + + useEffect(() => { + if (!open) setQuery(""); + }, [open]); const current = workspaces.find((w) => w.id === scope); - const label = scope === "all" ? "All workspaces" : scope === "global" ? "Global" : (current?.name ?? "All workspaces"); - const icon = - scope === "all" ? ( - - ) : scope === "global" ? ( - - ) : ( - {current?.icon ?? "🗂️"} - ); + const label = scope === "global" ? "Global" : (current?.name ?? "All workspaces"); + const here = scope === "all" ? null : activity(scope === "global" ? null : scope); + const shown = here ?? totals; + // Waiting elsewhere: the human sees it without opening the list. + const elsewhere = scope === "all" ? 0 : totals.needsYou - (here?.needsYou ?? 0); + + const pick = (id: string) => { + setScope(id); + setOpen(false); + }; + const go = (to: string) => { + setOpen(false); + navigate(to); + }; return ( - - - + + + {(railed || elsewhere > 0) && !open && ( + + {railed && `${label} · `} + {elsewhere > 0 ? `${elsewhere} waiting for you in other workspaces` : `Switch workspace (${modKey}0–9)`} + + )} + + + (keywords?.join(" ").toLowerCase().includes(search.trim().toLowerCase()) ? 1 : 0)}> + + + No workspace called “{query}”. + + pick("all")} shortcut={`${modKey}0`}> + + } /> + + pick("global")} activity={activity(null)}> + + + + + {recent.length > 0 && !query && ( + + {recent.map((w) => ( + pick(w.id)} /> + ))} + + )} + {sorted.length > 0 && ( + + {sorted.map((w, i) => ( + pick(w.id)} + /> + ))} + )} - > - {icon} - - {label} - - - - - Scope - setScope("all")}> - All workspaces {scope === "all" && } - - setScope("global")}> - Global {scope === "global" && } - - {workspaces.length > 0 && } - {workspaces.map((w) => ( - setScope(w.id)}> - {w.icon} {w.name} - {scope === w.id && } - - ))} - - navigate("/workspaces?new=1")}> - New workspace - - navigate("/workspaces")}> - Manage workspaces - - - + + + go("/workspaces?new=1")} className="text-[13px]"> + New workspace + + go("/workspaces")} className="text-[13px]"> + Manage workspaces + + + + + + + ); +} + +function ScopeTile({ scope, workspace, className }: { scope: string; workspace?: Workspace; className?: string }) { + if (workspace) return ; + const Icon = scope === "global" ? Globe2 : Layers; + return ( + + + + ); +} + +function ActivityLine({ activity, fallback }: { activity: Pick; fallback: string }) { + if (!activity.running && !activity.needsYou) return {fallback}; + return ( + <> + {activity.needsYou > 0 && ( + + + {activity.needsYou} need{activity.needsYou === 1 ? "s" : ""} you + + )} + {activity.running > 0 && ( + + + {activity.running} working + + )} + + ); +} + +function RowText({ name, meta }: { name: string; meta?: React.ReactNode }) { + return ( + + {name} + {meta && {meta}} + + ); +} + +function ScopeRow({ + value, + keywords, + selected, + onSelect, + shortcut, + activity, + children, +}: { + value: string; + keywords: string[]; + selected: boolean; + onSelect: () => void; + shortcut?: string; + activity?: WorkspaceActivity; + children: React.ReactNode; +}) { + return ( + + {children} + + {selected ? ( + + ) : shortcut ? ( + {shortcut} + ) : ( + + )} + + ); +} + +function RowBadges({ activity }: { activity?: WorkspaceActivity }) { + if (!activity) return null; + return ( + + {activity.running > 0 && ( + + + {activity.running} + + )} + {activity.needsYou > 0 && ( + + {activity.needsYou} + + )} + + ); +} + +function WorkspaceRow({ + workspace, + selected, + activity, + shortcut, + keyPrefix = "ws", + onSelect, +}: { + workspace: Workspace; + selected: boolean; + activity: WorkspaceActivity; + shortcut?: string; + keyPrefix?: string; + onSelect: () => void; +}) { + const meta = [ + activity.agents ? `${activity.agents} agent${activity.agents === 1 ? "" : "s"}` : null, + activity.openTasks ? `${activity.openTasks} open task${activity.openTasks === 1 ? "" : "s"}` : null, + ] + .filter(Boolean) + .join(" · "); + return ( + + + + ); } diff --git a/apps/desktop/src/components/workspaces/workspace-activity.ts b/apps/desktop/src/components/workspaces/workspace-activity.ts new file mode 100644 index 00000000..7930c4c5 --- /dev/null +++ b/apps/desktop/src/components/workspaces/workspace-activity.ts @@ -0,0 +1,73 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { useAllAgents, useAttention, useConversations } from "@/lib/hooks"; +import { qk } from "@/lib/queryKeys"; +import { useLive } from "@/stores/live"; + +export interface WorkspaceActivity { + /** Runs working or queued right now. */ + running: number; + /** Questions, reviews, missing logins… waiting for the human. */ + needsYou: number; + /** Tickets not done, cancelled or archived. */ + openTasks: number; + agents: number; + /** Last message in one of its chats. */ + lastActive: string | null; +} + +const EMPTY: WorkspaceActivity = { running: 0, needsYou: 0, openTasks: 0, agents: 0, lastActive: null }; + +/** What happens in each workspace, keyed by workspace id ("global" for the shared scope). */ +export function useWorkspaceActivity(): { of: (scope: string | null) => WorkspaceActivity; total: WorkspaceActivity } { + const { data: agents } = useAllAgents(); + const { data: conversations } = useConversations(); + const { data: attention } = useAttention(); + const { data: tasks } = useQuery({ queryKey: qk.taskList("all"), queryFn: () => api.tasks.list({ workspaceId: "all" }), staleTime: 30_000 }); + const runs = useLive((s) => s.runs); + + const map = useMemo(() => { + const out = new Map(); + const at = (ws: string | null | undefined) => { + const key = ws ?? "global"; + let entry = out.get(key); + if (!entry) out.set(key, (entry = { ...EMPTY })); + return entry; + }; + const agentWs = new Map((agents ?? []).map((a) => [a.id, a.workspaceId ?? null])); + const chatWs = new Map(); + for (const a of agents ?? []) at(a.workspaceId).agents++; + for (const c of conversations ?? []) { + const ws = c.workspaceId ?? agentWs.get(c.agentId) ?? null; + chatWs.set(c.id, ws); + const last = c.lastMessageAt ?? c.createdAt; + const entry = at(ws); + if (!entry.lastActive || last > entry.lastActive) entry.lastActive = last; + } + for (const r of Object.values(runs)) { + if (r.status !== "running" && r.status !== "queued") continue; + at(chatWs.has(r.conversationId) ? chatWs.get(r.conversationId) : agentWs.get(r.agentId)).running++; + } + for (const item of attention ?? []) { + const ws = item.task ? item.task.workspaceId : item.conversationId && chatWs.has(item.conversationId) ? chatWs.get(item.conversationId) : item.agentId ? agentWs.get(item.agentId) : null; + at(ws).needsYou++; + } + for (const t of tasks ?? []) if (!t.archivedAt && t.status !== "done" && t.status !== "cancelled") at(t.workspaceId).openTasks++; + return out; + }, [agents, conversations, attention, tasks, runs]); + + const total = useMemo(() => { + const sum = { ...EMPTY }; + for (const a of map.values()) { + sum.running += a.running; + sum.needsYou += a.needsYou; + sum.openTasks += a.openTasks; + sum.agents += a.agents; + if (a.lastActive && (!sum.lastActive || a.lastActive > sum.lastActive)) sum.lastActive = a.lastActive; + } + return sum; + }, [map]); + + return useMemo(() => ({ of: (scope: string | null) => map.get(scope ?? "global") ?? EMPTY, total }), [map, total]); +} diff --git a/apps/desktop/src/pages/workspaces/workspaces-page.tsx b/apps/desktop/src/pages/workspaces/workspaces-page.tsx index 5b144b34..87f6abfa 100644 --- a/apps/desktop/src/pages/workspaces/workspaces-page.tsx +++ b/apps/desktop/src/pages/workspaces/workspaces-page.tsx @@ -1,11 +1,31 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "motion/react"; -import { ArrowRight, Bot, EllipsisVertical, Globe2, KeyRound, Layers, Pencil, Plug, Plus, ScrollText, ShieldCheck, SquareKanban, Trash2, TriangleAlert } from "lucide-react"; +import { formatDistanceToNowStrict } from "date-fns"; +import { + ArrowRight, + Bot, + EllipsisVertical, + Globe2, + KeyRound, + Layers, + LayoutGrid, + List, + MessagesSquare, + Pencil, + Plug, + Plus, + ScrollText, + Search, + ShieldCheck, + SquareKanban, + Trash2, + TriangleAlert, +} from "lucide-react"; import { toast } from "sonner"; import type { Workspace, WorkspaceSource } from "@godmode/shared"; -import { PageBody, PageHeader } from "@/components/common"; +import { EmptyState, Kbd, PageBody, PageHeader } from "@/components/common"; import { AlertDialog, AlertDialogAction, @@ -17,6 +37,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Skeleton } from "@/components/ui/skeleton"; @@ -26,10 +47,22 @@ import { ApiRequestError, api, errorMessage } from "@/lib/api"; import { useBootstrap, useWorkspaces } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; -import { useUi } from "@/stores/ui"; +import { type WorkspacesSort, type WorkspacesView, useUi } from "@/stores/ui"; import { WorkspaceDialog } from "@/components/workspaces/workspace-dialog"; import { SourcesSummary } from "@/components/workspaces/workspace-sources"; import { WorkspaceTile } from "@/components/workspaces/workspace-tile"; +import { type WorkspaceActivity, useWorkspaceActivity } from "@/components/workspaces/workspace-activity"; + +const SORTS: { id: WorkspacesSort; label: string }[] = [ + { id: "name", label: "A–Z" }, + { id: "active", label: "Recently active" }, + { id: "attention", label: "Needs you" }, +]; + +const VIEWS: { id: WorkspacesView; label: string; icon: typeof List }[] = [ + { id: "grid", label: "Cards", icon: LayoutGrid }, + { id: "list", label: "List", icon: List }, +]; interface Counts { agents: number | null; @@ -78,6 +111,24 @@ export default function WorkspacesPage() { const { data: workspaces, isLoading, isError, error, refetch } = useWorkspaces(); const { data: boot } = useBootstrap(); const counts = useScopeCounts(); + const activity = useWorkspaceActivity(); + const view = useUi((s) => s.workspacesView); + const setView = useUi((s) => s.setWorkspacesView); + const sort = useUi((s) => s.workspacesSort); + const setSort = useUi((s) => s.setWorkspacesSort); + const [search, setSearch] = useState(""); + const searchRef = useRef(null); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const el = e.target as HTMLElement | null; + if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey || el?.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el?.tagName ?? "")) return; + if (el?.closest("[role=dialog],[role=alertdialog],[role=menu],[role=listbox]")) return; + e.preventDefault(); + searchRef.current?.focus(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); const hasGlobalInstructions = !!boot?.settings.runner.appendSystemPrompt?.trim(); const [editing, setEditing] = useState(null); @@ -142,7 +193,58 @@ export default function WorkspacesPage() { }, }); - const list = workspaces ?? []; + const all = workspaces ?? []; + const query = search.trim().toLowerCase(); + const list = useMemo(() => { + const hits = query ? all.filter((w) => `${w.name} ${w.description}`.toLowerCase().includes(query)) : all; + const byName = (x: Workspace, y: Workspace) => x.name.localeCompare(y.name, undefined, { sensitivity: "base" }); + return [...hits].sort((x, y) => { + const ax = activity.of(x.id); + const ay = activity.of(y.id); + if (sort === "active") return (ay.lastActive ?? "").localeCompare(ax.lastActive ?? "") || byName(x, y); + if (sort === "attention") return ay.needsYou - ax.needsYou || ay.running - ax.running || ay.openTasks - ax.openTasks || byName(x, y); + return byName(x, y); + }); + }, [all, query, sort, activity]); + const showGlobal = !query || "global shared".includes(query); + const menuFor = (ws: Workspace) => ( + + + + + + open(ws, "/")}> + Chats + + open(ws, "/tasks")}> + Tasks + + open(ws, "/agents")}> + Agents + + + setEditing(ws)}> + Edit + + + setDeleting(ws)}> + Delete + + + + ); + const globalTile = ( +
    + +
    + ); + const globalContext = { + label: hasGlobalInstructions ? "Instructions for every agent" : "Add instructions for every agent", + set: hasGlobalInstructions, + onClick: () => navigate("/settings/instructions"), + }; return (
    @@ -157,6 +259,63 @@ export default function WorkspacesPage() { } /> + {all.length > 0 && ( +
    +
    + + setSearch(e.target.value)} + onKeyDown={(e) => e.key === "Escape" && setSearch("")} + placeholder={`Search ${all.length} workspace${all.length === 1 ? "" : "s"}…`} + aria-label="Search workspaces" + className="pr-10 pl-9" + /> + + / + +
    +
    + {SORTS.map((o) => ( + + ))} +
    +
    + {VIEWS.map((v) => ( + + ))} +
    +
    + )} {isLoading ? (
    {Array.from({ length: 3 }).map((_, i) => ( @@ -171,29 +330,76 @@ export default function WorkspacesPage() { Try again
    + ) : query && !list.length && !showGlobal ? ( + } + title={`No workspace matches “${search.trim()}”`} + description="Try another name, or create it." + action={ + + } + /> + ) : view === "list" ? ( +
    + {showGlobal && ( +
    } + title="Global" + description="Shared with every workspace" + counts={counts.get(null)} + activity={activity.of(null)} + onOpen={() => open(null)} + onBoard={() => open(null, "/tasks")} + /> + )} + {list.map((ws) => ( + } + title={ws.name} + description={ws.description} + counts={counts.get(ws.id)} + activity={activity.of(ws.id)} + onOpen={() => open(ws)} + onBoard={() => open(ws, "/tasks")} + menu={menuFor(ws)} + /> + ))} + {!query && ( + + )} +
    ) : ( - - -
    - } - title="Global" - subtitle="Shared with every workspace" - description="Logins, 2FA codes, integrations and agents here are available everywhere — perfect for your personal accounts and the main Godmode assistant." - counts={counts.get(null)} - countsLoading={counts.loading} - onOpen={() => open(null)} - onBoard={() => open(null, "/tasks")} - context={{ - label: hasGlobalInstructions ? "Instructions for every agent" : "Add instructions for every agent", - set: hasGlobalInstructions, - onClick: () => navigate("/settings/instructions"), - }} - /> + {showGlobal && ( + open(null)} + onBoard={() => open(null, "/tasks")} + context={globalContext} + /> + )} {list.map((ws, i) => ( open(ws)} onBoard={() => open(ws, "/tasks")} context={{ @@ -213,44 +420,29 @@ export default function WorkspacesPage() { onClick: () => editFocused(ws, "instructions"), }} sources={{ list: ws.sources, onClick: () => editFocused(ws, "sources") }} - menu={ - - - - - - setEditing(ws)}> - Edit - - - setDeleting(ws)}> - Delete - - - - } + menu={menuFor(ws)} /> ))} - - - - - {list.length ? "New workspace" : "Create your first workspace"} - - {list.length ? "Another client, project or team." : "e.g. one per client — agents there only see that client's logins."} - - + {!query && ( + + + + + {list.length ? "New workspace" : "Create your first workspace"} + + {list.length ? "Another client, project or team." : "e.g. one per client — agents there only see that client's logins."} + + + )} )} @@ -358,6 +550,7 @@ function ScopeCard({ description, counts, countsLoading, + activity, onOpen, onBoard, menu, @@ -372,6 +565,7 @@ function ScopeCard({ description?: string; counts: Counts; countsLoading: boolean; + activity: WorkspaceActivity; onOpen: () => void; onBoard: () => void; menu?: ReactNode; @@ -411,6 +605,7 @@ function ScopeCard({

    {title}

    {subtitle &&

    {subtitle}

    } +

    {description || "No description yet."}

    @@ -481,3 +676,98 @@ function ScopeCard({ ); } + +/** What's going on in a scope, in a line: what waits for the human, what works, what's open, and when it last moved. */ +function ActivityChips({ activity, className, compact }: { activity: WorkspaceActivity; className?: string; compact?: boolean }) { + const quiet = !activity.needsYou && !activity.running; + return ( +
    + {activity.needsYou > 0 && ( + + + {activity.needsYou} need{activity.needsYou === 1 ? "s" : ""} you + + )} + {activity.running > 0 && ( + + + {activity.running} working + + )} + {!compact && ( + + {[ + activity.openTasks ? `${activity.openTasks} open task${activity.openTasks === 1 ? "" : "s"}` : quiet ? "No open tasks" : null, + activity.lastActive ? `active ${formatDistanceToNowStrict(new Date(activity.lastActive), { addSuffix: true })}` : null, + ] + .filter(Boolean) + .join(" · ")} + + )} +
    + ); +} + +function ScopeListRow({ + current, + tile, + title, + description, + counts, + activity, + onOpen, + onBoard, + menu, +}: { + current: boolean; + tile: ReactNode; + title: string; + description?: string; + counts: Counts; + activity: WorkspaceActivity; + onOpen: () => void; + onBoard: () => void; + menu?: ReactNode; +}) { + const stats: { label: string; n: number | null; icon: ReactNode }[] = [ + { label: "Agents", n: counts.agents, icon: }, + { label: "Open tasks", n: activity.openTasks, icon: }, + { label: "Logins", n: counts.logins, icon: }, + { label: "Integrations", n: counts.integrations, icon: }, + ]; + return ( +
    + {current && } + {tile} + + +
    + {stats.map((s) => ( + + + + {s.icon} + {s.n ?? "–"} + + + {s.label} + + ))} +
    +
    + + {menu ?? } +
    +
    + ); +} diff --git a/apps/desktop/src/stores/ui.ts b/apps/desktop/src/stores/ui.ts index a32d4364..e3116c10 100644 --- a/apps/desktop/src/stores/ui.ts +++ b/apps/desktop/src/stores/ui.ts @@ -6,6 +6,10 @@ export type AgentsView = "grid" | "list" | "chart"; export type RecentTab = "all" | "running" | "scheduled" | "done"; +export type WorkspacesView = "grid" | "list"; + +export type WorkspacesSort = "name" | "active" | "attention"; + interface UiState { /** Workspace scope selected in the sidebar: "all" | "global" | workspace id */ workspace: string; @@ -30,6 +34,13 @@ interface UiState { collapsedAgentGroups: string[]; /** Which chats the sidebar's Recent list shows. */ recentTab: RecentTab; + /** Workspace ids picked lately, newest first (the switcher lists them on top). */ + recentWorkspaces: string[]; + /** The Workspaces page shows cards or rows, in this order. */ + workspacesView: WorkspacesView; + workspacesSort: WorkspacesSort; + /** Sidebar sections shown as full lists (the rest are a row of icons). */ + expandedNav: string[]; /** 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; @@ -45,6 +56,9 @@ interface UiState { setAgentsView: (v: AgentsView) => void; toggleAgentGroup: (key: string) => void; setRecentTab: (tab: RecentTab) => void; + toggleNavSection: (key: string) => void; + setWorkspacesView: (v: WorkspacesView) => void; + setWorkspacesSort: (v: WorkspacesSort) => void; setAway: (away: { since: string; until: string } | null) => void; } @@ -64,8 +78,16 @@ export const useUi = create()( agentsView: "grid", collapsedAgentGroups: [], recentTab: "all", + recentWorkspaces: [], + expandedNav: [], + workspacesView: "grid", + workspacesSort: "name", away: null, - setWorkspace: (workspace) => set({ workspace }), + setWorkspace: (workspace) => + set((s) => ({ + workspace, + recentWorkspaces: workspace === "all" || workspace === "global" ? s.recentWorkspaces : [workspace, ...s.recentWorkspaces.filter((id) => id !== workspace)].slice(0, 6), + })), setCommandOpen: (commandOpen) => set({ commandOpen }), setShortcutsOpen: (shortcutsOpen) => set({ shortcutsOpen }), setVoiceMode: (voiceMode) => set({ voiceMode }), @@ -84,6 +106,10 @@ export const useUi = create()( collapsedAgentGroups: s.collapsedAgentGroups.includes(key) ? s.collapsedAgentGroups.filter((k) => k !== key) : [...s.collapsedAgentGroups, key], })), setRecentTab: (recentTab) => set({ recentTab }), + setWorkspacesView: (workspacesView) => set({ workspacesView }), + setWorkspacesSort: (workspacesSort) => set({ workspacesSort }), + toggleNavSection: (key) => + set((s) => ({ expandedNav: s.expandedNav.includes(key) ? s.expandedNav.filter((k) => k !== key) : [...s.expandedNav, key] })), setAway: (away) => set({ away }), }), { @@ -101,6 +127,10 @@ export const useUi = create()( agentsView: s.agentsView, collapsedAgentGroups: s.collapsedAgentGroups, recentTab: s.recentTab, + recentWorkspaces: s.recentWorkspaces, + expandedNav: s.expandedNav, + workspacesView: s.workspacesView, + workspacesSort: s.workspacesSort, }), }, ), From 795f56840b8a94738495154e3690621015270884 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Wed, 7 Oct 2026 08:07:00 +0200 Subject: [PATCH 2/3] Scope Home's Needs you to the workspace, show workspaces in the Inbox and search, list the new shortcuts --- .../components/attention/attention-list.tsx | 31 ++++++++++++++++--- .../src/components/layout/command-palette.tsx | 29 ++++++++++++++++- .../components/layout/shortcuts-dialog.tsx | 4 ++- .../components/layout/workspace-switcher.tsx | 18 +++++------ .../workspaces/workspace-activity.ts | 29 ++++++++++++++--- apps/desktop/src/pages/chat/chat-home.tsx | 30 ++++++++++++++---- apps/desktop/src/pages/inbox/inbox-page.tsx | 7 +++-- 7 files changed, 120 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/components/attention/attention-list.tsx b/apps/desktop/src/components/attention/attention-list.tsx index 43636263..fc6b005f 100644 --- a/apps/desktop/src/components/attention/attention-list.tsx +++ b/apps/desktop/src/components/attention/attention-list.tsx @@ -3,13 +3,14 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { formatDistanceToNowStrict } from "date-fns"; import { AlertTriangle, Coins, KanbanSquare, KeyRound, MessageCircleQuestion, OctagonAlert, Pause, Play, UserPlus, Workflow, type LucideIcon } from "lucide-react"; -import type { Agent, AttentionItem } from "@godmode/shared"; +import type { Agent, AttentionItem, Workspace } from "@godmode/shared"; import { api, errorMessage } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { AgentAvatar } from "@/components/common"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { WorkspaceTile } from "@/components/workspaces/workspace-tile"; export const ATTENTION_ICON: Record = { question: MessageCircleQuestion, @@ -36,17 +37,28 @@ const TONE: Partial> = { * Things that wait for the human, one row each: who, what, since when, and the one thing to do. A row leaves when the * thing is handled, wherever that happened. */ -export function AttentionList({ items, agentById, className }: { items: AttentionItem[]; agentById: Map; className?: string }) { +export function AttentionList({ + items, + agentById, + workspaceOf, + className, +}: { + items: AttentionItem[]; + agentById: Map; + /** Shows which workspace each row belongs to (when the list mixes them). */ + workspaceOf?: (item: AttentionItem) => Workspace | undefined; + className?: string; +}) { return (
      {items.map((item) => ( - + ))}
    ); } -function AttentionRow({ item, agent }: { item: AttentionItem; agent?: Agent }) { +function AttentionRow({ item, agent, workspace }: { item: AttentionItem; agent?: Agent; workspace?: Workspace }) { const qc = useQueryClient(); const Icon = ATTENTION_ICON[item.kind]; const resume = useMutation({ @@ -77,9 +89,18 @@ function AttentionRow({ item, agent }: { item: AttentionItem; agent?: Agent }) {

    {item.title}

    -

    +

    + {workspace && ( + + + {workspace.name} + · + + )} + {item.detail ? `${item.detail} · ` : ""} +

    {item.kind === "paused" && item.conversationId ? ( diff --git a/apps/desktop/src/components/layout/command-palette.tsx b/apps/desktop/src/components/layout/command-palette.tsx index 5aad0080..fa3dab5f 100644 --- a/apps/desktop/src/components/layout/command-palette.tsx +++ b/apps/desktop/src/components/layout/command-palette.tsx @@ -52,7 +52,8 @@ import { CommandSeparator, CommandShortcut, } from "@/components/ui/command"; -import { useAllAgents, useConversations } from "@/lib/hooks"; +import { useAllAgents, useConversations, useWorkspaces } from "@/lib/hooks"; +import { WorkspaceTile } from "@/components/workspaces/workspace-tile"; import { useStartAgentChat } from "@/components/agents/agent-actions"; import { ATTENTION_ICON } from "@/components/attention/attention-list"; import { useUi } from "@/stores/ui"; @@ -103,6 +104,9 @@ export function CommandPalette() { const { resolved, setTheme } = useTheme(); const { data: agents = [] } = useAllAgents(); const { data: conversations = [] } = useConversations(); + const { data: workspaces = [] } = useWorkspaces(); + const scope = useUi((s) => s.workspace); + const setScope = useUi((s) => s.setWorkspace); const startChat = useStartAgentChat(); const [q, setQ] = useState(""); const query = q.trim().toLowerCase(); @@ -167,6 +171,29 @@ export function CommandPalette() { )} + {searching && workspaces.length > 0 && ( + + {workspaces.map((w) => ( + { + setScope(w.id); + setOpen(false); + }} + > + + {w.name} + {scope === w.id && current} + + ))} + {scope !== "all" && ( + { setScope("all"); setOpen(false); }}> + All workspaces {modKey}0 + + )} + + )} {ticketHits.length > 0 && ( {ticketHits.map((t) => ( diff --git a/apps/desktop/src/components/layout/shortcuts-dialog.tsx b/apps/desktop/src/components/layout/shortcuts-dialog.tsx index dbe6b8ee..1dd308e9 100644 --- a/apps/desktop/src/components/layout/shortcuts-dialog.tsx +++ b/apps/desktop/src/components/layout/shortcuts-dialog.tsx @@ -23,6 +23,8 @@ const GROUPS: { title: string; rows: { keys: ReactNode[]; what: string }[] }[] = { keys: [`${modKey}K`], what: "Search chats, tickets, automations, settings — and what waits for you" }, { keys: [`${modKey}N`], what: "New chat" }, { keys: [`${modKey}B`], what: "Show or hide the sidebar" }, + { keys: [`${modKey}1–9`], what: "Switch to a workspace (in A–Z order)" }, + { keys: [`${modKey}0`], what: "All workspaces" }, { keys: [`${modKey},`], what: "Settings" }, { keys: ["?"], what: "This list" }, ], @@ -45,7 +47,7 @@ const GROUPS: { title: string; rows: { keys: ReactNode[]; what: string }[] }[] = title: "Lists", rows: [ { keys: ["C"], what: "New ticket (Tasks)" }, - { keys: ["/"], what: "Search (Agents, Logins)" }, + { keys: ["/"], what: "Search (Agents, Logins, Workspaces)" }, ], }, ]; diff --git a/apps/desktop/src/components/layout/workspace-switcher.tsx b/apps/desktop/src/components/layout/workspace-switcher.tsx index a442a778..0e8cedbe 100644 --- a/apps/desktop/src/components/layout/workspace-switcher.tsx +++ b/apps/desktop/src/components/layout/workspace-switcher.tsx @@ -34,8 +34,8 @@ export function WorkspaceSwitcher() { const sorted = useMemo(() => [...workspaces].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })), [workspaces]); const recent = useMemo( - () => (sorted.length > 6 ? recentIds.flatMap((id) => sorted.find((w) => w.id === id) ?? []).slice(0, 3) : []), - [sorted, recentIds], + () => (sorted.length > 6 ? recentIds.flatMap((id) => (id === scope ? [] : (sorted.find((w) => w.id === id) ?? []))).slice(0, 3) : []), + [sorted, recentIds, scope], ); useEffect(() => { @@ -224,13 +224,13 @@ function ScopeRow({ {children} - {selected ? ( - - ) : shortcut ? ( - {shortcut} - ) : ( - - )} + + {selected ? ( + + ) : shortcut ? ( + {shortcut} + ) : null} + ); } diff --git a/apps/desktop/src/components/workspaces/workspace-activity.ts b/apps/desktop/src/components/workspaces/workspace-activity.ts index 7930c4c5..406dcbfb 100644 --- a/apps/desktop/src/components/workspaces/workspace-activity.ts +++ b/apps/desktop/src/components/workspaces/workspace-activity.ts @@ -1,9 +1,11 @@ import { useMemo } from "react"; +import type { AttentionItem } from "@godmode/shared"; import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api"; import { useAllAgents, useAttention, useConversations } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; import { useLive } from "@/stores/live"; +import { useUi } from "@/stores/ui"; export interface WorkspaceActivity { /** Runs working or queued right now. */ @@ -49,10 +51,7 @@ export function useWorkspaceActivity(): { of: (scope: string | null) => Workspac if (r.status !== "running" && r.status !== "queued") continue; at(chatWs.has(r.conversationId) ? chatWs.get(r.conversationId) : agentWs.get(r.agentId)).running++; } - for (const item of attention ?? []) { - const ws = item.task ? item.task.workspaceId : item.conversationId && chatWs.has(item.conversationId) ? chatWs.get(item.conversationId) : item.agentId ? agentWs.get(item.agentId) : null; - at(ws).needsYou++; - } + for (const item of attention ?? []) at(workspaceOfItem(item, chatWs, agentWs)).needsYou++; for (const t of tasks ?? []) if (!t.archivedAt && t.status !== "done" && t.status !== "cancelled") at(t.workspaceId).openTasks++; return out; }, [agents, conversations, attention, tasks, runs]); @@ -71,3 +70,25 @@ export function useWorkspaceActivity(): { of: (scope: string | null) => Workspac return useMemo(() => ({ of: (scope: string | null) => map.get(scope ?? "global") ?? EMPTY, total }), [map, total]); } + +function workspaceOfItem(item: AttentionItem, chatWs: Map, agentWs: Map): string | null { + if (item.task) return item.task.workspaceId ?? null; + if (item.conversationId && chatWs.has(item.conversationId)) return chatWs.get(item.conversationId) ?? null; + return item.agentId ? (agentWs.get(item.agentId) ?? null) : null; +} + +/** What waits for the human in the workspace picked in the sidebar, how many wait elsewhere, and where each one belongs. */ +export function useScopedAttention() { + const scope = useUi((s) => s.workspace); + const { data: agents } = useAllAgents(); + const { data: conversations } = useConversations(); + const { data: attention = [] } = useAttention(); + return useMemo(() => { + const agentWs = new Map((agents ?? []).map((a) => [a.id, a.workspaceId ?? null])); + const chatWs = new Map((conversations ?? []).map((c) => [c.id, c.workspaceId ?? agentWs.get(c.agentId) ?? null])); + const workspaceOf = (item: AttentionItem) => workspaceOfItem(item, chatWs, agentWs); + const target = scope === "global" ? null : scope; + const items = scope === "all" ? attention : attention.filter((i) => workspaceOf(i) === target); + return { items, elsewhere: attention.length - items.length, workspaceOf }; + }, [scope, agents, conversations, attention]); +} diff --git a/apps/desktop/src/pages/chat/chat-home.tsx b/apps/desktop/src/pages/chat/chat-home.tsx index e3c5b6e0..06f048c7 100644 --- a/apps/desktop/src/pages/chat/chat-home.tsx +++ b/apps/desktop/src/pages/chat/chat-home.tsx @@ -27,11 +27,12 @@ import { VoiceMode } from "@/components/chat/voice-mode"; import { formatElapsed } from "@/components/runs/run-status"; import { api, errorMessage, isLicenseRequired } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; -import { useAllAgents, useBootstrap, useInScope, useRunners, useScopedConversations, useScopeWorkspace, useWorkspaces, useAttention } from "@/lib/hooks"; +import { useAllAgents, useBootstrap, useInScope, useRunners, useScopedConversations, useScopeWorkspace, useWorkspaces } from "@/lib/hooks"; import { modKey } from "@/lib/desktop"; import { useVoiceSession } from "@/lib/voice"; import { useDraft } from "@/lib/drafts"; import { AttentionList } from "@/components/attention/attention-list"; +import { useScopedAttention } from "@/components/workspaces/workspace-activity"; import { AwaySummaryCard } from "@/components/attention/away-summary"; import { useLive, type LiveRun } from "@/stores/live"; import { useUi } from "@/stores/ui"; @@ -311,10 +312,23 @@ export default function ChatHome() { /** What waits for the human, first thing on Home: the newest few, the rest in the Inbox. */ function NeedsYou({ agents }: { agents: Agent[] }) { - const { data: items = [] } = useAttention(); + const { items, elsewhere, workspaceOf } = useScopedAttention(); + const scope = useUi((s) => s.workspace); + const { data: workspaces = [] } = useWorkspaces(); const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]); - if (!items.length) return null; + if (!items.length && !elsewhere) return null; const shown = items.slice(0, 5); + if (!items.length) + return ( + + + Nothing waits for you here. {elsewhere} {elsewhere === 1 ? "thing waits" : "things wait"} in other workspaces + + + ); return (
    @@ -322,13 +336,17 @@ function NeedsYou({ agents }: { agents: Agent[] }) { Needs you {items.length} - {items.length > shown.length && ( + {(items.length > shown.length || elsewhere > 0) && ( - All {items.length} in the Inbox + {elsewhere > 0 ? `+${elsewhere} in other workspaces · Inbox` : `All ${items.length} in the Inbox`} )}
    - + workspaces.find((w) => w.id === workspaceOf(item)) : undefined} + />
    ); } diff --git a/apps/desktop/src/pages/inbox/inbox-page.tsx b/apps/desktop/src/pages/inbox/inbox-page.tsx index 48f51c43..38b43b0e 100644 --- a/apps/desktop/src/pages/inbox/inbox-page.tsx +++ b/apps/desktop/src/pages/inbox/inbox-page.tsx @@ -13,7 +13,8 @@ import { NotificationList } from "@/components/inbox/notification-list"; import { InboxQuestion } from "@/components/inbox/question-item"; import { isVaultLocked } from "@/components/vault/vault-utils"; import { errorMessage } from "@/lib/api"; -import { useAllAgents, useAttention, useMissingLogins, useQuestions } from "@/lib/hooks"; +import { useAllAgents, useAttention, useMissingLogins, useQuestions, useWorkspaces } from "@/lib/hooks"; +import { useScopedAttention } from "@/components/workspaces/workspace-activity"; import { AttentionList } from "@/components/attention/attention-list"; type Entry = { kind: "login"; item: MissingLogin; at: number } | { kind: "question"; item: AgentQuestion; at: number }; @@ -64,6 +65,8 @@ export default function InboxPage() { ); // The rest of what waits (tickets, paused chats, budgets, failures, people): questions and logins have their cards below. const attention = useAttention(); + const { workspaceOf } = useScopedAttention(); + const { data: workspaces = [] } = useWorkspaces(); const others = (attention.data ?? []).filter((i) => i.kind !== "question" && i.kind !== "login"); const openCount = (open.data?.length ?? 0) + (qOpen.data?.length ?? 0) + others.length; @@ -116,7 +119,7 @@ export default function InboxPage() { {tab === "open" && others.length > 0 && (
    - + workspaces.find((w) => w.id === workspaceOf(item)) : undefined} />
    )} {current.isLoading && currentQuestions.isLoading ? ( From 36eca370323ca1e568a2d69519110e23eb99a8e5 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Wed, 7 Oct 2026 08:10:32 +0200 Subject: [PATCH 3/3] Workspace shortcuts work with the sidebar closed and stay out of dialogs; list-view stats get their tooltips --- .../src/components/layout/app-shell.tsx | 3 +- .../components/layout/workspace-switcher.tsx | 40 +++++++++---------- .../src/pages/workspaces/workspaces-page.tsx | 4 +- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/components/layout/app-shell.tsx b/apps/desktop/src/components/layout/app-shell.tsx index 30b45746..085252f4 100644 --- a/apps/desktop/src/components/layout/app-shell.tsx +++ b/apps/desktop/src/components/layout/app-shell.tsx @@ -52,7 +52,7 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Logo, Wordmark } from "@/components/brand"; import { LiveDot } from "@/components/aicss/Motion"; -import { WorkspaceSwitcher } from "@/components/layout/workspace-switcher"; +import { WorkspaceSwitcher, useWorkspaceShortcuts } from "@/components/layout/workspace-switcher"; import { RecentChats } from "@/components/layout/recent-chats"; import { CommandPalette } from "@/components/layout/command-palette"; import { UpdateButton } from "@/components/layout/update-button"; @@ -122,6 +122,7 @@ export function AppShell({ children }: { children: ReactNode }) { // Notices when the human comes back after a while (Home then sums up what happened). useEffect(() => startPresence(), []); + useWorkspaceShortcuts(); // Global shortcuts useEffect(() => { diff --git a/apps/desktop/src/components/layout/workspace-switcher.tsx b/apps/desktop/src/components/layout/workspace-switcher.tsx index 0e8cedbe..7453f106 100644 --- a/apps/desktop/src/components/layout/workspace-switcher.tsx +++ b/apps/desktop/src/components/layout/workspace-switcher.tsx @@ -13,13 +13,28 @@ import { isMac, modKey } from "@/lib/desktop"; import { useUi } from "@/stores/ui"; import { cn } from "@/lib/utils"; -/** Typing somewhere ⌘1 could mean something else (a remote screen, a field): the shortcut stays out of it. */ -function busyTarget(target: EventTarget | null): boolean { - const el = target as HTMLElement | null; - return !!el?.closest("[role=application]"); +/** ⌘0 is all workspaces, ⌘1–9 the workspaces in A–Z order. Not behind dialogs or on a remote screen the human controls. */ +export function useWorkspaceShortcuts() { + const { data: workspaces = [] } = useWorkspaces(); + const setScope = useUi((s) => s.setWorkspace); + useEffect(() => { + const sorted = [...workspaces].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); + const onKey = (e: KeyboardEvent) => { + const mod = isMac ? e.metaKey : e.ctrlKey; + if (!mod || e.shiftKey || e.altKey || !/^Digit[0-9]$/.test(e.code)) return; + if ((e.target as HTMLElement | null)?.closest?.("[role=application],[role=dialog],[role=alertdialog]")) return; + const n = Number(e.code.slice(5)); + const next = n === 0 ? "all" : sorted[n - 1]?.id; + if (!next) return; + e.preventDefault(); + setScope(next); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [workspaces, setScope]); } -/** Switches the active scope: all workspaces, global only, or a single workspace. ⌘0 is all, ⌘1–9 the workspaces in order. */ +/** Switches the active scope: all workspaces, global only, or a single workspace. */ export function WorkspaceSwitcher() { const { data: workspaces = [] } = useWorkspaces(); const scope = useUi((s) => s.workspace); @@ -38,21 +53,6 @@ export function WorkspaceSwitcher() { [sorted, recentIds, scope], ); - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - const mod = isMac ? e.metaKey : e.ctrlKey; - if (!mod || e.shiftKey || e.altKey || !/^Digit[0-9]$/.test(e.code) || busyTarget(e.target)) return; - const n = Number(e.code.slice(5)); - const next = n === 0 ? "all" : sorted[n - 1]?.id; - if (!next) return; - e.preventDefault(); - setScope(next); - setOpen(false); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [sorted, setScope]); - useEffect(() => { if (!open) setQuery(""); }, [open]); diff --git a/apps/desktop/src/pages/workspaces/workspaces-page.tsx b/apps/desktop/src/pages/workspaces/workspaces-page.tsx index 87f6abfa..7d11010f 100644 --- a/apps/desktop/src/pages/workspaces/workspaces-page.tsx +++ b/apps/desktop/src/pages/workspaces/workspaces-page.tsx @@ -748,8 +748,8 @@ function ScopeListRow({ {activity.lastActive ? `Active ${formatDistanceToNowStrict(new Date(activity.lastActive), { addSuffix: true })}` : description || "No activity yet"}
    - -
    + +
    {stats.map((s) => (