Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { OverlayScrollbarsInit } from "@/components/overlay-scrollbars-init"
import { ClipboardFallbackInit } from "@/components/clipboard-fallback-init"
import { WebConnectionGuard } from "@/components/connection/web-connection-guard"
import { WindowResizeGrips } from "@/components/layout/window-resize-grips"
import { WorkspaceLeaveGuard } from "@/components/workspace/workspace-leave-guard"

export const viewport: Viewport = {
width: "device-width",
Expand Down Expand Up @@ -73,6 +74,7 @@ export default async function RootLayout({
<ClipboardFallbackInit />
<WebConnectionGuard />
<WindowResizeGrips />
<WorkspaceLeaveGuard />
{children}
</AppearanceProvider>
</ThemeProvider>
Expand Down
12 changes: 8 additions & 4 deletions src/app/workspace/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
WorkbenchRouteProvider,
useWorkbenchRoute,
} from "@/contexts/workbench-route-context"
import { WorkspaceWindowHistoryProvider } from "@/contexts/workspace-window-history"

import {
WorkbenchRoutePage,
WorkbenchRouteStrip,
Expand Down Expand Up @@ -1267,18 +1269,19 @@

function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) {
return (
<AppWorkspaceProvider>
<AlertProvider>
<WorkspaceWindowHistoryProvider>
<AppWorkspaceProvider>
<AlertProvider>
<GitCredentialProvider>

Check failure on line 1275 in src/app/workspace/layout.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint + vitest + build)

Insert `··`
<TaskProvider>

Check failure on line 1276 in src/app/workspace/layout.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint + vitest + build)

Insert `··`
<AcpConnectionsProvider>

Check failure on line 1277 in src/app/workspace/layout.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint + vitest + build)

Insert `··`
<DelegationProvider>

Check failure on line 1278 in src/app/workspace/layout.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint + vitest + build)

Insert `··`
<ConversationStatusEventBridge />

Check failure on line 1279 in src/app/workspace/layout.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint + vitest + build)

Insert `··`
<ConversationRuntimeProvider>

Check failure on line 1280 in src/app/workspace/layout.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint + vitest + build)

Replace `················` with `··················`
<WorkspaceProvider>

Check failure on line 1281 in src/app/workspace/layout.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint + vitest + build)

Insert `··`
<TabProvider>

Check failure on line 1282 in src/app/workspace/layout.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint + vitest + build)

Insert `··`
<WorkspaceDocumentTitle />

Check failure on line 1283 in src/app/workspace/layout.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint + vitest + build)

Insert `··`
<TabKeysSync />

Check failure on line 1284 in src/app/workspace/layout.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint + vitest + build)

Insert `··`
<HeavyPluginsWarmup />
<DeepLinkBootstrap />
<PetFocusBridge />
Expand Down Expand Up @@ -1314,8 +1317,9 @@
</AcpConnectionsProvider>
</TaskProvider>
</GitCredentialProvider>
</AlertProvider>
</AppWorkspaceProvider>
</AlertProvider>
</AppWorkspaceProvider>
</WorkspaceWindowHistoryProvider>
)
}

Expand Down
15 changes: 15 additions & 0 deletions src/components/ui/drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { attachRef } from "@/lib/attach-ref"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
import { useBrowserBackWindow } from "@/contexts/workspace-window-history"

