diff --git a/apps/code/src/main/db/migrations/0006_youthful_warstar.sql b/apps/code/src/main/db/migrations/0006_youthful_warstar.sql new file mode 100644 index 000000000..cfc3cbb08 --- /dev/null +++ b/apps/code/src/main/db/migrations/0006_youthful_warstar.sql @@ -0,0 +1,6 @@ +CREATE TABLE `default_additional_directories` ( + `path` text PRIMARY KEY NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); +--> statement-breakpoint +ALTER TABLE `workspaces` ADD `additional_directories` text DEFAULT '[]' NOT NULL; \ No newline at end of file diff --git a/apps/code/src/main/db/migrations/meta/0006_snapshot.json b/apps/code/src/main/db/migrations/meta/0006_snapshot.json new file mode 100644 index 000000000..ee3bb09af --- /dev/null +++ b/apps/code/src/main/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,559 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "805d2ed3-331d-4ba6-8379-30f926268064", + "prevId": "b530fcd1-77cc-4df0-ad3c-148ee9d5c46b", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "default_additional_directories": { + "name": "default_additional_directories", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additional_directories": { + "name": "additional_directories", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/code/src/main/db/migrations/meta/_journal.json b/apps/code/src/main/db/migrations/meta/_journal.json index 5ea0be65d..98745d4e4 100644 --- a/apps/code/src/main/db/migrations/meta/_journal.json +++ b/apps/code/src/main/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1775755977659, "tag": "0005_youthful_scarlet_spider", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1777639303535, + "tag": "0006_youthful_warstar", + "breakpoints": true } ] } diff --git a/apps/code/src/main/db/repositories/default-additional-directory-repository.mock.ts b/apps/code/src/main/db/repositories/default-additional-directory-repository.mock.ts new file mode 100644 index 000000000..4ca09e512 --- /dev/null +++ b/apps/code/src/main/db/repositories/default-additional-directory-repository.mock.ts @@ -0,0 +1,25 @@ +import type { IDefaultAdditionalDirectoryRepository } from "./default-additional-directory-repository"; + +export interface MockDefaultAdditionalDirectoryRepository + extends IDefaultAdditionalDirectoryRepository { + _paths: string[]; +} + +export function createMockDefaultAdditionalDirectoryRepository(): MockDefaultAdditionalDirectoryRepository { + let paths: string[] = []; + return { + get _paths() { + return [...paths]; + }, + set _paths(value) { + paths = [...value]; + }, + list: () => [...paths], + add: (path) => { + if (!paths.includes(path)) paths.push(path); + }, + remove: (path) => { + paths = paths.filter((p) => p !== path); + }, + }; +} diff --git a/apps/code/src/main/db/repositories/default-additional-directory-repository.ts b/apps/code/src/main/db/repositories/default-additional-directory-repository.ts new file mode 100644 index 000000000..a4fda1bcf --- /dev/null +++ b/apps/code/src/main/db/repositories/default-additional-directory-repository.ts @@ -0,0 +1,54 @@ +import { eq } from "drizzle-orm"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { normalizeDirectoryPath } from "../../utils/normalize-path"; +import { defaultAdditionalDirectories } from "../schema"; +import type { DatabaseService } from "../service"; + +export type DefaultAdditionalDirectory = + typeof defaultAdditionalDirectories.$inferSelect; + +export interface IDefaultAdditionalDirectoryRepository { + list(): string[]; + add(path: string): void; + remove(path: string): void; +} + +@injectable() +export class DefaultAdditionalDirectoryRepository + implements IDefaultAdditionalDirectoryRepository +{ + constructor( + @inject(MAIN_TOKENS.DatabaseService) + private readonly databaseService: DatabaseService, + ) {} + + private get db() { + return this.databaseService.db; + } + + list(): string[] { + return this.db + .select() + .from(defaultAdditionalDirectories) + .all() + .map((row) => row.path); + } + + add(path: string): void { + this.db + .insert(defaultAdditionalDirectories) + .values({ path: normalizeDirectoryPath(path) }) + .onConflictDoNothing() + .run(); + } + + remove(path: string): void { + this.db + .delete(defaultAdditionalDirectories) + .where( + eq(defaultAdditionalDirectories.path, normalizeDirectoryPath(path)), + ) + .run(); + } +} diff --git a/apps/code/src/main/db/repositories/workspace-repository.mock.ts b/apps/code/src/main/db/repositories/workspace-repository.mock.ts index 7be3ade37..97393a6eb 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.mock.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.mock.ts @@ -1,7 +1,8 @@ -import type { - CreateWorkspaceData, - IWorkspaceRepository, - Workspace, +import { + type CreateWorkspaceData, + type IWorkspaceRepository, + parseDirectories, + type Workspace, } from "./workspace-repository"; export interface MockWorkspaceRepository extends IWorkspaceRepository { @@ -15,6 +16,26 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { const clone = (w: Workspace | null): Workspace | null => w ? { ...w } : null; + const findLiveByTaskId = (taskId: string): Workspace | undefined => { + const id = taskIndex.get(taskId); + return id ? workspaces.get(id) : undefined; + }; + + const updateDirectoriesForTask = ( + taskId: string, + update: (current: string[]) => string[] | null, + ) => { + const w = findLiveByTaskId(taskId); + if (!w) return; + const next = update(parseDirectories(w.additionalDirectories)); + if (next === null) return; + workspaces.set(w.id, { + ...w, + additionalDirectories: JSON.stringify(next), + updatedAt: new Date().toISOString(), + }); + }; + return { _workspaces: workspaces, findById: (id: string) => clone(workspaces.get(id) ?? null), @@ -42,6 +63,7 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { lastViewedAt: null, lastActivityAt: null, linkedBranch: null, + additionalDirectories: "[]", createdAt: now, updatedAt: now, }; @@ -79,6 +101,18 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { updatedAt: new Date().toISOString(), }); }, + getAdditionalDirectories: (taskId) => + parseDirectories(findLiveByTaskId(taskId)?.additionalDirectories), + addAdditionalDirectory: (taskId, path) => { + updateDirectoriesForTask(taskId, (current) => + current.includes(path) ? null : [...current, path], + ); + }, + removeAdditionalDirectory: (taskId, path) => { + updateDirectoriesForTask(taskId, (current) => + current.includes(path) ? current.filter((p) => p !== path) : null, + ); + }, deleteAll: () => { workspaces.clear(); taskIndex.clear(); diff --git a/apps/code/src/main/db/repositories/workspace-repository.ts b/apps/code/src/main/db/repositories/workspace-repository.ts index 6dfcb391f..1c5f63053 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.ts @@ -1,6 +1,7 @@ import { eq, isNotNull } from "drizzle-orm"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; +import { normalizeDirectoryPath } from "../../utils/normalize-path"; import { workspaces } from "../schema"; import type { DatabaseService } from "../service"; @@ -33,9 +34,24 @@ export interface IWorkspaceRepository { mode: WorkspaceMode, repositoryId: string | null, ): void; + getAdditionalDirectories(taskId: string): string[]; + addAdditionalDirectory(taskId: string, path: string): void; + removeAdditionalDirectory(taskId: string, path: string): void; deleteAll(): void; } +export function parseDirectories(value: string | null | undefined): string[] { + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((v): v is string => typeof v === "string") + : []; + } catch { + return []; + } +} + const byId = (id: string) => eq(workspaces.id, id); const byTaskId = (taskId: string) => eq(workspaces.taskId, taskId); const byRepositoryId = (repoId: string) => eq(workspaces.repositoryId, repoId); @@ -158,6 +174,40 @@ export class WorkspaceRepository implements IWorkspaceRepository { .run(); } + getAdditionalDirectories(taskId: string): string[] { + const workspace = this.findByTaskId(taskId); + return parseDirectories(workspace?.additionalDirectories); + } + + private updateDirectories( + taskId: string, + update: (current: string[]) => string[] | null, + ): void { + const next = update(this.getAdditionalDirectories(taskId)); + if (next === null) return; + this.db + .update(workspaces) + .set({ additionalDirectories: JSON.stringify(next), updatedAt: now() }) + .where(byTaskId(taskId)) + .run(); + } + + addAdditionalDirectory(taskId: string, path: string): void { + const normalized = normalizeDirectoryPath(path); + this.updateDirectories(taskId, (current) => + current.includes(normalized) ? null : [...current, normalized], + ); + } + + removeAdditionalDirectory(taskId: string, path: string): void { + const normalized = normalizeDirectoryPath(path); + this.updateDirectories(taskId, (current) => + current.includes(normalized) + ? current.filter((p) => p !== normalized) + : null, + ); + } + deleteAll(): void { this.db.delete(workspaces).run(); } diff --git a/apps/code/src/main/db/schema.ts b/apps/code/src/main/db/schema.ts index 8e4f14404..8823ad274 100644 --- a/apps/code/src/main/db/schema.ts +++ b/apps/code/src/main/db/schema.ts @@ -31,6 +31,8 @@ export const workspaces = sqliteTable( pinnedAt: text(), lastViewedAt: text(), lastActivityAt: text(), + /** JSON-encoded array of absolute paths the agent can access for this task. */ + additionalDirectories: text().notNull().default("[]"), createdAt: createdAt(), updatedAt: updatedAt(), }, @@ -88,6 +90,14 @@ export const authSessions = sqliteTable("auth_sessions", { updatedAt: updatedAt(), }); +export const defaultAdditionalDirectories = sqliteTable( + "default_additional_directories", + { + path: text().primaryKey(), + createdAt: createdAt(), + }, +); + export const authPreferences = sqliteTable( "auth_preferences", { diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 959ea1431..890e3ae17 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -4,6 +4,7 @@ import { Container } from "inversify"; import { ArchiveRepository } from "../db/repositories/archive-repository"; import { AuthPreferenceRepository } from "../db/repositories/auth-preference-repository"; import { AuthSessionRepository } from "../db/repositories/auth-session-repository"; +import { DefaultAdditionalDirectoryRepository } from "../db/repositories/default-additional-directory-repository"; import { RepositoryRepository } from "../db/repositories/repository-repository"; import { SuspensionRepositoryImpl } from "../db/repositories/suspension-repository"; import { WorkspaceRepository } from "../db/repositories/workspace-repository"; @@ -97,6 +98,9 @@ container.bind(MAIN_TOKENS.WorkspaceRepository).to(WorkspaceRepository); container.bind(MAIN_TOKENS.WorktreeRepository).to(WorktreeRepository); container.bind(MAIN_TOKENS.ArchiveRepository).to(ArchiveRepository); container.bind(MAIN_TOKENS.SuspensionRepository).to(SuspensionRepositoryImpl); +container + .bind(MAIN_TOKENS.DefaultAdditionalDirectoryRepository) + .to(DefaultAdditionalDirectoryRepository); container.bind(MAIN_TOKENS.AgentAuthAdapter).to(AgentAuthAdapter); container.bind(MAIN_TOKENS.AgentService).to(AgentService); container.bind(MAIN_TOKENS.AuthService).to(AuthService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index c8225b2b1..4ae512ca6 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -34,6 +34,9 @@ export const MAIN_TOKENS = Object.freeze({ WorktreeRepository: Symbol.for("Main.WorktreeRepository"), ArchiveRepository: Symbol.for("Main.ArchiveRepository"), SuspensionRepository: Symbol.for("Main.SuspensionRepository"), + DefaultAdditionalDirectoryRepository: Symbol.for( + "Main.DefaultAdditionalDirectoryRepository", + ), // Services AgentAuthAdapter: Symbol.for("Main.AgentAuthAdapter"), diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts index 6dcab002c..14aa92925 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/apps/code/src/main/services/agent/service.test.ts @@ -190,6 +190,16 @@ function createMockDependencies() { appDataPath: "/mock/userData", logsPath: "/mock/logs", }, + defaultAdditionalDirectoryRepository: { + list: vi.fn(() => [] as string[]), + add: vi.fn(), + remove: vi.fn(), + }, + workspaceRepository: { + getAdditionalDirectories: vi.fn(() => [] as string[]), + addAdditionalDirectory: vi.fn(), + removeAdditionalDirectory: vi.fn(), + }, }; } @@ -220,6 +230,8 @@ describe("AgentService", () => { deps.bundledResources as never, deps.appMeta as never, deps.storagePaths as never, + deps.defaultAdditionalDirectoryRepository as never, + deps.workspaceRepository as never, ); }); diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index af80ec302..272fef8fc 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -44,6 +44,8 @@ import type { IStoragePaths } from "@posthog/platform/storage-paths"; import { isAuthError } from "@shared/errors"; import type { AcpMessage } from "@shared/types/session-events"; import { inject, injectable, preDestroy } from "inversify"; +import type { IDefaultAdditionalDirectoryRepository } from "../../db/repositories/default-additional-directory-repository"; +import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; import { MAIN_TOKENS } from "../../di/tokens"; import { isDevBuild } from "../../utils/env"; import { logger } from "../../utils/logger"; @@ -213,8 +215,6 @@ interface SessionConfig { /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ sessionId?: string; adapter?: "claude" | "codex"; - /** Additional directories Claude can access beyond cwd (for worktree support) */ - additionalDirectories?: string[]; /** Permission mode to use for the session */ permissionMode?: string; /** Custom instructions injected into the system prompt */ @@ -304,6 +304,10 @@ export class AgentService extends TypedEventEmitter { private readonly appMeta: IAppMeta, @inject(MAIN_TOKENS.StoragePaths) private readonly storagePaths: IStoragePaths, + @inject(MAIN_TOKENS.DefaultAdditionalDirectoryRepository) + private readonly defaultAdditionalDirectoryRepository: IDefaultAdditionalDirectoryRepository, + @inject(MAIN_TOKENS.WorkspaceRepository) + private readonly workspaceRepository: IWorkspaceRepository, ) { super(); this.processTracking = processTracking; @@ -453,6 +457,7 @@ export class AgentService extends TypedEventEmitter { credentials: Credentials, taskId: string, customInstructions?: string, + additionalDirectories?: string[], ): { append: string; } { @@ -490,9 +495,32 @@ When creating pull requests, add the following footer at the end of the PR descr prompt += `\n\nUser custom instructions:\n${customInstructions}`; } + if (additionalDirectories?.length) { + const escapeXml = (s: string) => + s.replace(/&/g, "&").replace(//g, ">"); + const dirs = additionalDirectories + .map((d) => ` ${escapeXml(d)}`) + .join("\n"); + prompt += `\n\nThe user has granted you access to additional directories outside the working directory. You may read and edit files in these paths just like the working directory:\n\n${dirs}\n`; + } + return { append: prompt }; } + private resolveAdditionalDirectories(taskId: string): string[] { + const defaults = this.defaultAdditionalDirectoryRepository.list(); + const taskScoped = + this.workspaceRepository.getAdditionalDirectories(taskId); + const seen = new Set(); + const merged: string[] = []; + for (const path of [...defaults, ...taskScoped]) { + if (!path || seen.has(path)) continue; + seen.add(path); + merged.push(path); + } + return merged; + } + async startSession(params: StartSessionInput): Promise { this.validateSessionParams(params); const config = this.toSessionConfig(params); @@ -530,7 +558,6 @@ When creating pull requests, add the following footer at the end of the PR descr credentials, logUrl, adapter, - additionalDirectories, permissionMode, customInstructions, effort, @@ -541,6 +568,9 @@ When creating pull requests, add the following footer at the end of the PR descr // Preview config doesn't need a real repo — use a temp directory const repoPath = taskId === "__preview__" ? tmpdir() : rawRepoPath; + const additionalDirectories = + taskId === "__preview__" ? [] : this.resolveAdditionalDirectories(taskId); + if (!isRetry) { const existing = this.sessions.get(taskRunId); if (existing) { @@ -590,6 +620,7 @@ When creating pull requests, add the following footer at the end of the PR descr credentials, taskId, customInstructions, + additionalDirectories, ); const acpConnection = await agent.run(taskId, taskRunId, { @@ -599,6 +630,8 @@ When creating pull requests, add the following footer at the end of the PR descr adapter === "codex" ? this.getCodexBinaryPath() : undefined, model, instructions: adapter === "codex" ? systemPrompt.append : undefined, + additionalDirectories: + adapter === "codex" ? additionalDirectories : undefined, onStructuredOutput: jsonSchema ? async (output) => { const posthogAPI = agent.getPosthogAPI(); @@ -1491,10 +1524,6 @@ For git operations while detached: logUrl: "logUrl" in params ? params.logUrl : undefined, sessionId: "sessionId" in params ? params.sessionId : undefined, adapter: "adapter" in params ? params.adapter : undefined, - additionalDirectories: - "additionalDirectories" in params - ? params.additionalDirectories - : undefined, permissionMode: "permissionMode" in params ? params.permissionMode : undefined, customInstructions: diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 75a5c85c2..9ed2b1bb7 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -1,3 +1,4 @@ +import { additionalDirectoriesRouter } from "./routers/additional-directories"; import { agentRouter } from "./routers/agent"; import { analyticsRouter } from "./routers/analytics"; import { archiveRouter } from "./routers/archive"; @@ -38,6 +39,7 @@ import { workspaceRouter } from "./routers/workspace"; import { router } from "./trpc"; export const trpcRouter = router({ + additionalDirectories: additionalDirectoriesRouter, agent: agentRouter, analytics: analyticsRouter, archive: archiveRouter, diff --git a/apps/code/src/main/trpc/routers/additional-directories.ts b/apps/code/src/main/trpc/routers/additional-directories.ts new file mode 100644 index 000000000..3e202c090 --- /dev/null +++ b/apps/code/src/main/trpc/routers/additional-directories.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import type { IDefaultAdditionalDirectoryRepository } from "../../db/repositories/default-additional-directory-repository"; +import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { publicProcedure, router } from "../trpc"; + +const getDefaults = () => + container.get( + MAIN_TOKENS.DefaultAdditionalDirectoryRepository, + ); + +const getWorkspaces = () => + container.get(MAIN_TOKENS.WorkspaceRepository); + +const pathInput = z.object({ path: z.string().min(1) }); +const taskPathInput = z.object({ + taskId: z.string(), + path: z.string().min(1), +}); +const ok = { ok: true as const }; + +export const additionalDirectoriesRouter = router({ + listDefaults: publicProcedure + .output(z.array(z.string())) + .query(() => getDefaults().list()), + + listForTask: publicProcedure + .input(z.object({ taskId: z.string() })) + .output(z.array(z.string())) + .query(({ input }) => + getWorkspaces().getAdditionalDirectories(input.taskId), + ), + + addDefault: publicProcedure.input(pathInput).mutation(({ input }) => { + getDefaults().add(input.path); + return ok; + }), + + removeDefault: publicProcedure.input(pathInput).mutation(({ input }) => { + getDefaults().remove(input.path); + return ok; + }), + + addForTask: publicProcedure.input(taskPathInput).mutation(({ input }) => { + getWorkspaces().addAdditionalDirectory(input.taskId, input.path); + return ok; + }), + + removeForTask: publicProcedure.input(taskPathInput).mutation(({ input }) => { + getWorkspaces().removeAdditionalDirectory(input.taskId, input.path); + return ok; + }), +}); diff --git a/apps/code/src/main/utils/normalize-path.ts b/apps/code/src/main/utils/normalize-path.ts new file mode 100644 index 000000000..33963605c --- /dev/null +++ b/apps/code/src/main/utils/normalize-path.ts @@ -0,0 +1,5 @@ +import { resolve } from "node:path"; + +export function normalizeDirectoryPath(input: string): string { + return resolve(input); +} diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index 15babd5de..643c7173f 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -11,6 +11,7 @@ import { useCurrentUser, } from "@features/auth/hooks/authQueries"; import { useAuthSession } from "@features/auth/hooks/useAuthSession"; +import { AddDirectoryDialog } from "@features/folder-picker/components/AddDirectoryDialog"; import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { Flex, Spinner, Text } from "@radix-ui/themes"; @@ -286,6 +287,7 @@ function App() { onComplete={handleTransitionComplete} /> + ); diff --git a/apps/code/src/renderer/features/folder-picker/components/AddDirectoryDialog.tsx b/apps/code/src/renderer/features/folder-picker/components/AddDirectoryDialog.tsx new file mode 100644 index 000000000..833dfd051 --- /dev/null +++ b/apps/code/src/renderer/features/folder-picker/components/AddDirectoryDialog.tsx @@ -0,0 +1,119 @@ +import { Folder } from "@phosphor-icons/react"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@posthog/quill"; +import { trpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; +import { useEffect, useRef } from "react"; +import { useAddDirectoryDialogStore } from "../stores/addDirectoryDialogStore"; + +const log = logger.scope("add-directory-dialog"); + +export function AddDirectoryDialog() { + const open = useAddDirectoryDialogStore((s) => s.open); + const taskId = useAddDirectoryDialogStore((s) => s.taskId); + const path = useAddDirectoryDialogStore((s) => s.path); + const onCancel = useAddDirectoryDialogStore((s) => s.onCancel); + const close = useAddDirectoryDialogStore((s) => s.close); + + const decidedRef = useRef(false); + const justThisChatRef = useRef(null); + useEffect(() => { + if (!open) return; + decidedRef.current = false; + const id = window.setTimeout(() => justThisChatRef.current?.focus(), 0); + return () => window.clearTimeout(id); + }, [open]); + + if (!path || !taskId) return null; + + const decideAndClose = async ( + action: () => unknown, + errorMessage: string, + ) => { + decidedRef.current = true; + try { + await action(); + } catch (err) { + log.error(errorMessage, err); + } finally { + close(); + } + }; + + const handleJustThisChat = () => + decideAndClose( + () => + trpcClient.additionalDirectories.addForTask.mutate({ taskId, path }), + "Failed to add directory for task", + ); + + const handleAlways = () => + decideAndClose( + () => + Promise.all([ + trpcClient.additionalDirectories.addDefault.mutate({ path }), + trpcClient.additionalDirectories.addForTask.mutate({ taskId, path }), + ]), + "Failed to add default directory", + ); + + const handleCancel = () => + decideAndClose(() => onCancel?.(), "Failed to remove chip"); + + return ( + { + if (!isOpen && !decidedRef.current) handleCancel(); + }} + > + + + + + Add folder to chat + + + The agent will be able to read and write files in this folder. + + + +
+
+ {path} +
+
+ + + + + + +
+
+ ); +} diff --git a/apps/code/src/renderer/features/folder-picker/stores/addDirectoryDialogStore.ts b/apps/code/src/renderer/features/folder-picker/stores/addDirectoryDialogStore.ts new file mode 100644 index 000000000..31616d2a6 --- /dev/null +++ b/apps/code/src/renderer/features/folder-picker/stores/addDirectoryDialogStore.ts @@ -0,0 +1,29 @@ +import { create } from "zustand"; + +interface AddDirectoryDialogState { + open: boolean; + taskId: string | null; + path: string | null; + onCancel: (() => void) | null; +} + +interface AddDirectoryDialogActions { + show: (params: { + taskId: string; + path: string; + onCancel: () => void; + }) => void; + close: () => void; +} + +type Store = AddDirectoryDialogState & AddDirectoryDialogActions; + +export const useAddDirectoryDialogStore = create()((set) => ({ + open: false, + taskId: null, + path: null, + onCancel: null, + show: ({ taskId, path, onCancel }) => + set({ open: true, taskId, path, onCancel }), + close: () => set({ open: false, taskId: null, path: null, onCancel: null }), +})); diff --git a/apps/code/src/renderer/features/message-editor/commands.ts b/apps/code/src/renderer/features/message-editor/commands.ts index 3cacc20f6..04deb7efb 100644 --- a/apps/code/src/renderer/features/message-editor/commands.ts +++ b/apps/code/src/renderer/features/message-editor/commands.ts @@ -1,7 +1,11 @@ import type { AvailableCommand } from "@agentclientprotocol/sdk"; +import { useAddDirectoryDialogStore } from "@features/folder-picker/stores/addDirectoryDialogStore"; +import { trpcClient } from "@renderer/trpc/client"; import { ANALYTICS_EVENTS, type FeedbackType } from "@shared/types/analytics"; +import type { Editor } from "@tiptap/core"; import { track } from "@utils/analytics"; import { toast } from "@utils/toast"; +import type { MentionChipAttrs } from "./tiptap/MentionChipNode"; interface CommandContext { taskId: string; @@ -14,16 +18,33 @@ interface CommandContext { taskRun: { id?: string; log_url?: string } | null; } +export interface CodeCommandInsertContext { + editor: Editor; + chipId: string; + sessionId: string; +} + interface CodeCommand { name: string; description: string; input?: { hint: string }; - execute: ( + /** Optional override for the chip attrs inserted when this command is committed. */ + placeholderChip?: Partial; + /** Fires immediately after the chip is inserted into the editor. */ + onInsert?: (ctx: CodeCommandInsertContext) => void; + /** Runs at submission time when the message is sent. Optional. */ + execute?: ( args: string | undefined, context: CommandContext, ) => Promise | void; } +function basename(path: string): string { + const trimmed = path.replace(/[\\/]+$/, ""); + const idx = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return idx >= 0 ? trimmed.slice(idx + 1) || trimmed : trimmed; +} + function makeFeedbackCommand( name: string, feedbackType: FeedbackType, @@ -47,7 +68,37 @@ function makeFeedbackCommand( }; } +const addDirCommand: CodeCommand = { + name: "add-dir", + description: "Add a folder the agent can access in this task", + async onInsert(ctx) { + const taskId = ctx.sessionId; + try { + const path = await trpcClient.os.selectDirectory.query(); + if (!path) { + ctx.editor.commands.removeMentionChipById(ctx.chipId); + return; + } + ctx.editor.commands.replaceMentionChipById(ctx.chipId, { + id: path, + label: `add-dir - ${basename(path)}`, + }); + useAddDirectoryDialogStore.getState().show({ + taskId, + path, + onCancel: () => ctx.editor.commands.removeMentionChipById(ctx.chipId), + }); + } catch (err) { + ctx.editor.commands.removeMentionChipById(ctx.chipId); + toast.error("Failed to open folder picker", { + description: err instanceof Error ? err.message : String(err), + }); + } + }, +}; + const commands: CodeCommand[] = [ + addDirCommand, makeFeedbackCommand("good", "good", "Positive"), makeFeedbackCommand("bad", "bad", "Negative"), makeFeedbackCommand("feedback", "general", "General"), @@ -61,6 +112,10 @@ export const CODE_COMMANDS: AvailableCommand[] = commands.map((cmd) => ({ const commandMap = new Map(commands.map((cmd) => [cmd.name, cmd])); +export function getCodeCommand(name: string): CodeCommand | undefined { + return commandMap.get(name); +} + export async function tryExecuteCodeCommand( text: string, context: CommandContext, @@ -69,7 +124,7 @@ export async function tryExecuteCodeCommand( if (!match) return false; const cmd = commandMap.get(match[1]); - if (!cmd) return false; + if (!cmd?.execute) return false; await cmd.execute(match[2], context); return true; diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx index f15364bac..203b7e636 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx @@ -113,6 +113,8 @@ export const PromptInput = forwardRef( getContent, setContent, insertChip, + removeChipById, + replaceChipAttrs, attachments, addAttachment, removeAttachment, @@ -151,6 +153,8 @@ export const PromptInput = forwardRef( getText, setContent, insertChip, + removeChipById, + replaceChipAttrs, addAttachment, removeAttachment, }), @@ -163,6 +167,8 @@ export const PromptInput = forwardRef( getText, setContent, insertChip, + removeChipById, + replaceChipAttrs, addAttachment, removeAttachment, ], diff --git a/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts b/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts index 80558b937..399a300e5 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts @@ -1,3 +1,4 @@ +import { getCodeCommand } from "@features/message-editor/commands"; import { getCommandSuggestions } from "../suggestions/getSuggestions"; import { createSuggestionMention } from "./createSuggestionMention"; @@ -13,7 +14,20 @@ export function createCommandMention(options: CommandMentionOptions) { char: "/", chipType: "command", startOfLine: true, + autoCommit: true, items: (query) => sessionId ? getCommandSuggestions(sessionId, query) : [], + resolveChipAttrs: (item) => { + const cmd = getCodeCommand(item.label); + return cmd?.placeholderChip ?? {}; + }, + onAfterInsert: (item, ctx) => { + const cmd = getCodeCommand(item.label); + cmd?.onInsert?.({ + editor: ctx.editor, + chipId: ctx.chipId, + sessionId, + }); + }, }); } diff --git a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts index 880bf8acc..107eaa32a 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts @@ -18,12 +18,19 @@ export interface MentionChipAttrs { id: string; label: string; pastedText: boolean; + /** Optional unique handle so callers can later replace or remove this chip. */ + chipId?: string | null; } declare module "@tiptap/core" { interface Commands { mentionChip: { insertMentionChip: (attrs: MentionChipAttrs) => ReturnType; + replaceMentionChipById: ( + chipId: string, + attrs: Partial, + ) => ReturnType; + removeMentionChipById: (chipId: string) => ReturnType; }; } } @@ -41,6 +48,7 @@ export const MentionChipNode = Node.create({ id: { default: "" }, label: { default: "" }, pastedText: { default: false }, + chipId: { default: null as string | null }, }; }, @@ -85,6 +93,43 @@ export const MentionChipNode = Node.create({ ]) .run(); }, + replaceMentionChipById: + (chipId: string, attrs: Partial) => + ({ tr, state, dispatch }) => { + let found = false; + state.doc.descendants((node, pos) => { + if (found) return false; + if (node.type.name !== "mentionChip") return; + if (node.attrs.chipId !== chipId) return; + found = true; + tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...attrs }); + return false; + }); + if (found && dispatch) dispatch(tr); + return found; + }, + removeMentionChipById: + (chipId: string) => + ({ tr, state, dispatch }) => { + let found = false; + state.doc.descendants((node, pos) => { + if (found) return false; + if (node.type.name !== "mentionChip") return; + if (node.attrs.chipId !== chipId) return; + found = true; + const from = pos; + const to = pos + node.nodeSize; + // Also swallow a trailing single space the suggestion adds. + const after = state.doc.textBetween( + to, + Math.min(to + 1, state.doc.content.size), + ); + tr.delete(from, after === " " ? to + 1 : to); + return false; + }); + if (found && dispatch) dispatch(tr); + return found; + }, }; }, }); diff --git a/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts b/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts index 9632ef95f..0568e04e5 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts @@ -1,11 +1,12 @@ import { getPortalContainer } from "@components/ThemeWrapper"; +import type { Editor } from "@tiptap/core"; import Mention, { type MentionOptions } from "@tiptap/extension-mention"; import { ReactRenderer } from "@tiptap/react"; import type { SuggestionOptions } from "@tiptap/suggestion"; import type { ReactNode } from "react"; import tippy, { type Instance as TippyInstance } from "tippy.js"; import type { SuggestionItem } from "../types"; -import type { ChipType } from "./MentionChipNode"; +import type { ChipType, MentionChipAttrs } from "./MentionChipNode"; import { SuggestionList, type SuggestionListRef } from "./SuggestionList"; import { createSuggestionLoader } from "./suggestionLoader"; @@ -18,6 +19,15 @@ export interface SuggestionMentionConfig { debounceMs?: number; items: (query: string) => T[] | Promise; renderItem?: (item: T) => ReactNode; + /** + * When true, commit the suggestion as soon as the typed query exactly matches + * an item's label and no other item label extends it. + */ + autoCommit?: boolean; + /** Override the chip attrs inserted for a given item. */ + resolveChipAttrs?: (item: T) => Partial; + /** Fires after the chip is inserted into the document. */ + onAfterInsert?: (item: T, ctx: { editor: Editor; chipId: string }) => void; } export function createSuggestionMention( @@ -32,6 +42,9 @@ export function createSuggestionMention( debounceMs = 0, items: loadItems, renderItem, + autoCommit = false, + resolveChipAttrs, + onAfterInsert, } = config; const renderItemUntyped = renderItem @@ -111,6 +124,23 @@ export function createSuggestionMention( getReferenceClientRect: props.clientRect as () => DOMRect, }); } + + if (autoCommit) { + // Caveat: if one item label is a strict prefix of another (e.g. + // "add" vs "add-dir"), the shorter name becomes uncommittable via + // auto-commit and the user has to pick from the list. Avoid + // shipping prefix-clashing command names, or rename to disambiguate. + const q = props.query.toLowerCase(); + const exact = props.items.find((i) => i.label.toLowerCase() === q); + const hasLongerExtension = props.items.some( + (i) => + i.label.toLowerCase().startsWith(q) && + i.label.length > q.length, + ); + if (exact && !hasLongerExtension) { + props.command(exact); + } + } }, onKeyDown: (props) => { @@ -137,23 +167,26 @@ export function createSuggestionMention( }, command: ({ editor, range, props }) => { - const item = props as SuggestionItem; + const item = props as T; + const chipId = crypto.randomUUID(); + const overrides = resolveChipAttrs?.(item) ?? {}; + const attrs: MentionChipAttrs = { + type: overrides.type ?? item.chipType ?? chipType, + id: overrides.id ?? item.id, + label: overrides.label ?? item.label, + pastedText: false, + chipId, + }; editor .chain() .focus() .deleteRange(range) .insertContent([ - { - type: "mentionChip", - attrs: { - type: item.chipType ?? chipType, - id: item.id, - label: item.label, - }, - }, + { type: "mentionChip", attrs }, { type: "text", text: " " }, ]) .run(); + onAfterInsert?.(item, { editor, chipId }); }, }; diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index 731e79233..9475f01d5 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -643,6 +643,31 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { [editor, draft, attachments], ); + const removeChipById = useCallback( + (chipId: string) => { + if (!editor) return; + editor.commands.removeMentionChipById(chipId); + draft.saveDraft(editor, attachments); + }, + [editor, draft, attachments], + ); + + const replaceChipAttrs = useCallback( + ( + chipId: string, + attrs: Partial<{ + id: string; + label: string; + type: MentionChip["type"]; + }>, + ) => { + if (!editor) return; + editor.commands.replaceMentionChipById(chipId, attrs); + draft.saveDraft(editor, attachments); + }, + [editor, draft, attachments], + ); + const addAttachment = useCallback((attachment: FileAttachment) => { setAttachments((prev) => { if (prev.some((a) => a.id === attachment.id)) return prev; @@ -671,6 +696,8 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { getContent: draft.getContent, setContent, insertChip, + removeChipById, + replaceChipAttrs, attachments, addAttachment, removeAttachment, diff --git a/apps/code/src/renderer/features/message-editor/types.ts b/apps/code/src/renderer/features/message-editor/types.ts index f0db43043..22624fc5d 100644 --- a/apps/code/src/renderer/features/message-editor/types.ts +++ b/apps/code/src/renderer/features/message-editor/types.ts @@ -18,6 +18,11 @@ export interface EditorHandle { getText: () => string; setContent: (text: string) => void; insertChip: (chip: MentionChip) => void; + removeChipById: (chipId: string) => void; + replaceChipAttrs: ( + chipId: string, + attrs: Partial<{ id: string; label: string; type: MentionChip["type"] }>, + ) => void; addAttachment: (attachment: FileAttachment) => void; removeAttachment: (id: string) => void; } diff --git a/apps/code/src/renderer/features/message-editor/utils/content.ts b/apps/code/src/renderer/features/message-editor/utils/content.ts index 67acea8f5..ff7d63ec3 100644 --- a/apps/code/src/renderer/features/message-editor/utils/content.ts +++ b/apps/code/src/renderer/features/message-editor/utils/content.ts @@ -40,6 +40,10 @@ export function contentToPlainText(content: EditorContent): string { .join(""); } +function isAbsolutePathLike(p: string): boolean { + return p.startsWith("/") || p.startsWith("~") || /^[A-Za-z]:[\\/]/.test(p); +} + export function contentToXml(content: EditorContent): string { const inlineFilePaths = new Set(); const parts = content.segments.map((seg) => { @@ -54,6 +58,9 @@ export function contentToXml(content: EditorContent): string { inlineFilePaths.add(chip.id); return ``; case "command": + if (chip.id && chip.id !== chip.label && isAbsolutePathLike(chip.id)) { + return ``; + } return `/${chip.label}`; case "error": return ``; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/parseFileMentions.tsx b/apps/code/src/renderer/features/sessions/components/session-update/parseFileMentions.tsx index 8db9a381c..f68488fd9 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/parseFileMentions.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/parseFileMentions.tsx @@ -3,7 +3,7 @@ import { baseComponents, defaultRemarkPlugins, } from "@features/editor/components/MarkdownRenderer"; -import { File, Warning } from "@phosphor-icons/react"; +import { File, Folder, Warning } from "@phosphor-icons/react"; import { Text } from "@radix-ui/themes"; import { unescapeXmlAttr } from "@utils/xml"; import type { ReactNode } from "react"; @@ -12,9 +12,9 @@ import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; const MENTION_TAG_REGEX = - /|<(github_issue|github_pr)\s+number="([^"]+)"(?:\s+title="([^"]*)")?(?:\s+url="([^"]*)")?\s*\/>|[\s\S]*?<\/error_context>/g; + /|<(github_issue|github_pr)\s+number="([^"]+)"(?:\s+title="([^"]*)")?(?:\s+url="([^"]*)")?\s*\/>|[\s\S]*?<\/error_context>|/g; const MENTION_TAG_TEST = - /<(?:file\s+path|github_issue\s+number|github_pr\s+number|error_context\s+label)="[^"]+"/; + /<(?:file\s+path|folder\s+path|github_issue\s+number|github_pr\s+number|error_context\s+label)="[^"]+"/; const SLASH_COMMAND_START = /^\/([a-zA-Z][\w-]*)(?=\s|$)/; const inlineComponents: Components = { @@ -151,6 +151,17 @@ export function parseMentionTags(content: string): ReactNode[] { label={unescapeXmlAttr(match[6])} />, ); + } else if (match[7]) { + const folderPath = unescapeXmlAttr(match[7]); + const segments = folderPath.split("/").filter(Boolean); + const folderName = segments.pop() ?? folderPath; + parts.push( + } + label={folderName} + />, + ); } lastIndex = matchIndex + match[0].length; diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index eda53cbbb..545bf7ed3 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -9,6 +9,7 @@ import { } from "@features/auth/hooks/authClient"; import { fetchAuthState } from "@features/auth/hooks/authQueries"; import { useUsageLimitStore } from "@features/billing/stores/usageLimitStore"; +import { useAddDirectoryDialogStore } from "@features/folder-picker/stores/addDirectoryDialogStore"; import { useSessionAdapterStore } from "@features/sessions/stores/sessionAdapterStore"; import { getPersistedConfigOptions, @@ -198,6 +199,29 @@ export interface ConnectParams { reasoningLevel?: string; } +const FOLDER_TAG_REGEX = //g; + +function isAbsoluteFolderPath(p: string): boolean { + return p.startsWith("/") || p.startsWith("~") || /^[A-Za-z]:[\\/]/.test(p); +} + +function promptReferencesAbsoluteFolder( + prompt: string | ContentBlock[], +): boolean { + const text = + typeof prompt === "string" + ? prompt + : prompt + .map((block) => + "text" in block && typeof block.text === "string" ? block.text : "", + ) + .join(""); + for (const match of text.matchAll(FOLDER_TAG_REGEX)) { + if (isAbsoluteFolderPath(match[1])) return true; + } + return false; +} + // --- Singleton Service Instance --- let serviceInstance: SessionService | null = null; @@ -479,6 +503,8 @@ export class SessionService { const resolvedAdapter = adapter ?? storedAdapter; const persistedConfigOptions = getPersistedConfigOptions(taskRunId); + const previous = sessionStoreSetters.getSessions()[taskRunId]; + const session = this.createBaseSession(taskRunId, taskId, taskTitle); session.events = events; if (logUrl) { @@ -492,6 +518,14 @@ export class SessionService { useSessionAdapterStore.getState().setAdapter(taskRunId, resolvedAdapter); } + if (previous) { + session.optimisticItems = previous.optimisticItems; + session.messageQueue = previous.messageQueue; + session.isPromptPending = previous.isPromptPending; + session.promptStartedAt = previous.promptStartedAt; + session.pausedDurationMs = previous.pausedDurationMs; + } + sessionStoreSetters.setSession(session); this.subscribeToChannel(taskRunId); @@ -1295,9 +1329,18 @@ export class SessionService { ); } - const session = sessionStoreSetters.getSessionByTaskId(taskId); + let session = sessionStoreSetters.getSessionByTaskId(taskId); if (!session) throw new Error("No active session for task"); + // The /add-dir dialog mutates the per-task additional-directories list and + // we re-read it during respawn below. Sending while it's open would race + // and respawn with the pre-decision set, so block here. + if (useAddDirectoryDialogStore.getState().open) { + throw new Error( + "Confirm the folder access dialog before sending your message.", + ); + } + if (session.isCloud) { return this.sendCloudPrompt(session, prompt); } @@ -1344,7 +1387,39 @@ export class SessionService { prompt_length_chars: promptText.length, }); - return this.sendLocalPrompt(session, blocks, promptText); + // Show the user's message in the chat immediately, before any respawn + this.applyOptimisticPrompt(session.taskRunId, blocks, promptText); + + if (promptReferencesAbsoluteFolder(prompt)) { + const repoPath = this.localRepoPaths.get(taskId); + if (repoPath) { + try { + await this.reconnectInPlace(taskId, repoPath); + } catch (err) { + log.error("Respawn failed; aborting prompt send", { taskId, err }); + sessionStoreSetters.clearOptimisticItems(session.taskRunId); + sessionStoreSetters.updateSession(session.taskRunId, { + isPromptPending: false, + promptStartedAt: null, + }); + toast.error("Couldn't grant the new folder access", { + description: + "The session needs to restart to pick up the added folder. Try sending again, or remove the folder reference.", + }); + throw err instanceof Error + ? err + : new Error("Failed to apply additional directories"); + } + const refreshed = sessionStoreSetters.getSessionByTaskId(taskId); + if (refreshed) { + session = refreshed; + } + } + } + + return this.sendLocalPrompt(session, blocks, promptText, { + optimisticApplied: true, + }); } /** @@ -1403,12 +1478,12 @@ export class SessionService { } } - private async sendLocalPrompt( - session: AgentSession, + private applyOptimisticPrompt( + taskRunId: string, blocks: ContentBlock[], promptText: string, - ): Promise<{ stopReason: string }> { - sessionStoreSetters.updateSession(session.taskRunId, { + ): void { + sessionStoreSetters.updateSession(taskRunId, { isPromptPending: true, promptStartedAt: Date.now(), pausedDurationMs: 0, @@ -1416,17 +1491,28 @@ export class SessionService { const skillButtonId = extractSkillButtonId(blocks); if (skillButtonId) { - sessionStoreSetters.appendOptimisticItem(session.taskRunId, { + sessionStoreSetters.appendOptimisticItem(taskRunId, { type: "skill_button_action", buttonId: skillButtonId, }); } else { - sessionStoreSetters.appendOptimisticItem(session.taskRunId, { + sessionStoreSetters.appendOptimisticItem(taskRunId, { type: "user_message", content: promptText, timestamp: Date.now(), }); } + } + + private async sendLocalPrompt( + session: AgentSession, + blocks: ContentBlock[], + promptText: string, + options: { optimisticApplied?: boolean } = {}, + ): Promise<{ stopReason: string }> { + if (!options.optimisticApplied) { + this.applyOptimisticPrompt(session.taskRunId, blocks, promptText); + } try { const result = await trpcClient.agent.prompt.mutate({ diff --git a/apps/code/src/renderer/features/settings/components/sections/WorkspacesSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/WorkspacesSettings.tsx index 4cc3ebe97..9b9ea4581 100644 --- a/apps/code/src/renderer/features/settings/components/sections/WorkspacesSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/WorkspacesSettings.tsx @@ -1,15 +1,18 @@ import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; import { SettingRow } from "@features/settings/components/SettingRow"; -import { Flex } from "@radix-ui/themes"; +import { Folder, X } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; import { trpcClient, useTRPC } from "@renderer/trpc"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; +import { toast } from "@utils/toast"; import { useEffect, useState } from "react"; const log = logger.scope("workspaces-settings"); export function WorkspacesSettings() { const trpc = useTRPC(); + const queryClient = useQueryClient(); const [localWorktreeLocation, setLocalWorktreeLocation] = useState(""); @@ -38,12 +41,44 @@ export function WorkspacesSettings() { } }; + const defaultsQuery = useQuery( + trpc.additionalDirectories.listDefaults.queryOptions(), + ); + const defaults = defaultsQuery.data ?? []; + + const invalidateDefaults = () => + queryClient.invalidateQueries( + trpc.additionalDirectories.listDefaults.pathFilter(), + ); + + const addMutation = useMutation( + trpc.additionalDirectories.addDefault.mutationOptions({ + onSuccess: invalidateDefaults, + }), + ); + const removeMutation = useMutation( + trpc.additionalDirectories.removeDefault.mutationOptions({ + onSuccess: invalidateDefaults, + }), + ); + + const handleAddDefaultDirectory = async () => { + try { + const path = await trpcClient.os.selectDirectory.query(); + if (path) { + await addMutation.mutateAsync({ path }); + } + } catch (err) { + log.error("Failed to add default directory", err); + toast.error("Failed to open folder picker"); + } + }; + return ( - +
- +
+

Default folders for new chats

+

+ Folders the agent can access in every new chat on your device. +

+
+ {defaults.length === 0 && ( +

No default folders.

+ )} + {defaults.map((path) => ( +
+ + + {path} + + +
+ ))} +
+ +
+
+
+
); } diff --git a/apps/code/src/renderer/styles/globals.css b/apps/code/src/renderer/styles/globals.css index 5f5ad9801..74e8ab560 100644 --- a/apps/code/src/renderer/styles/globals.css +++ b/apps/code/src/renderer/styles/globals.css @@ -1118,3 +1118,17 @@ button, background-color: var(--accent-11); } } + +/* + * @posthog/quill Dialog — make the backdrop visible against this app's + * dark theme (Quill defaults to opacity-20/dark:opacity-70 which barely + * shows), and center the popup vertically (Quill anchors to ~10vh). + */ +[data-slot="dialog-overlay"] { + background-color: rgb(0 0 0 / 0.6); + opacity: 1; +} +.dark [data-slot="dialog-overlay"] { + background-color: rgb(0 0 0 / 0.7); + opacity: 1; +} diff --git a/packages/agent/src/adapters/codex/spawn.ts b/packages/agent/src/adapters/codex/spawn.ts index 6f3fbd695..b84bf7989 100644 --- a/packages/agent/src/adapters/codex/spawn.ts +++ b/packages/agent/src/adapters/codex/spawn.ts @@ -17,6 +17,8 @@ export interface CodexProcessOptions { logger?: Logger; processCallbacks?: ProcessSpawnedCallback; settings?: CodexSettings; + /** Additional writable roots passed to Codex's workspace-write sandbox. */ + additionalDirectories?: string[]; } export interface CodexProcess { @@ -57,6 +59,13 @@ function buildConfigArgs(options: CodexProcessOptions): string[] { args.push("-c", `model_reasoning_effort="${options.reasoningEffort}"`); } + if (options.additionalDirectories?.length) { + const escaped = options.additionalDirectories + .map((p) => `"${p.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`) + .join(","); + args.push("-c", `sandbox_workspace_write.writable_roots=[${escaped}]`); + } + if (options.instructions) { const escaped = options.instructions .replace(/\\/g, "\\\\") diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index f66b0fd74..e4b3d9bfe 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -136,6 +136,7 @@ export class Agent { binaryPath: options.codexBinaryPath, model: sanitizedModel, instructions: options.instructions, + additionalDirectories: options.additionalDirectories, } : undefined, }); diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 18e5572c0..0adae47c2 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -124,6 +124,8 @@ export interface TaskExecutionOptions { processCallbacks?: ProcessSpawnedCallback; /** Callback invoked when the agent calls the create_output tool for structured output */ onStructuredOutput?: (output: Record) => Promise; + /** Additional directories the agent process can access beyond cwd. */ + additionalDirectories?: string[]; } export type LogLevel = "debug" | "info" | "warn" | "error";