@saadnvd1/agent-os 0.3.603 → 0.3.606

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 (46) 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/env-setup.ts +27 -12
  24. package/lib/inbox/counts.ts +140 -0
  25. package/lib/project-config/agentos.schema.json +14 -0
  26. package/lib/project-config/schema.ts +10 -0
  27. package/lib/worktree-deps/caches.ts +250 -0
  28. package/lib/worktree-deps/clone.ts +94 -9
  29. package/lib/worktree-deps/fastcopy.ts +89 -0
  30. package/lib/worktree-deps/index.ts +14 -5
  31. package/lib/worktree-deps/paths.ts +20 -0
  32. package/lib/worktree-deps/spare.ts +26 -8
  33. package/package.json +1 -1
  34. package/scripts/bench/worktree-setup.ts +101 -0
  35. package/scripts/check-bundle.mjs +25 -10
  36. package/scripts/check-bundle.test.ts +2 -2
  37. package/stores/inboxUi.ts +60 -0
  38. package/stores/needsYouFocus.ts +11 -3
  39. package/components/Bus/MessagesButton.tsx +0 -25
  40. package/components/Bus/MessagesDialog.tsx +0 -176
  41. package/components/LumifyHub/Docs/DocsButton.tsx +0 -31
  42. package/components/NotificationsMenu.tsx +0 -86
  43. package/components/Tasks/TasksButton.tsx +0 -28
  44. package/components/Tasks/TasksDialog.tsx +0 -75
  45. package/stores/busUi.ts +0 -12
  46. 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
+ }
package/lib/env-setup.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  import * as fs from "fs";
10
10
  import * as path from "path";
11
11
  import { bringDependencies, runCommand } from "./worktree-deps";
12
+ import { inside, realExisting } from "./worktree-deps/paths";
13
+ import { seedBuildCaches } from "./worktree-deps/caches";
12
14
  import { loadProjectConfig, projectEnv, withPorts } from "./project-config";
13
15
  import { recordPlaced } from "./worktree-placed";
14
16
  import { databaseEnv, ensureSessionDatabase } from "./project-config/database";
@@ -79,18 +81,6 @@ export async function copyEnvFiles(
79
81
  return copied;
80
82
  }
81
83
 
82
- const inside = (root: string, p: string) =>
83
- p === root || p.startsWith(root + path.sep);
84
-
85
- // The real path of `p`'s deepest part that exists.
86
- async function realExisting(p: string): Promise<string> {
87
- for (let at = p; ; at = path.dirname(at)) {
88
- const real = await fs.promises.realpath(at).catch(() => null);
89
- if (real) return real;
90
- if (path.dirname(at) === at) return at;
91
- }
92
- }
93
-
94
84
  /**
95
85
  * agentos.json's `copy`: files or folders from the main checkout, each one
96
86
  * resolved (symlinks included) to a place inside it, so a project can't
@@ -272,6 +262,31 @@ export async function setupWorktree(options: {
272
262
  );
273
263
  }
274
264
 
265
+ // Build caches, so the first typecheck, test or build is warm. An
266
+ // optimisation: it never fails a setup.
267
+ if (config.caches?.length !== 0 && sourcePath !== worktreePath) {
268
+ progress?.onStage?.("deps");
269
+ try {
270
+ const seeded = await seedBuildCaches({
271
+ sourcePath,
272
+ worktreePath,
273
+ paths: config.caches,
274
+ });
275
+ if (seeded.seeded.length || seeded.skipped.length)
276
+ result.steps.push({
277
+ name: "Seed build caches",
278
+ command: "cp (build caches)",
279
+ success: true,
280
+ output: [
281
+ ...seeded.seeded.map((c) => `${c.rel} (${c.how})`),
282
+ ...seeded.skipped.map((c) => `skipped ${c.rel}: ${c.why}`),
283
+ ].join("\n"),
284
+ });
285
+ } catch (error) {
286
+ console.error("Seeding build caches failed:", error);
287
+ }
288
+ }
289
+
275
290
  // 4. The config's setup commands
276
291
  if (config.setup && config.setup.length > 0) {
277
292
  progress?.onStage?.("script");