@saadnvd1/agent-os 0.3.591 → 0.3.594
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/resurrect/route.test.ts +47 -0
- package/app/api/bus/resurrect/route.ts +36 -0
- package/app/api/sessions/[id]/pause/route.ts +5 -2
- package/app/api/sessions/[id]/resurrect/route.test.ts +32 -0
- package/app/api/sessions/[id]/resurrect/route.ts +24 -0
- package/app/api/sessions/ended/route.ts +19 -0
- package/app/globals.css +11 -0
- package/components/Archived/ArchivedRow.tsx +11 -7
- package/components/Chat/ChatPanel.tsx +45 -11
- package/components/Chat/Messages.test.ts +40 -1
- package/components/Chat/Messages.tsx +96 -26
- package/components/Chat/PausedBanner.tsx +1 -1
- package/components/Chat/PlanCard.tsx +4 -2
- package/components/Chat/Undo.tsx +3 -0
- package/components/Chat/idle.ts +9 -0
- package/components/SessionList/RowSubtitle.tsx +1 -0
- package/components/SessionList/SessionRowMenu.tsx +12 -1
- package/components/SessionList/useDoneAction.ts +14 -0
- package/components/SessionList/useResurrectAction.test.ts +96 -0
- package/components/Tasks/TaskRow.tsx +18 -0
- package/data/chat/useChat.ts +83 -13
- package/data/chat/usePending.ts +70 -0
- package/data/done/index.ts +17 -0
- package/lib/chat/client-send.ts +8 -1
- package/lib/chat/demo.ts +22 -3
- package/lib/chat/drivers/claude.ts +15 -2
- package/lib/chat/drivers/opencode.ts +1 -0
- package/lib/chat/events.ts +7 -2
- package/lib/chat/pending.ts +169 -0
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/socket.ts +12 -1
- package/lib/db/migrations.ts +11 -0
- package/lib/db/types.ts +2 -0
- package/lib/done/worktree.ts +18 -2
- package/lib/orchestrator/act.ts +44 -5
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/digest.ts +3 -2
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +2 -1
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +6 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/resurrect/find.ts +111 -0
- package/lib/resurrect/index.ts +422 -0
- package/lib/resurrect/plan.ts +61 -0
- package/lib/resurrect/snapshot.ts +229 -0
- package/lib/security/demo.ts +1 -0
- package/lib/sidebar/paused.ts +8 -1
- package/lib/sidebar/row-actions.ts +7 -3
- package/lib/sidebar/shelves.ts +7 -1
- package/lib/tasks/auto-hold.ts +17 -0
- package/lib/tasks/finish.ts +9 -0
- package/lib/tasks/pause-hold.ts +1 -1
- package/lib/tasks/pause.ts +76 -8
- package/lib/tasks/queue.ts +39 -12
- package/package.json +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const resurrect = vi.hoisted(() => vi.fn());
|
|
5
|
+
vi.mock("@/lib/resurrect", () => ({ resurrectSession: resurrect }));
|
|
6
|
+
|
|
7
|
+
const { db } = await import("@/lib/db");
|
|
8
|
+
const { seedWorkspace } = await import("@/lib/orchestrator/testing");
|
|
9
|
+
const { POST } = await import("./route");
|
|
10
|
+
|
|
11
|
+
const call = (body: object) =>
|
|
12
|
+
POST(
|
|
13
|
+
new NextRequest("http://localhost/api/bus/resurrect", {
|
|
14
|
+
method: "POST",
|
|
15
|
+
body: JSON.stringify(body),
|
|
16
|
+
})
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe("POST /api/bus/resurrect (aos resurrect)", () => {
|
|
20
|
+
it("runs only from inside a session", async () => {
|
|
21
|
+
const res = await call({ session: "x" });
|
|
22
|
+
expect(res.status).toBe(409);
|
|
23
|
+
expect((await res.json()).error).toMatch(/inside an AgentOS session/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("reaches only the caller's workspace, and lists what ended with no session", async () => {
|
|
27
|
+
const mine = seedWorkspace("Bus mine");
|
|
28
|
+
const other = seedWorkspace("Bus other");
|
|
29
|
+
db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
|
|
30
|
+
mine.task
|
|
31
|
+
);
|
|
32
|
+
db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
|
|
33
|
+
other.task
|
|
34
|
+
);
|
|
35
|
+
const list = await (await call({ from: mine.chat })).json();
|
|
36
|
+
expect(list.sessions.map((s: { id: string }) => s.id)).toEqual([mine.task]);
|
|
37
|
+
|
|
38
|
+
const refused = await call({ from: mine.chat, session: other.task });
|
|
39
|
+
expect(refused.status).toBe(409);
|
|
40
|
+
expect(resurrect).not.toHaveBeenCalled();
|
|
41
|
+
|
|
42
|
+
resurrect.mockResolvedValueOnce({ text: "back" });
|
|
43
|
+
const ok = await call({ from: mine.chat, session: mine.task });
|
|
44
|
+
expect((await ok.json()).summary).toBe("back");
|
|
45
|
+
expect(resurrect).toHaveBeenCalledWith(mine.task);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getDoneTarget } from "@/lib/done";
|
|
3
|
+
import { scopeOf } from "@/lib/done/bulk";
|
|
4
|
+
import { resurrectSession } from "@/lib/resurrect";
|
|
5
|
+
import { endedSessions, resolveEnded } from "@/lib/resurrect/find";
|
|
6
|
+
|
|
7
|
+
// `aos resurrect [session]`, from inside a session: reaches only the
|
|
8
|
+
// caller's workspace (or its project when it has none). Without a session it
|
|
9
|
+
// lists the dropped and done ones that can come back.
|
|
10
|
+
export async function POST(request: NextRequest) {
|
|
11
|
+
try {
|
|
12
|
+
const { from = null, session } = await request.json();
|
|
13
|
+
if (typeof from !== "string" || !from)
|
|
14
|
+
throw new Error("aos resurrect runs from inside an AgentOS session");
|
|
15
|
+
const scope = scopeOf(getDoneTarget(from));
|
|
16
|
+
if (typeof session !== "string" || !session.trim()) {
|
|
17
|
+
const ended = endedSessions(scope, { limit: 15 });
|
|
18
|
+
return NextResponse.json({
|
|
19
|
+
sessions: ended,
|
|
20
|
+
summary: ended.length
|
|
21
|
+
? ended
|
|
22
|
+
.map(
|
|
23
|
+
(e) =>
|
|
24
|
+
`${e.id.slice(0, 8)} ${e.kind.padEnd(8)} ${e.projectName ? `${e.projectName}/` : ""}${e.name}`
|
|
25
|
+
)
|
|
26
|
+
.join("\n")
|
|
27
|
+
: "Nothing dropped, done or archived to restore.",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const outcome = await resurrectSession(resolveEnded(session, scope));
|
|
31
|
+
return NextResponse.json({ summary: outcome.text, outcome });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
return NextResponse.json({ error: message }, { status: 409 });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -89,8 +89,11 @@ export async function DELETE(
|
|
|
89
89
|
) {
|
|
90
90
|
const { id } = await params;
|
|
91
91
|
try {
|
|
92
|
-
await resumeNow(id, "person");
|
|
93
|
-
return NextResponse.json({
|
|
92
|
+
const out = await resumeNow(id, "person");
|
|
93
|
+
return NextResponse.json({
|
|
94
|
+
pause: pauseViewOf(id),
|
|
95
|
+
...(out.outcome === "held" && { note: out.note }),
|
|
96
|
+
});
|
|
94
97
|
} catch (error) {
|
|
95
98
|
return fail(error);
|
|
96
99
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const resurrect = vi.hoisted(() => vi.fn());
|
|
5
|
+
vi.mock("@/lib/resurrect", () => ({ resurrectSession: resurrect }));
|
|
6
|
+
|
|
7
|
+
const { POST } = await import("./route");
|
|
8
|
+
|
|
9
|
+
const call = (id: string) =>
|
|
10
|
+
POST(
|
|
11
|
+
new NextRequest(`http://localhost/api/sessions/${id}/resurrect`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
}),
|
|
14
|
+
{ params: Promise.resolve({ id }) }
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe("POST /api/sessions/:id/resurrect", () => {
|
|
18
|
+
it("answers with the outcome", async () => {
|
|
19
|
+
resurrect.mockResolvedValueOnce({ id: "s1", text: "back" });
|
|
20
|
+
const res = await call("s1");
|
|
21
|
+
expect(res.status).toBe(200);
|
|
22
|
+
expect((await res.json()).outcome).toEqual({ id: "s1", text: "back" });
|
|
23
|
+
expect(resurrect).toHaveBeenCalledWith("s1", { device: null });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("answers a refusal with 409 and the reason", async () => {
|
|
27
|
+
resurrect.mockRejectedValueOnce(new Error("its PR #3 was merged"));
|
|
28
|
+
const res = await call("s2");
|
|
29
|
+
expect(res.status).toBe(409);
|
|
30
|
+
expect(await res.json()).toEqual({ error: "its PR #3 was merged" });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { resurrectSession } from "@/lib/resurrect";
|
|
3
|
+
import { callingDevice } from "@/lib/security/device-me";
|
|
4
|
+
import { db, queries, type Session } from "@/lib/db";
|
|
5
|
+
|
|
6
|
+
// POST /api/sessions/:id/resurrect - bring a dropped, done or archived
|
|
7
|
+
// session back: same id, same conversation, its status as it was. A refusal
|
|
8
|
+
// (merged PR, conversation gone, project removed) is a 409 with the reason.
|
|
9
|
+
export async function POST(
|
|
10
|
+
request: NextRequest,
|
|
11
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
12
|
+
) {
|
|
13
|
+
try {
|
|
14
|
+
const id = (await params).id;
|
|
15
|
+
const outcome = await resurrectSession(id, {
|
|
16
|
+
device: callingDevice(request),
|
|
17
|
+
});
|
|
18
|
+
const session = queries.getSession(db).get(id) as Session | undefined;
|
|
19
|
+
return NextResponse.json({ outcome, session });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
return NextResponse.json({ error: message }, { status: 409 });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { endedSessions, type EndedKind } from "@/lib/resurrect/find";
|
|
3
|
+
|
|
4
|
+
const KINDS = new Set(["dropped", "done", "archived"]);
|
|
5
|
+
|
|
6
|
+
// GET /api/sessions/ended[?workspace=id][&kind=dropped|done|archived][&limit=n]
|
|
7
|
+
// - what can be restored, most recently ended first.
|
|
8
|
+
export async function GET(request: NextRequest) {
|
|
9
|
+
const q = request.nextUrl.searchParams;
|
|
10
|
+
const workspace = q.get("workspace");
|
|
11
|
+
const kind = q.get("kind");
|
|
12
|
+
const limit = Math.min(Math.max(Number(q.get("limit")) || 50, 1), 200);
|
|
13
|
+
return NextResponse.json({
|
|
14
|
+
sessions: endedSessions(workspace ? { workspaceId: workspace } : null, {
|
|
15
|
+
limit,
|
|
16
|
+
kind: kind && KINDS.has(kind) ? (kind as EndedKind) : undefined,
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
}
|
package/app/globals.css
CHANGED
|
@@ -131,6 +131,17 @@
|
|
|
131
131
|
animation: status-pulse 1.6s ease-in-out infinite;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/* Shown only once something has taken a moment: a quick send never flashes it. */
|
|
135
|
+
@keyframes status-late {
|
|
136
|
+
from {
|
|
137
|
+
opacity: 0;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@utility status-late {
|
|
142
|
+
animation: status-late 0.2s ease-out 0.6s both;
|
|
143
|
+
}
|
|
144
|
+
|
|
134
145
|
@media (prefers-reduced-motion: reduce) {
|
|
135
146
|
.status-live {
|
|
136
147
|
animation: none;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { ArchiveRestore, ExternalLink, Loader2 } from "lucide-react";
|
|
4
4
|
import { toast } from "sonner";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
|
-
import {
|
|
6
|
+
import { useResurrectSession } from "@/data/done";
|
|
7
7
|
import type { ArchivedView } from "@/lib/done/archive";
|
|
8
8
|
import { fromSqliteTime } from "@/lib/session-meta";
|
|
9
9
|
|
|
@@ -24,7 +24,7 @@ function when(at: string): string {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function ArchivedRow({ session }: { session: ArchivedView }) {
|
|
27
|
-
const
|
|
27
|
+
const restore = useResurrectSession();
|
|
28
28
|
const kind = session.taskStatus ? STATUS[session.taskStatus] : "Session";
|
|
29
29
|
|
|
30
30
|
return (
|
|
@@ -70,20 +70,24 @@ export function ArchivedRow({ session }: { session: ArchivedView }) {
|
|
|
70
70
|
? "Puts the entry back in the sidebar; its worktree was removed"
|
|
71
71
|
: undefined
|
|
72
72
|
}
|
|
73
|
-
disabled={
|
|
73
|
+
disabled={restore.isPending}
|
|
74
74
|
onClick={() =>
|
|
75
|
-
|
|
76
|
-
onSuccess: () => toast.success(
|
|
75
|
+
restore.mutate(session.id, {
|
|
76
|
+
onSuccess: (outcome) => toast.success(outcome.text),
|
|
77
77
|
onError: (e) => toast.error(e.message),
|
|
78
78
|
})
|
|
79
79
|
}
|
|
80
80
|
>
|
|
81
|
-
{
|
|
81
|
+
{restore.isPending ? (
|
|
82
82
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
83
83
|
) : (
|
|
84
84
|
<ArchiveRestore className="h-3.5 w-3.5" />
|
|
85
85
|
)}
|
|
86
|
-
{session.
|
|
86
|
+
{session.taskStatus === "dropped"
|
|
87
|
+
? "Restore"
|
|
88
|
+
: session.worktreeRemoved
|
|
89
|
+
? "Restore entry"
|
|
90
|
+
: "Unarchive"}
|
|
87
91
|
</Button>
|
|
88
92
|
)}
|
|
89
93
|
</div>
|
|
@@ -51,6 +51,7 @@ import { FIND_EVENT, isFindKey, useFind } from "./useFind";
|
|
|
51
51
|
import { McpServers } from "./McpServers";
|
|
52
52
|
import { ArtifactCard } from "./Artifact";
|
|
53
53
|
import { PlanCard } from "./PlanCard";
|
|
54
|
+
import { ChatIdle } from "./idle";
|
|
54
55
|
import { ContextMeter } from "./ContextMeter";
|
|
55
56
|
import { SetupCard } from "./SetupCard";
|
|
56
57
|
import { UserTurn } from "./EventRow";
|
|
@@ -77,6 +78,7 @@ import {
|
|
|
77
78
|
CommandOutput,
|
|
78
79
|
Compacted,
|
|
79
80
|
SkillChip,
|
|
81
|
+
UserMessage,
|
|
80
82
|
ErrorMessage,
|
|
81
83
|
NoteLine,
|
|
82
84
|
Reasoning,
|
|
@@ -86,10 +88,10 @@ import {
|
|
|
86
88
|
|
|
87
89
|
interface ItemActions {
|
|
88
90
|
respond: (id: string, answer: ApprovalDecision) => void;
|
|
89
|
-
// Absent
|
|
91
|
+
// Absent inside undone messages; hidden while the agent works (ChatIdle).
|
|
90
92
|
onCarryPlan?: (id: string) => void;
|
|
91
93
|
onKeepPlanning?: () => void;
|
|
92
|
-
// Absent
|
|
94
|
+
// Absent inside undone messages; hidden while the agent works (ChatIdle).
|
|
93
95
|
onUndo?: (id: string) => void;
|
|
94
96
|
onQuote?: (text: string) => void;
|
|
95
97
|
}
|
|
@@ -294,6 +296,9 @@ export function ChatPanel({
|
|
|
294
296
|
loadTaskOutput,
|
|
295
297
|
taskOutputs,
|
|
296
298
|
queue,
|
|
299
|
+
pending,
|
|
300
|
+
retrySend,
|
|
301
|
+
dropSend,
|
|
297
302
|
queueEdit,
|
|
298
303
|
queueMove,
|
|
299
304
|
queueDelete,
|
|
@@ -606,12 +611,12 @@ export function ChatPanel({
|
|
|
606
611
|
const actions: ItemActions = useMemo(
|
|
607
612
|
() => ({
|
|
608
613
|
respond,
|
|
609
|
-
onUndo:
|
|
610
|
-
onCarryPlan:
|
|
614
|
+
onUndo: (id) => undo(id, true),
|
|
615
|
+
onCarryPlan: carryPlan,
|
|
611
616
|
onKeepPlanning: focusComposer,
|
|
612
617
|
onQuote: (text) => setInsert(quoteInsert(text)),
|
|
613
618
|
}),
|
|
614
|
-
[respond,
|
|
619
|
+
[respond, undo, carryPlan, focusComposer]
|
|
615
620
|
);
|
|
616
621
|
|
|
617
622
|
return (
|
|
@@ -653,13 +658,42 @@ export function ChatPanel({
|
|
|
653
658
|
</div>
|
|
654
659
|
)}
|
|
655
660
|
<SetupCard sessionId={sessionId} />
|
|
656
|
-
{blocks.length === 0 &&
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
+
{blocks.length === 0 &&
|
|
662
|
+
!queue.length &&
|
|
663
|
+
!pending.length && (
|
|
664
|
+
<p className="text-muted-foreground py-16 text-center text-sm">
|
|
665
|
+
Ask anything to start.
|
|
666
|
+
</p>
|
|
667
|
+
)}
|
|
661
668
|
</div>
|
|
662
|
-
<
|
|
669
|
+
<ChatIdle.Provider value={idle}>
|
|
670
|
+
<TimelineRows blocks={blocks} actions={actions} />
|
|
671
|
+
</ChatIdle.Provider>
|
|
672
|
+
{pending.map((p) => (
|
|
673
|
+
<div
|
|
674
|
+
key={p.id}
|
|
675
|
+
className="mx-auto w-full max-w-3xl px-4 pb-4"
|
|
676
|
+
>
|
|
677
|
+
<UserMessage
|
|
678
|
+
item={{
|
|
679
|
+
id: p.id,
|
|
680
|
+
kind: "user",
|
|
681
|
+
text: p.text,
|
|
682
|
+
images: p.images,
|
|
683
|
+
createdAt: p.at,
|
|
684
|
+
}}
|
|
685
|
+
pending={{
|
|
686
|
+
status: p.status,
|
|
687
|
+
error: p.error,
|
|
688
|
+
onRetry: () => retrySend(p.id),
|
|
689
|
+
onEdit: () => {
|
|
690
|
+
dropSend(p.id);
|
|
691
|
+
setPrefill({ text: p.text, at: Date.now() });
|
|
692
|
+
},
|
|
693
|
+
}}
|
|
694
|
+
/>
|
|
695
|
+
</div>
|
|
696
|
+
))}
|
|
663
697
|
</div>
|
|
664
698
|
</BookmarkChatContext.Provider>
|
|
665
699
|
</ToolBodies.Provider>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createElement } from "react";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { AssistantMessage } from "./Messages";
|
|
4
|
+
import { AssistantMessage, UserMessage } from "./Messages";
|
|
5
5
|
|
|
6
6
|
const item = {
|
|
7
7
|
id: "a1",
|
|
@@ -47,3 +47,42 @@ describe("AssistantMessage quote action", () => {
|
|
|
47
47
|
expect(html).not.toContain("Quote message");
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
describe("a sent message not in the chat yet", () => {
|
|
52
|
+
const user = { id: "user-c-1", kind: "user", text: "An article" } as never;
|
|
53
|
+
const render = (status: "sending" | "accepted" | "failed") =>
|
|
54
|
+
renderToStaticMarkup(
|
|
55
|
+
createElement(UserMessage, {
|
|
56
|
+
item: user,
|
|
57
|
+
pending: {
|
|
58
|
+
status,
|
|
59
|
+
error: "connection lost",
|
|
60
|
+
onRetry: vi.fn(),
|
|
61
|
+
onEdit: vi.fn(),
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
it("shows the text at once, dimmed, with its progress", () => {
|
|
67
|
+
expect(render("sending")).toContain("An article");
|
|
68
|
+
expect(render("sending")).toContain("opacity-60");
|
|
69
|
+
expect(render("sending")).toContain("Sending");
|
|
70
|
+
expect(render("accepted")).toContain("Starting the agent");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("says a failed one wasn't sent, with Retry and Edit", () => {
|
|
74
|
+
const html = render("failed");
|
|
75
|
+
expect(html).toContain("Not sent · connection lost");
|
|
76
|
+
expect(html).toContain(">Retry</button>");
|
|
77
|
+
expect(html).toContain(">Edit</button>");
|
|
78
|
+
expect(html).not.toContain("opacity-60");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("a message in the chat shows no status", () => {
|
|
82
|
+
const html = renderToStaticMarkup(
|
|
83
|
+
createElement(UserMessage, { item: user })
|
|
84
|
+
);
|
|
85
|
+
expect(html).not.toContain("Sending");
|
|
86
|
+
expect(html).not.toContain("Not sent");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { memo } from "react";
|
|
3
4
|
import {
|
|
4
5
|
Check,
|
|
5
6
|
ChevronRight,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
Sparkles,
|
|
10
11
|
} from "lucide-react";
|
|
11
12
|
import type { ChatItem } from "@/lib/chat/events";
|
|
13
|
+
import type { PendingSend } from "@/lib/chat/pending";
|
|
12
14
|
import { cn } from "@/lib/utils";
|
|
13
15
|
import { leadingCommand } from "@/lib/chat/commands";
|
|
14
16
|
import { compactedLine } from "@/lib/chat/stale";
|
|
@@ -68,7 +70,9 @@ function Prose({ text }: { text: string }) {
|
|
|
68
70
|
);
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
// Memoized: a chat full of pasted articles re-renders its rows when a turn
|
|
74
|
+
// starts and ends, and splitting and linking each one again is the cost.
|
|
75
|
+
const UserText = memo(function UserText({ text }: { text: string }) {
|
|
72
76
|
return (
|
|
73
77
|
<>
|
|
74
78
|
{splitUserText(text).map((part, i) => {
|
|
@@ -89,45 +93,111 @@ function UserText({ text }: { text: string }) {
|
|
|
89
93
|
})}
|
|
90
94
|
</>
|
|
91
95
|
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// A sent message not in the chat yet (lib/chat/pending).
|
|
99
|
+
export interface PendingStatus {
|
|
100
|
+
status: PendingSend["status"];
|
|
101
|
+
error?: string;
|
|
102
|
+
onRetry: () => void;
|
|
103
|
+
onEdit: () => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function StatusButton({
|
|
107
|
+
onClick,
|
|
108
|
+
children,
|
|
109
|
+
}: {
|
|
110
|
+
onClick: () => void;
|
|
111
|
+
children: string;
|
|
112
|
+
}) {
|
|
113
|
+
return (
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={onClick}
|
|
117
|
+
className="text-foreground hover:bg-foreground/[0.06] min-h-11 rounded-md px-2 underline underline-offset-2 md:min-h-7"
|
|
118
|
+
>
|
|
119
|
+
{children}
|
|
120
|
+
</button>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function SendStatus({ pending }: { pending: PendingStatus }) {
|
|
125
|
+
if (pending.status === "failed")
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
data-copy-skip
|
|
129
|
+
role="alert"
|
|
130
|
+
className="text-destructive flex items-center gap-1 font-mono text-[11px]"
|
|
131
|
+
>
|
|
132
|
+
<span>Not sent · {pending.error}</span>
|
|
133
|
+
<StatusButton onClick={pending.onRetry}>Retry</StatusButton>
|
|
134
|
+
<StatusButton onClick={pending.onEdit}>Edit</StatusButton>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
data-copy-skip
|
|
140
|
+
aria-live="polite"
|
|
141
|
+
className="text-muted-foreground status-late flex items-center gap-1.5 font-mono text-[11px]"
|
|
142
|
+
>
|
|
143
|
+
<span className="status-live bg-muted-foreground h-1.5 w-1.5 rounded-full" />
|
|
144
|
+
{pending.status === "accepted" ? "Starting the agent" : "Sending"}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
92
147
|
}
|
|
93
148
|
|
|
94
149
|
export function UserMessage({
|
|
95
150
|
item,
|
|
96
151
|
onUndo,
|
|
152
|
+
pending,
|
|
97
153
|
}: {
|
|
98
154
|
item: Of<"user">;
|
|
99
155
|
onUndo?: () => void;
|
|
156
|
+
pending?: PendingStatus;
|
|
100
157
|
}) {
|
|
101
|
-
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
158
|
+
const message = (
|
|
159
|
+
<div className="group flex flex-col items-end gap-1">
|
|
160
|
+
{item.from && (
|
|
161
|
+
<span data-copy-skip className="label-mono text-muted-foreground">
|
|
162
|
+
from {item.from}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
{item.images?.length ? (
|
|
166
|
+
<div data-copy-skip className="flex flex-wrap justify-end gap-1.5">
|
|
167
|
+
{item.images.map((img, i) => (
|
|
168
|
+
<ImageThumb
|
|
169
|
+
key={i}
|
|
170
|
+
src={`data:${img.mediaType};base64,${img.data}`}
|
|
171
|
+
/>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
) : null}
|
|
175
|
+
{item.text && (
|
|
176
|
+
<div
|
|
177
|
+
className={cn(
|
|
178
|
+
"bg-foreground/[0.07] max-w-[85%] rounded-2xl rounded-br-md px-3.5 py-2 text-sm whitespace-pre-wrap",
|
|
179
|
+
pending &&
|
|
180
|
+
(pending.status === "failed"
|
|
181
|
+
? "ring-destructive ring-1 ring-inset"
|
|
182
|
+
: "opacity-60")
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
185
|
+
<UserText text={item.text} />
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
{pending ? (
|
|
189
|
+
<SendStatus pending={pending} />
|
|
190
|
+
) : (
|
|
124
191
|
<div className="-mr-1.5 flex items-center">
|
|
125
192
|
{onUndo && <UndoButton onClick={onUndo} />}
|
|
126
193
|
<BookmarkButton itemId={item.id} />
|
|
127
194
|
</div>
|
|
128
|
-
|
|
129
|
-
</
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
130
197
|
);
|
|
198
|
+
// Not in the chat yet: nothing to star.
|
|
199
|
+
if (pending) return message;
|
|
200
|
+
return <BookmarkPress itemId={item.id}>{message}</BookmarkPress>;
|
|
131
201
|
}
|
|
132
202
|
|
|
133
203
|
export function AssistantMessage({
|
|
@@ -28,7 +28,7 @@ export function PausedBanner({ sessionId }: { sessionId: string }) {
|
|
|
28
28
|
className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400"
|
|
29
29
|
/>
|
|
30
30
|
<p className="min-w-0 flex-1 text-sm">
|
|
31
|
-
{pauseSubtitle({ state, until })}
|
|
31
|
+
{pauseSubtitle({ state, until, note: session.pause_note })}
|
|
32
32
|
<span className="text-muted-foreground block text-xs">
|
|
33
33
|
Messages you send now are held and delivered together when it resumes.
|
|
34
34
|
</span>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { Check, ListChecks } from "lucide-react";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
5
|
import type { ChatItem } from "@/lib/chat/events";
|
|
6
|
+
import { useChatIdle } from "./idle";
|
|
6
7
|
import { Markdown } from "./Markdown";
|
|
7
8
|
|
|
8
9
|
type PlanItem = Extract<ChatItem, { kind: "plan" }>;
|
|
@@ -14,10 +15,11 @@ export function PlanCard({
|
|
|
14
15
|
onKeepPlanning,
|
|
15
16
|
}: {
|
|
16
17
|
item: PlanItem;
|
|
17
|
-
// Absent
|
|
18
|
+
// Absent inside undone messages; a running turn disables it too.
|
|
18
19
|
onCarryOut?: () => void;
|
|
19
20
|
onKeepPlanning: () => void;
|
|
20
21
|
}) {
|
|
22
|
+
const idle = useChatIdle();
|
|
21
23
|
return (
|
|
22
24
|
<div className="bg-primary/[0.06] space-y-3 rounded-xl p-3">
|
|
23
25
|
<div className="text-primary flex items-center gap-2 text-xs font-medium">
|
|
@@ -41,7 +43,7 @@ export function PlanCard({
|
|
|
41
43
|
</Button>
|
|
42
44
|
<Button
|
|
43
45
|
className="h-11 md:h-9"
|
|
44
|
-
disabled={!onCarryOut}
|
|
46
|
+
disabled={!onCarryOut || !idle}
|
|
45
47
|
onClick={onCarryOut}
|
|
46
48
|
>
|
|
47
49
|
Carry it out
|
package/components/Chat/Undo.tsx
CHANGED
|
@@ -14,6 +14,7 @@ import type { UndoPreview } from "@/lib/chat/events";
|
|
|
14
14
|
import type { UndoItem } from "@/lib/chat/group";
|
|
15
15
|
import { shortPath } from "@/lib/chat/diff";
|
|
16
16
|
import { cn } from "@/lib/utils";
|
|
17
|
+
import { useChatIdle } from "./idle";
|
|
17
18
|
import { useRowState } from "./rowState";
|
|
18
19
|
|
|
19
20
|
const counts = (p: { insertions?: number; deletions?: number }) =>
|
|
@@ -86,7 +87,9 @@ export function UndoDialog({
|
|
|
86
87
|
);
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
// Shown only while the chat is idle.
|
|
89
91
|
export function UndoButton({ onClick }: { onClick: () => void }) {
|
|
92
|
+
if (!useChatIdle()) return null;
|
|
90
93
|
return (
|
|
91
94
|
<button
|
|
92
95
|
type="button"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
// Whether the chat can take an undo or carry out a plan (no turn running,
|
|
6
|
+
// connected). A context rather than a prop, so a turn starting or ending
|
|
7
|
+
// re-renders those buttons and not every row of a long conversation.
|
|
8
|
+
export const ChatIdle = createContext(true);
|
|
9
|
+
export const useChatIdle = () => useContext(ChatIdle);
|