Files
kandev/apps/web/lib/api/domains/session-api.ts
T
João Salavisa ea84b913b4 feat: choose worktree when opening IDE on multi-repo tasks (#1743)
* feat: choose worktree when opening IDE on multi-repo tasks

On multi-repo tasks the open-in-IDE button always opened the first
worktree. The editors menu now shows a worktree picker (repo + branch)
when a session has multiple worktrees, and each editor in the dropdown
expands into a per-worktree submenu.

- open-editor/open-folder accept an optional worktree_id; empty keeps
  the first-worktree fallback so existing clients are unaffected
- session DTOs now serialize the full worktrees list instead of only
  the flattened first-worktree fields
- useSessionWorktrees unions API worktrees with the WS-populated list
- new Playwright spec covers the picker on a real multi-repo session

* fix: address review feedback on worktree picker

- fall back on empty-string worktree fields, not only null/undefined
- bind optional open-folder body via EOF check instead of ContentLength
  so chunked requests keep their worktree_id
- wrap disabled editor buttons in a focusable span so tooltips work
- add hook test asserting worktreeId is forwarded as worktree_id

* fix: map editor service errors to HTTP status, drop e2e sleep

- open-editor/open-folder return 404/400/409 for known service errors
  (unknown worktree or editor, invalid config, unavailable editor)
  instead of a blanket 500
- replace the picker spec's 750ms sleep with an explicit composer click
  so the autofocus race is deterministic

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-07-16 22:43:06 +01:00

175 lines
5.5 KiB
TypeScript

import { fetchJson, type ApiRequestOptions } from "../client";
import type {
TaskSessionsResponse,
TaskSessionResponse,
ListMessagesResponse,
ListTurnsResponse,
} from "@/lib/types/http";
import { getWebSocketClient } from "@/lib/ws/connection";
export type ShellCommandOutput = {
exit_code?: number;
stdout?: string;
stderr?: string;
truncated?: boolean;
};
export type ShellCommandOutputSnapshot = {
message_id: string;
status: string;
updated_at: string;
output: ShellCommandOutput;
};
export type MessageSearchHit = {
id: string;
turn_id?: string;
author_type: string;
type: string;
snippet: string;
created_at: string;
};
export type SearchMessagesResponse = {
hits: MessageSearchHit[];
total: number;
};
/** Search messages in a single session via WebSocket. */
export async function searchSessionMessages(
sessionId: string,
query: string,
limit = 50,
): Promise<SearchMessagesResponse> {
const client = getWebSocketClient();
if (!client) return { hits: [], total: 0 };
return client.request<SearchMessagesResponse>(
"message.search",
{ session_id: sessionId, query, limit },
10000,
);
}
/**
* Rename a session's tab label. Pass an empty string to clear the custom
* name and revert to the derived agent/model title. The backend broadcasts
* a session.state_changed event so every client picks up the new label.
*/
export async function renameSession(sessionId: string, name: string): Promise<void> {
const client = getWebSocketClient();
if (!client) throw new Error("WebSocket unavailable");
await client.request("session.rename", { session_id: sessionId, name });
}
// Session operations
export async function listTaskSessions(taskId: string, options?: ApiRequestOptions) {
return fetchJson<TaskSessionsResponse>(`/api/v1/tasks/${taskId}/sessions`, options);
}
export async function fetchTaskSession(taskSessionId: string, options?: ApiRequestOptions) {
return fetchJson<TaskSessionResponse>(`/api/v1/task-sessions/${taskSessionId}`, options);
}
export async function dismissLastAgentError(
taskSessionId: string,
stamp: string,
options?: ApiRequestOptions,
) {
return fetchJson<TaskSessionResponse>(
`/api/v1/task-sessions/${taskSessionId}/last-agent-error/dismiss`,
{
...options,
init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ stamp }) },
},
);
}
export async function listTaskSessionMessages(
taskSessionId: string,
params?: { limit?: number; before?: string; after?: string; sort?: "asc" | "desc" },
options?: ApiRequestOptions,
) {
const query = new URLSearchParams();
if (params?.limit) query.set("limit", params.limit.toString());
if (params?.before) query.set("before", params.before);
if (params?.after) query.set("after", params.after);
if (params?.sort) query.set("sort", params.sort);
const suffix = query.toString();
const url = `/api/v1/task-sessions/${taskSessionId}/messages${suffix ? `?${suffix}` : ""}`;
return fetchJson<ListMessagesResponse>(url, options);
}
export async function fetchShellCommandOutput(
taskSessionId: string,
messageId: string,
options?: ApiRequestOptions,
) {
return fetchJson<ShellCommandOutputSnapshot>(
`/api/v1/task-sessions/${taskSessionId}/messages/${messageId}/shell-output`,
options,
);
}
export async function listSessionTurns(taskSessionId: string, options?: ApiRequestOptions) {
return fetchJson<ListTurnsResponse>(`/api/v1/task-sessions/${taskSessionId}/turns`, options);
}
export async function openSessionInEditor(
sessionId: string,
payload: Partial<{
editor_id: string;
editor_type: string;
file_path: string;
line: number;
column: number;
worktree_id: string;
}>,
options?: ApiRequestOptions,
) {
return fetchJson<{ url?: string }>(`/api/v1/task-sessions/${sessionId}/open-editor`, {
...options,
init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) },
});
}
export async function openSessionFolder(sessionId: string, options?: ApiRequestOptions) {
return fetchJson<{ success: boolean }>(`/api/v1/task-sessions/${sessionId}/open-folder`, {
...options,
init: { method: "POST", ...(options?.init ?? {}) },
});
}
export async function setSessionMode(sessionId: string, modeId: string) {
return fetchJson<{ ok: boolean }>(`/api/v1/task-sessions/${sessionId}/set-mode`, {
init: { method: "POST", body: JSON.stringify({ mode_id: modeId }) },
});
}
export async function setSessionModel(sessionId: string, modelId: string) {
return fetchJson<{ ok: boolean }>(`/api/v1/task-sessions/${sessionId}/set-model`, {
init: { method: "POST", body: JSON.stringify({ model_id: modelId }) },
});
}
export async function setSessionConfigOption(sessionId: string, configId: string, value: string) {
return fetchJson<{ ok: boolean }>(`/api/v1/task-sessions/${sessionId}/set-config-option`, {
init: { method: "POST", body: JSON.stringify({ config_id: configId, value }) },
});
}
export async function authenticateSession(sessionId: string, methodId: string) {
return fetchJson<{ ok: boolean }>(`/api/v1/task-sessions/${sessionId}/authenticate`, {
init: { method: "POST", body: JSON.stringify({ method_id: methodId }) },
});
}
export { launchSession, type LaunchSessionResponse } from "@/lib/services/session-launch-service";
export {
buildPRPrepareRequest,
buildPrepareRequest,
buildStartRequest,
buildStartCreatedRequest,
buildResumeRequest,
buildWorkflowStepRequest,
} from "@/lib/services/session-launch-helpers";