@saadnvd1/agent-os 0.3.728 → 0.3.733
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/api/bus/ports/route.test.ts +43 -0
- package/app/api/bus/ports/route.ts +35 -0
- package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
- package/app/api/sessions/[id]/export/turn/route.ts +33 -0
- package/components/Chat/Messages.tsx +2 -0
- package/components/Chat/TurnCopy.tsx +84 -0
- package/components/SessionList/GroupShelves.tsx +57 -22
- package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
- package/components/SessionList/SessionRow.tsx +12 -1
- package/components/SessionList/SidebarContextMenu.test.ts +21 -1
- package/components/SessionList/SidebarContextMenu.tsx +33 -9
- package/components/SessionList/index.tsx +11 -2
- package/components/SessionList/useGroupDrag.ts +83 -0
- package/components/Updates/UpdateActions.tsx +6 -0
- package/components/Updates/UpdateBanner.test.ts +1 -0
- package/data/exports/queries.ts +11 -0
- package/lib/agents/brief.ts +1 -1
- package/lib/claims/collectLocal.ts +3 -1
- package/lib/claims/overlap.ts +22 -2
- package/lib/export/markdown.ts +7 -1
- package/lib/export/turn.ts +48 -0
- package/lib/health/config.ts +2 -0
- package/lib/health/env/classes.ts +14 -1
- package/lib/health/port-wait-tick.ts +222 -0
- package/lib/health/port-wait.ts +160 -0
- package/lib/health/tick.ts +9 -2
- package/lib/health/types.ts +2 -0
- package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
- package/lib/ports/ensurePorts.ts +138 -0
- package/lib/ports/findHardcodedPorts.ts +116 -0
- package/lib/ports/index.ts +15 -0
- package/lib/ports/listListeners.ts +134 -0
- package/lib/ports/reassignPort.ts +45 -0
- package/lib/ports/types.ts +62 -0
- package/lib/ports/whoHolds.ts +155 -0
- package/lib/project-config/brief.ts +4 -2
- package/lib/project-config/session.ts +19 -3
- package/lib/sidebar/groupDrop.ts +36 -0
- package/lib/updates/banner.ts +3 -0
- package/lib/updates/run.ts +7 -2
- package/lib/updates/view.ts +2 -0
- package/lib/windows-ports.ts +36 -18
- package/package.json +1 -1
- package/scripts/redeploy +10 -1
- package/scripts/redeploy.test.ts +77 -0
|
@@ -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
|
-
<
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 {
|
|
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-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
198
|
-
|
|
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}
|