Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 0 additions & 71 deletions apps/app/src/app/lib/ipollowork-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,27 +696,6 @@ export type iPolloWorkReloadEvent = {
timestamp: number;
};

export type iPolloWorkSessionGroupDefinition = {
id: string;
label: string;
};

export type iPolloWorkSessionGroupState = {
groups: iPolloWorkSessionGroupDefinition[];
assignments: Record<string, string>;
};

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;
Expand Down Expand Up @@ -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,
Expand Down
116 changes: 39 additions & 77 deletions apps/app/src/app/lib/work-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import {
} from "./enterprise-connections";
import {
createiPolloWorkServerClient,
type iPolloWorkServerClient,
} from "./ipollowork-server";
import { isDesktopRuntime } from "./runtime-env";

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",
Expand Down Expand Up @@ -124,70 +124,33 @@ export function filterWorkspacesForWorkContext<T extends Pick<WorkspaceInfo, "wo
return workspaces.filter((workspace) => workspaceBelongsToWorkContext(workspace, contextId));
}

function isLegacyWorkstationPath(workspacePath: string | null | undefined) {
return /(?:^|[/\\])\.ipollowork[/\\]workstations[/\\]/i.test(workspacePath?.trim() ?? "");
}

export function canonicalWorkspaceForWorkContext<
T extends Pick<WorkspaceInfo, "id" | "path" | "workContextId" | "workspaceType">,
>(
workspaces: T[],
contextId: WorkContextId,
preferredIds: Array<string | null | undefined> = [],
): 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<string, string> {
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<string, string> = {};
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<WorkspaceInfo, "id" | "path" | "workContextId" | "workspaceType">,
>(
workspaces: T[],
contextId: WorkContextId,
preferredIds: Array<string | null | undefined> = [],
): 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<Pick<WorkspaceInfo, "id" | "workContextId">>,
contextId: WorkContextId,
canonicalWorkspaceId: string,
): Promise<string[]> {
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) {
Expand Down Expand Up @@ -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);
Expand All @@ -268,12 +222,16 @@ export async function activatePersonalWorkContext(): Promise<string | null> {
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;
Expand All @@ -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);
Expand All @@ -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;
Expand Down
30 changes: 27 additions & 3 deletions apps/app/src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,39 @@ import type {
PermissionRequest as ApiPermissionRequest,
PermissionV2Request,
QuestionRequest,
ProviderListResponse,
Session,
} from "@opencode-ai/sdk/v2/client";
import type { createClient } from "./lib/opencode";
import type { OpencodeConfigFile, WorkspaceInfo } from "./lib/desktop-types";

export type Client = ReturnType<typeof createClient>;

export type ProviderListItem = ProviderListResponse["all"][number];
export type ProviderModel = {
id: string;
name: string;
capabilities: {
attachment?: boolean;
reasoning?: boolean;
input?: {
image?: boolean;
};
};
variants?: Record<string, Record<string, unknown>>;
};

export type ProviderListItem = {
id: string;
name: string;
source: "env" | "config" | "custom" | "api";
env: string[];
models: Record<string, ProviderModel>;
};

export type ProviderListResponse = {
all: ProviderListItem[];
connected: string[];
default: Record<string, string>;
};

export type SidebarSessionItem = {
id: string;
Expand All @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/app/utils/providers.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
3 changes: 2 additions & 1 deletion apps/app/src/components/model-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 0 additions & 1 deletion apps/app/src/i18n/locales/ca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 20 additions & 25 deletions apps/app/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading