From 1a999f201a1554b7e8f3713cc30407712a88bb75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 1 Aug 2026 14:46:57 +0100 Subject: [PATCH] fix: resolve chat file links from task workspace root (#2127) --- .../internal/task/dto/converters_test.go | 11 +- apps/backend/internal/task/dto/dto.go | 8 + apps/backend/internal/task/models/models.go | 2 +- .../task/repository/sqlite/session.go | 12 +- .../task/repository/sqlite/session_test.go | 91 ++++++ .../tasks/[id]/advanced-panels/chat-panel.tsx | 3 +- .../tasks/[id]/office-dockview-layout.tsx | 8 +- .../quick-chat/quick-chat-content.tsx | 3 +- .../shared/markdown-components.test.tsx | 23 +- .../components/shared/markdown-components.tsx | 3 +- .../components/task/file-browser-path.test.ts | 16 +- apps/web/components/task/file-browser-path.ts | 9 + apps/web/components/task/file-browser.tsx | 4 +- .../task/file-editor-panel.image.test.tsx | 43 ++- .../web/components/task/file-editor-panel.tsx | 3 +- .../components/task/file-tab-content.test.tsx | 36 ++- apps/web/components/task/file-tab-content.tsx | 14 +- .../mobile/mobile-file-viewer-panel.test.tsx | 33 ++- .../task/mobile/mobile-file-viewer-panel.tsx | 3 +- apps/web/components/task/task-chat-panel.tsx | 3 +- apps/web/e2e/helpers/api-client.ts | 1 + .../tests/task/add-workspace-sources.spec.ts | 50 ++++ .../task/mobile-add-workspace-sources.spec.ts | 35 +++ apps/web/hooks/use-lsp-file-opener.test.ts | 125 +++++++++ apps/web/hooks/use-lsp-file-opener.ts | 21 +- apps/web/lib/session-workspace-path.test.ts | 23 ++ apps/web/lib/session-workspace-path.ts | 13 + .../lib/state/slices/session/session-slice.ts | 1 + .../session/session-slice.upsert.test.ts | 16 ++ apps/web/lib/types/backend.ts | 2 + apps/web/lib/types/http.ts | 2 + .../web/lib/ws/handlers/agent-session.test.ts | 97 ++++++- apps/web/lib/ws/handlers/agent-session.ts | 48 +++- docs/plans/multi-repo-chat-file-links/plan.md | 261 ++++++++++++++++++ ...-01-expose-authoritative-workspace-root.md | 66 +++++ ...-resolve-chat-links-from-workspace-root.md | 119 ++++++++ docs/specs/tasks/attach-workspace-sources.md | 26 +- 37 files changed, 1173 insertions(+), 61 deletions(-) create mode 100644 apps/web/hooks/use-lsp-file-opener.test.ts create mode 100644 apps/web/lib/session-workspace-path.test.ts create mode 100644 apps/web/lib/session-workspace-path.ts create mode 100644 docs/plans/multi-repo-chat-file-links/plan.md create mode 100644 docs/plans/multi-repo-chat-file-links/task-01-expose-authoritative-workspace-root.md create mode 100644 docs/plans/multi-repo-chat-file-links/task-02-resolve-chat-links-from-workspace-root.md diff --git a/apps/backend/internal/task/dto/converters_test.go b/apps/backend/internal/task/dto/converters_test.go index cc28e2e00c..a4ffb47544 100644 --- a/apps/backend/internal/task/dto/converters_test.go +++ b/apps/backend/internal/task/dto/converters_test.go @@ -83,8 +83,9 @@ func TestFromWorkflowStep_PreservesWIPFields(t *testing.T) { func TestFromTaskSession_IncludesAllWorktrees(t *testing.T) { session := &models.TaskSession{ - ID: "session-1", - TaskID: "task-1", + ID: "session-1", + TaskID: "task-1", + WorkspacePath: "/task-root", Worktrees: []*models.TaskSessionWorktree{ {ID: "assoc-1", WorktreeID: "wt-1", RepositoryID: "repo-a", WorktreePath: "/x/a"}, {ID: "assoc-2", WorktreeID: "wt-2", RepositoryID: "repo-b", WorktreePath: "/x/b"}, @@ -98,6 +99,9 @@ func TestFromTaskSession_IncludesAllWorktrees(t *testing.T) { if full.WorktreePath != "/x/a" { t.Fatalf("WorktreePath = %q, want /x/a (first worktree)", full.WorktreePath) } + if full.WorkspacePath != "/task-root" { + t.Fatalf("WorkspacePath = %q, want /task-root", full.WorkspacePath) + } summary := FromTaskSessionSummary(session) if len(summary.Worktrees) != 2 { @@ -106,4 +110,7 @@ func TestFromTaskSession_IncludesAllWorktrees(t *testing.T) { if summary.Worktrees[1].WorktreeID != "wt-2" { t.Fatalf("second worktree id = %q, want wt-2", summary.Worktrees[1].WorktreeID) } + if summary.WorkspacePath != "/task-root" { + t.Fatalf("summary WorkspacePath = %q, want /task-root", summary.WorkspacePath) + } } diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 4d413bd7bc..895a1f454a 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -237,6 +237,9 @@ type TaskSessionDTO struct { WorktreeID string `json:"worktree_id,omitempty"` WorktreePath string `json:"worktree_path,omitempty"` WorktreeBranch string `json:"worktree_branch,omitempty"` + // WorkspacePath is the effective task root used by Files and chat links; + // WorktreePath remains the flattened primary repository path. + WorkspacePath string `json:"workspace_path,omitempty"` // Worktrees lists all session worktrees (one per repo on multi-repo tasks); // the flattened Worktree* fields above carry only the first for // backward compatibility. @@ -290,6 +293,9 @@ type TaskSessionSummaryDTO struct { WorktreeID string `json:"worktree_id,omitempty"` WorktreePath string `json:"worktree_path,omitempty"` WorktreeBranch string `json:"worktree_branch,omitempty"` + // WorkspacePath is the effective task root used by Files and chat links; + // WorktreePath remains the flattened primary repository path. + WorkspacePath string `json:"workspace_path,omitempty"` // Worktrees lists all session worktrees (one per repo on multi-repo tasks); // the flattened Worktree* fields above carry only the first for // backward compatibility. @@ -728,6 +734,7 @@ func FromTaskSessionSummary(session *models.TaskSession) TaskSessionSummaryDTO { RepositoryID: session.RepositoryID, BaseBranch: session.BaseBranch, BaseCommitSHA: session.BaseCommitSHA, + WorkspacePath: session.WorkspacePath, State: session.State, ErrorMessage: session.ErrorMessage, Metadata: session.Metadata, @@ -763,6 +770,7 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO { RepositoryID: session.RepositoryID, BaseBranch: session.BaseBranch, BaseCommitSHA: session.BaseCommitSHA, + WorkspacePath: session.WorkspacePath, State: session.State, ErrorMessage: session.ErrorMessage, Metadata: session.Metadata, diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 907eafdb68..5ecd59d3ea 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -929,7 +929,7 @@ type TaskSession struct { RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility) BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility) BaseCommitSHA string `json:"base_commit_sha"` // Git commit SHA at session start (for cumulative diff) - WorkspacePath string `json:"workspace_path"` // Optional host folder for repo-less tasks (when user picked a starting folder) + WorkspacePath string `json:"workspace_path"` // Effective task workspace root; legacy repo-less sessions may use the picked host folder Worktrees []*TaskSessionWorktree `json:"worktrees,omitempty"` // Associated worktrees AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 864a30d3fb..2bbb382434 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -186,8 +186,10 @@ func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ( // so columns can be added in one place. Order MUST match the scan helpers. // // agent_execution_id and container_id come from executors_running (the single -// source of truth for active execution state) via LEFT JOIN. All other columns -// come from task_sessions (aliased ts). +// source of truth for active execution state) via LEFT JOIN. The effective +// workspace_path comes from the linked task environment when available so a +// promoted multi-repo task is correct after a session refresh; all other +// columns come from task_sessions (aliased ts). // // ADR 0005: agent_profile_id is the single column for both kanban (FK to a // shallow profile) and office (FK to a per-workspace rich profile) sessions — @@ -195,7 +197,8 @@ func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ( const taskSessionSelectCols = `ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.execution_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, - ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path, + ts.repository_id, ts.base_branch, ts.base_commit_sha, + COALESCE(NULLIF(te.workspace_path, ''), ts.workspace_path), ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id, ts.name, ts.last_read_message_id` @@ -203,7 +206,8 @@ const taskSessionSelectCols = `ts.id, ts.task_id, // taskSessionFromClause is the FROM clause that pairs with taskSessionSelectCols. // Always reference task_sessions as `ts` and executors_running as `er` in WHERE/ORDER. const taskSessionFromClause = `FROM task_sessions ts - LEFT JOIN executors_running er ON er.session_id = ts.id` + LEFT JOIN executors_running er ON er.session_id = ts.id + LEFT JOIN task_environments te ON te.id = ts.task_environment_id` // Task Session operations diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index d231b71d7d..0b7b019540 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -35,6 +35,97 @@ func newRepoForSessionTests(t *testing.T) *Repository { return repo } +func TestTaskSessionWorkspacePathUsesCurrentEnvironmentRoot(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + const ( + taskID = "task-workspace-root" + sessionID = "session-workspace-root" + envID = "env-workspace-root" + ) + + if err := repo.CreateTask(ctx, &models.Task{ID: taskID, Title: "Workspace root"}); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: envID, + TaskID: taskID, + ExecutorType: string(models.ExecutorTypeWorktree), + Status: models.TaskEnvironmentStatusReady, + WorkspacePath: "/task-root/kandev", + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: sessionID, + TaskID: taskID, + TaskEnvironmentID: envID, + WorkspacePath: "/task-root/kandev", + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "session-worktree-root", + SessionID: sessionID, + WorktreeID: "worktree-primary", + WorktreePath: "/task-root/kandev", + Position: 0, + }); err != nil { + t.Fatalf("CreateTaskSessionWorktree: %v", err) + } + + env, err := repo.GetTaskEnvironment(ctx, envID) + if err != nil { + t.Fatalf("GetTaskEnvironment: %v", err) + } + env.WorkspacePath = "/task-root" + if err := repo.UpdateTaskEnvironment(ctx, env); err != nil { + t.Fatalf("UpdateTaskEnvironment: %v", err) + } + + got, err := repo.GetTaskSession(ctx, sessionID) + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + if got.WorkspacePath != "/task-root" { + t.Fatalf("GetTaskSession WorkspacePath = %q, want %q", got.WorkspacePath, "/task-root") + } + if len(got.Worktrees) != 1 || got.Worktrees[0].WorktreePath != "/task-root/kandev" { + t.Fatalf("GetTaskSession primary worktree = %+v, want repository path", got.Worktrees) + } + + listed, err := repo.ListTaskSessions(ctx, taskID) + if err != nil { + t.Fatalf("ListTaskSessions: %v", err) + } + if len(listed) != 1 || listed[0].WorkspacePath != "/task-root" { + t.Fatalf("ListTaskSessions workspace = %+v, want %q", listed, "/task-root") + } +} + +func TestTaskSessionWorkspacePathFallsBackWithoutEnvironment(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + if err := repo.CreateTask(ctx, &models.Task{ID: "task-legacy-workspace", Title: "Legacy workspace"}); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-legacy-workspace", + TaskID: "task-legacy-workspace", + WorkspacePath: "/legacy/repository", + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + got, err := repo.GetTaskSession(ctx, "session-legacy-workspace") + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + if got.WorkspacePath != "/legacy/repository" { + t.Fatalf("WorkspacePath = %q, want legacy fallback", got.WorkspacePath) + } +} + // seedForMsgTest seeds task, session, and turn rows so that all FK constraints // on task_session_messages are satisfied. Returns the turn ID for use in inserts. func seedForMsgTest(t *testing.T, repo *Repository, taskID, sessionID, turnID string) { diff --git a/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx b/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx index 099c87840a..4f83fdc8c3 100644 --- a/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx +++ b/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx @@ -15,6 +15,7 @@ import { getWebSocketClient } from "@/lib/ws/connection"; import { buildStartRequest } from "@/lib/services/session-launch-helpers"; import { MessageRenderer } from "@/components/task/chat/message-renderer"; import type { Message } from "@/lib/types/http"; +import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; type AdvancedChatPanelProps = { taskId: string; @@ -210,7 +211,7 @@ export function AdvancedChatPanel({ taskId, sessionId, hideInput }: AdvancedChat isLoading={isLoading} taskId={taskId} sessionId={sessionId} - worktreePath={session?.worktree_path} + worktreePath={getSessionWorkspacePath(session)} onOpenFile={openFile} activeTurnId={activeTurnId} scrollRef={scrollRef} diff --git a/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx b/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx index 8d986177f8..6c6b91aabc 100644 --- a/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx +++ b/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx @@ -90,12 +90,12 @@ export function OfficeDockviewLayout({ taskId, sessionId }: OfficeDockviewLayout status: "ready", updatedAt: new Date().toISOString(), }); - // Populate worktree_path from workspace_path for quick-chat sessions - // so the file browser shows the workspace path instead of a skeleton. + // Populate workspace_path for quick-chat sessions so the file + // browser and chat links use the effective task root. if (resp.workspace_path) { const existing = appStore.getState().taskSessions.items[resp.session_id]; - if (existing && !existing.worktree_path) { - setTaskSession({ ...existing, worktree_path: resp.workspace_path }); + if (existing && !existing.workspace_path) { + setTaskSession({ ...existing, workspace_path: resp.workspace_path }); } } } diff --git a/apps/web/components/quick-chat/quick-chat-content.tsx b/apps/web/components/quick-chat/quick-chat-content.tsx index 9c3fdbd65e..185045e100 100644 --- a/apps/web/components/quick-chat/quick-chat-content.tsx +++ b/apps/web/components/quick-chat/quick-chat-content.tsx @@ -16,6 +16,7 @@ import { ClarificationInputOverlay } from "@/components/task/chat/clarification- import { ResizeHandle } from "@/components/task/chat/resize-handle"; import { useResizableClarificationOverlay } from "@/hooks/use-resizable-clarification-overlay"; import type { Message } from "@/lib/types/http"; +import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; import { routePanelMouseDown } from "@/components/task/chat/route-panel-mouse-down"; type QuickChatContentProps = { @@ -183,7 +184,7 @@ export const QuickChatContent = memo(function QuickChatContent({ messagesLoading={panelState.messagesLoading} isWorking={panelState.isWorking} sessionState={panelState.session?.state} - worktreePath={panelState.session?.worktree_path} + worktreePath={getSessionWorkspacePath(panelState.session)} onOpenFile={undefined} /> diff --git a/apps/web/components/shared/markdown-components.test.tsx b/apps/web/components/shared/markdown-components.test.tsx index a39b5bea6f..9393ce36a9 100644 --- a/apps/web/components/shared/markdown-components.test.tsx +++ b/apps/web/components/shared/markdown-components.test.tsx @@ -13,7 +13,7 @@ const appState = vi.hoisted(() => ({ "session-1": { worktree_path: "/root/.kandev/tasks/example/kandev", }, - } as Record, + } as Record, }, }, })); @@ -225,6 +225,27 @@ describe("markdownComponents", () => { }); }); +describe("markdownComponents workspace roots", () => { + afterEach(resetMarkdownComponentTestState); + + it("opens multi-repository absolute links relative to the task workspace root", () => { + appState.value.taskSessions.items["session-1"].workspace_path = "/root/.kandev/tasks/example"; + render( + + { + "[primary](/root/.kandev/tasks/example/kandev/docs/specs/native/spec.md) [sibling](/root/.kandev/tasks/example/plugin/ui/bundle.js)" + } + , + ); + + fireEvent.click(screen.getByRole("link", { name: "primary" })); + fireEvent.click(screen.getByRole("link", { name: "sibling" })); + + expect(openFile).toHaveBeenNthCalledWith(1, "kandev/docs/specs/native/spec.md"); + expect(openFile).toHaveBeenNthCalledWith(2, "plugin/ui/bundle.js"); + }); +}); + describe("markdownComponents omp read selectors", () => { afterEach(() => { cleanup(); diff --git a/apps/web/components/shared/markdown-components.tsx b/apps/web/components/shared/markdown-components.tsx index 33ab06c49d..30f972d98c 100644 --- a/apps/web/components/shared/markdown-components.tsx +++ b/apps/web/components/shared/markdown-components.tsx @@ -10,6 +10,7 @@ import { MermaidBlock } from "@/components/shared/mermaid-block"; import { isMermaidContent } from "@/components/editors/tiptap/tiptap-mermaid-extension"; import { usePanelActions } from "@/hooks/use-panel-actions"; import { useAppStore } from "@/components/state-provider"; +import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; /** Shared remark plugins used by all markdown renderers */ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -187,7 +188,7 @@ function MarkdownFallbackLink(props: MarkdownLinkProps) { const worktreePath = useAppStore((state) => { const sessionId = state.tasks.activeSessionId; if (!sessionId) return null; - return state.taskSessions.items[sessionId]?.worktree_path ?? null; + return getSessionWorkspacePath(state.taskSessions.items[sessionId]); }); return ; diff --git a/apps/web/components/task/file-browser-path.test.ts b/apps/web/components/task/file-browser-path.test.ts index 97a04a591f..09d2e267a9 100644 --- a/apps/web/components/task/file-browser-path.test.ts +++ b/apps/web/components/task/file-browser-path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveFileBrowserPaths } from "./file-browser-path"; +import { getFileBrowserSessionWorkspacePath, resolveFileBrowserPaths } from "./file-browser-path"; describe("resolveFileBrowserPaths", () => { it("keeps the toolbar out of loading state when the loaded root path is empty", () => { @@ -44,3 +44,17 @@ describe("resolveFileBrowserPaths", () => { }); }); }); + +describe("getFileBrowserSessionWorkspacePath", () => { + it("prefers the effective workspace path and falls back to the legacy worktree path", () => { + expect( + getFileBrowserSessionWorkspacePath({ + workspace_path: "/task-root", + worktree_path: "/task-root/kandev", + }), + ).toBe("/task-root"); + expect(getFileBrowserSessionWorkspacePath({ worktree_path: "/legacy-worktree" })).toBe( + "/legacy-worktree", + ); + }); +}); diff --git a/apps/web/components/task/file-browser-path.ts b/apps/web/components/task/file-browser-path.ts index d65b7c70b7..0447f71fed 100644 --- a/apps/web/components/task/file-browser-path.ts +++ b/apps/web/components/task/file-browser-path.ts @@ -1,5 +1,14 @@ +import type { TaskSession } from "@/lib/types/http"; +import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; + const HOME_PATH_PATTERN = /^\/(?:Users|home)\/[^/]+\//; +export function getFileBrowserSessionWorkspacePath( + session: Pick | null | undefined, +): string | undefined { + return getSessionWorkspacePath(session); +} + export type FileBrowserPathInput = { sessionWorktreePath?: string | null; repositoryLocalPath?: string | null; diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx index 122abc45d2..4d31e94e18 100644 --- a/apps/web/components/task/file-browser.tsx +++ b/apps/web/components/task/file-browser.tsx @@ -33,7 +33,7 @@ import { toggleFolderExpand, fetchAndOpenFile, } from "./file-browser-hooks"; -import { resolveFileBrowserPaths } from "./file-browser-path"; +import { getFileBrowserSessionWorkspacePath, resolveFileBrowserPaths } from "./file-browser-path"; import { FileTreeEditorProvider } from "./file-tree-editor-menu"; import { getVisiblePaths, moveNodesInTree, computeMoveTargets } from "./file-tree-utils"; @@ -452,7 +452,7 @@ function useFileBrowserData(sessionId: string, environmentId: string | null | un [gitStatus?.files], ); const paths = resolveFileBrowserPaths({ - sessionWorktreePath: session?.worktree_path, + sessionWorktreePath: getFileBrowserSessionWorkspacePath(session), repositoryLocalPath: repository?.local_path, treePath: treeState.tree?.path, treeLoaded: isTreeLoaded, diff --git a/apps/web/components/task/file-editor-panel.image.test.tsx b/apps/web/components/task/file-editor-panel.image.test.tsx index da0f83f47c..7f03b94fd7 100644 --- a/apps/web/components/task/file-editor-panel.image.test.tsx +++ b/apps/web/components/task/file-editor-panel.image.test.tsx @@ -1,5 +1,5 @@ -import { act, render, screen } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useDockviewStore, type FileEditorState } from "@/lib/state/dockview-store"; import { buildRepoScopedItemId } from "@/lib/state/dockview-panel-actions"; @@ -7,13 +7,15 @@ vi.mock("./file-image-viewer", () => ({ FileImageViewer: ({ path, content, + worktreePath, headerActions, }: { path: string; content: string; + worktreePath?: string; headerActions?: React.ReactNode; }) => ( -
+
{content} {headerActions}
@@ -33,7 +35,11 @@ vi.mock("@/components/state-provider", () => ({ tasks: { activeSessionId: "session-1", activeTaskId: "task-1" }, taskSessions: { items: { - "session-1": { worktree_path: "/tmp/worktree", repository_id: "repo-1" }, + "session-1": { + workspace_path: "/tmp/task-root", + worktree_path: "/tmp/task-root/kandev", + repository_id: "repo-1", + }, }, }, }), @@ -60,6 +66,8 @@ import { FileEditorPanel } from "./file-editor-panel"; const PREVIEW_PANEL_ID = "preview:file-editor"; const IMAGE_VIEWER_TEST_ID = "image-viewer"; const SHARED_IMAGE_PATH = "docs/shared.png"; +const REPO_A_CONTENT = "content-from-repo-a"; +const REPO_B_CONTENT = "content-from-repo-b"; const makeImageState = (path: string, content: string): FileEditorState => ({ path, @@ -71,6 +79,8 @@ const makeImageState = (path: string, content: string): FileEditorState => ({ isBinary: true, }); +afterEach(cleanup); + function seedImage(path: string, content: string, repo?: string) { useDockviewStore.getState().setFileState(buildRepoScopedItemId(path, repo), { ...makeImageState(path, content), @@ -104,8 +114,8 @@ describe("FileEditorPanel image preview", () => { it("shows different image content for the same path across repos", () => { act(() => { - seedImage(SHARED_IMAGE_PATH, "content-from-repo-a", "repo-a"); - seedImage(SHARED_IMAGE_PATH, "content-from-repo-b", "repo-b"); + seedImage(SHARED_IMAGE_PATH, REPO_A_CONTENT, "repo-a"); + seedImage(SHARED_IMAGE_PATH, REPO_B_CONTENT, "repo-b"); }); const { rerender } = render( { />, ); - expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).textContent).toBe("content-from-repo-a"); + expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).textContent).toBe(REPO_A_CONTENT); rerender( { />, ); - expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).textContent).toBe("content-from-repo-b"); + expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).textContent).toBe(REPO_B_CONTENT); }); it("shows the shared external action in the docked desktop image header", () => { - act(() => seedImage(SHARED_IMAGE_PATH, "content-from-repo-a", "repo-a")); + act(() => seedImage(SHARED_IMAGE_PATH, REPO_A_CONTENT, "repo-a")); render( { size: "sm", }); }); + + it("uses the effective workspace path for docked desktop viewers", () => { + act(() => seedImage(SHARED_IMAGE_PATH, REPO_A_CONTENT, "repo-a")); + + render( + , + ); + + expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).getAttribute("data-worktree-path")).toBe( + "/tmp/task-root", + ); + }); }); diff --git a/apps/web/components/task/file-editor-panel.tsx b/apps/web/components/task/file-editor-panel.tsx index f6fca685c3..145d052c52 100644 --- a/apps/web/components/task/file-editor-panel.tsx +++ b/apps/web/components/task/file-editor-panel.tsx @@ -17,6 +17,7 @@ import { panelPortalManager } from "@/lib/layout/panel-portal-manager"; import { syncOpenFileFromWorkspace } from "@/hooks/file-editors-sync"; import { buildRepoScopedItemId } from "@/lib/state/dockview-panel-actions"; import { FileViewerExternalLink } from "./file-viewer-header"; +import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; type FileCategory = "image" | "binary" | "text"; @@ -327,7 +328,7 @@ export const FileEditorPanel = memo(function FileEditorPanel({ return ; } - const worktreePath = activeSession?.worktree_path ?? undefined; + const worktreePath = getSessionWorkspacePath(activeSession); const repositoryId = activeSession?.repository_id ?? undefined; const category = resolveFileCategory(isBinary, path); if (category !== "text") { diff --git a/apps/web/components/task/file-tab-content.test.tsx b/apps/web/components/task/file-tab-content.test.tsx index b6e8f3bba7..6995145613 100644 --- a/apps/web/components/task/file-tab-content.test.tsx +++ b/apps/web/components/task/file-tab-content.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; import type { OpenFileTab } from "@/lib/types/backend"; import { FileTabContent } from "./file-tab-content"; @@ -7,12 +7,18 @@ import { FileTabContent } from "./file-tab-content"; vi.mock("./file-editor-content", () => ({ FileEditorContent: ({ markdownPreview, + worktreePath, onToggleMarkdownPreview, }: { markdownPreview?: boolean; + worktreePath?: string; onToggleMarkdownPreview?: () => void; }) => ( -
+
@@ -41,6 +47,8 @@ const file: OpenFileTab = { markdownPreview: true, }; +afterEach(cleanup); + describe("FileTabContent Markdown preview", () => { it("renders a Markdown tab in preview mode and forwards the toggle", () => { const onToggleMarkdownPreview = vi.fn(); @@ -65,4 +73,26 @@ describe("FileTabContent Markdown preview", () => { fireEvent.click(screen.getByRole("button", { name: "Toggle preview" })); expect(onToggleMarkdownPreview).toHaveBeenCalledOnce(); }); + + it("uses the effective workspace path for desktop file viewers", () => { + render( + , + ); + + expect(screen.getByTestId("file-editor-content").getAttribute("data-worktree-path")).toBe( + "/tmp/task-root", + ); + }); }); diff --git a/apps/web/components/task/file-tab-content.tsx b/apps/web/components/task/file-tab-content.tsx index d19ae63a27..90e2f349fd 100644 --- a/apps/web/components/task/file-tab-content.tsx +++ b/apps/web/components/task/file-tab-content.tsx @@ -6,6 +6,7 @@ import { FileImageViewer } from "./file-image-viewer"; import { FileBinaryViewer } from "./file-binary-viewer"; import type { OpenFileTab } from "@/lib/types/backend"; import { getFileCategory, isMarkdownFile } from "@/lib/utils/file-types"; +import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; import { FileViewerExternalLink } from "./file-viewer-header"; import { getFileTabKey } from "./task-center-panel-file-tabs"; @@ -26,7 +27,11 @@ export function FileTabContent({ onToggleMarkdownPreview, }: { tab: OpenFileTab; - activeSession: { worktree_path?: string | null; repository_id?: string | null } | null; + activeSession: { + workspace_path?: string | null; + worktree_path?: string | null; + repository_id?: string | null; + } | null; activeSessionId: string | null; taskId?: string | null; isSaving: boolean; @@ -36,6 +41,7 @@ export function FileTabContent({ onToggleMarkdownPreview?: () => void; }) { const category = resolveTabCategory(tab); + const workspacePath = getSessionWorkspacePath(activeSession); const externalLink = ( )} {category === "binary" && ( )} @@ -73,7 +79,7 @@ export function FileTabContent({ sessionId={activeSessionId || undefined} taskId={taskId} repositoryId={activeSession?.repository_id ?? undefined} - worktreePath={activeSession?.worktree_path ?? undefined} + worktreePath={workspacePath} repo={tab.repo} enableComments={!!activeSessionId} markdownPreview={isMarkdownFile(tab.path) ? tab.markdownPreview : false} diff --git a/apps/web/components/task/mobile/mobile-file-viewer-panel.test.tsx b/apps/web/components/task/mobile/mobile-file-viewer-panel.test.tsx index 6a52ffcebb..23735bac12 100644 --- a/apps/web/components/task/mobile/mobile-file-viewer-panel.test.tsx +++ b/apps/web/components/task/mobile/mobile-file-viewer-panel.test.tsx @@ -8,7 +8,8 @@ const state = { id: "session-1", task_id: "task-1", repository_id: "primary-repo", - worktree_path: "/tmp/task", + workspace_path: "/tmp/task-root", + worktree_path: "/tmp/task-root/kandev", }, }, }, @@ -33,12 +34,40 @@ vi.mock("../markdown-preview-content", () => ({ MarkdownPreviewContent: () => , })); vi.mock("../file-image-viewer", () => ({ FileImageViewer: () => null })); -vi.mock("../file-binary-viewer", () => ({ FileBinaryViewer: () => null })); +vi.mock("../file-binary-viewer", () => ({ + FileBinaryViewer: ({ worktreePath }: { worktreePath?: string }) => ( + + ), +})); import { MobileFileViewerPanel } from "./mobile-file-viewer-panel"; afterEach(cleanup); +describe("MobileFileViewerPanel workspace path", () => { + it("uses the effective workspace path for binary file viewers", () => { + render( + , + ); + + expect(screen.getByTestId("binary-viewer").getAttribute("data-worktree-path")).toBe( + "/tmp/task-root", + ); + }); +}); + describe("MobileFileViewerPanel external file action", () => { it("renders a touch-sized action scoped to the open file's repository", () => { render( diff --git a/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx b/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx index 8978454bc0..2e4ffc5c68 100644 --- a/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx +++ b/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx @@ -11,6 +11,7 @@ import { FileBinaryViewer } from "../file-binary-viewer"; import { getFileCategory, isMarkdownFile } from "@/lib/utils/file-types"; import { useAppStore } from "@/components/state-provider"; import type { OpenFileTab } from "@/lib/types/backend"; +import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; import { ExternalVcsFileLink, useExternalVcsFileStatus, @@ -86,7 +87,7 @@ export function MobileFileViewerPanel({ sessionId ? (state.taskSessions.items[sessionId] ?? null) : null, ); const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); - const worktreePath = activeSession?.worktree_path ?? undefined; + const worktreePath = getSessionWorkspacePath(activeSession); const repositoryId = activeSession?.repository_id ?? undefined; const fileStatus = useExternalVcsFileStatus(file.path, sessionId, file.repo); const viewerKind = useMemo(() => resolveViewerKind(file), [file]); diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 189619c9ee..6d00df4189 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -35,6 +35,7 @@ import { useSessionReadTracking } from "./chat/use-session-read-tracking"; import { useDrainOlderMessages } from "@/components/task/chat/use-drain-older-messages"; import { useAppStore } from "@/components/state-provider"; import type { Message } from "@/lib/types/http"; +import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; import { routePanelMouseDown } from "./chat/route-panel-mouse-down"; /** @@ -362,7 +363,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ messagesLoading={messagesLoading} isWorking={isWorking} sessionState={session?.state} - worktreePath={session?.worktree_path} + worktreePath={getSessionWorkspacePath(session)} onOpenFile={onOpenFile} dividerBeforeItemKey={dividerBeforeItemKey} lastPromptMessageId={lastPromptMessageId} diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 01612f0f94..c92319ce19 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1759,6 +1759,7 @@ export class ApiClient { state: string; started_at: string; task_environment_id?: string; + workspace_path?: string; worktree_path?: string; worktree_branch?: string; worktrees?: Array<{ repository_id?: string; worktree_path?: string }>; diff --git a/apps/web/e2e/tests/task/add-workspace-sources.spec.ts b/apps/web/e2e/tests/task/add-workspace-sources.spec.ts index 36da7f7f2a..57a273aef8 100644 --- a/apps/web/e2e/tests/task/add-workspace-sources.spec.ts +++ b/apps/web/e2e/tests/task/add-workspace-sources.spec.ts @@ -39,6 +39,14 @@ function createSourceDirectories(root: string) { return { repositoryPath, folderPath }; } +function activeFileEditor(page: Page) { + return page.locator(".monaco-editor:visible").first(); +} + +function activeFileTab(page: Page, filename: string) { + return page.locator(".dv-tab.dv-active-tab", { hasText: filename }).first(); +} + test.describe("Attach local workspace sources", () => { test("adds a local repository and folder successively, scopes Changes to Git, and persists", async ({ testPage, @@ -207,6 +215,14 @@ test.describe("Attach local workspace sources", () => { await testPage.reload(); await session.waitForLoad(); + const refreshedSessions = await apiClient.listTaskSessions(task.id); + const refreshedPrimarySession = refreshedSessions.sessions.find( + ({ id }) => id === task.session_id, + ); + expect(refreshedPrimarySession).toMatchObject({ + workspace_path: path.dirname(repoPaths[0]), + worktree_path: repoPaths[0], + }); await session.clickTab("Files"); await expect( session.files @@ -216,6 +232,40 @@ test.describe("Attach local workspace sources", () => { await expect( session.files.getByTestId("file-tree-node").filter({ hasText: "plain-local-folder" }), ).toBeVisible(); + + // Chat links in a multi-repository workspace are absolute on the host but + // must resolve relative to the task root (not the primary repository). + const taskState = await apiClient.getTask(task.id); + const activeSessionId = taskState.primary_session_id; + if (!activeSessionId) throw new Error("task has no primary session after source attachment"); + const linkedRepoPath = repoPaths.find((repoPath) => + repoPath.endsWith("second-local-repository-main"), + ); + if (!linkedRepoPath) throw new Error("attached repository worktree path was not returned"); + const primaryFilePath = path.join(repoPaths[0], "changes/repository-0.txt"); + const linkedFilePath = path.join(linkedRepoPath, "second-source.txt"); + await apiClient.seedSessionMessage(activeSessionId, { + type: "message", + content: `[primary source](${primaryFilePath}) [second source](${linkedFilePath})`, + }); + + await testPage.reload(); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await session.showSessionContext(); + const primaryChatLink = session.activeChat().getByRole("link", { name: "primary source" }); + const siblingChatLink = session.activeChat().getByRole("link", { name: "second source" }); + await expect(primaryChatLink).toBeVisible({ timeout: 15_000 }); + await expect(siblingChatLink).toBeVisible({ timeout: 15_000 }); + await primaryChatLink.click(); + const fileEditor = activeFileEditor(testPage); + await expect(fileEditor).toBeVisible({ timeout: 15_000 }); + await expect(fileEditor.locator(".view-lines")).toContainText("repository 0"); + await expect(activeFileTab(testPage, "repository-0.txt")).toBeVisible({ timeout: 15_000 }); + await session.showSessionContext(); + await session.activeChat().getByRole("link", { name: "second source" }).click(); + await expect(fileEditor.locator(".view-lines")).toContainText("repository source"); + await expect(activeFileTab(testPage, "second-source.txt")).toBeVisible({ timeout: 15_000 }); }); test("explains the disabled action while an active turn is running", async ({ diff --git a/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts b/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts index b4b85c27ef..fa2fc3b3ac 100644 --- a/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts +++ b/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts @@ -252,4 +252,39 @@ test("mobile Files drawer attaches sources with fixed controls and persisted wor "scrollWidth", await testPage.evaluate(() => innerWidth), ); + + // The mobile chat surface must use the task workspace root when opening an + // absolute link into the attached repository. + const taskState = await apiClient.getTask(task.id); + const activeSessionId = taskState.primary_session_id; + expect(activeSessionId).toBeTruthy(); + const sessionData = await apiClient.listTaskSessions(task.id); + const linkedRepoPath = sessionData.sessions + .flatMap((item) => item.worktrees ?? []) + .map((worktree) => worktree.worktree_path) + .find((worktreePath) => worktreePath?.endsWith("mobile-local-repository-main")); + expect(linkedRepoPath).toBeTruthy(); + await apiClient.seedSessionMessage(activeSessionId!, { + type: "message", + content: `[mobile source](${path.join(linkedRepoPath!, "mobile-repository.txt")})`, + }); + + await testPage.reload(); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await testPage.getByRole("button", { name: "Chat" }).tap(); + const chatLink = session.activeChat().getByRole("link", { name: "mobile source" }); + await expect(chatLink).toBeVisible({ timeout: 15_000 }); + await chatLink.tap(); + const viewer = testPage.getByTestId("mobile-file-viewer-panel"); + await expect(viewer).toBeVisible({ timeout: 15_000 }); + await expect(viewer.locator(".cm-line").filter({ hasText: "repository source" })).toBeVisible(); + await expect(viewer.getByRole("button", { name: "Close" })).toBeVisible(); + await expect( + viewer.getByText("mobile-local-repository-main/mobile-repository.txt"), + ).toBeVisible(); + await expect(testPage.locator("html")).toHaveJSProperty( + "scrollWidth", + await testPage.evaluate(() => innerWidth), + ); }); diff --git a/apps/web/hooks/use-lsp-file-opener.test.ts b/apps/web/hooks/use-lsp-file-opener.test.ts new file mode 100644 index 0000000000..7b4b8ae1cc --- /dev/null +++ b/apps/web/hooks/use-lsp-file-opener.test.ts @@ -0,0 +1,125 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const lspMocks = vi.hoisted(() => ({ + sessionId: "session-1", + taskRoot: "/task-root", + state: { + tasks: { activeSessionId: "session-1" }, + taskSessions: { + items: { + "session-1": { + workspace_path: "/task-root", + worktree_path: "/task-root/kandev", + }, + }, + }, + }, + openFile: vi.fn(), + setPendingCursorPosition: vi.fn(), + scrollEditorIfMounted: vi.fn(), + disposePlaceholderModel: vi.fn(), + setFileOpener: vi.fn(), + getFileOpener: vi.fn(), + currentOpener: null as ((uri: string, line?: number, column?: number) => void) | null, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: typeof lspMocks.state) => unknown) => selector(lspMocks.state), +})); + +vi.mock("@/hooks/use-file-editors", () => ({ + useFileEditors: () => ({ openFile: lspMocks.openFile }), + setPendingCursorPosition: lspMocks.setPendingCursorPosition, + scrollEditorIfMounted: lspMocks.scrollEditorIfMounted, +})); + +vi.mock("@/lib/lsp/lsp-client-manager", () => ({ + lspClientManager: { + disposePlaceholderModel: lspMocks.disposePlaceholderModel, + setFileOpener: lspMocks.setFileOpener, + getFileOpener: lspMocks.getFileOpener, + }, +})); + +import { toWorkspaceRelativePath, useLspFileOpener } from "./use-lsp-file-opener"; + +const ATTACHED_FILE_PATH = "second-repository-main/src/index.ts"; +const ATTACHED_FILE_URI = `file:///task-root/${ATTACHED_FILE_PATH}`; +const NEXT_TASK_ROOT = "/next-task-root"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + lspMocks.currentOpener = null; + lspMocks.state.taskSessions.items["session-1"].workspace_path = lspMocks.taskRoot; +}); + +describe("toWorkspaceRelativePath", () => { + it("resolves attached-repository files from the task workspace root", () => { + expect(toWorkspaceRelativePath(`/task-root/${ATTACHED_FILE_PATH}`, lspMocks.taskRoot)).toBe( + ATTACHED_FILE_PATH, + ); + }); + + it("preserves sibling prefixes and handles a trailing workspace separator", () => { + expect(toWorkspaceRelativePath("/task-root-old/src/index.ts", lspMocks.taskRoot)).toBe( + "/task-root-old/src/index.ts", + ); + expect(toWorkspaceRelativePath("/task-root/src/index.ts", `${lspMocks.taskRoot}/`)).toBe( + "src/index.ts", + ); + }); + + it("keeps the absolute path when no workspace root is available", () => { + expect(toWorkspaceRelativePath("/legacy-worktree/src/index.ts", null)).toBe( + "/legacy-worktree/src/index.ts", + ); + }); +}); + +describe("useLspFileOpener", () => { + it("registers an opener that uses the effective workspace path", async () => { + lspMocks.setFileOpener.mockImplementation((opener) => { + lspMocks.currentOpener = opener; + }); + lspMocks.getFileOpener.mockImplementation(() => lspMocks.currentOpener); + + renderHook(() => useLspFileOpener()); + const opener = lspMocks.currentOpener; + expect(opener).not.toBeNull(); + + await act(async () => { + await opener?.(ATTACHED_FILE_URI, 12, 3); + }); + + expect(lspMocks.disposePlaceholderModel).toHaveBeenCalledWith(ATTACHED_FILE_URI); + expect(lspMocks.openFile).toHaveBeenCalledWith(ATTACHED_FILE_PATH); + expect(lspMocks.setPendingCursorPosition).toHaveBeenCalledWith(ATTACHED_FILE_PATH, 12, 3); + expect(lspMocks.scrollEditorIfMounted).toHaveBeenCalledWith( + ATTACHED_FILE_PATH, + lspMocks.taskRoot, + 12, + 3, + ); + }); + + it("refreshes the registered opener when the workspace root changes", async () => { + lspMocks.setFileOpener.mockImplementation((opener) => { + lspMocks.currentOpener = opener; + }); + lspMocks.getFileOpener.mockImplementation(() => lspMocks.currentOpener); + + const view = renderHook(() => useLspFileOpener()); + const firstOpener = lspMocks.currentOpener; + lspMocks.state.taskSessions.items["session-1"].workspace_path = NEXT_TASK_ROOT; + view.rerender(); + const secondOpener = lspMocks.currentOpener; + + expect(secondOpener).not.toBe(firstOpener); + await act(async () => { + await secondOpener?.(`file://${NEXT_TASK_ROOT}/src/index.ts`); + }); + expect(lspMocks.openFile).toHaveBeenCalledWith("src/index.ts"); + }); +}); diff --git a/apps/web/hooks/use-lsp-file-opener.ts b/apps/web/hooks/use-lsp-file-opener.ts index d44d7d0841..67883b8482 100644 --- a/apps/web/hooks/use-lsp-file-opener.ts +++ b/apps/web/hooks/use-lsp-file-opener.ts @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { useAppStore } from "@/components/state-provider"; +import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; import { useFileEditors, setPendingCursorPosition, @@ -9,6 +10,13 @@ import { } from "@/hooks/use-file-editors"; import { lspClientManager } from "@/lib/lsp/lsp-client-manager"; +export function toWorkspaceRelativePath(filePath: string, workspacePath: string | null): string { + const normalizedWorkspacePath = workspacePath?.replace(/\/+$/, "") ?? ""; + if (!normalizedWorkspacePath) return filePath; + const workspacePrefix = `${normalizedWorkspacePath}/`; + return filePath.startsWith(workspacePrefix) ? filePath.slice(workspacePrefix.length) : filePath; +} + /** * Connects LSP Go-to-Definition / Find References navigation to dockview file tabs. * When Monaco's registerEditorOpener fires with a file:// URI, this hook converts @@ -17,11 +25,11 @@ import { lspClientManager } from "@/lib/lsp/lsp-client-manager"; export function useLspFileOpener() { const { openFile } = useFileEditors(); - const worktreePath = useAppStore((state) => { + const workspacePath = useAppStore((state) => { const sessionId = state.tasks.activeSessionId; if (!sessionId) return null; const session = state.taskSessions.items[sessionId]; - return session?.worktree_path ?? null; + return getSessionWorkspacePath(session) ?? null; }); useEffect(() => { @@ -33,10 +41,7 @@ export function useLspFileOpener() { lspClientManager.disposePlaceholderModel(uri); // Convert absolute path to workspace-relative path - let relativePath = filePath; - if (worktreePath && filePath.startsWith(worktreePath)) { - relativePath = filePath.slice(worktreePath.length + 1); // +1 for the trailing / - } + const relativePath = toWorkspaceRelativePath(filePath, workspacePath); // Set pending cursor position so the editor jumps to the correct line/column. // For new files: consumed by handleEditorDidMount when the editor mounts. @@ -50,7 +55,7 @@ export function useLspFileOpener() { // For already-open files, the editor is already mounted so handleEditorDidMount // won't fire. Scroll the editor directly. if (line) { - scrollEditorIfMounted(relativePath, worktreePath, line, column ?? 1); + scrollEditorIfMounted(relativePath, workspacePath, line, column ?? 1); } }; @@ -61,5 +66,5 @@ export function useLspFileOpener() { lspClientManager.setFileOpener(null); } }; - }, [openFile, worktreePath]); + }, [openFile, workspacePath]); } diff --git a/apps/web/lib/session-workspace-path.test.ts b/apps/web/lib/session-workspace-path.test.ts new file mode 100644 index 0000000000..583846544b --- /dev/null +++ b/apps/web/lib/session-workspace-path.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { getSessionWorkspacePath } from "./session-workspace-path"; + +describe("getSessionWorkspacePath", () => { + it("prefers the effective task workspace root", () => { + expect( + getSessionWorkspacePath({ + workspace_path: "/task-root", + worktree_path: "/task-root/kandev", + }), + ).toBe("/task-root"); + }); + + it("falls back to the primary worktree for legacy sessions", () => { + expect(getSessionWorkspacePath({ worktree_path: "/legacy/repository" })).toBe( + "/legacy/repository", + ); + }); + + it("returns undefined when neither path is available", () => { + expect(getSessionWorkspacePath(null)).toBeUndefined(); + }); +}); diff --git a/apps/web/lib/session-workspace-path.ts b/apps/web/lib/session-workspace-path.ts new file mode 100644 index 0000000000..6e703f467e --- /dev/null +++ b/apps/web/lib/session-workspace-path.ts @@ -0,0 +1,13 @@ +import type { TaskSession } from "@/lib/types/http"; + +type SessionWorkspacePathInput = { + workspace_path?: TaskSession["workspace_path"] | null; + worktree_path?: TaskSession["worktree_path"] | null; +}; + +/** Returns the task-root path used by Files/chat, with legacy session fallback. */ +export function getSessionWorkspacePath( + session: SessionWorkspacePathInput | null | undefined, +): string | undefined { + return session?.workspace_path || session?.worktree_path || undefined; +} diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts index 67783621e7..300e669ec5 100644 --- a/apps/web/lib/state/slices/session/session-slice.ts +++ b/apps/web/lib/state/slices/session/session-slice.ts @@ -110,6 +110,7 @@ function mergeTaskSession(existing: TaskSession, incoming: TaskSession): TaskSes worktree_id: incoming.worktree_id ?? existing.worktree_id, worktree_path: incoming.worktree_path ?? existing.worktree_path, worktree_branch: incoming.worktree_branch ?? existing.worktree_branch, + workspace_path: incoming.workspace_path ?? existing.workspace_path, repository_id: incoming.repository_id ?? existing.repository_id, base_branch: incoming.base_branch ?? existing.base_branch, task_environment_id: incoming.task_environment_id ?? existing.task_environment_id, diff --git a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts index 0b9467678e..ab77da8c43 100644 --- a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts +++ b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts @@ -89,6 +89,22 @@ describe("upsertTaskSessionFromEvent", () => { expect(store.getState().environmentIdBySessionId[SESSION_ID]).toBe("env-1"); }); + it("preserves the live workspace root when a later partial refresh omits it", () => { + const store = makeStore(); + + store + .getState() + .upsertTaskSessionFromEvent( + TASK_ID, + makeSession({ workspace_path: "/task-root", worktree_path: "/task-root/kandev" }), + ); + store.getState().setTaskSessionsForTask(TASK_ID, [makeSession()]); + + const session = store.getState().taskSessions.items[SESSION_ID]; + expect(session.workspace_path).toBe("/task-root"); + expect(session.worktree_path).toBe("/task-root/kandev"); + }); + it("does not seed environmentIdBySessionId when task_environment_id is absent", () => { const store = makeStore(); diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index f7aebde986..3b76f008af 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -320,6 +320,8 @@ export type TaskSessionAgentctlPayload = { worktree_id?: string; worktree_path?: string; worktree_branch?: string; + /** Effective task workspace root when the agentctl payload carries it. */ + workspace_path?: string; /** Task root that contains every per-repo worktree as a sibling subdir. * Set only when the event signals a sibling worktree addition (multi-branch * add_branch flow) — the frontend repoints the file browser to it instead of diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index cdefb2e2bc..3d3106a101 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -417,6 +417,8 @@ export type TaskSession = ActiveSubagentCountFields & { worktree_id?: string; worktree_path?: string; worktree_branch?: string; + /** Effective task root containing every attached workspace source. */ + workspace_path?: string; worktrees?: TaskSessionWorktree[]; task_environment_id?: string; state: TaskSessionState; diff --git a/apps/web/lib/ws/handlers/agent-session.test.ts b/apps/web/lib/ws/handlers/agent-session.test.ts index 0f0b6738a1..7a2fca1b27 100644 --- a/apps/web/lib/ws/handlers/agent-session.test.ts +++ b/apps/web/lib/ws/handlers/agent-session.test.ts @@ -49,6 +49,7 @@ const STATE_CHANGED_EVENT = "session.state_changed"; const ACTIVITY_EVENT = "session.activity_changed"; const RECOVERABLE_ERROR_MESSAGE = "peer disconnected before response"; const RECOVERABLE_ERROR_AT = "2026-06-14T14:06:40Z"; +const TASK_ROOT = "/task-root"; function makeMessage(payload: TaskSessionStateChangedPayload) { return { @@ -234,11 +235,39 @@ describe("session.workspace_sources.updated handler", () => { } as never); expect(setTaskSession).toHaveBeenCalledWith( - expect.objectContaining({ id: "s-1", worktree_path: "/new" }), + expect.objectContaining({ id: "s-1", worktree_path: "/old", workspace_path: "/new" }), ); expect(bumpWorkspaceFilesRefresh).toHaveBeenCalledWith("s-1"); expect(store.getState().reconcileWorkspaceSourcesAdopted).toHaveBeenCalledWith(["s-1"]); }); + + it("does not clear the workspace root when a partial event omits it", () => { + const setTaskSession = vi.fn(); + const store = makeStore({ + taskSessions: { + items: { + "s-1": { + id: "s-1", + task_id: "t-1", + state: "IDLE", + worktree_path: "/task-root/kandev", + workspace_path: TASK_ROOT, + }, + }, + }, + setTaskSession, + }); + + const handler = registerTaskSessionHandlers(store)["session.workspace_sources.updated"]!; + handler({ + id: "msg-workspace-sources-partial", + type: "notification", + action: "session.workspace_sources.updated", + payload: { task_id: "t-1", session_id: "s-1" }, + } as never); + + expect(setTaskSession).not.toHaveBeenCalled(); + }); }); describe("session.state_changed name propagation", () => { @@ -907,6 +936,7 @@ describe("session.state_changed → agentctl ready fallback", () => { id: "s-1", task_environment_id: "env-1", worktree_path: "/tmp/kandev/tasks/ws/task-1", + workspace_path: "/tmp/kandev/tasks/ws/task-1", }), ); }); @@ -943,6 +973,71 @@ describe("session.state_changed → agentctl ready fallback", () => { ); }); + it("preserves the primary worktree when a sibling agentctl_ready arrives", () => { + const upsertTaskSessionFromEvent = vi.fn(); + const setTaskSession = vi.fn(); + const store = makeStore({ + taskSessions: { + items: { + "s-1": { + id: "s-1", + task_id: "t-1", + state: "RUNNING", + repository_id: "primary-repo", + worktree_id: "primary-worktree", + worktree_path: "/task-root/kandev", + worktree_branch: "main", + }, + }, + }, + sessionAgentctl: { itemsBySessionId: {} }, + sessionWorktreesBySessionId: { itemsBySessionId: { "s-1": ["primary-worktree"] } }, + setSessionAgentctlStatus: vi.fn(), + setTaskSession, + upsertTaskSessionFromEvent, + setWorktree: vi.fn(), + setSessionWorktrees: vi.fn(), + }); + const handler = registerTaskSessionHandlers(store)["session.agentctl_ready"]!; + + handler({ + id: "m", + type: "notification", + action: "session.agentctl_ready", + timestamp: TS, + payload: { + task_id: "t-1", + session_id: "s-1", + agent_execution_id: "ae-sibling", + task_environment_id: "env-1", + worktree_id: "sibling-worktree", + worktree_path: "/task-root/second-repository-main", + worktree_branch: "main", + workspace_path: TASK_ROOT, + }, + }); + + const upsertPayload = upsertTaskSessionFromEvent.mock.calls[0]?.[1]; + expect(upsertPayload).toEqual( + expect.objectContaining({ + id: "s-1", + task_environment_id: "env-1", + workspace_path: TASK_ROOT, + }), + ); + expect(upsertPayload).not.toHaveProperty("worktree_id"); + expect(upsertPayload).not.toHaveProperty("worktree_path"); + expect(upsertPayload).not.toHaveProperty("worktree_branch"); + expect(setTaskSession).toHaveBeenCalledWith( + expect.objectContaining({ + worktree_id: "primary-worktree", + worktree_path: "/task-root/kandev", + worktree_branch: "main", + workspace_path: TASK_ROOT, + }), + ); + }); + it("does not call upsertTaskSessionFromEvent when agentctl payload omits task_environment_id", () => { const upsertTaskSessionFromEvent = vi.fn(); const store = makeStore({ diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index c7973efa13..652d6ac0a3 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -49,6 +49,27 @@ function isTaskSessionListHydrating(state: AppState, taskId: string): boolean { return !byTask.loadedByTaskId?.[taskId]; } +function isSiblingWorktree( + existing: { worktree_id?: string } | undefined, + payload: { worktree_id?: string } | undefined, +): boolean { + const existingWorktreeId = existing?.worktree_id; + const payloadWorktreeId = payload?.worktree_id; + return !!existingWorktreeId && !!payloadWorktreeId && existingWorktreeId !== payloadWorktreeId; +} + +function getAgentctlWorktreeFields( + payload: { worktree_id?: string; worktree_path?: string; worktree_branch?: string }, + isSibling: boolean, +): Record { + if (isSibling) return {}; + return { + worktree_id: payload.worktree_id, + worktree_path: payload.worktree_path, + worktree_branch: payload.worktree_branch, + }; +} + /** * Manual session selection pins a task-scoped session. Background WS events * may only override that pin once the pinned session is known terminal, or @@ -366,6 +387,7 @@ function syncEnvFromAgentctlPayload( const taskId = toTaskId(rawTaskId); const sessionId = toSessionId(rawSessionId); const existing = store.getState().taskSessions.items[sessionId]; + const isSibling = isSiblingWorktree(existing, payload); store.getState().upsertTaskSessionFromEvent(taskId, { id: sessionId, task_id: taskId, @@ -373,15 +395,14 @@ function syncEnvFromAgentctlPayload( started_at: existing?.started_at ?? "", updated_at: existing?.updated_at ?? "", task_environment_id: envId, - worktree_id: payload.worktree_id, - worktree_path: payload.worktree_path, - worktree_branch: payload.worktree_branch, + ...getAgentctlWorktreeFields(payload, isSibling), + workspace_path: payload.workspace_path ?? payload.task_workspace_path ?? payload.worktree_path, }); } /** Builds the partial-session patch applied for an agentctl_ready event. - * On sibling materialize we repoint worktree_path to the task root and keep - * the primary's id/branch; the initial ready event sets id/path/branch + * On sibling materialize we update workspace_path to the task root and keep + * the primary's id/path/branch; the initial ready event sets id/path/branch * straight from the payload. */ function buildAgentctlReadySessionUpdate( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -390,12 +411,16 @@ function buildAgentctlReadySessionUpdate( ): Record { const update: Record = {}; if (isSibling) { - if (payload.task_workspace_path) update.worktree_path = payload.task_workspace_path; + const workspacePath = payload.workspace_path ?? payload.task_workspace_path; + if (workspacePath) update.workspace_path = workspacePath; return update; } if (payload.worktree_id) update.worktree_id = payload.worktree_id; if (payload.worktree_path) update.worktree_path = payload.worktree_path; if (payload.worktree_branch) update.worktree_branch = payload.worktree_branch; + const workspacePath = + payload.workspace_path ?? payload.task_workspace_path ?? payload.worktree_path; + if (workspacePath) update.workspace_path = workspacePath; return update; } @@ -429,7 +454,7 @@ function recordAgentctlReadyWorktree( * 2. Sibling materialized (multi-branch add_branch flow) — payload * describes a NEW worktree being added alongside the primary. The * primary's worktree_id/branch must NOT be clobbered (they still own - * the chat/agent process); only worktree_path moves to the task root + * the chat/agent process); only workspace_path moves to the task root * so the file browser repoints from "primary worktree" to "task root * containing both worktree siblings". A commits refetch is bumped so * the Commits panel re-queries with the new multi-repo subpaths @@ -440,10 +465,7 @@ function handleAgentctlReady(store: StoreApi, payload: any): void { const existingSession = store.getState().taskSessions.items[payload.session_id]; if (!existingSession) return; - const isSibling = - !!payload.worktree_id && - !!existingSession.worktree_id && - payload.worktree_id !== existingSession.worktree_id; + const isSibling = isSiblingWorktree(existingSession, payload); const sessionUpdate = buildAgentctlReadySessionUpdate(payload, isSibling); if (Object.keys(sessionUpdate).length > 0) { @@ -539,7 +561,9 @@ function handleWorkspaceSourcesUpdated( adopted_session_ids: adoptedSessionIds, } = payload; const existing = store.getState().taskSessions.items[sessionId]; - if (existing) store.getState().setTaskSession({ ...existing, worktree_path: workspacePath }); + if (existing && workspacePath) { + store.getState().setTaskSession({ ...existing, workspace_path: workspacePath }); + } store.getState().reconcileWorkspaceSourcesAdopted(adoptedSessionIds ?? [sessionId]); store.getState().bumpWorkspaceFilesRefresh(sessionId); store.getState().clearLegacyGitStatusEntry(sessionId); diff --git a/docs/plans/multi-repo-chat-file-links/plan.md b/docs/plans/multi-repo-chat-file-links/plan.md new file mode 100644 index 0000000000..17044098aa --- /dev/null +++ b/docs/plans/multi-repo-chat-file-links/plan.md @@ -0,0 +1,261 @@ +--- +spec: docs/specs/tasks/attach-workspace-sources.md +decision: docs/decisions/2026-07-22-runtime-mutable-task-workspace-sources.md +created: 2026-08-01 +status: completed +--- + +# Implementation Plan: Fix Multi-Repository Chat File Links + +## Overview + +Keep the primary repository worktree and the effective task workspace root as separate session +fields. The backend will return an environment-backed `workspace_path` in every task-session +projection, while the frontend will use that root for Files and chat path resolution and preserve +`worktree_path` as the primary repository path. The repair covers live source adoption, sibling +materialization, reload/hydration, desktop chat, and the native mobile file viewer. + +## Confirmed Root Cause + +The failing task had a promoted task root containing three repositories. The requested file existed +at `/kandev/docs/public/agents-and-profiles.md`, but the browser restored the session's +flattened primary `worktree_path` (`/kandev`) after a refresh. Markdown resolution removed +that prefix and sent `docs/public/agents-and-profiles.md` to agentctl, whose file API was rooted at +``; it therefore looked for `/docs/...` and returned `ENOENT`. + +Three contracts currently conflict: + +1. `session.workspace_sources.updated` and sibling `session.agentctl_ready` temporarily overwrite + `worktree_path` with the promoted task root. +2. Full and summary session DTOs omit the model's `workspace_path` and flatten the first session + worktree back into `worktree_path`, so API hydration reverses the live update. +3. Chat and Files consumers use `worktree_path` as both repository identity and file-service root. + +The file endpoint itself behaves correctly: with no explicit repository argument it resolves a +safe path beneath agentctl's adopted task root. The repair is to supply that endpoint with the +correct task-root-relative path, not to widen its filesystem boundary. + +--- + +## Backend + +### Authoritative session workspace projection + +Files: + +- `apps/backend/internal/task/repository/sqlite/session.go` +- `apps/backend/internal/task/repository/sqlite/session_test.go` +- `apps/backend/internal/task/models/models.go` +- `apps/backend/internal/task/dto/dto.go` +- `apps/backend/internal/task/dto/converters_test.go` + +Changes: + +- Extend the common task-session read query to left-join the linked task environment and project + `task_environments.workspace_path` when present, falling back to the persisted + `task_sessions.workspace_path` for legacy or environment-less sessions. +- Keep session creation/update persistence and the existing first-worktree flattening unchanged; + `WorkspacePath` represents the effective file-service root and `WorktreePath` remains the primary + repository path. +- Add `workspace_path` to both `TaskSessionDTO` and `TaskSessionSummaryDTO`, and populate it from the + model in both converters. +- Do not add a schema migration: both workspace-path columns and the session-to-environment foreign + key already exist. + +### Backend behavioral coverage + +- Seed a task session whose persisted workspace and primary worktree point at the repository, then + promote its task environment to the parent task root. Assert both `GetTaskSession` and + `ListTaskSessions` return the promoted `WorkspacePath` while retaining the primary worktree. +- Assert the full and summary DTO converters serialize the same `workspace_path` alongside the + flattened primary `worktree_path`. +- Retain a legacy fallback case with no linked task environment so existing single-repository and + repository-less sessions continue returning their persisted workspace path. + +--- + +## Frontend + +### Separate workspace and worktree state + +Files: + +- `apps/web/lib/types/http.ts` +- `apps/web/lib/state/slices/session/session-slice.ts` +- `apps/web/lib/state/slices/session/session-slice.upsert.test.ts` +- `apps/web/lib/ws/handlers/agent-session.ts` +- `apps/web/lib/ws/handlers/agent-session.test.ts` +- `apps/web/app/office/tasks/[id]/office-dockview-layout.tsx` +- `apps/web/components/task/file-browser-path.ts` +- `apps/web/components/task/file-browser-path.test.ts` +- `apps/web/components/task/file-tab-content.tsx` +- `apps/web/components/task/file-tab-content.test.tsx` +- `apps/web/components/task/file-editor-panel.tsx` +- `apps/web/components/task/file-editor-panel.image.test.tsx` +- `apps/web/components/task/mobile/mobile-file-viewer-panel.tsx` +- `apps/web/components/task/mobile/mobile-file-viewer-panel.test.tsx` +- `apps/web/hooks/use-lsp-file-opener.ts` +- `apps/web/hooks/use-lsp-file-opener.test.ts` + +Changes: + +- Add optional `workspace_path` to the frontend task-session type and preserve it during partial + session merges. +- Change workspace-source adoption and sibling-materialization handlers to write + `workspace_path`; they must not overwrite the primary `worktree_path`. +- Seed `workspace_path` from initial agentctl/ensure-session payloads when available, with + `worktree_path` as the compatibility fallback for older payloads. +- Update the Office ensure-session bridge to populate `workspace_path` instead of manufacturing a + `worktree_path` value. +- Add regression tests for event ordering in both directions: a live workspace-root event followed + by API hydration, and authoritative API hydration followed by a partial event. + +### Resolve file paths from the effective root + +Files: + +- `apps/web/lib/session-workspace-path.ts` (new) +- `apps/web/lib/session-workspace-path.test.ts` (new) +- `apps/web/components/shared/markdown-components.tsx` +- `apps/web/components/shared/markdown-components.test.tsx` +- `apps/web/components/task/task-chat-panel.tsx` +- `apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx` +- `apps/web/components/quick-chat/quick-chat-content.tsx` +- `apps/web/components/task/file-browser.tsx` + +Changes: + +- Add one pure resolver that selects `workspace_path` first and falls back to `worktree_path` for + backward compatibility. +- Use that resolver wherever the session root is passed into shared chat Markdown/tool rendering, + the fallback Markdown link context, the Files browser root, desktop/mobile file viewers, and LSP + navigation. +- Keep the existing Markdown safety checks: traversal, unrelated host absolute paths, and paths + outside the known workspace remain rejected. +- When the task root is ``, resolve absolute links under the primary repository to + `kandev/...` and links under siblings to their own top-level source directory. Pass those paths + through the existing file-open action; do not change the agentctl file API or infer paths with + `filepath.Dir` in the browser. + +### Frontend behavioral coverage + +- Extend Markdown component tests with primary and sibling absolute links beneath a multi-source + task root, a post-hydration session root, a legacy single-repository fallback, and an outside-root + rejection. +- Extend session store and WS tests to prove `workspace_path` survives refresh races while + `worktree_path` remains unchanged. +- Cover the pure root selector independently so all chat/File consumers share one precedence rule. +- Cover workspace-relative LSP conversion, registration, cleanup, and cursor scrolling with + distinct workspace and primary worktree paths. + +--- + +## E2E Tests + +Files: + +- `apps/web/e2e/tests/task/add-workspace-sources.spec.ts` +- `apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts` + +Desktop scenario: + +1. Create a Worktree task and attach a second local repository through the existing UI flow. +2. Obtain the authoritative environment workspace root, seed an agent message containing absolute + Markdown links to known files in the primary and attached repositories, and reload the page. +3. Click each link and assert the exact file content opens without the **Failed to open file** toast. +4. Assert the refreshed session payload still reports a primary `worktree_path` distinct from the + promoted `workspace_path`. + +Mobile scenario: + +1. Reuse the native mobile add-sources flow, seed an absolute chat link beneath the promoted root, + and reload. +2. Tap the link from the Chat tab and assert the native mobile file viewer shows the expected file + content and close control with no horizontal overflow or failure toast. + +This is shared state/path normalization with no new controls or layout. The mobile E2E verifies +capability parity; no separate mobile composition is introduced. + +--- + +## Implementation Waves + +Wave 1: + +- [x] [task-01-expose-authoritative-workspace-root](task-01-expose-authoritative-workspace-root.md) + +Wave 2: + +- [x] [task-02-resolve-chat-links-from-workspace-root](task-02-resolve-chat-links-from-workspace-root.md) + +Execution is sequential in the primary conversation. No task is marked parallel-safe, and these +waves do not authorize subagents. + +## Verification Commands + +```bash +make -C apps/backend test + +cd apps/web +rtk pnpm exec vitest run \ + lib/session-workspace-path.test.ts \ + lib/state/slices/session/session-slice.upsert.test.ts \ + lib/ws/handlers/agent-session.test.ts \ + components/shared/markdown-components.test.tsx \ + components/task/file-browser-path.test.ts \ + components/task/file-tab-content.test.tsx \ + components/task/mobile/mobile-file-viewer-panel.test.tsx \ + components/task/file-editor-panel.image.test.tsx \ + hooks/use-lsp-file-opener.test.ts +rtk pnpm run typecheck +rtk pnpm e2e:run --host --no-build tests/task/add-workspace-sources.spec.ts --grep "adds a local repository" +rtk pnpm e2e:run --host --no-build --project mobile-chrome tests/task/mobile-add-workspace-sources.spec.ts +``` + +Run `pnpm install --frozen-lockfile` from `apps/` before frontend verification when this worktree +does not yet have `apps/node_modules/`. + +## Risks + +- The shared session SELECT feeds many read paths; its column order must remain exactly aligned with + both single-row and multi-row scanners. +- Some legacy sessions have no linked task environment. The SQL fallback and frontend fallback must + keep those sessions working without inventing a parent directory. +- Treating `worktree_path` as a workspace root anywhere else can reintroduce the race. The shared + selector should be used only by file-root consumers; repository identity and Git operations must + continue using worktree/repository fields. +- E2E mock-agent messages must escape absolute paths without changing their Markdown link target. + +## Out of Scope + +- Changing agent or terminal working directories. +- Changing source attachment, materialization, or agentctl rescan behavior. +- Weakening workspace containment or enabling links outside the adopted task root. +- Redesigning chat file-open callbacks to carry repository metadata; task-root-relative paths are + already supported by the file service and Files tree. +- Adding or removing workspace sources. +- Public documentation changes; this repairs an existing documented behavior and adds no new user + controls or terminology. + +## Architecture and Documentation Review + +No new ADR is required. The plan implements the existing task-scoped source ownership and effective +workspace-root contracts from ADR-2026-07-22 and the existing attach-workspace-sources spec. Public +documentation is unaffected. + +## Open Questions + +None. + +## Verification Results + +- `make -C apps/backend test` — passed. +- Focused Vitest suite (workspace selector, session merge/WS handlers, file viewers, LSP paths, and + Markdown links) — 115 tests passed in 9 files. +- `rtk pnpm run typecheck` — passed. +- Targeted ESLint on changed frontend files — passed with zero warnings. +- Desktop Chromium add-sources regression — passed; absolute links opened in both the primary and + attached repository after reload. +- Mobile Chrome add-sources regression — passed; the attached-repository link opened in the native + file viewer after reload. +- `rtk git diff --check` — passed. diff --git a/docs/plans/multi-repo-chat-file-links/task-01-expose-authoritative-workspace-root.md b/docs/plans/multi-repo-chat-file-links/task-01-expose-authoritative-workspace-root.md new file mode 100644 index 0000000000..4dea42b079 --- /dev/null +++ b/docs/plans/multi-repo-chat-file-links/task-01-expose-authoritative-workspace-root.md @@ -0,0 +1,66 @@ +--- +id: "01-expose-authoritative-workspace-root" +title: "Expose authoritative workspace root" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 01: Expose the authoritative workspace root + +## Acceptance + +- Full and summary task-session API responses include `workspace_path`. +- A session linked to a promoted task environment returns the environment's current workspace root, + even when the persisted session value and primary worktree still point at the original repository. +- `worktree_path` remains the first/primary session worktree for backward compatibility. +- A legacy or environment-less session falls back to its persisted session workspace path. +- No database migration or file-API change is introduced. + +## Verification + +```bash +make -C apps/backend test +``` + +## Files Likely Touched + +- `apps/backend/internal/task/repository/sqlite/session.go` +- `apps/backend/internal/task/repository/sqlite/session_test.go` +- `apps/backend/internal/task/models/models.go` +- `apps/backend/internal/task/dto/dto.go` +- `apps/backend/internal/task/dto/converters_test.go` + +## Dependencies + +None. + +## Parallelism + +`sequential`. Task 02 consumes the API field and its environment-backed persistence semantics. + +## Inputs + +- Spec sections: **API surface**, **Failure modes**, and the multi-repository chat-link scenarios. +- ADR-2026-07-22-runtime-mutable-task-workspace-sources. +- Existing patterns: `taskSessionSelectCols`, `taskSessionFromClause`, both task-session scanners, + `FromTaskSession`, and `FromTaskSessionSummary`. + +## TDD Sequence + +1. Add repository regressions that create a session plus task environment, promote only the + environment workspace root, and expect session reads to return that root while retaining the + primary worktree. Confirm RED because reads currently return the stale session value. +2. Add converter assertions for `workspace_path` on both DTO shapes and confirm RED because the + field is absent. +3. Add the environment-backed projection to the shared SELECT/scanners and the DTO field mappings. +4. Add the legacy no-environment fallback case and reach GREEN for both packages. +5. Run the exact verification command above. + +## Output Contract + +Report the expected RED failures, the query/DTO changes, confirmation that scanner order and legacy +fallbacks are covered, files changed, exact command results, risks, and blockers. Mark this task +`done` and update its plan checkbox in the primary conversation. diff --git a/docs/plans/multi-repo-chat-file-links/task-02-resolve-chat-links-from-workspace-root.md b/docs/plans/multi-repo-chat-file-links/task-02-resolve-chat-links-from-workspace-root.md new file mode 100644 index 0000000000..ce91891aa3 --- /dev/null +++ b/docs/plans/multi-repo-chat-file-links/task-02-resolve-chat-links-from-workspace-root.md @@ -0,0 +1,119 @@ +--- +id: "02-resolve-chat-links-from-workspace-root" +title: "Resolve chat links from workspace root" +status: done +wave: 2 +depends_on: ["01-expose-authoritative-workspace-root"] +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 02: Resolve chat links from the workspace root + +## Acceptance + +- Frontend session state stores `workspace_path` independently of `worktree_path` and preserves both + across live events, partial merges, API hydration, and reload. +- Workspace-source and sibling-materialization events update only the effective workspace root; the + primary worktree remains stable for repository-aware consumers. +- Shared task, Office, quick-chat, Markdown-fallback, and Files-root consumers prefer + `workspace_path` and fall back to `worktree_path` for legacy payloads. +- Desktop, native-mobile, and LSP file viewers use the same workspace-root selector for display, + navigation, and relative-path conversion. +- Absolute chat links under primary and attached repositories resolve to the correct + task-root-relative file path; traversal and outside-root absolute paths remain blocked. +- Desktop and native mobile flows open the linked file after a full reload without showing a file + failure notification. + +## Verification + +```bash +cd apps/web +rtk pnpm exec vitest run \ + lib/session-workspace-path.test.ts \ + lib/state/slices/session/session-slice.upsert.test.ts \ + lib/ws/handlers/agent-session.test.ts \ + components/shared/markdown-components.test.tsx \ + components/task/file-browser-path.test.ts \ + components/task/file-tab-content.test.tsx \ + components/task/mobile/mobile-file-viewer-panel.test.tsx \ + components/task/file-editor-panel.image.test.tsx \ + hooks/use-lsp-file-opener.test.ts +rtk pnpm run typecheck +rtk pnpm e2e:run -- --project=chromium tests/task/add-workspace-sources.spec.ts +rtk pnpm e2e:run -- --project=mobile-chrome tests/task/mobile-add-workspace-sources.spec.ts +``` + +If dependencies are absent, first run `rtk pnpm install --frozen-lockfile` from `apps/`. + +## Files Likely Touched + +- `apps/web/lib/types/http.ts` +- `apps/web/lib/session-workspace-path.ts` +- `apps/web/lib/session-workspace-path.test.ts` +- `apps/web/lib/state/slices/session/session-slice.ts` +- `apps/web/lib/state/slices/session/session-slice.upsert.test.ts` +- `apps/web/lib/ws/handlers/agent-session.ts` +- `apps/web/lib/ws/handlers/agent-session.test.ts` +- `apps/web/components/shared/markdown-components.tsx` +- `apps/web/components/shared/markdown-components.test.tsx` +- `apps/web/components/task/task-chat-panel.tsx` +- `apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx` +- `apps/web/app/office/tasks/[id]/office-dockview-layout.tsx` +- `apps/web/components/quick-chat/quick-chat-content.tsx` +- `apps/web/components/task/file-browser.tsx` +- `apps/web/components/task/file-browser-path.ts` +- `apps/web/components/task/file-browser-path.test.ts` +- `apps/web/components/task/file-tab-content.tsx` +- `apps/web/components/task/file-tab-content.test.tsx` +- `apps/web/components/task/file-editor-panel.tsx` +- `apps/web/components/task/file-editor-panel.image.test.tsx` +- `apps/web/components/task/mobile/mobile-file-viewer-panel.tsx` +- `apps/web/components/task/mobile/mobile-file-viewer-panel.test.tsx` +- `apps/web/hooks/use-lsp-file-opener.ts` +- `apps/web/hooks/use-lsp-file-opener.test.ts` +- `apps/web/e2e/helpers/api-client.ts` +- `apps/web/e2e/tests/task/add-workspace-sources.spec.ts` +- `apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts` + +## Dependencies + +- Task 01: full and summary session responses must provide the authoritative `workspace_path` used + after reload. + +## Parallelism + +`sequential`. This task consumes Task 01 and owns the shared frontend root-selection contract plus +browser verification. + +## Inputs + +- Spec sections: **API surface**, **Failure modes**, and the multi-repository chat-link scenarios. +- Existing patterns: `mergeTaskSession`, `handleWorkspaceSourcesUpdated`, + `buildAgentctlReadySessionUpdate`, `resolveAbsoluteMarkdownFileHref`, and + `resolveFileBrowserPaths`. +- Existing desktop and native-mobile add-workspace-sources E2E fixtures. + +## TDD Sequence + +1. Extend the desktop and mobile workspace-source scenarios with absolute chat links and a reload; + confirm RED with the current wrong-root file-not-found behavior. +2. Add unit regressions for workspace-root precedence, session merge/event ordering, primary and + sibling Markdown links, legacy fallback, and outside-root rejection; confirm RED. +3. Add `workspace_path` to the session type/store, correct the WS/Office writers, and implement the + shared root selector. +4. Route chat, Markdown fallback, Files, desktop/mobile viewer, and LSP consumers through the + selector without changing the file API or repository-aware Git fields. +5. Reach GREEN on the focused Vitest set, typecheck, desktop E2E, and mobile E2E. + +## Mobile-Parity Notes + +This is a shared data/path correction, not a new control or layout. Keep the existing native mobile +Chat tab and full-screen file viewer. The mobile E2E must use touch actions and verify the viewer's +existing close affordance and absence of horizontal document overflow. + +## Output Contract + +Report the expected RED failures, state/event precedence rules, resolved primary and sibling paths, +desktop/mobile behavior, files changed, exact command results, risks, and blockers. Mark this task +`done` and update its plan checkbox in the primary conversation. diff --git a/docs/specs/tasks/attach-workspace-sources.md b/docs/specs/tasks/attach-workspace-sources.md index 38532e8465..1cfd9d3214 100644 --- a/docs/specs/tasks/attach-workspace-sources.md +++ b/docs/specs/tasks/attach-workspace-sources.md @@ -132,6 +132,18 @@ session-scoped workspace-sources update after agentctl has adopted the new works refresh the Files tree and repository trackers from those events rather than assuming the POST response is the only writer. +Full and summary task-session responses expose `workspace_path` as the effective task workspace +root. In a multi-source task this is the parent that contains every repository and folder entry; +`worktree_path` remains the flattened primary-repository path for backward compatibility. Live +workspace-source and sibling-materialization events update `workspace_path` without replacing the +primary `worktree_path`, and a later session refresh returns the same effective root. + +Chat file links resolve against `workspace_path`, so an absolute path under any attached source is +converted to its task-root-relative Files path before it is opened. Clients may fall back to +`worktree_path` for legacy session payloads that do not yet include `workspace_path`. Absolute paths +outside the effective workspace remain non-actionable and are never rewritten into a workspace +file request. + `add_workspace_sources_kandev` accepts the same source union and defaults `task_id` to the current task. @@ -175,6 +187,7 @@ persisted in source URLs or copied into agent-visible metadata. | A container/remote repository clone fails | Newly created remote entries are removed best-effort, durable attachments are rolled back, and the response identifies the failed source. | | A container/remote task submits a folder source | The request returns `422` without persistence or filesystem changes. | | Agentctl cannot rescan the new root | The attachment fails rather than reporting success with a stale Files tree. | +| A chat message links to an absolute path outside the task root | The link is not sent to the workspace file API; normal external/unsafe-path handling remains in effect. | | An idle agent must restart to adopt the promoted root | The intentional stop is not shown as a prior agent failure; the replacement agent uses the promoted task root. | | A requested file move or rename crosses canonical source roots | The request is rejected before either source is mutated. | | A persisted local folder later disappears | The current live environment keeps its existing materialization; a new/reset environment surfaces the missing source and does not silently omit it. | @@ -241,6 +254,16 @@ must be retried. - **GIVEN** the original repository has no pending changes, **WHEN** a legacy add-branch call creates a sibling worktree, **THEN** Git status in the original repository does not report the sibling as an untracked or changed path. +- **GIVEN** a task whose workspace contains multiple repositories, **WHEN** an agent message links + to an absolute file path in either the primary or an attached repository, **THEN** clicking the + link opens the exact file through the task-root-relative Files API path without a file-not-found + notification. +- **GIVEN** that multi-repository task is reloaded after workspace promotion, **WHEN** the user + clicks the same chat file link, **THEN** the session-restored `workspace_path` resolves the same + file and `worktree_path` still identifies the primary repository. +- **GIVEN** a legacy single-repository session payload without `workspace_path`, **WHEN** a chat + file link is inside `worktree_path`, **THEN** the link continues to open as a repository-relative + path. - **GIVEN** a live legacy add-branch materialization fails, **WHEN** the MCP call returns an error, **THEN** the new `task_repositories` row and any newly created repository entity are rolled back while the agent and existing processes continue running. @@ -276,4 +299,5 @@ must be retried. ## Implementation plan See [Attach Workspace Sources plan](../../plans/attach-workspace-sources/plan.md) and the -[live add-branch compatibility repair plan](../../plans/restore-live-add-branch/plan.md). +[live add-branch compatibility repair plan](../../plans/restore-live-add-branch/plan.md), plus the +[multi-repository chat file-link repair plan](../../plans/multi-repo-chat-file-links/plan.md).