diff --git a/apps/app/src/app/lib/ipollowork-server.ts b/apps/app/src/app/lib/ipollowork-server.ts index e434a0ebe..87a18c5a6 100644 --- a/apps/app/src/app/lib/ipollowork-server.ts +++ b/apps/app/src/app/lib/ipollowork-server.ts @@ -696,27 +696,6 @@ export type iPolloWorkReloadEvent = { timestamp: number; }; -export type iPolloWorkSessionGroupDefinition = { - id: string; - label: string; -}; - -export type iPolloWorkSessionGroupState = { - groups: iPolloWorkSessionGroupDefinition[]; - assignments: Record; -}; - -export type iPolloWorkSessionGroupEvent = { - id: string; - seq: number; - workspaceId: string; - type: "session_groups.updated"; - action: "created" | "updated" | "deleted" | "assigned" | "reordered" | "imported"; - groupId?: string; - sessionId?: string; - timestamp: number; -}; - // Fallback for explicit server-mode URL derivation. Desktop local workers replace this // with the persisted runtime-discovered port once the host reports it. export const DEFAULT_IPOLLOWORK_SERVER_PORT = 8787; @@ -1416,56 +1395,6 @@ export function createiPolloWorkServerClient(options: { baseUrl: string; token?: { token, hostToken, timeoutMs: timeouts.sessionRead }, ); }, - getSessionGroups: (workspaceId: string) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number | null }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups`, - { token, hostToken, timeoutMs: timeouts.sessionRead }, - ), - putSessionGroups: (workspaceId: string, state: iPolloWorkSessionGroupState) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups`, - { token, hostToken, method: "PUT", body: { state }, timeoutMs: timeouts.config }, - ), - createSessionGroup: (workspaceId: string, input: { id?: string; label: string }) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups`, - { token, hostToken, method: "POST", body: input, timeoutMs: timeouts.config }, - ), - reorderSessionGroups: (workspaceId: string, groupIds: string[]) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups/reorder`, - { token, hostToken, method: "PATCH", body: { groupIds }, timeoutMs: timeouts.config }, - ), - assignSessionGroup: (workspaceId: string, sessionId: string, groupId: string | null) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups/assignments/${encodeURIComponent(sessionId)}`, - { token, hostToken, method: "PATCH", body: { groupId }, timeoutMs: timeouts.config }, - ), - renameSessionGroup: (workspaceId: string, groupId: string, label: string) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups/${encodeURIComponent(groupId)}`, - { token, hostToken, method: "PATCH", body: { label }, timeoutMs: timeouts.config }, - ), - removeSessionGroup: (workspaceId: string, groupId: string) => - requestJson<{ state: iPolloWorkSessionGroupState; updatedAt: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups/${encodeURIComponent(groupId)}`, - { token, hostToken, method: "DELETE", timeoutMs: timeouts.config }, - ), - listSessionGroupEvents: (workspaceId: string, options?: { since?: number }) => { - const query = typeof options?.since === "number" ? `?since=${options.since}` : ""; - return requestJson<{ items: iPolloWorkSessionGroupEvent[]; cursor?: number }>( - baseUrl, - `/workspace/${encodeURIComponent(workspaceId)}/session-groups/events${query}`, - { token, hostToken }, - ); - }, getSession: (workspaceId: string, sessionId: string) => requestJson<{ item: Session }>( baseUrl, diff --git a/apps/app/src/app/lib/work-context.ts b/apps/app/src/app/lib/work-context.ts index d68104ce0..f66430b98 100644 --- a/apps/app/src/app/lib/work-context.ts +++ b/apps/app/src/app/lib/work-context.ts @@ -14,7 +14,6 @@ import { } from "./enterprise-connections"; import { createiPolloWorkServerClient, - type iPolloWorkServerClient, } from "./ipollowork-server"; import { isDesktopRuntime } from "./runtime-env"; @@ -22,6 +21,7 @@ export const PERSONAL_WORK_CONTEXT_ID = "personal" as const; export type WorkContextId = typeof PERSONAL_WORK_CONTEXT_ID | `enterprise:${string}`; const ACTIVE_WORK_CONTEXT_KEY = "ipollowork.work-context.v1"; +const LAST_PROJECT_BY_CONTEXT_KEY = "ipollowork.work-context-projects.v1"; const LEGACY_ACTIVE_ENTERPRISE_KEY = "ipollowork.enterprise-active.v1"; const LEGACY_WORK_CONTEXT_STORAGE_KEYS = [ "ipollowork.work-context-workspaces.v1", @@ -124,70 +124,33 @@ export function filterWorkspacesForWorkContext workspaceBelongsToWorkContext(workspace, contextId)); } -function isLegacyWorkstationPath(workspacePath: string | null | undefined) { - return /(?:^|[/\\])\.ipollowork[/\\]workstations[/\\]/i.test(workspacePath?.trim() ?? ""); -} - -export function canonicalWorkspaceForWorkContext< - T extends Pick, ->( - workspaces: T[], - contextId: WorkContextId, - preferredIds: Array = [], -): T | null { - const candidates = filterWorkspacesForWorkContext(workspaces, contextId); - if (candidates.length === 0) return null; - - const contextCandidates = contextId === PERSONAL_WORK_CONTEXT_ID - ? candidates.some((workspace) => !isLegacyWorkstationPath(workspace.path)) - ? candidates.filter((workspace) => !isLegacyWorkstationPath(workspace.path)) - : candidates - : (() => { - const enterpriseId = contextId.slice("enterprise:".length); - const contextPathPattern = new RegExp(`(?:^|[/\\\\])\\.ipollowork[/\\\\]work-contexts[/\\\\]${enterpriseId}(?:[/\\\\]|$)`, "i"); - const dedicated = candidates.filter((workspace) => contextPathPattern.test(workspace.path?.trim() ?? "")); - return dedicated.length > 0 ? dedicated : candidates; - })(); - - for (const preferredId of preferredIds) { - const normalized = preferredId?.trim() ?? ""; - if (!normalized) continue; - const match = contextCandidates.find((workspace) => workspace.id === normalized); - if (match) return match; +function readLastProjectMap(): Record { + if (typeof window === "undefined") return {}; + try { + const parsed: unknown = JSON.parse(window.localStorage.getItem(LAST_PROJECT_BY_CONTEXT_KEY) ?? "{}"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const projects: Record = {}; + for (const [contextId, projectId] of Object.entries(parsed)) { + if (!normalizeWorkContextId(contextId) || typeof projectId !== "string" || !projectId.trim()) continue; + projects[contextId] = projectId.trim(); + } + return projects; + } catch { + return {}; } - - return contextCandidates.find((workspace) => workspace.workspaceType !== "remote") - ?? contextCandidates[0] - ?? null; } -export function canonicalWorkspacesForWorkContext< - T extends Pick, ->( - workspaces: T[], - contextId: WorkContextId, - preferredIds: Array = [], -): T[] { - const workspace = canonicalWorkspaceForWorkContext(workspaces, contextId, preferredIds); - return workspace ? [workspace] : []; +export function readLastProjectForWorkContext(contextId: WorkContextId): string | null { + return readLastProjectMap()[contextId] ?? null; } -export async function pruneServerWorkspacesForWorkContext( - client: iPolloWorkServerClient, - workspaces: Array>, - contextId: WorkContextId, - canonicalWorkspaceId: string, -): Promise { - const staleWorkspaceIds = filterWorkspacesForWorkContext(workspaces, contextId) - .map((workspace) => workspace.id) - .filter((workspaceId) => workspaceId !== canonicalWorkspaceId); - - const removed: string[] = []; - for (const workspaceId of staleWorkspaceIds) { - const result = await client.deleteWorkspace(workspaceId).catch(() => null); - if (result?.deleted) removed.push(workspaceId); - } - return removed; +export function rememberProjectForWorkContext(contextId: WorkContextId, projectId: string | null): void { + if (typeof window === "undefined") return; + const projects = readLastProjectMap(); + const normalized = projectId?.trim() ?? ""; + if (normalized) projects[contextId] = normalized; + else delete projects[contextId]; + window.localStorage.setItem(LAST_PROJECT_BY_CONTEXT_KEY, JSON.stringify(projects)); } function dispatchSwitch(phase: WorkContextSwitchDetail["phase"], contextId: WorkContextId) { @@ -242,15 +205,6 @@ async function activateWorkspaceEverywhere(workspace: WorkspaceInfo) { serverWorkspace = created.workspaces.find((entry) => entry.id === workspace.id || entry.path === workspace.path) ?? null; } if (!serverWorkspace) throw new Error("work_context_workspace_unavailable"); - const contextId = normalizeWorkContextId(workspace.workContextId) ?? PERSONAL_WORK_CONTEXT_ID; - const canonicalDisplayName = contextId === PERSONAL_WORK_CONTEXT_ID - ? "Personal" - : workspace.displayName?.trim() || workspace.name; - if ((serverWorkspace.displayName?.trim() || serverWorkspace.name) !== canonicalDisplayName) { - const renamed = await client.updateWorkspaceDisplayName(serverWorkspace.id, canonicalDisplayName); - serverWorkspace = renamed.workspaces.find((entry) => entry.id === serverWorkspace?.id) ?? serverWorkspace; - } - await pruneServerWorkspacesForWorkContext(client, serverWorkspaces, contextId, serverWorkspace.id); await client.activateWorkspace(serverWorkspace.id, { persist: true }); await workspaceSetSelected(workspace.id); await workspaceSetRuntimeActive(workspace.id); @@ -268,12 +222,16 @@ export async function activatePersonalWorkContext(): Promise { return null; } const state = await workspaceBootstrap(); - const workspace = canonicalWorkspaceForWorkContext(state.workspaces, contextId, [ - state.selectedId, - state.activeId, - ]); + const projects = filterWorkspacesForWorkContext(state.workspaces, contextId); + const rememberedProjectId = readLastProjectForWorkContext(contextId); + const workspace = projects.find((project) => project.id === rememberedProjectId) + ?? projects.find((project) => project.id === state.selectedId) + ?? projects.find((project) => project.id === state.activeId) + ?? projects[0] + ?? null; if (!workspace) throw new Error("personal_workspace_unavailable"); const workspaceId = await activateWorkspaceEverywhere(workspace); + rememberProjectForWorkContext(contextId, workspaceId); commitActiveContext(contextId); committed = true; return workspaceId; @@ -293,10 +251,13 @@ export async function activateEnterpriseWorkContext(connection: EnterpriseConnec return null; } const state = await workspaceBootstrap(); - let workspace = canonicalWorkspaceForWorkContext(state.workspaces, contextId, [ - state.selectedId, - state.activeId, - ]); + const rememberedProjectId = readLastProjectForWorkContext(contextId); + const projects = filterWorkspacesForWorkContext(state.workspaces, contextId); + let workspace: WorkspaceInfo | null = projects.find((project) => project.id === rememberedProjectId) + ?? projects.find((project) => project.id === state.selectedId) + ?? projects.find((project) => project.id === state.activeId) + ?? projects[0] + ?? null; if (!workspace) { const homeDir = await getDesktopHomeDir(); const folderPath = await joinDesktopPath(homeDir, ".ipollowork", "work-contexts", connection.id); @@ -312,6 +273,7 @@ export async function activateEnterpriseWorkContext(connection: EnterpriseConnec } if (!workspace) throw new Error("enterprise_workspace_unavailable"); const workspaceId = await activateWorkspaceEverywhere(workspace); + rememberProjectForWorkContext(contextId, workspaceId); commitActiveContext(contextId); committed = true; return workspaceId; diff --git a/apps/app/src/app/types.ts b/apps/app/src/app/types.ts index 5e3dddd60..4a1c93ed8 100644 --- a/apps/app/src/app/types.ts +++ b/apps/app/src/app/types.ts @@ -4,7 +4,6 @@ import type { PermissionRequest as ApiPermissionRequest, PermissionV2Request, QuestionRequest, - ProviderListResponse, Session, } from "@opencode-ai/sdk/v2/client"; import type { createClient } from "./lib/opencode"; @@ -12,7 +11,32 @@ import type { OpencodeConfigFile, WorkspaceInfo } from "./lib/desktop-types"; export type Client = ReturnType; -export type ProviderListItem = ProviderListResponse["all"][number]; +export type ProviderModel = { + id: string; + name: string; + capabilities: { + attachment?: boolean; + reasoning?: boolean; + input?: { + image?: boolean; + }; + }; + variants?: Record>; +}; + +export type ProviderListItem = { + id: string; + name: string; + source: "env" | "config" | "custom" | "api"; + env: string[]; + models: Record; +}; + +export type ProviderListResponse = { + all: ProviderListItem[]; + connected: string[]; + default: Record; +}; export type SidebarSessionItem = { id: string; @@ -30,7 +54,7 @@ export type SidebarSessionItem = { directory?: string | null; }; -export type WorkspaceSessionGroup = { +export type ProjectSessionList = { workspace: WorkspaceInfo; sessions: SidebarSessionItem[]; status: "idle" | "loading" | "ready" | "error"; diff --git a/apps/app/src/app/utils/providers.ts b/apps/app/src/app/utils/providers.ts index 36712b8c0..2d340f2e8 100644 --- a/apps/app/src/app/utils/providers.ts +++ b/apps/app/src/app/utils/providers.ts @@ -1,4 +1,4 @@ -import type { ProviderListResponse } from "@opencode-ai/sdk/v2/client"; +import type { ProviderListResponse } from "../types"; const PINNED_PROVIDER_ORDER = ["opencode", "openai", "anthropic"] as const; diff --git a/apps/app/src/components/model-select.tsx b/apps/app/src/components/model-select.tsx index f4fd7b623..eb1bf512e 100644 --- a/apps/app/src/components/model-select.tsx +++ b/apps/app/src/components/model-select.tsx @@ -44,11 +44,12 @@ function getProviderDisplayName(providerId: string) { } function useModelOptions(open: boolean) { - const { client, opencodeBaseUrl, selectedWorkspaceRoot } = useWorkspace(); + const { client, engineId, opencodeBaseUrl, selectedWorkspaceRoot } = useWorkspace(); const checkDesktopRestriction = useCheckDesktopRestriction(); const { data, refetch } = useProviderListQuery({ client, + engineId, baseUrl: opencodeBaseUrl, directory: selectedWorkspaceRoot, enabled: Boolean(client), diff --git a/apps/app/src/i18n/locales/ca.ts b/apps/app/src/i18n/locales/ca.ts index 9aae7c791..1b8e588dc 100644 --- a/apps/app/src/i18n/locales/ca.ts +++ b/apps/app/src/i18n/locales/ca.ts @@ -114,7 +114,6 @@ export default { "composer.agent_label": "Agent", "composer.any_file_type_supported": "S'admet qualsevol tipus de fitxer.", "composer.attach_files": "Adjuntar fitxers", - "composer.attachments_unavailable": "Els fitxers adjunts no estan disponibles.", "composer.behavior_label": "Comportament", "composer.configure": "Configura", "composer.default_agent": "Agent per defecte", diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 5c56ed0ce..dd3b9c208 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -985,8 +985,7 @@ export default { "composer.computer_use_permissions_setup": "Set up", "composer.any_file_type_supported": "Any file type is supported.", "composer.attach_files": "Attach files", - "composer.attachments_unavailable": "Attachments are unavailable.", - "composer.attachments_require_multimodal": "Switch to a multimodal model that supports file or image input to attach files.", + "composer.attachments_require_multimodal": "This model can attach text and code files. Switch to a multimodal model for images or PDFs.", "composer.behavior_label": "Behavior", "composer.configure": "Configure", "composer.default_agent": "Default agent", @@ -2655,34 +2654,30 @@ export default { "workspace_list.unavailable": "Unavailable", "workspace_list.workspace_fallback": "Workspace", "session_management.pin_session": "Pin session", - "session_management.unpin_session": "Unpin session", + "session_management.unpin_session": "Unpin session", + "projects.title": "Projects", + "projects.actions": "Project actions", + "projects.create": "New project", + "projects.new_conversation": "New conversation in {project}", + "projects.create_description": "Choose an independent folder for this project. Its conversations, files, and configuration stay isolated.", + "projects.choose_folder": "Choose project folder", + "projects.name_placeholder": "Project name", + "projects.rename": "Rename project", + "projects.show_in_folder": "Show in folder", + "projects.remove": "Remove project", + "projects.remove_title": "Remove this project?", + "projects.remove_description": "The project will be removed from iPolloWork. Local files will not be deleted.", + "projects.removing": "Removing…", + "projects.server_unavailable": "The project service is unavailable. Reconnect and try again.", + "projects.name_and_folder_required": "Enter a project name and choose a project folder.", + "projects.create_failed": "Project creation failed.", + "projects.keep_one": "Personal and organization spaces must keep at least one project.", "session_management.pinned": "Pinned", "session_management.archive_session": "Archive session", "session_management.unarchive_session": "Unarchive session", "session_management.archive_failed": "Couldn't archive session", "session_management.unarchive_failed": "Couldn't unarchive session", - "session_management.archived_count_one": "Archived ({count})", - "session_management.archived_count_other": "Archived ({count})", - "session_management.move_to_group": "Move to group", - "session_management.no_group": "No group", - "session_management.no_groups_yet": "No groups yet", - "session_management.create_group": "Create a Group", - "session_management.new_group": "New group...", - "session_management.new_group_prompt": "Name this group (e.g. Done, In progress):", - "session_management.remove_group": "Delete", - "session_management.remove_group_title": "Remove group?", - "session_management.remove_group_message": "Remove {group}? Its conversations will move to Ungrouped and will not be deleted.", - "session_management.rename_group": "Rename", - "session_management.group_actions": "Actions for {group}", - "session_management.new_conversation_in_group": "New conversation in {group}", - "session_management.program": "Program", - "session_management.program_actions": "Program actions", - "session_management.ungrouped_actions": "Ungrouped actions", - "session_management.sort_by_name": "Sort by name", - "session_management.sort_by_recent": "Sort by most recent", - "session_management.empty_group": "No sessions", - "session_management.ungrouped": "Ungrouped", - "session_management.archived_label": "Archived", + "session_management.archived_label": "Archived", "settings.tab_ai": "AI Providers", "settings.tab_preferences": "Preferences", "settings.tab_shell": "Customization", diff --git a/apps/app/src/i18n/locales/es.ts b/apps/app/src/i18n/locales/es.ts index f5ce4aeb3..b21e58080 100644 --- a/apps/app/src/i18n/locales/es.ts +++ b/apps/app/src/i18n/locales/es.ts @@ -114,7 +114,6 @@ export default { "composer.agent_label": "Agente", "composer.any_file_type_supported": "Se admite cualquier tipo de archivo.", "composer.attach_files": "Adjuntar archivos", - "composer.attachments_unavailable": "Los archivos adjuntos no están disponibles.", "composer.behavior_label": "Comportamiento", "composer.configure": "Configurar", "composer.default_agent": "Agente predeterminado", diff --git a/apps/app/src/i18n/locales/fr.ts b/apps/app/src/i18n/locales/fr.ts index dab5104d0..d8bb301e8 100644 --- a/apps/app/src/i18n/locales/fr.ts +++ b/apps/app/src/i18n/locales/fr.ts @@ -114,7 +114,6 @@ export default { "composer.agent_label": "Agent", "composer.any_file_type_supported": "Tous les types de fichiers sont pris en charge.", "composer.attach_files": "Joindre des fichiers", - "composer.attachments_unavailable": "Les pièces jointes ne sont pas disponibles.", "composer.behavior_label": "Comportement", "composer.configure": "Configurer", "composer.default_agent": "Agent par défaut", diff --git a/apps/app/src/i18n/locales/ja.ts b/apps/app/src/i18n/locales/ja.ts index b01033846..2cd6f63ae 100644 --- a/apps/app/src/i18n/locales/ja.ts +++ b/apps/app/src/i18n/locales/ja.ts @@ -113,7 +113,6 @@ export default { "composer.agent_label": "エージェント", "composer.any_file_type_supported": "すべてのファイル形式に対応しています。", "composer.attach_files": "ファイルを添付", - "composer.attachments_unavailable": "添付ファイルは利用できません。", "composer.behavior_label": "動作", "composer.configure": "設定", "composer.default_agent": "デフォルトエージェント", diff --git a/apps/app/src/i18n/locales/pt-BR.ts b/apps/app/src/i18n/locales/pt-BR.ts index 76bd193ae..c36ef60cd 100644 --- a/apps/app/src/i18n/locales/pt-BR.ts +++ b/apps/app/src/i18n/locales/pt-BR.ts @@ -114,7 +114,6 @@ export default { "composer.agent_label": "Agente", "composer.any_file_type_supported": "Qualquer tipo de arquivo é aceito.", "composer.attach_files": "Anexar arquivos", - "composer.attachments_unavailable": "Anexos não estão disponíveis.", "composer.behavior_label": "Comportamento", "composer.configure": "Configurar", "composer.default_agent": "Agente padrão", diff --git a/apps/app/src/i18n/locales/ru.ts b/apps/app/src/i18n/locales/ru.ts index a392074fb..6096b1a6c 100644 --- a/apps/app/src/i18n/locales/ru.ts +++ b/apps/app/src/i18n/locales/ru.ts @@ -115,7 +115,6 @@ export default { "composer.agent_label": "Агент", "composer.any_file_type_supported": "Поддерживаются файлы любого типа.", "composer.attach_files": "Прикрепить файлы", - "composer.attachments_unavailable": "Вложения недоступны.", "composer.behavior_label": "Поведение", "composer.configure": "Настроить", "composer.default_agent": "Агент по умолчанию", diff --git a/apps/app/src/i18n/locales/th.ts b/apps/app/src/i18n/locales/th.ts index 8e8bb8a49..4a83a2f5c 100644 --- a/apps/app/src/i18n/locales/th.ts +++ b/apps/app/src/i18n/locales/th.ts @@ -114,7 +114,6 @@ export default { "composer.agent_label": "Agent", "composer.any_file_type_supported": "รองรับไฟล์ทุกประเภท", "composer.attach_files": "แนบไฟล์", - "composer.attachments_unavailable": "ไฟล์แนบไม่พร้อมใช้งาน", "composer.behavior_label": "พฤติกรรม", "composer.configure": "ตั้งค่า", "composer.default_agent": "Agent เริ่มต้น", diff --git a/apps/app/src/i18n/locales/vi.ts b/apps/app/src/i18n/locales/vi.ts index 87f0f7764..202fe2d9a 100644 --- a/apps/app/src/i18n/locales/vi.ts +++ b/apps/app/src/i18n/locales/vi.ts @@ -114,7 +114,6 @@ export default { "composer.agent_label": "Agent", "composer.any_file_type_supported": "Hỗ trợ mọi loại tệp.", "composer.attach_files": "Đính kèm tệp", - "composer.attachments_unavailable": "Đính kèm không khả dụng.", "composer.behavior_label": "Hành vi", "composer.configure": "Cấu hình", "composer.default_agent": "Agent mặc định", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 7b1d2a743..e33b12178 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -982,8 +982,7 @@ export default { "composer.agent_label": "智能体", "composer.any_file_type_supported": "支持任何文件类型。", "composer.attach_files": "附加文件", - "composer.attachments_unavailable": "附件功能不可用。", - "composer.attachments_require_multimodal": "请切换到支持文件或图片输入的多模态模型后再添加附件。", + "composer.attachments_require_multimodal": "当前模型可添加文本和代码文件;图片和 PDF 请切换到多模态模型。", "composer.behavior_label": "行为", "composer.configure": "配置", "composer.default_agent": "默认智能体", @@ -2493,33 +2492,29 @@ export default { "session.untitled": "未命名", "session_management.archive_failed": "无法归档会话", "session_management.archive_session": "归档会话", - "session_management.archived_count_one": "已归档({count})", - "session_management.archived_count_other": "已归档({count})", - "session_management.archived_label": "已归档", - "session_management.create_group": "创建分组", - "session_management.empty_group": "没有会话", - "session_management.move_to_group": "移动到分组", - "session_management.new_group": "新建分组…", - "session_management.new_group_prompt": "为这个分组命名(例如:已完成、进行中):", - "session_management.no_group": "不分组", - "session_management.no_groups_yet": "还没有分组", - "session_management.pin_session": "置顶会话", - "session_management.pinned": "已置顶", - "session_management.remove_group": "删除", - "session_management.remove_group_title": "移除分组?", - "session_management.remove_group_message": "移除“{group}”?其中的对话会移动到“未分组”,不会被删除。", - "session_management.rename_group": "重命名", - "session_management.group_actions": "{group}的操作", - "session_management.new_conversation_in_group": "在{group}中新建对话", - "session_management.program": "项目", - "session_management.program_actions": "项目操作", - "session_management.ungrouped_actions": "未分组操作", - "session_management.sort_by_name": "按名称排序", - "session_management.sort_by_recent": "按最近使用排序", + "session_management.archived_label": "已归档", + "session_management.pin_session": "置顶会话", + "session_management.pinned": "已置顶", "session_management.unarchive_failed": "无法取消归档会话", - "session_management.unarchive_session": "取消归档会话", - "session_management.ungrouped": "未分组", - "session_management.unpin_session": "取消置顶会话", + "session_management.unarchive_session": "取消归档会话", + "session_management.unpin_session": "取消置顶会话", + "projects.title": "项目", + "projects.actions": "项目操作", + "projects.create": "新建项目", + "projects.new_conversation": "在“{project}”中新建对话", + "projects.create_description": "选择一个独立文件夹作为项目空间。项目中的对话、文件和配置互相隔离。", + "projects.choose_folder": "选择项目文件夹", + "projects.name_placeholder": "项目名称", + "projects.rename": "重命名项目", + "projects.show_in_folder": "在文件夹中显示", + "projects.remove": "移除项目", + "projects.remove_title": "移除这个项目?", + "projects.remove_description": "项目会从 iPolloWork 中移除,本地文件不会被删除。", + "projects.removing": "正在移除…", + "projects.server_unavailable": "项目服务暂不可用,请重新连接后再试。", + "projects.name_and_folder_required": "请输入项目名称并选择项目文件夹。", + "projects.create_failed": "项目创建失败。", + "projects.keep_one": "个人或组织空间至少需要保留一个项目。", "settings.activity_section_desc": "审计记录和最近的运行时事件。", "settings.activity_section_title": "活动", "settings.analytics_toggle": "共享匿名使用数据", diff --git a/apps/app/src/react-app/domains/connections/provider-auth/cloud-provider-config.ts b/apps/app/src/react-app/domains/connections/provider-auth/cloud-provider-config.ts index c579487ba..9ea627c7d 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/cloud-provider-config.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/cloud-provider-config.ts @@ -1,17 +1,10 @@ -import { applyEdits, modify } from "jsonc-parser"; -import type { ProviderConfig } from "@opencode-ai/sdk/v2/client"; - import type { DenOrgLlmProvider, DenOrgLlmProviderConnection, } from "../../../../app/lib/den"; import type { CloudImportedProvider } from "../../../../app/cloud/import-state"; -/** - * Pure helpers that build and reconcile the cloud-managed ("lpr_*") provider - * block inside a workspace `opencode.jsonc`. Extracted from the provider-auth - * store so the diff/update behaviour can be unit tested directly (#2346). - */ +/** Pure Work-layer helpers for cloud-managed provider identity and credentials. */ const getStringList = (value: unknown): string[] => Array.isArray(value) @@ -24,24 +17,12 @@ const getStringList = (value: unknown): string[] => const sameStringList = (a: string[], b: string[]) => a.length === b.length && a.every((value, index) => value === b[index]); -const escapeRegExp = (value: string) => - value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - -const removeCloudProviderComment = (raw: string, providerId: string) => - raw.replace( - new RegExp( - `(^[ \t]*)// iPolloWork Cloud import:.*\\n\\1(?="${escapeRegExp(providerId)}":)`, - "m", - ), - "$1", - ); - export const getCloudProviderEnv = (config: Record) => getStringList(config.env); /** - * Split a connect payload's credential into the opencode auth.json entry and - * the env vars to upsert. Multi-env providers (`apiKeys`) set every value as + * Split a connect payload's credential into the engine credential and the env + * vars to upsert. Multi-env providers (`apiKeys`) set every value as * an env var and use the first env-ordered value as the auth entry, following * the models.dev convention that `env[0]` is the primary credential. Legacy * single-credential payloads (`apiKey`) keep today's auth-only behaviour. @@ -71,8 +52,8 @@ export const getCloudManagedProviderId = ( ) => (provider.source === "ipollowork" ? "ipollowork" : provider.id.trim()); /** - * A provider key in `opencode.jsonc` that is owned by the cloud-import system: - * `lpr_*` keys (org-managed providers) and the `ipollowork` hosted provider. + * A provider key owned by the cloud-import system: `lpr_*` keys + * (org-managed providers) and the `ipollowork` hosted provider. * These keys are never hand-authored, so re-importing over an existing block * with one of these ids is a safe reconcile (recovers a lost import baseline) * rather than a clobber of a user's manual provider (#2346). @@ -105,115 +86,3 @@ export const isCloudProviderOutOfSync = ( // which otherwise made providers permanently out-of-sync. getProviderModelIds(provider), ); - -export const buildCloudProviderConfig = ( - provider: DenOrgLlmProviderConnection, -): ProviderConfig => { - const models = Object.fromEntries( - provider.models.map((model) => { - const next: NonNullable[string] = { - id: model.id, - name: model.name, - }; - const raw = model.config; - for (const key of [ - "family", - "release_date", - "attachment", - "reasoning", - "temperature", - "tool_call", - "interleaved", - "cost", - "limit", - "modalities", - "status", - "options", - "headers", - "provider", - "variants", - ] as const) { - const value = raw[key]; - if (value !== undefined) { - (next as Record)[key] = value; - } - } - return [model.id, next]; - }), - ); - - const next: ProviderConfig = { - id: provider.providerId, - name: provider.name, - env: getCloudProviderEnv(provider.providerConfig), - models, - }; - - if ( - typeof provider.providerConfig.npm === "string" && - provider.providerConfig.npm.trim() - ) { - next.npm = provider.providerConfig.npm; - } - if ( - typeof provider.providerConfig.api === "string" && - provider.providerConfig.api.trim() - ) { - next.api = provider.providerConfig.api; - } - if ( - provider.providerConfig.options && - typeof provider.providerConfig.options === "object" - ) { - next.options = provider.providerConfig.options as Record; - } - if (Array.isArray(provider.providerConfig.whitelist)) { - next.whitelist = getStringList(provider.providerConfig.whitelist); - } - if (Array.isArray(provider.providerConfig.blacklist)) { - next.blacklist = getStringList(provider.providerConfig.blacklist); - } - - return next; -}; - -/** - * Build the per-key runtime provider patch for a cloud import/reconcile. - * Sent to `PATCH /workspace/:id/config` where record values upsert and - * explicit `null` deletes (`mergeRuntimeProviderUpdate`) — no client-side - * read-modify-write of the user's `opencode.jsonc` at all. - */ -export const buildRuntimeProviderPatch = ( - provider: DenOrgLlmProviderConnection, - localProviderId: string, - previousProviderId?: string | null, -): Record => { - const patch: Record = {}; - if (previousProviderId && previousProviderId !== localProviderId) { - patch[previousProviderId] = null; - } - patch[localProviderId] = buildCloudProviderConfig(provider) as unknown as Record; - return patch; -}; - -export const formatConfigWithoutCloudProvider = ( - raw: string, - providerId: string, - disabledProviders: string[], -) => { - let updated = raw.trim() - ? raw - : '{\n "$schema": "https://opencode.ai/config.json"\n}\n'; - updated = removeCloudProviderComment(updated, providerId); - const providerEdits = modify(updated, ["provider", providerId], undefined, { - formattingOptions: { insertSpaces: true, tabSize: 2 }, - }); - updated = applyEdits(updated, providerEdits); - - const nextDisabled = disabledProviders.filter((id) => id !== providerId); - const disabledEdits = modify(updated, ["disabled_providers"], nextDisabled, { - formattingOptions: { insertSpaces: true, tabSize: 2 }, - }); - updated = applyEdits(updated, disabledEdits); - return updated.endsWith("\n") ? updated : `${updated}\n`; -}; diff --git a/apps/app/src/react-app/domains/connections/provider-auth/opencode-provider-engine-adapter.ts b/apps/app/src/react-app/domains/connections/provider-auth/opencode-provider-engine-adapter.ts new file mode 100644 index 000000000..7df42e49d --- /dev/null +++ b/apps/app/src/react-app/domains/connections/provider-auth/opencode-provider-engine-adapter.ts @@ -0,0 +1,317 @@ +import { applyEdits, modify, parse } from "jsonc-parser"; +import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; + +import { + readOpencodeConfig, + writeOpencodeConfig, +} from "../../../../app/lib/desktop"; +import type { DenOrgLlmProviderConnection } from "../../../../app/lib/den"; +import { unwrap, waitForHealthy } from "../../../../app/lib/opencode"; +import type { Client } from "../../../../app/types"; +import { isDesktopRuntime } from "../../../../app/utils"; +import { getCloudProviderEnv } from "./cloud-provider-config"; +import type { + ProviderEngineAdapter, + ProviderEngineConnection, +} from "./provider-engine-adapter"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasMethod(value: unknown, name: string) { + return isRecord(value) && typeof value[name] === "function"; +} + +function isOpenCodeClient(value: unknown): value is Client { + if (!isRecord(value)) return false; + return ( + hasMethod(value.provider, "list") && + hasMethod(value.provider, "auth") && + isRecord(value.provider) && + isRecord(value.provider.oauth) && + hasMethod(value.provider.oauth, "authorize") && + hasMethod(value.provider.oauth, "callback") && + hasMethod(value.auth, "set") && + hasMethod(value.auth, "remove") && + hasMethod(value.config, "get") && + hasMethod(value.config, "update") && + hasMethod(value.instance, "dispose") + ); +} + +function openCodeConnection(client: unknown): ProviderEngineConnection { + if (!isOpenCodeClient(client)) { + throw new Error("OpenCode provider client is unavailable"); + } + + return { + async listProviders(directory) { + return unwrap(await client.provider.list({ directory: directory?.trim() || undefined })); + }, + async listAuthMethods() { + const methods = unwrap(await client.provider.auth()); + return Object.fromEntries( + Object.entries(methods).map(([providerId, entries]) => [ + providerId, + entries.map(({ type, label }) => ({ type, label })), + ]), + ); + }, + async authorizeOAuth(providerId, methodIndex) { + return unwrap( + await client.provider.oauth.authorize({ + providerID: providerId, + method: methodIndex, + }), + ); + }, + async completeOAuth(providerId, methodIndex, code) { + unwrap( + await client.provider.oauth.callback({ + providerID: providerId, + method: methodIndex, + code: code?.trim() || undefined, + }), + ); + }, + async setApiKey(providerId, apiKey) { + unwrap( + await client.auth.set({ + providerID: providerId, + auth: { type: "api", key: apiKey }, + }), + ); + }, + async removeCredentials(providerId) { + unwrap(await client.auth.remove({ providerID: providerId })); + }, + async readDisabledProviders() { + const config = unwrap(await client.config.get()); + return Array.isArray(config.disabled_providers) ? config.disabled_providers : []; + }, + async writeDisabledProviders(providerIds) { + const config = unwrap(await client.config.get()); + const next = { ...config }; + if (providerIds.length) { + next.disabled_providers = providerIds; + } else { + delete next.disabled_providers; + } + unwrap(await client.config.update({ config: next })); + }, + async dispose() { + unwrap(await client.instance.dispose()); + }, + async waitUntilHealthy() { + await waitForHealthy(client, { timeoutMs: 8000, pollMs: 250 }); + }, + }; +} + +function normalizeDisabledProviders(value: unknown) { + return Array.isArray(value) + ? [ + ...new Set( + value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ] + : []; +} + +function projectProviderIds(raw: string) { + const config: unknown = parse(raw); + if (!isRecord(config) || !isRecord(config.provider)) return []; + return Object.keys(config.provider); +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function formatOpenCodeConfigWithoutProvider( + raw: string, + providerId: string, + disabledProviders: string[], +) { + const commentPattern = new RegExp( + `(^[ \\t]*)// iPolloWork Cloud import:.*\\n\\1(?="${escapeRegExp(providerId)}":)`, + "m", + ); + let updated = raw.replace(commentPattern, "$1"); + updated = applyEdits( + updated, + modify(updated, ["provider", providerId], undefined, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }), + ); + updated = applyEdits( + updated, + modify( + updated, + ["disabled_providers"], + disabledProviders.filter((id) => id !== providerId), + { formattingOptions: { insertSpaces: true, tabSize: 2 } }, + ), + ); + return updated.endsWith("\n") ? updated : `${updated}\n`; +} + +function buildOpenCodeCloudProviderPatch( + provider: DenOrgLlmProviderConnection, + localProviderId: string, + previousProviderId?: string | null, +) { + const modelFields = [ + "family", + "release_date", + "attachment", + "reasoning", + "temperature", + "tool_call", + "interleaved", + "cost", + "limit", + "modalities", + "status", + "options", + "headers", + "provider", + "variants", + ] as const; + const models = Object.fromEntries( + provider.models.map((model) => [ + model.id, + { + id: model.id, + name: model.name, + ...Object.fromEntries( + modelFields.flatMap((key) => + model.config[key] === undefined ? [] : [[key, model.config[key]]], + ), + ), + }, + ]), + ); + const config: Record = { + id: provider.providerId, + name: provider.name, + env: getCloudProviderEnv(provider.providerConfig), + models, + }; + const npm = provider.providerConfig.npm; + const api = provider.providerConfig.api; + if (typeof npm === "string" && npm.trim()) config.npm = npm; + if (typeof api === "string" && api.trim()) config.api = api; + if (isRecord(provider.providerConfig.options)) config.options = provider.providerConfig.options; + if (Array.isArray(provider.providerConfig.whitelist)) { + config.whitelist = provider.providerConfig.whitelist.filter( + (entry): entry is string => typeof entry === "string" && Boolean(entry.trim()), + ); + } + if (Array.isArray(provider.providerConfig.blacklist)) { + config.blacklist = provider.providerConfig.blacklist.filter( + (entry): entry is string => typeof entry === "string" && Boolean(entry.trim()), + ); + } + return { + ...(previousProviderId && previousProviderId !== localProviderId + ? { [previousProviderId]: null } + : {}), + [localProviderId]: config, + }; +} + +export const openCodeProviderEngineAdapter: ProviderEngineAdapter = { + id: DEFAULT_ENGINE_ID, + configFileName: "opencode.json", + connect: openCodeConnection, + emptyProjectConfig: () => '{\n "$schema": "https://opencode.ai/config.json"\n}\n', + async readProjectConfig(target) { + if (target.canUseiPolloWorkServer && target.ipolloworkClient && target.workspaceId) { + return target.ipolloworkClient.readOpencodeConfigFile(target.workspaceId, "project"); + } + if (target.hasiPolloWorkTarget) { + throw new Error("iPolloWork server config API is unavailable for this workspace."); + } + if (target.isLocalWorkspace && isDesktopRuntime() && target.root) { + return readOpencodeConfig("project", target.root); + } + return null; + }, + async writeProjectConfig(target, content) { + if (target.canUseiPolloWorkServer && target.ipolloworkClient && target.workspaceId) { + const result = await target.ipolloworkClient.writeOpencodeConfigFile( + target.workspaceId, + "project", + content, + ); + if (!result.ok) { + throw new Error(result.stderr || result.stdout || "Failed to write opencode.jsonc"); + } + return true; + } + if (target.hasiPolloWorkTarget) { + throw new Error("iPolloWork server config API is unavailable for this workspace."); + } + if (target.isLocalWorkspace && isDesktopRuntime() && target.root) { + const result = await writeOpencodeConfig("project", target.root, content); + if (!result.ok) { + throw new Error(result.stderr || result.stdout || "Failed to write opencode.jsonc"); + } + return true; + } + return false; + }, + async patchRuntimeProviders(target, update) { + if (!target.canUseiPolloWorkServer || !target.ipolloworkClient || !target.workspaceId) { + throw new Error("iPolloWork server unavailable. Connect to manage cloud providers."); + } + await target.ipolloworkClient.patchConfig(target.workspaceId, { + opencode: { provider: update }, + }); + }, + async runtimeProviderIds(target) { + if (!target.canUseiPolloWorkServer || !target.ipolloworkClient || !target.workspaceId) return []; + const config = await target.ipolloworkClient.getConfig(target.workspaceId); + return isRecord(config.opencode) && isRecord(config.opencode.provider) + ? Object.keys(config.opencode.provider) + : []; + }, + projectProviderIds, + formatProjectProviderDisabledState(raw, providerId, disabled) { + const resolvedProviderId = providerId.trim(); + const config: unknown = parse(raw); + const currentDisabled = normalizeDisabledProviders( + isRecord(config) ? config.disabled_providers : undefined, + ); + const nextDisabled = disabled + ? [...currentDisabled.filter((entry) => entry !== resolvedProviderId), resolvedProviderId] + : currentDisabled.filter((entry) => entry !== resolvedProviderId); + const edits = modify(raw, ["disabled_providers"], nextDisabled.length ? nextDisabled : undefined, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + const updated = applyEdits(raw, edits); + return updated.endsWith("\n") ? updated : `${updated}\n`; + }, + formatProjectWithoutProvider: formatOpenCodeConfigWithoutProvider, + buildCloudProviderPatch: buildOpenCodeCloudProviderPatch, + buildCompatibleProviderPatch(profile) { + const api = profile.api?.trim(); + const baseURL = profile.baseURL?.trim(); + if (!api && !baseURL) { + throw new Error("Compatible provider API URL is required"); + } + return { + [profile.id]: { + npm: profile.npm ?? "@ai-sdk/openai-compatible", + name: profile.name, + ...(api ? { api } : { options: { baseURL } }), + models: profile.models, + }, + }; + }, +}; diff --git a/apps/app/src/react-app/domains/connections/provider-auth/provider-engine-adapter.ts b/apps/app/src/react-app/domains/connections/provider-auth/provider-engine-adapter.ts new file mode 100644 index 000000000..9740d7f38 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/provider-auth/provider-engine-adapter.ts @@ -0,0 +1,102 @@ +import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; + +import type { iPolloWorkServerClient } from "../../../../app/lib/ipollowork-server"; +import type { DenOrgLlmProviderConnection } from "../../../../app/lib/den"; +import type { ProviderListResponse } from "../../../../app/types"; +import { openCodeProviderEngineAdapter } from "./opencode-provider-engine-adapter"; + +export { openCodeProviderEngineAdapter } from "./opencode-provider-engine-adapter"; + +export type ProviderEngineAuthMethod = { + type: "oauth" | "api"; + label: string; +}; + +export type ProviderEngineAuthAuthorization = { + url: string; + method: "auto" | "code"; + instructions: string; +}; + +export type CompatibleProviderProfile = { + id: string; + name: string; + npm?: string; + api?: string; + baseURL?: string; + models: Record>; +}; + +export type ProviderEngineConfigTarget = { + ipolloworkClient: iPolloWorkServerClient | null; + workspaceId: string | null; + hasiPolloWorkTarget: boolean; + canUseiPolloWorkServer: boolean; + isLocalWorkspace: boolean; + root: string; +}; + +export type ProviderEngineConnection = { + listProviders(directory?: string): Promise; + listAuthMethods(): Promise>; + authorizeOAuth(providerId: string, methodIndex: number): Promise; + completeOAuth(providerId: string, methodIndex: number, code?: string): Promise; + setApiKey(providerId: string, apiKey: string): Promise; + removeCredentials(providerId: string): Promise; + readDisabledProviders(): Promise; + writeDisabledProviders(providerIds: string[]): Promise; + dispose(): Promise; + waitUntilHealthy(): Promise; +}; + +export interface ProviderEngineAdapter { + readonly id: string; + readonly configFileName: string; + connect(client: unknown): ProviderEngineConnection; + emptyProjectConfig(): string; + readProjectConfig(target: ProviderEngineConfigTarget): Promise<{ content?: string | null } | null>; + writeProjectConfig(target: ProviderEngineConfigTarget, content: string): Promise; + patchRuntimeProviders(target: ProviderEngineConfigTarget, update: Record): Promise; + runtimeProviderIds(target: ProviderEngineConfigTarget): Promise; + projectProviderIds(raw: string): string[]; + formatProjectProviderDisabledState(raw: string, providerId: string, disabled: boolean): string; + formatProjectWithoutProvider(raw: string, providerId: string, disabledProviders: string[]): string; + buildCloudProviderPatch( + provider: DenOrgLlmProviderConnection, + localProviderId: string, + previousProviderId?: string | null, + ): Record; + buildCompatibleProviderPatch(profile: CompatibleProviderProfile): Record; +} + +export class ProviderEngineAdapterRegistry { + readonly #adapters: ReadonlyMap; + + constructor(adapters: readonly ProviderEngineAdapter[]) { + const entries = new Map(); + for (const adapter of adapters) { + const id = adapter.id.trim(); + if (!id) throw new Error("Provider engine adapter ID is required"); + if (entries.has(id)) throw new Error(`Duplicate provider engine adapter: ${id}`); + entries.set(id, adapter); + } + this.#adapters = entries; + } + + get(id?: string | null): ProviderEngineAdapter { + const resolved = id?.trim() || DEFAULT_ENGINE_ID; + const adapter = this.#adapters.get(resolved); + if (!adapter) { + throw new Error(`Provider engine is not registered: ${resolved}`); + } + return adapter; + } + + ids(): string[] { + return [...this.#adapters.keys()]; + } +} + +export const providerEngineAdapters = new ProviderEngineAdapterRegistry([ + openCodeProviderEngineAdapter, +]); diff --git a/apps/app/src/react-app/domains/connections/provider-auth/store.ts b/apps/app/src/react-app/domains/connections/provider-auth/store.ts index 44957a52a..4909d202f 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/store.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/store.ts @@ -1,10 +1,6 @@ import { useSyncExternalStore } from "react"; -import { applyEdits, modify, parse } from "jsonc-parser"; -import type { - ProviderAuthAuthorization, - ProviderListResponse, -} from "@opencode-ai/sdk/v2/client"; +import { parse } from "jsonc-parser"; import { t } from "../../../../i18n"; import { @@ -13,18 +9,15 @@ import { type DenOrgLlmProvider, type DenOrgLlmProviderConnection, } from "../../../../app/lib/den"; -import { unwrap, waitForHealthy } from "../../../../app/lib/opencode"; import { - readOpencodeConfig, - writeOpencodeConfig, engineRestart, workspaceiPolloWorkRead, workspaceiPolloWorkWrite, } from "../../../../app/lib/desktop"; import { iPolloWorkServerError } from "../../../../app/lib/ipollowork-server"; import type { - Client, ProviderListItem, + ProviderListResponse, WorkspaceDisplay, } from "../../../../app/types"; import { isDesktopRuntime, safeStringify } from "../../../../app/utils"; @@ -59,8 +52,6 @@ import { type CloudImportedProvider, } from "../../../../app/cloud/import-state"; import { - buildRuntimeProviderPatch, - formatConfigWithoutCloudProvider, getCloudManagedProviderId, getCloudProviderEnv, getProviderModelIds, @@ -75,6 +66,12 @@ import { type DesktopAppRestrictionChecker, } from "../../../../app/cloud/desktop-app-restrictions"; import { TOKENSTAR_PROVIDER, tokenStarRuntimeModels } from "./tokenstar-provider"; +import { + providerEngineAdapters, + type ProviderEngineAuthAuthorization, + type ProviderEngineAuthMethod, + type ProviderEngineConfigTarget, +} from "./provider-engine-adapter"; type ProviderReturnFocusTarget = "none" | "composer"; type CloudProviderSyncReason = "sign_in" | "app_launch" | "interval" | "settings_cloud_opened"; @@ -128,7 +125,7 @@ export type ProviderAuthProvider = { export type ProviderOAuthStartResult = { methodIndex: number; - authorization: ProviderAuthAuthorization; + authorization: ProviderEngineAuthAuthorization; }; export type ProviderAuthStoreSnapshot = { @@ -144,7 +141,7 @@ export type ProviderAuthStoreSnapshot = { }; type CreateProviderAuthStoreOptions = { - client: () => Client | null; + client: () => unknown | null; providers: () => ProviderListItem[]; providerDefaults: () => Record; providerConnectedIds: () => string[]; @@ -160,7 +157,7 @@ type CreateProviderAuthStoreOptions = { setProviderDefaults: (value: Record) => void; setProviderConnectedIds: (value: string[]) => void; setDisabledProviders: (value: string[]) => void; - markOpencodeConfigReloadRequired: () => void; + markEngineConfigReloadRequired: (configFileName: string) => void; focusPromptSoon?: () => void; }; @@ -219,6 +216,21 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const getProviderAuthWorkerType = (): "local" | "remote" => options.selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local"; + const getProviderEngineAdapter = () => + providerEngineAdapters.get(options.selectedWorkspaceDisplay().engineId); + + const getProviderEngineConnection = () => { + const client = options.client(); + if (!client) { + throw new Error(t("providers.not_connected")); + } + return getProviderEngineAdapter().connect(client); + }; + + const markProviderEngineConfigReloadRequired = () => { + options.markEngineConfigReloadRequired(getProviderEngineAdapter().configFileName); + }; + const getProviderAuthProviders = (): ProviderAuthProvider[] => { const merged = new Map(); @@ -296,6 +308,18 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }; }; + const resolveProviderEngineConfigTarget = async ( + mode: "read" | "write", + ): Promise => { + const target = await resolveiPolloWorkConfigTarget(mode); + return { + ...target, + workspaceId: target.ipolloworkWorkspaceId, + isLocalWorkspace: options.selectedWorkspaceDisplay().workspaceType === "local", + root: options.selectedWorkspaceRoot().trim(), + }; + }; + const refreshSnapshot = () => { snapshot = { providerAuthModalOpen: state.providerAuthModalOpen, @@ -477,82 +501,28 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }; const readProjectConfigFile = async () => { - const root = options.selectedWorkspaceRoot().trim(); - const isLocalWorkspace = - options.selectedWorkspaceDisplay().workspaceType === "local"; - const { ipolloworkClient, ipolloworkWorkspaceId, hasiPolloWorkTarget, canUseiPolloWorkServer } = - await resolveiPolloWorkConfigTarget("read"); - - if (canUseiPolloWorkServer && ipolloworkClient && ipolloworkWorkspaceId) { - return await ipolloworkClient.readOpencodeConfigFile(ipolloworkWorkspaceId, "project"); - } - - if (hasiPolloWorkTarget) { - throw new Error("iPolloWork server config API is unavailable for this workspace."); - } - - if (isLocalWorkspace && isDesktopRuntime() && root) { - return await readOpencodeConfig("project", root); - } - - return null; + const target = await resolveProviderEngineConfigTarget("read"); + return getProviderEngineAdapter().readProjectConfig(target); }; const writeProjectConfigFile = async (content: string) => { - const root = options.selectedWorkspaceRoot().trim(); - const isLocalWorkspace = - options.selectedWorkspaceDisplay().workspaceType === "local"; - const { ipolloworkClient, ipolloworkWorkspaceId, hasiPolloWorkTarget, canUseiPolloWorkServer } = - await resolveiPolloWorkConfigTarget("write"); - - if (canUseiPolloWorkServer && ipolloworkClient && ipolloworkWorkspaceId) { - const result = await ipolloworkClient.writeOpencodeConfigFile( - ipolloworkWorkspaceId, - "project", - content, - ) as { ok: boolean; stderr?: string; stdout?: string }; - if (!result.ok) { - throw new Error(result.stderr || result.stdout || "Failed to write opencode.jsonc"); - } - return true; - } - - if (hasiPolloWorkTarget) { - throw new Error("iPolloWork server config API is unavailable for this workspace."); - } - - if (isLocalWorkspace && isDesktopRuntime() && root) { - const result = await writeOpencodeConfig("project", root, content) as { ok: boolean; stderr?: string; stdout?: string }; - if (!result.ok) { - throw new Error(result.stderr || result.stdout || "Failed to write opencode.jsonc"); - } - return true; - } - - return false; + const target = await resolveProviderEngineConfigTarget("write"); + return getProviderEngineAdapter().writeProjectConfig(target, content); }; /** * Upsert/delete cloud-managed provider entries in the workspace's runtime - * opencode config (server-side SQLite merged into OPENCODE_CONFIG). Record - * values upsert, explicit `null` deletes — per-key on the server, so there - * is no read-modify-write race and no edit of the user's opencode.jsonc. + * engine config. Record values upsert and explicit `null` deletes per key, + * so there is no read-modify-write race or edit of user-owned config. */ const patchRuntimeProviders = async (update: Record) => { - const { ipolloworkClient, ipolloworkWorkspaceId, canUseiPolloWorkServer } = - await resolveiPolloWorkConfigTarget("write"); - if (!canUseiPolloWorkServer || !ipolloworkClient || !ipolloworkWorkspaceId) { - throw new Error("iPolloWork server unavailable. Connect to manage cloud providers."); - } - await ipolloworkClient.patchConfig(ipolloworkWorkspaceId, { - opencode: { provider: update }, - }); + const target = await resolveProviderEngineConfigTarget("write"); + await getProviderEngineAdapter().patchRuntimeProviders(target, update); }; /** - * Best-effort migration: pre-runtime builds wrote cloud provider blocks - * into the project opencode.jsonc. Strip them so the runtime entry is the - * single owner (and stale blocks from older builds stop shadowing state). + * Best-effort migration: pre-runtime builds wrote cloud provider blocks into + * project config. Strip them so the runtime entry remains the single owner. */ const stripLegacyCloudProviderBlocks = async (providerIds: Array) => { const ids = [...new Set(providerIds.flatMap((id) => (id?.trim() ? [id.trim()] : [])))]; @@ -561,7 +531,11 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) await updateProjectConfigFile((raw) => { let next = raw; for (const id of ids) { - next = formatConfigWithoutCloudProvider(next, id, options.disabledProviders()); + next = getProviderEngineAdapter().formatProjectWithoutProvider( + next, + id, + options.disabledProviders(), + ); } return next; }); @@ -572,13 +546,12 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const updateProjectConfigFile = async ( updater: (raw: string) => string, - fallbackUpdate?: (config: Record) => Record, ) => { const configFile = await readProjectConfigFile() as { content?: string } | null; if (configFile) { const raw = configFile.content?.trim() ? configFile.content - : '{\n "$schema": "https://opencode.ai/config.json"\n}\n'; + : getProviderEngineAdapter().emptyProjectConfig(); const next = updater(raw); if (configsAreSemanticallyEqual(raw, next)) { return false; @@ -586,56 +559,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) await writeProjectConfigFile(next); return true; } - - if (!fallbackUpdate) { - return false; - } - - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } - const config = unwrap(await c.config.get()); - const next = fallbackUpdate(config); - await c.config.update({ config: next }); - return true; - }; - - const normalizeDisabledProviders = (value: unknown) => - Array.isArray(value) - ? [ - ...new Set( - value - .filter((entry): entry is string => typeof entry === "string") - .map((entry) => entry.trim()) - .filter(Boolean), - ), - ] - : []; - - const formatConfigWithProviderDisabledState = ( - raw: string, - providerId: string, - disabled: boolean, - ) => { - const resolvedProviderId = providerId.trim(); - let updated = raw.trim() - ? raw - : '{\n "$schema": "https://opencode.ai/config.json"\n}\n'; - const parsed = parse(updated) as Record | undefined; - const currentDisabled = normalizeDisabledProviders(parsed?.disabled_providers); - const nextDisabled = disabled - ? [...currentDisabled.filter((entry) => entry !== resolvedProviderId), resolvedProviderId] - : currentDisabled.filter((entry) => entry !== resolvedProviderId); - - const disabledEdits = modify( - updated, - ["disabled_providers"], - nextDisabled.length ? nextDisabled : undefined, - { formattingOptions: { insertSpaces: true, tabSize: 2 } }, - ); - updated = applyEdits(updated, disabledEdits); - return updated.endsWith("\n") ? updated : `${updated}\n`; + return false; }; const ensureProjectProviderDisabledState = async ( @@ -647,7 +571,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) throw new Error(t("providers.provider_id_required")); } - const currentDisabled = normalizeDisabledProviders(options.disabledProviders()); + const currentDisabled = [ + ...new Set(options.disabledProviders().map((entry) => entry.trim()).filter(Boolean)), + ]; const nextDisabled = disabled ? [...currentDisabled.filter((entry) => entry !== resolvedProviderId), resolvedProviderId] : currentDisabled.filter((entry) => entry !== resolvedProviderId); @@ -659,25 +585,20 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) return false; } - const updatedConfig = await updateProjectConfigFile( - (raw) => formatConfigWithProviderDisabledState(raw, resolvedProviderId, disabled), - (config) => { - const nextConfig = { ...config }; - if (nextDisabled.length) { - nextConfig.disabled_providers = nextDisabled; - } else { - delete nextConfig.disabled_providers; - } - return nextConfig; - }, + const updatedProjectConfig = await updateProjectConfigFile((raw) => + getProviderEngineAdapter().formatProjectProviderDisabledState( + raw, + resolvedProviderId, + disabled, + ), ); - if (!updatedConfig) { - throw new Error("Could not update opencode.jsonc for this workspace."); + if (!updatedProjectConfig) { + await getProviderEngineConnection().writeDisabledProviders(nextDisabled); } options.setDisabledProviders(nextDisabled); - options.markOpencodeConfigReloadRequired(); + markProviderEngineConfigReloadRequired(); refreshSnapshot(); emitChange(); return true; @@ -695,7 +616,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }; // Sweep all cloud-managed provider entries (keys matching /^lpr_/) from - // both the runtime config and opencode.jsonc, regardless of + // both runtime and project engine config, regardless of // importedCloudProviders state. Returns the list of provider IDs that were // removed so callers can also clear their auth credentials. const sweepOrphanCloudProvidersFromConfig = async (): Promise => { @@ -703,42 +624,34 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) // Runtime-managed orphans (`lpr_*` keys in the workspace runtime config). try { - const { ipolloworkClient, ipolloworkWorkspaceId, canUseiPolloWorkServer } = - await resolveiPolloWorkConfigTarget("write"); - if (canUseiPolloWorkServer && ipolloworkClient && ipolloworkWorkspaceId) { - const merged = await ipolloworkClient.getConfig(ipolloworkWorkspaceId); - const runtimeProvider = isRecord(merged.opencode) ? merged.opencode.provider : null; - const runtimeOrphans = isRecord(runtimeProvider) - ? Object.keys(runtimeProvider).filter((key) => /^lpr_/i.test(key)) - : []; - if (runtimeOrphans.length > 0) { - await patchRuntimeProviders( - Object.fromEntries(runtimeOrphans.map((id) => [id, null])), - ); - for (const id of runtimeOrphans) orphanIds.add(id); - } + const target = await resolveProviderEngineConfigTarget("write"); + const runtimeOrphans = (await getProviderEngineAdapter().runtimeProviderIds(target)) + .filter((key) => /^lpr_/i.test(key)); + if (runtimeOrphans.length > 0) { + await patchRuntimeProviders( + Object.fromEntries(runtimeOrphans.map((id) => [id, null])), + ); + for (const id of runtimeOrphans) orphanIds.add(id); } } catch { // Best-effort; the legacy file sweep below still runs. } - // Legacy `opencode.jsonc` blocks written by pre-runtime builds. + // Legacy project-config blocks written by pre-runtime builds. const configFile = await readProjectConfigFile().catch(() => null) as { content?: string } | null; if (configFile?.content?.trim()) { - const parsed = parse(configFile.content); - const providerSection = - parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record).provider - : null; - const fileOrphans = - providerSection && typeof providerSection === "object" && !Array.isArray(providerSection) - ? Object.keys(providerSection as Record).filter((key) => /^lpr_/i.test(key)) - : []; + const fileOrphans = getProviderEngineAdapter() + .projectProviderIds(configFile.content) + .filter((key) => /^lpr_/i.test(key)); if (fileOrphans.length > 0) { await updateProjectConfigFile((raw) => { let next = raw; for (const id of fileOrphans) { - next = formatConfigWithoutCloudProvider(next, id, options.disabledProviders()); + next = getProviderEngineAdapter().formatProjectWithoutProvider( + next, + id, + options.disabledProviders(), + ); } return next; }); @@ -787,19 +700,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) return; } - const parsed = parse(configFile.content); - const providerSection = - parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record).provider - : null; - if ( - providerSection && - typeof providerSection === "object" && - !Array.isArray(providerSection) && - localProviderId in (providerSection as Record) - ) { + if (getProviderEngineAdapter().projectProviderIds(configFile.content).includes(localProviderId)) { throw new Error( - `${localProviderId} already has a provider block in opencode.jsonc. Remove it before importing the cloud-managed version.`, + `${localProviderId} already has a provider block in ${getProviderEngineAdapter().configFileName}. Remove it before importing the cloud-managed version.`, ); } }; @@ -923,43 +826,8 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) emitChange(); }; - const assertNoClientError = (result: unknown) => { - const maybe = result as { error?: unknown } | null | undefined; - if (!maybe || maybe.error === undefined) return; - throw new Error(describeProviderError(maybe.error, t("providers.request_failed"))); - }; - const removeProviderAuthCredentials = async (providerId: string) => { - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } - - const authClient = c.auth as unknown as { - remove?: (options: { providerID: string }) => Promise; - set?: (options: { providerID: string; auth: unknown }) => Promise; - }; - if (typeof authClient.remove === "function") { - const result = await authClient.remove({ providerID: providerId }); - assertNoClientError(result); - return; - } - - const rawClient = (c as unknown as { - client?: { delete?: (options: { url: string }) => Promise }; - }).client; - if (rawClient?.delete) { - await rawClient.delete({ url: `/auth/${encodeURIComponent(providerId)}` }); - return; - } - - if (typeof authClient.set === "function") { - const result = await authClient.set({ providerID: providerId, auth: null }); - assertNoClientError(result); - return; - } - - throw new Error(t("providers.removal_unsupported")); + await getProviderEngineConnection().removeCredentials(providerId); }; const describeProviderError = (error: unknown, fallback: string) => { @@ -1041,7 +909,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }; const buildProviderAuthMethods = ( - methods: Record, + methods: Record, availableProviders: ProviderAuthProvider[], workerType: "local" | "remote", cloudProviders: DenOrgLlmProvider[], @@ -1139,16 +1007,12 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }; const loadProviderAuthMethods = async (workerType: "local" | "remote") => { - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } - const methods = unwrap(await c.provider.auth()); + const methods = await getProviderEngineConnection().listAuthMethods(); const cloudProviders = await refreshCloudOrgProviders().catch( () => [] as DenOrgLlmProvider[], ); return buildProviderAuthMethods( - methods as Record, + methods, getProviderAuthProviders(), workerType, cloudProviders, @@ -1160,10 +1024,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) methodIndex?: number, ): Promise { setStateField("providerAuthError", null); - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } + const connection = getProviderEngineConnection(); try { const cachedMethods = state.providerAuthMethods; const authMethods = Object.keys(cachedMethods).length @@ -1200,9 +1061,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) throw new Error(`${t("providers.not_oauth_flow_prefix")} ${resolved}.`); } - const auth = unwrap( - await c.provider.oauth.authorize({ providerID: resolved, method: oauthIndex }), - ); + const auth = await connection.authorizeOAuth(resolved, oauthIndex); return { methodIndex: oauthIndex, authorization: auth }; } catch (error) { const message = describeProviderError(error, t("providers.connect_failed")); @@ -1212,8 +1071,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } async function refreshProviders(optionsArg?: { dispose?: boolean }) { - const c = options.client(); - if (!c) return null; + const client = options.client(); + if (!client) return null; + const connection = getProviderEngineAdapter().connect(client); if (optionsArg?.dispose) { // Prefer the iPolloWork server engine reload: it disposes the engine AND @@ -1249,26 +1109,25 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) if (!reloaded) { try { - unwrap(await c.instance.dispose()); + await connection.dispose(); } catch { // ignore dispose failures and try reading current state anyway } } try { - await waitForHealthy(options.client() ?? c, { timeoutMs: 8000, pollMs: 250 }); + const activeClient = options.client() ?? client; + await getProviderEngineAdapter().connect(activeClient).waitUntilHealthy(); } catch { // ignore health wait failures and still attempt provider reads } } - const activeClient = options.client() ?? c; + const activeClient = options.client() ?? client; + const activeConnection = getProviderEngineAdapter().connect(activeClient); let disabledProviders = options.disabledProviders() ?? []; try { - const config = unwrap(await activeClient.config.get()); - disabledProviders = Array.isArray(config.disabled_providers) - ? config.disabled_providers - : []; + disabledProviders = await activeConnection.readDisabledProviders(); options.setDisabledProviders(disabledProviders); refreshSnapshot(); emitChange(); @@ -1280,6 +1139,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const updated = filterProviderList( await ensureProviderListQuery(getReactQueryClient(), { client: activeClient, + engineId: getProviderEngineAdapter().id, directory: options.selectedWorkspaceRoot(), force: Boolean(optionsArg?.dispose), }), @@ -1298,10 +1158,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) code?: string, ) { setStateField("providerAuthError", null); - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } + const connection = getProviderEngineConnection(); const resolved = providerId?.trim(); if (!resolved) { @@ -1337,12 +1194,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) try { const trimmedCode = code?.trim(); - const result = await c.provider.oauth.callback({ - providerID: resolved, - method: methodIndex, - code: trimmedCode || undefined, - }); - assertNoClientError(result); + await connection.completeOAuth(resolved, methodIndex, trimmedCode || undefined); const updated = await refreshProviders({ dispose: true }); const connectedNow = Array.isArray(updated?.connected) && updated.connected.includes(resolved); if (connectedNow) { @@ -1377,12 +1229,6 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) providerAuthBusy: true, providerAuthError: null, })); - const c = options.client(); - if (!c) { - setStateField("providerAuthBusy", false); - throw new Error(t("providers.not_connected")); - } - const trimmed = apiKey.trim(); if (!trimmed) { setStateField("providerAuthBusy", false); @@ -1394,22 +1240,23 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const isTokenStar = resolvedProviderId === TOKENSTAR_PROVIDER.providerId; try { + const connection = getProviderEngineConnection(); if (isQwen3Coder) { - await patchRuntimeProviders({ - [QWEN3_CODER_PROVIDER.providerId]: { - npm: "@ai-sdk/openai-compatible", + await patchRuntimeProviders( + getProviderEngineAdapter().buildCompatibleProviderPatch({ + id: QWEN3_CODER_PROVIDER.providerId, name: QWEN3_CODER_PROVIDER.name, - options: { baseURL: QWEN3_CODER_PROVIDER.baseURL }, + baseURL: QWEN3_CODER_PROVIDER.baseURL, models: { [QWEN3_CODER_PROVIDER.modelId]: { name: QWEN3_CODER_PROVIDER.modelName, }, }, - }, - }); + }), + ); try { await updateProjectConfigFile((raw) => - formatConfigWithoutCloudProvider( + getProviderEngineAdapter().formatProjectWithoutProvider( raw, QWEN3_CODER_PROVIDER.providerId, options.disabledProviders(), @@ -1427,17 +1274,17 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) .filter(Boolean), ), ]; - await patchRuntimeProviders({ - [TOKENSTAR_PROVIDER.providerId]: { - npm: "@ai-sdk/openai-compatible", + await patchRuntimeProviders( + getProviderEngineAdapter().buildCompatibleProviderPatch({ + id: TOKENSTAR_PROVIDER.providerId, name: TOKENSTAR_PROVIDER.name, - options: { baseURL: TOKENSTAR_PROVIDER.baseURL }, + baseURL: TOKENSTAR_PROVIDER.baseURL, models: tokenStarRuntimeModels(selectedModelIds), - }, - }); + }), + ); try { await updateProjectConfigFile((raw) => - formatConfigWithoutCloudProvider( + getProviderEngineAdapter().formatProjectWithoutProvider( raw, TOKENSTAR_PROVIDER.providerId, options.disabledProviders(), @@ -1447,7 +1294,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) // Runtime config owns this provider; legacy file cleanup is best-effort. } } - await c.auth.set({ providerID: providerId, auth: { type: "api", key: trimmed } }); + await connection.setApiKey(providerId, trimmed); if (isQwen3Coder || isTokenStar) { const syntheticProviderId = isTokenStar ? TOKENSTAR_PROVIDER.providerId @@ -1480,10 +1327,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) if (!optionsArg?.silent) { setStateField("providerAuthError", null); } - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } + const connection = getProviderEngineConnection(); const settings = readDenSettings(); const token = settings.authToken?.trim() ?? ""; @@ -1521,10 +1365,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) await ipolloworkClient.upsertUserEnv(envEntries); } if (primaryApiKey) { - await c.auth.set({ - providerID: localProviderId, - auth: { type: "api", key: primaryApiKey }, - }); + await connection.setApiKey(localProviderId, primaryApiKey); await mirroriPolloWorkModelsVoiceEnv(provider, primaryApiKey); } if (existingImported?.providerId && existingImported.providerId !== localProviderId) { @@ -1539,9 +1380,13 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } // Cloud providers are runtime-managed: upsert (and delete a renamed // predecessor) via the server's per-key provider merge instead of - // editing the user's opencode.jsonc. + // editing the user's engine config file. await patchRuntimeProviders( - buildRuntimeProviderPatch(provider, localProviderId, existingImported?.providerId ?? null), + getProviderEngineAdapter().buildCloudProviderPatch( + provider, + localProviderId, + existingImported?.providerId ?? null, + ), ); await stripLegacyCloudProviderBlocks([localProviderId, existingImported?.providerId]); @@ -1567,7 +1412,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) .disabledProviders() .filter((id) => id !== localProviderId && id !== existingImported?.providerId); options.setDisabledProviders(nextDisabledProviders); - options.markOpencodeConfigReloadRequired(); + markProviderEngineConfigReloadRequired(); await refreshProviders({ dispose: true }); refreshSnapshot(); emitChange(); @@ -1607,7 +1452,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } } // Runtime-managed: delete the provider entry via the server's per-key - // merge (`null` deletes), then strip any legacy opencode.jsonc block + // merge (`null` deletes), then strip any legacy project-config block // left by pre-runtime builds. Both are idempotent. await patchRuntimeProviders({ [imported.providerId]: null }); await stripLegacyCloudProviderBlocks([imported.providerId]); @@ -1619,7 +1464,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) options.setDisabledProviders( options.disabledProviders().filter((id) => id !== imported.providerId), ); - options.markOpencodeConfigReloadRequired(); + markProviderEngineConfigReloadRequired(); refreshSnapshot(); emitChange(); return `${t("providers.disconnected_prefix")} ${imported.name}`; @@ -1800,10 +1645,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) async function disconnectProvider(providerId: string) { setStateField("providerAuthError", null); - const c = options.client(); - if (!c) { - throw new Error(t("providers.not_connected")); - } + getProviderEngineConnection(); const resolved = providerId.trim(); if (!resolved) { @@ -1822,7 +1664,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) await patchRuntimeProviders({ [TOKENSTAR_PROVIDER.providerId]: null }); try { await updateProjectConfigFile((raw) => - formatConfigWithoutCloudProvider( + getProviderEngineAdapter().formatProjectWithoutProvider( raw, TOKENSTAR_PROVIDER.providerId, options.disabledProviders(), @@ -1955,7 +1797,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const importedProviders = { ...state.importedCloudProviders }; const importedIds = Object.keys(importedProviders); - // Best-effort cleanup: remove each cloud provider from opencode.jsonc + // Best-effort cleanup: remove each cloud provider from project config // BEFORE clearing state so removeCloudProviderInternal can find the records void (async () => { for (const cloudId of importedIds) { @@ -1966,7 +1808,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } } // Final sweep: remove any orphan `lpr_*` provider keys that remain - // in opencode.jsonc but weren't tracked in importedCloudProviders + // in project config but weren't tracked in importedCloudProviders // (e.g. from a previous failed cleanup or external edit). try { const orphans = await sweepOrphanCloudProvidersFromConfig(); @@ -1978,7 +1820,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } } if (orphans.length > 0) { - options.markOpencodeConfigReloadRequired(); + markProviderEngineConfigReloadRequired(); } } catch { // Ignore sweep failures during sign-out cleanup @@ -2020,7 +1862,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } catch {} } } - // Then: sweep any `lpr_*` keys that remain in opencode.jsonc + // Then: sweep any `lpr_*` keys that remain in project config try { const orphans = await sweepOrphanCloudProvidersFromConfig(); for (const providerId of orphans) { @@ -2029,7 +1871,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } catch {} } if (orphans.length > 0) { - options.markOpencodeConfigReloadRequired(); + markProviderEngineConfigReloadRequired(); } } catch {} mutateState((current) => ({ diff --git a/apps/app/src/react-app/domains/connections/provider-auth/use-session-provider-auth.ts b/apps/app/src/react-app/domains/connections/provider-auth/use-session-provider-auth.ts index ae42b6670..eda27e7a7 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/use-session-provider-auth.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/use-session-provider-auth.ts @@ -115,10 +115,10 @@ export function useSessionProviderAuth(input: UseSessionProviderAuthInput) { setProviderDefaults, setProviderConnectedIds, setDisabledProviders: setDisabledProviderIds, - markOpencodeConfigReloadRequired: () => { + markEngineConfigReloadRequired: (configFileName) => { markReloadRequired("config", { type: "config", - name: "opencode.json", + name: configFileName, action: "updated", }); }, @@ -175,7 +175,7 @@ export function useSessionProviderAuth(input: UseSessionProviderAuthInput) { }, [selectedWorkspaceEndpoint, store]); // Session is where forced sign-in lands. Keep org-managed cloud providers in - // sync here so sign-in applies opencode.json changes before Settings opens. + // sync here so sign-in applies engine-provider changes before Settings opens. useCloudProviderAutoSync(store.runCloudProviderSync); const snapshot = useProviderAuthStoreSnapshot(store); diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 394479d4f..255c8f9fb 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -3,7 +3,7 @@ import type { CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { UIMessage } from "ai"; import { useNavigate } from "react-router-dom"; -import { Code2, Ellipsis, Eye, FileText, Film, Globe, Image, LoaderCircle, Mic2, Palette, PanelRightClose, PanelRightOpen, Pencil, Presentation, Search, Settings2, Trash2, Upload, X, Zap } from "lucide-react"; +import { Code2, Ellipsis, Eye, FileText, Film, FolderOpen, Globe, Image, LoaderCircle, Mic2, Palette, PanelRightClose, PanelRightOpen, Pencil, Presentation, Search, Settings2, Trash2, Upload, X, Zap } from "lucide-react"; import { MAX_TEMPLATE_PACKAGE_BYTES, TEMPLATE_PACKAGE_FILE_ACCEPT, isPptxCompatibleTemplate, type PptxCompatibility, type TemplateCatalogItem, type TemplateCategory, type TemplateManifestV1, type TemplateSessionSnapshot, type TemplateSessionState, type TemplateValidationReport } from "@ipollowork/types/templates"; import { currentLocale, t } from "../../../../i18n"; @@ -23,16 +23,16 @@ import { } from "@/app/lib/enterprise-connections"; import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; import type { BootPhase } from "../../../../app/lib/startup-boot"; -import { openDesktopPath, revealDesktopItemInDir, saveFile, type WorkspaceInfo } from "../../../../app/lib/desktop"; +import { openDesktopPath, pickDirectory, revealDesktopItemInDir, saveFile, type WorkspaceInfo } from "../../../../app/lib/desktop"; import type { ComposerDraft, PendingPermission, PendingQuestion, ProviderListItem, TodoItem, - WorkspaceConnectionState, - WorkspaceSessionGroup, -} from "../../../../app/types"; + WorkspaceConnectionState, + ProjectSessionList, +} from "../../../../app/types"; import { ConversationOutputPanel, ConversationOutputTrigger } from "@/components/chat/artifact"; import { buildSessionMarkdown, sessionMarkdownFilename } from "@/components/chat/utils"; import { @@ -64,7 +64,6 @@ import { RenameSessionModal } from "../modals/rename-session-modal"; import { AppSidebar } from "../sidebar/app-sidebar"; import type { iPolloWorkSessionType, iPolloWorkTemplateId } from "../sidebar/app-sidebar-provider"; import { readSessionType, sessionTypeForTemplate, setSessionType } from "../sidebar/session-type"; -import { useSessionManagementStore, useActiveWorkspaceGroupId } from "../sidebar/session-management-store"; import { SessionSurface, type SessionSurfaceProps } from "../surface/session-surface"; import { replaceDesignSelectionToken } from "../surface/composer/composer-draft"; import { getComposerDraft, useComposerStateStore } from "../surface/composer-state-store"; @@ -163,9 +162,9 @@ export type SessionPageHistoryControls = { onRedo: () => void | Promise; }; -export type SessionPageSidebarProps = { - workspaceSessionGroups: WorkspaceSessionGroup[]; - selectedWorkspaceId: string; +export type SessionPageSidebarProps = { + projectSessionLists: ProjectSessionList[]; + selectedWorkspaceId: string; selectedSessionId: string | null; developerMode: boolean; sessionStatusById: Record; @@ -174,20 +173,23 @@ export type SessionPageSidebarProps = { newTaskDisabled: boolean; sidebarHydratedFromCache: boolean; startupPhase: BootPhase; - onOpenSession: (workspaceId: string, sessionId: string) => void; + onOpenSession: (workspaceId: string, sessionId: string) => void; + onSelectProject: (workspaceId: string) => Promise | boolean | void; + onCreateProject: (input: { name: string; folderPath: string }) => Promise | void; + onRenameProject: (workspaceId: string, name: string) => Promise | void; + onRevealProject: (workspaceId: string) => Promise | void; + onDeleteProject: (workspaceId: string) => Promise | void; onPrefetchSession?: (workspaceId: string, sessionId: string) => void; onCreateTaskInWorkspace: ( workspaceId: string, type?: iPolloWorkSessionType, templateId?: iPolloWorkTemplateId, templateScope?: WorkContextId, - groupId?: string | null, ) => Promise | string | null | void; onCreateTaskWithPrompt?: (workspaceId: string, prompt: string) => void; onCreateTemplateAuthoring: ( workspaceId: string, input: { category: TemplateCategory; pptxCompatibility?: PptxCompatibility }, - groupId?: string | null, ) => Promise | string | null | void; onRecoverWorkspace: (workspaceId: string) => Promise | boolean | void; onTestWorkspaceConnection: (workspaceId: string) => Promise | boolean | void; @@ -260,9 +262,9 @@ export type SessionPageProps = { onTerminalOpenChange?: (open: boolean) => void; }; -function getSidebarInitialLoading(props: SessionPageSidebarProps) { - if (props.workspaceSessionGroups.some((group) => group.sessions.length > 0)) { - return false; +function getSidebarInitialLoading(props: SessionPageSidebarProps) { + if (props.projectSessionLists.some((project) => project.sessions.length > 0)) { + return false; } if (props.sidebarHydratedFromCache) return false; if ( @@ -271,15 +273,15 @@ function getSidebarInitialLoading(props: SessionPageSidebarProps) { props.startupPhase !== "ready" ) { return true; - } - return props.workspaceSessionGroups.some( - (group) => group.status === "loading" || group.status === "idle", + } + return props.projectSessionLists.some( + (project) => project.status === "loading" || project.status === "idle", ); } - -function sessionTitleForId(groups: WorkspaceSessionGroup[], id: string | null | undefined) { - if (!id) return ""; - const sessionsById = new Map(groups.flatMap((group) => group.sessions.map((session) => [session.id, session] as const))); + +function sessionTitleForId(projects: ProjectSessionList[], id: string | null | undefined) { + if (!id) return ""; + const sessionsById = new Map(projects.flatMap((project) => project.sessions.map((session) => [session.id, session] as const))); const match = sessionsById.get(id); return match ? getDisplaySessionTitle(match.title) : ""; } @@ -590,8 +592,8 @@ export function SessionPage(props: SessionPageProps) { const hasArtifactTargets = artifactTargetCount > 0; const activeSidePanel = voiceSidePanelOpen ? "voice" : sessionSidePanel; const selectedSessionTitle = useMemo( - () => sessionTitleForId(props.sidebar.workspaceSessionGroups, props.selectedSessionId), - [props.selectedSessionId, props.sidebar.workspaceSessionGroups], + () => sessionTitleForId(props.sidebar.projectSessionLists, props.selectedSessionId), + [props.selectedSessionId, props.sidebar.projectSessionLists], ); const [templateSessionRevision, setTemplateSessionRevision] = useState(0); const [templateCatalog, setTemplateCatalog] = useState([]); @@ -631,7 +633,6 @@ export function SessionPage(props: SessionPageProps) { ? readSessionType(props.selectedSessionId) : null ), [props.selectedSessionId, sessionTypeRevision]); - const activeSessionGroupId = useActiveWorkspaceGroupId(props.selectedWorkspaceId); const isDesignSession = selectedSessionType === "design"; const isVideoSession = selectedSessionType === "video"; const currentVideoEntryPath = props.selectedSessionId && isVideoSession @@ -1224,17 +1225,18 @@ export function SessionPage(props: SessionPageProps) { const [renameTitle, setRenameTitle] = useState(""); const [renameBusy, setRenameBusy] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); - const [deleteBusy, setDeleteBusy] = useState(false); - const [sessionActionId, setSessionActionId] = useState(null); - const [createGroupOpen, setCreateGroupOpen] = useState(false); - const [createGroupLabel, setCreateGroupLabel] = useState(""); - const [createGroupWorkspaceId, setCreateGroupWorkspaceId] = useState(null); - const [renameGroupOpen, setRenameGroupOpen] = useState(false); - const [renameGroupLabel, setRenameGroupLabel] = useState(""); - const [renameGroupOriginalLabel, setRenameGroupOriginalLabel] = useState(""); - const [renameGroupTarget, setRenameGroupTarget] = useState<{ workspaceId: string; groupId: string } | null>(null); - const [removeGroupOpen, setRemoveGroupOpen] = useState(false); - const [removeGroupTarget, setRemoveGroupTarget] = useState<{ workspaceId: string; groupId: string; label: string } | null>(null); + const [deleteBusy, setDeleteBusy] = useState(false); + const [sessionActionId, setSessionActionId] = useState(null); + const [createProjectOpen, setCreateProjectOpen] = useState(false); + const [createProjectName, setCreateProjectName] = useState(""); + const [createProjectFolder, setCreateProjectFolder] = useState(""); + const [createProjectBusy, setCreateProjectBusy] = useState(false); + const [createProjectError, setCreateProjectError] = useState(null); + const [renameProjectId, setRenameProjectId] = useState(null); + const [renameProjectName, setRenameProjectName] = useState(""); + const [renameProjectBusy, setRenameProjectBusy] = useState(false); + const [deleteProjectId, setDeleteProjectId] = useState(null); + const [deleteProjectBusy, setDeleteProjectBusy] = useState(false); const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | null>(null); const preserveSidePanelOnPanelOpenRef = useRef(false); @@ -2229,10 +2231,10 @@ export function SessionPage(props: SessionPageProps) { toast.error(error instanceof Error ? error.message : t("prompt_templates.error_save")); } }, [canSavePromptTemplate, conversationMessages, props.selectedSessionId, selectedSessionTitle]); - const sessionActionTitle = useMemo( - () => sessionTitleForId(props.sidebar.workspaceSessionGroups, sessionActionId), - [props.sidebar.workspaceSessionGroups, sessionActionId], - ); + const sessionActionTitle = useMemo( + () => sessionTitleForId(props.sidebar.projectSessionLists, sessionActionId), + [props.sidebar.projectSessionLists, sessionActionId], + ); const showWorkspaceSetupEmptyState = props.workspaces.length === 0 && !props.selectedSessionId; const showNewConversationChrome = !props.selectedSessionId && !showWorkspaceSetupEmptyState; const showStartupSkeleton = @@ -2247,23 +2249,23 @@ export function SessionPage(props: SessionPageProps) { // Derive the main-pane error from the same data the sidebar uses so the two // panes can never disagree. We check (in priority order): // 1. selectedWorkspaceError (errorsByWorkspaceId[selectedWorkspaceId]) - // 2. workspaceConnectionStateById[selectedWorkspaceId].message (covers test/recover paths) - // 3. group.error from workspaceSessionGroups (the same source the sidebar reads) - const selectedWorkspaceConnectionMessage = (() => { + // 2. workspaceConnectionStateById[selectedWorkspaceId].message (covers test/recover paths) + // 3. Project load errors from the same source the sidebar reads. + const selectedWorkspaceConnectionMessage = (() => { const state = props.sidebar.workspaceConnectionStateById[props.selectedWorkspaceId]; if (state?.status === "error") return state.message?.trim() ?? ""; return ""; })(); - const selectedWorkspaceGroupError = (() => { - const group = props.sidebar.workspaceSessionGroups.find( - (item) => item.workspace.id === props.selectedWorkspaceId, - ); - return group?.error?.trim() ?? ""; + const selectedWorkspaceProjectError = (() => { + const project = props.sidebar.projectSessionLists.find( + (item) => item.workspace.id === props.selectedWorkspaceId, + ); + return project?.error?.trim() ?? ""; })(); const selectedWorkspaceErrorMessage = props.selectedWorkspaceError?.trim() || selectedWorkspaceConnectionMessage || - selectedWorkspaceGroupError || + selectedWorkspaceProjectError || ""; const showSelectedWorkspaceError = Boolean(selectedWorkspaceErrorMessage); const selectedWorkspaceErrorTitle = @@ -2333,9 +2335,9 @@ export function SessionPage(props: SessionPageProps) { const openRenameModal = (sessionId: string) => { if (!props.onRenameSession) return; - setSessionActionId(sessionId); - setRenameTitle(sessionTitleForId(props.sidebar.workspaceSessionGroups, sessionId)); - setRenameOpen(true); + setSessionActionId(sessionId); + setRenameTitle(sessionTitleForId(props.sidebar.projectSessionLists, sessionId)); + setRenameOpen(true); }; const submitRename = async () => { @@ -2351,7 +2353,7 @@ export function SessionPage(props: SessionPageProps) { } }; - const confirmDelete = async () => { + const confirmDelete = async () => { const sessionId = sessionActionId; if (!sessionId || !props.onDeleteSession) return; setDeleteBusy(true); @@ -2361,7 +2363,66 @@ export function SessionPage(props: SessionPageProps) { } finally { setDeleteBusy(false); } - }; + }; + + const openRenameProject = (workspaceId: string) => { + const project = props.workspaces.find((workspace) => workspace.id === workspaceId); + if (!project) return; + setRenameProjectId(workspaceId); + setRenameProjectName(project.displayName?.trim() || project.name?.trim() || ""); + }; + + const pickProjectFolder = async () => { + const selected = await pickDirectory({ title: t("projects.choose_folder") }); + if (typeof selected !== "string" || !selected.trim()) return; + const folderPath = selected.trim(); + setCreateProjectFolder(folderPath); + if (!createProjectName.trim()) { + const folderName = folderPath.replaceAll("\\", "/").split("/").filter(Boolean).pop() ?? ""; + setCreateProjectName(folderName); + } + }; + + const submitCreateProject = async () => { + const name = createProjectName.trim(); + const folderPath = createProjectFolder.trim(); + if (!name || !folderPath) return; + setCreateProjectBusy(true); + setCreateProjectError(null); + try { + await props.sidebar.onCreateProject({ name, folderPath }); + setCreateProjectOpen(false); + } catch (error) { + setCreateProjectError(error instanceof Error ? error.message : t("app.unknown_error")); + } finally { + setCreateProjectBusy(false); + } + }; + + const submitRenameProject = async () => { + const workspaceId = renameProjectId; + const name = renameProjectName.trim(); + if (!workspaceId || !name) return; + setRenameProjectBusy(true); + try { + await props.sidebar.onRenameProject(workspaceId, name); + setRenameProjectId(null); + } finally { + setRenameProjectBusy(false); + } + }; + + const confirmDeleteProject = async () => { + const workspaceId = deleteProjectId; + if (!workspaceId) return; + setDeleteProjectBusy(true); + try { + await props.sidebar.onDeleteProject(workspaceId); + setDeleteProjectId(null); + } finally { + setDeleteProjectBusy(false); + } + }; return (
@@ -2376,9 +2437,9 @@ export function SessionPage(props: SessionPageProps) { )} style={sidebarProviderStyle} > - { + setCreateProjectName(""); + setCreateProjectFolder(""); + setCreateProjectError(null); + setCreateProjectOpen(true); + } : undefined} + onOpenRenameProject={openRenameProject} + onRevealProject={(workspaceId) => void props.sidebar.onRevealProject(workspaceId)} + onOpenDeleteProject={setDeleteProjectId} onPrefetchSession={props.sidebar.onPrefetchSession} onCreateTaskInWorkspace={props.sidebar.onCreateTaskInWorkspace} onOpenRenameSession={props.onRenameSession ? openRenameModal : undefined} @@ -2398,21 +2469,6 @@ export function SessionPage(props: SessionPageProps) { onArchiveSession={props.onArchiveSession ? (sessionId, archived) => { void props.onArchiveSession?.(sessionId, archived); } : undefined} - onOpenCreateGroupModal={(workspaceId) => { - setCreateGroupWorkspaceId(workspaceId); - setCreateGroupLabel(""); - setCreateGroupOpen(true); - }} - onOpenRenameGroupModal={(workspaceId, groupId, label) => { - setRenameGroupTarget({ workspaceId, groupId }); - setRenameGroupLabel(label); - setRenameGroupOriginalLabel(label); - setRenameGroupOpen(true); - }} - onOpenRemoveGroupModal={(workspaceId, groupId, label) => { - setRemoveGroupTarget({ workspaceId, groupId, label }); - setRemoveGroupOpen(true); - }} onRecoverWorkspace={props.sidebar.onRecoverWorkspace} onTestWorkspaceConnection={props.sidebar.onTestWorkspaceConnection} onEditWorkspaceConnection={props.sidebar.onEditWorkspaceConnection} @@ -2720,7 +2776,6 @@ export function SessionPage(props: SessionPageProps) { type, templateId, PERSONAL_WORK_CONTEXT_ID, - activeSessionGroupId, )} onMaterializeTemplate={async (templateId, surface) => { if (!props.ipolloworkServerClient || !props.runtimeWorkspaceId || !props.selectedSessionId) return; @@ -2982,7 +3037,7 @@ export function SessionPage(props: SessionPageProps) { onExport={(template) => void exportPersonalTemplate(template)} onImport={importDesignTemplate} canCreate={props.selectedWorkspaceDisplay.workspaceType === "local"} - onCreate={(input) => props.sidebar.onCreateTemplateAuthoring(props.selectedWorkspaceId, input, activeSessionGroupId)} + onCreate={(input) => props.sidebar.onCreateTemplateAuthoring(props.selectedWorkspaceId, input)} onUse={(template) => { if (template.manifest.surface === "video" && props.selectedWorkspaceDisplay.workspaceType === "remote") { toast.error(t("templates.video_local_only")); @@ -2994,7 +3049,6 @@ export function SessionPage(props: SessionPageProps) { sessionTypeForTemplate(template.manifest), template.manifest.id, templateResourceScope, - activeSessionGroupId, ); }} /> : null} @@ -3015,7 +3069,7 @@ export function SessionPage(props: SessionPageProps) { /> ) : null} - {props.onDeleteSession ? ( + {props.onDeleteSession ? ( - ) : null} - - { if (!open) setCreateGroupOpen(false); }}> - - - {t("session_management.new_group")} - - setCreateGroupLabel(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && createGroupLabel.trim()) { - if (createGroupWorkspaceId) useSessionManagementStore.getState().createGroup(createGroupWorkspaceId, createGroupLabel.trim()); - setCreateGroupOpen(false); - } - }} - placeholder={t("session_management.new_group_prompt")} - /> - - }>{t("common.cancel")} - - - - - - { if (!open) setRenameGroupOpen(false); }}> - - - {t("session_management.rename_group")} - - setRenameGroupLabel(event.currentTarget.value)} - onKeyDown={(event) => { - const label = renameGroupLabel.trim(); - if (event.key !== "Enter" || !label || label === renameGroupOriginalLabel.trim() || !renameGroupTarget) return; - useSessionManagementStore.getState().renameGroup(renameGroupTarget.workspaceId, renameGroupTarget.groupId, label); - setRenameGroupOpen(false); - }} - placeholder={t("session_management.new_group_prompt")} - /> - - }>{t("common.cancel")} - - - - - - { - if (removeGroupTarget) { - useSessionManagementStore.getState().removeGroup(removeGroupTarget.workspaceId, removeGroupTarget.groupId); - } - setRemoveGroupOpen(false); - }} - onCancel={() => setRemoveGroupOpen(false)} - /> - - { if (!open && !createProjectBusy) setCreateProjectOpen(false); }}> + + + {t("projects.create")} + {t("projects.create_description")} + +
+ setCreateProjectName(event.currentTarget.value)} + placeholder={t("projects.name_placeholder")} + disabled={createProjectBusy} + /> + + {createProjectError ?

{createProjectError}

: null} +
+ + }>{t("common.cancel")} + + +
+ + + { if (!open && !renameProjectBusy) setRenameProjectId(null); }}> + + + {t("projects.rename")} + + setRenameProjectName(event.currentTarget.value)} + onKeyDown={(event) => { if (event.key === "Enter") void submitRenameProject(); }} + placeholder={t("projects.name_placeholder")} + disabled={renameProjectBusy} + /> + + }>{t("common.cancel")} + + + + + + void confirmDeleteProject()} + onCancel={() => { if (!deleteProjectBusy) setDeleteProjectId(null); }} + /> + + diff --git a/apps/app/src/react-app/domains/session/control/session-control-actions.ts b/apps/app/src/react-app/domains/session/control/session-control-actions.ts index 35ea56bca..5ee288998 100644 --- a/apps/app/src/react-app/domains/session/control/session-control-actions.ts +++ b/apps/app/src/react-app/domains/session/control/session-control-actions.ts @@ -1,12 +1,12 @@ /** @jsxImportSource react */ -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import type { createClient } from "../../../../app/lib/opencode"; import type { iPolloWorkServerClient, iPolloWorkWorkspaceInfo } from "../../../../app/lib/ipollowork-server"; import { setSessionArchived } from "../../../../app/lib/opencode-session"; import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; import { useControlAction, type iPolloWorkControlAction } from "../../../shell/control/control-provider"; -import { useSessionManagementStore } from "../sidebar/session-management-store"; +import { useSessionPinStore } from "../sidebar/session-pin-store"; type SessionLike = { id?: string; @@ -206,27 +206,10 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) { useControlAction(modelPickerControlAction); // --------------------------------------------------------------------------- - // Session management control actions (pin, archive, groups) + // Session management control actions (pin and archive) // --------------------------------------------------------------------------- - const store = useSessionManagementStore; - - /** Resolve a workspace ID from user input. Falls back to selectedWorkspaceId - * if the input is empty or doesn't match any known workspace (e.g. if the - * caller passes a display name instead of the actual ID). */ - const resolveWorkspaceId = useCallback((input: string | undefined): string | undefined => { - if (!input) return selectedWorkspaceId || undefined; - // Exact match on ID. - if (workspaces.some((ws) => ws.id === input)) return input; - // Fuzzy match on display name / path — return the first matching workspace ID. - const byName = workspaces.find( - (ws) => - (ws.displayName?.trim() || ws.name?.trim() || ws.path?.trim() || "").toLowerCase() === input.toLowerCase(), - ); - if (byName) return byName.id; - // Unknown — fall back to selected. - return selectedWorkspaceId || undefined; - }, [selectedWorkspaceId, workspaces]); + const store = useSessionPinStore; const pinControlAction = useMemo(() => ({ id: "session.pin", @@ -269,97 +252,4 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) { }), [opencodeClient, refreshRouteState, selectedWorkspaceRoot, sessionsByWorkspaceId, workspaces]); useControlAction(archiveControlAction); - const groupCreateControlAction = useMemo(() => ({ - id: "session.group.create", - label: "Create a session group", - description: "Create a new group (folder/separator) in the current workspace sidebar. Sessions can then be moved into it.", - sideEffect: "mutation", - requiresArgs: true, - args: [ - { name: "label", type: "string", required: true, description: "Group name (e.g. 'Done', 'In progress', 'Backlog')." }, - { name: "workspaceId", type: "string", required: false, description: "Workspace ID. Defaults to the selected workspace." }, - ], - disabled: !selectedWorkspaceId, - execute: (args) => { - const label = stringArg(args, "label"); - const wsId = resolveWorkspaceId(stringArg(args, "workspaceId")); - if (!label) return { ok: false, error: "label is required" }; - if (!wsId) return { ok: false, error: "No workspace selected" }; - store.getState().createGroup(wsId, label); - const created = store.getState().groupsByWorkspace[wsId]; - const newGroup = created?.groups[created.groups.length - 1]; - return { ok: true, workspaceId: wsId, label, groupId: newGroup?.id ?? null }; - }, - }), [resolveWorkspaceId]); - useControlAction(groupCreateControlAction); - - const groupMoveControlAction = useMemo(() => ({ - id: "session.group.move", - label: "Move a session to a group", - description: "Assign a session to a group (folder). Pass groupId=null or omit to remove from current group. Use session.group.list to see available groups.", - sideEffect: "mutation", - requiresArgs: true, - args: [ - { name: "sessionId", type: "string", required: true, description: "Session ID." }, - { name: "groupId", type: "string", required: false, description: "Group ID to move into. Omit or null to ungrouped." }, - { name: "workspaceId", type: "string", required: false, description: "Workspace ID. Defaults to session's workspace." }, - ], - execute: (args) => { - const sessionId = stringArg(args, "sessionId"); - const groupId = stringArg(args, "groupId") || null; - if (!sessionId) return { ok: false, error: "sessionId is required" }; - const targetWorkspace = findSessionWorkspace(workspaces, sessionsByWorkspaceId, sessionId); - const wsId = resolveWorkspaceId(stringArg(args, "workspaceId")) || targetWorkspace?.id; - if (!wsId) return { ok: false, error: "Could not determine workspace" }; - store.getState().assignGroup(wsId, sessionId, groupId); - return { ok: true, sessionId, groupId, workspaceId: wsId }; - }, - }), [resolveWorkspaceId, sessionsByWorkspaceId, workspaces]); - useControlAction(groupMoveControlAction); - - const groupRemoveControlAction = useMemo(() => ({ - id: "session.group.remove", - label: "Remove a session group", - description: "Remove a group from the workspace. Sessions in the group become ungrouped (not deleted).", - sideEffect: "mutation", - requiresConfirmation: true, - requiresArgs: true, - args: [ - { name: "groupId", type: "string", required: true, description: "Group ID to remove." }, - { name: "workspaceId", type: "string", required: false, description: "Workspace ID. Defaults to selected." }, - { name: "confirmed", type: "boolean", required: true, description: "Must be true." }, - ], - disabled: !selectedWorkspaceId, - execute: (args) => { - const groupId = stringArg(args, "groupId"); - const confirmed = booleanArg(args, "confirmed"); - const wsId = resolveWorkspaceId(stringArg(args, "workspaceId")); - if (!groupId) return { ok: false, error: "groupId is required" }; - if (!confirmed) return { ok: false, error: "Requires confirmed: true" }; - if (!wsId) return { ok: false, error: "No workspace selected" }; - store.getState().removeGroup(wsId, groupId); - return { ok: true, groupId, workspaceId: wsId }; - }, - }), [resolveWorkspaceId]); - useControlAction(groupRemoveControlAction); - - const groupListControlAction = useMemo(() => ({ - id: "session.group.list", - label: "List session groups", - description: "List all groups in a workspace with their IDs and labels.", - sideEffect: "none", - args: [{ name: "workspaceId", type: "string", required: false, description: "Workspace ID. Defaults to selected." }], - execute: (args) => { - const wsId = resolveWorkspaceId(stringArg(args, "workspaceId")); - if (!wsId) return { ok: false, error: "No workspace selected" }; - const state = store.getState().groupsByWorkspace[wsId]; - return { - ok: true, - workspaceId: wsId, - groups: (state?.groups ?? []).map((g) => ({ id: g.id, label: g.label })), - assignments: state?.assignments ?? {}, - }; - }, - }), [resolveWorkspaceId]); - useControlAction(groupListControlAction); } diff --git a/apps/app/src/react-app/domains/session/modals/use-model-picker.ts b/apps/app/src/react-app/domains/session/modals/use-model-picker.ts index 053828013..0609ca0ad 100644 --- a/apps/app/src/react-app/domains/session/modals/use-model-picker.ts +++ b/apps/app/src/react-app/domains/session/modals/use-model-picker.ts @@ -22,6 +22,7 @@ import { t } from "@/i18n"; export type UseModelPickerInput = { client: Client | null; + engineId?: string | null; baseUrl: string; workspaceRoot: string; /** Optional: surface option-load failures (settings shows a toast; the session route stays silent). */ @@ -29,7 +30,7 @@ export type UseModelPickerInput = { }; export function useModelPicker(input: UseModelPickerInput) { - const { client, baseUrl, workspaceRoot, onLoadError } = input; + const { client, engineId, baseUrl, workspaceRoot, onLoadError } = input; const checkDesktopRestriction = useCheckDesktopRestriction(); const [open, setOpen] = useState(false); @@ -83,6 +84,7 @@ export function useModelPicker(input: UseModelPickerInput) { try { const data = await ensureProviderListQuery(getReactQueryClient(), { client, + engineId, baseUrl, directory: workspaceRoot || undefined, }); @@ -131,7 +133,7 @@ export function useModelPicker(input: UseModelPickerInput) { return () => { cancelled = true; }; - }, [open, baseUrl, client, recentProviderIds, workspaceRoot]); + }, [open, baseUrl, client, engineId, recentProviderIds, workspaceRoot]); // Apply org-level restrictions (dev #1505) on top of the raw model list // so the picker never surfaces blocked options: diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx index f972fa9bc..45ba07f18 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx @@ -25,14 +25,10 @@ export type SidebarContextValue = { type?: iPolloWorkSessionType, templateId?: iPolloWorkTemplateId, templateScope?: WorkContextId, - groupId?: string | null, ) => Promise | string | null | void; onOpenRenameSession?: (sessionId: string) => void; onOpenDeleteSession?: (sessionId: string) => void; onArchiveSession?: (sessionId: string, archived: boolean) => void; - onOpenCreateGroupModal?: (workspaceId: string) => void; - onOpenRenameGroupModal?: (workspaceId: string, groupId: string, label: string) => void; - onOpenRemoveGroupModal?: (workspaceId: string, groupId: string, label: string) => void; onRecoverWorkspace: (workspaceId: string) => Promise | boolean | void; onTestWorkspaceConnection: (workspaceId: string) => Promise | boolean | void; onEditWorkspaceConnection: (workspaceId: string) => void; diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx index 388535f8f..8d8f49142 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx @@ -5,22 +5,22 @@ import { Archive, ArchiveRestore, ChevronRight, + Folder, + FolderOpen, FolderPlus, Loader2, Languages, Pencil, Pin, PinOff, - Plus, Trash2, RefreshCw, RotateCcw, Settings, HelpCircle, - Tag, UserRound, } from "lucide-react"; -import { LazyMotion, Reorder, domMax, m, useDragControls } from "motion/react"; +import { LazyMotion, domMax, m } from "motion/react"; import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; import { publicAssetUrl } from "../../../../app/lib/public-asset"; @@ -28,7 +28,7 @@ import type { WorkContextId } from "../../../../app/lib/work-context"; import { IPolloWorkDenHelpLink } from "../../workspace/ipollowork-den-help-link"; import type { WorkspaceConnectionState, - WorkspaceSessionGroup, + ProjectSessionList, } from "../../../../app/types"; import { isRemoteConnectionErrorMessage, @@ -64,9 +64,6 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { @@ -89,19 +86,17 @@ import { MAX_SESSIONS_PREVIEW, buildSessionTreeState, flattenSessionRows, + getRootSessions, isSessionArchived, isStreamingSessionStatus, partitionArchivedSessions, + workspaceLabel, } from "./utils"; import type { FlattenedSessionRow, SessionListItem, SessionTreeState } from "./utils"; import { - useSessionManagementStore, - useActiveWorkspaceGroupId, usePinnedSessionIds, - useSessionOrder, - useWorkspaceGroups, - type SessionGroupDefinition, -} from "./session-management-store"; + useSessionPinStore, +} from "./session-pin-store"; import { cn } from "@/lib/utils"; import { MarbleAvatar } from "../../../design-system/marble-avatar"; import { getSessionActivityStatusLabel, type SessionActivityStatus } from "../status/session-activity-store"; @@ -158,9 +153,6 @@ function SessionStatusIndicator({ className, status, isStreaming, isActive }: Se } function useCanManageSession() { - // Pin and group actions come from the Zustand store (always available). - // Rename/delete/archive depend on wired callbacks but the menu should - // always render so pin/group remain accessible. return true; } @@ -182,9 +174,7 @@ type SessionMenuContentProps = { function SessionMenuContent({ variant, sessionId, workspaceId, isPinned, isArchived }: SessionMenuContentProps) { const ctx = useSidebarContext(); - const { groups, assignments } = useWorkspaceGroups(workspaceId); - const store = useSessionManagementStore; - const assignedGroupId = assignments[sessionId] ?? null; + const store = useSessionPinStore; if (variant === "dropdown") { return ( @@ -199,48 +189,6 @@ function SessionMenuContent({ variant, sessionId, workspaceId, isPinned, isArchi {t("workspace_list.rename_session")} ) : null} - - - - {t("session_management.move_to_group")} - - - {groups.length === 0 ? ( - ctx.onOpenCreateGroupModal?.(workspaceId)}> - - {t("session_management.no_groups_yet")} - - - - - - ) : ( - <> - store.getState().assignGroup(workspaceId, sessionId, null)} - disabled={!assignedGroupId} - > - {t("session_management.no_group")} - - - {groups.map((group) => ( - store.getState().assignGroup(workspaceId, sessionId, group.id)} - disabled={assignedGroupId === group.id} - > - {group.label} - - ))} - - ctx.onOpenCreateGroupModal?.(workspaceId)}> - - {t("session_management.new_group")} - - - )} - - {ctx.onArchiveSession ? ( ctx.onArchiveSession?.(sessionId, !isArchived)}> {isArchived ? : } @@ -272,48 +220,6 @@ function SessionMenuContent({ variant, sessionId, workspaceId, isPinned, isArchi {t("workspace_list.rename_session")} ) : null} - - - - {t("session_management.move_to_group")} - - - {groups.length === 0 ? ( - ctx.onOpenCreateGroupModal?.(workspaceId)}> - - {t("session_management.no_groups_yet")} - - - - - - ) : ( - <> - store.getState().assignGroup(workspaceId, sessionId, null)} - disabled={!assignedGroupId} - > - {t("session_management.no_group")} - - - {groups.map((group) => ( - store.getState().assignGroup(workspaceId, sessionId, group.id)} - disabled={assignedGroupId === group.id} - > - {group.label} - - ))} - - ctx.onOpenCreateGroupModal?.(workspaceId)}> - - {t("session_management.new_group")} - - - )} - - {ctx.onArchiveSession ? ( ctx.onArchiveSession?.(sessionId, !isArchived)}> {isArchived ? : } @@ -477,7 +383,7 @@ function RemoteConnectionIssueCard(props: { } export type AppSidebarProps = { - workspaceSessionGroups: WorkspaceSessionGroup[]; + projectSessionLists: ProjectSessionList[]; showInitialLoading?: boolean; selectedWorkspaceId: string; developerMode: boolean; @@ -488,20 +394,21 @@ export type AppSidebarProps = { workspaceConnectionStateById: Record; newTaskDisabled: boolean; onOpenSession: (workspaceId: string, sessionId: string) => void; + onSelectProject: (workspaceId: string) => Promise | boolean | void; + onOpenCreateProject?: () => void; + onOpenRenameProject: (workspaceId: string) => void; + onRevealProject: (workspaceId: string) => void; + onOpenDeleteProject: (workspaceId: string) => void; onPrefetchSession?: (workspaceId: string, sessionId: string) => void; onCreateTaskInWorkspace: ( workspaceId: string, type?: iPolloWorkSessionType, templateId?: iPolloWorkTemplateId, templateScope?: WorkContextId, - groupId?: string | null, ) => Promise | string | null | void; onOpenRenameSession?: (sessionId: string) => void; onOpenDeleteSession?: (sessionId: string) => void; onArchiveSession?: (sessionId: string, archived: boolean) => void; - onOpenCreateGroupModal?: (workspaceId: string) => void; - onOpenRenameGroupModal?: (workspaceId: string, groupId: string, label: string) => void; - onOpenRemoveGroupModal?: (workspaceId: string, groupId: string, label: string) => void; onRecoverWorkspace: (workspaceId: string) => Promise | boolean | void; onTestWorkspaceConnection: (workspaceId: string) => Promise | boolean | void; onEditWorkspaceConnection: (workspaceId: string) => void; @@ -526,7 +433,7 @@ export type AppSidebarProps = { const primarySidebarActionClassName = "h-8 gap-1 rounded-[8px] px-1 py-0 text-sm font-normal leading-4 text-sidebar-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground focus-visible:ring-1"; function useSessionTree( - sessions: WorkspaceSessionGroup["sessions"], + sessions: ProjectSessionList["sessions"], sessionStatusById: Record | undefined, ) { return React.useMemo( @@ -572,28 +479,28 @@ export function AppSidebar(props: AppSidebarProps) { const workspaceId = props.selectedWorkspaceId.trim(); if (!workspaceId) return; - const group = props.workspaceSessionGroups.find( + const project = props.projectSessionLists.find( (entry) => entry.workspace.id === workspaceId, ); - if (!group?.sessions.length) return; + if (!project?.sessions.length) return; const selectedId = props.selectedSessionId?.trim() ?? ""; const selectedIndex = selectedId - ? group.sessions.findIndex((session) => session.id === selectedId) + ? project.sessions.findIndex((session) => session.id === selectedId) : -1; const start = selectedIndex >= 0 ? Math.max(0, selectedIndex - 2) : 0; const end = selectedIndex >= 0 - ? Math.min(group.sessions.length, selectedIndex + 3) - : Math.min(group.sessions.length, 4); + ? Math.min(project.sessions.length, selectedIndex + 3) + : Math.min(project.sessions.length, 4); - group.sessions.slice(start, end).forEach((session) => { + project.sessions.slice(start, end).forEach((session) => { props.onPrefetchSession?.(workspaceId, session.id); }); }, [ props.onPrefetchSession, props.selectedSessionId, props.selectedWorkspaceId, - props.workspaceSessionGroups, + props.projectSessionLists, ]); const contextValue: SidebarContextValue = { @@ -612,9 +519,6 @@ export function AppSidebar(props: AppSidebarProps) { onOpenRenameSession: props.onOpenRenameSession, onOpenDeleteSession: props.onOpenDeleteSession, onArchiveSession: props.onArchiveSession, - onOpenCreateGroupModal: props.onOpenCreateGroupModal, - onOpenRenameGroupModal: props.onOpenRenameGroupModal, - onOpenRemoveGroupModal: props.onOpenRemoveGroupModal, onRecoverWorkspace: props.onRecoverWorkspace, onTestWorkspaceConnection: props.onTestWorkspaceConnection, onEditWorkspaceConnection: props.onEditWorkspaceConnection, @@ -629,23 +533,13 @@ export function AppSidebar(props: AppSidebarProps) { ? activeEnterprise.logoUrl ?? DEFAULT_BRAND_LOGO_URL : brandLogoUrl ?? shellConfig.brandLogoDataUrl ?? DEFAULT_BRAND_LOGO_URL; const effectiveBrandAppName = activeEnterprise?.name ?? brandAppName; - const activeSessionGroupId = useActiveWorkspaceGroupId(props.selectedWorkspaceId); - const sidebarWorkspaceSessionGroups = React.useMemo(() => { - const selectedGroup = props.workspaceSessionGroups.find( - (entry) => entry.workspace.id === props.selectedWorkspaceId, - ); - return selectedGroup - ? [selectedGroup] - : props.workspaceSessionGroups.slice(0, 1); - }, [props.selectedWorkspaceId, props.workspaceSessionGroups]); - return ( - +
- - props.onCreateTaskInWorkspace(props.selectedWorkspaceId, "work", undefined, undefined, activeSessionGroupId)} - > - - {t("session.new_task")} - + +
+ props.onCreateTaskInWorkspace(props.selectedWorkspaceId)} + > + + {t("session.new_task")} + +
+ {props.onOpenCreateProject ? ( + + ) : null}
- {sidebarWorkspaceSessionGroups.map((group) => ( - + {t("projects.title")} +
+ {props.projectSessionLists.map((project) => ( + 1} /> ))} @@ -828,20 +744,32 @@ export function AppSidebar(props: AppSidebarProps) { ); } -type WorkspaceSidebarGroupProps = { +type ProjectSidebarContentProps = { className: string; - group: WorkspaceSessionGroup; + project: ProjectSessionList; showInitialLoading?: boolean; + onSelectProject: (workspaceId: string) => Promise | boolean | void; + onOpenRenameProject: (workspaceId: string) => void; + onRevealProject: (workspaceId: string) => void; + onOpenDeleteProject: (workspaceId: string) => void; + canRemoveProject: boolean; }; -function WorkspaceSidebarGroup({ +function ProjectSidebarContent({ className, - group, + project, showInitialLoading, -}: WorkspaceSidebarGroupProps) { + onSelectProject, + onOpenRenameProject, + onRevealProject, + onOpenDeleteProject, + canRemoveProject, +}: ProjectSidebarContentProps) { const ctx = useSidebarContext(); - const workspace = group.workspace; - const tree = useSessionTree(group.sessions, ctx.sessionStatusById); + const workspace = project.workspace; + const isSelectedProject = ctx.selectedWorkspaceId === workspace.id; + const [projectExpanded, setProjectExpanded] = React.useState(true); + const tree = useSessionTree(project.sessions, ctx.sessionStatusById); const forcedExpandedSessionIds = React.useMemo( () => new Set( @@ -860,625 +788,239 @@ function WorkspaceSidebarGroup({ const isConnectionActionBusy = isConnecting || connectionState.status === "connecting"; const isRemoteWorkspace = isRemoteConnectionWorkspace(workspace); const canRecover = isRemoteWorkspace && connectionState.status === "error"; - const taskLoadError = getWorkspaceTaskLoadErrorDisplay(workspace, group.error); + const taskLoadError = getWorkspaceTaskLoadErrorDisplay(workspace, project.error); const connectionIssueMessage = connectionState.status === "error" ? connectionState.message?.trim() || taskLoadError.message - : group.error?.trim() || taskLoadError.message; + : project.error?.trim() || taskLoadError.message; const showRemoteConnectionIssue = (isRemoteWorkspace || isRemoteConnectionErrorMessage(connectionIssueMessage)) && Boolean(connectionIssueMessage) && - (connectionState.status === "error" || group.status === "error"); + (connectionState.status === "error" || project.status === "error"); const pinnedIds = usePinnedSessionIds(); - const orderIds = useSessionOrder(workspace.id); - const { groups: wsGroups, assignments: wsAssignments } = useWorkspaceGroups(workspace.id); - const activeSessionGroupId = useActiveWorkspaceGroupId(workspace.id); - const store = useSessionManagementStore; - const { archived: archivedSessions } = React.useMemo( - () => partitionArchivedSessions(group.sessions), - [group.sessions], + const { active: activeSessions, archived: archivedSessions } = React.useMemo( + () => partitionArchivedSessions(project.sessions), + [project.sessions], ); + const projectRootSessions = getRootSessions(activeSessions); + const projectStatuses = activeSessions.map((session) => ctx.sessionStatusById?.[session.id]); + const projectStreamingStatus = projectStatuses.find(isStreamingSessionStatus); + const projectActivityStatus = projectStreamingStatus ?? projectStatuses.find((status) => status && status !== "idle"); + const projectIsStreaming = projectRootSessions.some((session) => tree.streamingIds.has(session.id)); + const projectIsActive = projectRootSessions.some((session) => tree.activeIds.has(session.id)); + const [sessionPreviewCount, setSessionPreviewCount] = React.useState(MAX_SESSIONS_PREVIEW); const sessionRows = flattenSessionRows( - group.sessions, - Number.MAX_SAFE_INTEGER, + project.sessions, + sessionPreviewCount, tree, ctx.expandedSessionIds, forcedExpandedSessionIds, pinnedIds, - orderIds, ); + const remainingSessionCount = Math.max(0, getRootSessions(activeSessions).length - sessionPreviewCount); const [archivedExpanded, setArchivedExpanded] = React.useState(false); + const createConversationInProject = async () => { + if (!isSelectedProject) { + const selected = await onSelectProject(workspace.id); + if (selected === false) return; + } + await ctx.onCreateTaskInWorkspace(workspace.id); + }; return ( - + - - } open className="group/collapsible"> - - - {showRemoteConnectionIssue ? ( - { - void Promise.resolve(ctx.onRecoverWorkspace(workspace.id)); - }} - onTest={() => { - void Promise.resolve(ctx.onTestWorkspaceConnection(workspace.id)); - }} - onEdit={() => { - ctx.onEditWorkspaceConnection(workspace.id); - }} + + + + + {!projectExpanded ? ( + + ) : null} +
+ + + + + + } /> - ) : showInitialLoading || (group.status === "loading" && group.sessions.length === 0) ? ( - - - {t("workspace.loading_tasks")} - - - ) : group.status !== "error" ? ( - <> - + onOpenRenameProject(workspace.id)}> + + {t("projects.rename")} + + onRevealProject(workspace.id)} + disabled={workspace.workspaceType === "remote"} + > + + {t("projects.show_in_folder")} + + + onOpenDeleteProject(workspace.id)} + disabled={!canRemoveProject} + > + + {t("projects.remove")} + + + +
+
+
+ + + {showRemoteConnectionIssue ? ( + { + void Promise.resolve(ctx.onRecoverWorkspace(workspace.id)); + }} + onTest={() => { + void Promise.resolve(ctx.onTestWorkspaceConnection(workspace.id)); + }} + onEdit={() => { + ctx.onEditWorkspaceConnection(workspace.id); + }} + /> + ) : showInitialLoading || (project.status === "loading" && project.sessions.length === 0) ? ( + + + {t("workspace.loading_tasks")} + + + ) : project.status !== "error" ? ( + <> + setSessionPreviewCount((count) => count + MAX_SESSIONS_PREVIEW)} + /> + {archivedSessions.length > 0 ? ( + setArchivedExpanded((value) => !value)} /> - {archivedSessions.length > 0 ? ( - setArchivedExpanded((value) => !value)} - /> - ) : null} - - ) : ( - - - {taskLoadError.message} - - - )} - - -
-
+ ) : null} + + ) : ( + + + {taskLoadError.message} + + + )} + + +
); } -const SESSION_DRAG_TYPE = "application/x-ipollowork-session-id"; -const UNGROUPED_GROUP_ID = "__ipollowork_ungrouped"; - -function SidebarSectionIcon({ name }: { name: "plus" | "ellipsis" }) { - return ( - - ); -} - -function SidebarSectionHeader({ label, expanded, onToggle, onAdd, addDisabled, addLabel, menuLabel, menu }: { - label: string; - expanded: boolean; - onToggle: () => void; - onAdd?: () => void; - addDisabled?: boolean; - addLabel?: string; - menuLabel: string; - menu: React.ReactNode; -}) { - return ( -
- -
- {onAdd ? ( - - ) : null} - - - - - } - /> - - {menu} - - -
-
- ); -} - -function SessionGroupSeparator({ label, expanded, isActive, onToggle, onAdd, addDisabled, onRename, onRemove, onTitlePointerDown }: { - label: string; - expanded: boolean; - isActive: boolean; - onToggle: () => void; - onAdd: () => void; - addDisabled?: boolean; - onRename: () => void; - onRemove?: () => void; - onTitlePointerDown?: React.PointerEventHandler; -}) { - return ( -
- -
- - - - - - } - /> - - - - {t("session_management.rename_group")} - - {onRemove ? ( - <> - - - - {t("session_management.remove_group")} - - - ) : null} - - -
-
- ); -} - -/** Drop zone wrapping a group's header + sessions. Dropping a session anywhere in the zone assigns it to this group. */ -function GroupDropZone({ groupId, workspaceId, children }: { - groupId: string | null; - workspaceId: string; - children: React.ReactNode; -}) { - const [dragOver, setDragOver] = React.useState(false); - const store = useSessionManagementStore; - - return ( -
{ - if (e.dataTransfer.types.includes(SESSION_DRAG_TYPE)) { - e.preventDefault(); - setDragOver(true); - } - }} - onDragLeave={(e) => { - // Only clear when leaving this container, not when entering a child. - if (!e.currentTarget.contains(e.relatedTarget as Node)) { - setDragOver(false); - } - }} - onDrop={(e) => { - setDragOver(false); - const sessionId = e.dataTransfer.getData(SESSION_DRAG_TYPE); - if (sessionId) { - store.getState().assignGroup(workspaceId, sessionId, groupId); - } - }} - > - {children} -
- ); -} - -/** Renders sessions partitioned by group. Empty groups always show. Ungrouped sessions render at the end. */ -function GroupedSessionList({ sessionRows, groups, assignments, pinnedIds, tree, workspaceId, forcedExpandedSessionIds, store, activeSessionGroupId }: { +function ConversationList({ sessionRows, pinnedIds, tree, workspaceId, forcedExpandedSessionIds, remainingCount, onShowMore }: { sessionRows: FlattenedSessionRow[]; - groups: SessionGroupDefinition[]; - assignments: Record; pinnedIds: Set; tree: SessionTreeState; workspaceId: string; forcedExpandedSessionIds: Set; - store: typeof useSessionManagementStore; - activeSessionGroupId: string | null; + remainingCount: number; + onShowMore: () => void; }) { - const ctx = useSidebarContext(); - const [programExpanded, setProgramExpanded] = React.useState(true); - const [previewCountByGroup, setPreviewCountByGroup] = React.useState>({}); - - const groupPreviewCount = (groupId: string) => - previewCountByGroup[groupId] ?? MAX_SESSIONS_PREVIEW; - - const showMoreInGroup = React.useCallback((groupId: string, totalCount: number) => { - setPreviewCountByGroup((current) => ({ - ...current, - [groupId]: Math.min( - (current[groupId] ?? MAX_SESSIONS_PREVIEW) + MAX_SESSIONS_PREVIEW, - totalCount, - ), - })); - }, []); - - // Partition root rows into per-group buckets + ungrouped. - const rootRowsByGroup = new Map(); - const ungroupedRows: FlattenedSessionRow[] = []; - // Child rows follow their parent regardless of group. - const childrenByParent = new Map(); - const rowIndexById = new Map(sessionRows.map((row, index) => [row.session.id, index])); - const compareByMostRecent = (left: FlattenedSessionRow, right: FlattenedSessionRow) => - (right.session.time?.updated ?? right.session.time?.created ?? 0) - - (left.session.time?.updated ?? left.session.time?.created ?? 0); - - for (const row of sessionRows) { - if (row.depth > 0) { - const rowIndex = rowIndexById.get(row.session.id); - if (rowIndex === undefined) continue; - let parentId: string | null = null; - for (let j = rowIndex - 1; j >= 0; j--) { - if (sessionRows[j].depth < row.depth) { parentId = sessionRows[j].session.id; break; } - } - if (parentId) { - const kids = childrenByParent.get(parentId) ?? []; - kids.push(row); - childrenByParent.set(parentId, kids); - } - continue; - } - const groupId = assignments[row.session.id]; - if (groupId && groups.some((g) => g.id === groupId)) { - const bucket = rootRowsByGroup.get(groupId) ?? []; - bucket.push(row); - rootRowsByGroup.set(groupId, bucket); - } else { - ungroupedRows.push(row); - } - } - - for (const rows of rootRowsByGroup.values()) rows.sort(compareByMostRecent); - for (const rows of childrenByParent.values()) rows.sort(compareByMostRecent); - ungroupedRows.sort(compareByMostRecent); - - const renderRow = (row: FlattenedSessionRow, groupContextId?: string | null) => ( - - - {(childrenByParent.get(row.session.id) ?? []).map((child) => renderRow(child, groupContextId))} - - ); - - const renderGroup = (group: SessionGroupDefinition) => { - const rows = rootRowsByGroup.get(group.id) ?? []; - const expanded = !(store.getState().groupsByWorkspace[workspaceId]?.collapsedGroupIds ?? []).includes(group.id); - const limit = groupPreviewCount(group.id); - - return ( - showMoreInGroup(group.id, rows.length)} - /> - ); - }; - - const ungroupedExpanded = !(store.getState().groupsByWorkspace[workspaceId]?.collapsedGroupIds ?? []).includes(UNGROUPED_GROUP_ID); - const ungroupedLimit = groupPreviewCount(UNGROUPED_GROUP_ID); - const visibleUngroupedRows = ungroupedRows.slice(0, ungroupedLimit); - const ungroupedRemaining = Math.max(0, ungroupedRows.length - ungroupedLimit); - const visibleUngroupedRootIds = visibleUngroupedRows.map((r) => r.session.id); - const reorderUngrouped = React.useCallback((orderedIds: string[]) => { - const allRootIds = sessionRows.filter((row) => row.depth === 0).map((row) => row.session.id); - const ungroupedSet = new Set(ungroupedRows.map((row) => row.session.id)); - let nextIndex = 0; - const fullOrder = allRootIds.map((id) => ungroupedSet.has(id) ? orderedIds[nextIndex++] : id); - store.getState().reorderSessions(workspaceId, fullOrder); - }, [sessionRows, store, ungroupedRows, workspaceId]); - - const sortUngrouped = React.useCallback((mode: "recent" | "name") => { - const orderedIds = [...ungroupedRows] - .sort((left, right) => mode === "recent" - ? (right.session.time?.updated ?? right.session.time?.created ?? 0) - (left.session.time?.updated ?? left.session.time?.created ?? 0) - : getDisplaySessionTitle(left.session.title).localeCompare(getDisplaySessionTitle(right.session.title))) - .map((row) => row.session.id); - reorderUngrouped(orderedIds); - }, [reorderUngrouped, ungroupedRows]); - - const sortGroupsByName = React.useCallback(() => { - const groupIds = [...groups] - .sort((left, right) => left.label.localeCompare(right.label)) - .map((group) => group.id); - store.getState().reorderGroups(workspaceId, groupIds); - }, [groups, store, workspaceId]); - return ( -
-
- setProgramExpanded((value) => !value)} - onAdd={() => ctx.onOpenCreateGroupModal?.(workspaceId)} - addDisabled={!ctx.onOpenCreateGroupModal} - addLabel={t("session_management.new_group")} - menuLabel={t("session_management.program_actions")} - menu={( - <> - ctx.onOpenCreateGroupModal?.(workspaceId)} disabled={!ctx.onOpenCreateGroupModal}> - - {t("session_management.new_group")} - - - {t("session_management.sort_by_name")} - - - )} +
+ {sessionRows.map((row) => ( + - {programExpanded ? ( - group.id)} - onReorder={(ids) => store.getState().reorderGroups(workspaceId, ids)} - className="flex flex-col gap-1" + ))} + {remainingCount > 0 ? ( + + - {groups.map(renderGroup)} - - ) : null} -
- {ungroupedRows.length > 0 ? ( -
- store.getState().toggleGroupExpanded(workspaceId, UNGROUPED_GROUP_ID)} - menuLabel={t("session_management.ungrouped_actions")} - menu={( - <> - sortUngrouped("recent")} disabled={ungroupedRows.length < 2}> - {t("session_management.sort_by_recent")} - - sortUngrouped("name")} disabled={ungroupedRows.length < 2}> - {t("session_management.sort_by_name")} - - - )} - /> - {ungroupedExpanded ? ( - - { - const visibleSet = new Set(ids); - const fullUngrouped = [...ids, ...ungroupedRows.map((r) => r.session.id).filter((id) => !visibleSet.has(id))]; - reorderUngrouped(fullUngrouped); - }} - className="flex flex-col gap-1" - > - {visibleUngroupedRows.map((row) => ( - - - {(childrenByParent.get(row.session.id) ?? []).map((child) => renderRow(child, null))} - - ))} - - {ungroupedRemaining > 0 ? ( - - showMoreInGroup(UNGROUPED_GROUP_ID, ungroupedRows.length)} - > - - {t("workspace_list.show_more", { count: Math.min(MAX_SESSIONS_PREVIEW, ungroupedRemaining) })} - - - - ) : null} - - ) : null} -
+ + {t("workspace_list.show_more", { count: Math.min(MAX_SESSIONS_PREVIEW, remainingCount) })} + + + ) : null}
); } -function SessionGroupSection({ group, rows, expanded, workspaceId, store, activeSessionGroupId, renderRow, previewCount, onShowMore }: { - group: SessionGroupDefinition; - rows: FlattenedSessionRow[]; - expanded: boolean; - workspaceId: string; - store: typeof useSessionManagementStore; - activeSessionGroupId: string | null; - renderRow: (row: FlattenedSessionRow, groupContextId?: string | null) => React.ReactNode; - previewCount: number; - onShowMore: () => void; -}) { - const ctx = useSidebarContext(); - const dragControls = useDragControls(); - const visibleRows = rows.slice(0, previewCount); - const remaining = Math.max(0, rows.length - previewCount); - - return ( - generated.replace(/ ?scale[XY]?\([^)]*\)/g, "")} - > - - store.getState().toggleGroupExpanded(workspaceId, group.id)} - className="group/session-group flex flex-col gap-1" - > - { - store.getState().setActiveGroup(workspaceId, group.id); - store.getState().toggleGroupExpanded(workspaceId, group.id); - }} - onAdd={() => { - store.getState().setActiveGroup(workspaceId, group.id); - void Promise.resolve(ctx.onCreateTaskInWorkspace(workspaceId, "work")).then((sessionId) => { - if (sessionId) store.getState().assignGroup(workspaceId, sessionId, group.id); - }); - }} - addDisabled={ctx.newTaskDisabled} - onRename={() => ctx.onOpenRenameGroupModal?.(workspaceId, group.id, group.label)} - onRemove={() => ctx.onOpenRemoveGroupModal?.(workspaceId, group.id, group.label)} - onTitlePointerDown={(event) => dragControls.start(event)} - /> - - {visibleRows.length > 0 - ? ( - <> - {visibleRows.map((row) => renderRow(row, group.id))} - {remaining > 0 ? ( - - - - {t("workspace_list.show_more", { count: Math.min(MAX_SESSIONS_PREVIEW, remaining) })} - - - - ) : null} - - ) - : ( - - - {t("session_management.empty_group")} - - - )} - - - - - ); -} - function PinnedIndicator({ isPinned }: { isPinned: boolean }) { if (!isPinned) return null; return ( @@ -1496,9 +1038,6 @@ type SessionMenuItemProps = { workspaceId: string; forcedExpandedSessionIds: Set; isPinned?: boolean; - draggable?: boolean; - rootIndent?: "group" | "recent"; - groupContextId?: string | null; }; function SessionMenuItem({ @@ -1508,9 +1047,6 @@ function SessionMenuItem({ forcedExpandedSessionIds, depth, isPinned = false, - draggable = false, - rootIndent, - groupContextId, }: SessionMenuItemProps) { const ctx = useSidebarContext(); const isSelected = ctx.selectedSessionId === session.id; @@ -1521,17 +1057,12 @@ function SessionMenuItem({ const isSessionActive = tree.activeIds.has(session.id); const isSessionStreaming = tree.streamingIds.has(session.id) || isStreamingSessionStatus(sessionActivityStatus); const isArchived = isSessionArchived(session); - const rowPadding = depth > 0 ? "ps-10" : rootIndent === "group" ? "ps-5" : rootIndent === "recent" ? "ps-1" : "ps-8"; - const trailingActionPosition = rootIndent === "recent" ? "right-1" : "right-2"; - const nestedActionPosition = rootIndent === "recent" ? "right-8" : "right-9"; + const rowPadding = depth > 0 ? "ps-10" : "ps-1"; + const trailingActionPosition = "right-1"; + const nestedActionPosition = "right-8"; const sessionFontWeight = ctx.language === "zh" ? "font-medium" : "font-normal"; const openSession = () => { - const store = useSessionManagementStore.getState(); - const groupId = groupContextId !== undefined - ? groupContextId - : store.groupsByWorkspace[workspaceId]?.assignments[session.id] ?? null; - store.setActiveGroup(workspaceId, groupId); ctx.onOpenSession(workspaceId, session.id); }; @@ -1543,21 +1074,13 @@ function SessionMenuItem({ ctx.onPrefetchSession?.(workspaceId, session.id); }; - const dragProps = depth === 0 ? { - draggable: true, - onDragStart: (e: React.DragEvent) => { - e.dataTransfer.setData(SESSION_DRAG_TYPE, session.id); - e.dataTransfer.effectAllowed = "move"; - }, - } : {}; - const item = hasChildren ? ( ctx.toggleSessionExpanded(session.id)} className="group/session-collapsible" > - + ) : ( - + ); - if (!draggable) return item; - - return ( - generated.replace(/ ?scale[XY]?\([^)]*\)/g, "")} - > - {item} - - ); + return item; } type ArchivedSessionsSectionProps = { diff --git a/apps/app/src/react-app/domains/session/sidebar/session-management-store.ts b/apps/app/src/react-app/domains/session/sidebar/session-management-store.ts deleted file mode 100644 index 94d0e1ed8..000000000 --- a/apps/app/src/react-app/domains/session/sidebar/session-management-store.ts +++ /dev/null @@ -1,399 +0,0 @@ -/** - * Zustand store for session management primitives (pin, manual order, custom - * group mirror + expanded state). Persisted to localStorage via - * zustand/middleware/persist. - * - * Archive is server-side (OpenCode session.time.archived). Session groups are - * synced server-side; this store keeps a local optimistic mirror plus UI-only - * collapsed state. Components import it directly with selectors, avoiding - * context/prop drilling. - */ -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; - -export type SessionGroupDefinition = { - id: string; - label: string; -}; - -export type WorkspaceGroupState = { - groups: SessionGroupDefinition[]; - assignments: Record; - collapsedGroupIds?: string[]; -}; - -export type SessionGroupServerState = { - groups: SessionGroupDefinition[]; - assignments: Record; -}; - -type SessionGroupSyncHandler = { - createGroup: (workspaceId: string, group: SessionGroupDefinition) => Promise; - assignGroup: (workspaceId: string, sessionId: string, groupId: string | null) => Promise; - reorderGroups: (workspaceId: string, groupIds: string[]) => Promise; - renameGroup: (workspaceId: string, groupId: string, label: string) => Promise; - removeGroup: (workspaceId: string, groupId: string) => Promise; -}; - -type SessionGroupMutationSuccess = { - version: number; - state: SessionGroupServerState; -}; - -type SessionGroupDeferredServerSync = { - version: number; - state: SessionGroupServerState; -}; - -type SessionGroupSyncStatus = { - nextMutationVersion: number; - nextServerSyncVersion: number; - pendingMutations: number; - lastAppliedMutationVersion: number; - latestMutationSuccess?: SessionGroupMutationSuccess; - deferredServerSync?: SessionGroupDeferredServerSync; -}; - -type SessionManagementState = { - pinnedIds: string[]; - orderByWorkspace: Record; - groupsByWorkspace: Record; - activeGroupByWorkspace: Record; -}; - -type SessionManagementActions = { - togglePin: (sessionId: string) => void; - reorderSessions: (workspaceId: string, sessionIds: string[]) => void; - assignGroup: (workspaceId: string, sessionId: string, groupId: string | null) => void; - setActiveGroup: (workspaceId: string, groupId: string | null) => void; - createGroup: (workspaceId: string, label: string) => void; - reorderGroups: (workspaceId: string, groupIds: string[]) => void; - renameGroup: (workspaceId: string, groupId: string, label: string) => void; - toggleGroupExpanded: (workspaceId: string, groupId: string) => void; - replaceWorkspaceGroups: (workspaceId: string, state: SessionGroupServerState) => void; - /** Remove a group definition. Sessions assigned to it become ungrouped. */ - removeGroup: (workspaceId: string, groupId: string) => void; - forgetWorkspace: (workspaceId: string) => void; -}; - -type SessionManagementStore = SessionManagementState & SessionManagementActions; - -const EMPTY_GROUP_STATE: WorkspaceGroupState = { groups: [], assignments: {} }; - -let sessionGroupSyncHandler: SessionGroupSyncHandler | null = null; -const sessionGroupSyncStatusByWorkspace: Record = {}; - -export function setSessionGroupSyncHandler(handler: SessionGroupSyncHandler | null): void { - sessionGroupSyncHandler = handler; -} - -function syncStatus(workspaceId: string): SessionGroupSyncStatus { - sessionGroupSyncStatusByWorkspace[workspaceId] ??= { - nextMutationVersion: 0, - nextServerSyncVersion: 0, - pendingMutations: 0, - lastAppliedMutationVersion: 0, - }; - return sessionGroupSyncStatusByWorkspace[workspaceId]; -} - -function beginSessionGroupMutation(workspaceId: string): number { - const status = syncStatus(workspaceId); - status.nextMutationVersion += 1; - status.pendingMutations += 1; - return status.nextMutationVersion; -} - -export function beginSessionGroupServerSync(workspaceId: string): number { - const status = syncStatus(workspaceId); - status.nextServerSyncVersion += 1; - return status.nextServerSyncVersion; -} - -export function applySessionGroupServerState( - workspaceId: string, - state: SessionGroupServerState | null, - version: number, -): void { - if (!state) return; - const status = syncStatus(workspaceId); - if (version !== status.nextServerSyncVersion) return; - if (status.pendingMutations > 0) { - status.deferredServerSync = { version, state }; - return; - } - useSessionManagementStore.getState().replaceWorkspaceGroups(workspaceId, state); -} - -function completeSessionGroupMutation( - workspaceId: string, - version: number, - state: SessionGroupServerState | null, -): void { - const status = syncStatus(workspaceId); - status.pendingMutations = Math.max(0, status.pendingMutations - 1); - if (state && version > (status.latestMutationSuccess?.version ?? 0)) { - status.latestMutationSuccess = { version, state }; - } - if (status.pendingMutations > 0) return; - - const success = status.latestMutationSuccess; - if (success && success.version > status.lastAppliedMutationVersion) { - status.lastAppliedMutationVersion = success.version; - status.deferredServerSync = undefined; - useSessionManagementStore.getState().replaceWorkspaceGroups(workspaceId, success.state); - return; - } - - const deferred = status.deferredServerSync; - if (!deferred) return; - status.deferredServerSync = undefined; - useSessionManagementStore.getState().replaceWorkspaceGroups(workspaceId, deferred.state); -} - -function reportSyncError(error: unknown): void { - console.warn("[session-groups] server sync failed", error); -} - -function syncServerState(request: Promise | undefined, workspaceId: string): void { - if (!request) return; - const version = beginSessionGroupMutation(workspaceId); - void request - .then((state) => completeSessionGroupMutation(workspaceId, version, state)) - .catch((error) => { - completeSessionGroupMutation(workspaceId, version, null); - reportSyncError(error); - }); -} - -export const useSessionManagementStore = create()( - persist( - (set) => ({ - pinnedIds: [], - orderByWorkspace: {}, - groupsByWorkspace: {}, - activeGroupByWorkspace: {}, - - togglePin: (sessionId) => - set((state) => { - const idx = state.pinnedIds.indexOf(sessionId); - return { - pinnedIds: - idx >= 0 - ? state.pinnedIds.filter((id) => id !== sessionId) - : [...state.pinnedIds, sessionId], - }; - }), - - reorderSessions: (workspaceId, sessionIds) => - set((state) => ({ - orderByWorkspace: { ...state.orderByWorkspace, [workspaceId]: sessionIds }, - })), - - assignGroup: (workspaceId, sessionId, groupId) => { - set((state) => { - const ws = state.groupsByWorkspace[workspaceId] ?? EMPTY_GROUP_STATE; - const assignments = { ...ws.assignments }; - if (groupId && ws.groups.some((g) => g.id === groupId)) { - assignments[sessionId] = groupId; - } else { - delete assignments[sessionId]; - } - return { - groupsByWorkspace: { - ...state.groupsByWorkspace, - [workspaceId]: { ...ws, assignments }, - }, - }; - }); - syncServerState( - sessionGroupSyncHandler?.assignGroup(workspaceId, sessionId, groupId), - workspaceId, - ); - }, - - setActiveGroup: (workspaceId, groupId) => - set((state) => { - const ws = state.groupsByWorkspace[workspaceId] ?? EMPTY_GROUP_STATE; - if (groupId && ws.groups.some((group) => group.id === groupId)) { - return { - activeGroupByWorkspace: { - ...state.activeGroupByWorkspace, - [workspaceId]: groupId, - }, - }; - } - const { [workspaceId]: _active, ...rest } = state.activeGroupByWorkspace; - return { activeGroupByWorkspace: rest }; - }), - - createGroup: (workspaceId, label) => { - const id = `grp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; - const group = { id, label }; - set((state) => { - const ws = state.groupsByWorkspace[workspaceId] ?? EMPTY_GROUP_STATE; - return { - groupsByWorkspace: { - ...state.groupsByWorkspace, - [workspaceId]: { ...ws, groups: [...ws.groups, group] }, - }, - }; - }); - syncServerState(sessionGroupSyncHandler?.createGroup(workspaceId, group), workspaceId); - }, - - reorderGroups: (workspaceId, groupIds) => { - set((state) => { - const ws = state.groupsByWorkspace[workspaceId] ?? EMPTY_GROUP_STATE; - const byId = new Map(ws.groups.map((group) => [group.id, group])); - const used = new Set(); - const groups: SessionGroupDefinition[] = []; - for (const id of groupIds) { - const group = byId.get(id); - if (!group || used.has(id)) continue; - groups.push(group); - used.add(id); - } - for (const group of ws.groups) { - if (!used.has(group.id)) groups.push(group); - } - return { - groupsByWorkspace: { - ...state.groupsByWorkspace, - [workspaceId]: { ...ws, groups }, - }, - }; - }); - syncServerState(sessionGroupSyncHandler?.reorderGroups(workspaceId, groupIds), workspaceId); - }, - - renameGroup: (workspaceId, groupId, label) => { - const nextLabel = label.trim(); - if (!nextLabel) return; - set((state) => { - const ws = state.groupsByWorkspace[workspaceId] ?? EMPTY_GROUP_STATE; - return { - groupsByWorkspace: { - ...state.groupsByWorkspace, - [workspaceId]: { - ...ws, - groups: ws.groups.map((group) => group.id === groupId ? { ...group, label: nextLabel } : group), - }, - }, - }; - }); - syncServerState(sessionGroupSyncHandler?.renameGroup(workspaceId, groupId, nextLabel), workspaceId); - }, - - toggleGroupExpanded: (workspaceId, groupId) => - set((state) => { - const ws = state.groupsByWorkspace[workspaceId] ?? EMPTY_GROUP_STATE; - const collapsed = new Set(ws.collapsedGroupIds ?? []); - if (collapsed.has(groupId)) { - collapsed.delete(groupId); - } else { - collapsed.add(groupId); - } - return { - groupsByWorkspace: { - ...state.groupsByWorkspace, - [workspaceId]: { ...ws, collapsedGroupIds: [...collapsed] }, - }, - }; - }), - - replaceWorkspaceGroups: (workspaceId, serverState) => - set((state) => { - const ws = state.groupsByWorkspace[workspaceId] ?? EMPTY_GROUP_STATE; - const knownGroupIds = new Set(serverState.groups.map((group) => group.id)); - const collapsedGroupIds = (ws.collapsedGroupIds ?? []).filter( - (id) => id === "__ipollowork_ungrouped" || knownGroupIds.has(id), - ); - const activeGroupByWorkspace = knownGroupIds.has(state.activeGroupByWorkspace[workspaceId] ?? "") - ? state.activeGroupByWorkspace - : Object.fromEntries( - Object.entries(state.activeGroupByWorkspace).filter(([id]) => id !== workspaceId), - ); - return { - groupsByWorkspace: { - ...state.groupsByWorkspace, - [workspaceId]: { - groups: serverState.groups, - assignments: serverState.assignments, - collapsedGroupIds, - }, - }, - activeGroupByWorkspace, - }; - }), - - removeGroup: (workspaceId, groupId) => { - set((state) => { - const ws = state.groupsByWorkspace[workspaceId] ?? EMPTY_GROUP_STATE; - const groups = ws.groups.filter((g) => g.id !== groupId); - // Unassign sessions that belonged to the removed group. - const assignments: Record = {}; - for (const [sid, gid] of Object.entries(ws.assignments)) { - if (gid !== groupId) assignments[sid] = gid; - } - const collapsedGroupIds = (ws.collapsedGroupIds ?? []).filter((id) => id !== groupId); - return { - groupsByWorkspace: { - ...state.groupsByWorkspace, - [workspaceId]: { groups, assignments, collapsedGroupIds }, - }, - activeGroupByWorkspace: state.activeGroupByWorkspace[workspaceId] === groupId - ? Object.fromEntries( - Object.entries(state.activeGroupByWorkspace).filter(([id]) => id !== workspaceId), - ) - : state.activeGroupByWorkspace, - }; - }); - syncServerState(sessionGroupSyncHandler?.removeGroup(workspaceId, groupId), workspaceId); - }, - - forgetWorkspace: (workspaceId) => - set((state) => { - const { [workspaceId]: _o, ...orderRest } = state.orderByWorkspace; - const { [workspaceId]: _g, ...groupsRest } = state.groupsByWorkspace; - const { [workspaceId]: _active, ...activeRest } = state.activeGroupByWorkspace; - return { orderByWorkspace: orderRest, groupsByWorkspace: groupsRest, activeGroupByWorkspace: activeRest }; - }), - }), - { - name: "ipollowork.react.sessionManagement", - storage: createJSONStorage(() => localStorage), - }, - ), -); - -// --------------------------------------------------------------------------- -// Selectors (keep render-stable references via shallow selectors) -// --------------------------------------------------------------------------- - -const EMPTY_PINNED = new Set(); -const EMPTY_ORDER: string[] = []; - -export function usePinnedSessionIds(): Set { - const ids = useSessionManagementStore((s) => s.pinnedIds); - // Derive a Set; reference-stable when the array is the same object. - // Consumers only need membership checks so Set is ideal. - return ids.length ? new Set(ids) : EMPTY_PINNED; -} - -export function useSessionOrder(workspaceId: string): string[] { - return useSessionManagementStore((s) => s.orderByWorkspace[workspaceId] ?? EMPTY_ORDER); -} - -export function useWorkspaceGroups(workspaceId: string): WorkspaceGroupState { - return useSessionManagementStore((s) => s.groupsByWorkspace[workspaceId] ?? EMPTY_GROUP_STATE); -} - -export function useActiveWorkspaceGroupId(workspaceId: string): string | null { - return useSessionManagementStore((s) => { - const groupId = s.activeGroupByWorkspace[workspaceId] ?? null; - if (!groupId) return null; - const ws = s.groupsByWorkspace[workspaceId] ?? EMPTY_GROUP_STATE; - return ws.groups.some((group) => group.id === groupId) ? groupId : null; - }); -} diff --git a/apps/app/src/react-app/domains/session/sidebar/session-pin-store.ts b/apps/app/src/react-app/domains/session/sidebar/session-pin-store.ts new file mode 100644 index 000000000..df9eac5d5 --- /dev/null +++ b/apps/app/src/react-app/domains/session/sidebar/session-pin-store.ts @@ -0,0 +1,32 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +type SessionPinStore = { + pinnedIds: string[]; + togglePin: (sessionId: string) => void; +}; + +export const useSessionPinStore = create()( + persist( + (set) => ({ + pinnedIds: [], + togglePin: (sessionId) => + set((state) => ({ + pinnedIds: state.pinnedIds.includes(sessionId) + ? state.pinnedIds.filter((id) => id !== sessionId) + : [...state.pinnedIds, sessionId], + })), + }), + { + name: "ipollowork.react.sessionPins", + storage: createJSONStorage(() => localStorage), + }, + ), +); + +const EMPTY_PINNED = new Set(); + +export function usePinnedSessionIds(): Set { + const ids = useSessionPinStore((state) => state.pinnedIds); + return ids.length ? new Set(ids) : EMPTY_PINNED; +} diff --git a/apps/app/src/react-app/domains/session/sidebar/utils.ts b/apps/app/src/react-app/domains/session/sidebar/utils.ts index 26306a433..46b7aff39 100644 --- a/apps/app/src/react-app/domains/session/sidebar/utils.ts +++ b/apps/app/src/react-app/domains/session/sidebar/utils.ts @@ -1,11 +1,11 @@ import type { WorkspaceInfo } from "../../../../app/lib/desktop"; -import type { WorkspaceSessionGroup } from "../../../../app/types"; +import type { ProjectSessionList } from "../../../../app/types"; import { isSandboxWorkspace } from "../../../../app/utils"; import { t } from "../../../../i18n"; export const MAX_SESSIONS_PREVIEW = 6; -export type SessionListItem = WorkspaceSessionGroup["sessions"][number]; +export type SessionListItem = ProjectSessionList["sessions"][number]; export type FlattenedSessionRow = { session: SessionListItem; depth: number }; export type SessionTreeState = { childrenByParent: Map; @@ -32,7 +32,7 @@ const normalizeSessionParentID = (session: SessionListItem) => { return parentID || ""; }; -export const getRootSessions = (sessions: WorkspaceSessionGroup["sessions"]) => { +export const getRootSessions = (sessions: ProjectSessionList["sessions"]) => { const byID = new Set(sessions.map((session) => session.id)); return sessions.filter((session) => { const parentID = normalizeSessionParentID(session); @@ -41,7 +41,7 @@ export const getRootSessions = (sessions: WorkspaceSessionGroup["sessions"]) => }; /** Split sessions into active vs. archived. Archived sessions live in their own section. */ -export const partitionArchivedSessions = (sessions: WorkspaceSessionGroup["sessions"]) => { +export const partitionArchivedSessions = (sessions: ProjectSessionList["sessions"]) => { const active: SessionListItem[] = []; const archived: SessionListItem[] = []; for (const session of sessions) { @@ -81,7 +81,7 @@ export const orderRootSessions = ( }; export const buildSessionTreeState = ( - sessions: WorkspaceSessionGroup["sessions"], + sessions: ProjectSessionList["sessions"], sessionStatusById: Record | undefined, ): SessionTreeState => { const childrenByParent = new Map(); @@ -137,7 +137,7 @@ export const buildSessionTreeState = ( }; export const flattenSessionRows = ( - sessions: WorkspaceSessionGroup["sessions"], + sessions: ProjectSessionList["sessions"], rootLimit: number, tree: SessionTreeState, expandedSessionIds: Set, diff --git a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx index fd089766b..644d0745a 100644 --- a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx +++ b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx @@ -66,11 +66,9 @@ type ComposerProps = { onConfigureTokenStar?: () => void; attachments: ComposerAttachment[]; hasPromptContext?: boolean; - onAttachFiles: (files: File[]) => void; - onRemoveAttachment: (id: string) => void; - attachmentsEnabled: boolean; - attachmentsDisabledReason: string | null; - modelVariantLabel: string; + onAttachFiles: (files: File[]) => void; + onRemoveAttachment: (id: string) => void; + modelVariantLabel: string; modelVariant: string | null; modelBehaviorOptions?: { value: string | null; label: string }[]; onModelVariantChange: (value: string | null) => void; @@ -1025,14 +1023,10 @@ export function ReactSessionComposer(props: ComposerProps) { } }; - const addAttachments = async (inputFiles: File[]) => { - if (!inputFiles.length) return; - if (!props.attachmentsEnabled) { - toast.warning(props.attachmentsDisabledReason ?? t("composer.attachments_unavailable")); - return; - } - - const accepted: File[] = []; + const addAttachments = async (inputFiles: File[]) => { + if (!inputFiles.length) return; + + const accepted: File[] = []; const oversize: string[] = []; for (const original of inputFiles) { @@ -1380,15 +1374,12 @@ export function ReactSessionComposer(props: ComposerProps) {
diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index 02abc3d90..2e5562f1a 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -73,7 +73,10 @@ import { useReactRenderWatchdog } from "@/react-app/shell/react-render-watchdog" import { SessionDebugPanel } from "./debug-panel"; import { deriveRenderedSessionMessages, resolveRenderedSessionSnapshot } from "./session-render-state"; import { useLocal } from "@/react-app/kernel/local-provider"; -import { isModelReadableAttachment } from "@/react-app/domains/session/sync/attachment-support"; +import { + attachmentRequiresNativeModelSupport, + isModelReadableAttachment, +} from "@/react-app/domains/session/sync/attachment-support"; import { deriveSessionRenderModel } from "@/react-app/domains/session/sync/transition-controller"; import { useSessionScrollController } from "./scroll-controller"; import { SessionScrollOverlay } from "./scroll-overlay"; @@ -180,8 +183,7 @@ export type SessionSurfaceProps = { onModelChange: (model: ModelRef) => void; onSendDraft: (draft: ComposerDraft, sessionId: string) => boolean | Promise; onDraftChange: (draft: ComposerDraft) => void; - attachmentsEnabled: boolean; - attachmentsDisabledReason: string | null; + supportsNativeAttachments: boolean; modelVariantLabel: string; modelVariant: string | null; modelBehaviorOptions?: { value: string | null; label: string }[]; @@ -1324,30 +1326,35 @@ export function SessionSurface(props: SessionSurfaceProps) { props.onDraftChange(buildDraft(draft, attachments)); }, [attachments, buildDraft, draft, props.onDraftChange]); - const handleAttachFiles = (files: File[]) => { - if (!props.attachmentsEnabled) { - toast.warning(props.attachmentsDisabledReason ?? "Attachments are unavailable."); - return; - } - const oversized = files.filter((file) => file.size > 25 * 1024 * 1024); - const sized = files.filter((file) => file.size <= 25 * 1024 * 1024); + const handleAttachFiles = (files: File[]) => { + const oversized = files.filter((file) => file.size > 25 * 1024 * 1024); + const sized = files.filter((file) => file.size <= 25 * 1024 * 1024); if (oversized.length) { toast.warning( oversized.length === 1 ? `${oversized[0]?.name ?? "File"} is too large` : `${oversized.length} files are too large`, { description: "Files over 25 MB were skipped." }, ); - } - const unreadable = sized.filter((file) => !isModelReadableAttachment(file.type)); - const accepted = sized.filter((file) => isModelReadableAttachment(file.type)); + } + const unreadable = sized.filter((file) => !isModelReadableAttachment(file.type)); + const readable = sized.filter((file) => isModelReadableAttachment(file.type)); + const unsupportedNative = props.supportsNativeAttachments + ? [] + : readable.filter((file) => attachmentRequiresNativeModelSupport(file.type)); + const accepted = readable.filter((file) => ( + props.supportsNativeAttachments || !attachmentRequiresNativeModelSupport(file.type) + )); if (unreadable.length) { toast.warning( unreadable.length === 1 ? `${unreadable[0]?.name ?? "File"} has a format the model can't read` : `${unreadable.length} files have formats the model can't read`, - { description: "Convert to PDF, image, or plain text and attach again." }, - ); - } - if (!accepted.length) return; + { description: "Convert to PDF, image, or plain text and attach again." }, + ); + } + if (unsupportedNative.length) { + toast.warning(t("composer.attachments_require_multimodal")); + } + if (!accepted.length) return; const next = accepted.map((file) => ({ id: `${file.name}-${file.lastModified}-${Math.random().toString(36).slice(2)}`, name: file.name, @@ -1779,11 +1786,9 @@ export function SessionSurface(props: SessionSurfaceProps) { onConfigureModels={props.onConfigureModels} attachments={attachments} hasPromptContext={selectedAnimations.length > 0 || Boolean(selectedVoiceReference) || Boolean(selectedIllustrationReference)} - onAttachFiles={handleAttachFiles} - onRemoveAttachment={handleRemoveAttachment} - attachmentsEnabled={props.attachmentsEnabled} - attachmentsDisabledReason={props.attachmentsDisabledReason} - modelVariantLabel={props.modelVariantLabel} + onAttachFiles={handleAttachFiles} + onRemoveAttachment={handleRemoveAttachment} + modelVariantLabel={props.modelVariantLabel} modelVariant={props.modelVariant} modelBehaviorOptions={props.modelBehaviorOptions} onModelVariantChange={props.onModelVariantChange} diff --git a/apps/app/src/react-app/domains/session/sync/attachment-support.ts b/apps/app/src/react-app/domains/session/sync/attachment-support.ts index a823b8237..158a47452 100644 --- a/apps/app/src/react-app/domains/session/sync/attachment-support.ts +++ b/apps/app/src/react-app/domains/session/sync/attachment-support.ts @@ -18,3 +18,8 @@ export function isModelReadableAttachment(mimeType: string) { if (mime === "application/pdf" || mime === "application/json") return true; return mime.endsWith("+json") || mime.endsWith("+xml") || mime === "application/xml" || mime === "application/javascript"; } + +export function attachmentRequiresNativeModelSupport(mimeType: string) { + const mime = mimeType.toLowerCase(); + return mime.startsWith("image/") || mime === "application/pdf"; +} diff --git a/apps/app/src/react-app/domains/settings/minimax-provider.ts b/apps/app/src/react-app/domains/settings/minimax-provider.ts index 8fb866746..a73e24957 100644 --- a/apps/app/src/react-app/domains/settings/minimax-provider.ts +++ b/apps/app/src/react-app/domains/settings/minimax-provider.ts @@ -1,5 +1,3 @@ -import type { ProviderConfig } from "@opencode-ai/sdk/v2/client"; - export type MiniMaxEndpointId = | "global-openai" | "global-anthropic" @@ -100,11 +98,11 @@ export function getMiniMaxEndpoint(endpointId: MiniMaxEndpointId): MiniMaxEndpoi return endpoint; } -export function buildMiniMaxProviderConfig(endpointId: MiniMaxEndpointId): ProviderConfig { +export function buildMiniMaxProviderConfig(endpointId: MiniMaxEndpointId) { const endpoint = getMiniMaxEndpoint(endpointId); const models = Object.fromEntries( MINIMAX_PROVIDER.models.map((model) => { - const modelConfig: NonNullable[string] = { + const modelConfig = { id: model.id, name: model.id, attachment: model.inputModalities.some((modality) => modality !== "text"), diff --git a/apps/app/src/react-app/domains/settings/openai-image-extension.ts b/apps/app/src/react-app/domains/settings/openai-image-extension.ts index c6ade1ebe..797cf1f06 100644 --- a/apps/app/src/react-app/domains/settings/openai-image-extension.ts +++ b/apps/app/src/react-app/domains/settings/openai-image-extension.ts @@ -1,6 +1,6 @@ -import type { ProviderConfig } from "@opencode-ai/sdk/v2/client"; - -export type LocalProviderModelConfig = NonNullable[string]; +export type LocalProviderModelConfig = Record & { + name: string; +}; export type LocalProviderInstallInput = { providerId: string; diff --git a/apps/app/src/react-app/infra/provider-list-query.ts b/apps/app/src/react-app/infra/provider-list-query.ts index c3c85fc32..f63f37259 100644 --- a/apps/app/src/react-app/infra/provider-list-query.ts +++ b/apps/app/src/react-app/infra/provider-list-query.ts @@ -1,13 +1,17 @@ import { useQuery, type QueryClient } from "@tanstack/react-query"; -import type { Client, ModelRef, ProviderListItem } from "../../app/types"; -import { unwrap } from "../../app/lib/opencode"; +import type { + ModelRef, + ProviderListItem, + ProviderListResponse, +} from "../../app/types"; import { dispatchNewProviders } from "../../app/lib/provider-events"; -import type { ProviderListResponse } from "@opencode-ai/sdk/v2/client"; +import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; +import { providerEngineAdapters } from "../domains/connections/provider-auth/provider-engine-adapter"; import type { SelectableChatModelSnapshot } from "./preferred-chat-model"; export const PROVIDER_LIST_CACHE_MS = 5 * 60 * 1000; -const PROVIDER_LIST_QUERY_ROOT = ["opencode-provider-list"] as const; +const PROVIDER_LIST_QUERY_ROOT = ["provider-list"] as const; export type ConnectedProviderSnapshot = Array<{ id: string; @@ -26,11 +30,13 @@ const connectedProviderSnapshots = new Map(); const connectedProviderSnapshotChanges = new Map(); export function providerListQueryKey(input: { + engineId?: string | null; baseUrl?: string | null; directory?: string | null; }) { return [ ...PROVIDER_LIST_QUERY_ROOT, + input.engineId?.trim() || DEFAULT_ENGINE_ID, input.baseUrl?.trim() ?? "", input.directory?.trim() ?? "", ] as const; @@ -42,15 +48,15 @@ export async function refreshProviderListQueries(queryClient: QueryClient) { } export async function fetchProviderList(input: { - client: Client; + client: unknown; + engineId?: string | null; baseUrl?: string | null; directory?: string | null; }): Promise { - const value = unwrap( - await input.client.provider.list({ - directory: input.directory?.trim() || undefined, - }), - ); + const value = await providerEngineAdapters + .get(input.engineId) + .connect(input.client) + .listProviders(input.directory?.trim() || undefined); recordConnectedProviderSnapshot(input, value); return value; } @@ -121,6 +127,7 @@ export function isModelAvailableInSelectableChatProviders( } export function getConnectedProviderSnapshotChange(input: { + engineId?: string | null; baseUrl?: string | null; directory?: string | null; }) { @@ -129,6 +136,7 @@ export function getConnectedProviderSnapshotChange(input: { function recordConnectedProviderSnapshot( input: { + engineId?: string | null; baseUrl?: string | null; directory?: string | null; }, @@ -146,6 +154,7 @@ function recordConnectedProviderSnapshot( } function connectedProviderSnapshotKey(input: { + engineId?: string | null; baseUrl?: string | null; directory?: string | null; }) { @@ -201,7 +210,8 @@ function dispatchConnectedProviderChanges( export function ensureProviderListQuery( queryClient: QueryClient, input: { - client: Client; + client: unknown; + engineId?: string | null; baseUrl?: string | null; directory?: string | null; force?: boolean; @@ -225,7 +235,8 @@ export function ensureProviderListQuery( } export function useProviderListQuery(input: { - client: Client | null; + client: unknown | null; + engineId?: string | null; baseUrl?: string | null; directory?: string | null; enabled?: boolean; @@ -245,6 +256,7 @@ export function useProviderListQuery(input: { } return fetchProviderList({ client: input.client, + engineId: input.engineId, baseUrl: input.baseUrl, directory: input.directory, }); diff --git a/apps/app/src/react-app/shell/command-palette.tsx b/apps/app/src/react-app/shell/command-palette.tsx index d86c3d403..cbcfd5880 100644 --- a/apps/app/src/react-app/shell/command-palette.tsx +++ b/apps/app/src/react-app/shell/command-palette.tsx @@ -24,7 +24,7 @@ import { CommandShortcut, } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; -import { BrainCircuit, Check, ChevronLeftIcon, FileText, FolderInput, Globe, Zap } from "lucide-react"; +import { BrainCircuit, Check, ChevronLeftIcon, FileText, Globe, Zap } from "lucide-react"; export type PaletteItem = { id: string; @@ -44,7 +44,7 @@ export type AccessibleTargetOption = { preview: string; }; -type PaletteMode = "root" | "sessions" | "accessible-items" | "agents" | "groups"; +type PaletteMode = "root" | "sessions" | "accessible-items" | "agents"; export type SessionOption = { workspaceId: string; @@ -56,11 +56,6 @@ export type SessionOption = { isActive: boolean; }; -export type SessionGroupOption = { - id: string; - label: string; -}; - function targetIcon(target: AccessibleTargetOption) { if (target.kind === "url") return ; if (target.preview === "sheet") { @@ -102,10 +97,6 @@ export type CommandPaletteProps = { onHideAccessibleTarget?: (target: AccessibleTargetOption) => void; /** Optional: sessions for the second mode. */ sessions: SessionOption[]; - sessionGroups?: SessionGroupOption[]; - currentSessionForGroupMove?: { title: string } | null; - currentSessionGroupId?: string | null; - onMoveCurrentSessionToGroup?: (groupId: string) => void; extraItems?: PaletteItem[]; /** Optional: agent picker submode (Switch agent). */ listAgents?: () => Promise; @@ -155,8 +146,6 @@ export function CommandPalette(props: CommandPaletteProps) { }; const accessibleTargetCount = props.accessibleTargets?.length ?? 0; - const sessionGroupCount = props.sessionGroups?.length ?? 0; - const canMoveCurrentSessionToGroup = Boolean(props.currentSessionForGroupMove && props.onMoveCurrentSessionToGroup); const rootItems = useMemo(() => [ { @@ -208,21 +197,6 @@ export function CommandPalette(props: CommandPaletteProps) { }, }] : []), - ...(canMoveCurrentSessionToGroup - ? [{ - id: "move-to-group", - title: "Move to Group", - detail: props.currentSessionForGroupMove - ? `Add ${props.currentSessionForGroupMove.title} to an existing group` - : "Add the selected task to an existing group", - meta: sessionGroupCount > 0 ? `${sessionGroupCount.toLocaleString()} groups` : "No groups", - icon: , - searchText: "move to group add task session folder organize", - action: () => { - setMode("groups"); - }, - }] - : []), { id: "accessible-items", title: "Accessible items", @@ -318,7 +292,7 @@ export function CommandPalette(props: CommandPaletteProps) { props.onOpenSettings("/settings/updates"); }, }, - ], [accessibleTargetCount, canMoveCurrentSessionToGroup, props, sessionGroupCount]); + ], [accessibleTargetCount, props]); const sessionItems = useMemo( () => @@ -398,22 +372,6 @@ export function CommandPalette(props: CommandPaletteProps) { ]; }, [agents, props]); - const groupItems = useMemo(() => ( - (props.sessionGroups ?? []).map((group) => ({ - id: `group:${group.id}`, - title: group.label, - meta: props.currentSessionGroupId === group.id ? "Current" : undefined, - icon: props.currentSessionGroupId === group.id - ? - : , - searchText: `group ${group.label}`.toLowerCase(), - action: () => { - props.onClose(); - props.onMoveCurrentSessionToGroup?.(group.id); - }, - })) - ), [props]); - const handleEscape = (event: ReactKeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); @@ -449,9 +407,7 @@ export function CommandPalette(props: CommandPaletteProps) { ? accessibleItems : mode === "agents" ? agentItems - : mode === "groups" - ? groupItems - : rootItems; + : rootItems; return ( @@ -463,9 +419,7 @@ export function CommandPalette(props: CommandPaletteProps) { ? "Accessible items" : mode === "agents" ? t("session.cmd_agents_title") - : mode === "groups" - ? "Move to Group" - : t("session.palette_title_actions") + : t("session.palette_title_actions") } @@ -485,15 +439,13 @@ export function CommandPalette(props: CommandPaletteProps) { ? "Search servers and artifacts..." : mode === "agents" ? t("session.palette_placeholder_agents") - : mode === "groups" - ? "Search groups..." - : t("session.palette_placeholder_actions") + : t("session.palette_placeholder_actions") } onKeyDown={handleBackspace} /> - {mode === "accessible-items" ? "No accessible items found for this session." : mode === "groups" ? "No groups found for this workspace." : t("session.palette_no_matches")} + {mode === "accessible-items" ? "No accessible items found for this session." : t("session.palette_no_matches")} {(item: PaletteItem) => ( [workspace.id, workspace])); + const orderedIdSet = new Set(orderIds); const ordered: RouteWorkspace[] = []; const usedIds = new Set(); + for (const workspace of workspaces) { + if (orderedIdSet.has(workspace.id)) continue; + ordered.push(workspace); + usedIds.add(workspace.id); + } + for (const id of orderIds) { const workspace = workspaceById.get(id); if (!workspace || usedIds.has(id)) continue; @@ -306,20 +313,15 @@ export function orderRouteWorkspaces(workspaces: RouteWorkspace[], orderIds: str usedIds.add(id); } - for (const workspace of workspaces) { - if (usedIds.has(workspace.id)) continue; - ordered.push(workspace); - } - return ordered; } -export function toSessionGroups( +export function toProjectSessionLists( workspaces: RouteWorkspace[], sessionsByWorkspaceId: Record, errorsByWorkspaceId: Record, loadingWorkspaceIds: Set, -): WorkspaceSessionGroup[] { +): ProjectSessionList[] { return workspaces.map((workspace) => ({ workspace, sessions: sessionsByWorkspaceId[workspace.id] ?? [], diff --git a/apps/app/src/react-app/shell/session-prompt.ts b/apps/app/src/react-app/shell/session-prompt.ts index d590493b7..b590fb6c4 100644 --- a/apps/app/src/react-app/shell/session-prompt.ts +++ b/apps/app/src/react-app/shell/session-prompt.ts @@ -11,6 +11,7 @@ import { } from "@ipollowork/design-studio"; import { useDesignAiSelectionStore } from "@/react-app/domains/session/design/design-ai-selection-store"; import { firstLineLocalFileParts } from "@/react-app/domains/session/sync/prompt-file-parts"; +import { attachmentRequiresNativeModelSupport } from "@/react-app/domains/session/sync/attachment-support"; import { appMentionInstruction } from "@/react-app/domains/session/surface/composer/app-mentions"; type DesignSelectionScope = { @@ -25,6 +26,10 @@ type DesignSelectionWorkspaceClient = { type DesignSelectionStore = Pick; +type DraftToPartsOptions = { + supportsNativeAttachments?: boolean; +}; + export function serializeSDKError(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; @@ -121,6 +126,7 @@ export async function draftToParts( workspaceRoot: string, designSelectionStore: DesignSelectionStore = useDesignAiSelectionStore, scope?: DesignSelectionScope, + options: DraftToPartsOptions = {}, ) { const parts: Array = []; const root = workspaceRoot.trim(); @@ -197,6 +203,16 @@ export async function draftToParts( parts.push( ...(await Promise.all( draft.attachments.map(async (attachment) => { + if (options.supportsNativeAttachments === false) { + if (attachmentRequiresNativeModelSupport(attachment.mimeType)) { + throw new Error("The selected model cannot read image or PDF attachments."); + } + return { + type: "text" as const, + text: `Attached file: ${attachment.name}\n\n${await attachment.file.text()}`, + synthetic: true, + }; + } const mime = attachmentMime(attachment); return { type: "file" as const, diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index df3521df6..a874a9a16 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -8,16 +8,14 @@ import { } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "@/components/ui/sonner"; -import type { - ProviderListResponse, - SessionStatus, -} from "@opencode-ai/sdk/v2/client"; +import type { SessionStatus } from "@opencode-ai/sdk/v2/client"; import type { PptxCompatibility, TemplateCategory } from "@ipollowork/types/templates"; import { captureAnalyticsEvent, markTaskRunStart } from "@/app/lib/analytics"; import { PERSONAL_WORK_CONTEXT_ID, readActiveWorkContextId, + rememberProjectForWorkContext, type WorkContextId, workContextChangedEvent, } from "@/app/lib/work-context"; @@ -26,19 +24,27 @@ import { buildDiagnosticsBundleJson } from "@/app/lib/diagnostics-bundle"; import { downloadTextAsFile } from "@/app/lib/download"; import { createClient, unwrap } from "@/app/lib/opencode"; import { abortSessionSafe, forkSession, listCommands, revertSession, setSessionArchived, shellInSession } from "@/app/lib/opencode-session"; -import { useSessionManagementStore as sessionManagementStore } from "@/react-app/domains/session/sidebar/session-management-store"; import { resolveWorkspaceEndpoint, workspaceServerId, } from "@/app/lib/workspace-endpoint"; import { buildiPolloWorkEnvRuntimeKey } from "@/app/lib/ipollowork-env-runtime"; -import type { iPolloWorkServerInfo } from "@/app/lib/desktop"; +import { + revealDesktopItemInDir, + workspaceCreate, + workspaceForget, + workspaceSetRuntimeActive, + workspaceSetSelected, + workspaceUpdateDisplayName, + type iPolloWorkServerInfo, +} from "@/app/lib/desktop"; import type { ComposerDraft, ModelRef, SlashCommandOption, WorkspaceConnectionState, ProviderListItem, + ProviderListResponse, } from "@/app/types"; import { getWorkspaceTaskLoadErrorDisplay, @@ -54,7 +60,7 @@ import { getSessionStatus, isActiveSessionStatus, isTransientStartupError, - toSessionGroups, + toProjectSessionLists, userVisibleSessionsByWorkspaceId, } from "@/react-app/shell/route-workspaces"; import { useLocal } from "@/react-app/kernel/local-provider"; @@ -68,6 +74,7 @@ import { applySessionRevert, destroyWorkspaceSessionResources, } from "@/react-app/domains/session/sync/session-sync"; +import { attachmentRequiresNativeModelSupport } from "@/react-app/domains/session/sync/attachment-support"; import { designHtmlThemeSystemContext, type DesignAiSelectionContext, @@ -85,6 +92,7 @@ import { useSessionFindStore } from "@/react-app/domains/session/surface/find-st import { useModelPicker } from "@/react-app/domains/session/modals/use-model-picker"; import { CreateRemoteWorkspaceModal } from "@/react-app/domains/workspace/create-remote-workspace-modal"; import { useSessionProviderAuth } from "@/react-app/domains/connections/provider-auth/use-session-provider-auth"; +import { providerEngineAdapters } from "@/react-app/domains/connections/provider-auth/provider-engine-adapter"; import { useMcpConnectedCount } from "@/react-app/domains/connections/use-mcp-connected-count"; import { useSessionMcpMaintenance } from "@/react-app/domains/connections/use-session-mcp-maintenance"; import type { iPolloWorkSessionType, iPolloWorkTemplateId } from "@/react-app/domains/session/sidebar/app-sidebar-provider"; @@ -103,7 +111,7 @@ import { IPOLLOWORK_MODEL_PREVIEWS } from "@/react-app/domains/cloud/ipollowork- import { FirstRunLoader } from "@/react-app/domains/onboarding/first-run-loader"; import { useiPolloWorkModelsStartupPromo } from "@/react-app/domains/cloud/use-ipollowork-models-startup-promo"; import { ModelPickerModal } from "@/react-app/domains/session/modals/model-picker-modal"; -import { CommandPalette, type PaletteItem, type SessionGroupOption, type SessionOption as PaletteSessionOption } from "./command-palette"; +import { CommandPalette, type PaletteItem, type SessionOption as PaletteSessionOption } from "./command-palette"; import { SessionSearchDialog } from "./session-search-dialog"; import type { SessionMessageFetcher } from "@/react-app/domains/session/search/session-search"; import { @@ -124,7 +132,6 @@ import { filterProviderList } from "@/app/utils/providers"; import { useReloadCoordinator } from "./reload-coordinator"; import { useShellShortcuts } from "./use-shell-shortcuts"; import { useEngineReload } from "./use-engine-reload"; -import { useSessionGroupSync } from "./use-session-group-sync"; import { useWorkspaceRouteState } from "./use-workspace-route-state"; import { getReactQueryClient } from "@/react-app/infra/query-client"; import { useSessionControlActions } from "@/react-app/domains/session/control/session-control-actions"; @@ -229,6 +236,7 @@ export function SessionRoute() { routeNotFoundMessage, endpointForWorkspace, refreshRouteState, + loadWorkspaceSessionsInBackground, rememberPendingCreatedSession, handleRuntimeSessionUpdated, handleRemoteWorkspaceConnectionSaved, @@ -349,15 +357,118 @@ export function SessionRoute() { () => userVisibleSessionsByWorkspaceId(sessionsByWorkspaceId), [sessionsByWorkspaceId], ); - const workspaceSessionGroups = useMemo( - () => toSessionGroups(workspaces, visibleSessionsByWorkspaceId, errorsByWorkspaceId, new Set(retryingWorkspaceIds)), + const projectSessionLists = useMemo( + () => toProjectSessionLists(workspaces, visibleSessionsByWorkspaceId, errorsByWorkspaceId, new Set(retryingWorkspaceIds)), [errorsByWorkspaceId, retryingWorkspaceIds, visibleSessionsByWorkspaceId, workspaces], ); - useSessionGroupSync({ workspaces, endpointForWorkspace }); - const selectedWorkspaceGroupState = sessionManagementStore((state) => ( - selectedWorkspaceId ? state.groupsByWorkspace[selectedWorkspaceId] : undefined - )); - const assignSessionToGroup = sessionManagementStore((state) => state.assignGroup); + + const selectProject = useCallback(async (workspaceId: string) => { + const project = workspaces.find((workspace) => workspace.id === workspaceId); + if (!project) return false; + + setLegacySelectedWorkspaceId(workspaceId); + writeActiveWorkspaceId(workspaceId); + rememberProjectForWorkContext(activeWorkContextId, workspaceId); + + if (!sessionsByWorkspaceId[workspaceId]?.length) { + setRetryingWorkspaceIds((current) => Array.from(new Set([...current, workspaceId]))); + void loadWorkspaceSessionsInBackground([project]); + } + + if (isDesktopRuntime()) { + void workspaceSetSelected(workspaceId).catch(() => undefined); + void workspaceSetRuntimeActive(workspaceId).catch(() => undefined); + } + + const endpoint = endpointForWorkspace(project); + if (endpoint) { + void endpoint.client.activateWorkspace(endpoint.workspaceId, { persist: true }).catch(() => undefined); + } + + const rememberedSessionId = readLastSessionFor(workspaceId); + const loadedSessions = sessionsByWorkspaceIdRef.current[workspaceId]; + const restoredSessionId = rememberedSessionId && ( + loadedSessions === undefined || loadedSessions.some((session) => session.id === rememberedSessionId) + ) + ? rememberedSessionId + : null; + navigateToWorkspaceSession(workspaceId, restoredSessionId); + return true; + }, [ + activeWorkContextId, + endpointForWorkspace, + loadWorkspaceSessionsInBackground, + navigateToWorkspaceSession, + sessionsByWorkspaceId, + sessionsByWorkspaceIdRef, + setLegacySelectedWorkspaceId, + setRetryingWorkspaceIds, + workspaces, + ]); + + const createProject = useCallback(async (input: { name: string; folderPath: string }) => { + if (!client) throw new Error(t("projects.server_unavailable")); + const name = input.name.trim(); + const requestedFolderPath = input.folderPath.trim(); + if (!name || !requestedFolderPath) throw new Error(t("projects.name_and_folder_required")); + const workContextId = activeWorkContextId === PERSONAL_WORK_CONTEXT_ID ? null : activeWorkContextId; + let folderPath = requestedFolderPath; + let desktopProjectId: string | null = null; + + if (isDesktopRuntime()) { + const desktopState = await workspaceCreate({ folderPath, name, preset: "starter", workContextId }); + const desktopProject = desktopState.workspaces.find((workspace) => workspace.id === desktopState.selectedId) + ?? desktopState.workspaces.find((workspace) => workspace.path === folderPath) + ?? null; + if (!desktopProject) throw new Error(t("projects.create_failed")); + desktopProjectId = desktopProject.id; + folderPath = desktopProject.path; + } + + const result = await client.createLocalWorkspace({ folderPath, name, preset: "starter", workContextId }); + const project = result.workspaces.find((workspace) => workspace.id === desktopProjectId) + ?? result.workspaces.find((workspace) => workspace.path === folderPath) + ?? null; + if (!project) throw new Error(t("projects.create_failed")); + + if (isDesktopRuntime()) { + await workspaceSetSelected(project.id); + await workspaceSetRuntimeActive(project.id); + } + await client.activateWorkspace(project.id, { persist: true }); + setLegacySelectedWorkspaceId(project.id); + writeActiveWorkspaceId(project.id); + rememberProjectForWorkContext(activeWorkContextId, project.id); + await refreshRouteState(); + navigateToWorkspaceSession(project.id); + }, [activeWorkContextId, client, navigateToWorkspaceSession, refreshRouteState, setLegacySelectedWorkspaceId]); + + const renameProject = useCallback(async (workspaceId: string, name: string) => { + const trimmed = name.trim(); + if (!client || !trimmed) return; + await client.updateWorkspaceDisplayName(workspaceId, trimmed); + if (isDesktopRuntime()) { + await workspaceUpdateDisplayName({ workspaceId, displayName: trimmed }); + } + await refreshRouteState(); + }, [client, refreshRouteState]); + + const revealProject = useCallback(async (workspaceId: string) => { + const project = workspaces.find((workspace) => workspace.id === workspaceId); + if (!project?.path || !isDesktopRuntime()) return; + await revealDesktopItemInDir(project.path); + }, [workspaces]); + + const deleteProject = useCallback(async (workspaceId: string) => { + if (workspaces.length <= 1) throw new Error(t("projects.keep_one")); + const fallback = workspaces.find((workspace) => workspace.id !== workspaceId); + if (!fallback) throw new Error(t("projects.keep_one")); + if (client) await client.deleteWorkspace(workspaceId); + if (isDesktopRuntime()) await workspaceForget(workspaceId); + writeLastSessionFor(workspaceId, null); + await selectProject(fallback.id); + await refreshRouteState(); + }, [client, refreshRouteState, selectProject, workspaces]); const seedWorkspaceActivitySessions = useSessionActivityStore((state) => state.seedWorkspaceSessions); const sessionActivityByWorkspaceId = useSessionActivityStore((state) => state.statusesByWorkspaceId); @@ -374,30 +485,30 @@ export function SessionRoute() { const sidebarSessionStatusById = useMemo(() => { const next: Record = {}; - for (const group of workspaceSessionGroups) { - const serverId = workspaceServerId(group.workspace); + for (const project of projectSessionLists) { + const serverId = workspaceServerId(project.workspace); const workspaceStatuses = { - ...(sessionActivityByWorkspaceId[group.workspace.id] ?? {}), + ...(sessionActivityByWorkspaceId[project.workspace.id] ?? {}), ...(serverId ? sessionActivityByWorkspaceId[serverId] ?? {} : {}), }; - for (const session of group.sessions) { + for (const session of project.sessions) { const status = workspaceStatuses[session.id]; if (status) next[session.id] = status; } } return next; - }, [sessionActivityByWorkspaceId, workspaceSessionGroups]); + }, [sessionActivityByWorkspaceId, projectSessionLists]); const sidebarActiveWorkspaceId = useMemo(() => { const sessionId = selectedSessionId?.trim() ?? ""; if (sessionId) { - const owner = workspaceSessionGroups.find((group) => - group.sessions.some((session) => session?.id === sessionId), + const owner = projectSessionLists.find((project) => + project.sessions.some((session) => session?.id === sessionId), ); if (owner?.workspace.id) return owner.workspace.id; } return selectedWorkspaceId; - }, [selectedSessionId, selectedWorkspaceId, workspaceSessionGroups]); + }, [selectedSessionId, selectedWorkspaceId, projectSessionLists]); const workspaceConnectionStateById = useMemo(() => { const next: Record = { ...workspaceConnectionOverrides }; @@ -417,6 +528,7 @@ export function SessionRoute() { const mcpConnectedCount = useMcpConnectedCount(opencodeClient, selectedWorkspaceRoot); const providerListQuery = useProviderListQuery({ client: opencodeClient, + engineId: selectedWorkspace?.engineId, baseUrl: opencodeBaseUrl, directory: selectedWorkspaceRoot || undefined, }); @@ -432,6 +544,7 @@ export function SessionRoute() { ); const modelPicker = useModelPicker({ client: opencodeClient, + engineId: selectedWorkspace?.engineId, baseUrl: opencodeBaseUrl, workspaceRoot: selectedWorkspaceRoot, }); @@ -546,8 +659,8 @@ export function SessionRoute() { const hasCloudAuth = !!readDenSettings().authToken?.trim(); const isCloudProvider = (id: string) => /^lpr_/i.test(id); const all = hasCloudAuth - ? ((value.all ?? []) as ProviderListItem[]) - : ((value.all ?? []) as ProviderListItem[]).filter( + ? value.all + : value.all.filter( (p) => !isCloudProvider(p.id ?? ""), ); const connected = hasCloudAuth @@ -562,14 +675,10 @@ export function SessionRoute() { void (async () => { let disabledProviders: string[] = []; try { - const config = unwrap( - await opencodeClient.config.get({ - directory: selectedWorkspaceRoot || undefined, - }), - ) as { disabled_providers?: string[] }; - disabledProviders = Array.isArray(config.disabled_providers) - ? config.disabled_providers - : []; + disabledProviders = await providerEngineAdapters + .get(selectedWorkspace?.engineId) + .connect(opencodeClient) + .readDisabledProviders(); if (!cancelled) setDisabledProviderIds(disabledProviders); } catch { // ignore config read failures and continue with provider discovery @@ -580,6 +689,7 @@ export function SessionRoute() { filterProviderList( await ensureProviderListQuery(getReactQueryClient(), { client: opencodeClient, + engineId: selectedWorkspace?.engineId, baseUrl: opencodeBaseUrl, directory: selectedWorkspaceRoot || undefined, }), @@ -597,7 +707,7 @@ export function SessionRoute() { return () => { cancelled = true; }; - }, [opencodeBaseUrl, opencodeClient, selectedWorkspaceRoot, denSessionVersion]); + }, [denSessionVersion, opencodeBaseUrl, opencodeClient, selectedWorkspace?.engineId, selectedWorkspaceRoot]); const modelLabel = local.prefs.defaultModel ? resolveModelDisplayName(local.prefs.defaultModel.modelID) @@ -742,7 +852,10 @@ export function SessionRoute() { if (!targetSessionId) return false; const text = (draft.resolvedText ?? draft.text).trim(); if (!text && draft.attachments.length === 0) return false; - if (draft.attachments.length > 0 && !selectedModelSupportsAttachments) { + if ( + !selectedModelSupportsAttachments + && draft.attachments.some((attachment) => attachmentRequiresNativeModelSupport(attachment.mimeType)) + ) { toast.warning(t("composer.attachments_require_multimodal")); return false; } @@ -819,6 +932,7 @@ export function SessionRoute() { selectedWorkspaceRoot, useDesignAiSelectionStore, designSelectionScope, + { supportsNativeAttachments: selectedModelSupportsAttachments }, ); const capabilitySystemContext = draft.capability?.instruction ?? null; // Template-session metadata is authoritative. The in-memory surface @@ -967,10 +1081,7 @@ export function SessionRoute() { onDraftChange: () => { // Draft persistence will be wired once the full React shell owns session state. }, - attachmentsEnabled: selectedModelSupportsAttachments, - attachmentsDisabledReason: selectedModelSupportsAttachments - ? null - : t("composer.attachments_require_multimodal"), + supportsNativeAttachments: selectedModelSupportsAttachments, modelVariantLabel, modelVariant: modelVariantValue, modelBehaviorOptions, @@ -1124,14 +1235,12 @@ export function SessionRoute() { type: iPolloWorkSessionType = "work", templateId?: iPolloWorkTemplateId, templateScope?: WorkContextId, - groupId?: string | null, authoring?: { category: TemplateCategory; pptxCompatibility?: PptxCompatibility }, ): Promise => { const workspace = workspaces.find((item) => item.id === workspaceId); if ( !workspace || - loading || - retryingWorkspaceIds.includes(workspaceId) + loading ) { return null; } @@ -1183,9 +1292,6 @@ export function SessionRoute() { } } setSessionType(session.id, sessionType); - if (groupId?.trim()) { - sessionManagementStore.getState().assignGroup(workspaceId, session.id, groupId); - } captureAnalyticsEvent("task_created", { source: "new_task", workspace_type: workspace.workspaceType ?? "unknown", @@ -1237,7 +1343,7 @@ export function SessionRoute() { description: message, action: { label: "Retry", - onClick: () => void handleCreateTaskInWorkspace(workspaceId, type, templateId, templateScope, groupId, authoring), + onClick: () => void handleCreateTaskInWorkspace(workspaceId, type, templateId, templateScope, authoring), }, duration: Infinity, }); @@ -1250,7 +1356,7 @@ export function SessionRoute() { description: message, action: { label: "Retry", - onClick: () => void handleCreateTaskInWorkspace(workspaceId, type, templateId, templateScope, groupId), + onClick: () => void handleCreateTaskInWorkspace(workspaceId, type, templateId, templateScope), }, duration: Infinity, }); @@ -1266,7 +1372,7 @@ export function SessionRoute() { } return null; } - }, [baseUrl, loading, navigateToWorkspaceSession, refreshRouteState, rememberPendingCreatedSession, retryingWorkspaceIds, token, workspaces]); + }, [baseUrl, loading, navigateToWorkspaceSession, refreshRouteState, rememberPendingCreatedSession, token, workspaces]); // Full-screen first-run loader. Armed once per app launch from the very // first render of a brand-new profile (no active-workspace memory yet) and @@ -1348,7 +1454,7 @@ export function SessionRoute() { selectedWorkspaceId, loading, workspaceCount: workspaces.length, - sessionGroupCount: Object.keys(sessionsByWorkspaceId).length, + sessionWorkspaceCount: Object.keys(sessionsByWorkspaceId).length, commandPaletteOpen, modelPickerOpen: modelPicker.open, }); @@ -1426,27 +1532,6 @@ export function SessionRoute() { ); }, [selectedWorkspaceId, visibleSessionsByWorkspaceId, workspaces]); - const paletteSessionGroups = useMemo( - () => selectedWorkspaceGroupState?.groups ?? [], - [selectedWorkspaceGroupState?.groups], - ); - - const currentSessionForGroupMove = useMemo(() => { - if (!selectedWorkspaceId || !selectedSessionId) return null; - return paletteSessionOptions.find( - (session) => session.workspaceId === selectedWorkspaceId && session.sessionId === selectedSessionId, - ) ?? null; - }, [paletteSessionOptions, selectedSessionId, selectedWorkspaceId]); - - const currentSessionGroupId = selectedSessionId - ? selectedWorkspaceGroupState?.assignments[selectedSessionId] ?? null - : null; - - const handleMoveCurrentSessionToGroup = useCallback((groupId: string) => { - if (!selectedWorkspaceId || !selectedSessionId) return; - assignSessionToGroup(selectedWorkspaceId, selectedSessionId, groupId); - }, [assignSessionToGroup, selectedSessionId, selectedWorkspaceId]); - const sessionSearchFetcher = useMemo(() => { if (!client) return null; // Cap the transcript fetch to keep multi-workspace scans fast; matches in @@ -1610,6 +1695,7 @@ export function SessionRoute() { return ( @@ -1710,7 +1796,7 @@ export function SessionRoute() { terminalOpen={terminalOpen} onTerminalOpenChange={setTerminalOpen} sidebar={{ - workspaceSessionGroups, + projectSessionLists, selectedWorkspaceId, selectedSessionId, developerMode: false, @@ -1726,11 +1812,16 @@ export function SessionRoute() { writeLastSessionFor(workspaceId, sessionId); navigateToWorkspaceSession(workspaceId, sessionId); }, + onSelectProject: selectProject, + onCreateProject: createProject, + onRenameProject: renameProject, + onRevealProject: revealProject, + onDeleteProject: deleteProject, onPrefetchSession: () => {}, - onCreateTaskInWorkspace: (workspaceId, type, templateId, templateScope, groupId) => - handleCreateTaskInWorkspace(workspaceId, type, templateId, templateScope, groupId), - onCreateTemplateAuthoring: (workspaceId, input, groupId) => - handleCreateTaskInWorkspace(workspaceId, "work", undefined, undefined, groupId, input), + onCreateTaskInWorkspace: (workspaceId, type, templateId, templateScope) => + handleCreateTaskInWorkspace(workspaceId, type, templateId, templateScope), + onCreateTemplateAuthoring: (workspaceId, input) => + handleCreateTaskInWorkspace(workspaceId, "work", undefined, undefined, input), onCreateTaskWithPrompt: (workspaceId, prompt) => { void (async () => { const workspace = workspaces.find((item) => item.id === workspaceId); @@ -1873,10 +1964,6 @@ export function SessionRoute() { } }} sessions={paletteSessionOptions} - sessionGroups={paletteSessionGroups} - currentSessionForGroupMove={currentSessionForGroupMove} - currentSessionGroupId={currentSessionGroupId} - onMoveCurrentSessionToGroup={handleMoveCurrentSessionToGroup} extraItems={[...(sessionFindPaletteItem ? [sessionFindPaletteItem] : []), sessionSearchPaletteItem, ...terminalPaletteItems, developerModePaletteItem, diagnosticsCopyPaletteItem, diagnosticsExportPaletteItem, reloadConfigPaletteItem]} listAgents={listAgents} selectedAgent={selectedAgent} @@ -1917,12 +2004,14 @@ export function SessionRoute() { onToggleProvider={async (providerId, enable) => { if (!opencodeClient) return; try { - const config = unwrap(await opencodeClient.config.get()) as { disabled_providers?: string[] }; - const current = Array.isArray(config.disabled_providers) ? config.disabled_providers : []; + const connection = providerEngineAdapters + .get(selectedWorkspace?.engineId) + .connect(opencodeClient); + const current = await connection.readDisabledProviders(); const next = enable ? current.filter((id: string) => id !== providerId) : [...current, providerId]; - await opencodeClient.config.update({ config: { ...config, disabled_providers: next } }); + await connection.writeDisabledProviders(next); setDisabledProviderIds(next); } catch {} }} diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index eb2092a83..95a0fc8ab 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -9,9 +9,8 @@ import { SUGGESTED_PLUGINS, } from "@/app/constants"; import { - canonicalWorkspacesForWorkContext, + filterWorkspacesForWorkContext, PERSONAL_WORK_CONTEXT_ID, - pruneServerWorkspacesForWorkContext, readActiveWorkContextId, workContextChangedEvent, } from "@/app/lib/work-context"; @@ -53,6 +52,7 @@ import { createConnectionsStore, useConnectionsStoreSnapshot } from "@/react-app import { useOrgMcpConnections } from "@/react-app/domains/connections/use-org-mcp-connections"; import { createiPolloWorkServerStore, useiPolloWorkServerStoreSnapshot } from "@/react-app/domains/connections/ipollowork-server-store"; import { createProviderAuthStore, useProviderAuthStoreSnapshot } from "@/react-app/domains/connections/provider-auth/store"; +import { providerEngineAdapters } from "@/react-app/domains/connections/provider-auth/provider-engine-adapter"; import { formatProviderAuthName } from "@/react-app/domains/connections/provider-auth/provider-auth-curation"; import ProviderAuthModal from "@/react-app/domains/connections/provider-auth/provider-auth-modal"; import ConnectionsModals from "@/react-app/domains/connections/modals"; @@ -466,6 +466,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { path: selectedWorkspace.path ?? "", preset: "starter", workspaceType: selectedWorkspace.workspaceType ?? "local", + engineId: selectedWorkspace.engineId, displayName: selectedWorkspace.displayNameResolved, ipolloworkWorkspaceName: selectedWorkspace.ipolloworkWorkspaceName, } @@ -586,11 +587,11 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { setProviderDefaults, setProviderConnectedIds, setDisabledProviders, - markOpencodeConfigReloadRequired: () => { + markEngineConfigReloadRequired: (configFileName) => { setConfigActionStatus(t("settings.config_updated")); reloadCoordinator.markReloadRequired("config", { type: "config", - name: "opencode.json", + name: configFileName, action: "updated", }); }, @@ -771,6 +772,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { }, []); const modelPicker = useModelPicker({ client: opencodeClient, + engineId: selectedWorkspace?.engineId, baseUrl: opencodeBaseUrl, workspaceRoot: selectedWorkspaceRoot, onLoadError: handleModelPickerLoadError, @@ -977,26 +979,30 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { setLocalProviderStatus(null); setLocalProviderError(null); try { - await client.patchConfig(workspaceId, { - opencode: { - provider: { - [input.providerId]: { - npm: input.npm ?? "@ai-sdk/openai-compatible", - name: input.name, - ...(api ? { api } : { options: { baseURL } }), - models, - }, - }, + const engineAdapter = providerEngineAdapters.get(selectedWorkspace?.engineId); + await engineAdapter.patchRuntimeProviders( + { + ipolloworkClient: client, + workspaceId, + hasiPolloWorkTarget: true, + canUseiPolloWorkServer: true, + isLocalWorkspace: selectedWorkspace?.workspaceType !== "remote", + root: selectedWorkspaceRoot, }, - }); + engineAdapter.buildCompatibleProviderPatch({ + id: input.providerId, + name: input.name, + npm: input.npm, + api, + baseURL, + models, + }), + ); if (input.apiKey?.trim()) { if (!opencodeClient) { throw new Error("OpenCode is not connected for this workspace."); } - await opencodeClient.auth.set({ - providerID: input.providerId, - auth: { type: "api", key: input.apiKey.trim() }, - }); + await engineAdapter.connect(opencodeClient).setApiKey(input.providerId, input.apiKey.trim()); } if (input.setDefault) { local.setPrefs((previous) => ({ @@ -1023,7 +1029,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { } finally { setLocalProviderBusy(false); } - }, [local, ipolloworkClient, opencodeClient, reloadCoordinator, runtimeWorkspaceId, selectedWorkspaceEndpoint]); + }, [local, ipolloworkClient, opencodeClient, reloadCoordinator, runtimeWorkspaceId, selectedWorkspace, selectedWorkspaceEndpoint, selectedWorkspaceRoot]); useEffect(() => { local.setUi((previous) => ({ ...previous, view: "settings", tab: route.tab })); @@ -1058,10 +1064,9 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { if (isDesktopRuntime()) { try { desktopList = await workspaceBootstrap() as WorkspaceList; - desktopWorkspaces = canonicalWorkspacesForWorkContext( + desktopWorkspaces = filterWorkspacesForWorkContext( (desktopList.workspaces ?? []).map(mapDesktopWorkspace), requestedContextId, - [resolveWorkspaceListSelectedId(desktopList)], ); } catch (error) { const message = describeRouteError(error); @@ -1097,28 +1102,11 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { }); const list = await client.listWorkspaces(); const serverWorkspaceIds = new Set(list.items.map((workspace) => workspace.id)); - const nextWorkspaces = canonicalWorkspacesForWorkContext( + const nextWorkspaces = filterWorkspacesForWorkContext( mergeRouteWorkspaces(list.items, desktopWorkspaces), requestedContextId, - [ - routeWorkspaceId, - readActiveWorkspaceId(), - resolveWorkspaceListSelectedId(desktopList), - list.activeId, - ], ); if (workContextRef.current !== requestedContextId) return; - const canonicalServerWorkspaceId = nextWorkspaces.find((workspace) => serverWorkspaceIds.has(workspace.id))?.id ?? ""; - if (canonicalServerWorkspaceId) { - void pruneServerWorkspacesForWorkContext( - client, - list.items, - requestedContextId, - canonicalServerWorkspaceId, - ).catch((error) => { - console.warn("[settings-route] failed to prune legacy workspace identities", error); - }); - } const sessionEntries = await Promise.all( nextWorkspaces.map(async (workspace) => { if (!serverWorkspaceIds.has(workspace.id)) { diff --git a/apps/app/src/react-app/shell/use-session-group-sync.ts b/apps/app/src/react-app/shell/use-session-group-sync.ts deleted file mode 100644 index 2b99e4aaa..000000000 --- a/apps/app/src/react-app/shell/use-session-group-sync.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { useEffect, useRef } from "react"; - -import type { iPolloWorkSessionGroupState } from "@/app/lib/ipollowork-server"; -import type { ResolvedWorkspaceEndpoint } from "@/app/lib/workspace-endpoint"; -import { - applySessionGroupServerState, - beginSessionGroupServerSync, - setSessionGroupSyncHandler, - useSessionManagementStore, - type SessionGroupDefinition, - type SessionGroupServerState, - type WorkspaceGroupState, -} from "@/react-app/domains/session/sidebar/session-management-store"; -import type { RouteWorkspace } from "./route-workspaces"; - -const MIGRATION_PREFIX = "ipollowork.sessionGroups.migrated.v2"; - -type UseSessionGroupSyncInput = { - workspaces: RouteWorkspace[]; - endpointForWorkspace: (workspace: RouteWorkspace | null | undefined) => ResolvedWorkspaceEndpoint | null; -}; - -function hasGroupData(state: SessionGroupServerState | WorkspaceGroupState | undefined): boolean { - return Boolean(state && (state.groups.length > 0 || Object.keys(state.assignments).length > 0)); -} - -function serverStateFromWorkspaceState(state: WorkspaceGroupState): iPolloWorkSessionGroupState { - return { - groups: state.groups.map((group) => ({ id: group.id, label: group.label })), - assignments: { ...state.assignments }, - }; -} - -function localGroupStateForWorkspace( - workspace: RouteWorkspace, - endpoint: ResolvedWorkspaceEndpoint, -): WorkspaceGroupState | undefined { - const byWorkspace = useSessionManagementStore.getState().groupsByWorkspace; - const ids = [workspace.id, endpoint.workspaceId, `rem_${endpoint.workspaceId}`]; - for (const id of ids) { - const state = byWorkspace[id]; - if (hasGroupData(state)) return state; - } - return undefined; -} - -function migrationKey(endpoint: ResolvedWorkspaceEndpoint): string { - return `${MIGRATION_PREFIX}:${endpoint.baseUrl}:${endpoint.workspaceId}`; -} - -function readMigrationComplete(endpoint: ResolvedWorkspaceEndpoint): boolean { - try { - return window.localStorage.getItem(migrationKey(endpoint)) === "1"; - } catch { - return false; - } -} - -function writeMigrationComplete(endpoint: ResolvedWorkspaceEndpoint): void { - try { - window.localStorage.setItem(migrationKey(endpoint), "1"); - } catch { - // ignore storage failures; the next launch can retry safely. - } -} - -export function useSessionGroupSync(input: UseSessionGroupSyncInput): void { - const { workspaces, endpointForWorkspace } = input; - const workspacesRef = useRef(workspaces); - const endpointForWorkspaceRef = useRef(endpointForWorkspace); - const eventCursorByWorkspaceRef = useRef>({}); - const pollInFlightRef = useRef(false); - - useEffect(() => { - workspacesRef.current = workspaces; - }, [workspaces]); - - useEffect(() => { - endpointForWorkspaceRef.current = endpointForWorkspace; - }, [endpointForWorkspace]); - - useEffect(() => { - setSessionGroupSyncHandler({ - createGroup: async (workspaceId: string, group: SessionGroupDefinition) => { - const workspace = workspacesRef.current.find((item) => item.id === workspaceId); - const endpoint = endpointForWorkspaceRef.current(workspace); - if (!endpoint) return null; - const response = await endpoint.client.createSessionGroup(endpoint.workspaceId, group); - writeMigrationComplete(endpoint); - return response.state; - }, - assignGroup: async (workspaceId: string, sessionId: string, groupId: string | null) => { - const workspace = workspacesRef.current.find((item) => item.id === workspaceId); - const endpoint = endpointForWorkspaceRef.current(workspace); - if (!endpoint) return null; - const response = await endpoint.client.assignSessionGroup(endpoint.workspaceId, sessionId, groupId); - writeMigrationComplete(endpoint); - return response.state; - }, - reorderGroups: async (workspaceId: string, groupIds: string[]) => { - const workspace = workspacesRef.current.find((item) => item.id === workspaceId); - const endpoint = endpointForWorkspaceRef.current(workspace); - if (!endpoint) return null; - const response = await endpoint.client.reorderSessionGroups(endpoint.workspaceId, groupIds); - writeMigrationComplete(endpoint); - return response.state; - }, - renameGroup: async (workspaceId: string, groupId: string, label: string) => { - const workspace = workspacesRef.current.find((item) => item.id === workspaceId); - const endpoint = endpointForWorkspaceRef.current(workspace); - if (!endpoint) return null; - const response = await endpoint.client.renameSessionGroup(endpoint.workspaceId, groupId, label); - writeMigrationComplete(endpoint); - return response.state; - }, - removeGroup: async (workspaceId: string, groupId: string) => { - const workspace = workspacesRef.current.find((item) => item.id === workspaceId); - const endpoint = endpointForWorkspaceRef.current(workspace); - if (!endpoint) return null; - const response = await endpoint.client.removeSessionGroup(endpoint.workspaceId, groupId); - writeMigrationComplete(endpoint); - return response.state; - }, - }); - return () => setSessionGroupSyncHandler(null); - }, []); - - useEffect(() => { - let cancelled = false; - - const syncWorkspace = async (workspace: RouteWorkspace, migrateLocal: boolean) => { - const endpoint = endpointForWorkspace(workspace); - if (!endpoint) return; - const version = beginSessionGroupServerSync(workspace.id); - - const response = await endpoint.client.getSessionGroups(endpoint.workspaceId); - if (cancelled) return; - - let nextState = response.state; - const localState = localGroupStateForWorkspace(workspace, endpoint); - if ( - migrateLocal && - !readMigrationComplete(endpoint) && - !hasGroupData(response.state) && - localState !== undefined - ) { - const migrated = await endpoint.client.putSessionGroups( - endpoint.workspaceId, - serverStateFromWorkspaceState(localState), - ); - if (cancelled) return; - nextState = migrated.state; - } - - writeMigrationComplete(endpoint); - applySessionGroupServerState(workspace.id, nextState, version); - }; - - for (const workspace of workspaces) { - void syncWorkspace(workspace, true).catch((error) => { - console.warn("[session-groups] initial sync failed", error); - }); - } - - const pollEvents = async () => { - if (pollInFlightRef.current) return; - pollInFlightRef.current = true; - try { - for (const workspace of workspacesRef.current) { - const endpoint = endpointForWorkspaceRef.current(workspace); - if (!endpoint) continue; - const key = `${endpoint.baseUrl}:${endpoint.workspaceId}`; - const currentCursor = eventCursorByWorkspaceRef.current[key]; - try { - const response = await endpoint.client.listSessionGroupEvents( - endpoint.workspaceId, - typeof currentCursor === "number" ? { since: currentCursor } : undefined, - ); - if (cancelled) return; - eventCursorByWorkspaceRef.current[key] = - typeof response.cursor === "number" - ? response.cursor - : Math.max(currentCursor ?? 0, ...((response.items ?? []).map((item) => Number(item.seq) || 0))); - if (currentCursor === undefined || currentCursor === null) continue; - if ((response.items ?? []).length === 0) continue; - await syncWorkspace(workspace, false); - } catch { - // Best effort: normal workspace/session loading still surfaces connection issues. - } - } - } finally { - pollInFlightRef.current = false; - } - }; - - void pollEvents(); - const interval = window.setInterval(() => void pollEvents(), 3000); - return () => { - cancelled = true; - window.clearInterval(interval); - }; - }, [endpointForWorkspace, workspaces]); -} diff --git a/apps/app/src/react-app/shell/use-workspace-route-state.ts b/apps/app/src/react-app/shell/use-workspace-route-state.ts index 2766b36ef..d690a53af 100644 --- a/apps/app/src/react-app/shell/use-workspace-route-state.ts +++ b/apps/app/src/react-app/shell/use-workspace-route-state.ts @@ -20,9 +20,8 @@ import { createClient } from "@/app/lib/opencode"; import { createiPolloWorkServerClient, type iPolloWorkServerClient } from "@/app/lib/ipollowork-server"; import { isDesktopRuntime } from "@/app/lib/runtime-env"; import { - canonicalWorkspacesForWorkContext, + filterWorkspacesForWorkContext, finishWorkContextSwitch, - pruneServerWorkspacesForWorkContext, type WorkContextId, } from "@/app/lib/work-context"; import { @@ -46,6 +45,7 @@ import { isTransientStartupError, mapDesktopWorkspace, mergeRouteWorkspaces, + orderRouteWorkspaces, partitionInitialWorkspaceLoads, resolveKnownWorkspaceId, type RouteSession, @@ -364,10 +364,9 @@ export function useWorkspaceRouteState(input: UseWorkspaceRouteStateInput) { const workspaceBootstrapStartedAt = Date.now(); try { desktopList = await workspaceBootstrap() as WorkspaceList; - desktopWorkspaces = canonicalWorkspacesForWorkContext( + desktopWorkspaces = filterWorkspacesForWorkContext( (desktopList.workspaces ?? []).map(mapDesktopWorkspace), requestedContextId, - [resolveWorkspaceListSelectedId(desktopList)], ); } catch (error) { const message = describeRouteError(error); @@ -425,15 +424,12 @@ export function useWorkspaceRouteState(input: UseWorkspaceRouteStateInput) { const list = await ipolloworkClient.listWorkspaces(); logStartupTiming(`[startup] server workspaces loaded in ${Date.now() - serverWorkspacesStartedAt}ms`); const persistedActiveId = readActiveWorkspaceId(); - const nextWorkspaces = canonicalWorkspacesForWorkContext( - mergeRouteWorkspaces(list.items, desktopWorkspaces), - requestedContextId, - [ - routeWorkspaceId, - persistedActiveId, - resolveWorkspaceListSelectedId(desktopList), - list.activeId, - ], + const nextWorkspaces = orderRouteWorkspaces( + filterWorkspacesForWorkContext( + mergeRouteWorkspaces(list.items, desktopWorkspaces), + requestedContextId, + ), + workspacesRef.current.map((workspace) => workspace.id), ); if (!isCurrentRefresh()) return; @@ -489,19 +485,6 @@ export function useWorkspaceRouteState(input: UseWorkspaceRouteStateInput) { ); setLegacySelectedWorkspaceId(nextWorkspaceId); writeActiveWorkspaceId(nextWorkspaceId || null); - const canonicalServerWorkspaceId = nextWorkspaces.find((workspace) => - list.items.some((serverWorkspace) => serverWorkspace.id === workspace.id) - )?.id ?? ""; - if (canonicalServerWorkspaceId) { - void pruneServerWorkspacesForWorkContext( - ipolloworkClient, - list.items, - requestedContextId, - canonicalServerWorkspaceId, - ).catch((error) => { - console.warn("[session-route] failed to prune legacy workspace identities", error); - }); - } // Session surface is canonical server metadata. Populate the small // in-memory icon cache from that one source; no browser persistence or // legacy path probing is involved. @@ -534,12 +517,8 @@ export function useWorkspaceRouteState(input: UseWorkspaceRouteStateInput) { errors: {}, }); - // A context switch is not complete until the new context's chat list is - // ready. Finishing the branded transition before this request resolved - // briefly exposed the previous context's cached chats during fast, - // repeated Personal/Enterprise switches. There is exactly one - // canonical workspace per context now, so wait for that workspace's - // sessions before dismissing the transition. + // Wait for the selected project's sessions before completing a context + // switch. Other projects continue loading in the background. const initialLoads = partitionInitialWorkspaceLoads( nextWorkspaces, nextWorkspaceId, diff --git a/apps/app/src/react-app/shell/workspace-provider.ts b/apps/app/src/react-app/shell/workspace-provider.ts index fc3af3676..0d9e0a6ea 100644 --- a/apps/app/src/react-app/shell/workspace-provider.ts +++ b/apps/app/src/react-app/shell/workspace-provider.ts @@ -4,6 +4,7 @@ import type { Client } from "@/app/types"; type WorkspaceContextValue = { client: Client | null; + engineId?: string | null; opencodeBaseUrl: string; selectedWorkspaceRoot: string; }; @@ -12,6 +13,7 @@ const WorkspaceContext = React.createContext(null) type WorkspaceProviderProps = { client: Client | null; + engineId?: string | null; opencodeBaseUrl?: string; selectedWorkspaceRoot: string; children: React.ReactNode; @@ -19,13 +21,14 @@ type WorkspaceProviderProps = { export function WorkspaceProvider({ client, + engineId, opencodeBaseUrl = "", selectedWorkspaceRoot, children, }: WorkspaceProviderProps) { const value = React.useMemo( - () => ({ client, opencodeBaseUrl, selectedWorkspaceRoot }), - [client, opencodeBaseUrl, selectedWorkspaceRoot], + () => ({ client, engineId, opencodeBaseUrl, selectedWorkspaceRoot }), + [client, engineId, opencodeBaseUrl, selectedWorkspaceRoot], ); return React.createElement(WorkspaceContext.Provider, { value }, children); diff --git a/apps/app/tests/cloud-provider-reimport.test.ts b/apps/app/tests/cloud-provider-reimport.test.ts index 876dd7bc5..c171e7c2b 100644 --- a/apps/app/tests/cloud-provider-reimport.test.ts +++ b/apps/app/tests/cloud-provider-reimport.test.ts @@ -7,14 +7,15 @@ import type { } from "../src/app/lib/den"; import type { CloudImportedProvider } from "../src/app/cloud/import-state"; import { - buildRuntimeProviderPatch, getCloudManagedProviderId, getProviderModelIds, isCloudManagedProviderKey, isCloudProviderOutOfSync, } from "../src/react-app/domains/connections/provider-auth/cloud-provider-config"; +import { openCodeProviderEngineAdapter } from "../src/react-app/domains/connections/provider-auth/provider-engine-adapter"; const LPR_ID = "lpr_openrouter"; +const buildProviderPatch = openCodeProviderEngineAdapter.buildCloudProviderPatch; const makeModel = (id: string, name = id): DenOrgLlmProviderModel => ({ id, @@ -68,7 +69,7 @@ const patchModelKeys = (patch: Record): string[] => { describe("cloud provider runtime patch (re-import diff #2346)", () => { test("first import upserts the lpr_* entry with the initial model", () => { const provider = makeProvider([makeModel("model-x")], "2024-02-01T00:00:00.000Z"); - const patch = buildRuntimeProviderPatch(provider, LPR_ID); + const patch = buildProviderPatch(provider, LPR_ID); expect(Object.keys(patch)).toEqual([LPR_ID]); expect(patchModelKeys(patch)).toEqual(["model-x"]); }); @@ -78,18 +79,18 @@ describe("cloud provider runtime patch (re-import diff #2346)", () => { [makeModel("model-x"), makeModel("model-y")], "2024-03-01T00:00:00.000Z", ); - expect(patchModelKeys(buildRuntimeProviderPatch(updated, LPR_ID, LPR_ID))).toEqual([ + expect(patchModelKeys(buildProviderPatch(updated, LPR_ID, LPR_ID))).toEqual([ "model-x", "model-y", ]); const onlyY = makeProvider([makeModel("model-y")], "2024-04-01T00:00:00.000Z"); - expect(patchModelKeys(buildRuntimeProviderPatch(onlyY, LPR_ID, LPR_ID))).toEqual(["model-y"]); + expect(patchModelKeys(buildProviderPatch(onlyY, LPR_ID, LPR_ID))).toEqual(["model-y"]); }); test("a renamed provider id deletes the predecessor entry", () => { const provider = makeProvider([makeModel("model-x")], "2024-03-01T00:00:00.000Z"); - const patch = buildRuntimeProviderPatch(provider, LPR_ID, "lpr_previous"); + const patch = buildProviderPatch(provider, LPR_ID, "lpr_previous"); expect(patch["lpr_previous"]).toBeNull(); expect(patchModelKeys(patch)).toEqual(["model-x"]); }); diff --git a/apps/app/tests/composer-model-behavior-menu.test.ts b/apps/app/tests/composer-model-behavior-menu.test.ts index f7a39e251..27df3a9b6 100644 --- a/apps/app/tests/composer-model-behavior-menu.test.ts +++ b/apps/app/tests/composer-model-behavior-menu.test.ts @@ -6,6 +6,9 @@ import { modelSupportsAttachments, type ProviderCatalog, } from "../src/react-app/domains/session/surface/use-model-behavior"; +import { attachmentRequiresNativeModelSupport } from "../src/react-app/domains/session/sync/attachment-support"; +import { draftToParts } from "../src/react-app/shell/session-prompt"; +import type { ComposerDraft } from "../src/app/types"; const modelSelectPath = resolve(import.meta.dir, "../src/components/model-select.tsx"); const composerPath = resolve(import.meta.dir, "../src/react-app/domains/session/surface/composer/composer.tsx"); @@ -27,12 +30,50 @@ describe("Composer model and reasoning menu", () => { expect(modelSupportsAttachments(catalog, null)).toBe(false); }); - test("uses the selected model capability for both the attachment entry and send boundary", () => { + test("keeps file attachment available while guarding native media at the send boundary", () => { const route = readFileSync(sessionRoutePath, "utf8"); + const composer = readFileSync(composerPath, "utf8"); - expect(route).toContain("attachmentsEnabled: selectedModelSupportsAttachments"); - expect(route).toContain("draft.attachments.length > 0 && !selectedModelSupportsAttachments"); + expect(route).toContain("supportsNativeAttachments: selectedModelSupportsAttachments"); + expect(route).toContain("attachmentRequiresNativeModelSupport(attachment.mimeType)"); + expect(route).toContain("{ supportsNativeAttachments: selectedModelSupportsAttachments }"); expect(route).toContain('t("composer.attachments_require_multimodal")'); + expect(composer).not.toContain("attachmentsEnabled"); + }); + + test("uses text fallback for ordinary files on text-only models", async () => { + const attachment = new File(["export const answer = 42;"], "answer.ts", { type: "text/plain" }); + const draft: ComposerDraft = { + mode: "prompt", + text: "Review this file", + parts: [{ type: "text", text: "Review this file" }], + attachments: [{ + id: "attachment-1", + name: attachment.name, + mimeType: attachment.type, + size: attachment.size, + kind: "file", + file: attachment, + }], + }; + + const parts = await draftToParts(draft, "", undefined, undefined, { supportsNativeAttachments: false }); + + expect(parts).toEqual([ + { type: "text", text: "Review this file" }, + { + type: "text", + text: "Attached file: answer.ts\n\nexport const answer = 42;", + synthetic: true, + }, + ]); + }); + + test("requires native model support only for images and PDFs", () => { + expect(attachmentRequiresNativeModelSupport("image/png")).toBe(true); + expect(attachmentRequiresNativeModelSupport("application/pdf")).toBe(true); + expect(attachmentRequiresNativeModelSupport("text/plain")).toBe(false); + expect(attachmentRequiresNativeModelSupport("application/json")).toBe(false); }); test("exports reusable Composer model-list content", () => { diff --git a/apps/app/tests/managed-brand-header.test.ts b/apps/app/tests/managed-brand-header.test.ts index 94b381cd8..9fa2ddfb2 100644 --- a/apps/app/tests/managed-brand-header.test.ts +++ b/apps/app/tests/managed-brand-header.test.ts @@ -31,7 +31,7 @@ describe("managed brand header", () => { expect(source).toContain("DEFAULT_BRAND_LOGO_URL"); expect(source).toContain("mac:bg-sidebar/15"); expect(source).toContain("mac:backdrop-blur-2xl"); - expect(source).toContain('SidebarHeader className="gap-4 px-2 pb-8 pt-1'); + expect(source).toContain('SidebarHeader className="gap-3 px-2 pb-3 pt-1'); expect(brandThemeSource).toContain('publicAssetUrl("default-brand-avatar.jpg")'); expect(existsSync(new URL("../public/default-brand-avatar.jpg", import.meta.url))).toBe(true); }); diff --git a/apps/app/tests/provider-engine-adapter.test.ts b/apps/app/tests/provider-engine-adapter.test.ts new file mode 100644 index 000000000..847a29c7c --- /dev/null +++ b/apps/app/tests/provider-engine-adapter.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { parse } from "jsonc-parser"; + +import { + ProviderEngineAdapterRegistry, + openCodeProviderEngineAdapter, +} from "../src/react-app/domains/connections/provider-auth/provider-engine-adapter"; +import { providerListQueryKey } from "../src/react-app/infra/provider-list-query"; +import { DEFAULT_ENGINE_ID } from "@ipollowork/types/workspace"; + +function createOpenCodeProviderClient() { + const calls: Array<{ name: string; value?: unknown }> = []; + let disabledProviders = ["disabled-provider"]; + const providerList = { + all: [ + { + id: "opencode", + name: "OpenCode", + source: "api" as const, + env: [], + models: {}, + }, + ], + connected: ["opencode"], + default: { opencode: "default-model" }, + }; + + return { + calls, + client: { + provider: { + list: async () => ({ data: providerList }), + auth: async () => ({ data: { openai: [{ type: "oauth", label: "OpenAI" }] } }), + oauth: { + authorize: async (value: unknown) => { + calls.push({ name: "authorize", value }); + return { + data: { + url: "https://example.com/oauth", + method: "code" as const, + instructions: "Paste the code", + }, + }; + }, + callback: async (value: unknown) => { + calls.push({ name: "callback", value }); + return { data: true }; + }, + }, + }, + auth: { + set: async (value: unknown) => { + calls.push({ name: "set", value }); + return { data: true }; + }, + remove: async (value: unknown) => { + calls.push({ name: "remove", value }); + return { data: true }; + }, + }, + config: { + get: async () => ({ data: { disabled_providers: disabledProviders } }), + update: async (value: { config: { disabled_providers?: string[] } }) => { + disabledProviders = value.config.disabled_providers ?? []; + calls.push({ name: "config.update", value }); + return { data: true }; + }, + }, + instance: { + dispose: async () => ({ data: true }), + }, + }, + }; +} + +describe("provider engine adapters", () => { + test("keeps OpenCode as the only default adapter", () => { + const registry = new ProviderEngineAdapterRegistry([openCodeProviderEngineAdapter]); + expect(registry.ids()).toEqual([DEFAULT_ENGINE_ID]); + expect(registry.get()).toBe(openCodeProviderEngineAdapter); + expect(() => registry.get("deepseek-harness")).toThrow( + "Provider engine is not registered: deepseek-harness", + ); + }); + + test("separates provider caches by engine", () => { + expect(providerListQueryKey({ engineId: "opencode", baseUrl: "http://runtime" })) + .not.toEqual(providerListQueryKey({ engineId: "deepseek-harness", baseUrl: "http://runtime" })); + }); + + test("routes provider list, auth and disabled state through OpenCode", async () => { + const { calls, client } = createOpenCodeProviderClient(); + const connection = openCodeProviderEngineAdapter.connect(client); + + expect(await connection.listProviders()).toEqual({ + all: [{ id: "opencode", name: "OpenCode", source: "api", env: [], models: {} }], + connected: ["opencode"], + default: { opencode: "default-model" }, + }); + expect(await connection.listAuthMethods()).toEqual({ + openai: [{ type: "oauth", label: "OpenAI" }], + }); + expect(await connection.readDisabledProviders()).toEqual(["disabled-provider"]); + + await connection.setApiKey("tokenstar", "secret"); + await connection.removeCredentials("tokenstar"); + await connection.writeDisabledProviders(["tokenstar"]); + + expect(await connection.readDisabledProviders()).toEqual(["tokenstar"]); + expect(calls.map((entry) => entry.name)).toEqual(["set", "remove", "config.update"]); + }); + + test("materializes compatible providers only inside the OpenCode adapter", () => { + expect( + openCodeProviderEngineAdapter.buildCompatibleProviderPatch({ + id: "tokenstar", + name: "TokenStar", + baseURL: "https://api.tokenstar.io/v1", + models: { model: { name: "Model" } }, + }), + ).toEqual({ + tokenstar: { + npm: "@ai-sdk/openai-compatible", + name: "TokenStar", + options: { baseURL: "https://api.tokenstar.io/v1" }, + models: { model: { name: "Model" } }, + }, + }); + }); + + test("removes project provider state without leaving disabled entries", () => { + const raw = `{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "tokenstar": { "name": "TokenStar" }, + "other": { "name": "Other" } + }, + "disabled_providers": ["tokenstar", "other"] +} +`; + const updated = openCodeProviderEngineAdapter.formatProjectWithoutProvider( + raw, + "tokenstar", + ["tokenstar", "other"], + ); + const config = parse(updated); + + expect(config.provider).toEqual({ other: { name: "Other" } }); + expect(config.disabled_providers).toEqual(["other"]); + }); +}); diff --git a/apps/app/tests/route-workspaces.test.ts b/apps/app/tests/route-workspaces.test.ts index 4a650c2d4..a532fc8ac 100644 --- a/apps/app/tests/route-workspaces.test.ts +++ b/apps/app/tests/route-workspaces.test.ts @@ -5,8 +5,9 @@ import { buildTaskPaletteSessionOptions, mapDesktopWorkspace, mergeRouteWorkspaces, + orderRouteWorkspaces, partitionInitialWorkspaceLoads, - toSessionGroups, + toProjectSessionLists, resolveKnownWorkspaceId, userVisibleSessionsByWorkspaceId, } from "../src/react-app/shell/route-workspaces"; @@ -58,6 +59,20 @@ describe("route workspaces", () => { expect(mergeRouteWorkspaces([], desktop).map((workspace) => workspace.id)).toEqual(["ws_local"]); }); + test("keeps existing project positions stable while placing newly created projects first", () => { + const workspaces = [ + mapDesktopWorkspace(localWorkspace("selected", "/workspace/selected")), + mapDesktopWorkspace(localWorkspace("new", "/workspace/new")), + mapDesktopWorkspace(localWorkspace("older", "/workspace/older")), + ]; + + expect(orderRouteWorkspaces(workspaces, ["older", "selected"]).map((workspace) => workspace.id)).toEqual([ + "new", + "older", + "selected", + ]); + }); + test("falls through from a stale remembered workspace to a current server workspace", () => { const workspaces = [mapDesktopWorkspace(localWorkspace("ws_live", "/Users/example/current"))]; @@ -151,9 +166,9 @@ describe("route workspaces", () => { }; const workspace = mapDesktopWorkspace(localWorkspace("ws", "/Users/example/current")); const visible = userVisibleSessionsByWorkspaceId(raw); - const groups = toSessionGroups([workspace], visible, {}, new Set()); + const projects = toProjectSessionLists([workspace], visible, {}, new Set()); - expect(groups[0]?.sessions).toBe(visible.ws); + expect(projects[0]?.sessions).toBe(visible.ws); expect(visible.ws.map((session) => session.id)).toEqual(["visible"]); }); diff --git a/apps/app/tests/sidebar-program-sections.test.ts b/apps/app/tests/sidebar-program-sections.test.ts deleted file mode 100644 index a8f91ef4a..000000000 --- a/apps/app/tests/sidebar-program-sections.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; - -const sidebarSource = readFileSync( - new URL("../src/react-app/domains/session/sidebar/app-sidebar.tsx", import.meta.url), - "utf8", -); -const storeSource = readFileSync( - new URL("../src/react-app/domains/session/sidebar/session-management-store.ts", import.meta.url), - "utf8", -); -const sessionPageSource = readFileSync( - new URL("../src/react-app/domains/session/chat/session-page.tsx", import.meta.url), - "utf8", -); - -describe("sidebar Program and Ungrouped sections", () => { - test("uses the exported Figma assets and labels", () => { - expect(sidebarSource).toContain("sidebar-icon/figma-folder-closed.svg"); - expect(sidebarSource).toContain("sidebar-icon/figma-section-chevron-down.svg"); - expect(sidebarSource).toContain("sidebar-icon/figma-section-${name}.svg"); - expect(sidebarSource).toContain(''); - expect(sidebarSource).toContain(''); - expect(sidebarSource).toContain('label={t("session_management.program")}'); - expect(sidebarSource).toContain('label={t("session_management.ungrouped")}'); - }); - - test("keeps ungrouped sessions only in Ungrouped", () => { - expect(sidebarSource).toContain("ungroupedRows.push(row)"); - expect(sidebarSource).toContain("{ungroupedRows.length > 0 ? ("); - expect(sidebarSource).toContain('rootIndent="recent"'); - expect(sidebarSource).not.toContain('label={t("session_management.recently")}'); - }); - - test("orders grouped, ungrouped, and child conversations by most recent activity", () => { - expect(sidebarSource).toContain("const compareByMostRecent"); - expect(sidebarSource).toContain("right.session.time?.updated ?? right.session.time?.created"); - expect(sidebarSource).toContain("rootRowsByGroup.values()) rows.sort(compareByMostRecent)"); - expect(sidebarSource).toContain("childrenByParent.values()) rows.sort(compareByMostRecent)"); - expect(sidebarSource).toContain("ungroupedRows.sort(compareByMostRecent)"); - }); - - test("keeps rename and delete in the group's more menu", () => { - expect(sidebarSource).toContain('t("session_management.group_actions"'); - expect(sidebarSource).toContain('t("session_management.rename_group")'); - expect(sidebarSource).toContain('t("session_management.remove_group")'); - }); - - test("creates a conversation in the current group from the unified plus action", () => { - expect(sidebarSource).toContain('aria-label={t("session_management.new_conversation_in_group"'); - expect(sidebarSource).toContain(''); - expect(sidebarSource).toContain('Promise.resolve(ctx.onCreateTaskInWorkspace(workspaceId, "work")).then((sessionId) =>'); - expect(sidebarSource).toContain("assignGroup(workspaceId, sessionId, group.id)"); - }); - - test("keeps global new conversations and template launches in the active project group", () => { - expect(storeSource).toContain("activeGroupByWorkspace: Record;"); - expect(storeSource).toContain("setActiveGroup: (workspaceId: string, groupId: string | null) => void;"); - expect(sidebarSource).toContain("const activeSessionGroupId = useActiveWorkspaceGroupId(props.selectedWorkspaceId)"); - expect(sidebarSource).toContain("isActive={activeSessionGroupId === group.id}"); - expect(sidebarSource).toContain("setActiveGroup(workspaceId, group.id)"); - expect(sidebarSource).toContain('props.onCreateTaskInWorkspace(props.selectedWorkspaceId, "work", undefined, undefined, activeSessionGroupId)'); - expect(sessionPageSource).toContain("const activeSessionGroupId = useActiveWorkspaceGroupId(props.selectedWorkspaceId)"); - expect(sessionPageSource).toContain("templateResourceScope,\n activeSessionGroupId,"); - expect(sessionPageSource).toContain("props.sidebar.onCreateTemplateAuthoring(props.selectedWorkspaceId, input, activeSessionGroupId)"); - }); - - test("reveals section and conversation actions only during interaction", () => { - expect(sidebarSource).toContain("group-hover/section-header:opacity-100"); - expect(sidebarSource).toContain("group-focus-within/section-header:opacity-100"); - expect(sidebarSource).toContain("group-hover/session-group-row:opacity-100"); - expect(sidebarSource).toContain("opacity-0 group-hover/menu-sub-item:opacity-100"); - expect(sidebarSource).not.toContain("actionsAlwaysVisible"); - }); - - test("reveals Program and Ungrouped disclosure icons only during interaction", () => { - expect(sidebarSource).toContain("opacity-0 transition-[transform,opacity] group-hover/section-header:opacity-100"); - expect(sidebarSource).toContain("group-focus-within/section-header:opacity-100"); - }); - - test("adds four-pixel horizontal padding to Ungrouped conversation controls", () => { - expect(sidebarSource).toContain('rootIndent === "recent" ? "ps-1"'); - expect(sidebarSource).toContain('rootIndent === "recent" ? "right-1"'); - }); - - test("matches the 32-pixel folder row and four-pixel conversation gap", () => { - expect(sidebarSource).toContain('group/session-group-row flex h-8 w-full items-center justify-between rounded-[8px] px-1'); - expect(sidebarSource).toContain('className="group/session-group flex flex-col gap-1"'); - expect(sidebarSource).toContain(''); - expect(sidebarSource).toContain('className="flex h-8 shrink-0 items-center gap-1 opacity-0'); - }); - - test("supports renaming and safely removing projects", () => { - expect(storeSource).toContain("renameGroup: (workspaceId, groupId, label) =>"); - expect(storeSource).toContain("sessionGroupSyncHandler?.renameGroup(workspaceId, groupId, nextLabel)"); - expect(sessionPageSource).toContain('title={t("session_management.remove_group_title")}'); - expect(sessionPageSource).toContain("removeGroup(removeGroupTarget.workspaceId, removeGroupTarget.groupId)"); - }); - - test("renders the selected workspace history instead of the first workspace", () => { - expect(sidebarSource).toContain("const sidebarWorkspaceSessionGroups = React.useMemo"); - expect(sidebarSource).toContain("entry.workspace.id === props.selectedWorkspaceId"); - expect(sidebarSource).toContain("{sidebarWorkspaceSessionGroups.map((group) => ("); - expect(sidebarSource).not.toContain("{props.workspaceSessionGroups.slice(0, 1).map((group) => ("); - }); -}); diff --git a/apps/app/tests/sidebar-projects.test.ts b/apps/app/tests/sidebar-projects.test.ts new file mode 100644 index 000000000..bfd509dea --- /dev/null +++ b/apps/app/tests/sidebar-projects.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; + +import { buildSessionTreeState } from "../src/react-app/domains/session/sidebar/utils"; + +const sidebarSource = readFileSync( + new URL("../src/react-app/domains/session/sidebar/app-sidebar.tsx", import.meta.url), + "utf8", +); +const pinStoreSource = readFileSync( + new URL("../src/react-app/domains/session/sidebar/session-pin-store.ts", import.meta.url), + "utf8", +); +const sessionPageSource = readFileSync( + new URL("../src/react-app/domains/session/chat/session-page.tsx", import.meta.url), + "utf8", +); +const sessionRouteSource = readFileSync( + new URL("../src/react-app/shell/session-route.tsx", import.meta.url), + "utf8", +); + +describe("sidebar projects", () => { + test("renders every project as a first-level sidebar folder", () => { + expect(sidebarSource).not.toContain("function ProjectSwitcher"); + expect(sidebarSource).not.toContain("selectedProjectSessionLists"); + expect(sidebarSource).toContain("props.projectSessionLists.map((project)"); + expect(sidebarSource).toContain('data-testid="project-row"'); + expect(sidebarSource).toContain('aria-current={isSelectedProject ? "page" : undefined}'); + expect(sidebarSource).toContain('aria-expanded={projectExpanded}'); + expect(sidebarSource).toContain("onSelectProject(workspace.id)"); + expect(sidebarSource).toContain(" { + expect(sidebarSource).toContain('data-testid="new-conversation-and-project-actions"'); + expect(sidebarSource).toContain('data-testid="new-project-button"'); + expect(sidebarSource).toContain('t("session.new_task")'); + expect(sidebarSource).toContain('t("projects.create")'); + }); + + test("manages project folders without restoring the legacy workspace UI", () => { + expect(sidebarSource).toContain("onDoubleClick={() => setProjectExpanded((expanded) => !expanded)}"); + expect(sidebarSource).toContain('data-testid="project-new-conversation-button"'); + expect(sidebarSource).toContain("const createConversationInProject = async () =>"); + expect(sidebarSource).toMatch(/await onSelectProject\(workspace\.id\);[\s\S]*await ctx\.onCreateTaskInWorkspace\(workspace\.id\);/); + expect(sidebarSource).toContain('disabled={showInitialLoading || isConnectionActionBusy}'); + expect(sessionRouteSource).not.toContain("retryingWorkspaceIds.includes(workspaceId)"); + expect(sidebarSource).toContain('t("projects.rename")'); + expect(sidebarSource).toContain('t("projects.show_in_folder")'); + expect(sessionPageSource).toContain("pickDirectory({ title: t(\"projects.choose_folder\") })"); + expect(sessionPageSource).toContain("props.sidebar.onCreateProject({ name, folderPath })"); + expect(sidebarSource).not.toContain("WorkspaceHeader"); + expect(sidebarSource).not.toContain("WorkspaceActionsMenu"); + }); + + test("shows nested conversation activity on a collapsed project", () => { + const tree = buildSessionTreeState( + [ + { id: "root", title: "Root" }, + { id: "child", title: "Child", parentID: "root" }, + ], + { child: "responding" }, + ); + + expect(tree.activeIds.has("root")).toBe(true); + expect(tree.streamingIds.has("root")).toBe(true); + expect(sidebarSource).toContain("!projectExpanded ? ("); + expect(sidebarSource).toContain("isStreaming={projectIsStreaming}"); + expect(sidebarSource).toContain("isActive={projectIsActive}"); + }); + + test("renders conversations directly under each project", () => { + expect(sidebarSource).toContain("function ConversationList"); + expect(sidebarSource).toContain("flattenSessionRows("); + expect(sidebarSource).toContain("remainingSessionCount"); + expect(sidebarSource).not.toContain("GroupedSessionList"); + }); + + test("keeps only the independent pin preference store", () => { + expect(pinStoreSource).toContain('name: "ipollowork.react.sessionPins"'); + expect(pinStoreSource).toContain("togglePin"); + expect(existsSync(new URL("../src/react-app/domains/session/sidebar/session-management-store.ts", import.meta.url))).toBe(false); + expect(existsSync(new URL("../src/react-app/shell/use-session-group-sync.ts", import.meta.url))).toBe(false); + }); + + test("contains no session-group product or API surface", () => { + const combined = `${sidebarSource}\n${sessionPageSource}`; + expect(combined).not.toContain("SessionGroup"); + expect(combined).not.toContain("session-groups"); + expect(combined).not.toContain("groupsByWorkspace"); + expect(combined).not.toContain("onOpenCreateGroupModal"); + }); +}); diff --git a/apps/app/tests/work-context-entry-wiring.test.ts b/apps/app/tests/work-context-entry-wiring.test.ts index e70ca5eb5..fe3cc1486 100644 --- a/apps/app/tests/work-context-entry-wiring.test.ts +++ b/apps/app/tests/work-context-entry-wiring.test.ts @@ -55,15 +55,16 @@ describe("personal and Enterprise chat entry wiring", () => { expect(cloudAccount).toContain('`#/workspace/${encodeURIComponent(personalWorkspaceId)}/session`'); }); - test("scopes workspaces and therefore all sessions to the active work context", () => { - expect(routeState).toContain("canonicalWorkspacesForWorkContext("); - expect(routeState).toContain("pruneServerWorkspacesForWorkContext("); + test("keeps all projects and sessions scoped to the active work context", () => { + expect(routeState).toContain("filterWorkspacesForWorkContext("); + expect(routeState).not.toContain("canonicalWorkspacesForWorkContext("); + expect(routeState).not.toContain("pruneServerWorkspacesForWorkContext("); expect(routeState).toContain("workContextRef.current === requestedContextId"); expect(sessionRoute).toContain("workContextId: activeWorkContextId"); expect(sessionRoute).toContain("sessionsByWorkspaceId,"); expect(sessionRoute).not.toContain("ChatSpace"); expect(workContext).toContain('joinDesktopPath(homeDir, ".ipollowork", "work-contexts", connection.id)'); - expect(workContext).not.toContain("rememberWorkspaceForWorkContext"); + expect(workContext).toContain("rememberProjectForWorkContext"); }); test("keeps market launches scoped while the starter catalog stays personal", () => { @@ -75,7 +76,13 @@ describe("personal and Enterprise chat entry wiring", () => { expect(sessionRoute).toContain("deleteSession(endpoint.workspaceId, createdSessionId)"); }); - test("removes the legacy workstation switch and cloud organization mapping", () => { + test("restores lightweight project management without the legacy workspace UI", () => { + expect(sidebar).not.toContain("function ProjectSwitcher"); + expect(sidebar).toContain('data-testid="project-row"'); + expect(sidebar).toContain('data-testid="new-project-button"'); + expect(sidebar).toContain("onSelectProject"); + expect(sessionRoute).toContain("createLocalWorkspace"); + expect(sessionRoute).toContain("deleteWorkspace"); expect(sidebar).not.toContain("WorkspaceHeader"); expect(sidebar).not.toContain("WorkspaceActionsMenu"); expect(sidebar).not.toContain("onReorderWorkspaces"); diff --git a/apps/app/tests/work-context.test.ts b/apps/app/tests/work-context.test.ts index 2657166ff..ba21500e5 100644 --- a/apps/app/tests/work-context.test.ts +++ b/apps/app/tests/work-context.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { - canonicalWorkspaceForWorkContext, enterpriseWorkContextId, filterWorkspacesForWorkContext, normalizeWorkContextId, @@ -12,42 +11,26 @@ import { describe("work context identity", () => { const workspaces = [ { id: "personal-a", path: "/Users/test/iPolloWork", workspaceType: "local" as const, workContextId: null }, - { id: "legacy-personal", path: "/Users/test/iPolloWork/.ipollowork/workstations/old", workspaceType: "local" as const }, - { id: "enterprise-a", path: "/Users/test/.ipollowork/work-contexts/ent_alpha", workspaceType: "local" as const, workContextId: "enterprise:ent_alpha" as const }, - { id: "enterprise-a-old", path: "/Users/test/iPolloWork/.ipollowork/workstations/enterprise-old", workspaceType: "local" as const, workContextId: "enterprise:ent_alpha" as const }, + { id: "personal-b", path: "/Users/test/ClientWork", workspaceType: "local" as const }, + { id: "enterprise-a", path: "/Users/test/MedicalOne", workspaceType: "local" as const, workContextId: "enterprise:ent_alpha" as const }, + { id: "enterprise-a-two", path: "/Users/test/MedicalTwo", workspaceType: "local" as const, workContextId: "enterprise:ent_alpha" as const }, { id: "enterprise-b", path: "/Users/test/.ipollowork/work-contexts/ent_beta", workspaceType: "local" as const, workContextId: "enterprise:ent_beta" as const }, ]; - test("treats unmarked legacy workspaces as Personal", () => { + test("keeps every Personal project", () => { expect(filterWorkspacesForWorkContext(workspaces, PERSONAL_WORK_CONTEXT_ID).map((item) => item.id)).toEqual([ "personal-a", - "legacy-personal", + "personal-b", ]); }); - test("returns only the exact enterprise workspace", () => { + test("keeps every project in the exact Enterprise space", () => { expect(filterWorkspacesForWorkContext(workspaces, enterpriseWorkContextId("ent_alpha")).map((item) => item.id)).toEqual([ "enterprise-a", - "enterprise-a-old", + "enterprise-a-two", ]); }); - test("keeps one canonical Personal space instead of a historical workstation", () => { - expect(canonicalWorkspaceForWorkContext( - workspaces, - PERSONAL_WORK_CONTEXT_ID, - ["legacy-personal"], - )?.id).toBe("personal-a"); - }); - - test("keeps the dedicated Enterprise context path instead of an older workstation", () => { - expect(canonicalWorkspaceForWorkContext( - workspaces, - enterpriseWorkContextId("ent_alpha"), - ["enterprise-a-old"], - )?.id).toBe("enterprise-a"); - }); - test("rejects malformed or obsolete context values", () => { expect(normalizeWorkContextId("enterprise:ent_alpha")).toBe("enterprise:ent_alpha"); expect(normalizeWorkContextId("team:old")).toBeNull(); diff --git a/apps/desktop/electron/workspace-store.mjs b/apps/desktop/electron/workspace-store.mjs index 838bce2ee..4c9e9110a 100644 --- a/apps/desktop/electron/workspace-store.mjs +++ b/apps/desktop/electron/workspace-store.mjs @@ -800,74 +800,24 @@ export function createWorkspaceStore({ app, defaultDenBaseUrl, defaultRequireSig }; } - function workspaceContextKey(workspace) { - return typeof workspace?.workContextId === "string" && workspace.workContextId.startsWith("enterprise:") - ? workspace.workContextId - : "personal"; - } - - function isLegacyWorkstationPath(workspacePath) { - return /(?:^|[/\\])\.ipollowork[/\\]workstations[/\\]/i.test(String(workspacePath ?? "").trim()); - } - - function canonicalWorkspaceForContext(workspaces, contextKey, preferredId) { - if (workspaces.length === 0) return null; - if (contextKey === "personal") { - const defaultPathKey = normalizeWorkspacePathKey(firstRunDefaultWorkspaceDir()); - return workspaces.find((workspace) => normalizeWorkspacePathKey(workspace.path) === defaultPathKey) - ?? workspaces.find((workspace) => workspace.id === preferredId && !isLegacyWorkstationPath(workspace.path)) - ?? workspaces.find((workspace) => workspace.workspaceType !== "remote" && !isLegacyWorkstationPath(workspace.path)) - ?? workspaces.find((workspace) => !isLegacyWorkstationPath(workspace.path)) - ?? workspaces.find((workspace) => workspace.id === preferredId) - ?? workspaces[0]; - } - - const enterpriseId = contextKey.slice("enterprise:".length); - const dedicatedPath = new RegExp(`(?:^|[/\\\\])\\.ipollowork[/\\\\]work-contexts[/\\\\]${enterpriseId}(?:[/\\\\]|$)`, "i"); - return workspaces.find((workspace) => workspace.id === preferredId && dedicatedPath.test(workspace.path)) - ?? workspaces.find((workspace) => dedicatedPath.test(workspace.path)) - ?? workspaces.find((workspace) => workspace.id === preferredId) - ?? workspaces.find((workspace) => workspace.workspaceType !== "remote") - ?? workspaces[0]; - } - - function collapseLegacyWorkspaceIdentities(workspaces, preferredId) { - const grouped = new Map(); - for (const workspace of workspaces) { - const contextKey = workspaceContextKey(workspace); - const existing = grouped.get(contextKey) ?? []; - existing.push(workspace); - grouped.set(contextKey, existing); - } - - const canonical = []; - for (const [contextKey, contextWorkspaces] of grouped) { - const workspace = canonicalWorkspaceForContext(contextWorkspaces, contextKey, preferredId); - if (!workspace) continue; - canonical.push(contextKey === "personal" - ? { ...workspace, name: "Personal", displayName: "Personal" } - : workspace); - } - return canonical; - } - - async function pruneLegacyWorkspaceTokenEntries(canonicalWorkspaces) { + async function pruneWorkspaceTokenEntries(workspaces) { const store = await readJsonFile(ipolloworkServerTokenStorePath(), null); if (!isRecord(store) || !isRecord(store.workspaces)) return false; - const canonicalPathKeys = new Set(); - for (const workspace of canonicalWorkspaces) { + const registeredPathKeys = new Set(); + for (const workspace of workspaces) { if (workspace.workspaceType === "remote") continue; - const canonicalPath = await normalizeLocalWorkspacePath(workspace.path); - const canonicalPathKey = normalizeWorkspacePathKey(canonicalPath); - if (canonicalPathKey) canonicalPathKeys.add(canonicalPathKey); + const workspacePath = await normalizeLocalWorkspacePath(workspace.path); + const pathKey = normalizeWorkspacePathKey(workspacePath); + if (pathKey) registeredPathKeys.add(pathKey); } + const retainedEntries = []; for (const entry of Object.entries(store.workspaces)) { const recoveredPath = normalizeRecoveredWorkspacePath(entry[0]); if (!recoveredPath) continue; - const canonicalPath = await normalizeLocalWorkspacePath(recoveredPath); - if (canonicalPathKeys.has(normalizeWorkspacePathKey(canonicalPath))) retainedEntries.push(entry); + const workspacePath = await normalizeLocalWorkspacePath(recoveredPath); + if (registeredPathKeys.has(normalizeWorkspacePathKey(workspacePath))) retainedEntries.push(entry); } if (retainedEntries.length === Object.keys(store.workspaces).length) return false; @@ -875,7 +825,7 @@ export function createWorkspaceStore({ app, defaultDenBaseUrl, defaultRequireSig ...store, workspaces: Object.fromEntries(retainedEntries), }); - console.info("[migration] removed legacy workspace token entries", { + console.info("[migration] removed unregistered workspace token entries", { removedCount: Object.keys(store.workspaces).length - retainedEntries.length, }); return true; @@ -1023,37 +973,27 @@ export function createWorkspaceStore({ app, defaultDenBaseUrl, defaultRequireSig changed = true; } - const canonicalWorkspaces = collapseLegacyWorkspaceIdentities(dedupedWorkspaces, idMap.get(selectedId) ?? selectedId); - if (canonicalWorkspaces.length !== dedupedWorkspaces.length) changed = true; - if (canonicalWorkspaces.some((workspace) => { - const previous = dedupedWorkspaces.find((entry) => entry.id === workspace.id); - return previous?.name !== workspace.name || previous?.displayName !== workspace.displayName; - })) changed = true; - const canonicalIds = new Set(canonicalWorkspaces.map((workspace) => workspace.id)); - const canonicalPersonalId = canonicalWorkspaces.find((workspace) => workspaceContextKey(workspace) === "personal")?.id ?? ""; - const resolveCanonicalId = (workspaceId) => { + const workspaceIds = new Set(dedupedWorkspaces.map((workspace) => workspace.id)); + const fallbackWorkspaceId = dedupedWorkspaces[0]?.id ?? ""; + const resolveWorkspaceId = (workspaceId) => { const migratedId = idMap.get(workspaceId) ?? workspaceId; - if (canonicalIds.has(migratedId)) return migratedId; - const previous = dedupedWorkspaces.find((workspace) => workspace.id === migratedId); - const contextKey = previous ? workspaceContextKey(previous) : "personal"; - return canonicalWorkspaces.find((workspace) => workspaceContextKey(workspace) === contextKey)?.id - ?? canonicalPersonalId; + return workspaceIds.has(migratedId) ? migratedId : fallbackWorkspaceId; }; - const migratedSelectedId = resolveCanonicalId(selectedId); - const migratedWatchedId = watchedId ? resolveCanonicalId(watchedId) || null : null; - const migratedActiveId = activeId ? resolveCanonicalId(activeId) || null : null; + const migratedSelectedId = resolveWorkspaceId(selectedId); + const migratedWatchedId = watchedId ? resolveWorkspaceId(watchedId) || null : null; + const migratedActiveId = activeId ? resolveWorkspaceId(activeId) || null : null; if (migratedSelectedId !== selectedId || migratedWatchedId !== watchedId || migratedActiveId !== activeId) changed = true; const nextState = { selectedId: migratedSelectedId, watchedId: migratedWatchedId, activeId: migratedActiveId, - workspaces: canonicalWorkspaces, + workspaces: dedupedWorkspaces, }; - if (canonicalWorkspaces.length > 0) { - await pruneLegacyWorkspaceTokenEntries(canonicalWorkspaces); + if (dedupedWorkspaces.length > 0) { + await pruneWorkspaceTokenEntries(dedupedWorkspaces); } if (changed) { diff --git a/apps/desktop/electron/workspace-store.test.mjs b/apps/desktop/electron/workspace-store.test.mjs index ac7fa4474..7b89e9171 100644 --- a/apps/desktop/electron/workspace-store.test.mjs +++ b/apps/desktop/electron/workspace-store.test.mjs @@ -163,29 +163,29 @@ test("migrates an older enterprise workspace from its dedicated context path", a }); }); -test("collapses historical workstations into one Personal and one Enterprise space", async () => { +test("preserves multiple projects in Personal and Enterprise spaces", async () => { await withIsolatedBootstrapStore(async ({ createStore, root, userDataPath }) => { - const personalPath = path.join(root, "personal"); - const legacyPersonalPath = path.join(personalPath, ".ipollowork", "workstations", "old-personal"); - const enterprisePath = path.join(root, ".ipollowork", "work-contexts", "ent_medical"); - const legacyEnterprisePath = path.join(personalPath, ".ipollowork", "workstations", "old-enterprise"); + const personalPath = path.join(root, "personal-one"); + const personalSecondPath = path.join(root, "personal-two"); + const enterprisePath = path.join(root, "enterprise-one"); + const enterpriseSecondPath = path.join(root, "enterprise-two"); await Promise.all([ mkdir(personalPath, { recursive: true }), - mkdir(legacyPersonalPath, { recursive: true }), + mkdir(personalSecondPath, { recursive: true }), mkdir(enterprisePath, { recursive: true }), - mkdir(legacyEnterprisePath, { recursive: true }), + mkdir(enterpriseSecondPath, { recursive: true }), mkdir(userDataPath, { recursive: true }), ]); await writeFile( path.join(userDataPath, "ipollowork-workspaces.json"), JSON.stringify({ - selectedId: "legacy-personal", - activeId: "legacy-personal", + selectedId: "personal-two", + activeId: "personal-two", workspaces: [ - { id: "personal", name: "Personal", path: personalPath, workspaceType: "local" }, - { id: "legacy-personal", name: "Old Personal", path: legacyPersonalPath, workspaceType: "local" }, - { id: "enterprise", name: "Medical", path: enterprisePath, workspaceType: "local", workContextId: "enterprise:ent_medical" }, - { id: "legacy-enterprise", name: "Old Medical", path: legacyEnterprisePath, workspaceType: "local", workContextId: "enterprise:ent_medical" }, + { id: "personal-one", name: "Personal One", path: personalPath, workspaceType: "local" }, + { id: "personal-two", name: "Personal Two", path: personalSecondPath, workspaceType: "local" }, + { id: "enterprise-one", name: "Medical One", path: enterprisePath, workspaceType: "local", workContextId: "enterprise:ent_medical" }, + { id: "enterprise-two", name: "Medical Two", path: enterpriseSecondPath, workspaceType: "local", workContextId: "enterprise:ent_medical" }, ], }), "utf8", @@ -196,23 +196,31 @@ test("collapses historical workstations into one Personal and one Enterprise spa version: 1, workspaces: { [personalPath]: { token: "personal-token" }, - [legacyPersonalPath]: { token: "old-personal-token" }, + [personalSecondPath]: { token: "personal-two-token" }, [enterprisePath]: { token: "enterprise-token" }, - [legacyEnterprisePath]: { token: "old-enterprise-token" }, + [enterpriseSecondPath]: { token: "enterprise-two-token" }, }, }), "utf8", ); const state = await createStore().readWorkspaceState(); - assert.deepEqual(state.workspaces.map((workspace) => workspace.id), ["personal", "enterprise"]); - assert.equal(state.workspaces[0].name, "Personal"); - assert.equal(state.workspaces[0].displayName, "Personal"); - assert.equal(state.selectedId, "personal"); - assert.equal(state.activeId, "personal"); + assert.deepEqual(state.workspaces.map((workspace) => workspace.id), [ + "personal-one", + "personal-two", + "enterprise-one", + "enterprise-two", + ]); + assert.equal(state.selectedId, "personal-two"); + assert.equal(state.activeId, "personal-two"); const tokenStore = JSON.parse(await readFile(path.join(userDataPath, "ipollowork-server-tokens.json"), "utf8")); - assert.deepEqual(Object.keys(tokenStore.workspaces).sort(), [enterprisePath, personalPath].sort()); + assert.deepEqual(Object.keys(tokenStore.workspaces).sort(), [ + enterprisePath, + enterpriseSecondPath, + personalPath, + personalSecondPath, + ].sort()); }); }); @@ -254,7 +262,7 @@ test("prefers server config workspaces when desktop state is empty", async () => const state = await store.readWorkspaceState(); assert.equal(state.workspaces.length, 1); assert.equal(state.workspaces[0].path, oldWorkspaceReal); - assert.equal(state.workspaces[0].name, "Personal"); + assert.equal(state.workspaces[0].name, "From Server"); } finally { if (previous === undefined) delete process.env.IPOLLOWORK_SERVER_CONFIG; else process.env.IPOLLOWORK_SERVER_CONFIG = previous; diff --git a/apps/server/src/opencode-plugins/ipollowork-capabilities-knowledge.ts b/apps/server/src/opencode-plugins/ipollowork-capabilities-knowledge.ts index e59ab3759..00e735ee8 100644 --- a/apps/server/src/opencode-plugins/ipollowork-capabilities-knowledge.ts +++ b/apps/server/src/opencode-plugins/ipollowork-capabilities-knowledge.ts @@ -31,7 +31,7 @@ Important docs to know: - Desktop policies: packages/docs/cloud/share-with-your-team/desktop-policies.mdx - Local MCP setup: packages/docs/start-here/connect-your-stack/add-an-mcp-server.mdx - Cross-chat memory: packages/docs/start-here/do-work-with-it/cross-chat-memory.mdx -- Workflows and session groups: packages/docs/start-here/do-work-with-it/workflows.mdx +- Projects and conversation workflows: packages/docs/start-here/do-work-with-it/workflows.mdx Here is what you can help users with: diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index cbd5897da..d3f58f5e4 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -1,15 +1,6 @@ import type { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; import { ApiError } from "../errors.js"; import { buildSession, buildSessionList, buildSessionMessages, buildSessionSnapshot } from "../session-read-model.js"; -import { - createSessionGroupId, - normalizeSessionGroupState, - readSessionGroupState, - SessionGroupEventStore, - updateSessionGroupState, - type SessionGroupDefinition, - type SessionGroupState, -} from "../session-groups.js"; import type { ServerConfig, TokenScope, WorkspaceInfo } from "../types.js"; import { addRoute, type RequestContext, type Route } from "./registry.js"; @@ -17,7 +8,6 @@ type JsonResponse = (data: unknown, status?: number) => Response; type ParseOptionalBoolean = (value: string | null, name: string) => boolean | undefined; type ParseOptionalPositiveInteger = (value: string | null, name: string) => number | undefined; type ParseOptionalNonNegativeInteger = (value: string | null, name: string) => number | undefined; -type ReadJsonBody = (request: Request) => Promise>; type WorkspaceOpencodeClient = ReturnType; type OpencodeClientResult = | { data: T | undefined; error: undefined; response: Response } @@ -31,7 +21,6 @@ interface RegisterSessionRoutesOptions { parseOptionalBoolean: ParseOptionalBoolean; parseOptionalPositiveInteger: ParseOptionalPositiveInteger; parseOptionalNonNegativeInteger: ParseOptionalNonNegativeInteger; - readJsonBody: ReadJsonBody; ensureWritable: (config: ServerConfig) => void; requireClientScope: (ctx: RequestContext, required: TokenScope) => void; resolveWorkspace: (config: ServerConfig, id: string) => Promise; @@ -51,14 +40,12 @@ export function registerSessionRoutes(options: RegisterSessionRoutesOptions): vo parseOptionalBoolean, parseOptionalPositiveInteger, parseOptionalNonNegativeInteger, - readJsonBody, ensureWritable, requireClientScope, resolveWorkspace, createWorkspaceOpencodeClient, unwrapOpencodeResult, } = options; - const sessionGroupEvents = new SessionGroupEventStore(); function remapSessionReadError(error: unknown): never { if (error instanceof ApiError && error.code === "opencode_request_failed") { @@ -154,21 +141,6 @@ export function registerSessionRoutes(options: RegisterSessionRoutesOptions): vo } } - async function updateWorkspaceSessionGroups( - workspaceId: string, - updater: (current: SessionGroupState) => SessionGroupState, - ) { - return updateSessionGroupState(config, workspaceId, updater); - } - - function requireStringField(body: Record, field: string): string { - const value = body[field]; - if (typeof value !== "string" || !value.trim()) { - throw new ApiError(400, "invalid_payload", `${field} is required`); - } - return value.trim(); - } - addRoute(routes, "GET", "/workspace/:id/sessions", "client", async (ctx) => { const workspace = await resolveWorkspace(config, ctx.params.id); const items = await listWorkspaceSessions(workspace, { @@ -180,132 +152,6 @@ export function registerSessionRoutes(options: RegisterSessionRoutesOptions): vo return jsonResponse({ items }); }); - addRoute(routes, "GET", "/workspace/:id/session-groups", "client", async (ctx) => { - const workspace = await resolveWorkspace(config, ctx.params.id); - const result = await readSessionGroupState(config, workspace.id); - return jsonResponse({ state: result.state, updatedAt: result.updatedAt }); - }); - - addRoute(routes, "PUT", "/workspace/:id/session-groups", "client", async (ctx) => { - ensureWritable(config); - requireClientScope(ctx, "collaborator"); - const workspace = await resolveWorkspace(config, ctx.params.id); - const body = await readJsonBody(ctx.request); - const state = normalizeSessionGroupState(body.state); - const result = await updateWorkspaceSessionGroups(workspace.id, () => state); - sessionGroupEvents.record(workspace.id, "imported"); - return jsonResponse({ state: result.state, updatedAt: result.updatedAt }); - }); - - addRoute(routes, "POST", "/workspace/:id/session-groups", "client", async (ctx) => { - ensureWritable(config); - requireClientScope(ctx, "collaborator"); - const workspace = await resolveWorkspace(config, ctx.params.id); - const body = await readJsonBody(ctx.request); - const label = requireStringField(body, "label").slice(0, 120); - const requestedId = typeof body.id === "string" ? body.id.trim().slice(0, 128) : ""; - const result = await updateWorkspaceSessionGroups(workspace.id, (current) => { - const existingIds = new Set(current.groups.map((group) => group.id)); - const id = requestedId && !existingIds.has(requestedId) ? requestedId : createSessionGroupId(); - return { ...current, groups: [...current.groups, { id, label }] }; - }); - const groupId = result.state.groups[result.state.groups.length - 1]?.id; - sessionGroupEvents.record(workspace.id, "created", groupId ? { groupId } : undefined); - return jsonResponse({ state: result.state, updatedAt: result.updatedAt }); - }); - - addRoute(routes, "PATCH", "/workspace/:id/session-groups/reorder", "client", async (ctx) => { - ensureWritable(config); - requireClientScope(ctx, "collaborator"); - const workspace = await resolveWorkspace(config, ctx.params.id); - const body = await readJsonBody(ctx.request); - const requestedIds = Array.isArray(body.groupIds) - ? body.groupIds.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) - : []; - const result = await updateWorkspaceSessionGroups(workspace.id, (current) => { - const byId = new Map(current.groups.map((group) => [group.id, group])); - const used = new Set(); - const groups: SessionGroupDefinition[] = []; - for (const id of requestedIds) { - const group = byId.get(id); - if (!group || used.has(id)) continue; - groups.push(group); - used.add(id); - } - for (const group of current.groups) { - if (!used.has(group.id)) groups.push(group); - } - return { ...current, groups }; - }); - sessionGroupEvents.record(workspace.id, "reordered"); - return jsonResponse({ state: result.state, updatedAt: result.updatedAt }); - }); - - addRoute(routes, "PATCH", "/workspace/:id/session-groups/assignments/:sessionId", "client", async (ctx) => { - ensureWritable(config); - requireClientScope(ctx, "collaborator"); - const workspace = await resolveWorkspace(config, ctx.params.id); - const sessionId = (ctx.params.sessionId ?? "").trim(); - if (!sessionId) throw new ApiError(400, "invalid_payload", "sessionId is required"); - const body = await readJsonBody(ctx.request); - const groupId = typeof body.groupId === "string" && body.groupId.trim() ? body.groupId.trim() : null; - const result = await updateWorkspaceSessionGroups(workspace.id, (current) => { - const assignments = { ...current.assignments }; - if (groupId && current.groups.some((group) => group.id === groupId)) { - assignments[sessionId] = groupId; - } else { - delete assignments[sessionId]; - } - return { ...current, assignments }; - }); - sessionGroupEvents.record(workspace.id, "assigned", { sessionId, ...(groupId ? { groupId } : {}) }); - return jsonResponse({ state: result.state, updatedAt: result.updatedAt }); - }); - - addRoute(routes, "PATCH", "/workspace/:id/session-groups/:groupId", "client", async (ctx) => { - ensureWritable(config); - requireClientScope(ctx, "collaborator"); - const workspace = await resolveWorkspace(config, ctx.params.id); - const groupId = (ctx.params.groupId ?? "").trim(); - if (!groupId) throw new ApiError(400, "invalid_payload", "groupId is required"); - const body = await readJsonBody(ctx.request); - const label = requireStringField(body, "label").slice(0, 120); - const result = await updateWorkspaceSessionGroups(workspace.id, (current) => ({ - ...current, - groups: current.groups.map((group) => group.id === groupId ? { ...group, label } : group), - })); - sessionGroupEvents.record(workspace.id, "updated", { groupId }); - return jsonResponse({ state: result.state, updatedAt: result.updatedAt }); - }); - - addRoute(routes, "DELETE", "/workspace/:id/session-groups/:groupId", "client", async (ctx) => { - ensureWritable(config); - requireClientScope(ctx, "collaborator"); - const workspace = await resolveWorkspace(config, ctx.params.id); - const groupId = (ctx.params.groupId ?? "").trim(); - if (!groupId) throw new ApiError(400, "invalid_payload", "groupId is required"); - const result = await updateWorkspaceSessionGroups(workspace.id, (current) => { - const assignments: Record = {}; - for (const [sessionId, assignedGroupId] of Object.entries(current.assignments)) { - if (assignedGroupId !== groupId) assignments[sessionId] = assignedGroupId; - } - return { - groups: current.groups.filter((group) => group.id !== groupId), - assignments, - }; - }); - sessionGroupEvents.record(workspace.id, "deleted", { groupId }); - return jsonResponse({ state: result.state, updatedAt: result.updatedAt }); - }); - - addRoute(routes, "GET", "/workspace/:id/session-groups/events", "client", async (ctx) => { - const workspace = await resolveWorkspace(config, ctx.params.id); - const sinceRaw = ctx.url.searchParams.get("since"); - const since = sinceRaw ? Number(sinceRaw) : undefined; - const items = sessionGroupEvents.list(workspace.id, since); - return jsonResponse({ items, cursor: sessionGroupEvents.cursor(workspace.id), workspaceId: workspace.id }); - }); - addRoute(routes, "GET", "/workspace/:id/sessions/:sessionId", "client", async (ctx) => { const workspace = await resolveWorkspace(config, ctx.params.id); const sessionId = (ctx.params.sessionId ?? "").trim(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4e6a4a8eb..84c410436 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1460,7 +1460,6 @@ function createRoutes( parseOptionalBoolean, parseOptionalPositiveInteger, parseOptionalNonNegativeInteger, - readJsonBody, ensureWritable, requireClientScope, resolveWorkspace, diff --git a/apps/server/src/session-groups.e2e.test.ts b/apps/server/src/session-groups.e2e.test.ts deleted file mode 100644 index 90d7e2f4c..000000000 --- a/apps/server/src/session-groups.e2e.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { startServer } from "./server.js"; -import { SessionGroupEventStore } from "./session-groups.js"; -import type { ServerConfig } from "./types.js"; - -const stops: Array<() => void | Promise> = []; -const roots: string[] = []; -const previousRuntimeDb = process.env.IPOLLOWORK_RUNTIME_DB; - -afterEach(async () => { - while (stops.length) await stops.pop()?.(); - while (roots.length) await rm(roots.pop()!, { recursive: true, force: true }); - if (previousRuntimeDb === undefined) delete process.env.IPOLLOWORK_RUNTIME_DB; - else process.env.IPOLLOWORK_RUNTIME_DB = previousRuntimeDb; -}); - -async function createWorkspaceRoot() { - const root = await mkdtemp(join(tmpdir(), "ipollowork-session-groups-")); - roots.push(root); - process.env.IPOLLOWORK_RUNTIME_DB = join(root, "runtime.sqlite"); - return root; -} - -async function startiPolloWorkServer(workspaceRoot: string) { - const config: ServerConfig = { - host: "127.0.0.1", - port: 0, - token: "owt_test_token", - hostToken: "owt_host_token", - approval: { mode: "auto", timeoutMs: 1000 }, - corsOrigins: ["*"], - workspaces: [{ id: "ws_1", name: "Workspace", path: workspaceRoot, preset: "starter", workspaceType: "local" }], - authorizedRoots: [workspaceRoot], - readOnly: false, - startedAt: Date.now(), - tokenSource: "cli", - hostTokenSource: "cli", - logFormat: "pretty", - logRequests: false, - }; - const server = await startServer(config); - stops.push(() => server.stop()); - return { base: `http://127.0.0.1:${server.port}`, token: config.token }; -} - -function auth(token: string) { - return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; -} - -async function json(response: Response) { - expect(response.status).toBe(200); - return response.json(); -} - -describe("session group API", () => { - test("persists groups, assignments, and update events in the runtime db", async () => { - const root = await createWorkspaceRoot(); - const { base, token } = await startiPolloWorkServer(root); - - const empty = await json(await fetch(`${base}/workspace/ws_1/session-groups`, { headers: auth(token) })); - expect(empty).toMatchObject({ state: { groups: [], assignments: {} } }); - - const imported = await json(await fetch(`${base}/workspace/ws_1/session-groups`, { - method: "PUT", - headers: auth(token), - body: JSON.stringify({ - state: { - groups: [{ id: "grp_imported", label: "Imported" }], - assignments: { ses_1: "grp_imported" }, - }, - }), - })); - expect(imported).toMatchObject({ - state: { - groups: [{ id: "grp_imported", label: "Imported" }], - assignments: { ses_1: "grp_imported" }, - }, - }); - - const created = await json(await fetch(`${base}/workspace/ws_1/session-groups`, { - method: "POST", - headers: auth(token), - body: JSON.stringify({ id: "grp_next", label: "Next" }), - })); - expect(created).toMatchObject({ - state: { - groups: [ - { id: "grp_imported", label: "Imported" }, - { id: "grp_next", label: "Next" }, - ], - }, - }); - - const assigned = await json(await fetch(`${base}/workspace/ws_1/session-groups/assignments/ses_2`, { - method: "PATCH", - headers: auth(token), - body: JSON.stringify({ groupId: "grp_next" }), - })); - expect(assigned).toMatchObject({ state: { assignments: { ses_1: "grp_imported", ses_2: "grp_next" } } }); - - const events = await json(await fetch(`${base}/workspace/ws_1/session-groups/events`, { headers: auth(token) })); - expect(events.items.map((event: { action: string }) => event.action)).toEqual(["imported", "created", "assigned"]); - - const persisted = await json(await fetch(`${base}/workspace/ws_1/session-groups`, { headers: auth(token) })); - expect(persisted).toMatchObject(assigned); - }); - - test("serializes concurrent read-modify-write group updates", async () => { - const root = await createWorkspaceRoot(); - const { base, token } = await startiPolloWorkServer(root); - - const responses = await Promise.all([ - fetch(`${base}/workspace/ws_1/session-groups`, { - method: "POST", - headers: auth(token), - body: JSON.stringify({ id: "grp_first", label: "First" }), - }), - fetch(`${base}/workspace/ws_1/session-groups`, { - method: "POST", - headers: auth(token), - body: JSON.stringify({ id: "grp_second", label: "Second" }), - }), - ]); - for (const response of responses) { - expect(response.status).toBe(200); - } - - const persisted = await json(await fetch(`${base}/workspace/ws_1/session-groups`, { headers: auth(token) })); - expect(persisted.state.groups.map((group: { id: string }) => group.id).sort()).toEqual([ - "grp_first", - "grp_second", - ]); - }); - - test("keeps session group events buffered per workspace", () => { - const store = new SessionGroupEventStore(2); - const first = store.record("quiet", "created", { groupId: "grp_quiet" }); - store.record("busy", "created", { groupId: "grp_1" }); - store.record("busy", "created", { groupId: "grp_2" }); - store.record("busy", "created", { groupId: "grp_3" }); - - expect(store.list("quiet", first.seq - 1).map((event) => event.groupId)).toEqual(["grp_quiet"]); - expect(store.list("busy").map((event) => event.groupId)).toEqual(["grp_2", "grp_3"]); - expect(store.cursor("quiet")).toBe(1); - expect(store.cursor("busy")).toBe(3); - }); -}); diff --git a/apps/server/src/session-groups.ts b/apps/server/src/session-groups.ts deleted file mode 100644 index 9d6598311..000000000 --- a/apps/server/src/session-groups.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { eq } from "drizzle-orm"; -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import { importNodeSqlite } from "./node-sqlite.js"; -import type { ServerConfig } from "./types.js"; -import { ensureDir, shortId } from "./utils.js"; - -export type SessionGroupDefinition = { - id: string; - label: string; -}; - -export type SessionGroupState = { - groups: SessionGroupDefinition[]; - assignments: Record; -}; - -export type SessionGroupEventAction = "created" | "updated" | "deleted" | "assigned" | "reordered" | "imported"; - -export type SessionGroupEvent = { - id: string; - seq: number; - workspaceId: string; - type: "session_groups.updated"; - action: SessionGroupEventAction; - groupId?: string; - sessionId?: string; - timestamp: number; -}; - -const EMPTY_SESSION_GROUP_STATE: SessionGroupState = { groups: [], assignments: {} }; - -const sessionGroupStates = sqliteTable("session_group_states", { - workspaceId: text("workspace_id").primaryKey(), - stateJson: text("state_json").notNull(), - schemaVersion: integer("schema_version").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - -type SessionGroupDb = { - get: (workspaceId: string) => { stateJson: string; updatedAt: number } | undefined; - upsert: (value: { workspaceId: string; stateJson: string; updatedAt: number }) => void; -}; - -type SessionGroupEventState = { - seq: number; - events: SessionGroupEvent[]; -}; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeGroupId(value: unknown): string { - if (typeof value !== "string") return ""; - const trimmed = value.trim(); - if (!trimmed) return ""; - return trimmed.slice(0, 128); -} - -function normalizeLabel(value: unknown): string { - if (typeof value !== "string") return ""; - return value.trim().slice(0, 120); -} - -export function createSessionGroupId(): string { - return `grp_${Date.now().toString(36)}_${shortId()}`; -} - -export function normalizeSessionGroupState(value: unknown): SessionGroupState { - if (!isRecord(value)) return EMPTY_SESSION_GROUP_STATE; - - const groups: SessionGroupDefinition[] = []; - const seenGroupIds = new Set(); - if (Array.isArray(value.groups)) { - for (const item of value.groups) { - if (!isRecord(item)) continue; - const id = normalizeGroupId(item.id); - const label = normalizeLabel(item.label); - if (!id || !label || seenGroupIds.has(id)) continue; - groups.push({ id, label }); - seenGroupIds.add(id); - } - } - - const assignments: Record = {}; - if (isRecord(value.assignments)) { - for (const [sessionId, rawGroupId] of Object.entries(value.assignments)) { - const normalizedSessionId = sessionId.trim().slice(0, 256); - const groupId = normalizeGroupId(rawGroupId); - if (!normalizedSessionId || !groupId || !seenGroupIds.has(groupId)) continue; - assignments[normalizedSessionId] = groupId; - } - } - - return { groups, assignments }; -} - -function runtimeDbPath(config: ServerConfig): string { - const override = process.env.IPOLLOWORK_RUNTIME_DB?.trim(); - if (override) return resolve(override); - const configPath = config.configPath?.trim(); - const configDir = configPath ? dirname(configPath) : join(homedir(), ".config", "ipollowork"); - return join(configDir, "runtime.sqlite"); -} - -async function openSessionGroupDb(path: string): Promise { - await ensureDir(dirname(path)); - if (typeof process.versions.bun === "string") { - const { Database } = await import("bun:sqlite"); - const { drizzle } = await import("drizzle-orm/bun-sqlite"); - const sqlite = new Database(path, { create: true }); - sqlite.run("CREATE TABLE IF NOT EXISTS session_group_states (workspace_id TEXT PRIMARY KEY NOT NULL, state_json TEXT NOT NULL, schema_version INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL)"); - const db = drizzle(sqlite); - return { - get: (workspaceId) => db - .select() - .from(sessionGroupStates) - .where(eq(sessionGroupStates.workspaceId, workspaceId)) - .get(), - upsert: ({ workspaceId, stateJson, updatedAt }) => { - db - .insert(sessionGroupStates) - .values({ workspaceId, stateJson, schemaVersion: 1, updatedAt }) - .onConflictDoUpdate({ - target: sessionGroupStates.workspaceId, - set: { stateJson, schemaVersion: 1, updatedAt }, - }) - .run(); - }, - }; - } - - const { DatabaseSync } = await importNodeSqlite(); - const sqlite = new DatabaseSync(path); - sqlite.exec("CREATE TABLE IF NOT EXISTS session_group_states (workspace_id TEXT PRIMARY KEY NOT NULL, state_json TEXT NOT NULL, schema_version INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL)"); - const get = sqlite.prepare("SELECT state_json AS stateJson, updated_at AS updatedAt FROM session_group_states WHERE workspace_id = ?"); - const upsert = sqlite.prepare("INSERT INTO session_group_states (workspace_id, state_json, schema_version, updated_at) VALUES (?, ?, 1, ?) ON CONFLICT(workspace_id) DO UPDATE SET state_json = excluded.state_json, schema_version = excluded.schema_version, updated_at = excluded.updated_at"); - return { - get: (workspaceId) => { - const row = get.get(workspaceId); - if (!isRecord(row) || typeof row.stateJson !== "string" || typeof row.updatedAt !== "number") return undefined; - return { stateJson: row.stateJson, updatedAt: row.updatedAt }; - }, - upsert: ({ workspaceId, stateJson, updatedAt }) => { - upsert.run(workspaceId, stateJson, updatedAt); - }, - }; -} - -const dbByPath = new Map>(); -const updateQueueByWorkspace = new Map>(); - -async function sessionGroupDb(config: ServerConfig): Promise { - const path = runtimeDbPath(config); - const existing = dbByPath.get(path); - if (existing) return existing; - const db = openSessionGroupDb(path); - dbByPath.set(path, db); - return db; -} - -export async function readSessionGroupState( - config: ServerConfig, - workspaceId: string, -): Promise<{ state: SessionGroupState; updatedAt: number | null }> { - const db = await sessionGroupDb(config); - const row = db.get(workspaceId); - if (!row) return { state: EMPTY_SESSION_GROUP_STATE, updatedAt: null }; - try { - return { state: normalizeSessionGroupState(JSON.parse(row.stateJson)), updatedAt: row.updatedAt }; - } catch { - return { state: EMPTY_SESSION_GROUP_STATE, updatedAt: row.updatedAt }; - } -} - -export async function writeSessionGroupState( - config: ServerConfig, - workspaceId: string, - state: SessionGroupState, -): Promise<{ state: SessionGroupState; updatedAt: number }> { - const db = await sessionGroupDb(config); - const next = normalizeSessionGroupState(state); - const updatedAt = Date.now(); - db.upsert({ workspaceId, stateJson: JSON.stringify(next), updatedAt }); - return { state: next, updatedAt }; -} - -export async function updateSessionGroupState( - config: ServerConfig, - workspaceId: string, - updater: (current: SessionGroupState) => SessionGroupState, -): Promise<{ state: SessionGroupState; updatedAt: number }> { - const key = `${runtimeDbPath(config)}:${workspaceId}`; - const previous = updateQueueByWorkspace.get(key) ?? Promise.resolve(); - let release = () => {}; - const queued = new Promise((resolve) => { - release = resolve; - }); - const currentQueue = previous.then(() => queued, () => queued); - updateQueueByWorkspace.set(key, currentQueue); - - await previous.catch(() => undefined); - try { - const current = await readSessionGroupState(config, workspaceId); - return await writeSessionGroupState(config, workspaceId, updater(current.state)); - } finally { - release(); - if (updateQueueByWorkspace.get(key) === currentQueue) { - updateQueueByWorkspace.delete(key); - } - } -} - -export class SessionGroupEventStore { - private eventsByWorkspace = new Map(); - private maxSize: number; - - constructor(maxSize = 500) { - this.maxSize = maxSize; - } - - record( - workspaceId: string, - action: SessionGroupEventAction, - details?: { groupId?: string; sessionId?: string }, - ): SessionGroupEvent { - const state = this.eventsByWorkspace.get(workspaceId) ?? { seq: 0, events: [] }; - const event: SessionGroupEvent = { - id: shortId(), - seq: ++state.seq, - workspaceId, - type: "session_groups.updated", - action, - ...(details?.groupId ? { groupId: details.groupId } : {}), - ...(details?.sessionId ? { sessionId: details.sessionId } : {}), - timestamp: Date.now(), - }; - - state.events.push(event); - if (state.events.length > this.maxSize) { - state.events.splice(0, state.events.length - this.maxSize); - } - this.eventsByWorkspace.set(workspaceId, state); - return event; - } - - list(workspaceId: string, since?: number): SessionGroupEvent[] { - const cursor = typeof since === "number" && Number.isFinite(since) ? since : 0; - const state = this.eventsByWorkspace.get(workspaceId); - return state ? state.events.filter((event) => event.seq > cursor) : []; - } - - cursor(workspaceId: string): number { - return this.eventsByWorkspace.get(workspaceId)?.seq ?? 0; - } -} diff --git a/apps/server/src/types.ts b/apps/server/src/types.ts index 3b2cf2674..73a45c507 100644 --- a/apps/server/src/types.ts +++ b/apps/server/src/types.ts @@ -1,4 +1,6 @@ -import type { WorkspaceWire } from "@ipollowork/types/workspace"; +import { DEFAULT_ENGINE_ID, type WorkspaceWire } from "@ipollowork/types/workspace"; + +export { DEFAULT_ENGINE_ID }; export type WorkspaceType = "local" | "remote"; @@ -14,8 +16,6 @@ export type ProviderPlacement = "in-sandbox" | "host-machine" | "client-machine" export type LogFormat = "pretty" | "json"; -export const DEFAULT_ENGINE_ID = "opencode"; - export interface WorkspaceConfig { id?: string; path: string; diff --git a/evals/flows/session-command-palette-move-to-group.flow.mjs b/evals/flows/session-command-palette-move-to-group.flow.mjs deleted file mode 100644 index c89e80ea6..000000000 --- a/evals/flows/session-command-palette-move-to-group.flow.mjs +++ /dev/null @@ -1,151 +0,0 @@ -const GROUP_LABEL = `Daytona Eval Group ${Date.now()}`; - -async function pressCommandK(ctx) { - await ctx.client.send("Input.dispatchKeyEvent", { - type: "keyDown", - key: "Control", - code: "ControlLeft", - windowsVirtualKeyCode: 17, - modifiers: 2, - }); - await ctx.client.send("Input.dispatchKeyEvent", { - type: "keyDown", - key: "k", - code: "KeyK", - windowsVirtualKeyCode: 75, - modifiers: 2, - }); - await ctx.client.send("Input.dispatchKeyEvent", { - type: "keyUp", - key: "k", - code: "KeyK", - windowsVirtualKeyCode: 75, - modifiers: 2, - }); - await ctx.client.send("Input.dispatchKeyEvent", { - type: "keyUp", - key: "Control", - code: "ControlLeft", - windowsVirtualKeyCode: 17, - }); -} - -async function clickCommandItem(ctx, text) { - await ctx.waitFor( - `(() => [...document.querySelectorAll('[data-slot="command-item"]')].some((el) => (el.textContent ?? '').includes(${JSON.stringify(text)})))()`, - { label: `command item ${text}` }, - ); - const clicked = await ctx.eval(`(() => { - const item = [...document.querySelectorAll('[data-slot="command-item"]')] - .find((el) => (el.textContent ?? '').includes(${JSON.stringify(text)})); - if (!item) return false; - item.scrollIntoView({ block: 'center' }); - item.click(); - return true; - })()`); - ctx.assert(clicked === true, `Could not click command item: ${text}`); -} - -export default { - id: "session-command-palette-move-to-group", - title: "Command palette moves the current session to a group", - spec: "Command-K shows Move to Group, lists groups, and assigns the current session", - steps: [ - { - name: "Electron app and control API are ready", - run: async (ctx) => { - const userAgent = await ctx.eval("navigator.userAgent"); - ctx.assert(typeof userAgent === "string" && userAgent.includes("Electron/"), `Expected Electron userAgent, got: ${userAgent}`); - await ctx.waitFor("Boolean(window.__ipolloworkControl)", { - timeoutMs: 60_000, - label: "control API", - }); - await ctx.waitFor( - "window.__ipolloworkControl.listActions().some((a) => a.id === 'session.create_task' && !a.disabled)", - { timeoutMs: 60_000, label: "enabled task creation action" }, - ); - }, - }, - { - name: "Create and select a current session", - run: async (ctx) => { - await ctx.control("session.create_task"); - const selected = await ctx.waitFor(`(() => { - const route = window.__ipolloworkControl.snapshot().route; - const match = new RegExp('session/([^/?#]+)').exec(route); - if (!match) return null; - const sessionId = decodeURIComponent(match[1]); - return { route, sessionId }; - })()`, { timeoutMs: 30_000, label: "selected session route" }); - ctx.assert(typeof selected.sessionId === "string" && selected.sessionId.length > 0, "No selected session id after task creation."); - ctx.log(`selected session: ${JSON.stringify(selected)}`); - await ctx.screenshot("session-selected"); - }, - }, - { - name: "Create a group for the current workspace", - run: async (ctx) => { - const created = await ctx.control("session.group.create", { label: GROUP_LABEL }); - ctx.assert(created?.ok === true, `Group creation did not return ok: ${JSON.stringify(created)}`); - ctx.assert(typeof created.groupId === "string" && created.groupId.length > 0, `No group id returned: ${JSON.stringify(created)}`); - ctx.log(`created group: ${JSON.stringify(created)}`); - await ctx.waitFor( - `document.body.innerText.toLowerCase().includes(${JSON.stringify(GROUP_LABEL.toLowerCase())})`, - { timeoutMs: 30_000, label: `visible group ${GROUP_LABEL}` }, - ); - await ctx.screenshot("group-created"); - }, - }, - { - name: "Command-K shows Move to Group", - run: async (ctx) => { - await pressCommandK(ctx); - await ctx.waitForText("Move to Group", { timeoutMs: 15_000 }); - const body = await ctx.eval("document.body.innerText"); - ctx.assert(body.includes("Move to Group"), "Command palette did not show Move to Group."); - ctx.assert(body.includes("groups"), "Move to Group did not show the current group count."); - await ctx.screenshot("command-k-move-to-group"); - }, - }, - { - name: "Group list appears from Move to Group", - run: async (ctx) => { - await clickCommandItem(ctx, "Move to Group"); - await ctx.waitForText(GROUP_LABEL, { timeoutMs: 15_000 }); - const body = await ctx.eval("document.body.innerText"); - ctx.assert(body.includes("Move to Group"), "Group picker title is missing."); - ctx.assert(body.includes(GROUP_LABEL), "Group picker did not list the created group."); - await ctx.screenshot("group-list-visible"); - }, - }, - { - name: "Selecting the group assigns the current session", - run: async (ctx) => { - await clickCommandItem(ctx, GROUP_LABEL); - await ctx.waitFor( - `(() => !document.body.innerText.includes('Search groups...'))()`, - { timeoutMs: 15_000, label: "group picker closed" }, - ); - - await pressCommandK(ctx); - await clickCommandItem(ctx, "Move to Group"); - await ctx.waitFor( - `(() => { - const items = [...document.querySelectorAll('[data-slot="command-item"]')]; - const group = items.find((el) => (el.textContent ?? '').includes(${JSON.stringify(GROUP_LABEL)})); - return Boolean(group && (group.textContent ?? '').includes('Current')); - })()`, - { timeoutMs: 15_000, label: "selected group marked Current" }, - ); - const groupItemText = await ctx.eval(`(() => { - const group = [...document.querySelectorAll('[data-slot="command-item"]')] - .find((el) => (el.textContent ?? '').includes(${JSON.stringify(GROUP_LABEL)})); - return group ? group.textContent : ''; - })()`); - ctx.assert(groupItemText.includes("Current"), `Expected selected group to be marked Current, got: ${groupItemText}`); - ctx.log(`assigned group item text: ${groupItemText}`); - await ctx.screenshot("group-assigned-current"); - }, - }, - ], -}; diff --git a/packages/docs/start-here/do-work-with-it/build-a-template.mdx b/packages/docs/start-here/do-work-with-it/build-a-template.mdx index efdc10ad7..2db93a547 100644 --- a/packages/docs/start-here/do-work-with-it/build-a-template.mdx +++ b/packages/docs/start-here/do-work-with-it/build-a-template.mdx @@ -3,7 +3,7 @@ title: "Build a template" description: "Package a reusable design or HyperFrames video template for iPolloWork" --- -Template Market lets developers and design teams reuse a proven starting point without copying an old project by hand. Importing a template installs it in the desktop profile's personal library, so it can be used from any of that person's workstations. Applying a template creates a session-owned copy; uninstalling it never removes existing work. +Template Market lets developers and design teams reuse a proven starting point without copying an old project by hand. Importing a template installs it in the desktop profile's personal library, so it can be used from any of that person's projects. Applying a template creates a session-owned copy; uninstalling it never removes existing work. This page documents the canonical iPolloWork Package (`.ipwp`) format. Existing `.ipwt` template packages remain fully supported as a legacy filename alias. There is no public hosted-market publishing API yet. diff --git a/packages/docs/start-here/do-work-with-it/workflows.mdx b/packages/docs/start-here/do-work-with-it/workflows.mdx index 6cdc5589f..07b0d2da8 100644 --- a/packages/docs/start-here/do-work-with-it/workflows.mdx +++ b/packages/docs/start-here/do-work-with-it/workflows.mdx @@ -1,10 +1,10 @@ --- title: "Workflows" -description: "Use chat and session groups to keep iPolloWork organized." +description: "Use projects, pins, and archives to keep iPolloWork organized." --- -Workflows are how you turn normal iPolloWork chats into organized, repeatable work. In the UI, every session can be renamed, pinned, archived, reordered, or moved into a custom group folder from the session menu. Group folders are workspace-specific: create a group like `Research`, `Ready for review`, `Shipped`, or `Bugs`, then move sessions into that group as the work changes. +Workflows are how you turn normal iPolloWork chats into organized, repeatable work. Each project points to an independent folder and keeps its conversations, files, and configuration together. Switch projects from the sidebar, then rename, pin, archive, or delete conversations from their session menu. -You can also describe the workflow in the composer while you work. For example: `When this is done, rename the session to "Auth cleanup", move it to Ready for review, and pin it`, or `If this turns into a bug investigation, put it in Bugs; otherwise archive it when finished.` Those instructions map to iPolloWork's session management primitives: `rename` changes the session title, `move to group` stores a session-to-group assignment, `pin` keeps important sessions at the top, `archive` hides finished sessions without deleting them, and manual ordering lets you arrange sessions and groups around your process. +You can also describe the workflow in the composer while you work. For example: `When this is done, rename the conversation to "Auth cleanup" and pin it`, or `Archive this conversation after the review is complete.` Those instructions map to iPolloWork's conversation controls: `rename` changes the title, `pin` keeps important conversations at the top, and `archive` hides finished conversations without deleting them. -This makes session organization dynamic instead of manual bookkeeping. A team can keep groups for `Triage`, `In progress`, `Needs human review`, and `Done`, then ask iPolloWork in chat to classify future sessions into those buckets as work completes. The exact groups and actions are configurable from the UI, so the workflow can match how you already work instead of forcing a fixed project-management structure. +Use separate projects when work needs a different folder, codebase, or configuration. Use pins and archives for lightweight organization inside a project, without creating a second hierarchy that competes with projects. diff --git a/packages/types/package.json b/packages/types/package.json index c347f9868..d5496731a 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -27,7 +27,8 @@ "./workspace": { "types": "./src/workspace.ts", "development": "./src/workspace.ts", - "default": "./src/workspace.ts" + "bun": "./src/workspace.ts", + "default": "./dist/workspace.js" }, "./desktop-ipc": { "types": "./src/desktop-ipc.ts", diff --git a/packages/types/src/workspace.ts b/packages/types/src/workspace.ts index 33d6c8ea0..40181248f 100644 --- a/packages/types/src/workspace.ts +++ b/packages/types/src/workspace.ts @@ -16,6 +16,8 @@ export type WorkspaceKind = "local" | "remote"; export type WorkspaceRemoteKind = "opencode" | "ipollowork"; +export const DEFAULT_ENGINE_ID = "opencode"; + export type WorkspaceWire = { id: string; name: string; diff --git a/packages/types/tsup.config.ts b/packages/types/tsup.config.ts index f93f076a9..24fecf387 100644 --- a/packages/types/tsup.config.ts +++ b/packages/types/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "hyperframes-project": "src/hyperframes-project.ts", plugins: "src/plugins.ts", templates: "src/templates.ts", + workspace: "src/workspace.ts", }, tsconfig: "./tsconfig.json", format: ["esm"],