@saadnvd1/agent-os 0.3.615 → 0.3.620
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/components/Chat/ChatPanel.tsx +2 -0
- package/components/Chat/Messages.test.ts +8 -1
- package/components/Chat/Messages.tsx +7 -1
- package/components/Pane/DesktopTabBar.tsx +1 -0
- package/components/Pane/PaneViewToggle.tsx +4 -1
- package/components/views/DesktopAppActions.tsx +23 -17
- package/components/views/DesktopView.tsx +1 -1
- package/lib/chat/pending.ts +13 -1
- package/lib/learning-cards/apply.ts +54 -8
- package/package.json +1 -1
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "@/lib/chat/group";
|
|
22
22
|
import type { ApprovalDecision, ChatItem } from "@/lib/chat/events";
|
|
23
23
|
import { isFreshCommand, withFresh } from "@/lib/chat/commands";
|
|
24
|
+
import { compactsFirst } from "@/lib/chat/pending";
|
|
24
25
|
import { useFreshStart } from "@/data/sessions";
|
|
25
26
|
import {
|
|
26
27
|
escapeAction,
|
|
@@ -692,6 +693,7 @@ export function ChatPanel({
|
|
|
692
693
|
}}
|
|
693
694
|
pending={{
|
|
694
695
|
status: p.status,
|
|
696
|
+
compacting: compactsFirst(p, { state, compacting }),
|
|
695
697
|
error: p.error,
|
|
696
698
|
onRetry: () => retrySend(p.id),
|
|
697
699
|
onEdit: () => {
|
|
@@ -50,12 +50,16 @@ describe("AssistantMessage quote action", () => {
|
|
|
50
50
|
|
|
51
51
|
describe("a sent message not in the chat yet", () => {
|
|
52
52
|
const user = { id: "user-c-1", kind: "user", text: "An article" } as never;
|
|
53
|
-
const render = (
|
|
53
|
+
const render = (
|
|
54
|
+
status: "sending" | "accepted" | "failed",
|
|
55
|
+
compacting?: boolean
|
|
56
|
+
) =>
|
|
54
57
|
renderToStaticMarkup(
|
|
55
58
|
createElement(UserMessage, {
|
|
56
59
|
item: user,
|
|
57
60
|
pending: {
|
|
58
61
|
status,
|
|
62
|
+
compacting,
|
|
59
63
|
error: "connection lost",
|
|
60
64
|
onRetry: vi.fn(),
|
|
61
65
|
onEdit: vi.fn(),
|
|
@@ -68,6 +72,9 @@ describe("a sent message not in the chat yet", () => {
|
|
|
68
72
|
expect(render("sending")).toContain("opacity-60");
|
|
69
73
|
expect(render("sending")).toContain("Sending");
|
|
70
74
|
expect(render("accepted")).toContain("Starting the agent");
|
|
75
|
+
expect(render("accepted", true)).toContain("Compacting first");
|
|
76
|
+
expect(render("accepted", true)).not.toContain("Starting the agent");
|
|
77
|
+
expect(render("sending", true)).toContain("Sending");
|
|
71
78
|
});
|
|
72
79
|
|
|
73
80
|
it("says a failed one wasn't sent, with Retry and Edit", () => {
|
|
@@ -98,6 +98,8 @@ const UserText = memo(function UserText({ text }: { text: string }) {
|
|
|
98
98
|
// A sent message not in the chat yet (lib/chat/pending).
|
|
99
99
|
export interface PendingStatus {
|
|
100
100
|
status: PendingSend["status"];
|
|
101
|
+
// The chat is compacting for this send (its real state, not a guess).
|
|
102
|
+
compacting?: boolean;
|
|
101
103
|
error?: string;
|
|
102
104
|
onRetry: () => void;
|
|
103
105
|
onEdit: () => void;
|
|
@@ -141,7 +143,11 @@ function SendStatus({ pending }: { pending: PendingStatus }) {
|
|
|
141
143
|
className="text-muted-foreground status-late flex items-center gap-1.5 font-mono text-[11px]"
|
|
142
144
|
>
|
|
143
145
|
<span className="status-live bg-muted-foreground h-1.5 w-1.5 rounded-full" />
|
|
144
|
-
{pending.status
|
|
146
|
+
{pending.status !== "accepted"
|
|
147
|
+
? "Sending"
|
|
148
|
+
: pending.compacting
|
|
149
|
+
? "Compacting first"
|
|
150
|
+
: "Starting the agent"}
|
|
145
151
|
</div>
|
|
146
152
|
);
|
|
147
153
|
}
|
|
@@ -18,6 +18,8 @@ import type { ViewMode } from "@/contexts/PaneContext";
|
|
|
18
18
|
|
|
19
19
|
interface PaneViewToggleProps {
|
|
20
20
|
sessionId?: string;
|
|
21
|
+
// The session runs as a chat, so its main view is the chat, not a terminal.
|
|
22
|
+
isChat?: boolean;
|
|
21
23
|
viewMode: ViewMode;
|
|
22
24
|
isLocalSession: boolean;
|
|
23
25
|
isConductor: boolean;
|
|
@@ -75,6 +77,7 @@ function ToggleButton({
|
|
|
75
77
|
// session only gets the terminal (and workers).
|
|
76
78
|
export function PaneViewToggle({
|
|
77
79
|
sessionId,
|
|
80
|
+
isChat,
|
|
78
81
|
viewMode,
|
|
79
82
|
isLocalSession,
|
|
80
83
|
isConductor,
|
|
@@ -89,7 +92,7 @@ export function PaneViewToggle({
|
|
|
89
92
|
return (
|
|
90
93
|
<div className="bg-accent/50 flex shrink-0 items-center rounded-md p-0.5">
|
|
91
94
|
<ToggleButton
|
|
92
|
-
label="Terminal"
|
|
95
|
+
label={isChat ? "Chat" : "Terminal"}
|
|
93
96
|
active={viewMode === "terminal"}
|
|
94
97
|
onClick={() => onViewModeChange("terminal")}
|
|
95
98
|
>
|
|
@@ -45,29 +45,35 @@ export function SidebarToggle({
|
|
|
45
45
|
);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// A pinned sidebar has its own New button, so this one shows only while the
|
|
49
|
+
// sidebar is hidden.
|
|
50
|
+
export function DesktopAppActions({ showNew }: { showNew: boolean }) {
|
|
49
51
|
return (
|
|
50
52
|
<div
|
|
51
53
|
className="flex shrink-0 items-center gap-0.5"
|
|
52
54
|
onClick={(e) => e.stopPropagation()}
|
|
53
55
|
>
|
|
54
56
|
<InboxButton />
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
57
|
+
{showNew && (
|
|
58
|
+
<Tooltip>
|
|
59
|
+
<TooltipTrigger asChild>
|
|
60
|
+
<Button
|
|
61
|
+
size="icon-sm"
|
|
62
|
+
aria-label="New session"
|
|
63
|
+
className="ml-1 h-7 w-7"
|
|
64
|
+
onClick={() => newDraft({ kind: "current" })}
|
|
65
|
+
>
|
|
66
|
+
<Plus className="h-4 w-4" />
|
|
67
|
+
</Button>
|
|
68
|
+
</TooltipTrigger>
|
|
69
|
+
<TooltipContent>
|
|
70
|
+
<p>New session</p>
|
|
71
|
+
<p className="text-muted-foreground text-xs">
|
|
72
|
+
{DRAFT_KEYS.current}
|
|
73
|
+
</p>
|
|
74
|
+
</TooltipContent>
|
|
75
|
+
</Tooltip>
|
|
76
|
+
)}
|
|
71
77
|
</div>
|
|
72
78
|
);
|
|
73
79
|
}
|
|
@@ -59,7 +59,7 @@ export function DesktopView({
|
|
|
59
59
|
leading={
|
|
60
60
|
<SidebarToggle isPinned={isPinned} togglePin={togglePin} />
|
|
61
61
|
}
|
|
62
|
-
trailing={<DesktopAppActions />}
|
|
62
|
+
trailing={<DesktopAppActions showNew={!isPinned} />}
|
|
63
63
|
>
|
|
64
64
|
<PaneLayout renderPane={renderPane} />
|
|
65
65
|
</PaneBarSlotsProvider>
|
package/lib/chat/pending.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChatImage } from "./events";
|
|
1
|
+
import type { ChatImage, ChatState } from "./events";
|
|
2
2
|
|
|
3
3
|
// A message the composer sent that isn't in the chat or its queue yet. It
|
|
4
4
|
// shows as a bubble at once, and stays (as failed, with Retry) if it never
|
|
@@ -113,6 +113,18 @@ export function pendingReducer(
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// A compact-first send reads "Compacting first" from the server accepting it
|
|
117
|
+
// until compaction is over. The chat is idle in that gap (compaction starts
|
|
118
|
+
// it running), so idle means it hasn't begun; running with no compaction
|
|
119
|
+
// means it finished, or the server decided not to compact.
|
|
120
|
+
export const compactsFirst = (
|
|
121
|
+
p: Pick<PendingSend, "status" | "compact">,
|
|
122
|
+
chat: { state: ChatState; compacting: unknown }
|
|
123
|
+
) =>
|
|
124
|
+
!!p.compact &&
|
|
125
|
+
p.status === "accepted" &&
|
|
126
|
+
(!!chat.compacting || chat.state === "idle");
|
|
127
|
+
|
|
116
128
|
// What a new socket sends again: anything not failed and not over its sends.
|
|
117
129
|
// Safe to repeat: the server drops an id already in the chat or its queue.
|
|
118
130
|
export const toResend = (state: PendingSend[]) =>
|
|
@@ -8,11 +8,14 @@ import { experimentOn } from "../experiments";
|
|
|
8
8
|
import { getOrchestrator } from "../orchestrator/home";
|
|
9
9
|
import { quickPR, startQuickChange } from "../orchestrator/quick";
|
|
10
10
|
import { addRule } from "../orchestrator/rules";
|
|
11
|
-
import { isRemoteHost } from "../hosts";
|
|
11
|
+
import { hostExec, isRemoteHost } from "../hosts";
|
|
12
|
+
import { shellQuote } from "../hosts/ssh";
|
|
12
13
|
import { getProject } from "../projects";
|
|
13
14
|
import { createRecipe, getRecipe, updateRecipe } from "../recipes/store";
|
|
14
15
|
import { recipePrefill } from "../recipes/draft";
|
|
15
|
-
import { createTask } from "../tasks";
|
|
16
|
+
import { createTask, dropTask } from "../tasks";
|
|
17
|
+
import { gitOk } from "../git-exec";
|
|
18
|
+
import { expandHome } from "../tasks/session";
|
|
16
19
|
import { claimCard, getCard, releaseCard, settleCard } from "./store";
|
|
17
20
|
import type { Card, LessonPayload, RecipePayload } from "./types";
|
|
18
21
|
|
|
@@ -46,6 +49,22 @@ export function withLesson(text: string, line: string): string {
|
|
|
46
49
|
const clean = (s: string | undefined, max: number) =>
|
|
47
50
|
(s ?? "").replace(/\s+/g, " ").trim().slice(0, max);
|
|
48
51
|
|
|
52
|
+
// Whether the remote checkout ignores CLAUDE.md; null when it can't be told.
|
|
53
|
+
async function remoteIgnores(hostId: string, dir: string) {
|
|
54
|
+
const rest = dir.replace(/^~/, "");
|
|
55
|
+
const cd = rest === dir ? shellQuote(dir) : `"$HOME"${shellQuote(rest)}`;
|
|
56
|
+
try {
|
|
57
|
+
const { stdout } = await hostExec(
|
|
58
|
+
hostId,
|
|
59
|
+
`cd ${cd} && git check-ignore -q CLAUDE.md; echo $?`
|
|
60
|
+
);
|
|
61
|
+
const code = stdout.trim().split("\n").pop();
|
|
62
|
+
return code === "0" ? true : code === "1" ? false : null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
async function toClaudeMd(card: Card, p: LessonPayload, line: string) {
|
|
50
69
|
const project = p.projectId ? getProject(p.projectId) : null;
|
|
51
70
|
if (!project) throw new CardRefused("That project is gone");
|
|
@@ -55,6 +74,28 @@ async function toClaudeMd(card: Card, p: LessonPayload, line: string) {
|
|
|
55
74
|
.prepare(`SELECT name FROM sessions WHERE id = ?`)
|
|
56
75
|
.get(card.session_id) as { name: string } | undefined
|
|
57
76
|
)?.name;
|
|
77
|
+
// Ignored CLAUDE.md is local dev notes: a worktree never has it, so a quick
|
|
78
|
+
// change or task would have nothing to commit and no PR could open.
|
|
79
|
+
if (isRemoteHost(project.host_id)) {
|
|
80
|
+
if (
|
|
81
|
+
(await remoteIgnores(project.host_id, project.working_directory)) !==
|
|
82
|
+
false
|
|
83
|
+
)
|
|
84
|
+
throw new CardRefused(
|
|
85
|
+
`${project.name} runs on another machine and its CLAUDE.md is gitignored or can't be checked; add the lesson there by hand`
|
|
86
|
+
);
|
|
87
|
+
} else if (
|
|
88
|
+
await gitOk(expandHome(project.working_directory), [
|
|
89
|
+
"check-ignore",
|
|
90
|
+
"-q",
|
|
91
|
+
"CLAUDE.md",
|
|
92
|
+
])
|
|
93
|
+
) {
|
|
94
|
+
const file = path.join(expandHome(project.working_directory), "CLAUDE.md");
|
|
95
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
96
|
+
fs.writeFileSync(file, withLesson(before, line));
|
|
97
|
+
return "CLAUDE.md";
|
|
98
|
+
}
|
|
58
99
|
if (
|
|
59
100
|
workspaceId &&
|
|
60
101
|
getOrchestrator(workspaceId) &&
|
|
@@ -68,12 +109,17 @@ async function toClaudeMd(card: Card, p: LessonPayload, line: string) {
|
|
|
68
109
|
const file = path.join(quick.worktreePath, "CLAUDE.md");
|
|
69
110
|
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
70
111
|
fs.writeFileSync(file, withLesson(before, line));
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
112
|
+
try {
|
|
113
|
+
await quickPR(
|
|
114
|
+
workspaceId,
|
|
115
|
+
quick.id,
|
|
116
|
+
"docs: add a lesson to CLAUDE.md",
|
|
117
|
+
`${line}${source ? `\n\nFrom the session "${source}".` : ""}`
|
|
118
|
+
);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
await dropTask(quick.id).catch(() => {});
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
77
123
|
return quick.id;
|
|
78
124
|
}
|
|
79
125
|
const task = await createTask({
|