@saadnvd1/agent-os 0.3.603 → 0.3.605

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/app/page.tsx +3 -8
  2. package/components/Bookmarks/ChatBookmarksButton.tsx +4 -0
  3. package/components/Bus/MessagesPanel.tsx +155 -0
  4. package/components/Bus/index.ts +1 -2
  5. package/components/Chat/ChatPanel.tsx +9 -1
  6. package/components/CommandPalette/useAppCommands.ts +12 -4
  7. package/components/Features/FeaturesSection.tsx +2 -2
  8. package/components/Inbox/InboxButton.tsx +39 -0
  9. package/components/Inbox/InboxDialog.tsx +139 -0
  10. package/components/Inbox/NeedsYouList.tsx +120 -0
  11. package/components/Inbox/index.ts +2 -0
  12. package/components/LumifyHub/index.ts +0 -1
  13. package/components/Pane/DesktopTabBar.tsx +2 -0
  14. package/components/Pane/PaneMenu.test.ts +118 -0
  15. package/components/Pane/PaneMenu.tsx +37 -0
  16. package/components/SessionList/SidebarHeader.tsx +7 -4
  17. package/components/Tasks/TaskRow.tsx +3 -3
  18. package/components/Tasks/TasksPanel.tsx +63 -0
  19. package/components/Tasks/index.ts +1 -1
  20. package/components/views/DesktopAppActions.tsx +10 -63
  21. package/components/views/DesktopView.tsx +1 -11
  22. package/hooks/useInbox.ts +69 -0
  23. package/lib/inbox/counts.ts +140 -0
  24. package/package.json +1 -1
  25. package/scripts/check-bundle.mjs +25 -10
  26. package/scripts/check-bundle.test.ts +2 -2
  27. package/stores/inboxUi.ts +60 -0
  28. package/stores/needsYouFocus.ts +11 -3
  29. package/components/Bus/MessagesButton.tsx +0 -25
  30. package/components/Bus/MessagesDialog.tsx +0 -176
  31. package/components/LumifyHub/Docs/DocsButton.tsx +0 -31
  32. package/components/NotificationsMenu.tsx +0 -86
  33. package/components/Tasks/TasksButton.tsx +0 -28
  34. package/components/Tasks/TasksDialog.tsx +0 -75
  35. package/stores/busUi.ts +0 -12
  36. package/stores/tasksUi.ts +0 -14
@@ -0,0 +1,118 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { Session } from "@/lib/db/types";
6
+ import { bookmarksUi } from "@/stores/bookmarksUi";
7
+ import { docsUi } from "@/stores/docsUi";
8
+ import { paletteUi } from "@/stores/palette";
9
+
10
+ const data = vi.hoisted(() => ({
11
+ bookmarks: [] as unknown[],
12
+ docs: null as { id: string } | null,
13
+ }));
14
+ vi.mock("@/data/bookmarks", () => ({
15
+ useSessionBookmarks: () => ({ data: data.bookmarks }),
16
+ }));
17
+ vi.mock("@/data/lumifyhub/docs", () => ({
18
+ useDocsWorkspace: () => data.docs,
19
+ }));
20
+ vi.mock("@/components/Tasks/MoveMenuItems", () => ({
21
+ MoveMenuItems: () => null,
22
+ }));
23
+ // Radix's focus handling needs a real browser; plain elements show what the
24
+ // menu offers.
25
+ vi.mock("@/components/ui/dropdown-menu", async () => {
26
+ const { createElement: h } = await import("react");
27
+ const part =
28
+ (tag: string) => (props: { children?: unknown; onSelect?: () => void }) =>
29
+ h(tag, { onClick: props.onSelect }, props.children as never);
30
+ return {
31
+ DropdownMenu: part("div"),
32
+ DropdownMenuTrigger: part("div"),
33
+ DropdownMenuContent: part("div"),
34
+ DropdownMenuLabel: part("div"),
35
+ DropdownMenuItem: part("button"),
36
+ DropdownMenuSeparator: part("hr"),
37
+ DropdownMenuShortcut: part("span"),
38
+ };
39
+ });
40
+
41
+ import { PaneMenu } from "./PaneMenu";
42
+
43
+ (
44
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
45
+ ).IS_REACT_ACT_ENVIRONMENT = true;
46
+
47
+ const session = {
48
+ id: "s1",
49
+ name: "Chat",
50
+ project_id: "p1",
51
+ view: "chat",
52
+ } as Session;
53
+
54
+ describe("PaneMenu", () => {
55
+ let host: HTMLElement;
56
+ let root: Root;
57
+ beforeEach(() => {
58
+ host = document.createElement("div");
59
+ document.body.appendChild(host);
60
+ root = createRoot(host);
61
+ data.bookmarks = [];
62
+ data.docs = null;
63
+ paletteUi.open = false;
64
+ bookmarksUi.chatSessionId = null;
65
+ docsUi.workspaceId = null;
66
+ });
67
+ afterEach(() => {
68
+ act(() => root.unmount());
69
+ host.remove();
70
+ });
71
+ const render = (bookmarksSessionId: string | null = "s1") =>
72
+ act(() => {
73
+ root.render(
74
+ createElement(PaneMenu, {
75
+ session,
76
+ bookmarksSessionId,
77
+ canSplit: true,
78
+ canClose: true,
79
+ hasAttachedTmux: false,
80
+ onSplitHorizontal: () => {},
81
+ onSplitVertical: () => {},
82
+ onClose: () => {},
83
+ onDetach: () => {},
84
+ })
85
+ );
86
+ });
87
+ const item = (text: string) =>
88
+ [...host.querySelectorAll("button")].find((b) =>
89
+ b.textContent?.startsWith(text)
90
+ );
91
+
92
+ it("always offers the command palette with its shortcut, and opens it", () => {
93
+ render(null);
94
+ const palette = item("Command palette");
95
+ expect(palette?.textContent).toContain("⌘K");
96
+ act(() => palette?.click());
97
+ expect(paletteUi.open).toBe(true);
98
+ });
99
+
100
+ it("offers Bookmarks with its count for a chat only", () => {
101
+ data.bookmarks = [{}, {}, {}];
102
+ render();
103
+ expect(item("Bookmarks")?.textContent).toContain("3");
104
+ act(() => root.unmount());
105
+ root = createRoot(host);
106
+ render(null);
107
+ expect(item("Bookmarks")).toBeUndefined();
108
+ });
109
+
110
+ it("offers Docs only when the project's workspace has them", () => {
111
+ render();
112
+ expect(item("Docs")).toBeUndefined();
113
+ data.docs = { id: "w1" };
114
+ render();
115
+ act(() => item("Docs")?.click());
116
+ expect(docsUi.workspaceId).toBe("w1");
117
+ });
118
+ });
@@ -2,12 +2,15 @@
2
2
 
3
3
  import { toast } from "sonner";
4
4
  import {
5
+ BookOpen,
6
+ Command,
5
7
  Copy,
6
8
  Link,
7
9
  MoreHorizontal,
8
10
  Share2,
9
11
  SplitSquareHorizontal,
10
12
  SplitSquareVertical,
13
+ Star,
11
14
  Unplug,
12
15
  X,
13
16
  } from "lucide-react";
@@ -18,15 +21,23 @@ import {
18
21
  DropdownMenuItem,
19
22
  DropdownMenuLabel,
20
23
  DropdownMenuSeparator,
24
+ DropdownMenuShortcut,
21
25
  DropdownMenuTrigger,
22
26
  } from "@/components/ui/dropdown-menu";
23
27
  import { copySessionLink, writeClipboard } from "@/hooks/useCopyToClipboard";
24
28
  import type { Session } from "@/lib/db";
25
29
  import { shareUiActions } from "@/stores/shareUi";
30
+ import { useSessionBookmarks } from "@/data/bookmarks";
31
+ import { useDocsWorkspace } from "@/data/lumifyhub/docs";
32
+ import { bookmarksUiActions } from "@/stores/bookmarksUi";
33
+ import { docsUiActions } from "@/stores/docsUi";
34
+ import { paletteActions } from "@/stores/palette";
26
35
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
27
36
 
