) {
@@ -256,15 +375,54 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className
} else if (event.key === "Enter") {
event.preventDefault();
const item = highlightedIndex >= 0 ? items[highlightedIndex] : undefined;
- if (item) onOpen({ path: item.relativePath, workspace });
+ if (item) {
+ const itemTarget = item.projectId
+ ? { projectId: item.projectId, workspaceId: item.workspaceId }
+ : targetRef;
+ onOpen({
+ path: item.relativePath,
+ workspace: effectiveWorkspace,
+ ...itemTarget,
+ });
+ }
else openTypedPath();
}
}
+ function handleProjectChange(projectId: string) {
+ const project = projectsWithWorkspaces.find((item) => item.id === projectId) ?? null;
+ setSelectedProjectId(project?.id ?? null);
+ setSelectedWorkspaceId(project?.primaryWorkspace?.id ?? project?.workspaces[0]?.id ?? null);
+ }
+
const activeOptionId = highlightedIndex >= 0 ? `${listboxId}-opt-${highlightedIndex}` : undefined;
let body: ReactNode;
- if (listQuery.isFetching && !data) {
+ if (source === "other" && !companyId) {
+ body = (
+ }
+ title="No company selected"
+ body="Choose a company before browsing another project workspace."
+ />
+ );
+ } else if (source === "other" && projectsQuery.isFetching && projectsWithWorkspaces.length === 0) {
+ body = (
+ }
+ title="Loading project workspaces"
+ body="Registered workspaces will appear here."
+ />
+ );
+ } else if (source === "other" && !canListFiles) {
+ body = (
+ }
+ title="No project workspaces"
+ body="No same-company project has a registered workspace to browse."
+ />
+ );
+ } else if (listQuery.isFetching && !data) {
body = (
{Array.from({ length: 6 }).map((_, index) => (
@@ -309,7 +467,16 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className
optionId={`${listboxId}-opt-${index}`}
selected={index === highlightedIndex}
showTimestamp={!isSearch}
- onOpen={() => onOpen({ path: item.relativePath, workspace })}
+ onOpen={() => {
+ const itemTarget = item.projectId
+ ? { projectId: item.projectId, workspaceId: item.workspaceId }
+ : targetRef;
+ onOpen({
+ path: item.relativePath,
+ workspace: effectiveWorkspace,
+ ...itemTarget,
+ });
+ }}
onHover={() => setHighlightedIndex(index)}
/>
))}
@@ -344,10 +511,62 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className
- Workspace
-
+ Source
+
+ {source === "current" ? (
+ <>
+ Workspace
+
+ >
+ ) : null}
+ {source === "other" ? (
+
+ Project
+
+
+ Workspace
+
+
+ ) : null}
+
+ {source === "other" && selectedProject && selectedWorkspace ? (
+
+ Browsing {selectedProject.name} / {selectedWorkspace.name}
+
+ ) : null}
+
{isSearch ? <>Files matching “{q}”> : "Recently changed"}
diff --git a/ui/src/components/WorkspaceFileLink.tsx b/ui/src/components/WorkspaceFileLink.tsx
index 9adc1db6cfc..7e1c2137723 100644
--- a/ui/src/components/WorkspaceFileLink.tsx
+++ b/ui/src/components/WorkspaceFileLink.tsx
@@ -47,6 +47,8 @@ export function WorkspaceFileLink({
line: workspaceFileRef.line ?? null,
column: workspaceFileRef.column ?? null,
workspace: "auto",
+ projectId: workspaceFileRef.projectId ?? null,
+ workspaceId: workspaceFileRef.workspaceId ?? null,
});
const href = canOpen
? `${location.pathname}${deepLinkSearch}${location.hash}`
diff --git a/ui/src/context/FileViewerContext.test.ts b/ui/src/context/FileViewerContext.test.ts
index 6196cc79a63..34160fd8e8a 100644
--- a/ui/src/context/FileViewerContext.test.ts
+++ b/ui/src/context/FileViewerContext.test.ts
@@ -27,6 +27,8 @@ describe("readFileViewerStateFromSearch", () => {
line: 42,
column: 3,
workspace: "project",
+ projectId: null,
+ workspaceId: null,
});
});
@@ -51,7 +53,14 @@ describe("writeFileViewerStateToSearch", () => {
it("sets all params when opening", () => {
const next = writeFileViewerStateToSearch(
"?existing=1",
- { path: "ui/src/a.ts", line: 42, column: 3, workspace: "project" },
+ {
+ path: "ui/src/a.ts",
+ line: 42,
+ column: 3,
+ workspace: "project",
+ projectId: null,
+ workspaceId: null,
+ },
);
const params = new URLSearchParams(next);
expect(params.get("file")).toBe("ui/src/a.ts");
@@ -64,14 +73,37 @@ describe("writeFileViewerStateToSearch", () => {
it("omits workspace when auto", () => {
const next = writeFileViewerStateToSearch(
"",
- { path: "a.ts", line: null, column: null, workspace: "auto" },
+ { path: "a.ts", line: null, column: null, workspace: "auto", projectId: null, workspaceId: null },
);
expect(next.includes("workspace")).toBe(false);
});
+ it("round-trips explicit target project workspace params", () => {
+ const next = writeFileViewerStateToSearch(
+ "?existing=1",
+ {
+ path: "content-os/cases/readme.md",
+ line: 7,
+ column: null,
+ workspace: "auto",
+ projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
+ workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
+ },
+ );
+ const state = readFileViewerStateFromSearch(next);
+ expect(state).toEqual({
+ path: "content-os/cases/readme.md",
+ line: 7,
+ column: null,
+ workspace: "auto",
+ projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
+ workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
+ });
+ });
+
it("clears viewer params when closing", () => {
const next = writeFileViewerStateToSearch(
- "?file=a.ts&line=1&column=2&workspace=project&keep=yes",
+ "?file=a.ts&line=1&column=2&workspace=project&projectId=project-1&workspaceId=workspace-1&keep=yes",
null,
);
const params = new URLSearchParams(next);
@@ -79,6 +111,8 @@ describe("writeFileViewerStateToSearch", () => {
expect(params.get("line")).toBeNull();
expect(params.get("column")).toBeNull();
expect(params.get("workspace")).toBeNull();
+ expect(params.get("projectId")).toBeNull();
+ expect(params.get("workspaceId")).toBeNull();
expect(params.get("keep")).toBe("yes");
});
diff --git a/ui/src/context/FileViewerContext.tsx b/ui/src/context/FileViewerContext.tsx
index 01e42093f68..77a9619fee4 100644
--- a/ui/src/context/FileViewerContext.tsx
+++ b/ui/src/context/FileViewerContext.tsx
@@ -8,6 +8,8 @@ export interface FileViewerUrlState {
line: number | null;
column: number | null;
workspace: WorkspaceFileSelector;
+ projectId: string | null;
+ workspaceId: string | null;
}
export interface FileViewerContextValue {
@@ -18,8 +20,12 @@ export interface FileViewerContextValue {
browse: boolean;
/** The active browse search query (URL `q`), or null. */
query: string | null;
+ browseProjectId: string | null;
+ browseWorkspaceId: string | null;
open(
- ref: Pick & { workspace?: WorkspaceFileSelector },
+ ref: Pick & {
+ workspace?: WorkspaceFileSelector;
+ },
opts?: { fromBrowse?: boolean },
): void;
/** Open (or stay in) browse mode, optionally seeding the search query. */
@@ -43,6 +49,9 @@ export function readFileViewerStateFromSearch(search: string): FileViewerUrlStat
const lineRaw = params.get("line");
const columnRaw = params.get("column");
const workspaceRaw = params.get("workspace");
+ const projectIdRaw = params.get("projectId");
+ const workspaceIdRaw = params.get("workspaceId");
+ const hasExplicitTarget = Boolean(projectIdRaw && workspaceIdRaw);
const line = lineRaw ? Number.parseInt(lineRaw, 10) : NaN;
const column = columnRaw ? Number.parseInt(columnRaw, 10) : NaN;
const workspace = (workspaceRaw === "execution" || workspaceRaw === "project")
@@ -53,6 +62,8 @@ export function readFileViewerStateFromSearch(search: string): FileViewerUrlStat
line: Number.isFinite(line) && line > 0 ? line : null,
column: Number.isFinite(column) && column > 0 ? column : null,
workspace,
+ projectId: hasExplicitTarget ? projectIdRaw : null,
+ workspaceId: hasExplicitTarget ? workspaceIdRaw : null,
};
}
@@ -66,6 +77,8 @@ export function writeFileViewerStateToSearch(current: string, next: FileViewerUr
params.delete("line");
params.delete("column");
params.delete("workspace");
+ params.delete("projectId");
+ params.delete("workspaceId");
} else {
params.set("file", next.path);
if (next.line !== null) params.set("line", String(next.line));
@@ -74,6 +87,10 @@ export function writeFileViewerStateToSearch(current: string, next: FileViewerUr
else params.delete("column");
if (next.workspace && next.workspace !== "auto") params.set("workspace", next.workspace);
else params.delete("workspace");
+ if (next.projectId) params.set("projectId", next.projectId);
+ else params.delete("projectId");
+ if (next.workspaceId) params.set("workspaceId", next.workspaceId);
+ else params.delete("workspaceId");
}
const str = params.toString();
return str ? `?${str}` : "";
@@ -81,13 +98,21 @@ export function writeFileViewerStateToSearch(current: string, next: FileViewerUr
export interface FileViewerBrowseState {
q: string | null;
+ projectId: string | null;
+ workspaceId: string | null;
}
export function readBrowseStateFromSearch(search: string): FileViewerBrowseState | null {
const params = new URLSearchParams(search);
if (params.get("browse") !== "1") return null;
const q = params.get("q");
- return { q: q && q.length > 0 ? q : null };
+ const projectId = params.get("projectId");
+ const workspaceId = params.get("workspaceId");
+ return {
+ q: q && q.length > 0 ? q : null,
+ projectId: projectId || null,
+ workspaceId: workspaceId || null,
+ };
}
interface FileViewerProviderProps {
@@ -124,6 +149,8 @@ function EnabledFileViewerProvider({ issueId, children }: Omit ["issues", "live-runs", issueId] as const,
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
- fileResources: (issueId: string, options: { workspace?: string; mode?: string; q?: string | null; limit?: number } = {}) =>
+ fileResources: (
+ issueId: string,
+ options: {
+ workspace?: string;
+ projectId?: string | null;
+ workspaceId?: string | null;
+ mode?: string;
+ q?: string | null;
+ limit?: number;
+ } = {},
+ ) =>
["issues", "file-resources", issueId, "list", options] as const,
- fileResource: (issueId: string, path: string, workspace: string) =>
- ["issues", "file-resources", issueId, "resolve", path, workspace] as const,
- fileResourceContent: (issueId: string, path: string, workspace: string) =>
- ["issues", "file-resources", issueId, "content", path, workspace] as const,
+ fileResource: (
+ issueId: string,
+ query: { path: string; workspace?: string; projectId?: string | null; workspaceId?: string | null },
+ ) =>
+ ["issues", "file-resources", issueId, "resolve", query] as const,
+ fileResourceContent: (
+ issueId: string,
+ query: { path: string; workspace?: string; projectId?: string | null; workspaceId?: string | null },
+ ) =>
+ ["issues", "file-resources", issueId, "content", query] as const,
},
routines: {
list: (companyId: string, filters?: { projectId?: string | null }) =>
diff --git a/ui/src/lib/remark-workspace-file-refs.test.ts b/ui/src/lib/remark-workspace-file-refs.test.ts
index 8ad05d391e8..efd53a9ef69 100644
--- a/ui/src/lib/remark-workspace-file-refs.test.ts
+++ b/ui/src/lib/remark-workspace-file-refs.test.ts
@@ -68,6 +68,27 @@ describe("remarkWorkspaceFileRefs", () => {
expect(parsed?.column).toBe(2);
});
+ it("round-trips explicit project workspace identity", () => {
+ const href = buildWorkspaceFileHref({
+ path: "content-os/cases/readme.md",
+ line: 5,
+ column: null,
+ raw: "content-os/cases/readme.md:5",
+ projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
+ workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
+ projectName: "Paperclip Content",
+ });
+ const parsed = parseWorkspaceFileHref(href);
+ expect(parsed).toMatchObject({
+ path: "content-os/cases/readme.md",
+ line: 5,
+ column: null,
+ projectId: "17acae7d-9d0c-46bf-9c82-be9694ac3461",
+ workspaceId: "0de5f74f-a7d4-4f73-a9a0-455a2b968cf2",
+ projectName: "Paperclip Content",
+ });
+ });
+
it("converts linked inline-code file paths into workspace-file links", () => {
const tree: MarkdownNode = {
type: "paragraph",
diff --git a/ui/src/lib/remark-workspace-file-refs.ts b/ui/src/lib/remark-workspace-file-refs.ts
index 0ceb76fb889..ee291afbd1b 100644
--- a/ui/src/lib/remark-workspace-file-refs.ts
+++ b/ui/src/lib/remark-workspace-file-refs.ts
@@ -11,9 +11,12 @@ type MarkdownNode = {
export function buildWorkspaceFileHref(ref: ParsedWorkspaceFileRef): string {
const params = new URLSearchParams();
+ if (ref.projectId) params.set("projectId", ref.projectId);
+ if (ref.workspaceId) params.set("workspaceId", ref.workspaceId);
params.set("path", ref.path);
if (ref.line !== null) params.set("line", String(ref.line));
if (ref.column !== null) params.set("column", String(ref.column));
+ if (ref.projectName) params.set("projectName", ref.projectName);
return `${WORKSPACE_FILE_HREF_SCHEME}?${params.toString()}`;
}
@@ -25,6 +28,10 @@ export function parseWorkspaceFileHref(href: string | null | undefined): ParsedW
const params = new URLSearchParams(withoutLeadingQuestion);
const path = params.get("path");
if (!path) return null;
+ const projectIdRaw = params.get("projectId");
+ const workspaceIdRaw = params.get("workspaceId");
+ const hasExplicitTarget = Boolean(projectIdRaw && workspaceIdRaw);
+ const projectName = params.get("projectName");
const lineRaw = params.get("line");
const columnRaw = params.get("column");
const line = lineRaw ? Number.parseInt(lineRaw, 10) : NaN;
@@ -33,6 +40,9 @@ export function parseWorkspaceFileHref(href: string | null | undefined): ParsedW
path,
line: Number.isFinite(line) && line > 0 ? line : null,
column: Number.isFinite(column) && column > 0 ? column : null,
+ projectId: hasExplicitTarget ? projectIdRaw : null,
+ workspaceId: hasExplicitTarget ? workspaceIdRaw : null,
+ projectName: projectName || null,
raw: path,
};
}
diff --git a/ui/src/lib/workspace-file-parser.ts b/ui/src/lib/workspace-file-parser.ts
index d1503ba39a0..ea8e7ad4ec4 100644
--- a/ui/src/lib/workspace-file-parser.ts
+++ b/ui/src/lib/workspace-file-parser.ts
@@ -2,6 +2,9 @@ export interface ParsedWorkspaceFileRef {
path: string;
line: number | null;
column: number | null;
+ projectId?: string | null;
+ projectName?: string | null;
+ workspaceId?: string | null;
/** The original matched text (useful for rendering) */
raw: string;
}
@@ -77,7 +80,10 @@ export function parseWorkspaceFileRef(input: string): ParsedWorkspaceFileRef | n
}
export function formatWorkspaceFileRefDisplay(ref: ParsedWorkspaceFileRef): string {
- if (ref.line && ref.column) return `${ref.path}:${ref.line}:${ref.column}`;
- if (ref.line) return `${ref.path}:${ref.line}`;
- return ref.path;
+ const path = ref.line && ref.column
+ ? `${ref.path}:${ref.line}:${ref.column}`
+ : ref.line
+ ? `${ref.path}:${ref.line}`
+ : ref.path;
+ return ref.projectName ? `${ref.projectName} / ${path}` : path;
}
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index bec1be1b85a..a43f96a303c 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -4405,6 +4405,7 @@ export function IssueDetail() {
{fileViewerEnabled ? (
@@ -4417,10 +4418,12 @@ export function IssueDetail() {
function IssueFileViewer({
issueId,
+ companyId,
promptOpen,
onPromptOpenChange,
}: {
issueId: string;
+ companyId: string;
promptOpen: boolean;
onPromptOpenChange: (next: boolean) => void;
}) {
@@ -4430,6 +4433,7 @@ function IssueFileViewer({
return (
{
diff --git a/ui/storybook/stories/file-viewer.stories.tsx b/ui/storybook/stories/file-viewer.stories.tsx
index 683234dc422..3d1b6a90079 100644
--- a/ui/storybook/stories/file-viewer.stories.tsx
+++ b/ui/storybook/stories/file-viewer.stories.tsx
@@ -157,7 +157,7 @@ function FileViewerShell({
state={
showPromptWhenEmpty
? null
- : { path, line, column, workspace }
+ : { path, line, column, workspace, projectId: null, workspaceId: null }
}
showPromptWhenEmpty={showPromptWhenEmpty}
open
@@ -176,12 +176,12 @@ function seedContent(
content: WorkspaceFileContent | null,
) {
queryClient.setQueryData(
- queryKeys.issues.fileResource(ISSUE_ID, path, workspace),
+ queryKeys.issues.fileResource(ISSUE_ID, { path, workspace }),
resource,
);
if (content) {
queryClient.setQueryData(
- queryKeys.issues.fileResourceContent(ISSUE_ID, path, workspace),
+ queryKeys.issues.fileResourceContent(ISSUE_ID, { path, workspace }),
content,
);
}
diff --git a/ui/storybook/stories/workspace-file-browser.stories.tsx b/ui/storybook/stories/workspace-file-browser.stories.tsx
index 56a9040556c..2e942ad230d 100644
--- a/ui/storybook/stories/workspace-file-browser.stories.tsx
+++ b/ui/storybook/stories/workspace-file-browser.stories.tsx
@@ -135,11 +135,17 @@ function ViewFromBrowse() {
const queryClient = useQueryClient();
queryClient.setQueryData(listKey(ISSUE_ID), recentList);
queryClient.setQueryData(
- queryKeys.issues.fileResource(ISSUE_ID, "ui/src/components/FileViewerSheet.tsx", "auto"),
+ queryKeys.issues.fileResource(ISSUE_ID, {
+ path: "ui/src/components/FileViewerSheet.tsx",
+ workspace: "auto",
+ }),
viewedResource,
);
queryClient.setQueryData(
- queryKeys.issues.fileResourceContent(ISSUE_ID, "ui/src/components/FileViewerSheet.tsx", "auto"),
+ queryKeys.issues.fileResourceContent(ISSUE_ID, {
+ path: "ui/src/components/FileViewerSheet.tsx",
+ workspace: "auto",
+ }),
viewedContent,
);
return (