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
31 changes: 26 additions & 5 deletions apps/desktop/src/components/attention/attention-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AttentionItem["kind"], LucideIcon> = {
question: MessageCircleQuestion,
Expand All @@ -36,17 +37,28 @@ const TONE: Partial<Record<AttentionItem["kind"], string>> = {
* 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<string, Agent>; className?: string }) {
export function AttentionList({
items,
agentById,
workspaceOf,
className,
}: {
items: AttentionItem[];
agentById: Map<string, Agent>;
/** Shows which workspace each row belongs to (when the list mixes them). */
workspaceOf?: (item: AttentionItem) => Workspace | undefined;
className?: string;
}) {
return (
<ul className={cn("divide-y overflow-hidden rounded-xl border bg-card shadow-card", className)}>
{items.map((item) => (
<AttentionRow key={item.id} item={item} agent={item.agentId ? agentById.get(item.agentId) : undefined} />
<AttentionRow key={item.id} item={item} agent={item.agentId ? agentById.get(item.agentId) : undefined} workspace={workspaceOf?.(item)} />
))}
</ul>
);
}

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({
Expand Down Expand Up @@ -77,9 +89,18 @@ function AttentionRow({ item, agent }: { item: AttentionItem; agent?: Agent }) {
<p className="truncate text-[13px] font-medium" title={item.title}>
{item.title}
</p>
<p className="truncate text-xs text-muted-foreground" title={item.detail}>
<p className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground" title={item.detail}>
{workspace && (
<span className="inline-flex shrink-0 items-center gap-1 text-foreground/70">
<WorkspaceTile icon={workspace.icon} color={workspace.color} size="sm" className="size-3.5 rounded-[4px] text-[8px]" />
{workspace.name}
<span className="text-muted-foreground/60">·</span>
</span>
)}
<span className="truncate">
{item.detail ? `${item.detail} · ` : ""}
<time dateTime={item.since}>{formatDistanceToNowStrict(new Date(item.since), { addSuffix: true })}</time>
</span>
</p>
</div>
{item.kind === "paused" && item.conversationId ? (
Expand Down
91 changes: 77 additions & 14 deletions apps/desktop/src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ArrowLeft,
Bot,
Box,
ChevronRight,
Globe,
MonitorUp,
Inbox,
Expand Down Expand Up @@ -51,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";
Expand Down Expand Up @@ -121,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(() => {
Expand Down Expand Up @@ -166,18 +168,20 @@ export function AppShell({ children }: { children: ReactNode }) {
}, [navigate, setCommandOpen]);

const workNav: NavItem[] = [
{ to: "/inbox", label: "Inbox", icon: <Inbox />, badge: inboxCount || undefined },
{ to: "/tasks", label: "Tasks", icon: <SquareKanban />, badge: (attention?.review ?? 0) + (attention?.blocked ?? 0) || undefined },
{ to: "/agents", label: "Agents", icon: <Bot />, badge: runningCount || undefined },
{ to: "/automations", label: "Automations", icon: <Workflow />, badge: attention?.automation || undefined },
{ to: "/mods", label: "Mods", icon: <Puzzle />, badge: modsWaiting || undefined },
{ to: "/activity", label: "Activity", icon: <Activity /> },
{ to: "/inbox", label: "Inbox", icon: <Inbox />, badge: inboxCount || undefined },
{ to: "/mods", label: "Mods", icon: <Puzzle />, badge: modsWaiting || undefined },
];
const accessNav: NavItem[] = [
{ to: "/vault/logins", label: "Logins", icon: <KeyRound />, badge: undefined },
{ to: "/vault/logins", label: "Logins", icon: <KeyRound /> },
{ to: "/vault/2fa", label: "2FA Codes", icon: <ShieldCheck /> },
{ to: "/integrations", label: "Integrations", icon: <Plug /> },
{ to: "/messaging", label: "Messaging", icon: <MessageCircle />, badge: boot?.counts.messagingRequests || undefined },
];
const machineNav: NavItem[] = [
{ to: "/browser", label: "Browser", icon: <Globe /> },
{ to: "/computer", label: "Computer", icon: <MonitorUp /> },
{ to: "/vms", label: "Virtual machines", icon: <Box /> },
Expand Down Expand Up @@ -237,16 +241,8 @@ export function AppShell({ children }: { children: ReactNode }) {
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel className="eyebrow text-[10.5px]">Access</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="group-data-[collapsible=icon]:items-center">
{accessNav.map((item) => (
<NavMenuItem key={item.to} item={item} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<NavSection id="access" label="Access" items={accessNav} />
<NavSection id="machines" label="Machines" items={machineNav} />
<RecentChats />
</SidebarContent>

Expand Down Expand Up @@ -359,6 +355,73 @@ function DesktopDragStrip() {
return <div className="absolute inset-x-0 top-0 z-50 h-7" data-tauri-drag-region />;
}

/** 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 = (
<button
type="button"
onClick={() => toggle(id)}
aria-expanded={open}
aria-controls={listId}
className="eyebrow group/label flex h-7 min-w-0 shrink items-center gap-1 rounded-md px-2 text-[10.5px] text-sidebar-foreground/70 outline-none transition hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring group-data-[collapsible=icon]:hidden"
>
{label}
<ChevronRight className={cn("size-3 opacity-0 transition group-hover/label:opacity-100 group-focus-visible/label:opacity-100", open && "rotate-90")} />
</button>
);
if (open)
return (
<SidebarGroup className="py-1">
{header}
<SidebarGroupContent id={listId}>
<SidebarMenu className="group-data-[collapsible=icon]:items-center">
{items.map((item) => (
<NavMenuItem key={item.to} item={item} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
return (
<SidebarGroup className="flex-row items-center justify-between gap-1 py-0.5">
{header}
<div id={listId} className="flex items-center">
{items.map((item) => {
const active = pathname.startsWith(item.to);
return (
<Tooltip key={item.to}>
<TooltipTrigger asChild>
<NavLink
to={item.to}
aria-label={item.badge ? `${item.label} (${item.badge})` : item.label}
className={cn(
"relative grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring [&>svg]:size-4",
active && "bg-card text-foreground shadow-card ring-1 ring-border",
)}
>
{item.icon}
{item.badge ? <span className="absolute top-1 right-1 size-1.5 rounded-full bg-brand ring-2 ring-sidebar" aria-hidden /> : null}
</NavLink>
</TooltipTrigger>
<TooltipContent side="bottom">
{item.label}
{item.badge ? ` · ${item.badge}` : ""}
</TooltipContent>
</Tooltip>
);
})}
</div>
</SidebarGroup>
);
}

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);
Expand Down
29 changes: 28 additions & 1 deletion apps/desktop/src/components/layout/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -167,6 +171,29 @@ export function CommandPalette() {
<CommandSeparator />
</>
)}
{searching && workspaces.length > 0 && (
<CommandGroup heading="Switch workspace">
{workspaces.map((w) => (
<CommandItem
key={w.id}
value={unique(`workspace switch ${w.name}`, w.id)}
onSelect={() => {
setScope(w.id);
setOpen(false);
}}
>
<WorkspaceTile icon={w.icon} color={w.color} size="sm" className="size-5 rounded-[5px] text-[11px]" />
<span className="truncate">{w.name}</span>
{scope === w.id && <span className="ml-2 text-xs text-muted-foreground">current</span>}
</CommandItem>
))}
{scope !== "all" && (
<CommandItem value="workspace switch all workspaces everything" onSelect={() => { setScope("all"); setOpen(false); }}>
<Layers /> All workspaces <CommandShortcut>{modKey}0</CommandShortcut>
</CommandItem>
)}
</CommandGroup>
)}
{ticketHits.length > 0 && (
<CommandGroup heading="Tickets">
{ticketHits.map((t) => (
Expand Down
58 changes: 50 additions & 8 deletions apps/desktop/src/components/layout/recent-chats.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";

Expand Down Expand Up @@ -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;

Expand All @@ -88,14 +107,21 @@ export function RecentChats() {
<SidebarGroupContent id="recent-chats-panel" role="tabpanel" aria-labelledby={`recent-tab-${recentTab}`}>
<SidebarMenu>
{items.length === 0 && <EmptyTab tab={recentTab} scopeName={scopeName} hasArchived={hasArchived} />}
{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 (
<SidebarMenuItem key={c.id}>
<Fragment key={c.id}>
{day && (
<li role="presentation" className={cn("px-2 pb-0.5 text-[10.5px] font-medium text-muted-foreground/80", i > 0 ? "pt-3" : "pt-1")}>
{day}
</li>
)}
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={conversationId === c.id}
Expand Down Expand Up @@ -189,8 +215,24 @@ export function RecentChats() {
<TooltipContent side="top">Delete</TooltipContent>
</Tooltip>
</SidebarMenuItem>
</Fragment>
);
})}
{more > 0 && (
<SidebarMenuItem>
<SidebarMenuButton
size="sm"
onClick={() => setLimit((n) => n + PAGE)}
className="mt-1 gap-2 text-[12.5px] text-muted-foreground [&>svg]:size-3.5"
>
<ChevronDown />
<span>
Show {Math.min(more, PAGE)} more
<span className="ml-1 text-muted-foreground/60">of {more}</span>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
{hasArchived && (
<SidebarMenuItem>
<SidebarMenuButton
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/components/layout/shortcuts-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
Expand All @@ -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)" },
],
},
];
Expand Down
Loading
Loading