28
37
  interface PaneMenuProps {
29
38
  session: Session | null | undefined;
39
+ // The chat on show, for its bookmarks; null for a terminal or another view.
40
+ bookmarksSessionId?: string | null;
30
41
  canSplit: boolean;
31
42
  canClose: boolean;
32
43
  hasAttachedTmux: boolean;
@@ -40,6 +51,7 @@ interface PaneMenuProps {
40
51
  // the bar.
41
52
  export function PaneMenu({
42
53
  session,
54
+ bookmarksSessionId,
43
55
  canSplit,
44
56
  canClose,
45
57
  hasAttachedTmux,
@@ -48,6 +60,8 @@ export function PaneMenu({
48
60
  onClose,
49
61
  onDetach,
50
62
  }: PaneMenuProps) {
63
+ const { data: bookmarks = [] } = useSessionBookmarks(bookmarksSessionId);
64
+ const docs = useDocsWorkspace(session?.project_id);
51
65
  const copyId = async (id: string) => {
52
66
  try {
53
67
  await writeClipboard(id);
@@ -112,6 +126,29 @@ export function PaneMenu({
112
126
  />
113
127
  </>
114
128
  )}
129
+ {bookmarksSessionId && (
130
+ <DropdownMenuItem
131
+ onSelect={() => bookmarksUiActions.openChat(bookmarksSessionId)}
132
+ >
133
+ <Star className="h-4 w-4" />
134
+ Bookmarks
135
+ {bookmarks.length > 0 && (
136
+ <DropdownMenuShortcut>{bookmarks.length}</DropdownMenuShortcut>
137
+ )}
138
+ </DropdownMenuItem>
139
+ )}
140
+ <DropdownMenuItem onSelect={paletteActions.open}>
141
+ <Command className="h-4 w-4" />
142
+ Command palette
143
+ <DropdownMenuShortcut>⌘K</DropdownMenuShortcut>
144
+ </DropdownMenuItem>
145
+ {docs && (
146
+ <DropdownMenuItem onSelect={() => docsUiActions.open(docs.id)}>
147
+ <BookOpen className="h-4 w-4" />
148
+ Docs
149
+ </DropdownMenuItem>
150
+ )}
151
+ <DropdownMenuSeparator />
115
152
  {hasAttachedTmux && (
116
153
  <DropdownMenuItem onSelect={onDetach}>
117
154
  <Unplug className="h-4 w-4" />
@@ -31,11 +31,10 @@ import { Button } from "@/components/ui/button";
31
31
  import type { Workspace } from "@/lib/db";
32
32
  import { archivedUiActions } from "@/stores/archivedUi";
33
33
  import { bookmarksUiActions } from "@/stores/bookmarksUi";
34
- import { tasksUiActions } from "@/stores/tasksUi";
34
+ import { inboxUiActions } from "@/stores/inboxUi";
35
35
  import { newDraft } from "@/stores/drafts";
36
36
  import { schedulesUiActions } from "@/stores/schedulesUi";
37
37
  import { recipesUiActions } from "@/stores/recipesUi";
38
- import { busUiActions } from "@/stores/busUi";
39
38
  import { settingsUiActions } from "@/stores/settingsUi";
40
39
  import { usageUiActions } from "@/stores/usageUi";
41
40
  import { WorkspaceSwitcher } from "./WorkspaceSwitcher";
@@ -99,7 +98,9 @@ export function SidebarHeader(props: SidebarHeaderProps) {
99
98
  </Button>
100
99
  }
101
100
  items={[
102
- menuItem("Tasks", tasksUiActions.openPanel, { icon: ListTodo }),
101
+ menuItem("Tasks", () => inboxUiActions.open("tasks"), {
102
+ icon: ListTodo,
103
+ }),
103
104
  // Opens Settings > Schedules.
104
105
  menuItem("Schedules", () => schedulesUiActions.open(workspaceId), {
105
106
  icon: Clock,
@@ -115,7 +116,9 @@ export function SidebarHeader(props: SidebarHeaderProps) {
115
116
  menuItem("Recipes", () => recipesUiActions.open(workspaceId), {
116
117
  icon: ChefHat,
117
118
  }),
118
- menuItem("Messages", busUiActions.open, { icon: MessagesSquare }),
119
+ menuItem("Messages", () => inboxUiActions.open("messages"), {
120
+ icon: MessagesSquare,
121
+ }),
119
122
  ...(props.onOpenDocs
120
123
  ? [menuItem("Docs", props.onOpenDocs, { icon: BookOpen })]
121
124
  : []),
@@ -15,7 +15,7 @@ import { isFinished } from "@/lib/tasks/state";
15
15
  import { useArmTask, useDropTask, useSignOffTask } from "@/data/tasks";
16
16
  import { useResurrectSession } from "@/data/done";
17
17
  import { tmuxAttachActions } from "@/stores/tmuxAttach";
18
- import { tasksUiActions } from "@/stores/tasksUi";
18
+ import { inboxUiActions } from "@/stores/inboxUi";
19
19
  import { recipesUiActions } from "@/stores/recipesUi";
20
20
  import { cn } from "@/lib/utils";
21
21
  import { SimulatorIndicator } from "@/components/Simulator";
@@ -103,7 +103,7 @@ export function TaskRow({ task }: { task: TaskView }) {
103
103
  onClick={() => {
104
104
  if (task.hostError) return;
105
105
  tmuxAttachActions.request(task.tmuxName, task.hostId ?? "local");
106
- tasksUiActions.setPanelOpen(false);
106
+ inboxUiActions.setOpen(false);
107
107
  }}
108
108
  >
109
109
  <SquareTerminal className="h-3.5 w-3.5" />
@@ -191,7 +191,7 @@ export function TaskRow({ task }: { task: TaskView }) {
191
191
  className="text-muted-foreground h-11 md:h-8"
192
192
  onClick={() => {
193
193
  recipesUiActions.saveSessionAs(task.id);
194
- tasksUiActions.setPanelOpen(false);
194
+ inboxUiActions.setOpen(false);
195
195
  }}
196
196
  >
197
197
  <ChefHat className="h-3.5 w-3.5" />
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import { Plus } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { useTasksQuery } from "@/data/tasks";
6
+ import { isFinished, needsHuman } from "@/lib/tasks/state";
7
+ import { inboxUiActions } from "@/stores/inboxUi";
8
+ import { newDraft } from "@/stores/drafts";
9
+ import { TaskRow } from "./TaskRow";
10
+ import { BoardTodoSection } from "@/components/LumifyHub";
11
+ import { StacksSection } from "@/components/Stacks";
12
+ import { FeaturesSection } from "@/components/Features";
13
+
14
+ export function TasksPanel() {
15
+ const { data: tasks = [], isPending } = useTasksQuery();
16
+ const active = tasks.filter((t) => !isFinished(t.state));
17
+ // Whatever needs a human first.
18
+ active.sort(
19
+ (a, b) => Number(needsHuman(b.state)) - Number(needsHuman(a.state))
20
+ );
21
+ const done = tasks.filter((t) => !active.includes(t)).slice(0, 10);
22
+
23
+ return (
24
+ <div className="space-y-3 [&>*]:min-w-0">
25
+ <div className="flex justify-end">
26
+ <Button
27
+ size="sm"
28
+ onClick={() => {
29
+ inboxUiActions.setOpen(false);
30
+ newDraft({ kind: "current", openPr: true });
31
+ }}
32
+ >
33
+ <Plus className="h-4 w-4" />
34
+ New task
35
+ </Button>
36
+ </div>
37
+ {isPending && (
38
+ <div className="bg-muted/40 h-16 animate-pulse rounded-xl" />
39
+ )}
40
+ {!isPending && tasks.length === 0 && (
41
+ <p className="text-muted-foreground py-6 text-center text-sm">
42
+ No tasks yet. Start one and keep working while it runs.
43
+ </p>
44
+ )}
45
+ <div className="space-y-2">
46
+ {active.map((t) => (
47
+ <TaskRow key={t.id} task={t} />
48
+ ))}
49
+ </div>
50
+ <FeaturesSection />
51
+ <StacksSection />
52
+ <BoardTodoSection />
53
+ {done.length > 0 && (
54
+ <div className="space-y-2">
55
+ <p className="label-mono text-muted-foreground pt-2">Finished</p>
56
+ {done.map((t) => (
57
+ <TaskRow key={t.id} task={t} />
58
+ ))}
59
+ </div>
60
+ )}
61
+ </div>
62
+ );
63
+ }
@@ -1,2 +1,2 @@
1
- export { TasksDialog } from "./TasksDialog";
1
+ export { TasksPanel } from "./TasksPanel";
2
2
  export { useTasksNeedingYou } from "./useTasksNeedingYou";
@@ -1,27 +1,16 @@
1
1
  "use client";
2
2
 
3
- import { Command, PanelLeft, PanelLeftClose, Plus } from "lucide-react";
3
+ import { PanelLeft, PanelLeftClose, Plus } from "lucide-react";
4
4
  import { Button } from "@/components/ui/button";
5
5
  import {
6
6
  Tooltip,
7
7
  TooltipContent,
8
8
  TooltipTrigger,
9
9
  } from "@/components/ui/tooltip";
10
- import { TasksButton } from "@/components/Tasks/TasksButton";
11
- import { MessagesButton } from "@/components/Bus/MessagesButton";
12
- import { DocsButton } from "@/components/LumifyHub/Docs/DocsButton";
13
- import { NotificationsMenu } from "@/components/NotificationsMenu";
14
- import type { ViewProps } from "./types";
10
+ import { InboxButton } from "@/components/Inbox/InboxButton";
15
11
  import { newDraft } from "@/stores/drafts";
16
- import { paletteActions } from "@/stores/palette";
17
- import { useClock } from "@/hooks/useMinuteTick";
18
- import { inBell } from "@/lib/sidebar/shelves";
19
12
  import { DRAFT_KEYS } from "@/lib/drafts";
20
13
 
21
- // Labels collapse to icons when the pane bar carrying these is narrow
22
- // (a split), via the bar's container query.
23
- export const BAR_LABEL = "hidden @5xl/bar:inline";
24
-
25
14
  export function SidebarToggle({
26
15
  isPinned,
27
16
  togglePin,
@@ -56,71 +45,29 @@ export function SidebarToggle({
56
45
  );
57
46
  }
58
47
 
59
- type AppActionsProps = Pick<
60
- ViewProps,
61
- | "sessions"
62
- | "sessionStatuses"
63
- | "activeSession"
64
- | "notificationSettings"
65
- | "attachToSession"
66
- >;
67
-
68
- export function DesktopAppActions({
69
- sessions,
70
- sessionStatuses,
71
- activeSession,
72
- notificationSettings,
73
- attachToSession,
74
- }: AppActionsProps) {
75
- const now = useClock();
48
+ export function DesktopAppActions() {
76
49
  return (
77
50
  <div
78
51
  className="flex shrink-0 items-center gap-0.5"
79
52
  onClick={(e) => e.stopPropagation()}
80
53
  >
54
+ <InboxButton />
81
55
  <Tooltip>
82
56
  <TooltipTrigger asChild>
83
57
  <Button
84
- variant="ghost"
85
58
  size="icon-sm"
86
- aria-label="Command palette"
87
- className="h-7 w-7"
88
- onClick={paletteActions.open}
59
+ aria-label="New session"
60
+ className="ml-1 h-7 w-7"
61
+ onClick={() => newDraft({ kind: "current" })}
89
62
  >
90
- <Command className="h-4 w-4" />
63
+ <Plus className="h-4 w-4" />
91
64
  </Button>
92
65
  </TooltipTrigger>
93
66
  <TooltipContent>
94
- <p>Command palette</p>
95
- <p className="text-muted-foreground text-xs">⌘K</p>
67
+ <p>New session</p>
68
+ <p className="text-muted-foreground text-xs">{DRAFT_KEYS.current}</p>
96
69
  </TooltipContent>
97
70
  </Tooltip>
98
- <NotificationsMenu
99
- settings={notificationSettings}
100
- waitingSessions={sessions
101
- .filter((s) => inBell(s, sessionStatuses[s.id], now))
102
- .map((s) => ({ id: s.id, name: s.name }))}
103
- onSelectSession={(id) => {
104
- const session = sessions.find((s) => s.id === id);
105
- if (session) attachToSession(session);
106
- }}
107
- />
108
- <MessagesButton labelClassName={BAR_LABEL} />
109
- <TasksButton labelClassName={BAR_LABEL} />
110
- <DocsButton
111
- projectId={activeSession?.project_id}
112
- labelClassName={BAR_LABEL}
113
- />
114
- <Button
115
- size="sm"
116
- aria-label="New session"
117
- title={`New session (${DRAFT_KEYS.current})`}
118
- className="ml-1 h-7 gap-1 px-2"
119
- onClick={() => newDraft({ kind: "current" })}
120
- >
121
- <Plus className="h-4 w-4" />
122
- <span className="hidden @3xl/bar:inline">New session</span>
123
- </Button>
124
71
  </div>
125
72
  );
126
73
  }
@@ -17,8 +17,6 @@ export function DesktopView({
17
17
  focusedActiveTab,
18
18
  showQuickSwitcher,
19
19
  setShowQuickSwitcher,
20
- notificationSettings,
21
- attachToSession,
22
20
  selectSession,
23
21
  openSessionInNewTab,
24
22
  handleNewSessionInProject,
@@ -61,15 +59,7 @@ export function DesktopView({
61
59
  leading={
62
60
  <SidebarToggle isPinned={isPinned} togglePin={togglePin} />
63
61
  }
64
- trailing={
65
- <DesktopAppActions
66
- sessions={sessions}
67
- sessionStatuses={sessionStatuses}
68
- activeSession={activeSession}
69
- notificationSettings={notificationSettings}
70
- attachToSession={attachToSession}
71
- />
72
- }
62
+ trailing={<DesktopAppActions />}
73
63
  >
74
64
  <PaneLayout renderPane={renderPane} />
75
65
  </PaneBarSlotsProvider>
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo } from "react";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { useSnapshot } from "valtio";
6
+ import { useSessionsQuery } from "@/data/sessions/queries";
7
+ import { fetchStatuses } from "@/data/statuses/queries";
8
+ import { statusKeys } from "@/data/sessions/keys";
9
+ import { useOrchestratorsQuery } from "@/data/orchestrators";
10
+ import { useTasksQuery } from "@/data/tasks";
11
+ import { useBusMessages } from "@/data/bus";
12
+ import { useClock } from "@/hooks/useMinuteTick";
13
+ import { inBell } from "@/lib/sidebar/shelves";
14
+ import { needsHuman } from "@/lib/tasks/state";
15
+ import {
16
+ inboxCounts,
17
+ needsYouRows,
18
+ unreadCount,
19
+ type InboxCounts,
20
+ type NeedsRow,
21
+ } from "@/lib/inbox/counts";
22
+ import { inboxUi, inboxUiActions } from "@/stores/inboxUi";
23
+
24
+ // Everything waiting on him, from the queries the old panels already used.
25
+ export function useInbox(): { rows: NeedsRow[]; counts: InboxCounts } {
26
+ const now = useClock();
27
+ const { data: sessions } = useSessionsQuery();
28
+ const { data: statuses } = useQuery({
29
+ queryKey: statusKeys.all,
30
+ queryFn: fetchStatuses,
31
+ staleTime: 2000,
32
+ });
33
+ const { data: overviews } = useOrchestratorsQuery();
34
+ const { data: tasks } = useTasksQuery();
35
+ const { data: messages } = useBusMessages();
36
+ const { seenMessageId } = useSnapshot(inboxUi);
37
+
38
+ useEffect(() => {
39
+ inboxUiActions.loadSeen();
40
+ }, []);
41
+ // First run: what is already there isn't new.
42
+ useEffect(() => {
43
+ if (!messages || inboxUi.seenMessageId !== null) return;
44
+ inboxUiActions.markSeen(messages.reduce((n, m) => Math.max(n, m.id), 0));
45
+ }, [messages, seenMessageId]);
46
+
47
+ const rows = useMemo(
48
+ () =>
49
+ needsYouRows({
50
+ waiting: (sessions?.sessions ?? [])
51
+ .filter((s) => inBell(s, statuses?.statuses[s.id], now))
52
+ .map((s) => ({ id: s.id, name: s.name })),
53
+ overviews: overviews ?? [],
54
+ }),
55
+ [sessions, statuses, overviews, now]
56
+ );
57
+ const counts = useMemo(
58
+ () =>
59
+ inboxCounts({
60
+ rows,
61
+ tasksNeedingYou: (tasks ?? [])
62
+ .filter((t) => needsHuman(t.state))
63
+ .map((t) => t.id),
64
+ unread: unreadCount(messages ?? [], seenMessageId),
65
+ }),
66
+ [rows, tasks, messages, seenMessageId]
67
+ );
68
+ return { rows, counts };
69
+ }
@@ -0,0 +1,140 @@
1
+ // What the Inbox lists and counts. Pure and client-safe, so the button, the
2
+ // panel and the tests share one rule.
3
+
4
+ import {
5
+ needsYouItems,
6
+ type NeedsYouItem,
7
+ } from "../orchestrator/needs-you-items";
8
+ import type { AskView, QuestionView } from "../orchestrator/ask-view";
9
+
10
+ export type InboxTab = "needs" | "tasks" | "messages";
11
+ export const INBOX_TABS: readonly InboxTab[] = ["needs", "tasks", "messages"];
12
+
13
+ export interface NeedsRow {
14
+ key: string;
15
+ kind: "input" | "ask" | "question";
16
+ title: string;
17
+ sub: string;
18
+ at: number;
19
+ // The session or task this is about, when known; what keeps one session
20
+ // from being counted twice.
21
+ sessionId: string | null;
22
+ workspaceId: string | null;
23
+ ask?: AskView;
24
+ question?: QuestionView;
25
+ }
26
+
27
+ export interface OverviewLike {
28
+ workspaceId: string;
29
+ asks: AskView[];
30
+ questions: QuestionView[];
31
+ dismissed: string[];
32
+ }
33
+
34
+ const TASK_PREFIX = "task:";
35
+
36
+ export function needsYouRows(input: {
37
+ waiting: { id: string; name: string }[];
38
+ overviews: OverviewLike[];
39
+ }): NeedsRow[] {
40
+ const rows: NeedsRow[] = [];
41
+ for (const o of input.overviews) {
42
+ const items = needsYouItems({
43
+ asks: o.asks,
44
+ questions: o.questions,
45
+ dismissed: o.dismissed,
46
+ });
47
+ for (const item of items) {
48
+ const row = fromItem(item, o.workspaceId);
49
+ if (row) rows.push(row);
50
+ }
51
+ }
52
+ const covered = new Set(
53
+ rows.flatMap((r) => (r.sessionId ? [r.sessionId] : []))
54
+ );
55
+ for (const s of input.waiting)
56
+ if (!covered.has(s.id))
57
+ rows.push({
58
+ key: `input:${s.id}`,
59
+ kind: "input",
60
+ title: s.name,
61
+ sub: "Waiting for input",
62
+ at: 0,
63
+ sessionId: s.id,
64
+ workspaceId: null,
65
+ });
66
+ return rows.sort((a, b) => b.at - a.at);
67
+ }
68
+
69
+ function fromItem(item: NeedsYouItem, workspaceId: string): NeedsRow | null {
70
+ if (item.kind === "ask")
71
+ return {
72
+ key: item.key,
73
+ kind: "ask",
74
+ title: item.title,
75
+ sub: item.sub,
76
+ at: item.at,
77
+ sessionId: item.ask.subject.startsWith(TASK_PREFIX)
78
+ ? item.ask.subject.slice(TASK_PREFIX.length)
79
+ : null,
80
+ workspaceId,
81
+ ask: item.ask,
82
+ };
83
+ if (item.kind === "question")
84
+ return {
85
+ key: item.key,
86
+ kind: "question",
87
+ title: item.title,
88
+ sub: item.sub,
89
+ at: item.at,
90
+ sessionId: item.question.task,
91
+ workspaceId,
92
+ question: item.question,
93
+ };
94
+ return null;
95
+ }
96
+
97
+ export function unreadCount(
98
+ messages: { id: number }[],
99
+ seenId: number | null
100
+ ): number {
101
+ if (seenId === null) return 0;
102
+ return messages.filter((m) => m.id > seenId).length;
103
+ }
104
+
105
+ export interface InboxCounts {
106
+ needs: number;
107
+ tasks: number;
108
+ messages: number;
109
+ total: number;
110
+ }
111
+
112
+ // The badge counts each session once: a task needing you that is already a
113
+ // Needs-you row (waiting for input, or the subject of an ask) adds nothing.
114
+ export function inboxCounts(input: {
115
+ rows: NeedsRow[];
116
+ tasksNeedingYou: string[];
117
+ unread: number;
118
+ }): InboxCounts {
119
+ const covered = new Set(
120
+ input.rows.flatMap((r) => (r.sessionId ? [r.sessionId] : []))
121
+ );
122
+ const extraTasks = input.tasksNeedingYou.filter((id) => !covered.has(id));
123
+ return {
124
+ needs: input.rows.length,
125
+ tasks: input.tasksNeedingYou.length,
126
+ messages: input.unread,
127
+ total: input.rows.length + extraTasks.length + input.unread,
128
+ };
129
+ }
130
+
131
+ // Needs you when it has items, else the tab he used last.
132
+ export function pickTab(input: {
133
+ explicit?: InboxTab;
134
+ needs: number;
135
+ last: InboxTab | null;
136
+ }): InboxTab {
137
+ if (input.explicit) return input.explicit;
138
+ if (input.needs > 0) return "needs";
139
+ return input.last ?? "needs";
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.603",
3
+ "version": "0.3.605",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"