fix: resolve chat file links from task workspace root (#2127)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
+119
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user