@saadnvd1/agent-os 0.3.698 → 0.3.715
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/README.md +1 -0
- package/app/api/bus/groups/route.ts +44 -0
- package/app/api/bus/send/route.ts +11 -1
- package/app/api/bus/spawn/route.ts +12 -1
- package/app/api/bus/wait/route.ts +22 -0
- package/app/api/claims/[id]/route.ts +20 -0
- package/app/api/claims/check/route.ts +23 -0
- package/app/api/claims/check.test.ts +113 -0
- package/app/api/claims/exchange/route.ts +42 -0
- package/app/api/claims/outside.test.ts +90 -0
- package/app/api/claims/route.test.ts +193 -0
- package/app/api/claims/route.ts +73 -0
- package/app/api/hosts/[id]/route.ts +9 -1
- package/app/api/peer/hello/reveal/route.ts +6 -0
- package/app/api/peer/hello/route.ts +9 -0
- package/app/api/peer/hello/status/route.ts +6 -0
- package/app/api/peer/unlink/route.ts +17 -0
- package/app/api/peers/[hostId]/audit/route.ts +14 -0
- package/app/api/peers/[hostId]/route.ts +20 -0
- package/app/api/peers/account/route.ts +9 -0
- package/app/api/peers/gate.test.ts +174 -0
- package/app/api/peers/link/[id]/confirm/route.ts +27 -0
- package/app/api/peers/link/[id]/route.ts +20 -0
- package/app/api/peers/link/route.ts +23 -0
- package/app/api/peers/pending/[id]/accept/route.ts +31 -0
- package/app/api/peers/pending/[id]/decline/route.ts +13 -0
- package/app/api/peers/pending/route.test.ts +37 -0
- package/app/api/peers/pending/route.ts +14 -0
- package/app/api/session-groups/[id]/restore/route.ts +11 -0
- package/app/api/session-groups/[id]/route.ts +25 -0
- package/app/api/session-groups/order/route.ts +15 -0
- package/app/api/session-groups/route.ts +21 -0
- package/app/api/sessions/[id]/fork/route.ts +3 -0
- package/app/api/sessions/[id]/group/route.ts +18 -0
- package/app/api/sessions/[id]/route.ts +14 -4
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/sessions/claims.test.ts +122 -0
- package/app/api/sessions/group/route.ts +17 -0
- package/app/api/sessions/route.ts +31 -13
- package/app/page.tsx +7 -0
- package/components/Claims/ClaimFiles.tsx +38 -0
- package/components/Claims/ClaimRow.tsx +82 -0
- package/components/Claims/ClaimsSheet.tsx +176 -0
- package/components/Claims/OverlapPill.tsx +31 -0
- package/components/Claims/StartClaimsNotice.tsx +70 -0
- package/components/Claims/claims-ui.test.ts +161 -0
- package/components/Claims/format.ts +57 -0
- package/components/Claims/index.ts +3 -0
- package/components/Draft/DraftPanel.tsx +40 -2
- package/components/Hosts/AccountMachines.tsx +194 -0
- package/components/Hosts/HostRow.tsx +195 -45
- package/components/Hosts/HostsDialog.tsx +49 -37
- package/components/Hosts/IncomingLinkSheet.tsx +246 -0
- package/components/Hosts/LinkCodeSheet.tsx +186 -0
- package/components/Hosts/PeerActivitySheet.tsx +111 -0
- package/components/Hosts/sheet.ts +3 -0
- package/components/SessionList/GroupShelves.tsx +195 -0
- package/components/SessionList/MoveToGroupMenu.tsx +105 -0
- package/components/SessionList/RowSubtitle.tsx +18 -2
- package/components/SessionList/SelectionToolbar.tsx +52 -1
- package/components/SessionList/SessionRow.tsx +1 -0
- package/components/SessionList/SessionRowMenu.tsx +17 -0
- package/components/SessionList/SessionShelves.tsx +5 -0
- package/components/SessionList/SidebarBody.tsx +6 -0
- package/components/SessionList/SidebarContextMenu.test.ts +104 -0
- package/components/SessionList/SidebarContextMenu.tsx +59 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
- package/components/SessionList/index.tsx +18 -14
- package/components/SessionList/useSidebarData.ts +10 -0
- package/components/Welcome/ConnectBlock.tsx +1 -1
- package/components/ui/notice.tsx +46 -0
- package/components/ui/status-pill.tsx +39 -0
- package/data/claims/index.ts +2 -0
- package/data/claims/keys.ts +5 -0
- package/data/claims/queries.ts +47 -0
- package/data/peers/index.ts +2 -0
- package/data/peers/keys.ts +7 -0
- package/data/peers/queries.ts +172 -0
- package/data/push/topics.ts +4 -0
- package/data/session-groups/index.ts +156 -0
- package/data/sessions/index.ts +0 -1
- package/data/sessions/pin.ts +2 -6
- package/data/sessions/queries.ts +10 -29
- package/data/sessions/shelf.ts +2 -6
- package/data/tasks/index.ts +2 -0
- package/hooks/useSessions.ts +0 -12
- package/lib/__fixtures__/peer-rpc.ts +65 -33
- package/lib/agents/brief.ts +3 -1
- package/lib/agents/spawn.ts +4 -0
- package/lib/bus/format.ts +25 -4
- package/lib/bus/index.ts +121 -106
- package/lib/bus/resolve.ts +16 -2
- package/lib/bus/targets.ts +122 -0
- package/lib/bus/waits/kick.ts +12 -0
- package/lib/bus/waits/lines.ts +69 -0
- package/lib/bus/waits/register.ts +83 -0
- package/lib/bus/waits/store.ts +196 -0
- package/lib/bus/waits/tick.ts +192 -0
- package/lib/claims/collectLocal.ts +121 -0
- package/lib/claims/describe.ts +41 -0
- package/lib/claims/exchange.ts +91 -0
- package/lib/claims/goal.ts +45 -0
- package/lib/claims/index.ts +10 -0
- package/lib/claims/loop.ts +51 -0
- package/lib/claims/machine.ts +21 -0
- package/lib/claims/mine.ts +43 -0
- package/lib/claims/notifyOverlaps.ts +101 -0
- package/lib/claims/onMerged.ts +85 -0
- package/lib/claims/outside.ts +36 -0
- package/lib/claims/overlap.ts +87 -0
- package/lib/claims/overlapCache.ts +86 -0
- package/lib/claims/overlapText.ts +36 -0
- package/lib/claims/peers.ts +56 -0
- package/lib/claims/startCheck.ts +100 -0
- package/lib/claims/startWarning.ts +29 -0
- package/lib/claims/store.ts +366 -0
- package/lib/claims/types.ts +102 -0
- package/lib/claims/view.ts +80 -0
- package/lib/claims/yield.ts +24 -0
- package/lib/connect/control-api.ts +108 -2
- package/lib/connect/heartbeat.ts +11 -3
- package/lib/connect/serve.ts +2 -0
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
- package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
- package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
- package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
- package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
- package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
- package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
- package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
- package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
- package/lib/db/queries.ts +0 -40
- package/lib/db/session-parts/columns.ts +1 -0
- package/lib/db/types.ts +20 -8
- package/lib/doctor/checks.ts +64 -1
- package/lib/done/archive.ts +2 -0
- package/lib/git-remote.ts +43 -0
- package/lib/hosts/index.ts +10 -1
- package/lib/orchestration.ts +2 -0
- package/lib/orchestrator/act.ts +54 -4
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/claim-wait.ts +71 -0
- package/lib/orchestrator/conditions.ts +28 -1
- package/lib/orchestrator/describe.ts +19 -1
- package/lib/orchestrator/facts.ts +22 -0
- package/lib/orchestrator/serve.ts +37 -2
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +31 -1
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/orchestrator/watcher.ts +19 -1
- package/lib/peer/account.ts +270 -0
- package/lib/peer/allowlist.ts +1 -1
- package/lib/peer/cli.ts +24 -0
- package/lib/peer/client.ts +26 -1
- package/lib/peer/code.ts +3 -0
- package/lib/peer/hello-route.ts +32 -0
- package/lib/peer/hello.ts +346 -0
- package/lib/peer/link-cli.ts +248 -0
- package/lib/peer/links.ts +117 -5
- package/lib/peer/machines.ts +84 -0
- package/lib/peer/name.ts +32 -0
- package/lib/peer/notify.ts +32 -0
- package/lib/peer/pairing-out.ts +382 -0
- package/lib/peer/pairing.ts +432 -0
- package/lib/peer/pin.ts +62 -0
- package/lib/peer/reasons.ts +7 -0
- package/lib/peer/revoke.ts +75 -0
- package/lib/peer/rpc-intake.ts +37 -8
- package/lib/peer/rpc-server.ts +72 -6
- package/lib/peer/unlink.ts +62 -0
- package/lib/peer/unlinked-proof.ts +44 -0
- package/lib/projects.ts +5 -2
- package/lib/schedules/when.ts +1 -1
- package/lib/security/auth.ts +10 -2
- package/lib/security/demo.ts +7 -0
- package/lib/security/gate.ts +3 -0
- package/lib/session-groups/assignSessions.ts +81 -0
- package/lib/session-groups/cli.ts +61 -0
- package/lib/session-groups/createGroup.ts +28 -0
- package/lib/session-groups/index.ts +21 -0
- package/lib/session-groups/removeGroup.ts +36 -0
- package/lib/session-groups/reorderGroups.ts +31 -0
- package/lib/session-groups/resolveGroup.ts +19 -0
- package/lib/session-groups/route.ts +32 -0
- package/lib/session-groups/store.ts +85 -0
- package/lib/session-groups/types.ts +25 -0
- package/lib/session-groups/updateGroup.ts +35 -0
- package/lib/session-groups/workspace.ts +21 -0
- package/lib/sessions/end.ts +3 -0
- package/lib/sessions/launch.ts +5 -2
- package/lib/sidebar/rollup.ts +1 -0
- package/lib/sidebar/row-menu.ts +2 -0
- package/lib/sidebar/shelves.ts +90 -15
- package/lib/sidebar/visible-order.ts +5 -0
- package/lib/tasks/hold-checks.ts +3 -1
- package/lib/tasks/holds.ts +3 -0
- package/lib/tasks/index.ts +5 -0
- package/lib/tasks/queue-rows.ts +8 -1
- package/lib/tasks/queue-start.ts +1 -0
- package/lib/tasks/queue-types.ts +9 -1
- package/lib/tasks/queue.ts +15 -5
- package/lib/tasks/schedule-types.ts +1 -0
- package/lib/test-temp.ts +19 -3
- package/lib/workspaces.ts +11 -3
- package/lib/worktree-sweep/testTemp.ts +23 -106
- package/package.json +1 -1
- package/scripts/agent-os +3 -1
- package/scripts/autodeploy +2 -0
- package/scripts/autodeploy.test.ts +31 -1
- package/scripts/claim-hook.sh +31 -0
- package/scripts/claim-hook.test.ts +230 -0
- package/scripts/lib/cli-links.sh +36 -0
- package/scripts/lib/commands.sh +5 -0
- package/scripts/lib/machine.sh +27 -2
- package/scripts/lib/machine.test.ts +25 -3
- package/scripts/sync-cli-links +7 -0
- package/scripts/sync-cli-links.test.ts +69 -0
- package/server.ts +6 -0
- package/stores/claimsUi.ts +22 -0
- package/app/api/groups/[...path]/route.ts +0 -136
- package/app/api/groups/route.ts +0 -93
- package/data/groups/index.ts +0 -1
- package/data/groups/mutations.ts +0 -95
- package/hooks/useGroups.ts +0 -37
|
@@ -3,7 +3,17 @@
|
|
|
3
3
|
import { useEffect, useCallback, useState } from "react";
|
|
4
4
|
import { useSnapshot } from "valtio";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
|
-
import { Trash2, X } from "lucide-react";
|
|
6
|
+
import { Check, Folder, Trash2, X } from "lucide-react";
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuSeparator,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from "@/components/ui/dropdown-menu";
|
|
14
|
+
import { useSessionsQuery } from "@/data/sessions";
|
|
15
|
+
import { useAssignGroup } from "@/data/session-groups";
|
|
16
|
+
import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
|
|
7
17
|
import { selectionStore, selectionActions } from "@/stores/sessionSelection";
|
|
8
18
|
import {
|
|
9
19
|
Dialog,
|
|
@@ -32,6 +42,19 @@ export function SelectionToolbar({
|
|
|
32
42
|
const selectedCount = selectedIds.size;
|
|
33
43
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
34
44
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
45
|
+
const { workspace } = useSelectedWorkspace();
|
|
46
|
+
const { data } = useSessionsQuery();
|
|
47
|
+
const assign = useAssignGroup();
|
|
48
|
+
const groups = (data?.sessionGroups ?? []).filter(
|
|
49
|
+
(g) => !workspace || g.workspace_id === workspace.id
|
|
50
|
+
);
|
|
51
|
+
const moveTo = (groupId: string | null) => {
|
|
52
|
+
const ids = selectionActions.getSelectedIds();
|
|
53
|
+
assign.mutate(
|
|
54
|
+
{ ids, groupId },
|
|
55
|
+
{ onSuccess: () => selectionActions.clear() }
|
|
56
|
+
);
|
|
57
|
+
};
|
|
35
58
|
|
|
36
59
|
const handleSelectAll = useCallback(() => {
|
|
37
60
|
selectionActions.selectAll(allSessionIds);
|
|
@@ -101,6 +124,34 @@ export function SelectionToolbar({
|
|
|
101
124
|
Select all
|
|
102
125
|
</Button>
|
|
103
126
|
)}
|
|
127
|
+
<DropdownMenu>
|
|
128
|
+
<DropdownMenuTrigger asChild>
|
|
129
|
+
<Button variant="ghost" size="sm" className="h-7 gap-1 text-xs">
|
|
130
|
+
<Folder className="h-3 w-3" />
|
|
131
|
+
Move to group
|
|
132
|
+
</Button>
|
|
133
|
+
</DropdownMenuTrigger>
|
|
134
|
+
<DropdownMenuContent align="end" className="w-52">
|
|
135
|
+
{groups.map((g) => (
|
|
136
|
+
<DropdownMenuItem
|
|
137
|
+
key={g.id}
|
|
138
|
+
className="min-h-11 md:min-h-8"
|
|
139
|
+
onSelect={() => moveTo(g.id)}
|
|
140
|
+
>
|
|
141
|
+
<Check className="invisible mr-2 h-3.5 w-3.5" />
|
|
142
|
+
<span className="truncate">{g.name}</span>
|
|
143
|
+
</DropdownMenuItem>
|
|
144
|
+
))}
|
|
145
|
+
{groups.length > 0 && <DropdownMenuSeparator />}
|
|
146
|
+
<DropdownMenuItem
|
|
147
|
+
className="min-h-11 md:min-h-8"
|
|
148
|
+
onSelect={() => moveTo(null)}
|
|
149
|
+
>
|
|
150
|
+
<X className="mr-2 h-3.5 w-3.5" />
|
|
151
|
+
Remove from group
|
|
152
|
+
</DropdownMenuItem>
|
|
153
|
+
</DropdownMenuContent>
|
|
154
|
+
</DropdownMenu>
|
|
104
155
|
<Tooltip>
|
|
105
156
|
<TooltipTrigger asChild>
|
|
106
157
|
<Button
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
type RowMenuItem,
|
|
62
62
|
} from "@/lib/sidebar/row-menu";
|
|
63
63
|
import { Expander, MenuItem, PARTS, icon, type MenuKind } from "./RowMenuParts";
|
|
64
|
+
import { MoveToGroupMenu } from "./MoveToGroupMenu";
|
|
64
65
|
|
|
65
66
|
const GROUP_ICON: Record<RowMenuGroup, typeof Copy> = {
|
|
66
67
|
open: ExternalLink,
|
|
@@ -105,6 +106,12 @@ export function SessionRowMenu({
|
|
|
105
106
|
ctx.projects.find((p) => p.id === session.project_id)?.workspace_id ??
|
|
106
107
|
null;
|
|
107
108
|
|
|
109
|
+
// The server files by the project's workspace first.
|
|
110
|
+
const groupWorkspaceId =
|
|
111
|
+
ctx.projects.find((p) => p.id === session.project_id)?.workspace_id ??
|
|
112
|
+
session.workspace_id ??
|
|
113
|
+
null;
|
|
114
|
+
|
|
108
115
|
const plan = rowMenu({
|
|
109
116
|
session,
|
|
110
117
|
row,
|
|
@@ -222,6 +229,16 @@ export function SessionRowMenu({
|
|
|
222
229
|
{projects}
|
|
223
230
|
</Expander>
|
|
224
231
|
);
|
|
232
|
+
case "move-group":
|
|
233
|
+
return (
|
|
234
|
+
<MoveToGroupMenu
|
|
235
|
+
key={it}
|
|
236
|
+
kind={kind}
|
|
237
|
+
sessionId={session.id}
|
|
238
|
+
groupId={session.session_group_id}
|
|
239
|
+
workspaceId={groupWorkspaceId}
|
|
240
|
+
/>
|
|
241
|
+
);
|
|
225
242
|
case "check-ins":
|
|
226
243
|
return item("Schedule check-ins", Clock, () =>
|
|
227
244
|
schedulesUiActions.openDraft(workspaceId!, {
|
|
@@ -14,6 +14,7 @@ import { sidebarUiActions } from "@/stores/sidebarUi";
|
|
|
14
14
|
import { cn } from "@/lib/utils";
|
|
15
15
|
import { usePendingCardSessions } from "@/data/learning-cards";
|
|
16
16
|
import { SessionRow } from "./SessionRow";
|
|
17
|
+
import { GroupShelves } from "./GroupShelves";
|
|
17
18
|
import { OrchestratorPinRow, OrchestratorStartRow } from "./OrchestratorPinRow";
|
|
18
19
|
import { QueuedRow } from "./QueuedRow";
|
|
19
20
|
import type { QueuedRow as QueuedRowData } from "@/lib/sidebar/queued";
|
|
@@ -117,6 +118,7 @@ export function SessionShelves({
|
|
|
117
118
|
doneCollapsed,
|
|
118
119
|
snoozedCollapsed,
|
|
119
120
|
donePages,
|
|
121
|
+
workspaceNames,
|
|
120
122
|
}: {
|
|
121
123
|
shelves: Shelves;
|
|
122
124
|
queued: QueuedRowData[];
|
|
@@ -124,6 +126,7 @@ export function SessionShelves({
|
|
|
124
126
|
doneCollapsed: boolean;
|
|
125
127
|
snoozedCollapsed: boolean;
|
|
126
128
|
donePages: number;
|
|
129
|
+
workspaceNames?: Map<string, string>;
|
|
127
130
|
}) {
|
|
128
131
|
const folds = { doneCollapsed, snoozedCollapsed, donePages };
|
|
129
132
|
const shown = shelves.done.slice(0, doneLimit(donePages));
|
|
@@ -142,6 +145,7 @@ export function SessionShelves({
|
|
|
142
145
|
shelves.orchestrators.length +
|
|
143
146
|
toStart.length +
|
|
144
147
|
shelves.pinned.length +
|
|
148
|
+
shelves.groups.length +
|
|
145
149
|
shelves.needsYou.length +
|
|
146
150
|
shelves.working.length +
|
|
147
151
|
shelves.inReview.length +
|
|
@@ -167,6 +171,7 @@ export function SessionShelves({
|
|
|
167
171
|
))}
|
|
168
172
|
</Shelf>
|
|
169
173
|
)}
|
|
174
|
+
<GroupShelves groups={shelves.groups} workspaceNames={workspaceNames} />
|
|
170
175
|
{shelves.needsYou.length > 0 && (
|
|
171
176
|
<Shelf label="Needs you" count={shelves.needsYou.length} accent>
|
|
172
177
|
{shelves.needsYou.map((r) => (
|
|
@@ -63,6 +63,7 @@ export function SidebarBody({
|
|
|
63
63
|
const count =
|
|
64
64
|
shelves.orchestrators.length +
|
|
65
65
|
shelves.pinned.length +
|
|
66
|
+
shelves.groups.length +
|
|
66
67
|
shelves.needsYou.length +
|
|
67
68
|
shelves.working.length +
|
|
68
69
|
shelves.inReview.length +
|
|
@@ -125,6 +126,11 @@ export function SidebarBody({
|
|
|
125
126
|
doneCollapsed={ui.doneCollapsed}
|
|
126
127
|
snoozedCollapsed={ui.snoozedCollapsed}
|
|
127
128
|
donePages={ui.donePages}
|
|
129
|
+
workspaceNames={
|
|
130
|
+
data.workspace
|
|
131
|
+
? undefined
|
|
132
|
+
: new Map(data.workspaces.map((w) => [w.id, w.name]))
|
|
133
|
+
}
|
|
128
134
|
/>
|
|
129
135
|
<MachineShelves groups={data.machines} />
|
|
130
136
|
<SidebarExtras
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
|
|
6
|
+
const mutate = vi.fn();
|
|
7
|
+
vi.mock("@/data/session-groups", () => ({
|
|
8
|
+
useCreateGroup: () => ({ mutate }),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// Radix menus need a real browser; render the item inline.
|
|
12
|
+
vi.mock("@/components/ui/context-menu", async () => {
|
|
13
|
+
const { createElement: h } = await import("react");
|
|
14
|
+
const part = (p: { children?: unknown }) =>
|
|
15
|
+
h("div", null, p.children as never);
|
|
16
|
+
return {
|
|
17
|
+
ContextMenu: part,
|
|
18
|
+
ContextMenuTrigger: part,
|
|
19
|
+
ContextMenuContent: part,
|
|
20
|
+
ContextMenuItem: (p: { children?: unknown; onSelect?: () => void }) =>
|
|
21
|
+
h("button", { onClick: p.onSelect }, p.children as never),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
vi.mock("@/components/ui/dialog", async () => {
|
|
26
|
+
const { createElement: h } = await import("react");
|
|
27
|
+
const part = (p: { children?: unknown; open?: boolean }) =>
|
|
28
|
+
p.open === false ? null : h("div", null, p.children as never);
|
|
29
|
+
return {
|
|
30
|
+
Dialog: part,
|
|
31
|
+
DialogContent: part,
|
|
32
|
+
DialogHeader: part,
|
|
33
|
+
DialogTitle: part,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
import { SidebarContextMenu, isEditableTarget } from "./SidebarContextMenu";
|
|
38
|
+
|
|
39
|
+
(
|
|
40
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
41
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
42
|
+
|
|
43
|
+
let root: Root;
|
|
44
|
+
let host: HTMLElement;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
mutate.mockClear();
|
|
47
|
+
host = document.createElement("div");
|
|
48
|
+
document.body.append(host);
|
|
49
|
+
root = createRoot(host);
|
|
50
|
+
act(() =>
|
|
51
|
+
root.render(createElement(SidebarContextMenu, { workspaceId: "w1" }, "x"))
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
act(() => root.unmount());
|
|
56
|
+
host.remove();
|
|
57
|
+
document.body.innerHTML = "";
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const openDialog = () =>
|
|
61
|
+
act(() => {
|
|
62
|
+
host.querySelector("button")!.click();
|
|
63
|
+
});
|
|
64
|
+
const type = (value: string) => {
|
|
65
|
+
const input = document.body.querySelector("input")!;
|
|
66
|
+
act(() => {
|
|
67
|
+
Object.getOwnPropertyDescriptor(
|
|
68
|
+
HTMLInputElement.prototype,
|
|
69
|
+
"value"
|
|
70
|
+
)!.set!.call(input, value);
|
|
71
|
+
input.dispatchEvent(new window.Event("input", { bubbles: true }));
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
const submit = () =>
|
|
75
|
+
act(() => {
|
|
76
|
+
document.body
|
|
77
|
+
.querySelector("form")!
|
|
78
|
+
.dispatchEvent(
|
|
79
|
+
new window.Event("submit", { bubbles: true, cancelable: true })
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("SidebarContextMenu", () => {
|
|
84
|
+
it("creates a group in the workspace from the typed name", () => {
|
|
85
|
+
openDialog();
|
|
86
|
+
type(" Docs ");
|
|
87
|
+
submit();
|
|
88
|
+
expect(mutate).toHaveBeenCalledWith({ name: "Docs", workspaceId: "w1" });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("does nothing for an empty name", () => {
|
|
92
|
+
openDialog();
|
|
93
|
+
type(" ");
|
|
94
|
+
submit();
|
|
95
|
+
expect(mutate).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("leaves text fields to the browser's menu", () => {
|
|
99
|
+
const input = document.createElement("input");
|
|
100
|
+
const div = document.createElement("div");
|
|
101
|
+
expect(isEditableTarget(input)).toBe(true);
|
|
102
|
+
expect(isEditableTarget(div)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type MouseEvent, type ReactNode } from "react";
|
|
4
|
+
import { FolderPlus } from "lucide-react";
|
|
5
|
+
import * as CM from "@/components/ui/context-menu";
|
|
6
|
+
import { WorkspaceNameDialog } from "@/components/Workspaces";
|
|
7
|
+
import { useCreateGroup } from "@/data/session-groups";
|
|
8
|
+
|
|
9
|
+
export function isEditableTarget(target: EventTarget | null): boolean {
|
|
10
|
+
return (
|
|
11
|
+
target instanceof HTMLElement &&
|
|
12
|
+
!!target.closest("input, textarea, select, [contenteditable='true']")
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Right-click anywhere in the session list (empty space or a group header)
|
|
17
|
+
// offers "New group". Rows keep their own menu, which wins because it has
|
|
18
|
+
// already handled the event; text fields keep the browser's.
|
|
19
|
+
export function SidebarContextMenu({
|
|
20
|
+
workspaceId,
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
workspaceId: string | null;
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
}) {
|
|
26
|
+
const createGroup = useCreateGroup();
|
|
27
|
+
const [naming, setNaming] = useState(false);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<CM.ContextMenu>
|
|
32
|
+
<CM.ContextMenuTrigger
|
|
33
|
+
asChild
|
|
34
|
+
onContextMenuCapture={(e: MouseEvent) => {
|
|
35
|
+
if (isEditableTarget(e.target)) e.stopPropagation();
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
{children}
|
|
39
|
+
</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>
|
|
48
|
+
</CM.ContextMenuContent>
|
|
49
|
+
</CM.ContextMenu>
|
|
50
|
+
<WorkspaceNameDialog
|
|
51
|
+
open={naming}
|
|
52
|
+
title="New group"
|
|
53
|
+
submitLabel="Create"
|
|
54
|
+
onSubmit={(name) => createGroup.mutate({ name, workspaceId })}
|
|
55
|
+
onClose={() => setNaming(false)}
|
|
56
|
+
/>
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
MoreHorizontal,
|
|
16
16
|
Pin,
|
|
17
17
|
PinOff,
|
|
18
|
+
Network,
|
|
18
19
|
Plus,
|
|
19
20
|
Server,
|
|
20
21
|
Settings,
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
import { Button } from "@/components/ui/button";
|
|
31
32
|
import type { Workspace } from "@/lib/db";
|
|
32
33
|
import { archivedUiActions } from "@/stores/archivedUi";
|
|
34
|
+
import { claimsUiActions } from "@/stores/claimsUi";
|
|
33
35
|
import { bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
34
36
|
import { inboxUiActions } from "@/stores/inboxUi";
|
|
35
37
|
import { newDraft } from "@/stores/drafts";
|
|
@@ -134,6 +136,9 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
134
136
|
}),
|
|
135
137
|
separator(),
|
|
136
138
|
menuItem("Machines", props.onManageHosts, { icon: Server }),
|
|
139
|
+
menuItem("Work across machines", () => claimsUiActions.open(), {
|
|
140
|
+
icon: Network,
|
|
141
|
+
}),
|
|
137
142
|
menuItem("Settings", () => settingsUiActions.open(), {
|
|
138
143
|
icon: Settings,
|
|
139
144
|
}),
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ChevronDown,
|
|
9
9
|
CircleCheck,
|
|
10
10
|
Clock,
|
|
11
|
+
Folder,
|
|
11
12
|
FolderPlus,
|
|
12
13
|
Gauge,
|
|
13
14
|
LayoutGrid,
|
|
@@ -32,6 +33,7 @@ import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
|
|
|
32
33
|
import { sidebarUiActions } from "@/stores/sidebarUi";
|
|
33
34
|
import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
34
35
|
import { recipesUiActions } from "@/stores/recipesUi";
|
|
36
|
+
import { useCreateGroup } from "@/data/session-groups";
|
|
35
37
|
import { useAddExistingProject } from "@/components/Projects/AddProject/useAddExistingProject";
|
|
36
38
|
|
|
37
39
|
const icon = "mr-2 h-3.5 w-3.5";
|
|
@@ -49,7 +51,8 @@ export function WorkspaceSwitcher({
|
|
|
49
51
|
const update = useUpdateWorkspace();
|
|
50
52
|
const remove = useDeleteWorkspace();
|
|
51
53
|
const addExisting = useAddExistingProject();
|
|
52
|
-
const
|
|
54
|
+
const createGroup = useCreateGroup();
|
|
55
|
+
const [dialog, setDialog] = useState<"new" | "rename" | "group" | null>(null);
|
|
53
56
|
const name = current?.name ?? "All workspaces";
|
|
54
57
|
|
|
55
58
|
return (
|
|
@@ -89,6 +92,12 @@ export function WorkspaceSwitcher({
|
|
|
89
92
|
<Plus className={icon} />
|
|
90
93
|
New workspace
|
|
91
94
|
</DM.DropdownMenuItem>
|
|
95
|
+
{current && (
|
|
96
|
+
<DM.DropdownMenuItem onClick={() => setDialog("group")}>
|
|
97
|
+
<Folder className={icon} />
|
|
98
|
+
New group
|
|
99
|
+
</DM.DropdownMenuItem>
|
|
100
|
+
)}
|
|
92
101
|
{!current && (
|
|
93
102
|
<>
|
|
94
103
|
<DM.DropdownMenuItem onClick={() => recipesUiActions.open(null)}>
|
|
@@ -175,14 +184,22 @@ export function WorkspaceSwitcher({
|
|
|
175
184
|
</DM.DropdownMenuContent>
|
|
176
185
|
</DM.DropdownMenu>
|
|
177
186
|
<WorkspaceNameDialog
|
|
178
|
-
open={dialog
|
|
179
|
-
title={
|
|
187
|
+
open={dialog !== null}
|
|
188
|
+
title={
|
|
189
|
+
dialog === "rename"
|
|
190
|
+
? "Rename workspace"
|
|
191
|
+
: dialog === "group"
|
|
192
|
+
? "New group"
|
|
193
|
+
: "New workspace"
|
|
194
|
+
}
|
|
180
195
|
initialName={dialog === "rename" ? current?.name : ""}
|
|
181
196
|
submitLabel={dialog === "rename" ? "Rename" : "Create"}
|
|
182
197
|
onSubmit={(name) =>
|
|
183
|
-
dialog === "
|
|
184
|
-
?
|
|
185
|
-
:
|
|
198
|
+
dialog === "group"
|
|
199
|
+
? createGroup.mutate({ name, workspaceId: current?.id ?? null })
|
|
200
|
+
: dialog === "rename" && current
|
|
201
|
+
? update.mutate({ id: current.id, name })
|
|
202
|
+
: create.mutate(name)
|
|
186
203
|
}
|
|
187
204
|
onClose={() => setDialog(null)}
|
|
188
205
|
/>
|
|
@@ -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 { SidebarContextMenu } from "./SidebarContextMenu";
|
|
24
25
|
import { SidebarDialogs, type SidebarDialog } from "./SidebarDialogs";
|
|
25
26
|
import type { SessionListProps } from "./SessionList.types";
|
|
26
27
|
|
|
@@ -193,20 +194,23 @@ export function SessionList({
|
|
|
193
194
|
)}
|
|
194
195
|
|
|
195
196
|
<ScrollArea className="w-full flex-1">
|
|
196
|
-
<
|
|
197
|
-
<
|
|
198
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
197
|
+
<SidebarContextMenu workspaceId={data.workspace?.id ?? null}>
|
|
198
|
+
<div className="min-h-[60vh] max-w-full px-1.5 pb-6">
|
|
199
|
+
<RowProvider value={rowContext}>
|
|
200
|
+
<SidebarBody
|
|
201
|
+
data={data}
|
|
202
|
+
onNewProject={addProject}
|
|
203
|
+
devServerHandlers={{
|
|
204
|
+
onStop: mutations.handleStopDevServer,
|
|
205
|
+
onRestart: mutations.handleRestartDevServer,
|
|
206
|
+
onRemove: mutations.handleRemoveDevServer,
|
|
207
|
+
onViewLogs: (serverId) =>
|
|
208
|
+
setDialog({ kind: "logs", serverId }),
|
|
209
|
+
}}
|
|
210
|
+
/>
|
|
211
|
+
</RowProvider>
|
|
212
|
+
</div>
|
|
213
|
+
</SidebarContextMenu>
|
|
210
214
|
</ScrollArea>
|
|
211
215
|
|
|
212
216
|
<SidebarDialogs
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
import { useCallback, useMemo } from "react";
|
|
4
4
|
import { useSnapshot } from "valtio";
|
|
5
5
|
import type { Session } from "@/lib/db";
|
|
6
|
+
import type { SessionGroup } from "@/lib/sidebar/shelves";
|
|
6
7
|
import {
|
|
7
8
|
buildShelves,
|
|
8
9
|
inWorkspace,
|
|
10
|
+
orderGroups,
|
|
9
11
|
projectsInWorkspace,
|
|
10
12
|
} from "@/lib/sidebar/shelves";
|
|
11
13
|
import type { TaskState } from "@/lib/tasks/state";
|
|
@@ -25,6 +27,7 @@ import type { SessionStatus } from "./SessionList.types";
|
|
|
25
27
|
|
|
26
28
|
// One empty list for queries still loading, so memos below hold.
|
|
27
29
|
const NONE: never[] = [];
|
|
30
|
+
const NONE_GROUPS: SessionGroup[] = [];
|
|
28
31
|
|
|
29
32
|
// Everything the flat list needs: the current workspace's sessions, sorted
|
|
30
33
|
// onto shelves by the remembered filters.
|
|
@@ -55,6 +58,7 @@ export function useSidebarData(
|
|
|
55
58
|
() => sessionsQuery.data?.sessions ?? [],
|
|
56
59
|
[sessionsQuery.data]
|
|
57
60
|
);
|
|
61
|
+
const allGroups = sessionsQuery.data?.sessionGroups ?? NONE_GROUPS;
|
|
58
62
|
// Keyed by its contents: the hosts query hands back a new object each time.
|
|
59
63
|
const linkedKey = JSON.stringify(useLinkedHostNames());
|
|
60
64
|
const linked = useMemo(
|
|
@@ -102,6 +106,10 @@ export function useSidebarData(
|
|
|
102
106
|
),
|
|
103
107
|
projectId: project?.id ?? null,
|
|
104
108
|
currentOrchestrators,
|
|
109
|
+
groups: orderGroups(
|
|
110
|
+
allGroups.filter((g) => !workspace || g.workspace_id === workspace.id),
|
|
111
|
+
workspaces.map((w) => w.id)
|
|
112
|
+
),
|
|
105
113
|
};
|
|
106
114
|
// The rollups count the whole list; the filter only narrows what shows.
|
|
107
115
|
const all = buildShelves(input);
|
|
@@ -120,6 +128,7 @@ export function useSidebarData(
|
|
|
120
128
|
now,
|
|
121
129
|
linked,
|
|
122
130
|
sessions,
|
|
131
|
+
allGroups,
|
|
123
132
|
projects,
|
|
124
133
|
workspaces,
|
|
125
134
|
tasks,
|
|
@@ -235,6 +244,7 @@ export function useSidebarData(
|
|
|
235
244
|
return {
|
|
236
245
|
ui,
|
|
237
246
|
shelves,
|
|
247
|
+
groups: allGroups,
|
|
238
248
|
queued,
|
|
239
249
|
toStart,
|
|
240
250
|
machines,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { AlertTriangle, Info } from "lucide-react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
const TONES = {
|
|
6
|
+
info: {
|
|
7
|
+
box: "bg-muted/60",
|
|
8
|
+
icon: <Info className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />,
|
|
9
|
+
},
|
|
10
|
+
warning: {
|
|
11
|
+
box: "bg-amber-500/10",
|
|
12
|
+
icon: (
|
|
13
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
14
|
+
),
|
|
15
|
+
},
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
/** A short message in a tinted box, with an optional action on the right. */
|
|
19
|
+
export function Notice({
|
|
20
|
+
tone = "info",
|
|
21
|
+
action,
|
|
22
|
+
className,
|
|
23
|
+
children,
|
|
24
|
+
}: {
|
|
25
|
+
tone?: keyof typeof TONES;
|
|
26
|
+
action?: ReactNode;
|
|
27
|
+
className?: string;
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
role="status"
|
|
33
|
+
className={cn(
|
|
34
|
+
"flex flex-col gap-2 rounded-lg p-3 text-sm sm:flex-row sm:items-center",
|
|
35
|
+
TONES[tone].box,
|
|
36
|
+
className
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
<div className="flex min-w-0 flex-1 gap-2">
|
|
40
|
+
{TONES[tone].icon}
|
|
41
|
+
<div className="min-w-0 flex-1">{children}</div>
|
|
42
|
+
</div>
|
|
43
|
+
{action}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
const TONES = {
|
|
5
|
+
amber: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
|
|
6
|
+
muted: "bg-muted text-muted-foreground",
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
/** A small coloured pill for a status. A button when it has an onClick. */
|
|
10
|
+
export function StatusPill({
|
|
11
|
+
tone,
|
|
12
|
+
className,
|
|
13
|
+
onClick,
|
|
14
|
+
...props
|
|
15
|
+
}: Omit<ComponentProps<"span">, "onClick"> & {
|
|
16
|
+
tone: keyof typeof TONES;
|
|
17
|
+
onClick?: () => void;
|
|
18
|
+
}) {
|
|
19
|
+
const classes = cn(
|
|
20
|
+
"inline-flex max-w-full min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold",
|
|
21
|
+
TONES[tone],
|
|
22
|
+
className
|
|
23
|
+
);
|
|
24
|
+
if (!onClick) return <span className={classes} {...props} />;
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
onClick={(e) => {
|
|
29
|
+
e.stopPropagation();
|
|
30
|
+
onClick();
|
|
31
|
+
}}
|
|
32
|
+
className={cn(
|
|
33
|
+
classes,
|
|
34
|
+
"relative cursor-pointer before:absolute before:-inset-x-1 before:-inset-y-3 hover:brightness-110"
|
|
35
|
+
)}
|
|
36
|
+
{...(props as ComponentProps<"button">)}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|