diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 1957c1e25..0f590284d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -49,6 +49,7 @@ import { } from "../../../shared/types"; import type { AgentChatApprovalDecision, + AgentChatCancelSteerArgs, AgentChatClaudePermissionMode, AgentChatCompletionReport, AgentChatCodexApprovalPolicy, @@ -56,6 +57,7 @@ import type { AgentChatCodexSandbox, AgentChatCreateArgs, AgentChatDisposeArgs, + AgentChatEditSteerArgs, AgentChatExecutionMode, AgentChatEvent, AgentChatEventEnvelope, @@ -8355,6 +8357,14 @@ export function createAgentChatService(args: { await executePreparedSendMessage(preparedSteer); }; + const cancelSteer = async ({ sessionId }: AgentChatCancelSteerArgs): Promise => { + await interrupt({ sessionId }); + }; + + const editSteer = async ({ sessionId, text }: AgentChatEditSteerArgs): Promise => { + await steer({ sessionId, text }); + }; + const interrupt = async ({ sessionId }: AgentChatInterruptArgs): Promise => { const managed = ensureManagedSession(sessionId); @@ -8954,7 +8964,7 @@ export function createAgentChatService(args: { } const nextProvider: AgentChatProvider = resolveProviderGroupForModel(descriptor); - const nextModel = descriptor.isCliWrapped ? descriptor.shortId : descriptor.id; + const nextModel = descriptor.isCliWrapped ? descriptor.sdkModelId : descriptor.id; const previousModelId = managed.session.modelId ?? resolveModelIdFromStoredValue(managed.session.model, managed.session.provider) ?? managed.session.model; @@ -9349,6 +9359,8 @@ export function createAgentChatService(args: { sendMessage, runSessionTurn, steer, + cancelSteer, + editSteer, interrupt, resumeSession, listSessions, diff --git a/apps/desktop/src/main/services/git/gitOperationsService.test.ts b/apps/desktop/src/main/services/git/gitOperationsService.test.ts index 4db363615..df2090841 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.test.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.test.ts @@ -14,6 +14,73 @@ vi.mock("./git", () => ({ import { createGitOperationsService } from "./gitOperationsService"; +describe("gitOperationsService.stashClear", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls git stash clear with the lane worktree path and returns the action result", async () => { + mockGit.getHeadSha.mockResolvedValue("abc123"); + mockGit.runGitOrThrow.mockResolvedValue(undefined); + + const mockStart = vi.fn().mockReturnValue({ operationId: "op-1" }); + const mockFinish = vi.fn(); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "feature/stash-test", + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + }), + } as any, + operationService: { + start: mockStart, + finish: mockFinish, + } as any, + projectConfigService: { + get: () => ({ effective: { ai: {} } }), + } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: [] })), + generateCommitMessage: vi.fn(), + } as any, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any, + }); + + const result = await service.stashClear({ laneId: "lane-1" }); + + expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + ["stash", "clear"], + { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, + ); + expect(result).toEqual({ + operationId: "op-1", + preHeadSha: "abc123", + postHeadSha: "abc123", + }); + expect(mockStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + kind: "git_stash_clear", + }), + ); + expect(mockFinish).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: "op-1", + status: "succeeded", + }), + ); + }); +}); + describe("gitOperationsService.generateCommitMessage", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index 0b03679c3..dcc7e82b2 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -770,6 +770,19 @@ export function createGitOperationsService({ return action; }, + async stashClear(args: { laneId: string }): Promise { + const { action } = await runLaneOperation({ + laneId: args.laneId, + kind: "git_stash_clear", + reason: "stash_clear", + metadata: {}, + fn: async (lane) => { + await runGitOrThrow(["stash", "clear"], { cwd: lane.worktreePath, timeoutMs: 15_000 }); + } + }); + return action; + }, + async fetch(args: { laneId: string }): Promise { const { action } = await runLaneOperation({ laneId: args.laneId, diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts new file mode 100644 index 000000000..1ba476477 --- /dev/null +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -0,0 +1,209 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// vi.hoisted mock state +// --------------------------------------------------------------------------- +const mockFetch = vi.hoisted(() => vi.fn()); + +// --------------------------------------------------------------------------- +// vi.mock — external dependencies +// --------------------------------------------------------------------------- + +vi.mock("electron", () => ({ + safeStorage: { + isEncryptionAvailable: () => true, + decryptString: () => JSON.stringify({ token: "ghp_mock" }), + encryptString: (s: string) => Buffer.from(s), + }, +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => Buffer.from("encrypted")), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + chmodSync: vi.fn(), + unlinkSync: vi.fn(), + }, + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => Buffer.from("encrypted")), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + chmodSync: vi.fn(), + unlinkSync: vi.fn(), + }; +}); + +vi.mock("../git/git", () => ({ + runGit: vi.fn(), +})); + +// Replace global fetch +vi.stubGlobal("fetch", mockFetch); + +import { createGithubService } from "./githubService"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeLogger() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any; +} + +function makeService() { + return createGithubService({ + logger: makeLogger(), + projectRoot: "/tmp/test-project", + appDataDir: "/tmp/test-appdata", + }); +} + +function jsonResponse( + status: number, + body: unknown, + extraHeaders?: Record, +) { + const headers = new Headers({ "content-type": "application/json", ...extraHeaders }); + return { + ok: status >= 200 && status < 300, + status, + headers, + text: async () => JSON.stringify(body), + json: async () => body, + } as unknown as Response; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("githubService.apiRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns data and response on success (HTTP 200)", async () => { + const payload = { id: 1, name: "test-repo" }; + mockFetch.mockResolvedValueOnce(jsonResponse(200, payload)); + + const service = makeService(); + const result = await service.apiRequest({ + method: "GET", + path: "/repos/owner/repo", + token: "ghp_test123", + }); + + expect(result.data).toEqual(payload); + expect(result.response).toBeDefined(); + expect(result.response!.status).toBe(200); + }); + + it("throws with message from response when errors array is absent", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(404, { message: "Not Found" })); + + const service = makeService(); + await expect( + service.apiRequest({ method: "GET", path: "/repos/owner/nope", token: "ghp_test123" }), + ).rejects.toThrow("Not Found"); + }); + + it("appends single error detail from errors array", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(422, { + message: "Validation Failed", + errors: [{ message: "A pull request already exists" }], + }), + ); + + const service = makeService(); + await expect( + service.apiRequest({ method: "POST", path: "/repos/o/r/pulls", token: "ghp_test123" }), + ).rejects.toThrow("Validation Failed: A pull request already exists"); + }); + + it("joins multiple error details with semicolons", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(422, { + message: "Validation Failed", + errors: [{ message: "err1" }, { message: "err2" }], + }), + ); + + const service = makeService(); + await expect( + service.apiRequest({ method: "POST", path: "/repos/o/r/pulls", token: "ghp_test123" }), + ).rejects.toThrow("Validation Failed: err1; err2"); + }); + + it("includes rate limit info and rateLimitResetAtMs when rate-limited", async () => { + const resetTimestamp = Math.floor(Date.now() / 1000) + 3600; + mockFetch.mockResolvedValueOnce( + jsonResponse( + 403, + { + message: "API rate limit exceeded", + errors: [{ message: "some detail" }], + }, + { + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(resetTimestamp), + }, + ), + ); + + const service = makeService(); + let thrownError: any; + try { + await service.apiRequest({ method: "GET", path: "/repos/o/r", token: "ghp_test123" }); + } catch (err) { + thrownError = err; + } + + expect(thrownError).toBeInstanceOf(Error); + expect(thrownError.message).toContain("API rate limit exceeded"); + expect(thrownError.message).toContain("some detail"); + expect(thrownError.message).toContain("rate limit exceeded; resets at"); + expect(thrownError.rateLimitResetAtMs).toBe(resetTimestamp * 1000); + }); + + it("falls back to generic HTTP message when response body has no message field", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(500, { unexpected: true })); + + const service = makeService(); + await expect( + service.apiRequest({ method: "GET", path: "/test", token: "ghp_test123" }), + ).rejects.toThrow("GitHub API request failed (HTTP 500)"); + }); + + it("ignores errors array entries without a string message", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(422, { + message: "Validation Failed", + errors: [{ code: "custom" }, { message: "real error" }, { message: 42 }], + }), + ); + + const service = makeService(); + await expect( + service.apiRequest({ method: "POST", path: "/repos/o/r/pulls", token: "ghp_test123" }), + ).rejects.toThrow("Validation Failed: real error"); + }); + + it("throws when no token is provided and none is stored", async () => { + const service = makeService(); + await expect( + service.apiRequest({ method: "GET", path: "/test" }), + ).rejects.toThrow("GitHub token missing"); + }); +}); diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 191b86bb4..23bd8d2ba 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -255,20 +255,28 @@ export function createGithubService({ } if (!response.ok) { - const message = - (data && typeof data === "object" && !Array.isArray(data) ? asString((data as any).message) : "") || - `GitHub API request failed (HTTP ${response.status})`; + const body = data && typeof data === "object" && !Array.isArray(data) ? (data as Record) : null; + const message = (body ? asString(body.message) : "") || `GitHub API request failed (HTTP ${response.status})`; + let detail = ""; + if (body && Array.isArray(body.errors)) { + const errorMessages = (body.errors as any[]) + .map((e) => (typeof e === "object" && e && typeof e.message === "string" ? e.message : null)) + .filter(Boolean); + if (errorMessages.length > 0) { + detail = ": " + errorMessages.join("; "); + } + } const rateRemaining = response.headers.get("x-ratelimit-remaining"); const rateReset = response.headers.get("x-ratelimit-reset"); if (rateRemaining === "0" && rateReset) { const resetAtMs = Number(rateReset) * 1000; const err = new Error( - `${message} (rate limit exceeded; resets at ${new Date(resetAtMs).toLocaleString()})` + `${message}${detail} (rate limit exceeded; resets at ${new Date(resetAtMs).toLocaleString()})` ); (err as any).rateLimitResetAtMs = resetAtMs; throw err; } - throw new Error(message); + throw new Error(message + detail); } // Cache ETag for future conditional requests diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 301cf14d1..1aace2a6f 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -177,6 +177,8 @@ import type { AgentChatSessionCapabilities, AgentChatSessionCapabilitiesArgs, AgentChatSteerArgs, + AgentChatCancelSteerArgs, + AgentChatEditSteerArgs, AgentChatUnifiedPermissionMode, AgentChatUpdateSessionArgs, AgentChatSlashCommand, @@ -3536,6 +3538,29 @@ export function registerIpc({ return { laneId: record.laneId }; }; + const parseAgentChatCancelSteerArgs = ( + value: unknown, + ): AgentChatCancelSteerArgs => { + const record = requireRecord(value, "Agent chat cancel steer request"); + if (typeof record.sessionId !== "string" || !record.sessionId.trim()) { + throw new Error("Agent chat cancel steer sessionId must be a non-empty string"); + } + return { sessionId: record.sessionId }; + }; + + const parseAgentChatEditSteerArgs = ( + value: unknown, + ): AgentChatEditSteerArgs => { + const record = requireRecord(value, "Agent chat edit steer request"); + if (typeof record.sessionId !== "string" || !record.sessionId.trim()) { + throw new Error("Agent chat edit steer sessionId must be a non-empty string"); + } + if (typeof record.text !== "string") { + throw new Error("Agent chat edit steer text must be a string"); + } + return { sessionId: record.sessionId, text: record.text }; + }; + ipcMain.handle(IPC.lanesOAuthGetStatus, async () => { const ctx = getCtx(); return ctx.oauthRedirectService?.getStatus() ?? { @@ -3738,6 +3763,16 @@ export function registerIpc({ await ctx.agentChatService.steer(arg); }); + ipcMain.handle(IPC.agentChatCancelSteer, async (_event, arg: unknown): Promise => { + const ctx = getCtx(); + await ctx.agentChatService.cancelSteer(parseAgentChatCancelSteerArgs(arg)); + }); + + ipcMain.handle(IPC.agentChatEditSteer, async (_event, arg: unknown): Promise => { + const ctx = getCtx(); + await ctx.agentChatService.editSteer(parseAgentChatEditSteerArgs(arg)); + }); + ipcMain.handle(IPC.agentChatInterrupt, async (_event, arg: AgentChatInterruptArgs): Promise => { const ctx = getCtx(); await ctx.agentChatService.interrupt(arg); @@ -4096,6 +4131,11 @@ export function registerIpc({ return ctx.gitService.stashDrop(arg); }); + ipcMain.handle(IPC.gitStashClear, async (_event, arg: { laneId: string }): Promise => { + const ctx = getCtx(); + return ctx.gitService.stashClear(arg); + }); + ipcMain.handle(IPC.gitFetch, async (_event, arg: { laneId: string }): Promise => { const ctx = getCtx(); return ctx.gitService.fetch(arg); diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts new file mode 100644 index 000000000..a9dd82b06 --- /dev/null +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -0,0 +1,360 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// vi.hoisted mock state +// --------------------------------------------------------------------------- +const mockGit = vi.hoisted(() => ({ + runGit: vi.fn(), + runGitOrThrow: vi.fn(), + runGitMergeTree: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// vi.mock — external dependencies +// --------------------------------------------------------------------------- + +vi.mock("../git/git", () => ({ + runGit: (...args: unknown[]) => mockGit.runGit(...args), + runGitOrThrow: (...args: unknown[]) => mockGit.runGitOrThrow(...args), + runGitMergeTree: (...args: unknown[]) => mockGit.runGitMergeTree(...args), +})); + +vi.mock("../ai/utils", () => ({ + extractFirstJsonObject: vi.fn(() => null), +})); + +vi.mock("./integrationPlanning", () => ({ + buildIntegrationPreflight: vi.fn(), +})); + +vi.mock("./integrationValidation", () => ({ + hasMergeConflictMarkers: vi.fn(() => false), + parseGitStatusPorcelain: vi.fn(() => []), +})); + +vi.mock("../shared/queueRebase", () => ({ + fetchRemoteTrackingBranch: vi.fn(), +})); + +import { createPrService } from "./prService"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeLogger() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any; +} + +function makeMockDb() { + return { + get: vi.fn(() => null), + all: vi.fn(() => []), + run: vi.fn(), + getJson: vi.fn(() => null), + setJson: vi.fn(), + sync: { getSiteId: vi.fn(), getDbVersion: vi.fn(), exportChangesSince: vi.fn(), applyChanges: vi.fn() }, + flushNow: vi.fn(), + close: vi.fn(), + } as any; +} + +const LANE_ID = "lane-42"; +const REPO = { owner: "test-owner", name: "test-repo" }; + +function makeFakeLane(overrides?: Partial>) { + return { + id: LANE_ID, + name: "my-feature", + laneType: "worktree", + baseRef: "refs/heads/main", + branchRef: "refs/heads/my-feature", + worktreePath: "/tmp/lane-wt", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false }, + color: null, + icon: null, + tags: [], + createdAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function makeGithubService(overrides?: Record) { + return { + getRepoOrThrow: vi.fn(async () => REPO), + apiRequest: vi.fn(), + getStatus: vi.fn(), + setToken: vi.fn(), + clearToken: vi.fn(), + getTokenOrThrow: vi.fn(() => "ghp_mock"), + ...overrides, + } as any; +} + +function makeLaneService(lanes?: unknown[]) { + return { + list: vi.fn(async () => lanes ?? [makeFakeLane()]), + getLaneBaseAndBranch: vi.fn(), + } as any; +} + +function makeOperationService() { + return { + start: vi.fn(() => ({ operationId: "op-1" })), + finish: vi.fn(), + } as any; +} + +function makeProjectConfigService() { + return { + get: vi.fn(() => ({ effective: { ai: {} } })), + } as any; +} + +interface BuildServiceOpts { + githubService?: any; + laneService?: any; + db?: any; +} + +function buildService(opts: BuildServiceOpts = {}) { + const db = opts.db ?? makeMockDb(); + const githubService = opts.githubService ?? makeGithubService(); + const laneService = opts.laneService ?? makeLaneService(); + + // Make runGit succeed for upstream check (returns exitCode 0 → push path) + mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "origin/my-feature", stderr: "" }); + // Make push succeed + mockGit.runGitOrThrow.mockResolvedValue(undefined); + + const service = createPrService({ + db, + logger: makeLogger(), + projectId: "proj-1", + projectRoot: "/tmp/test-project", + laneService, + operationService: makeOperationService(), + githubService, + projectConfigService: makeProjectConfigService(), + openExternal: vi.fn(async () => {}), + }); + + return { service, db, githubService, laneService }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("prService.createFromLane", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("wraps githubService.apiRequest errors with branch context", async () => { + const ghService = makeGithubService({ + apiRequest: vi.fn().mockRejectedValue(new Error("Validation Failed: A pull request already exists")), + }); + + const { service } = buildService({ githubService: ghService }); + + await expect( + service.createFromLane({ + laneId: LANE_ID, + title: "My PR", + body: "description", + draft: false, + allowDirtyWorktree: true, + }), + ).rejects.toThrow( + 'Failed to create pull request for "my-feature" \u2192 "main": Validation Failed: A pull request already exists', + ); + }); + + it("preserves non-Error throwables in the wrapped message", async () => { + const ghService = makeGithubService({ + apiRequest: vi.fn().mockRejectedValue("string error"), + }); + + const { service } = buildService({ githubService: ghService }); + + await expect( + service.createFromLane({ + laneId: LANE_ID, + title: "My PR", + body: "", + draft: false, + allowDirtyWorktree: true, + }), + ).rejects.toThrow( + 'Failed to create pull request for "my-feature" \u2192 "main": string error', + ); + }); + + it("extracts PR number from successful creation response", async () => { + const ghService = makeGithubService({ + apiRequest: vi.fn().mockResolvedValue({ + data: { + number: 99, + html_url: "https://github.com/test-owner/test-repo/pull/99", + node_id: "PR_node1", + title: "My PR", + state: "open", + draft: false, + merged_at: null, + head: { ref: "my-feature" }, + base: { ref: "main" }, + additions: 10, + deletions: 2, + }, + response: { status: 201 }, + }), + }); + + const db = makeMockDb(); + // refreshOne calls getRow → fetchPr → apiRequest(GET) → so we need + // db.get to return the inserted row, and apiRequest for the refresh GET + // We'll make db.get return a valid row on the second call (after upsertRow + // inserts via db.run). On the first call (inside upsertRow's getRowForLane), + // return null so it does an INSERT. + let getCallCount = 0; + db.get.mockImplementation(() => { + getCallCount++; + if (getCallCount === 1) { + // getRowForLane inside upsertRow — no existing row + return null; + } + // requireRow inside refreshOne — return the row + return { + id: "fake-uuid", + lane_id: LANE_ID, + project_id: "proj-1", + repo_owner: "test-owner", + repo_name: "test-repo", + github_pr_number: 99, + github_url: "https://github.com/test-owner/test-repo/pull/99", + github_node_id: "PR_node1", + title: "My PR", + state: "open", + base_branch: "main", + head_branch: "my-feature", + checks_status: "none", + review_status: "none", + additions: 10, + deletions: 2, + last_synced_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; + }); + + // After the initial POST for creation, refreshOne calls fetchPr (GET) + // and then several more GET calls for checks, reviews, comments, files, etc. + // We need apiRequest to handle both the initial POST and subsequent GETs. + let apiCallCount = 0; + ghService.apiRequest.mockImplementation(async (args: any) => { + apiCallCount++; + if (apiCallCount === 1) { + // The POST to create the PR + return { + data: { + number: 99, + html_url: "https://github.com/test-owner/test-repo/pull/99", + node_id: "PR_node1", + title: "My PR", + state: "open", + draft: false, + merged_at: null, + head: { ref: "my-feature" }, + base: { ref: "main" }, + additions: 10, + deletions: 2, + }, + response: { status: 201, headers: new Headers() }, + }; + } + // All subsequent GETs (fetchPr, checks, reviews, comments, files, actions) + return { + data: args.path.endsWith("/pulls/99") + ? { + number: 99, + html_url: "https://github.com/test-owner/test-repo/pull/99", + title: "My PR", + state: "open", + draft: false, + merged_at: null, + head: { ref: "my-feature", sha: "abc123" }, + base: { ref: "main" }, + additions: 10, + deletions: 2, + } + : [], + response: { + status: 200, + headers: new Headers(), + }, + }; + }); + + const { service } = buildService({ githubService: ghService, db }); + + const result = await service.createFromLane({ + laneId: LANE_ID, + title: "My PR", + body: "description", + draft: false, + allowDirtyWorktree: true, + }); + + expect(result.githubPrNumber).toBe(99); + expect(result.headBranch).toBe("my-feature"); + expect(result.baseBranch).toBe("main"); + }); + + it("throws when GitHub returns an invalid PR number", async () => { + const ghService = makeGithubService({ + apiRequest: vi.fn().mockResolvedValue({ + data: { number: null }, + response: { status: 201 }, + }), + }); + + const db = makeMockDb(); + + const { service } = buildService({ githubService: ghService, db }); + + await expect( + service.createFromLane({ + laneId: LANE_ID, + title: "My PR", + body: "", + draft: false, + allowDirtyWorktree: true, + }), + ).rejects.toThrow("GitHub returned an invalid PR number"); + }); + + it("throws when lane is not found", async () => { + const laneService = makeLaneService([]); // empty list + const { service } = buildService({ laneService }); + + await expect( + service.createFromLane({ + laneId: "nonexistent", + title: "PR", + body: "", + draft: false, + }), + ).rejects.toThrow("Lane not found: nonexistent"); + }); +}); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 21cc1d90b..e87cd71e4 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -1966,17 +1966,25 @@ export function createPrService({ } const createdAt = nowIso(); - const created = await githubService.apiRequest({ - method: "POST", - path: `/repos/${repo.owner}/${repo.name}/pulls`, - body: { - title: args.title, - head: headBranch, - base: baseBranch, - body: args.body, - draft: Boolean(args.draft) - } - }); + let created: { data: any; response: Response | null }; + try { + created = await githubService.apiRequest({ + method: "POST", + path: `/repos/${repo.owner}/${repo.name}/pulls`, + body: { + title: args.title, + head: headBranch, + base: baseBranch, + body: args.body, + draft: Boolean(args.draft) + } + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to create pull request for "${headBranch}" → "${baseBranch}": ${msg}` + ); + } const pr = created.data; const prNumber = Number(pr?.number); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 27734342e..29edf6687 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -82,6 +82,8 @@ import type { AgentChatSessionCapabilitiesArgs, AgentChatSessionSummary, AgentChatSteerArgs, + AgentChatCancelSteerArgs, + AgentChatEditSteerArgs, AgentChatSubagentSnapshot, AgentChatSubagentListArgs, AgentChatUpdateSessionArgs, @@ -825,6 +827,8 @@ declare global { handoff: (args: AgentChatHandoffArgs) => Promise; send: (args: AgentChatSendArgs) => Promise; steer: (args: AgentChatSteerArgs) => Promise; + cancelSteer: (args: AgentChatCancelSteerArgs) => Promise; + editSteer: (args: AgentChatEditSteerArgs) => Promise; interrupt: (args: AgentChatInterruptArgs) => Promise; resume: (args: AgentChatResumeArgs) => Promise; approve: (args: AgentChatApproveArgs) => Promise; @@ -895,6 +899,7 @@ declare global { stashApply: (args: GitStashRefArgs) => Promise; stashPop: (args: GitStashRefArgs) => Promise; stashDrop: (args: GitStashRefArgs) => Promise; + stashClear: (args: { laneId: string }) => Promise; fetch: (args: { laneId: string }) => Promise; pull: (args: { laneId: string }) => Promise; getSyncStatus: (args: { laneId: string }) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e405e75ce..f9c5f6168 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -241,6 +241,8 @@ import type { AgentChatSessionCapabilitiesArgs, AgentChatSessionSummary, AgentChatSteerArgs, + AgentChatCancelSteerArgs, + AgentChatEditSteerArgs, AgentChatSubagentSnapshot, AgentChatSubagentListArgs, AgentChatUpdateSessionArgs, @@ -1120,6 +1122,10 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.agentChatSend, args), steer: async (args: AgentChatSteerArgs): Promise => ipcRenderer.invoke(IPC.agentChatSteer, args), + cancelSteer: async (args: AgentChatCancelSteerArgs): Promise => + ipcRenderer.invoke(IPC.agentChatCancelSteer, args), + editSteer: async (args: AgentChatEditSteerArgs): Promise => + ipcRenderer.invoke(IPC.agentChatEditSteer, args), interrupt: async (args: AgentChatInterruptArgs): Promise => ipcRenderer.invoke(IPC.agentChatInterrupt, args), resume: async (args: AgentChatResumeArgs): Promise => @@ -1237,6 +1243,7 @@ contextBridge.exposeInMainWorld("ade", { stashApply: async (args: GitStashRefArgs): Promise => ipcRenderer.invoke(IPC.gitStashApply, args), stashPop: async (args: GitStashRefArgs): Promise => ipcRenderer.invoke(IPC.gitStashPop, args), stashDrop: async (args: GitStashRefArgs): Promise => ipcRenderer.invoke(IPC.gitStashDrop, args), + stashClear: async (args: { laneId: string }): Promise => ipcRenderer.invoke(IPC.gitStashClear, args), fetch: async (args: { laneId: string }): Promise => ipcRenderer.invoke(IPC.gitFetch, args), pull: async (args: { laneId: string }): Promise => ipcRenderer.invoke(IPC.gitPull, args), getSyncStatus: async (args: { laneId: string }): Promise => diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index 78d4ce13f..77fc85616 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -115,10 +115,10 @@ export function TabNav() { : "All active terminals running" } className={cn( - "absolute -right-1 -top-1 ade-status-dot animate-spin", + "absolute -right-1 -top-1 ade-status-dot", terminalAttention.indicator === "running-needs-attention" ? "ade-status-dot-warning" - : "ade-status-dot-active", + : "ade-status-dot-active animate-spin", )} /> ) : null} diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index c700f0ba1..61badd1fb 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -351,10 +351,10 @@ export function TopBar() { : `${terminalAttention.runningCount} running terminal${terminalAttention.runningCount === 1 ? "" : "s"}` } className={cn( - "ade-status-dot h-1.5 w-1.5 shrink-0 animate-pulse", + "ade-status-dot h-1.5 w-1.5 shrink-0", indicator === "running-needs-attention" ? "ade-status-dot-warning" - : "ade-status-dot-active" + : "ade-status-dot-active animate-pulse" )} /> ) : null} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 9a9bd0870..f4fcd692c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -611,7 +611,7 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.getByTestId("location").textContent).toBe("/files::{\"laneId\":\"lane-123\"}"); }); - it("renders ask-user requests with an amber waiting spinner", () => { + it("renders ask-user requests with an amber waiting icon", () => { const view = renderMessageList([ { sessionId: "session-1", @@ -631,7 +631,8 @@ describe("AgentChatMessageList transcript rendering", () => { ]); expect(screen.getByText("Needs Input")).toBeTruthy(); - expect(view.container.querySelector(".animate-spin.text-amber-400")).toBeTruthy(); + expect(view.container.querySelector("svg.text-amber-400")).toBeTruthy(); + expect(view.container.querySelector(".animate-spin.text-amber-400")).toBeFalsy(); }); it("labels provider chats as Codex and preserves explicit assistant labels", () => { diff --git a/apps/desktop/src/renderer/components/chat/chatStatusVisuals.tsx b/apps/desktop/src/renderer/components/chat/chatStatusVisuals.tsx index 75ecc94ba..489b2e06a 100644 --- a/apps/desktop/src/renderer/components/chat/chatStatusVisuals.tsx +++ b/apps/desktop/src/renderer/components/chat/chatStatusVisuals.tsx @@ -1,4 +1,4 @@ -import { CheckCircle, SpinnerGap, XCircle } from "@phosphor-icons/react"; +import { CheckCircle, Clock, SpinnerGap, XCircle } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; export type ChatStatusVisualState = "working" | "waiting" | "completed" | "failed"; @@ -39,7 +39,7 @@ export function ChatStatusGlyph({ case "failed": return ; case "waiting": - return ; + return ; case "working": return ; } diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index d47add419..8a9f27d6a 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -365,6 +365,7 @@ export function LaneGitActionsPane({ const hasStaged = stagedCount > 0; const hasUnstaged = changes.unstaged.length > 0; const responsiveMode = getResponsiveMode(paneWidth); + const maxVisibleStashes = responsiveMode === "wide" ? 2 : 3; const actionGridColumns = responsiveMode === "wide" ? "repeat(3, minmax(0, 1fr))" : responsiveMode === "medium" ? "repeat(2, minmax(0, 1fr))" : "1fr"; @@ -888,6 +889,7 @@ export function LaneGitActionsPane({ }, [forcePushSuggested, lane, laneId, syncStatus]); const divergedSync = Boolean(syncStatus?.diverged); + const behindCount = syncStatus?.behind ?? 0; const headerDotColor = getLaneHeaderDotColor(lane); const pushButtonTitle = syncStatus?.hasUpstream === false ? "Publish lane" : "Push to remote"; const rebaseConflictParentLaneId = autoRebaseStatus?.parentLaneId ?? lane?.parentLaneId ?? null; @@ -1275,6 +1277,7 @@ export function LaneGitActionsPane({ + )} + + }} + > + SAVE CHANGES + + {stashes.length === 0 ? (
- Use stash when you want to clear the worktree without committing. Hover the buttons for the git details. + Save your in-progress changes without committing. You can restore them later.
) : (
- {stashes.slice(0, responsiveMode === "wide" ? 2 : 3).map((stash) => ( + {stashes.slice(0, maxVisibleStashes).map((stash) => (
-
-
- {stash.subject || stash.ref} -
-
- {stash.ref} · {formatRelativeTime(stash.createdAt)} +
+
+
+ {stash.subject || stash.ref} +
+
+ {stash.ref} · {formatRelativeTime(stash.createdAt)} +
+ + + +
+
+ Restore removes entry. Copy to Worktree keeps it. Delete discards permanently.
- - -
))} - {stashes.length > (responsiveMode === "wide" ? 2 : 3) ? ( + {stashes.length > maxVisibleStashes ? (
- +{stashes.length - (responsiveMode === "wide" ? 2 : 3)} more stash entr{stashes.length - (responsiveMode === "wide" ? 2 : 3) === 1 ? "y" : "ies"}. + +{stashes.length - maxVisibleStashes} more stash entr{stashes.length - maxVisibleStashes === 1 ? "y" : "ies"}.
) : null}
diff --git a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx index 88751f690..e8bd46faf 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx @@ -22,7 +22,7 @@ const tabTrigger = function statusDotCls(indicator: ReturnType): string { if (indicator === "running-active") return "border-2 border-emerald-500 border-t-transparent bg-transparent"; - if (indicator === "running-needs-attention") return "border-2 border-amber-400 border-t-transparent bg-transparent"; + if (indicator === "running-needs-attention") return "bg-amber-300"; return "bg-red-500"; } @@ -321,7 +321,7 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string runtimeState: s.runtimeState }); const dotClass = statusDotCls(indicator); - const dotSpin = !profileColor && indicator !== "ended"; + const dotSpin = !profileColor && indicator === "running-active"; return ( NEW LANE {addLaneDropdownOpen ? ( -
+