type DrawerContextProps = {
hasSnapPoints: boolean
Expand Down Expand Up @@ -177,6 +178,20 @@ function Drawer({
showSwipeHandle?: boolean
}) {
const hasSnapPoints = snapPoints != null && snapPoints.length > 0
useBrowserBackWindow({
open: props.open ?? false,
onClose: () =>
onOpenChange?.(false, {
reason: "none",
event: new Event("close"),
cancel: () => {},
allowPropagation: () => {},
isCanceled: false,
isPropagationAllowed: true,
trigger: undefined,
preventUnmountOnClose: () => {},
}),
})
const contextValue = React.useMemo(
() => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }),
[hasSnapPoints, modal, showSwipeHandle, swipeDirection]
Expand Down
48 changes: 48 additions & 0 deletions src/components/workspace/workspace-leave-guard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client"

import { useEffect } from "react"
import { usePathname, useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { isDesktop } from "@/lib/platform"

/**
* Confirms before a same-document back/forward traversal leaves /workspace,
* and before the page is closed or refreshed while the workspace is open.
* In-window navigation (drawers, in-memory routes) is handled by
* WorkspaceWindowHistoryProvider and never reaches this guard.
* Applies on every platform; only the beforeunload half skips the desktop
* client, where suppressing unload could block the app window from closing.
*/
export function WorkspaceLeaveGuard() {
const t = useTranslations("Folder.workspaceContext")
const pathname = usePathname()
const router = useRouter()

useEffect(() => {
const onPopState = () => {
if (
pathname !== "/workspace" ||
window.location.pathname === "/workspace"
) {
return
}
if (!window.confirm(t("confirmLeaveWorkspace"))) {
router.replace("/workspace", { scroll: false })
}
}
window.addEventListener("popstate", onPopState)
return () => window.removeEventListener("popstate", onPopState)
}, [pathname, router, t])

useEffect(() => {
if (isDesktop() || pathname !== "/workspace") return
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ""
}
window.addEventListener("beforeunload", onBeforeUnload)
return () => window.removeEventListener("beforeunload", onBeforeUnload)
}, [pathname])

return null
}
7 changes: 7 additions & 0 deletions src/contexts/workbench-route-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useState,
type ReactNode,
} from "react"
import { useBrowserBackWindow } from "@/contexts/workspace-window-history"

/**
* The view occupying the main content region. `"conversations"` is the default
Expand Down Expand Up @@ -77,6 +78,12 @@ export function WorkbenchRouteProvider({ children }: { children: ReactNode }) {
const setRoute = useCallback((id: WorkbenchRouteId) => setRouteId(id), [])
const openConversations = useCallback(() => setRouteId("conversations"), [])

useBrowserBackWindow({
open: routeId !== "conversations",
onClose: openConversations,
key: "workbench-route",
})

const value = useMemo<WorkbenchRouteContextValue>(
() => ({
routeId,
Expand Down
26 changes: 19 additions & 7 deletions src/contexts/workspace-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "react"
import { useTranslations } from "next-intl"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useBrowserBackWindow } from "@/contexts/workspace-window-history"
import { useAppWorkspaceStore } from "@/stores/app-workspace-store"
import { buildFileTabId } from "@/lib/file-tab-id"
import {
Expand Down Expand Up @@ -111,7 +112,9 @@ interface WorkspaceActionsValue {
switchFileTab: (tabId: string) => void
closeFileTab: (tabId: string) => void
closeOtherFileTabs: (tabId: string) => void
closeAllFileTabs: () => void
/** Returns false when the user vetoes the dirty-tabs confirm, so the
* browser-back integration can restore the history entry it consumed. */
closeAllFileTabs: () => boolean
reorderFileTabs: (tabs: FileWorkspaceTab[]) => void
// Open a file tab. Accepts absolute paths, `~/` paths (expanded via the
// backend home dir), and paths relative to a folder root. `folderId` is
Expand Down Expand Up @@ -2243,13 +2246,15 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
[activateFilePane, t]
)

const closeAllFileTabs = useCallback(() => {
setFileTabs((prev) => {
if (prev.some(isDirtyFileTab)) {
const confirmed = window.confirm(t("confirmCloseAllDirtyTabs"))
if (!confirmed) return prev
}
const closeAllFileTabs = useCallback((): boolean => {
// Confirm outside the state updater: the browser-back handler needs the
// veto synchronously to decide whether to restore the history entry.
if (fileTabsRef.current.some(isDirtyFileTab)) {
const confirmed = window.confirm(t("confirmCloseAllDirtyTabs"))
if (!confirmed) return false
}

setFileTabs((prev) => {
for (const tab of prev) {
const closed = snapshotFileTab(tab)
if (closed) pushClosedTab(closed)
Expand All @@ -2261,8 +2266,15 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
activateConversationPane()
return []
})
return true
}, [activateConversationPane, t])

useBrowserBackWindow({
open: fileTabs.length > 0,
onClose: closeAllFileTabs,
key: "file-workspace",
})

const reorderFileTabs = useCallback((tabs: FileWorkspaceTab[]) => {
setFileTabs(tabs)
}, [])
Expand Down
Loading
Loading