@saadnvd1/agent-os 0.3.489 → 0.3.494
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 +38 -2
- package/app/api/sessions/[id]/fresh/route.ts +31 -0
- package/components/Chat/ChatPanel.tsx +19 -3
- package/components/Chat/Tools.tsx +9 -3
- package/components/Pane/index.tsx +4 -0
- package/components/SessionList/OrchestratorPinRow.test.ts +3 -0
- package/components/SessionList/OrchestratorPinRow.tsx +36 -0
- package/components/SessionList/RowContext.tsx +5 -0
- package/components/SessionList/SessionRow.tsx +17 -4
- package/components/SessionList/SessionRowMenu.tsx +10 -0
- package/components/SessionList/SessionShelves.tsx +8 -2
- package/components/SessionList/SidebarBody.tsx +17 -1
- package/components/SessionList/index.tsx +23 -3
- package/components/SessionList/useSidebarData.ts +64 -18
- package/data/chat/useChat.ts +12 -1
- package/data/sessions/fresh.ts +20 -0
- package/data/sessions/index.ts +1 -0
- package/lib/chat/bodyCache.ts +69 -0
- package/lib/chat/commands.ts +16 -0
- package/lib/chat/refresh.ts +18 -0
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +50 -0
- package/lib/chat/store.ts +89 -0
- package/lib/chat/worker/host.ts +46 -12
- package/lib/chat/worker/main.ts +1 -1
- package/lib/chat/worker/protocol.ts +4 -0
- package/lib/orchestrator/brief.ts +1 -1
- package/lib/orchestrator/shell-guard.ts +536 -0
- package/lib/orchestrator/shell-parse.ts +289 -0
- package/lib/orchestrator/tool-names.ts +13 -22
- package/lib/orchestrator/write-guard.ts +54 -57
- package/lib/sidebar/queued.ts +3 -0
- package/lib/sidebar/rollup.ts +58 -0
- package/lib/sidebar/shelves.ts +24 -0
- package/lib/tasks/queue.ts +3 -0
- package/package.json +1 -1
- package/stores/sidebarUi.ts +15 -3
- package/lib/orchestrator/git-guard.ts +0 -201
package/README.md
CHANGED
|
@@ -920,7 +920,13 @@ other sessions. Sessions sit on shelves: **Pinned** (pin from a row's ⋯
|
|
|
920
920
|
menu), **Needs you** (an approval, a question, a terminal waiting for input, a
|
|
921
921
|
message typed and never sent, a failed task, or the orchestrator's open asks,
|
|
922
922
|
each with a badge), **Working**,
|
|
923
|
-
and **Done
|
|
923
|
+
and **Done**. Done is folded to one row, "3 done today · 12 earlier", until you
|
|
924
|
+
tap it (open or folded is remembered per device); open, it lists newest first,
|
|
925
|
+
ten at a time. The orchestrator's row carries small chips for the work it
|
|
926
|
+
started: running, in review, blocked and queued, each left out at zero. Tap
|
|
927
|
+
them to show only that orchestrator's work on the same shelves; the "Showing
|
|
928
|
+
… work ×" chip at the top clears it (it resets on reload). A session an
|
|
929
|
+
orchestrator started has a small orchestrator icon on its row. A purple dot marks a session that
|
|
924
930
|
finished or changed since you last opened it. Search matches
|
|
925
931
|
titles and project names, and the project filter narrows the list to one
|
|
926
932
|
project and holds that project's own actions: new session, terminal, dev
|
|
@@ -1059,7 +1065,31 @@ it may do on its own, and the lines that always come back to you as asks.
|
|
|
1059
1065
|
arrives marked as from that orchestrator and fenced as untrusted, so it can't
|
|
1060
1066
|
approve, answer an ask or pass a gate. The route
|
|
1061
1067
|
they call answers only the orchestrator's worker, by a per-orchestrator
|
|
1062
|
-
secret.
|
|
1068
|
+
secret.
|
|
1069
|
+
- **Shell:** it runs anything without asking, so an orchestrator can read and
|
|
1070
|
+
investigate on its own: `gh pr list`, `gh run view --log-failed`, `gh api`
|
|
1071
|
+
GETs, `curl`, `sqlite3 -readonly`, git in any repository, a skill's scripts,
|
|
1072
|
+
pipes and chains. Its chat worker refuses a deny list before a command runs,
|
|
1073
|
+
naming the tool to use instead: merging, closing or retargeting a PR
|
|
1074
|
+
(`gh pr merge`, `gh pr close`, `gh pr edit --base`, and `gh api` or `curl`
|
|
1075
|
+
calls that merge a pull request or PATCH or PUT one; use `sign_off`, `arm`,
|
|
1076
|
+
`land` or `drop`), `git push` (`quick_pr` pushes), writing in a project
|
|
1077
|
+
repository or task worktree other than its own open quick changes (git
|
|
1078
|
+
commit, reset, checkout, rebase, merge, stash, clean or rm there, and `rm`,
|
|
1079
|
+
`mv`, `cp`, `tee` or a redirect into one), writing AgentOS's state (`sqlite3`
|
|
1080
|
+
on its database without `-readonly` or with INSERT, UPDATE, DELETE, DROP,
|
|
1081
|
+
ALTER or ATTACH; files under `~/.agent-os` outside its folder), stopping
|
|
1082
|
+
AgentOS or its tmux (`pkill`/`kill` aimed at it, `tmux kill-*`, a
|
|
1083
|
+
`launchctl` or `serviceman` restart), and reading secret files (the same
|
|
1084
|
+
list as the write guard) or tokenvault (`tv get`, `tv dump`). It reads each
|
|
1085
|
+
command line the way a shell would, so these are caught inside pipes,
|
|
1086
|
+
chains and `$( )`, behind `bash -c`, `sh -c`, `env`, `sudo`, `xargs`,
|
|
1087
|
+
`find -exec` or an absolute path, and after a `cd` or `git -C`. **The deny
|
|
1088
|
+
list catches honest mistakes, not a determined bypass:** a script or
|
|
1089
|
+
`python -c` can do any of these where no parser sees it, and an
|
|
1090
|
+
orchestrator reads other sessions' text, which can carry planted
|
|
1091
|
+
instructions. The trade-off was chosen on 2026-10-11, for an orchestrator
|
|
1092
|
+
that does its own investigating instead of handing it to him.
|
|
1063
1093
|
- **Triage:** its brief has it decide each job: small and clear is a task with
|
|
1064
1094
|
`plan: false` and the steps written into the brief; big, unclear or
|
|
1065
1095
|
cross-cutting is `plan: true`; work outside a repository is a
|
|
@@ -1191,6 +1221,12 @@ it may do on its own, and the lines that always come back to you as asks.
|
|
|
1191
1221
|
Nothing is lost, because its state lives in the database; its chat goes on,
|
|
1192
1222
|
with a line where it started over. The same can be switched on for chat
|
|
1193
1223
|
tasks (off by default: a task keeps only its brief and its branch).
|
|
1224
|
+
**Start fresh by hand:** after a deploy that changes its permissions or
|
|
1225
|
+
brief, use **New conversation (keep brief)** in the orchestrator's row menu or type `/fresh`
|
|
1226
|
+
in its chat (task chats have both too). It takes the threshold's path: an
|
|
1227
|
+
idle agent starts over at once; a running turn finishes first, with a
|
|
1228
|
+
"Starts fresh after this turn" line. The next conversation launches with
|
|
1229
|
+
the brief, permissions and workspace state the server has then.
|
|
1194
1230
|
- **Events:** the server sends it one short line per event (a PR opened, CI
|
|
1195
1231
|
failed, or green once it has settled so `sign_off`'s ci gate passes, a `BLOCKED:` line, a merge, a session needing input, a
|
|
1196
1232
|
stack step, a task idle 30 minutes with no PR, a review's verdict). Events
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { queries, db, type Session } from "@/lib/db";
|
|
3
|
+
import { FreshRefusal, freshStartChat } from "@/lib/chat/runner";
|
|
4
|
+
|
|
5
|
+
// POST /api/sessions/:id/fresh - start the agent's conversation over (the
|
|
6
|
+
// path the context threshold takes, lib/chat/refresh): now if it's idle,
|
|
7
|
+
// else at the next turn boundary. Only for chats whose state lives outside
|
|
8
|
+
// the conversation: an orchestrator, or a task with a brief.
|
|
9
|
+
export async function POST(
|
|
10
|
+
_request: Request,
|
|
11
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
12
|
+
) {
|
|
13
|
+
const { id } = await params;
|
|
14
|
+
const session = queries.getSession(db).get(id) as Session | undefined;
|
|
15
|
+
if (!session)
|
|
16
|
+
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
|
17
|
+
if (session.role !== "orchestrator" && !session.task_brief)
|
|
18
|
+
return NextResponse.json(
|
|
19
|
+
{ error: "Only an orchestrator or a task can start fresh" },
|
|
20
|
+
{ status: 400 }
|
|
21
|
+
);
|
|
22
|
+
try {
|
|
23
|
+
return NextResponse.json({ when: await freshStartChat(id) });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{ error: message },
|
|
28
|
+
{ status: error instanceof FreshRefusal ? 409 : 500 }
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
type TimelineBlock,
|
|
20
20
|
} from "@/lib/chat/group";
|
|
21
21
|
import type { ApprovalDecision, ChatItem } from "@/lib/chat/events";
|
|
22
|
+
import { isFreshCommand, withFresh } from "@/lib/chat/commands";
|
|
23
|
+
import { useFreshStart } from "@/data/sessions";
|
|
22
24
|
import {
|
|
23
25
|
escapeAction,
|
|
24
26
|
GHOST_SELECTOR,
|
|
@@ -206,6 +208,7 @@ export function ChatPanel({
|
|
|
206
208
|
sessionName,
|
|
207
209
|
accessLocked = false,
|
|
208
210
|
orchestratorOf,
|
|
211
|
+
freshStart = false,
|
|
209
212
|
active,
|
|
210
213
|
}: {
|
|
211
214
|
sessionId: string;
|
|
@@ -214,6 +217,8 @@ export function ChatPanel({
|
|
|
214
217
|
accessLocked?: boolean;
|
|
215
218
|
// The workspace it orchestrates: its header line, Pause and asks show on top.
|
|
216
219
|
orchestratorOf?: string | null;
|
|
220
|
+
// An orchestrator or task: /fresh starts its conversation over.
|
|
221
|
+
freshStart?: boolean;
|
|
217
222
|
// The tab on show in the focused pane: opening it takes the caret.
|
|
218
223
|
active: boolean;
|
|
219
224
|
}) {
|
|
@@ -226,6 +231,7 @@ export function ChatPanel({
|
|
|
226
231
|
earlierTasks,
|
|
227
232
|
toolBodies,
|
|
228
233
|
requestToolBody,
|
|
234
|
+
pinToolBody,
|
|
229
235
|
state,
|
|
230
236
|
connected,
|
|
231
237
|
commands,
|
|
@@ -258,6 +264,7 @@ export function ChatPanel({
|
|
|
258
264
|
requestFiles,
|
|
259
265
|
search,
|
|
260
266
|
} = useChat(sessionId, (text) => setPrefill({ text, at: Date.now() }));
|
|
267
|
+
const startFresh = useFreshStart();
|
|
261
268
|
const { isMobile } = useViewport();
|
|
262
269
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
263
270
|
const listRef = useRef<HTMLDivElement>(null);
|
|
@@ -364,8 +371,13 @@ export function ChatPanel({
|
|
|
364
371
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
365
372
|
const rowState = useMemo(() => new RowStore(), [sessionId]);
|
|
366
373
|
const bodies = useMemo(
|
|
367
|
-
() => ({
|
|
368
|
-
|
|
374
|
+
() => ({
|
|
375
|
+
bodies: toolBodies,
|
|
376
|
+
request: requestToolBody,
|
|
377
|
+
pin: pinToolBody,
|
|
378
|
+
connected,
|
|
379
|
+
}),
|
|
380
|
+
[toolBodies, requestToolBody, pinToolBody, connected]
|
|
369
381
|
);
|
|
370
382
|
const itemsById = useMemo(
|
|
371
383
|
() => new Map(items.map((i) => [i.id, i])),
|
|
@@ -574,6 +586,10 @@ export function ChatPanel({
|
|
|
574
586
|
disabled={!connected}
|
|
575
587
|
placeholder={connected ? `Message ${sessionName}` : "Reconnecting…"}
|
|
576
588
|
onSend={(text, images) => {
|
|
589
|
+
if (freshStart && isFreshCommand(text)) {
|
|
590
|
+
startFresh.mutate(sessionId);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
577
593
|
toEnd();
|
|
578
594
|
const compact = composerCompacts(text, context, {
|
|
579
595
|
state,
|
|
@@ -584,7 +600,7 @@ export function ChatPanel({
|
|
|
584
600
|
send(text, images.length ? images : undefined, compact);
|
|
585
601
|
}}
|
|
586
602
|
onStop={interrupt}
|
|
587
|
-
commands={commands}
|
|
603
|
+
commands={freshStart ? withFresh(commands) : commands}
|
|
588
604
|
onRefreshCommands={refreshCommands}
|
|
589
605
|
refreshingCommands={refreshingCommands}
|
|
590
606
|
models={models}
|
|
@@ -5,6 +5,7 @@ import { Check, ChevronRight, Loader2, Square, X } from "lucide-react";
|
|
|
5
5
|
import type { FileDiff, ToolBody } from "@/lib/chat/events";
|
|
6
6
|
import type { ToolItem } from "@/lib/chat/group";
|
|
7
7
|
import { lineDiff, shortPath, type DiffLine } from "@/lib/chat/diff";
|
|
8
|
+
import { needsBody } from "@/lib/chat/bodyCache";
|
|
8
9
|
import { cn } from "@/lib/utils";
|
|
9
10
|
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
10
11
|
import { Highlighted } from "./Code";
|
|
@@ -16,16 +17,21 @@ import { useRowState } from "./rowState";
|
|
|
16
17
|
export const ToolBodies = createContext<{
|
|
17
18
|
bodies: Record<string, ToolBody | null>;
|
|
18
19
|
request: (id: string) => void;
|
|
20
|
+
// Marks a body as on screen until the returned cleanup runs.
|
|
21
|
+
pin: (id: string) => () => void;
|
|
19
22
|
connected: boolean;
|
|
20
|
-
}>({ bodies: {}, request: () => {}, connected: false });
|
|
23
|
+
}>({ bodies: {}, request: () => {}, pin: () => () => {}, connected: false });
|
|
21
24
|
|
|
22
25
|
// A tool call's output and diff: its own, or once loaded. `undefined` while
|
|
23
26
|
// it loads.
|
|
24
27
|
export function useToolBody(tool: ToolItem): ToolBody | null | undefined {
|
|
25
|
-
const { bodies, request, connected } = useContext(ToolBodies);
|
|
28
|
+
const { bodies, request, pin, connected } = useContext(ToolBodies);
|
|
26
29
|
const body = tool.deferred ? bodies[tool.id] : tool;
|
|
27
30
|
useEffect(() => {
|
|
28
|
-
if (tool.deferred
|
|
31
|
+
if (tool.deferred) return pin(tool.id);
|
|
32
|
+
}, [tool.deferred, tool.id, pin]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (needsBody(tool.deferred, body, connected)) request(tool.id);
|
|
29
35
|
}, [tool.deferred, tool.id, body, request, connected]);
|
|
30
36
|
return body;
|
|
31
37
|
}
|
|
@@ -289,6 +289,10 @@ export const Pane = memo(function Pane({
|
|
|
289
289
|
sessionId={chat.id}
|
|
290
290
|
sessionName={chat.name}
|
|
291
291
|
accessLocked={chat.role === "orchestrator"}
|
|
292
|
+
freshStart={
|
|
293
|
+
(chat.role === "orchestrator" || !!chat.task_brief) &&
|
|
294
|
+
!chat.archived_at
|
|
295
|
+
}
|
|
292
296
|
orchestratorOf={
|
|
293
297
|
chat.role === "orchestrator" ? chat.workspace_id : null
|
|
294
298
|
}
|
|
@@ -77,6 +77,8 @@ describe("OrchestratorPinRow", () => {
|
|
|
77
77
|
orchestrators: [],
|
|
78
78
|
runningByWorkspace: new Map(),
|
|
79
79
|
queuedByWorkspace: new Map(),
|
|
80
|
+
rollups: new Map(),
|
|
81
|
+
orchestratorNames: new Map(),
|
|
80
82
|
orderedIds: () => [],
|
|
81
83
|
cardUrl: () => null,
|
|
82
84
|
onSelect,
|
|
@@ -86,6 +88,7 @@ describe("OrchestratorPinRow", () => {
|
|
|
86
88
|
onDelete: vi.fn(),
|
|
87
89
|
onMoveToProject: vi.fn(),
|
|
88
90
|
onPin: vi.fn(),
|
|
91
|
+
onFresh: vi.fn(),
|
|
89
92
|
};
|
|
90
93
|
const render = (over: Partial<RowContextValue> = {}, unread = false) =>
|
|
91
94
|
act(async () =>
|
|
@@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button";
|
|
|
6
6
|
import * as DM from "@/components/ui/dropdown-menu";
|
|
7
7
|
import * as CM from "@/components/ui/context-menu";
|
|
8
8
|
import { sameRow, type SidebarRow } from "@/lib/sidebar/shelves";
|
|
9
|
+
import { visibleChips } from "@/lib/sidebar/rollup";
|
|
10
|
+
import { sidebarUi, sidebarUiActions } from "@/stores/sidebarUi";
|
|
11
|
+
import { useSnapshot } from "valtio";
|
|
9
12
|
import { orchestratorRowText } from "@/lib/sidebar/orchestrator-row";
|
|
10
13
|
import { cn } from "@/lib/utils";
|
|
11
14
|
import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
|
|
@@ -36,6 +39,9 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
|
|
|
36
39
|
inReview: overview?.inReview ?? 0,
|
|
37
40
|
queued: ctx.queuedByWorkspace.get(ws) ?? 0,
|
|
38
41
|
});
|
|
42
|
+
const chips = visibleChips(ctx.rollups.get(session.id));
|
|
43
|
+
const { orchestratorId } = useSnapshot(sidebarUi);
|
|
44
|
+
const filtering = orchestratorId === session.id;
|
|
39
45
|
const label = ctx.workspaceNames.get(ws) ?? "Orchestrator";
|
|
40
46
|
const summary = asks ? `${status} · ${asks}` : status;
|
|
41
47
|
const menu = (kind: "dropdown" | "context") => (
|
|
@@ -100,6 +106,36 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
|
|
|
100
106
|
</>
|
|
101
107
|
)}
|
|
102
108
|
</span>
|
|
109
|
+
{chips.length > 0 && (
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
aria-pressed={filtering}
|
|
113
|
+
title={`Show only ${label}'s work`}
|
|
114
|
+
onClick={(e) => {
|
|
115
|
+
e.stopPropagation();
|
|
116
|
+
sidebarUiActions.setOrchestrator(
|
|
117
|
+
filtering ? null : session.id
|
|
118
|
+
);
|
|
119
|
+
}}
|
|
120
|
+
className={cn(
|
|
121
|
+
"text-muted-foreground -mx-1 mt-0.5 flex min-h-6 flex-wrap items-center gap-x-2.5 rounded px-1 text-xs",
|
|
122
|
+
"hover:bg-muted hover:text-foreground md:min-h-5",
|
|
123
|
+
filtering && "bg-muted text-foreground"
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
{chips.map((c) => (
|
|
127
|
+
<span key={c.key} className="flex items-center gap-1">
|
|
128
|
+
<span
|
|
129
|
+
aria-hidden
|
|
130
|
+
className={cn("h-1.5 w-1.5 rounded-full", c.dot)}
|
|
131
|
+
/>
|
|
132
|
+
<span className="tabular-nums">
|
|
133
|
+
{ctx.rollups.get(session.id)![c.key]} {c.label}
|
|
134
|
+
</span>
|
|
135
|
+
</span>
|
|
136
|
+
))}
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
103
139
|
</>
|
|
104
140
|
)}
|
|
105
141
|
</span>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { createContext, useContext } from "react";
|
|
4
4
|
import type { ProjectWithRepositories } from "@/lib/projects";
|
|
5
|
+
import type { Rollup } from "@/lib/sidebar/rollup";
|
|
5
6
|
import type { OrchestratorOverview } from "@/lib/orchestrator/overview";
|
|
6
7
|
|
|
7
8
|
// What every row needs from the list, so shelves don't pass it along.
|
|
@@ -14,6 +15,9 @@ export interface RowContextValue {
|
|
|
14
15
|
orchestrators: OrchestratorOverview[];
|
|
15
16
|
runningByWorkspace: Map<string, number>;
|
|
16
17
|
queuedByWorkspace: Map<string, number>;
|
|
18
|
+
// What each orchestrator session started, and its name (its workspace's).
|
|
19
|
+
rollups: Map<string, Rollup>;
|
|
20
|
+
orchestratorNames: Map<string, string>;
|
|
17
21
|
// Render order, for shift-click range selection.
|
|
18
22
|
orderedIds: () => string[];
|
|
19
23
|
cardUrl: (sessionId: string) => string | null | undefined;
|
|
@@ -25,6 +29,7 @@ export interface RowContextValue {
|
|
|
25
29
|
onDelete: (sessionId: string) => void;
|
|
26
30
|
onMoveToProject: (sessionId: string, projectId: string) => void;
|
|
27
31
|
onPin: (sessionId: string, pinned: boolean) => void;
|
|
32
|
+
onFresh: (sessionId: string) => void;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
const RowContext = createContext<RowContextValue | null>(null);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { memo, useState } from "react";
|
|
4
4
|
import { useSnapshot } from "valtio";
|
|
5
|
-
import { CheckSquare, GitFork, Square } from "lucide-react";
|
|
5
|
+
import { CheckSquare, GitFork, Square, Workflow } from "lucide-react";
|
|
6
6
|
import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
|
|
7
7
|
import { sameRow, type SidebarRow } from "@/lib/sidebar/shelves";
|
|
8
8
|
import { useMinuteTick } from "@/hooks/useMinuteTick";
|
|
@@ -33,6 +33,9 @@ export const SessionRow = memo(function SessionRow({
|
|
|
33
33
|
const [renaming, setRenaming] = useState(false);
|
|
34
34
|
const { session } = row;
|
|
35
35
|
const active = session.id === ctx.activeSessionId;
|
|
36
|
+
const viaName = session.started_by
|
|
37
|
+
? ctx.orchestratorNames.get(session.started_by)
|
|
38
|
+
: undefined;
|
|
36
39
|
const selecting = selectedIds.size > 0;
|
|
37
40
|
const selected = selectedIds.has(session.id);
|
|
38
41
|
const onClick = (e: React.MouseEvent) => {
|
|
@@ -76,9 +79,19 @@ export const SessionRow = memo(function SessionRow({
|
|
|
76
79
|
)
|
|
77
80
|
}
|
|
78
81
|
aside={
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
<>
|
|
83
|
+
{viaName && (
|
|
84
|
+
<span title={`Started by ${viaName}`} className="shrink-0">
|
|
85
|
+
<Workflow
|
|
86
|
+
aria-label={`Via orchestrator: ${viaName}`}
|
|
87
|
+
className="text-muted-foreground/60 h-3 w-3"
|
|
88
|
+
/>
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
{session.parent_session_id && (
|
|
92
|
+
<GitFork className="text-muted-foreground/60 h-3 w-3 shrink-0" />
|
|
93
|
+
)}
|
|
94
|
+
</>
|
|
82
95
|
}
|
|
83
96
|
trailing={
|
|
84
97
|
<>
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
Pencil,
|
|
16
16
|
Pin,
|
|
17
17
|
PinOff,
|
|
18
|
+
RotateCcw,
|
|
18
19
|
Sparkles,
|
|
19
20
|
Trash2,
|
|
20
21
|
} from "lucide-react";
|
|
@@ -174,6 +175,15 @@ export function SessionRowMenu({
|
|
|
174
175
|
{summarizing ? "Summarizing..." : "Fresh start"}
|
|
175
176
|
</Item>
|
|
176
177
|
)}
|
|
178
|
+
{(session.role === "orchestrator" || session.task_brief) &&
|
|
179
|
+
session.view === "chat" &&
|
|
180
|
+
!session.archived_at &&
|
|
181
|
+
!blocked && (
|
|
182
|
+
<Item onClick={() => ctx.onFresh(session.id)}>
|
|
183
|
+
<RotateCcw className={icon} />
|
|
184
|
+
New conversation (keep brief)
|
|
185
|
+
</Item>
|
|
186
|
+
)}
|
|
177
187
|
{session.pr_url && (
|
|
178
188
|
<Item onClick={() => window.open(session.pr_url!, "_blank")}>
|
|
179
189
|
<GitPullRequest className={icon} />
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
import { ChevronDown } from "lucide-react";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DONE_PAGE,
|
|
7
|
+
doneLimit,
|
|
8
|
+
doneSummary,
|
|
9
|
+
type Shelves,
|
|
10
|
+
} from "@/lib/sidebar/shelves";
|
|
6
11
|
import type { MachineGroup } from "@/lib/sidebar/machines";
|
|
7
12
|
import { sidebarUiActions } from "@/stores/sidebarUi";
|
|
8
13
|
import { cn } from "@/lib/utils";
|
|
@@ -97,6 +102,7 @@ export function SessionShelves({
|
|
|
97
102
|
}) {
|
|
98
103
|
const shown = shelves.done.slice(0, doneLimit(donePages));
|
|
99
104
|
const hidden = shelves.done.length - shown.length;
|
|
105
|
+
const summary = doneSummary(shelves.done).text;
|
|
100
106
|
return (
|
|
101
107
|
<>
|
|
102
108
|
{shelves.orchestrators.length + toStart.length > 0 && (
|
|
@@ -140,7 +146,7 @@ export function SessionShelves({
|
|
|
140
146
|
{shelves.done.length > 0 && (
|
|
141
147
|
<section aria-label="Done">
|
|
142
148
|
<ShelfLabel
|
|
143
|
-
label="Done"
|
|
149
|
+
label={doneCollapsed ? summary : "Done"}
|
|
144
150
|
collapsed={doneCollapsed}
|
|
145
151
|
onToggle={sidebarUiActions.toggleDone}
|
|
146
152
|
/>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { AlertCircle, FolderPlus, Plus } from "lucide-react";
|
|
3
|
+
import { AlertCircle, FolderPlus, Plus, X } from "lucide-react";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
5
|
import { ShimmeringLoader } from "@/components/ui/skeleton";
|
|
6
|
+
import { sidebarUiActions } from "@/stores/sidebarUi";
|
|
6
7
|
import type { SidebarData } from "./useSidebarData";
|
|
7
8
|
import { MachineShelves, SessionShelves } from "./SessionShelves";
|
|
8
9
|
import { SidebarExtras } from "./SidebarExtras";
|
|
@@ -71,6 +72,21 @@ export function SidebarBody({
|
|
|
71
72
|
|
|
72
73
|
return (
|
|
73
74
|
<>
|
|
75
|
+
{ui.orchestratorId && (
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={() => sidebarUiActions.setOrchestrator(null)}
|
|
79
|
+
aria-label="Clear the orchestrator filter"
|
|
80
|
+
className="bg-muted hover:bg-muted/70 text-foreground mx-2.5 mt-2 flex min-h-8 max-w-[calc(100%-1.25rem)] items-center gap-1.5 rounded-full px-3 text-xs md:min-h-7"
|
|
81
|
+
>
|
|
82
|
+
<span className="truncate">
|
|
83
|
+
Showing{" "}
|
|
84
|
+
{data.orchestratorNames.get(ui.orchestratorId) ?? "orchestrator"}
|
|
85
|
+
’s work
|
|
86
|
+
</span>
|
|
87
|
+
<X className="h-3 w-3 shrink-0" />
|
|
88
|
+
</button>
|
|
89
|
+
)}
|
|
74
90
|
<DraftShelf projects={data.projects} />
|
|
75
91
|
{count === 0 &&
|
|
76
92
|
(ui.query ? (
|
|
@@ -4,7 +4,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
|
4
4
|
import { Loader2 } from "lucide-react";
|
|
5
5
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
6
6
|
import { useDocsWorkspace } from "@/data/lumifyhub/docs";
|
|
7
|
-
import { usePinSession } from "@/data/sessions";
|
|
7
|
+
import { useFreshStart, usePinSession } from "@/data/sessions";
|
|
8
8
|
import { useOrchestratorsQuery } from "@/data/orchestrators";
|
|
9
9
|
import { docsUiActions } from "@/stores/docsUi";
|
|
10
10
|
import type { SidebarRow } from "@/lib/sidebar/shelves";
|
|
@@ -47,6 +47,7 @@ export function SessionList({
|
|
|
47
47
|
const mutations = useSessionListMutations({ onSelectSession: onSelect });
|
|
48
48
|
const bulkDelete = useBulkDelete();
|
|
49
49
|
const pin = usePinSession();
|
|
50
|
+
const fresh = useFreshStart();
|
|
50
51
|
const { data: orchestrators = NO_ORCHESTRATORS } = useOrchestratorsQuery();
|
|
51
52
|
const [dialog, setDialog] = useState<SidebarDialog | null>(null);
|
|
52
53
|
const addProject = useAddProject();
|
|
@@ -70,9 +71,23 @@ export function SessionList({
|
|
|
70
71
|
// Rows read their handlers through the latest render, so the context only
|
|
71
72
|
// changes when what rows draw does: a status push redraws the rows it
|
|
72
73
|
// touched, not the whole list.
|
|
73
|
-
const latest = useRef({
|
|
74
|
+
const latest = useRef({
|
|
75
|
+
orderedIds,
|
|
76
|
+
onSelect,
|
|
77
|
+
onOpenInTab,
|
|
78
|
+
mutations,
|
|
79
|
+
pin,
|
|
80
|
+
fresh,
|
|
81
|
+
});
|
|
74
82
|
useLayoutEffect(() => {
|
|
75
|
-
latest.current = {
|
|
83
|
+
latest.current = {
|
|
84
|
+
orderedIds,
|
|
85
|
+
onSelect,
|
|
86
|
+
onOpenInTab,
|
|
87
|
+
mutations,
|
|
88
|
+
pin,
|
|
89
|
+
fresh,
|
|
90
|
+
};
|
|
76
91
|
});
|
|
77
92
|
const canOpenInTab = !!onOpenInTab;
|
|
78
93
|
const handlers = useMemo(
|
|
@@ -92,6 +107,7 @@ export function SessionList({
|
|
|
92
107
|
latest.current.mutations.handleMoveSessionToProject(id, projectId),
|
|
93
108
|
onPin: (id: string, pinned: boolean) =>
|
|
94
109
|
latest.current.pin.mutate({ id, pinned }),
|
|
110
|
+
onFresh: (id: string) => latest.current.fresh.mutate(id),
|
|
95
111
|
}),
|
|
96
112
|
[canOpenInTab]
|
|
97
113
|
);
|
|
@@ -110,6 +126,8 @@ export function SessionList({
|
|
|
110
126
|
orchestrators,
|
|
111
127
|
runningByWorkspace: data.runningByWorkspace,
|
|
112
128
|
queuedByWorkspace: data.queuedByWorkspace,
|
|
129
|
+
rollups: data.rollupByOrchestrator,
|
|
130
|
+
orchestratorNames: data.orchestratorNames,
|
|
113
131
|
cardUrl: data.taskCardUrl,
|
|
114
132
|
}),
|
|
115
133
|
[
|
|
@@ -122,6 +140,8 @@ export function SessionList({
|
|
|
122
140
|
orchestrators,
|
|
123
141
|
data.runningByWorkspace,
|
|
124
142
|
data.queuedByWorkspace,
|
|
143
|
+
data.rollupByOrchestrator,
|
|
144
|
+
data.orchestratorNames,
|
|
125
145
|
data.taskCardUrl,
|
|
126
146
|
]
|
|
127
147
|
);
|
|
@@ -15,6 +15,7 @@ import { useSessionsQuery } from "@/data/sessions";
|
|
|
15
15
|
import { useLinkedHostNames } from "@/data/hosts";
|
|
16
16
|
import { useProjectsQuery } from "@/data/projects";
|
|
17
17
|
import { useQueuedTasksQuery, useTasksQuery } from "@/data/tasks";
|
|
18
|
+
import { rollups, type Rollup } from "@/lib/sidebar/rollup";
|
|
18
19
|
import { queuedRows } from "@/lib/sidebar/queued";
|
|
19
20
|
import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
|
|
20
21
|
import { useOrchestratorsQuery } from "@/data/orchestrators";
|
|
@@ -69,7 +70,7 @@ export function useSidebarData(
|
|
|
69
70
|
);
|
|
70
71
|
const project = workspaceProjects.find((p) => p.id === ui.projectId) ?? null;
|
|
71
72
|
|
|
72
|
-
const { shelves, machines } = useMemo(() => {
|
|
73
|
+
const { all, shelves, machines } = useMemo(() => {
|
|
73
74
|
const byId = new Map(projects.map((p) => [p.id, p]));
|
|
74
75
|
const workspaceName = new Map(workspaces.map((w) => [w.id, w.name]));
|
|
75
76
|
const taskStates: Record<string, TaskState> = {};
|
|
@@ -84,23 +85,32 @@ export function useSidebarData(
|
|
|
84
85
|
projectName,
|
|
85
86
|
query: ui.query,
|
|
86
87
|
};
|
|
88
|
+
const input = {
|
|
89
|
+
...common,
|
|
90
|
+
sessions: sessions.filter(
|
|
91
|
+
(s) =>
|
|
92
|
+
!onMachineOnly(s, linked) &&
|
|
93
|
+
inWorkspace(
|
|
94
|
+
s,
|
|
95
|
+
(id) => byId.get(id)?.workspace_id,
|
|
96
|
+
workspace?.id ?? null
|
|
97
|
+
)
|
|
98
|
+
),
|
|
99
|
+
projectId: project?.id ?? null,
|
|
100
|
+
currentOrchestrators,
|
|
101
|
+
};
|
|
102
|
+
// The rollups count the whole list; the filter only narrows what shows.
|
|
103
|
+
const all = buildShelves(input);
|
|
87
104
|
return {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
)
|
|
98
|
-
),
|
|
99
|
-
projectId: project?.id ?? null,
|
|
100
|
-
currentOrchestrators,
|
|
101
|
-
}),
|
|
102
|
-
// Not in any project, so not under a chosen one.
|
|
103
|
-
machines: project ? [] : machineGroups({ ...common, sessions }, linked),
|
|
105
|
+
all,
|
|
106
|
+
shelves: ui.orchestratorId
|
|
107
|
+
? buildShelves({ ...input, startedBy: ui.orchestratorId })
|
|
108
|
+
: all,
|
|
109
|
+
// Not in any project, nor started by an orchestrator.
|
|
110
|
+
machines:
|
|
111
|
+
project || ui.orchestratorId
|
|
112
|
+
? []
|
|
113
|
+
: machineGroups({ ...common, sessions }, linked),
|
|
104
114
|
};
|
|
105
115
|
}, [
|
|
106
116
|
linked,
|
|
@@ -113,6 +123,7 @@ export function useSidebarData(
|
|
|
113
123
|
workspace,
|
|
114
124
|
project,
|
|
115
125
|
ui.query,
|
|
126
|
+
ui.orchestratorId,
|
|
116
127
|
]);
|
|
117
128
|
|
|
118
129
|
// Per workspace, how many of its project sessions are working: the
|
|
@@ -150,7 +161,7 @@ export function useSidebarData(
|
|
|
150
161
|
);
|
|
151
162
|
|
|
152
163
|
// Tasks waiting to start, in their line's order.
|
|
153
|
-
const
|
|
164
|
+
const queuedAll = useMemo(
|
|
154
165
|
() =>
|
|
155
166
|
queuedRows({
|
|
156
167
|
queue,
|
|
@@ -161,6 +172,39 @@ export function useSidebarData(
|
|
|
161
172
|
}),
|
|
162
173
|
[queue, sessions, workspace, project, ui.query]
|
|
163
174
|
);
|
|
175
|
+
const queued = useMemo(
|
|
176
|
+
() =>
|
|
177
|
+
ui.orchestratorId
|
|
178
|
+
? queuedAll.filter((r) => r.item.startedBy === ui.orchestratorId)
|
|
179
|
+
: queuedAll,
|
|
180
|
+
[queuedAll, ui.orchestratorId]
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Each orchestrator's started work, keyed by its counts like the ones
|
|
184
|
+
// above so a push that changed none of them redraws no row.
|
|
185
|
+
const rollupKey = useMemo(
|
|
186
|
+
() => JSON.stringify([...rollups(all, queuedAll)].sort()),
|
|
187
|
+
[all, queuedAll]
|
|
188
|
+
);
|
|
189
|
+
const rollupByOrchestrator = useMemo(
|
|
190
|
+
() => new Map(JSON.parse(rollupKey) as [string, Rollup][]),
|
|
191
|
+
[rollupKey]
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Orchestrator session id -> the workspace it serves, for tags and chips.
|
|
195
|
+
const orchestratorKey = JSON.stringify(
|
|
196
|
+
sessions
|
|
197
|
+
.filter((s) => s.role === "orchestrator")
|
|
198
|
+
.map((s) => [
|
|
199
|
+
s.id,
|
|
200
|
+
workspaces.find((w) => w.id === s.workspace_id)?.name ?? s.name,
|
|
201
|
+
])
|
|
202
|
+
.sort()
|
|
203
|
+
);
|
|
204
|
+
const orchestratorNames = useMemo(
|
|
205
|
+
() => new Map(JSON.parse(orchestratorKey) as [string, string][]),
|
|
206
|
+
[orchestratorKey]
|
|
207
|
+
);
|
|
164
208
|
|
|
165
209
|
const taskCardUrl = useCallback(
|
|
166
210
|
(id: string) => tasks.find((t) => t.id === id)?.cardUrl,
|
|
@@ -194,6 +238,8 @@ export function useSidebarData(
|
|
|
194
238
|
projectNames,
|
|
195
239
|
runningByWorkspace,
|
|
196
240
|
queuedByWorkspace,
|
|
241
|
+
rollupByOrchestrator,
|
|
242
|
+
orchestratorNames,
|
|
197
243
|
workspaces,
|
|
198
244
|
workspace,
|
|
199
245
|
workspaceProjects,
|