feat: add multi-platform downloads and improve homepage animations

This commit is contained in:
Mohamed Boudra
2026-03-10 11:20:17 +07:00
parent a3e271a1e7
commit cc45c3772f
11 changed files with 661 additions and 152 deletions
@@ -291,7 +291,12 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
await firstTurn.next();
const secondTurnPromise = collectUntilTerminal(session.stream("second prompt"));
await Promise.resolve();
for (let attempt = 0; attempt < 40; attempt += 1) {
if (sdkMocks.secondQuery) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
sdkMocks.releaseOldAssistant?.();
const secondTurnEvents = await secondTurnPromise;
@@ -309,6 +314,93 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
await session.close();
});
test("ignores stale interrupted query completion after the replacement run starts", async () => {
const logger = createTestLogger();
const releaseOldDone = deferred<void>();
let queryCreateCount = 0;
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
queryCreateCount += 1;
if (queryCreateCount === 1) {
let step = 0;
const mock = {
next: vi.fn(async () => {
if (step === 0) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "init",
session_id: "interrupt-stale-done-session",
permissionMode: "default",
model: "opus",
},
};
}
if (step === 1) {
await releaseOldDone.promise;
step += 1;
return { done: true, value: undefined };
}
return { done: true, value: undefined };
}),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
} satisfies QueryMock;
sdkMocks.firstQuery = mock;
return mock;
}
const mock = buildSecondQueryMock(prompt);
if (queryCreateCount === 2) {
sdkMocks.secondQuery = mock;
}
return mock;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const firstTurn = session.stream("first prompt");
await firstTurn.next();
const secondTurnPromise = collectUntilTerminal(session.stream("second prompt"));
for (let attempt = 0; attempt < 40; attempt += 1) {
if (sdkMocks.secondQuery) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
releaseOldDone.resolve(undefined);
const secondTurnEvents = await secondTurnPromise;
const secondAssistantText = collectAssistantText(secondTurnEvents);
expect(sdkMocks.firstQuery?.interrupt).toHaveBeenCalledTimes(1);
expect(sdkMocks.secondQuery?.next).toHaveBeenCalled();
expect(secondAssistantText).toContain("NEW_TURN_RESPONSE");
expect(
secondTurnEvents.some(
(event) =>
event.type === "turn_failed" &&
event.error.includes("Claude stream ended before terminal result")
)
).toBe(false);
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
await firstTurn.return?.();
await session.close();
});
test("ignores stale task-notification assistant/result events queued before the current prompt", async () => {
const logger = createTestLogger();
@@ -1038,7 +1038,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
let capturedQuestion: AgentPermissionRequest | null = null;
let sawResolvedAllow = false;
let sawDone = false;
let assistantText = "";
for await (const event of session.stream(prompt)) {
if (
@@ -1071,10 +1071,9 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
if (
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("QUESTION_FLOW_DONE")
event.item.type === "assistant_message"
) {
sawDone = true;
assistantText += event.item.text;
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
@@ -1085,7 +1084,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
expect(capturedQuestion).not.toBeNull();
expect(sawResolvedAllow).toBe(true);
expect(session.getPendingPermissions()).toHaveLength(0);
expect(sawDone).toBe(true);
expect(assistantText).toContain("QUESTION_FLOW_DONE");
} finally {
await closeSessionAndCleanup(session, cwd);
}
@@ -1418,12 +1418,12 @@ export class ClaudeAgentClient implements AgentClient {
if (this.claudePath) {
try {
const version = execSync(`${this.claudePath} --version`, { encoding: "utf8" }).trim();
this.logger.info({ claudePath: this.claudePath, version }, "Resolved Claude binary");
this.logger.trace({ claudePath: this.claudePath, version }, "Resolved Claude binary");
} catch {
this.logger.info({ claudePath: this.claudePath }, "Resolved Claude binary (version unknown)");
this.logger.trace({ claudePath: this.claudePath }, "Resolved Claude binary (version unknown)");
}
} else {
this.logger.warn("Claude binary not found in PATH; SDK will use bundled binary");
this.logger.trace("Claude binary not found in PATH; SDK will use bundled binary");
}
}
@@ -2864,7 +2864,7 @@ class ClaudeAgentSession implements AgentSession {
}
const pump = this.runQueryPump().catch((error) => {
this.logger.warn({ err: error }, "Claude query pump exited unexpectedly");
this.logger.trace({ err: error }, "Claude query pump exited unexpectedly");
});
this.queryPumpPromise = pump;
@@ -2886,7 +2886,7 @@ class ClaudeAgentSession implements AgentSession {
try {
q = await this.ensureQuery();
} catch (error) {
this.logger.warn({ err: error }, "Failed to initialize Claude query pump");
this.logger.trace({ err: error }, "Failed to initialize Claude query pump");
await this.waitForLiveHistoryPoll();
continue;
}
@@ -2894,12 +2894,23 @@ class ClaudeAgentSession implements AgentSession {
let next: IteratorResult<SDKMessage, void>;
try {
next = await q.next();
this.logger.info(
this.logger.trace(
{ claudeSessionId: this.claudeSessionId, next },
"Claude query pump raw next()"
);
} catch (error) {
this.logger.warn({ err: error }, "Claude query pump next() failed");
if (this.query !== q) {
this.logger.trace(
{ err: error, staleQuery: true },
"Ignoring Claude query pump next() failure from replaced query"
);
await this.awaitWithTimeout(
q.return?.(),
"query pump return after stale failure"
);
continue;
}
this.logger.trace({ err: error }, "Claude query pump next() failed");
for (const run of this.runTracker.listActiveRuns()) {
this.failRun(
run,
@@ -2917,6 +2928,21 @@ class ClaudeAgentSession implements AgentSession {
}
if (next.done) {
if (this.query !== q) {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
activeRunCount: this.runTracker.listActiveRuns().length,
staleQuery: true,
},
"Ignoring replaced Claude query pump completion"
);
await this.awaitWithTimeout(
q.return?.(),
"query pump return on stale done"
);
continue;
}
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
@@ -2945,6 +2971,22 @@ class ClaudeAgentSession implements AgentSession {
continue;
}
if (this.query !== q) {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
messageType: sdkMessage.type,
staleQuery: true,
},
"Ignoring Claude SDK message from replaced query"
);
await this.awaitWithTimeout(
q.return?.(),
"query pump return on stale message"
);
continue;
}
if (await this.handleMissingResumedConversation(sdkMessage, q)) {
continue;
}
@@ -2952,7 +2994,7 @@ class ClaudeAgentSession implements AgentSession {
try {
this.routeSdkMessageFromPump(sdkMessage);
} catch (error) {
this.logger.warn({ err: error }, "Failed to route Claude SDK message from query pump");
this.logger.trace({ err: error }, "Failed to route Claude SDK message from query pump");
}
}
}
@@ -3229,22 +3271,22 @@ class ClaudeAgentSession implements AgentSession {
private async interruptActiveTurn(): Promise<void> {
const queryToInterrupt = this.query;
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
this.logger.debug("interruptActiveTurn: no query to interrupt");
this.logger.trace("interruptActiveTurn: no query to interrupt");
return;
}
try {
this.logger.debug("interruptActiveTurn: calling query.interrupt()...");
this.logger.trace("interruptActiveTurn: calling query.interrupt()...");
const t0 = Date.now();
await queryToInterrupt.interrupt();
this.logger.debug({ durationMs: Date.now() - t0 }, "interruptActiveTurn: query.interrupt() returned");
this.logger.trace({ durationMs: Date.now() - t0 }, "interruptActiveTurn: query.interrupt() returned");
// After interrupt(), the query iterator is done (returns done: true).
// Clear it so ensureQuery() creates a fresh query for the next turn.
// Also end the input stream and call return() to clean up the SDK process.
this.input?.end();
this.logger.debug("interruptActiveTurn: calling query.return()...");
this.logger.trace("interruptActiveTurn: calling query.return()...");
const t1 = Date.now();
await queryToInterrupt.return?.();
this.logger.debug({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
this.logger.trace({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
this.query = null;
this.input = null;
this.queryRestartNeeded = false;