+
+
+
+
+ }
+ >
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ {(project) => (
+
+ }
+ variant="ghost-muted"
+ size="large"
+ state={summaryOpen() ? "pressed" : undefined}
+ aria-label="Session details"
+ aria-expanded={summaryOpen()}
+ />
+
+
+ setWorkspaceSuggestionDismissed(true)}
+ onReview={() => {
+ setSummary(false)
+ command.trigger("review.toggle")
+ }}
+ />
+
+
+
+ )}
+
- void archiveSession(id)}>
+ void archiveSession(id)}
+ >
{language.t("common.archive")}
dialog.show(() => )}
>
{language.t("common.delete")}
@@ -1637,11 +2012,17 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
- void archiveSession(id)}>
+ void archiveSession(id)}
+ >
{language.t("common.archive")}
- dialog.show(() => )}>
+ dialog.show(() => )}
+ >
{language.t("common.delete")}...
diff --git a/packages/app/src/pages/session/timeline/projection.test.ts b/packages/app/src/pages/session/timeline/projection.test.ts
index 68da2c2fe4d1..e5e389c9f804 100644
--- a/packages/app/src/pages/session/timeline/projection.test.ts
+++ b/packages/app/src/pages/session/timeline/projection.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
-import { reuseTimelineRows } from "./row-reconciliation"
+import { appendTurnExtensions, reuseTimelineRows } from "./row-reconciliation"
import { TimelineRow } from "./timeline-row"
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
@@ -94,3 +94,20 @@ describe("reuseTimelineRows", () => {
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
})
})
+
+test("appends turn extensions after projected turn rows", () => {
+ const rows: TimelineRow.TimelineRow[] = [user(), new TimelineRow.DiffSummary({ userMessageID: "user-1", diffs: [] })]
+ const lifecycle = new TimelineRow.WorkspaceLifecycle({
+ userMessageID: "user-1",
+ notice: {
+ type: "operation",
+ operation: { type: "move", status: "complete", directory: "/workspace", messageID: "user-1" },
+ },
+ })
+
+ expect(appendTurnExtensions(rows, [lifecycle]).map((row) => row._tag)).toEqual([
+ "UserMessage",
+ "DiffSummary",
+ "WorkspaceLifecycle",
+ ])
+})
diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts
index e30c936d73cc..76e9bbc0139c 100644
--- a/packages/app/src/pages/session/timeline/projection.ts
+++ b/packages/app/src/pages/session/timeline/projection.ts
@@ -14,6 +14,8 @@ export function createTimelineProjection(input: {
status: Accessor
showReasoningSummaries: Accessor
inlineComments: Accessor
+ extensionRevision?: Accessor
+ afterTurn?: (message: UserMessage) => TimelineRow.TimelineRow[]
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
@@ -29,8 +31,9 @@ export function createTimelineProjection(input: {
})
return result
})
- const projection = createMemo(() =>
- Timeline.constructSessionMessageRows(
+ const projection = createMemo(() => {
+ const extension = input.extensionRevision?.()
+ return Timeline.constructSessionMessageRows(
input.sessionMessages(),
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
input.parts,
@@ -38,8 +41,9 @@ export function createTimelineProjection(input: {
input.status().type,
input.inlineComments(),
input.userMessages(),
- ),
- )
+ input.extensionRevision && extension === undefined ? undefined : input.afterTurn,
+ )
+ })
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(previous, projection().rows),
diff --git a/packages/app/src/pages/session/timeline/row-reconciliation.ts b/packages/app/src/pages/session/timeline/row-reconciliation.ts
index ffd615ae2180..1e9d304cf43f 100644
--- a/packages/app/src/pages/session/timeline/row-reconciliation.ts
+++ b/packages/app/src/pages/session/timeline/row-reconciliation.ts
@@ -3,6 +3,11 @@ import { TimelineRow } from "./timeline-row"
type ContextRow = Extract
type PriorContext = { index: number; row: ContextRow }
+export function appendTurnExtensions(rows: TimelineRow.TimelineRow[], extensions: TimelineRow.TimelineRow[]) {
+ rows.push(...extensions)
+ return rows
+}
+
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts
index 32a1d5f57c7a..be332725e15c 100644
--- a/packages/app/src/pages/session/timeline/rows-current.test.ts
+++ b/packages/app/src/pages/session/timeline/rows-current.test.ts
@@ -167,4 +167,36 @@ describe("current session timeline rows", () => {
"thinking:msg_2",
])
})
+
+ test("appends lifecycle extensions after current turns", () => {
+ const source = [
+ { id: "msg_user", type: "user", text: "question", time: { created: 1 } },
+ ] satisfies SessionMessageInfo[]
+ const normalized = normalizeSessionMessages("ses_1", source)
+ const messages = new Map(normalized.messages.map((message) => [message.id, message]))
+
+ const result = Timeline.constructSessionMessageRows(
+ source,
+ (messageID) => messages.get(messageID),
+ (messageID) => normalized.parts.get(messageID) ?? [],
+ true,
+ "idle",
+ true,
+ normalized.messages.filter((message) => message.role === "user"),
+ (message) => [
+ new TimelineRow.WorkspaceLifecycle({
+ userMessageID: message.id,
+ notice: {
+ type: "operation",
+ operation: { type: "move", status: "complete", directory: "/workspace", messageID: message.id },
+ },
+ }),
+ ],
+ )
+
+ expect(result.rows.map(TimelineRow.key)).toEqual([
+ "user-message:msg_user",
+ "workspace-lifecycle:msg_user:operation",
+ ])
+ })
})
diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts
index 25a5113344eb..ac5be5488653 100644
--- a/packages/app/src/pages/session/timeline/rows.ts
+++ b/packages/app/src/pages/session/timeline/rows.ts
@@ -4,6 +4,7 @@ import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
import { uniqueSummaryDiffs } from "./summary-diffs"
+import { appendTurnExtensions } from "./row-reconciliation"
export { TimelineRow, type SummaryDiff } from "./timeline-row"
@@ -27,6 +28,10 @@ export type TimelineRowMap = {
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
+ WorkspaceLifecycle: {
+ userMessageID: string
+ notice: TimelineRow.WorkspaceLifecycle["notice"]
+ }
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
Error: { userMessageID: string; text: string }
}
@@ -40,6 +45,7 @@ export namespace Timeline {
status: SessionStatus["type"],
inlineComments: boolean,
projectedUserMessages: UserMessage[],
+ afterTurn?: (message: UserMessage) => TimelineRow.TimelineRow[],
) {
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
const turnByUserID = new Map()
@@ -82,8 +88,8 @@ export namespace Timeline {
const activeMessageID = turns.at(-1)?.user.id
return {
activeMessageID,
- rows: turns.flatMap((turn, index) =>
- constructMessageRows(
+ rows: turns.flatMap((turn, index) => {
+ const rows = constructMessageRows(
turn.user,
getMessageParts,
turn.assistants,
@@ -92,8 +98,10 @@ export namespace Timeline {
status,
turn.user.id === activeMessageID,
inlineComments,
- ),
- ),
+ )
+ if (!afterTurn) return rows
+ return appendTurnExtensions(rows, afterTurn(turn.user))
+ }),
}
}
diff --git a/packages/app/src/pages/session/timeline/timeline-row.ts b/packages/app/src/pages/session/timeline/timeline-row.ts
index 3905254b29b5..9869ba4ae209 100644
--- a/packages/app/src/pages/session/timeline/timeline-row.ts
+++ b/packages/app/src/pages/session/timeline/timeline-row.ts
@@ -1,6 +1,7 @@
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect"
+import type { WorkspaceOperationState } from "@/utils/workspace-operation"
export type SummaryDiff = SnapshotFileDiff & { file: string }
@@ -39,6 +40,10 @@ export namespace TimelineRow {
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
+ export class WorkspaceLifecycle extends Data.TaggedClass("WorkspaceLifecycle")<{
+ userMessageID: string
+ notice: { type: "operation"; operation: WorkspaceOperationState }
+ }> {}
export type TimelineRow =
| TurnGap
@@ -50,6 +55,7 @@ export namespace TimelineRow {
| DiffSummary
| Error
| Retry
+ | WorkspaceLifecycle
export const key = (row: TimelineRow) => {
switch (row._tag) {
@@ -71,6 +77,8 @@ export namespace TimelineRow {
return `error:${row.userMessageID}`
case "Retry":
return `retry:${row.userMessageID}`
+ case "WorkspaceLifecycle":
+ return `workspace-lifecycle:${row.userMessageID}:${row.notice.type}`
}
}
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index 12dd96a5e66b..c68c4548a512 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -19,6 +19,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local"
+import { WorkspaceOperation } from "@/utils/workspace-operation"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -73,6 +74,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!id) return
return sync().session.get(id)
}
+ const workspaceOperationPending = (sessionID: string) =>
+ WorkspaceOperation.get(sdk().scope, sessionID)?.status === "pending"
const hasReview = () => !!params.id
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
@@ -305,6 +308,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const undo = async () => {
const sessionID = params.id
if (!sessionID) return
+ if (workspaceOperationPending(sessionID)) return
const owner = sessionOwnership.capture()
const session = sdk().api.session
const directory = sdk().directory
@@ -333,6 +337,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const redo = async () => {
const sessionID = params.id
if (!sessionID) return
+ if (workspaceOperationPending(sessionID)) return
const owner = sessionOwnership.capture()
const session = sdk().api.session
const messages = userMessages()
@@ -365,6 +370,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const compact = async () => {
const sessionID = params.id
if (!sessionID) return
+ if (workspaceOperationPending(sessionID)) return
const model = local.model.current()
if (!model) {
@@ -382,6 +388,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const fork = () => {
+ const sessionID = params.id
+ if (!sessionID) return
+ if (workspaceOperationPending(sessionID)) return
void openDialog(
() => import("@/components/dialog-fork"),
(x) => dialog.show(() => ),
@@ -431,7 +440,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
- disabled: !params.id || visibleUserMessages().length === 0,
+ disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id),
onSelect: undo,
}),
sessionCommand({
@@ -439,7 +448,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
- disabled: !params.id || !info()?.revert?.messageID,
+ disabled: !params.id || !info()?.revert?.messageID || workspaceOperationPending(params.id),
onSelect: redo,
}),
sessionCommand({
@@ -447,7 +456,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
- disabled: !params.id || visibleUserMessages().length === 0,
+ disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id),
onSelect: compact,
}),
sessionCommand({
@@ -455,7 +464,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
- disabled: !params.id || visibleUserMessages().length === 0,
+ disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id),
onSelect: fork,
}),
]
diff --git a/packages/app/src/utils/workspace-operation.test.ts b/packages/app/src/utils/workspace-operation.test.ts
new file mode 100644
index 000000000000..04f3ea3c04d2
--- /dev/null
+++ b/packages/app/src/utils/workspace-operation.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from "bun:test"
+import { ServerScope } from "./server-scope"
+import { canMoveSessionToWorkspace, WorkspaceOperation } from "./workspace-operation"
+
+test("workspace moves require settled followup state", () => {
+ expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: false })).toBe(true)
+ expect(canMoveSessionToWorkspace({ queued: 1, failed: false, paused: false, editing: false })).toBe(false)
+ expect(canMoveSessionToWorkspace({ queued: 0, failed: true, paused: false, editing: false })).toBe(false)
+ expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: true, editing: false })).toBe(false)
+ expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: true })).toBe(false)
+})
+
+describe("WorkspaceOperation", () => {
+ test("reacts to operation completion", () => {
+ WorkspaceOperation.start(ServerScope.local, "session", "move", "/workspace")
+ expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending")
+ WorkspaceOperation.complete(ServerScope.local, "session")
+ expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("complete")
+ })
+
+ test("ignores completion for a different destination", () => {
+ WorkspaceOperation.start(ServerScope.local, "destination", "move", "/workspace/expected")
+ WorkspaceOperation.complete(ServerScope.local, "destination", "/workspace/other")
+ expect(WorkspaceOperation.get(ServerScope.local, "destination")?.status).toBe("pending")
+ })
+
+ test("does not downgrade a completed move after cleanup fails", () => {
+ WorkspaceOperation.start(ServerScope.local, "cleanup", "move", "/workspace")
+ WorkspaceOperation.complete(ServerScope.local, "cleanup")
+ WorkspaceOperation.fail(ServerScope.local, "cleanup", "source cleanup failed")
+ expect(WorkspaceOperation.get(ServerScope.local, "cleanup")?.status).toBe("complete")
+ })
+
+ test("settles a pending create from its worktree directory", () => {
+ WorkspaceOperation.start(ServerScope.local, "create", "create", "/workspace/create")
+ WorkspaceOperation.completeCreate(ServerScope.local, "/workspace/create")
+ expect(WorkspaceOperation.get(ServerScope.local, "create")?.status).toBe("complete")
+ })
+})
diff --git a/packages/app/src/utils/workspace-operation.ts b/packages/app/src/utils/workspace-operation.ts
new file mode 100644
index 000000000000..24b99df408c0
--- /dev/null
+++ b/packages/app/src/utils/workspace-operation.ts
@@ -0,0 +1,74 @@
+import { createSignal } from "solid-js"
+import { ScopedKey, type ServerScope } from "@/utils/server-scope"
+import { pathKey } from "@/utils/path-key"
+
+export type WorkspaceOperationType = "create" | "move"
+export type WorkspaceOperationState = {
+ type: WorkspaceOperationType
+ status: "pending" | "complete" | "failed"
+ directory: string
+ messageID?: string
+ message?: string
+}
+
+export function canMoveSessionToWorkspace(input: {
+ queued: number
+ failed: boolean
+ paused: boolean
+ editing: boolean
+}) {
+ return input.queued === 0 && !input.failed && !input.paused && !input.editing
+}
+
+const state = new Map()
+const [version, setVersion] = createSignal(0)
+const key = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
+const write = (scope: ServerScope, sessionID: string, value: WorkspaceOperationState) => {
+ if (!state.has(key(scope, sessionID)) && state.size >= 100) {
+ const terminal = [...state].find(([, item]) => item.status !== "pending")?.[0] ?? state.keys().next().value
+ if (terminal) state.delete(terminal)
+ }
+ state.set(key(scope, sessionID), value)
+ setVersion((current) => current + 1)
+}
+const settleCreate = (scope: ServerScope, directory: string, status: "complete" | "failed", message?: string) => {
+ const prefix = ScopedKey.prefix(scope)
+ const target = pathKey(directory)
+ const matches = [...state].filter(
+ ([id, item]) =>
+ id.startsWith(prefix) &&
+ item.type === "create" &&
+ item.status === "pending" &&
+ pathKey(item.directory) === target,
+ )
+ if (matches.length === 0) return
+ matches.forEach(([id, item]) => state.set(id, { ...item, status, message }))
+ setVersion((current) => current + 1)
+}
+
+export const WorkspaceOperation = {
+ get(scope: ServerScope, sessionID: string) {
+ version()
+ return state.get(key(scope, sessionID))
+ },
+ start(scope: ServerScope, sessionID: string, type: WorkspaceOperationType, directory: string, messageID?: string) {
+ write(scope, sessionID, { type, directory, messageID, status: "pending" })
+ },
+ complete(scope: ServerScope, sessionID: string, directory?: string) {
+ const current = state.get(key(scope, sessionID))
+ if (!current) return
+ if (directory && pathKey(directory) !== pathKey(current.directory)) return
+ write(scope, sessionID, { ...current, status: "complete" })
+ },
+ completeCreate(scope: ServerScope, directory: string) {
+ settleCreate(scope, directory, "complete")
+ },
+ failCreate(scope: ServerScope, directory: string, message: string) {
+ settleCreate(scope, directory, "failed", message)
+ },
+ fail(scope: ServerScope, sessionID: string, message: string) {
+ const current = state.get(key(scope, sessionID))
+ if (!current || current.status === "complete") return
+ write(scope, sessionID, { ...current, status: "failed", message })
+ },
+}
diff --git a/packages/app/src/utils/workspace-request.test.ts b/packages/app/src/utils/workspace-request.test.ts
new file mode 100644
index 000000000000..9722cc96ff8f
--- /dev/null
+++ b/packages/app/src/utils/workspace-request.test.ts
@@ -0,0 +1,36 @@
+import { expect, test } from "bun:test"
+import { workspaceRequestWithTimeout } from "./workspace-request"
+
+test("workspace requests abort at the bounded timeout", async () => {
+ let aborted = false
+ const request = workspaceRequestWithTimeout(
+ (signal) =>
+ new Promise((_, reject) => {
+ signal.addEventListener(
+ "abort",
+ () => {
+ aborted = true
+ reject(signal.reason)
+ },
+ { once: true },
+ )
+ }),
+ "Workspace request failed",
+ 1,
+ )
+
+ await expect(request).rejects.toThrow("Workspace request failed")
+ expect(aborted).toBe(true)
+})
+
+test("workspace requests preserve non-timeout failures", async () => {
+ await expect(
+ workspaceRequestWithTimeout(() => Promise.reject(new Error("Server failed")), "Workspace request failed", 1_000),
+ ).rejects.toThrow("Server failed")
+})
+
+test("workspace request timeout settles when the request ignores abort", async () => {
+ await expect(
+ workspaceRequestWithTimeout(() => new Promise(() => {}), "Workspace request failed", 1),
+ ).rejects.toThrow("Workspace request failed")
+})
diff --git a/packages/app/src/utils/workspace-request.ts b/packages/app/src/utils/workspace-request.ts
new file mode 100644
index 000000000000..d899cf3d80b0
--- /dev/null
+++ b/packages/app/src/utils/workspace-request.ts
@@ -0,0 +1,25 @@
+export const WORKSPACE_PREPARATION_TIMEOUT_MS = 5 * 60 * 1000
+export const WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS = 30_000
+
+export async function workspaceRequestWithTimeout(
+ request: (signal: AbortSignal) => Promise,
+ message: string,
+ timeoutMs: number,
+) {
+ const controller = new AbortController()
+ const timer = { id: undefined as ReturnType | undefined }
+ const timeout = new Promise((_, reject) => {
+ timer.id = setTimeout(() => {
+ controller.abort()
+ reject(new Error(message))
+ }, timeoutMs)
+ })
+ return Promise.race([request(controller.signal), timeout])
+ .catch((error) => {
+ if (controller.signal.aborted) throw new Error(message)
+ throw error
+ })
+ .finally(() => {
+ if (timer.id !== undefined) clearTimeout(timer.id)
+ })
+}
diff --git a/packages/app/src/utils/workspace.test.ts b/packages/app/src/utils/workspace.test.ts
new file mode 100644
index 000000000000..2b4cb04200ae
--- /dev/null
+++ b/packages/app/src/utils/workspace.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import { isWorkspaceDirectory, isWorkspaceSelection } from "./workspace"
+
+describe("isWorkspaceDirectory", () => {
+ const project = {
+ worktree: "C:\\repo\\",
+ sandboxes: ["C:\\repo-workspaces\\feature\\", "C:\\repo-workspaces\\other"],
+ }
+
+ test("distinguishes managed workspaces from the local repository", () => {
+ expect(isWorkspaceDirectory(project, "C:\\repo")).toBe(false)
+ expect(isWorkspaceDirectory(project, "C:\\repo-workspaces\\feature")).toBe(true)
+ expect(isWorkspaceDirectory(project, "c:\\repo-workspaces\\feature\\packages\\app")).toBe(true)
+ })
+
+ test("does not classify unknown directories", () => {
+ expect(isWorkspaceDirectory(project, "C:\\other")).toBe(false)
+ expect(isWorkspaceDirectory(undefined, "C:\\repo-workspaces\\feature")).toBe(false)
+ })
+})
+
+describe("isWorkspaceSelection", () => {
+ const project = { worktree: "/repo", sandboxes: ["/workspaces/feature"] }
+
+ test("accepts local, new, and managed workspace selections", () => {
+ expect(isWorkspaceSelection(project, "main")).toBe(true)
+ expect(isWorkspaceSelection(project, "create")).toBe(true)
+ expect(isWorkspaceSelection(project, "/repo/")).toBe(true)
+ expect(isWorkspaceSelection(project, "/workspaces/feature/")).toBe(true)
+ })
+
+ test("rejects selections from a different project", () => {
+ expect(isWorkspaceSelection(project, "/other/workspace")).toBe(false)
+ })
+})
diff --git a/packages/app/src/utils/workspace.ts b/packages/app/src/utils/workspace.ts
new file mode 100644
index 000000000000..b7ec706eb3f8
--- /dev/null
+++ b/packages/app/src/utils/workspace.ts
@@ -0,0 +1,25 @@
+import { pathKey } from "@/utils/path-key"
+
+type WorkspaceProject = { worktree: string; sandboxes?: readonly string[] }
+
+export function isWorkspaceDirectory(project: WorkspaceProject | undefined, directory: string) {
+ if (!project || containsDirectory(project.worktree, directory)) return false
+ return project.sandboxes?.some((workspace) => containsDirectory(workspace, directory)) ?? false
+}
+
+export function containsDirectory(parent: string, child: string) {
+ const normalize = (value: string) => {
+ const key = pathKey(value)
+ return /^[a-z]:\//i.test(key) || key.startsWith("//") ? key.toLowerCase() : key
+ }
+ const root = normalize(parent)
+ const target = normalize(child)
+ return target === root || target.startsWith(root.endsWith("/") ? root : `${root}/`)
+}
+
+export function isWorkspaceSelection(project: WorkspaceProject | undefined, selection: string) {
+ if (selection === "main" || selection === "create") return true
+ if (!project) return false
+ if (pathKey(project.worktree) === pathKey(selection)) return true
+ return isWorkspaceDirectory(project, selection)
+}