diff --git a/apps/web/hooks/use-editors.ts b/apps/web/hooks/use-editors.ts
new file mode 100644
index 0000000000..03302d0768
--- /dev/null
+++ b/apps/web/hooks/use-editors.ts
@@ -0,0 +1,66 @@
+'use client';
+
+import { useEffect } from 'react';
+import { fetchUserSettings, listEditors } from '@/lib/http';
+import { getWebSocketClient } from '@/lib/ws/connection';
+import { useAppStore } from '@/components/state-provider';
+
+export function useEditors() {
+ const editors = useAppStore((state) => state.editors.items);
+ const loaded = useAppStore((state) => state.editors.loaded);
+ const loading = useAppStore((state) => state.editors.loading);
+ const setEditors = useAppStore((state) => state.setEditors);
+ const setEditorsLoading = useAppStore((state) => state.setEditorsLoading);
+ const userSettingsLoaded = useAppStore((state) => state.userSettings.loaded);
+ const setUserSettings = useAppStore((state) => state.setUserSettings);
+
+ useEffect(() => {
+ const client = getWebSocketClient();
+ if (client) {
+ // Future: subscribe to editor update events.
+ client.subscribeUser();
+ }
+ }, []);
+
+ useEffect(() => {
+ if (loaded || loading) {
+ return;
+ }
+ setEditorsLoading(true);
+ listEditors({ cache: 'no-store' })
+ .then((response) => {
+ setEditors(response.editors ?? []);
+ })
+ .catch(() => {
+ setEditors([]);
+ })
+ .finally(() => {
+ setEditorsLoading(false);
+ });
+ }, [loaded, loading, setEditors, setEditorsLoading]);
+
+ useEffect(() => {
+ if (userSettingsLoaded) return;
+ fetchUserSettings({ cache: 'no-store' })
+ .then((data) => {
+ if (!data?.settings) return;
+ setUserSettings({
+ workspaceId: data.settings.workspace_id || null,
+ boardId: data.settings.board_id || null,
+ repositoryIds: data.settings.repository_ids ?? [],
+ preferredShell: data.settings.preferred_shell || null,
+ defaultEditorId: data.settings.default_editor_id || null,
+ loaded: true,
+ });
+ })
+ .catch(() => {
+ // Ignore settings fetch errors for now.
+ });
+ }, [setUserSettings, userSettingsLoaded]);
+
+ return {
+ editors,
+ loaded,
+ loading,
+ };
+}
diff --git a/apps/web/hooks/use-open-session-in-editor.ts b/apps/web/hooks/use-open-session-in-editor.ts
new file mode 100644
index 0000000000..dd3d4ea6fd
--- /dev/null
+++ b/apps/web/hooks/use-open-session-in-editor.ts
@@ -0,0 +1,41 @@
+'use client';
+
+import { openSessionInEditor } from '@/lib/http';
+import { useRequest } from '@/lib/http/use-request';
+
+type OpenEditorOptions = {
+ filePath?: string;
+ line?: number;
+ column?: number;
+ editorId?: string;
+ editorType?: string;
+};
+
+export function useOpenSessionInEditor(sessionId?: string | null) {
+ const request = useRequest(async (options?: OpenEditorOptions) => {
+ if (!sessionId) {
+ return null;
+ }
+ const response = await openSessionInEditor(
+ sessionId,
+ {
+ editor_id: options?.editorId,
+ editor_type: options?.editorType,
+ file_path: options?.filePath,
+ line: options?.line,
+ column: options?.column,
+ },
+ { cache: 'no-store' }
+ );
+ if (response?.url) {
+ window.open(response.url, '_blank', 'noopener,noreferrer');
+ }
+ return response ?? null;
+ });
+
+ return {
+ open: (options?: OpenEditorOptions) => request.run(options),
+ status: request.status,
+ isLoading: request.isLoading,
+ };
+}
diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts
index 69d7ffa4b9..d5902da788 100644
--- a/apps/web/hooks/use-user-display-settings.ts
+++ b/apps/web/hooks/use-user-display-settings.ts
@@ -10,6 +10,7 @@ type DisplaySettings = {
boardId: string | null;
repositoryIds: string[];
preferredShell: string | null;
+ defaultEditorId: string | null;
loaded: boolean;
};
@@ -45,6 +46,7 @@ export function useUserDisplaySettings({
boardId: next.boardId,
repositoryIds,
preferredShell: next.preferredShell ?? userSettings.preferredShell ?? null,
+ defaultEditorId: userSettings.defaultEditorId ?? null,
loaded: true,
};
const sameWorkspace = normalized.workspaceId === userSettings.workspaceId;
@@ -86,6 +88,7 @@ export function useUserDisplaySettings({
boardId: data.settings.board_id || null,
repositoryIds,
preferredShell: data.settings.preferred_shell || null,
+ defaultEditorId: data.settings.default_editor_id || null,
loaded: true,
});
})
diff --git a/apps/web/lib/http/index.ts b/apps/web/lib/http/index.ts
index 173f779141..25fec102c2 100644
--- a/apps/web/lib/http/index.ts
+++ b/apps/web/lib/http/index.ts
@@ -12,6 +12,8 @@ import type {
RepositoryBranchesResponse,
NotificationProvidersResponse,
NotificationProvider,
+ EditorsResponse,
+ EditorOption,
CreateTaskResponse,
Task,
TaskSessionsResponse,
@@ -165,6 +167,7 @@ export async function updateUserSettings(
board_id: string;
repository_ids: string[];
preferred_shell?: string;
+ default_editor_id?: string;
},
options?: ApiRequestOptions
) {
@@ -174,6 +177,59 @@ export async function updateUserSettings(
});
}
+export async function listEditors(options?: ApiRequestOptions) {
+ return fetchJson
('/api/v1/editors', options);
+}
+
+export async function createEditor(
+ payload: {
+ name: string;
+ kind: string;
+ config?: Record;
+ enabled?: boolean;
+ },
+ options?: ApiRequestOptions
+) {
+ return fetchJson('/api/v1/editors', {
+ ...options,
+ init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) },
+ });
+}
+
+export async function updateEditor(
+ editorId: string,
+ payload: {
+ name?: string;
+ kind?: string;
+ config?: Record;
+ enabled?: boolean;
+ },
+ options?: ApiRequestOptions
+) {
+ return fetchJson(`/api/v1/editors/${editorId}`, {
+ ...options,
+ init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) },
+ });
+}
+
+export async function deleteEditor(editorId: string, options?: ApiRequestOptions) {
+ return fetchJson<{ success: boolean }>(`/api/v1/editors/${editorId}`, {
+ ...options,
+ init: { method: 'DELETE', ...(options?.init ?? {}) },
+ });
+}
+
+export async function openSessionInEditor(
+ sessionId: string,
+ payload: Partial<{ editor_id: string; editor_type: string; file_path: string; line: number; column: number }>,
+ 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 listNotificationProviders(options?: ApiRequestOptions) {
return fetchJson('/api/v1/notification-providers', options);
}
diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts
index 2ef4bf3e5d..9e080e21d8 100644
--- a/apps/web/lib/state/store.ts
+++ b/apps/web/lib/state/store.ts
@@ -4,6 +4,7 @@ import type {
AgentDiscovery,
Branch,
Environment,
+ EditorOption,
Executor,
Message,
Repository,
@@ -64,6 +65,12 @@ export type AgentDiscoveryState = {
items: AgentDiscovery[];
};
+export type EditorsState = {
+ items: EditorOption[];
+ loaded: boolean;
+ loading: boolean;
+};
+
export type BoardState = {
items: Array<{ id: string; workspaceId: string; name: string }>;
activeId: string | null;
@@ -106,6 +113,7 @@ export type UserSettingsState = {
boardId: string | null;
repositoryIds: string[];
preferredShell: string | null;
+ defaultEditorId: string | null;
loaded: boolean;
};
@@ -224,6 +232,7 @@ export type AppState = {
repositories: RepositoriesState;
repositoryBranches: RepositoryBranchesState;
settingsData: SettingsDataState;
+ editors: EditorsState;
tasks: TaskState;
agents: AgentState;
agentProfiles: AgentProfilesState;
@@ -254,6 +263,8 @@ export type AppState = {
setRepositoryBranches: (repositoryId: string, branches: Branch[]) => void;
setRepositoryBranchesLoading: (repositoryId: string, loading: boolean) => void;
setSettingsData: (next: Partial) => void;
+ setEditors: (editors: EditorsState['items']) => void;
+ setEditorsLoading: (loading: boolean) => void;
setUserSettings: (settings: UserSettingsState) => void;
setTerminalOutput: (terminalId: string, data: string) => void;
appendShellOutput: (sessionId: string, data: string) => void;
@@ -307,6 +318,7 @@ const defaultState: AppState = {
repositories: { itemsByWorkspaceId: {}, loadingByWorkspaceId: {}, loadedByWorkspaceId: {} },
repositoryBranches: { itemsByRepositoryId: {}, loadingByRepositoryId: {}, loadedByRepositoryId: {} },
settingsData: { executorsLoaded: false, environmentsLoaded: false, agentsLoaded: false },
+ editors: { items: [], loaded: false, loading: false },
tasks: { activeTaskId: null, activeSessionId: null },
agents: { agents: [] },
agentProfiles: { items: [], version: 0 },
@@ -315,6 +327,7 @@ const defaultState: AppState = {
boardId: null,
repositoryIds: [],
preferredShell: null,
+ defaultEditorId: null,
loaded: false,
},
terminal: { terminals: [] },
@@ -343,6 +356,8 @@ const defaultState: AppState = {
setRepositoryBranches: () => undefined,
setRepositoryBranchesLoading: () => undefined,
setSettingsData: () => undefined,
+ setEditors: () => undefined,
+ setEditorsLoading: () => undefined,
setUserSettings: () => undefined,
setTerminalOutput: () => undefined,
appendShellOutput: () => undefined,
@@ -388,6 +403,8 @@ function mergeInitialState(
| 'setRepositoryBranches'
| 'setRepositoryBranchesLoading'
| 'setSettingsData'
+ | 'setEditors'
+ | 'setEditorsLoading'
| 'setUserSettings'
| 'setTerminalOutput'
| 'appendShellOutput'
@@ -424,6 +441,7 @@ function mergeInitialState(
repositories: { ...defaultState.repositories, ...initialState.repositories },
repositoryBranches: { ...defaultState.repositoryBranches, ...initialState.repositoryBranches },
settingsData: { ...defaultState.settingsData, ...initialState.settingsData },
+ editors: { ...defaultState.editors, ...initialState.editors },
kanban: { ...defaultState.kanban, ...initialState.kanban },
tasks: { ...defaultState.tasks, ...initialState.tasks },
agents: { ...defaultState.agents, ...initialState.agents },
@@ -463,6 +481,7 @@ export function createAppStore(initialState?: Partial) {
if (state.repositories) Object.assign(draft.repositories, state.repositories);
if (state.repositoryBranches) Object.assign(draft.repositoryBranches, state.repositoryBranches);
if (state.settingsData) Object.assign(draft.settingsData, state.settingsData);
+ if (state.editors) Object.assign(draft.editors, state.editors);
if (state.kanban) Object.assign(draft.kanban, state.kanban);
if (state.tasks) Object.assign(draft.tasks, state.tasks);
if (state.agents) Object.assign(draft.agents, state.agents);
@@ -558,6 +577,15 @@ export function createAppStore(initialState?: Partial) {
set((draft) => {
draft.settingsData = { ...draft.settingsData, ...next };
}),
+ setEditors: (editors) =>
+ set((draft) => {
+ draft.editors.items = editors;
+ draft.editors.loaded = true;
+ }),
+ setEditorsLoading: (loading) =>
+ set((draft) => {
+ draft.editors.loading = loading;
+ }),
setUserSettings: (settings) =>
set((draft) => {
draft.userSettings = settings;
diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts
index 41e7555917..52f9f95319 100644
--- a/apps/web/lib/types/backend.ts
+++ b/apps/web/lib/types/backend.ts
@@ -242,6 +242,7 @@ export type UserSettingsUpdatedPayload = {
repository_ids: string[];
initial_setup_complete?: boolean;
preferred_shell?: string;
+ default_editor_id?: string;
updated_at?: string;
};
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts
index 1ee225d069..4b958ff6bd 100644
--- a/apps/web/lib/types/http.ts
+++ b/apps/web/lib/types/http.ts
@@ -178,6 +178,7 @@ export type UserSettings = {
repository_ids: string[];
initial_setup_complete?: boolean;
preferred_shell?: string;
+ default_editor_id?: string;
updated_at: string;
};
@@ -186,6 +187,22 @@ export type UserSettingsResponse = {
shell_options?: Array<{ value: string; label: string }>;
};
+export type EditorOption = {
+ id: string;
+ type: string;
+ name: string;
+ kind: string;
+ command?: string;
+ scheme?: string;
+ config?: Record;
+ installed: boolean;
+ enabled: boolean;
+};
+
+export type EditorsResponse = {
+ editors: EditorOption[];
+};
+
export type UserResponse = {
user: User;
settings: UserSettings;
diff --git a/apps/web/lib/ws/handlers/users.ts b/apps/web/lib/ws/handlers/users.ts
index c1e4ba4ecc..e7d3ceaa0c 100644
--- a/apps/web/lib/ws/handlers/users.ts
+++ b/apps/web/lib/ws/handlers/users.ts
@@ -13,6 +13,7 @@ export function registerUsersHandlers(store: StoreApi): WsHandlers {
boardId: message.payload.board_id || null,
repositoryIds,
preferredShell: message.payload.preferred_shell || null,
+ defaultEditorId: message.payload.default_editor_id || null,
loaded: true,
},
}));