@saadnvd1/agent-os 0.3.728 → 0.3.731

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/api/bus/ports/route.test.ts +43 -0
  2. package/app/api/bus/ports/route.ts +35 -0
  3. package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
  4. package/app/api/sessions/[id]/export/turn/route.ts +33 -0
  5. package/components/Chat/Messages.tsx +2 -0
  6. package/components/Chat/TurnCopy.tsx +84 -0
  7. package/components/SessionList/GroupShelves.tsx +57 -22
  8. package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
  9. package/components/SessionList/SessionRow.tsx +12 -1
  10. package/components/SessionList/SidebarContextMenu.test.ts +21 -1
  11. package/components/SessionList/SidebarContextMenu.tsx +33 -9
  12. package/components/SessionList/index.tsx +11 -2
  13. package/components/SessionList/useGroupDrag.ts +83 -0
  14. package/data/exports/queries.ts +11 -0
  15. package/lib/agents/brief.ts +1 -1
  16. package/lib/export/markdown.ts +7 -1
  17. package/lib/export/turn.ts +48 -0
  18. package/lib/health/config.ts +2 -0
  19. package/lib/health/env/classes.ts +14 -1
  20. package/lib/health/port-wait-tick.ts +222 -0
  21. package/lib/health/port-wait.ts +160 -0
  22. package/lib/health/tick.ts +9 -2
  23. package/lib/health/types.ts +2 -0
  24. package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
  25. package/lib/ports/ensurePorts.ts +138 -0
  26. package/lib/ports/findHardcodedPorts.ts +116 -0
  27. package/lib/ports/index.ts +15 -0
  28. package/lib/ports/listListeners.ts +134 -0
  29. package/lib/ports/reassignPort.ts +45 -0
  30. package/lib/ports/types.ts +62 -0
  31. package/lib/ports/whoHolds.ts +155 -0
  32. package/lib/project-config/brief.ts +4 -2
  33. package/lib/project-config/session.ts +19 -3
  34. package/lib/sidebar/groupDrop.ts +36 -0
  35. package/lib/windows-ports.ts +36 -18
  36. package/package.json +1 -1
