fix: resolve chat file links from task workspace root (#2127)

This commit is contained in:
Carlos Florêncio
2026-08-01 14:46:57 +01:00
committed by GitHub
parent 65b0e249ec
commit 1a999f201a
37 changed files with 1173 additions and 61 deletions
@@ -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)
}
}
+8
View File
@@ -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,
+1 -1
View File
@@ -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"`
@@ -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
@@ -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) {
@@ -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}
@@ -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 });
}
}
}
@@ -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}
/>
</div>
@@ -13,7 +13,7 @@ const appState = vi.hoisted(() => ({
"session-1": {
worktree_path: "/root/.kandev/tasks/example/kandev",
},
} as Record<string, { worktree_path: string }>,
} as Record<string, { worktree_path: string; workspace_path?: string }>,
},
},
}));
@@ -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(
<Markdown>
{
"[primary](/root/.kandev/tasks/example/kandev/docs/specs/native/spec.md) [sibling](/root/.kandev/tasks/example/plugin/ui/bundle.js)"
}
</Markdown>,
);
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();
@@ -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 <MarkdownFileAnchor {...props} worktreePath={worktreePath} openFile={openFile} />;
@@ -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",
);
});
});
@@ -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<TaskSession, "workspace_path" | "worktree_path"> | null | undefined,
): string | undefined {
return getSessionWorkspacePath(session);
}
export type FileBrowserPathInput = {
sessionWorktreePath?: string | null;
repositoryLocalPath?: string | null;
+2 -2
View File
@@ -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,
@@ -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;
}) => (
<div data-testid="image-viewer" data-path={path}>
<div data-testid="image-viewer" data-path={path} data-worktree-path={worktreePath}>
{content}
{headerActions}
</div>
@@ -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(
<FileEditorPanel
@@ -114,7 +124,7 @@ describe("FileEditorPanel image preview", () => {
/>,
);
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(
<FileEditorPanel
@@ -123,11 +133,11 @@ describe("FileEditorPanel image preview", () => {
/>,
);
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(
<FileEditorPanel
@@ -148,4 +158,19 @@ describe("FileEditorPanel image preview", () => {
size: "sm",
});
});
it("uses the effective workspace path for docked desktop viewers", () => {
act(() => seedImage(SHARED_IMAGE_PATH, REPO_A_CONTENT, "repo-a"));
render(
<FileEditorPanel
panelId={PREVIEW_PANEL_ID}
params={{ path: SHARED_IMAGE_PATH, repo: "repo-a" }}
/>,
);
expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).getAttribute("data-worktree-path")).toBe(
"/tmp/task-root",
);
});
});
@@ -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 <LoadingFilePanel />;
}
const worktreePath = activeSession?.worktree_path ?? undefined;
const worktreePath = getSessionWorkspacePath(activeSession);
const repositoryId = activeSession?.repository_id ?? undefined;
const category = resolveFileCategory(isBinary, path);
if (category !== "text") {
@@ -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;
}) => (
<div data-testid="file-editor-content" data-markdown-preview={String(markdownPreview)}>
<div
data-testid="file-editor-content"
data-markdown-preview={String(markdownPreview)}
data-worktree-path={worktreePath}
>
<button type="button" onClick={onToggleMarkdownPreview}>
Toggle preview
</button>
@@ -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(
<FileTabContent
tab={file}
activeSession={{
workspace_path: "/tmp/task-root",
worktree_path: "/tmp/task-root/kandev",
}}
activeSessionId="session-1"
taskId="task-1"
isSaving={false}
onFileChange={vi.fn()}
onFileSave={vi.fn()}
onFileDelete={vi.fn()}
/>,
);
expect(screen.getByTestId("file-editor-content").getAttribute("data-worktree-path")).toBe(
"/tmp/task-root",
);
});
});
+10 -4
View File
@@ -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 = (
<FileViewerExternalLink
path={tab.path}
@@ -52,14 +58,14 @@ export function FileTabContent({
<FileImageViewer
path={tab.path}
content={tab.content}
worktreePath={activeSession?.worktree_path ?? undefined}
worktreePath={workspacePath}
headerActions={externalLink}
/>
)}
{category === "binary" && (
<FileBinaryViewer
path={tab.path}
worktreePath={activeSession?.worktree_path ?? undefined}
worktreePath={workspacePath}
headerActions={externalLink}
/>
)}
@@ -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}
@@ -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: () => <span data-testid="markdown-preview" />,
}));
vi.mock("../file-image-viewer", () => ({ FileImageViewer: () => null }));
vi.mock("../file-binary-viewer", () => ({ FileBinaryViewer: () => null }));
vi.mock("../file-binary-viewer", () => ({
FileBinaryViewer: ({ worktreePath }: { worktreePath?: string }) => (
<span data-testid="binary-viewer" data-worktree-path={worktreePath} />
),
}));
import { MobileFileViewerPanel } from "./mobile-file-viewer-panel";
afterEach(cleanup);
describe("MobileFileViewerPanel workspace path", () => {
it("uses the effective workspace path for binary file viewers", () => {
render(
<MobileFileViewerPanel
file={{
path: "dist/archive.zip",
name: "archive.zip",
content: "",
originalContent: "",
originalHash: "hash",
isDirty: false,
isBinary: true,
}}
sessionId="session-1"
onClose={vi.fn()}
/>,
);
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(
@@ -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]);
+2 -1
View File
@@ -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}
+1
View File
@@ -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 }>;
@@ -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 ({
@@ -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),
);
});
+125
View File
@@ -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");
});
});
+13 -8
View File
@@ -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]);
}
@@ -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();
});
});
+13
View File
@@ -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;
}
@@ -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,
@@ -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();
+2
View File
@@ -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
+2
View File
@@ -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;
+96 -1
View File
@@ -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({
+36 -12
View File
@@ -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<string, string | undefined> {
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<string, unknown> {
const update: Record<string, unknown> = {};
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<AppState>, 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);
@@ -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 `<task-root>/kandev/docs/public/agents-and-profiles.md`, but the browser restored the session's
flattened primary `worktree_path` (`<task-root>/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
`<task-root>`; it therefore looked for `<task-root>/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 `<task-root>`, 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.
@@ -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.
@@ -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.
+25 -1
View File
@@ -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).