@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.
- package/app/page.tsx +3 -8
- package/components/Bookmarks/ChatBookmarksButton.tsx +4 -0
- package/components/Bus/MessagesPanel.tsx +155 -0
- package/components/Bus/index.ts +1 -2
- package/components/Chat/ChatPanel.tsx +9 -1
- package/components/CommandPalette/useAppCommands.ts +12 -4
- package/components/Features/FeaturesSection.tsx +2 -2
- package/components/Inbox/InboxButton.tsx +39 -0
- package/components/Inbox/InboxDialog.tsx +139 -0
- package/components/Inbox/NeedsYouList.tsx +120 -0
- package/components/Inbox/index.ts +2 -0
- package/components/LumifyHub/index.ts +0 -1
- package/components/Pane/DesktopTabBar.tsx +2 -0
- package/components/Pane/PaneMenu.test.ts +118 -0
- package/components/Pane/PaneMenu.tsx +37 -0
- package/components/SessionList/SidebarHeader.tsx +7 -4
- package/components/Tasks/TaskRow.tsx +3 -3
- package/components/Tasks/TasksPanel.tsx +63 -0
- package/components/Tasks/index.ts +1 -1
- package/components/views/DesktopAppActions.tsx +10 -63
- package/components/views/DesktopView.tsx +1 -11
- package/hooks/useInbox.ts +69 -0
- package/lib/inbox/counts.ts +140 -0
- package/package.json +1 -1
- package/scripts/check-bundle.mjs +25 -10
- package/scripts/check-bundle.test.ts +2 -2
- package/stores/inboxUi.ts +60 -0
- package/stores/needsYouFocus.ts +11 -3
- package/components/Bus/MessagesButton.tsx +0 -25
- package/components/Bus/MessagesDialog.tsx +0 -176
- package/components/LumifyHub/Docs/DocsButton.tsx +0 -31
- package/components/NotificationsMenu.tsx +0 -86
- package/components/Tasks/TasksButton.tsx +0 -28
- package/components/Tasks/TasksDialog.tsx +0 -75
- package/stores/busUi.ts +0 -12
- 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 {
|
|
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",
|
|
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",
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
1
|
+
export { TasksPanel } from "./TasksPanel";
|
|
2
2
|
export { useTasksNeedingYou } from "./useTasksNeedingYou";
|
|
@@ -1,27 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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="
|
|
87
|
-
className="h-7 w-7"
|
|
88
|
-
onClick={
|
|
59
|
+
aria-label="New session"
|
|
60
|
+
className="ml-1 h-7 w-7"
|
|
61
|
+
onClick={() => newDraft({ kind: "current" })}
|
|
89
62
|
>
|
|
90
|
-
<
|
|
63
|
+
<Plus className="h-4 w-4" />
|
|
91
64
|
</Button>
|
|
92
65
|
</TooltipTrigger>
|
|
93
66
|
<TooltipContent>
|
|
94
|
-
<p>
|
|
95
|
-
<p className="text-muted-foreground text-xs"
|
|
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
|
+
}
|