diff --git a/src/contexts/workspace-context.test.tsx b/src/contexts/workspace-context.test.tsx
index c0a588a40..f33a74192 100644
--- a/src/contexts/workspace-context.test.tsx
+++ b/src/contexts/workspace-context.test.tsx
@@ -1,5 +1,5 @@
import { act, render, screen } from "@testing-library/react"
-import { beforeEach, describe, expect, it, vi } from "vitest"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
WorkspaceProvider,
useWorkspaceActions,
@@ -327,6 +327,120 @@ describe("WorkspaceProvider mode", () => {
})
})
+describe("WorkspaceProvider file history guard", () => {
+ beforeEach(() => {
+ window.history.replaceState({}, "", "/workspace")
+ vi.spyOn(window.history, "back").mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it("pushes one history entry when files open and closes all files on back", () => {
+ const pushState = vi.spyOn(window.history, "pushState")
+ renderWorkspace()
+
+ act(() => {
+ screen.getByRole("button", { name: "Open diff" }).click()
+ screen.getByRole("button", { name: "Open diff 2" }).click()
+ })
+
+ expect(screen.getByTestId("file-tab-count")).toHaveTextContent("2")
+ expect(pushState).toHaveBeenCalledTimes(1)
+ expect(pushState.mock.calls[0]?.[0]).toEqual({
+ codegFileWorkspace: true,
+ })
+
+ act(() => {
+ window.dispatchEvent(new PopStateEvent("popstate"))
+ })
+
+ expect(screen.getByTestId("mode")).toHaveTextContent("conversation")
+ expect(screen.getByTestId("file-tab-count")).toHaveTextContent("0")
+ })
+
+ function DirtyFileProbe() {
+ const {
+ openFilePreview,
+ updateActiveFileContent,
+ closeAllFileTabs,
+ mode,
+ fileTabs,
+ } = useWorkspaceContext()
+
+ return (
+
+
+
+
+
+
+
+
+ )
+ }
+
+ it("keeps a dirty file workspace when back-close is refused", async () => {
+ mockedApi.readFileForEdit.mockResolvedValue({
+ path: "a.ts",
+ content: "saved",
+ etag: "e1",
+ mtime_ms: 1,
+ readonly: false,
+ line_ending: "lf",
+ })
+ mockedApi.gitIsTracked.mockResolvedValue(false)
+ vi.spyOn(window.history, "pushState")
+ const confirm = vi.spyOn(window, "confirm").mockReturnValue(false)
+
+ render(
+
+
+
+ )
+
+ await act(async () => {
+ screen.getByText("open file").click()
+ })
+ await act(async () => {
+ screen.getByText("edit").click()
+ })
+ expect(screen.getByTestId("dirty-count")).toHaveTextContent("1")
+
+ await act(async () => {
+ window.dispatchEvent(new PopStateEvent("popstate"))
+ })
+
+ expect(confirm).toHaveBeenCalledTimes(1)
+ expect(screen.getByTestId("mode")).toHaveTextContent("fusion")
+ expect(screen.getByTestId("file-tab-count")).toHaveTextContent("1")
+ expect(screen.getByTestId("dirty-count")).toHaveTextContent("1")
+ })
+
+ it("consumes the history entry when the user closes all files manually", () => {
+ vi.spyOn(window.history, "pushState")
+ const back = vi.spyOn(window.history, "back")
+ renderWorkspace()
+
+ act(() => {
+ screen.getByRole("button", { name: "Open diff" }).click()
+ })
+ expect(screen.getByTestId("file-tab-count")).toHaveTextContent("1")
+
+ act(() => {
+ screen.getByRole("button", { name: "Close all" }).click()
+ })
+
+ expect(screen.getByTestId("file-tab-count")).toHaveTextContent("0")
+ expect(back).toHaveBeenCalledTimes(1)
+ })
+})
+
describe("WorkspaceProvider files-maximized", () => {
it("toggles filesMaximized only while files are open", () => {
renderWorkspace()
diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx
index d312fdb8a..0c613a629 100644
--- a/src/contexts/workspace-context.tsx
+++ b/src/contexts/workspace-context.tsx
@@ -50,6 +50,7 @@ import {
HIDDEN_TAB_CONTENT_BUDGET_CHARS,
selectTabsToUnload,
} from "@/lib/file-tab-memory"
+import { isDesktop } from "@/lib/transport"
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
import {
useOpenFileTabsWatch,
@@ -331,6 +332,20 @@ function loadingTab(
type LoadDecision = { kind: "skip" } | { kind: "fetch"; gen: number }
+const FILE_WORKSPACE_HISTORY_KEY = "codegFileWorkspace"
+
+function canUseFileWorkspaceHistory(): boolean {
+ return typeof window !== "undefined" && !isDesktop()
+}
+
+function hasFileWorkspaceHistoryState(): boolean {
+ return Boolean(
+ (window.history.state as Record | null)?.[
+ FILE_WORKSPACE_HISTORY_KEY
+ ]
+ )
+}
+
async function withTimeout(
promise: Promise,
timeoutMs: number,
@@ -421,6 +436,21 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
fileTabsRef.current = fileTabs
}, [fileTabs])
+ // Browser back is a mobile affordance for leaving the file workspace, not
+ // for leaving /workspace itself. One synthetic entry guards the whole file
+ // layer; popstate closes all tabs and manual close consumes the entry.
+ const fileHistoryDepthRef = useRef(0)
+ const ignoreNextFileHistoryPopRef = useRef(false)
+
+ const pushFileWorkspaceHistory = useCallback(() => {
+ window.history.pushState(
+ { [FILE_WORKSPACE_HISTORY_KEY]: true },
+ "",
+ window.location.href
+ )
+ fileHistoryDepthRef.current += 1
+ }, [])
+
useEffect(() => {
activeFileTabIdRef.current = activeFileTabId
}, [activeFileTabId])
@@ -2266,6 +2296,44 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
const reorderFileTabs = useCallback((tabs: FileWorkspaceTab[]) => {
setFileTabs(tabs)
}, [])
+ useEffect(() => {
+ if (!canUseFileWorkspaceHistory()) return
+
+ if (fileTabs.length > 0) {
+ if (fileHistoryDepthRef.current === 0) pushFileWorkspaceHistory()
+ return
+ }
+
+ if (fileHistoryDepthRef.current === 0) return
+ fileHistoryDepthRef.current = 0
+ if (hasFileWorkspaceHistoryState()) {
+ ignoreNextFileHistoryPopRef.current = true
+ window.history.back()
+ }
+ }, [fileTabs.length, pushFileWorkspaceHistory])
+
+ useEffect(() => {
+ if (!canUseFileWorkspaceHistory()) return
+
+ const handlePopState = () => {
+ if (ignoreNextFileHistoryPopRef.current) {
+ ignoreNextFileHistoryPopRef.current = false
+ return
+ }
+ if (fileHistoryDepthRef.current === 0) return
+
+ fileHistoryDepthRef.current = 0
+ if (fileTabsRef.current.length === 0) return
+
+ closeAllFileTabs()
+ // If close was refused for a dirty tab, restore the synthetic entry.
+ // If it succeeded, the empty-tabs effect above consumes it after commit.
+ pushFileWorkspaceHistory()
+ }
+
+ window.addEventListener("popstate", handlePopState)
+ return () => window.removeEventListener("popstate", handlePopState)
+ }, [closeAllFileTabs, pushFileWorkspaceHistory])
const activeFileTab = useMemo(
() => fileTabs.find((tab) => tab.id === activeFileTabId) ?? null,