@@ -0,0 +1,43 @@
1
+ import { NextRequest } from "next/server";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ const ensure = vi.hoisted(() => vi.fn());
5
+ vi.mock("@/lib/ports/ensurePorts", () => ({ ensurePorts: ensure }));
6
+
7
+ const { POST } = await import("./route");
8
+
9
+ const call = (body: unknown) =>
10
+ POST(
11
+ new NextRequest("http://localhost/api/bus/ports", {
12
+ method: "POST",
13
+ body: JSON.stringify(body),
14
+ })
15
+ );
16
+
17
+ describe("POST /api/bus/ports (aos port)", () => {
18
+ it("refuses a body without a session or with a target that's neither a name nor a port", async () => {
19
+ expect((await call({})).status).toBe(400);
20
+ expect((await call({ from: "s", target: "3000; rm -rf /" })).status).toBe(
21
+ 400
22
+ );
23
+ expect(ensure).not.toHaveBeenCalled();
24
+ });
25
+
26
+ it("hands the session and target to ensurePorts and returns its report", async () => {
27
+ ensure.mockResolvedValueOnce({
28
+ text: "PORT=3101 is free.",
29
+ blocked: false,
30
+ });
31
+ const res = await call({ from: "s1", target: "PORT" });
32
+ expect(res.status).toBe(200);
33
+ expect(await res.json()).toMatchObject({ text: "PORT=3101 is free." });
34
+ expect(ensure).toHaveBeenCalledWith("s1", "PORT");
35
+ });
36
+
37
+ it("is 404 for an unknown session and 409 for anything else it refuses", async () => {
38
+ ensure.mockRejectedValueOnce(new Error("No session nope"));
39
+ expect((await call({ from: "nope" })).status).toBe(404);
40
+ ensure.mockRejectedValueOnce(new Error("No port named X"));
41
+ expect((await call({ from: "s1", target: "X" })).status).toBe(409);
42
+ });
43
+ });
@@ -0,0 +1,35 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { ensurePorts } from "@/lib/ports/ensurePorts";
4
+
5
+ const body = z.object({
6
+ from: z.string().min(1),
7
+ target: z
8
+ .string()
9
+ .regex(/^(\d{1,5}|[A-Za-z_][A-Za-z0-9_]*)$/)
10
+ .optional(),
11
+ });
12
+
13
+ // POST /api/bus/ports { from, target? } - `aos port`: whether the caller's
14
+ // ports are free on this machine, who holds the ones that aren't, and a free
15
+ // one in place of each taken one.
16
+ export async function POST(request: NextRequest) {
17
+ const parsed = body.safeParse(await request.json().catch(() => null));
18
+ if (!parsed.success)
19
+ return NextResponse.json(
20
+ {
21
+ error: "Send { from: your session id, target?: a port name or number }",
22
+ },
23
+ { status: 400 }
24
+ );
25
+ const { from, target } = parsed.data;
26
+ try {
27
+ return NextResponse.json(await ensurePorts(from, target));
28
+ } catch (error) {
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ return NextResponse.json(
31
+ { error: message },
32
+ { status: /^No session /.test(message) ? 404 : 409 }
33
+ );
34
+ }
35
+ }
@@ -0,0 +1,65 @@
1
+ import { randomUUID } from "crypto";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const route = await import("./route");
5
+ const { saveItem } = await import("@/lib/chat/store");
6
+ const { seedSession } = await import("@/lib/orchestrator/testing");
7
+ const { createProject } = await import("@/lib/projects");
8
+
9
+ const secret = ["gh", "p_", "d".repeat(36)].join("");
10
+ const post = (b: unknown) =>
11
+ new Request("http://localhost/x", {
12
+ method: "POST",
13
+ headers: { "content-type": "application/json" },
14
+ body: JSON.stringify(b),
15
+ });
16
+ const params = (id: string) => ({ params: Promise.resolve({ id }) });
17
+
18
+ function session() {
19
+ const project = createProject({
20
+ name: `p-${randomUUID().slice(0, 6)}`,
21
+ workingDirectory: "/tmp/p",
22
+ });
23
+ const id = seedSession({
24
+ projectId: project.id,
25
+ name: "Copy me",
26
+ view: "chat",
27
+ });
28
+ let t = 1;
29
+ const add = (i: Record<string, unknown>) =>
30
+ saveItem(id, { id: `c${t}`, createdAt: t++, ...i } as never);
31
+ add({ kind: "user", text: "first" });
32
+ add({ kind: "assistant", text: "one" });
33
+ add({ kind: "user", text: "second" });
34
+ add({ kind: "assistant", text: `two ${secret}` });
35
+ return id;
36
+ }
37
+
38
+ afterEach(() => vi.unstubAllEnvs());
39
+
40
+ describe("POST /api/sessions/:id/export/turn", () => {
41
+ it("returns the turn and its response, secrets masked", async () => {
42
+ const id = session();
43
+ const res = await route.POST(post({ startId: "c3" }), params(id));
44
+ expect(res.status).toBe(200);
45
+ const j = await res.json();
46
+ expect(j.response).toContain("two [secret]");
47
+ expect(j.full).toContain("two [secret]");
48
+ expect(j.full).not.toContain("one");
49
+ expect(JSON.stringify(j)).not.toContain(secret);
50
+ });
51
+
52
+ it("404s an unknown turn and 400s a missing id", async () => {
53
+ const id = session();
54
+ expect((await route.POST(post({ startId: "zz" }), params(id))).status).toBe(
55
+ 404
56
+ );
57
+ expect((await route.POST(post({}), params(id))).status).toBe(400);
58
+ });
59
+
60
+ it("refuses in the demo", async () => {
61
+ vi.stubEnv("AGENTOS_DEMO", "1");
62
+ const res = await route.POST(post({ startId: "c1" }), params(session()));
63
+ expect(res.status).toBe(403);
64
+ });
65
+ });
@@ -0,0 +1,33 @@
1
+ import { NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { loadSession } from "@/lib/export/load";
4
+ import { copyTurn } from "@/lib/export/turn";
5
+ import { shareContext } from "@/lib/share/context";
6
+ import { readJson, refuseDemo, toResponse } from "@/lib/share/route";
7
+ import { BadShare } from "@/lib/share/service";
8
+
9
+ const Body = z.object({ startId: z.string().min(1).max(200) });
10
+
11
+ // POST /api/sessions/:id/export/turn - one turn as Markdown for the clipboard,
12
+ // secrets masked: the whole turn, and its last agent message.
13
+ export async function POST(
14
+ request: Request,
15
+ { params }: { params: Promise<{ id: string }> }
16
+ ) {
17
+ const refused = refuseDemo();
18
+ if (refused) return refused;
19
+ const read = await readJson(request);
20
+ if ("refused" in read) return read.refused;
21
+ try {
22
+ const { id } = await params;
23
+ const body = Body.safeParse(read.body);
24
+ if (!body.success) throw new BadShare("startId is required", 400);
25
+ const loaded = loadSession(id);
26
+ return NextResponse.json(
27
+ copyTurn(loaded.items, body.data.startId, shareContext(), loaded.meta),
28
+ { headers: { "Cache-Control": "no-store" } }
29
+ );
30
+ } catch (err) {
31
+ return toResponse(err);
32
+ }
33
+ }
@@ -24,6 +24,7 @@ import { FileChip } from "./FileChip";
24
24
  import { ImageThumb } from "./ImageThumb";
25
25
  import { Markdown } from "./Markdown";
26
26
  import { openSessionLink } from "./session-link";
27
+ import { TurnCopy } from "./TurnCopy";
27
28
  import { UndoButton } from "./Undo";
28
29
  import { useRowState } from "./rowState";
29
30
  import { useBookmarkChat } from "@/components/Bookmarks/context";
@@ -407,6 +408,7 @@ export function TurnEnd({ item }: { item: Of<"turn_end"> }) {
407
408
  <div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
408
409
  <span className="bg-foreground/[0.06] h-px flex-1" />
409
410
  {label}
411
+ <TurnCopy turnEndId={item.id} />
410
412
  <TurnBookmark turnEndId={item.id} />
411
413
  <span className="bg-foreground/[0.06] h-px flex-1" />
412
414
  </div>
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Check, Copy } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+ import { fetchTurnCopy } from "@/data/exports";
7
+ import { useBookmarkChat, useTurnStart } from "@/components/Bookmarks/context";
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuItem,
12
+ DropdownMenuTrigger,
13
+ } from "@/components/ui/dropdown-menu";
14
+
15
+ type Part = "response" | "full";
16
+
17
+ // "Copy" on a finished turn's end line: the answer alone, or the whole turn
18
+ // (reasoning, tools, output, errors) as Markdown. One menu, so a phone's
19
+ // turn row gets one button, not two.
20
+ export function TurnCopy({ turnEndId }: { turnEndId: string }) {
21
+ const chat = useBookmarkChat();
22
+ const start = useTurnStart(turnEndId);
23
+ const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
24
+ if (!chat || !start) return null;
25
+
26
+ const copy = async (part: Part) => {
27
+ try {
28
+ const turn = await fetchTurnCopy(chat.sessionId, start);
29
+ const text = part === "full" ? turn.full : turn.response;
30
+ if (!text) throw new Error("nothing to copy");
31
+ await navigator.clipboard.writeText(text);
32
+ setState("copied");
33
+ } catch {
34
+ setState("failed");
35
+ }
36
+ setTimeout(() => setState("idle"), 1500);
37
+ };
38
+
39
+ return (
40
+ <DropdownMenu>
41
+ <DropdownMenuTrigger asChild>
42
+ <button
43
+ type="button"
44
+ aria-label="Copy turn"
45
+ className={cn(
46
+ "hover:text-foreground hover:bg-foreground/[0.06] -my-2 flex min-h-11 shrink-0 items-center gap-1 rounded-md px-2 md:-my-1 md:min-h-7",
47
+ state === "idle"
48
+ ? "opacity-60 group-hover:opacity-100 focus-visible:opacity-100"
49
+ : state === "failed"
50
+ ? "text-destructive"
51
+ : "text-foreground"
52
+ )}
53
+ >
54
+ {state === "copied" ? (
55
+ <Check className="h-3 w-3" />
56
+ ) : (
57
+ <Copy className="h-3 w-3" />
58
+ )}
59
+ <span className="md:hidden">
60
+ {state === "failed"
61
+ ? "Copy failed"
62
+ : state === "copied"
63
+ ? "Copied"
64
+ : "Copy"}
65
+ </span>
66
+ </button>
67
+ </DropdownMenuTrigger>
68
+ <DropdownMenuContent align="end" className="font-sans text-sm">
69
+ <DropdownMenuItem
70
+ className="min-h-11 md:min-h-8"
71
+ onSelect={() => void copy("response")}
72
+ >
73
+ Copy Response
74
+ </DropdownMenuItem>
75
+ <DropdownMenuItem
76
+ className="min-h-11 md:min-h-8"
77
+ onSelect={() => void copy("full")}
78
+ >
79
+ Copy Full Turn
80
+ </DropdownMenuItem>
81
+ </DropdownMenuContent>
82
+ </DropdownMenu>
83
+ );
84
+ }
@@ -18,6 +18,7 @@ import {
18
18
  } from "@/components/ui/dropdown-menu";
19
19
  import { cn } from "@/lib/utils";
20
20
  import { SessionRow } from "./SessionRow";
21
+ import { useDropZone } from "./useGroupDrag";
21
22
 
22
23
  function GroupHeader({
23
24
  shelf,
@@ -154,6 +155,51 @@ function GroupHeader({
154
155
  );
155
156
  }
156
157
 
158
+ function GroupSection({
159
+ shelf,
160
+ siblings,
161
+ workspaceName,
162
+ }: {
163
+ shelf: GroupShelf;
164
+ siblings: GroupShelf[];
165
+ workspaceName?: string;
166
+ }) {
167
+ const { group } = shelf;
168
+ const update = useUpdateGroup();
169
+ const drop = useDropZone(
170
+ { groupId: group.id, workspaceId: group.workspace_id },
171
+ group.collapsed
172
+ ? () => update.mutate({ id: group.id, collapsed: false })
173
+ : undefined
174
+ );
175
+ const empty = !group.collapsed && shelf.rows.length === 0;
176
+ return (
177
+ <section
178
+ aria-label={group.name}
179
+ data-drop-over={drop.over || undefined}
180
+ className={cn(
181
+ "rounded-lg transition-colors",
182
+ drop.over && "bg-primary/10 ring-primary/50 ring-1"
183
+ )}
184
+ {...drop.props}
185
+ >
186
+ <GroupHeader
187
+ shelf={shelf}
188
+ siblings={siblings}
189
+ workspaceName={workspaceName}
190
+ />
191
+ {empty && (
192
+ <p className="text-muted-foreground/70 px-2.5 pb-2 text-xs">
193
+ Nothing here yet · drag a session in, or use its ⋯ menu
194
+ </p>
195
+ )}
196
+ {(group.collapsed ? [] : shelf.rows).map((r) => (
197
+ <SessionRow key={r.session.id} row={r} muted={r.settled === "auto"} />
198
+ ))}
199
+ </section>
200
+ );
201
+ }
202
+
157
203
  // The user's groups, in order, between Pinned and the status shelves. A
158
204
  // folded group keeps its header and counts; an auto-settled member is muted.
159
205
  export function GroupShelves({
@@ -166,30 +212,19 @@ export function GroupShelves({
166
212
  }) {
167
213
  return groups.map((shelf) => {
168
214
  const { group } = shelf;
169
- const siblings = groups.filter(
170
- (g) => g.group.workspace_id === group.workspace_id
171
- );
172
- const empty = !group.collapsed && shelf.rows.length === 0;
173
215
  return (
174
- <section key={group.id} aria-label={group.name}>
175
- <GroupHeader
176
- shelf={shelf}
177
- siblings={siblings}
178
- workspaceName={
179
- workspaceNames && group.workspace_id
180
- ? workspaceNames.get(group.workspace_id)
181
- : undefined
182
- }
183
- />
184
- {empty && (
185
- <p className="text-muted-foreground/70 px-2.5 pb-2 text-xs">
186
- Nothing here yet · move a session in from its ⋯ menu
187
- </p>
216
+ <GroupSection
217
+ key={group.id}
218
+ shelf={shelf}
219
+ siblings={groups.filter(
220
+ (g) => g.group.workspace_id === group.workspace_id
188
221
  )}
189
- {(group.collapsed ? [] : shelf.rows).map((r) => (
190
- <SessionRow key={r.session.id} row={r} muted={r.settled === "auto"} />
191
- ))}
192
- </section>
222
+ workspaceName={
223
+ workspaceNames && group.workspace_id
224
+ ? workspaceNames.get(group.workspace_id)
225
+ : undefined
226
+ }
227
+ />
193
228
  );
194
229
  });
195
230
  }
@@ -0,0 +1,77 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { sessionKeys } from "@/data/sessions/keys";
7
+ import type { SessionGroup } from "@/lib/sidebar/shelves";
8
+ import { groupTargets } from "./SidebarContextMenu";
9
+
10
+ // Radix menus need a real browser; plain elements show what the menu offers.
11
+ vi.mock("./RowMenuParts", async () => {
12
+ const { createElement: h } = await import("react");
13
+ const part = (p: { children?: unknown }) =>
14
+ h("div", null, p.children as never);
15
+ return {
16
+ icon: "",
17
+ MenuItem: part,
18
+ Expander: part,
19
+ };
20
+ });
21
+
22
+ (
23
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
24
+ ).IS_REACT_ACT_ENVIRONMENT = true;
25
+
26
+ let root: Root;
27
+ let host: HTMLElement;
28
+ beforeEach(() => {
29
+ host = document.createElement("div");
30
+ document.body.append(host);
31
+ root = createRoot(host);
32
+ });
33
+ afterEach(() => {
34
+ act(() => root.unmount());
35
+ host.remove();
36
+ });
37
+
38
+ const group = (id: string, name: string, workspace_id: string | null) =>
39
+ ({ id, name, workspace_id, sort_order: 0 }) as SessionGroup;
40
+
41
+ async function menuText(groups: SessionGroup[], workspaceId: string | null) {
42
+ const { MoveToGroupMenu } = await import("./MoveToGroupMenu");
43
+ const client = new QueryClient();
44
+ client.setQueryData(sessionKeys.list(), {
45
+ sessions: [],
46
+ sessionGroups: groups,
47
+ });
48
+ await act(async () =>
49
+ root.render(
50
+ createElement(
51
+ QueryClientProvider,
52
+ { client },
53
+ createElement(MoveToGroupMenu, {
54
+ kind: "dropdown",
55
+ sessionId: "s1",
56
+ groupId: null,
57
+ workspaceId,
58
+ })
59
+ )
60
+ )
61
+ );
62
+ return host.textContent ?? "";
63
+ }
64
+
65
+ describe("Move to group", () => {
66
+ it("offers an empty group, and one made from the all-workspaces view", async () => {
67
+ // Viewing every workspace, "New group" files the group under a workspace
68
+ // (it used to be none, which no session can join).
69
+ const [target] = groupTargets(null, [{ id: "w1", name: "Work" }]);
70
+ const text = await menuText(
71
+ [group("g1", "Fresh", target.id), group("g2", "Elsewhere", "w2")],
72
+ "w1"
73
+ );
74
+ expect(text).toContain("Fresh");
75
+ expect(text).not.toContain("Elsewhere");
76
+ });
77
+ });
@@ -26,6 +26,7 @@ import { RowShell, type RowMenuKind } from "./RowShell";
26
26
  import { CardBadge, NestRail, RowDot, RowRename, UnreadDot } from "./RowParts";
27
27
  import { RowSubtitle } from "./RowSubtitle";
28
28
  import { OrchestratorAsks } from "./OrchestratorAsks";
29
+ import { useRowDrag } from "./useGroupDrag";
29
30
 
30
31
  // One session: status dot, title over its project, and on the right what
31
32
  // needs you or when it last moved. Workers sit under their conductor. Drawn
@@ -64,6 +65,16 @@ export const SessionRow = memo(function SessionRow({
64
65
  ctx.onSelect(session.id);
65
66
  };
66
67
 
68
+ // The server files by the project's workspace first.
69
+ const workspaceId =
70
+ ctx.projects.find((p) => p.id === session.project_id)?.workspace_id ??
71
+ session.workspace_id ??
72
+ null;
73
+ const drag = useRowDrag(
74
+ { id: session.id, groupId: session.session_group_id ?? null, workspaceId },
75
+ !renaming
76
+ );
77
+
67
78
  const badge = rowStatusBadge(row);
68
79
  const cardCount = usePendingCardSessions().get(session.id) ?? 0;
69
80
 
@@ -77,7 +88,7 @@ export const SessionRow = memo(function SessionRow({
77
88
  );
78
89
 
79
90
  return (
80
- <div data-session-row={session.id}>
91
+ <div data-session-row={session.id} {...drag}>
81
92
  <RowShell
82
93
  title={session.name}
83
94
  tipKey={`session:${session.id}`}
@@ -34,7 +34,11 @@ vi.mock("@/components/ui/dialog", async () => {
34
34
  };
35
35
  });
36
36
 
37
- import { SidebarContextMenu, isEditableTarget } from "./SidebarContextMenu";
37
+ import {
38
+ SidebarContextMenu,
39
+ groupTargets,
40
+ isEditableTarget,
41
+ } from "./SidebarContextMenu";
38
42
 
39
43
  (
40
44
  globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
@@ -102,3 +106,19 @@ describe("SidebarContextMenu", () => {
102
106
  expect(isEditableTarget(div)).toBe(false);
103
107
  });
104
108
  });
109
+
110
+ describe("groupTargets", () => {
111
+ const ws = [
112
+ { id: "w1", name: "Work" },
113
+ { id: "w2", name: "Home" },
114
+ ];
115
+ it("uses the selected workspace", () => {
116
+ expect(groupTargets("w2", ws)).toEqual([{ id: "w2", label: "New group" }]);
117
+ });
118
+ it("never offers a group of no workspace when workspaces exist", () => {
119
+ expect(groupTargets(null, [ws[0]])).toEqual([
120
+ { id: "w1", label: "New group" },
121
+ ]);
122
+ expect(groupTargets(null, ws).map((t) => t.id)).toEqual(["w1", "w2"]);
123
+ });
124
+ });
@@ -16,15 +16,33 @@ export function isEditableTarget(target: EventTarget | null): boolean {
16
16
  // Right-click anywhere in the session list (empty space or a group header)
17
17
  // offers "New group". Rows keep their own menu, which wins because it has
18
18
  // already handled the event; text fields keep the browser's.
19
+ // A group belongs to one workspace and only that workspace's sessions can
20
+ // join it. With a workspace selected the group goes there; viewing all of
21
+ // them, the choice is one entry per workspace (a group of no workspace could
22
+ // hold nothing).
23
+ export function groupTargets(
24
+ workspaceId: string | null,
25
+ workspaces: readonly { id: string; name: string }[]
26
+ ): { id: string | null; label: string }[] {
27
+ if (workspaceId || workspaces.length === 0)
28
+ return [{ id: workspaceId, label: "New group" }];
29
+ if (workspaces.length === 1)
30
+ return [{ id: workspaces[0].id, label: "New group" }];
31
+ return workspaces.map((w) => ({ id: w.id, label: `New group in ${w.name}` }));
32
+ }
33
+
19
34
  export function SidebarContextMenu({
20
35
  workspaceId,
36
+ workspaces = [],
21
37
  children,
22
38
  }: {
23
39
  workspaceId: string | null;
40
+ workspaces?: readonly { id: string; name: string }[];
24
41
  children?: ReactNode;
25
42
  }) {
26
43
  const createGroup = useCreateGroup();
27
44
  const [naming, setNaming] = useState(false);
45
+ const [target, setTarget] = useState<string | null>(workspaceId);
28
46
 
29
47
  return (
30
48
  <>
@@ -37,21 +55,27 @@ export function SidebarContextMenu({
37
55
  >
38
56
  {children}
39
57
  </CM.ContextMenuTrigger>
40
- <CM.ContextMenuContent className="w-44">
41
- <CM.ContextMenuItem
42
- className="min-h-11 md:min-h-8"
43
- onSelect={() => setNaming(true)}
44
- >
45
- <FolderPlus className="mr-2 h-3.5 w-3.5" />
46
- New group
47
- </CM.ContextMenuItem>
58
+ <CM.ContextMenuContent className="w-52">
59
+ {groupTargets(workspaceId, workspaces).map((t) => (
60
+ <CM.ContextMenuItem
61
+ key={t.id ?? "none"}
62
+ className="min-h-11 md:min-h-8"
63
+ onSelect={() => {
64
+ setTarget(t.id);
65
+ setNaming(true);
66
+ }}
67
+ >
68
+ <FolderPlus className="mr-2 h-3.5 w-3.5" />
69
+ <span className="truncate">{t.label}</span>
70
+ </CM.ContextMenuItem>
71
+ ))}
48
72
  </CM.ContextMenuContent>
49
73
  </CM.ContextMenu>
50
74
  <WorkspaceNameDialog
51
75
  open={naming}
52
76
  title="New group"
53
77
  submitLabel="Create"
54
- onSubmit={(name) => createGroup.mutate({ name, workspaceId })}
78
+ onSubmit={(name) => createGroup.mutate({ name, workspaceId: target })}
55
79
  onClose={() => setNaming(false)}
56
80
  />
57
81
  </>
@@ -21,6 +21,7 @@ import { SidebarSearch } from "./SidebarSearch";
21
21
  import { ProjectFilter } from "./ProjectFilter";
22
22
  import { SelectionToolbar } from "./SelectionToolbar";
23
23
  import { SidebarBody } from "./SidebarBody";
24
+ import { useDropZone } from "./useGroupDrag";
24
25
  import { SidebarContextMenu } from "./SidebarContextMenu";
25
26
  import { SidebarDialogs, type SidebarDialog } from "./SidebarDialogs";
26
27
  import type { SessionListProps } from "./SessionList.types";
@@ -43,6 +44,8 @@ export function SessionList({
43
44
  pinControls,
44
45
  }: SessionListProps) {
45
46
  const data = useSidebarData(sessionStatuses);
47
+ // Dropping outside every group takes a session out of its group.
48
+ const outside = useDropZone(null);
46
49
  useSessionTips(data);
47
50
  const { shelves, ui, project } = data;
48
51
  const mutations = useSessionListMutations({ onSelectSession: onSelect });
@@ -194,8 +197,14 @@ export function SessionList({
194
197
  )}
195
198
 
196
199
  <ScrollArea className="w-full flex-1">
197
- <SidebarContextMenu workspaceId={data.workspace?.id ?? null}>
198
- <div className="min-h-[60vh] max-w-full px-1.5 pb-6">
200
+ <SidebarContextMenu
201
+ workspaceId={data.workspace?.id ?? null}
202
+ workspaces={data.workspaces}
203
+ >
204
+ <div
205
+ className="min-h-[60vh] max-w-full px-1.5 pb-6"
206
+ {...outside.props}
207
+ >
199
208
  <RowProvider value={rowContext}>
200
209
  <SidebarBody
201
210
  data={data}