From d99c07fb380ff2178fabb1c35a436c96fb65ab31 Mon Sep 17 00:00:00 2001 From: AdminTeamCoderz <164670747+AdminTeamCoderz@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:48:36 +0100 Subject: [PATCH 1/8] feat(tabs): preview split-view tabs and a configurable split modifier --- .../src/components/Layout/tabs/LinkButton.tsx | 6 +++ .../src/components/Layout/tabs/TabSync.tsx | 20 +++++++- apps/web/src/components/Layout/tabs/utils.ts | 31 +++++++++-- .../InterfacePreferencesSettings.tsx | 51 +++++++++++++++++++ apps/web/src/store/ui-slice.ts | 12 +++++ 5 files changed, 115 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Layout/tabs/LinkButton.tsx b/apps/web/src/components/Layout/tabs/LinkButton.tsx index 5f91e8f..4b2d354 100644 --- a/apps/web/src/components/Layout/tabs/LinkButton.tsx +++ b/apps/web/src/components/Layout/tabs/LinkButton.tsx @@ -78,6 +78,8 @@ function LinkButtonFn< state.tabs.tabList.find((t) => t.id === state.tabs.activeTabId[state.tabs.activePane]), ); const activePane = useSelector((state) => state.tabs.activePane); + const documentLinkTarget = useSelector((state) => state.ui.documentLinkTarget); + const splitTabsArePreview = useSelector((state) => state.ui.splitTabsArePreview); const primaryTabList = useSelector((state) => state.tabs.tabList.filter((t) => state.tabs.paneTabIds.primary.includes(t.id)), ); @@ -113,6 +115,10 @@ function LinkButtonFn< isShiftHeld: event.shiftKey, newTab, newSplitTab, + // LinkButton renders UI chrome (the space switcher), never document content. + isInDocument: false, + documentLinkTarget, + splitTabsArePreview, }); switch (action.type) { diff --git a/apps/web/src/components/Layout/tabs/TabSync.tsx b/apps/web/src/components/Layout/tabs/TabSync.tsx index 2eee2e9..76e5d81 100644 --- a/apps/web/src/components/Layout/tabs/TabSync.tsx +++ b/apps/web/src/components/Layout/tabs/TabSync.tsx @@ -32,6 +32,8 @@ export function TabSync() { state.tabs.tabList.filter((t) => state.tabs.paneTabIds.secondary.includes(t.id)), ); const activePane = useSelector((state) => state.tabs.activePane); + const documentLinkTarget = useSelector((state) => state.ui.documentLinkTarget); + const splitTabsArePreview = useSelector((state) => state.ui.splitTabsArePreview); const isDocumentTab = activeTab && (activeTab.pathname.startsWith('/edit/') || activeTab.pathname.startsWith('/view/')); @@ -50,6 +52,8 @@ export function TabSync() { const activeTabRef = useRef(activeTab); const isModifierHeldRef = useRef(isModifierHeld); const isShiftHeldRef = useRef(isShiftHeld); + const documentLinkTargetRef = useRef(documentLinkTarget); + const splitTabsArePreviewRef = useRef(splitTabsArePreview); useEffect(() => { primaryTabListRef.current = primaryTabList; secondaryTabListRef.current = secondaryTabList; @@ -57,7 +61,18 @@ export function TabSync() { activeTabRef.current = activeTab; isModifierHeldRef.current = isModifierHeld; isShiftHeldRef.current = isShiftHeld; - }, [primaryTabList, secondaryTabList, activePane, activeTab, isModifierHeld, isShiftHeld]); + documentLinkTargetRef.current = documentLinkTarget; + splitTabsArePreviewRef.current = splitTabsArePreview; + }, [ + primaryTabList, + secondaryTabList, + activePane, + activeTab, + isModifierHeld, + isShiftHeld, + documentLinkTarget, + splitTabsArePreview, + ]); useEffect(() => { const handleLinkClick = (event: MouseEvent) => { @@ -80,6 +95,9 @@ export function TabSync() { isShiftHeld: isShiftHeldRef.current, newTab: link.dataset.newTab === 'true', newSplitTab: link.dataset.newSplitTab === 'true', + isInDocument: !!link.closest('.editor-input'), + documentLinkTarget: documentLinkTargetRef.current, + splitTabsArePreview: splitTabsArePreviewRef.current, }); event.preventDefault(); diff --git a/apps/web/src/components/Layout/tabs/utils.ts b/apps/web/src/components/Layout/tabs/utils.ts index 154cc43..db921e8 100644 --- a/apps/web/src/components/Layout/tabs/utils.ts +++ b/apps/web/src/components/Layout/tabs/utils.ts @@ -4,6 +4,7 @@ */ import type { Tab } from '@repo/types'; +import type { DocumentLinkTarget } from '@/store/ui-slice'; export const matchTabLocation = ( tab: Tab, @@ -113,6 +114,10 @@ export interface ResolveTabActionInput { isShiftHeld: boolean; newTab: boolean; newSplitTab: boolean; + /** True when the clicked link lives inside document content (`.editor-input`). */ + isInDocument: boolean; + documentLinkTarget: DocumentLinkTarget; + splitTabsArePreview: boolean; } export type TabAction = @@ -126,7 +131,7 @@ export type TabAction = } | { type: 'preview'; - pane: 'primary' | 'secondary'; + pane: 'primary' | 'secondary' | 'opposite'; pathname: string; search: Record; hash: string; @@ -162,6 +167,11 @@ export type TabAction = * - The exact-match (`existingTab`) branch is only gated on `!shouldOpenNewTab`, * allowing split navigation to activate an existing tab in the target pane * rather than creating a duplicate. + * - When `documentLinkTarget` is 'split-view', Shift inverts for links inside + * document content: a plain click splits and Shift+Click stays in the current + * pane. An explicit `data-new-split-tab` still forces a split either way. + * - Split opens are preview-eligible when `splitTabsArePreview` is set; the + * store scopes preview replacement per pane, so each pane keeps its own. */ export const resolveTabAction = ({ pathname, @@ -175,9 +185,15 @@ export const resolveTabAction = ({ isShiftHeld, newTab, newSplitTab, + isInDocument, + documentLinkTarget, + splitTabsArePreview, }: ResolveTabActionInput): TabAction => { const shouldOpenNewTab = isModifierHeld || newTab; - const shouldSplitTab = isShiftHeld || newSplitTab; + // Not applied when forcing a new tab: Ctrl/Cmd means "new tab in this pane" + // and Ctrl+Shift "new tab beside it", identically in both modes. + const splitByDefault = documentLinkTarget === 'split-view' && isInDocument && !shouldOpenNewTab; + const shouldSplitTab = (splitByDefault ? !isShiftHeld : isShiftHeld) || newSplitTab; const activePaneTabList = activePane === 'secondary' ? secondaryTabList : primaryTabList; const oppositePaneTabList = activePane === 'secondary' ? primaryTabList : secondaryTabList; @@ -185,7 +201,8 @@ export const resolveTabAction = ({ const targetTabList = shouldSplitTab ? oppositePaneTabList : activePaneTabList; const isViewLink = pathname.startsWith('/view/'); - const isPreviewEligible = isViewLink && !shouldOpenNewTab && !shouldSplitTab; + const isPreviewEligible = + isViewLink && !shouldOpenNewTab && (!shouldSplitTab || splitTabsArePreview); const existingTab = targetTabList.find((t) => matchTabLocation(t, pathname, search, hash)) ?? null; @@ -207,7 +224,13 @@ export const resolveTabAction = ({ } else if (existingTab && !shouldOpenNewTab) { return { type: 'activate', tabId: existingTab.id }; } else if (isPreviewEligible) { - return { type: 'preview', pane: activePane, pathname, search, hash }; + return { + type: 'preview', + pane: shouldSplitTab ? 'opposite' : activePane, + pathname, + search, + hash, + }; } else if (!(shouldOpenNewTab || shouldSplitTab) && activeTab) { const isDocumentLink = pathname.startsWith('/edit/') || pathname.startsWith('/view/'); const requiresAutosave = diff --git a/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx b/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx index 0040b63..d298b79 100644 --- a/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx +++ b/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx @@ -21,11 +21,14 @@ import { THEME_BY_VALUE } from '@repo/ui/theme/themes'; import { cn } from '@repo/ui/lib/utils'; import { CircleOff, + Columns2, FoldHorizontal, Palette, RefreshCcw, + Square, UnfoldHorizontal, } from '@repo/ui/components/icons'; +import type { DocumentLinkTarget } from '@/store/ui-slice'; function InterfacePreferencesSettings() { const appSidebar = useSelector((state) => state.ui.appSidebar); @@ -33,9 +36,13 @@ function InterfacePreferencesSettings() { const folderColorsEnabled = useSelector((state) => state.ui.folderColorsEnabled); const folderDefaultColor = useSelector((state) => state.ui.folderDefaultColor); const folderColorSolid = useSelector((state) => state.ui.folderColorSolid); + const documentLinkTarget = useSelector((state) => state.ui.documentLinkTarget); + const splitTabsArePreview = useSelector((state) => state.ui.splitTabsArePreview); const { setAppSidebar, setDocumentSidebar, + setDocumentLinkTarget, + setSplitTabsArePreview, setFolderColorsEnabled, setFolderDefaultColor, setFolderColorSolid, @@ -194,6 +201,50 @@ function InterfacePreferencesSettings() { )} + + + +
+
+ +

+ Where a link in a document opens; Shift+Click does the opposite +

+
+ +
+ +
+
+ +

+ Temporary tabs show in italics and are replaced by the next one — double-click a tab + to keep it +

+
+ +
); diff --git a/apps/web/src/store/ui-slice.ts b/apps/web/src/store/ui-slice.ts index e8acc78..de67b8d 100644 --- a/apps/web/src/store/ui-slice.ts +++ b/apps/web/src/store/ui-slice.ts @@ -10,6 +10,8 @@ import type { Store } from './store'; export type AppSidebarState = 'expanded' | 'collapsed' | 'remember'; +export type DocumentLinkTarget = 'current-pane' | 'split-view'; + export type FolderColor = 'theme' | Theme['color-variants'][number]['value']; export type HomeSortState = { @@ -30,6 +32,8 @@ type UiState = { folderColorsEnabled: boolean; folderDefaultColor: FolderColor; folderColorSolid: boolean; + documentLinkTarget: DocumentLinkTarget; + splitTabsArePreview: boolean; }; type UiActions = { @@ -44,6 +48,8 @@ type UiActions = { setFolderColorsEnabled: (enabled: boolean) => void; setFolderDefaultColor: (color: FolderColor) => void; setFolderColorSolid: (solid: boolean) => void; + setDocumentLinkTarget: (target: DocumentLinkTarget) => void; + setSplitTabsArePreview: (preview: boolean) => void; }; export type UiSlice = { ui: UiState; uiActions: UiActions }; @@ -64,6 +70,8 @@ const initialState: UiState = { folderColorsEnabled: false, folderDefaultColor: 'theme', folderColorSolid: false, + documentLinkTarget: 'current-pane', + splitTabsArePreview: true, }; export const createUiSlice: StateCreator< @@ -101,6 +109,10 @@ export const createUiSlice: StateCreator< set((state) => ({ ui: { ...state.ui, folderDefaultColor } })), setFolderColorSolid: (folderColorSolid) => set((state) => ({ ui: { ...state.ui, folderColorSolid } })), + setDocumentLinkTarget: (documentLinkTarget) => + set((state) => ({ ui: { ...state.ui, documentLinkTarget } })), + setSplitTabsArePreview: (splitTabsArePreview) => + set((state) => ({ ui: { ...state.ui, splitTabsArePreview } })), }, }; }; From 8c1ef4effdf2889bed0abb821bff6c607d88967c Mon Sep 17 00:00:00 2001 From: AdminTeamCoderz <164670747+AdminTeamCoderz@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:57:26 +0100 Subject: [PATCH 2/8] fix(store): merge persisted state per slice so new preferences get their defaults --- apps/web/src/store/store.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/web/src/store/store.ts b/apps/web/src/store/store.ts index 7366b95..9d1123a 100644 --- a/apps/web/src/store/store.ts +++ b/apps/web/src/store/store.ts @@ -37,6 +37,21 @@ export const store = createStore()( ui: state.ui, wordy: state.wordy, }), + // Zustand's default merge is shallow: a persisted slice replaces the + // whole slice, so keys added to a slice's initial state later would be + // undefined for anyone with existing storage. Merge per slice instead, + // so new preferences pick up their defaults without a version bump. + merge: (persistedState, currentState) => { + const persisted = (persistedState ?? {}) as Partial; + return { + ...currentState, + ...persisted, + app: { ...currentState.app, ...persisted.app }, + tabs: { ...currentState.tabs, ...persisted.tabs }, + ui: { ...currentState.ui, ...persisted.ui }, + wordy: { ...currentState.wordy, ...persisted.wordy }, + }; + }, }, ), { name: 'Wordy' }, From 8c30195f94d72afdccde7e82a863b900ee8eb710 Mon Sep 17 00:00:00 2001 From: AdminTeamCoderz <164670747+AdminTeamCoderz@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:16:54 +0100 Subject: [PATCH 3/8] fix(tabs): keep tabs opened from in-document links through the id-to-handle redirect --- apps/web/src/components/Layout/tabs/TabSync.tsx | 14 +++++++++++++- .../components/Layout/tabs/resolveTabMetadata.ts | 9 +++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Layout/tabs/TabSync.tsx b/apps/web/src/components/Layout/tabs/TabSync.tsx index 76e5d81..7d5bafb 100644 --- a/apps/web/src/components/Layout/tabs/TabSync.tsx +++ b/apps/web/src/components/Layout/tabs/TabSync.tsx @@ -7,6 +7,8 @@ import { useSelector, useActions } from '@/store'; import { useLocation, useNavigate } from '@tanstack/react-router'; import { useEffect, useRef } from 'react'; import { resolveModifier, useKeyHold } from '@tanstack/react-hotkeys'; +import { useQueryClient } from '@tanstack/react-query'; +import { getDocumentByIdQueryOptions } from '@/queries/documents'; import { matchTabLocation, findGroupTab, resolveTabAction } from './utils'; /** @@ -18,6 +20,7 @@ import { matchTabLocation, findGroupTab, resolveTabAction } from './utils'; */ export function TabSync() { const navigate = useNavigate(); + const queryClient = useQueryClient(); const { openTab, updateTab, setActiveTab } = useActions(); const tabList = useSelector((state) => state.tabs.tabList); @@ -207,7 +210,16 @@ export function TabSync() { } const isSameDocument = isDocumentTab && pathname.split('/').pop() === documentHandle; const isSamePath = activeTab && activeTab.pathname === pathname; - if (isSameDocument || isSamePath) { + // A tab opened from an in-document link holds `/view/?id=true`; the route + // redirects it to the handle. That is the same document, so update the tab + // (keeping its preview state) rather than opening a second one. + const isIdRedirect = + !!activeTab?.search?.id && + isDocumentTab && + queryClient.getQueryData<{ handle?: string }>( + getDocumentByIdQueryOptions(documentHandle ?? '').queryKey, + )?.handle === pathname.split('/').pop(); + if (isSameDocument || isSamePath || isIdRedirect) { updateTab(activeTab.id, { pathname, search, diff --git a/apps/web/src/components/Layout/tabs/resolveTabMetadata.ts b/apps/web/src/components/Layout/tabs/resolveTabMetadata.ts index afe6793..abbde14 100644 --- a/apps/web/src/components/Layout/tabs/resolveTabMetadata.ts +++ b/apps/web/src/components/Layout/tabs/resolveTabMetadata.ts @@ -4,7 +4,7 @@ */ import type { TabMetadata } from '@repo/types'; -import { getDocumentByHandleQueryOptions } from '@/queries/documents'; +import { getDocumentByHandleQueryOptions, getDocumentByIdQueryOptions } from '@/queries/documents'; import type { UseQueryOptions } from '@tanstack/react-query'; export type TabMetadataQueryOption = UseQueryOptions; @@ -74,7 +74,12 @@ export function resolveTabMetadata( // 3. Document routes (dynamic — title & icon come from a query) const handle = getDocumentHandle(pathname); if (handle) { - const docQueryOpts = getDocumentByHandleQueryOptions(handle); + // In-document links carry the document id (`?id=true`) and are redirected + // to the handle by the route; looking the id up as a handle 404s, and a + // failed lookup auto-closes the tab. + const docQueryOpts = search?.id + ? getDocumentByIdQueryOptions(handle) + : getDocumentByHandleQueryOptions(handle); return { metadata: { title: '', icon: null }, // fallback while loading queryOption: { From dbc1b328fad4b19cd5c4898f226c5604b9fc8283 Mon Sep 17 00:00:00 2001 From: AdminTeamCoderz <164670747+AdminTeamCoderz@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:44:14 +0100 Subject: [PATCH 4/8] fix(tabs): let data-new-tab links open as preview when splitting --- apps/web/src/components/Layout/tabs/utils.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Layout/tabs/utils.ts b/apps/web/src/components/Layout/tabs/utils.ts index db921e8..d5f3e43 100644 --- a/apps/web/src/components/Layout/tabs/utils.ts +++ b/apps/web/src/components/Layout/tabs/utils.ts @@ -189,10 +189,12 @@ export const resolveTabAction = ({ documentLinkTarget, splitTabsArePreview, }: ResolveTabActionInput): TabAction => { + // Two different intents: `newTab` (the editor marks every in-document link + // with data-new-tab) only means "never replace the document being read"; + // Ctrl/Cmd means "a permanent new tab". Both block in-place navigation, only + // the modifier blocks preview and split-by-default. const shouldOpenNewTab = isModifierHeld || newTab; - // Not applied when forcing a new tab: Ctrl/Cmd means "new tab in this pane" - // and Ctrl+Shift "new tab beside it", identically in both modes. - const splitByDefault = documentLinkTarget === 'split-view' && isInDocument && !shouldOpenNewTab; + const splitByDefault = documentLinkTarget === 'split-view' && isInDocument && !isModifierHeld; const shouldSplitTab = (splitByDefault ? !isShiftHeld : isShiftHeld) || newSplitTab; const activePaneTabList = activePane === 'secondary' ? secondaryTabList : primaryTabList; @@ -201,8 +203,10 @@ export const resolveTabAction = ({ const targetTabList = shouldSplitTab ? oppositePaneTabList : activePaneTabList; const isViewLink = pathname.startsWith('/view/'); + // A same-pane open of a data-new-tab link stays a permanent tab, as before; + // a split open is preview when the preference says so. const isPreviewEligible = - isViewLink && !shouldOpenNewTab && (!shouldSplitTab || splitTabsArePreview); + isViewLink && !isModifierHeld && (shouldSplitTab ? splitTabsArePreview : !newTab); const existingTab = targetTabList.find((t) => matchTabLocation(t, pathname, search, hash)) ?? null; From 979015657c548f941b728176c8cdceef76aba0b5 Mon Sep 17 00:00:00 2001 From: AdminTeamCoderz <164670747+AdminTeamCoderz@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:04:11 +0100 Subject: [PATCH 5/8] feat(tabs): default in-document links to split view and rename the preview setting --- .../src/components/Layout/tabs/TabSync.tsx | 25 +++++++++++-------- .../InterfacePreferencesSettings.tsx | 6 ++--- apps/web/src/store/store.ts | 11 +++++++- apps/web/src/store/ui-slice.ts | 2 +- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/Layout/tabs/TabSync.tsx b/apps/web/src/components/Layout/tabs/TabSync.tsx index 7d5bafb..03a0dd6 100644 --- a/apps/web/src/components/Layout/tabs/TabSync.tsx +++ b/apps/web/src/components/Layout/tabs/TabSync.tsx @@ -200,6 +200,20 @@ export function TabSync() { if (pathname.startsWith('/login') || pathname.startsWith('/signup')) return; const locationMatches = activeTab && matchTabLocation(activeTab, pathname, search, hash); if (locationMatches) return; + // A tab opened from an in-document link holds `/view/?id=true`; the route + // redirects it to the handle. That redirect belongs to the active tab, so it + // is updated in place (keeping its preview state) — checked before any other + // tab with the same location, which would otherwise steal the navigation. + const isIdRedirect = + !!activeTab?.search?.id && + isDocumentTab && + queryClient.getQueryData<{ handle?: string }>( + getDocumentByIdQueryOptions(documentHandle ?? '').queryKey, + )?.handle === pathname.split('/').pop(); + if (isIdRedirect) { + updateTab(activeTab.id, { pathname, search, hash }); + return; + } const existingTab = tabList.find((t) => matchTabLocation(t, pathname, search, hash)); if (existingTab) return setActiveTab(existingTab.id); const existingGroupTab = findGroupTab(tabList, pathname); @@ -210,16 +224,7 @@ export function TabSync() { } const isSameDocument = isDocumentTab && pathname.split('/').pop() === documentHandle; const isSamePath = activeTab && activeTab.pathname === pathname; - // A tab opened from an in-document link holds `/view/?id=true`; the route - // redirects it to the handle. That is the same document, so update the tab - // (keeping its preview state) rather than opening a second one. - const isIdRedirect = - !!activeTab?.search?.id && - isDocumentTab && - queryClient.getQueryData<{ handle?: string }>( - getDocumentByIdQueryOptions(documentHandle ?? '').queryKey, - )?.handle === pathname.split('/').pop(); - if (isSameDocument || isSamePath || isIdRedirect) { + if (isSameDocument || isSamePath) { updateTab(activeTab.id, { pathname, search, diff --git a/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx b/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx index d298b79..97e7c58 100644 --- a/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx +++ b/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx @@ -233,10 +233,10 @@ function InterfacePreferencesSettings() {
- +

- Temporary tabs show in italics and are replaced by the next one — double-click a tab - to keep it + Preview tabs show in italics and are replaced by the next one — double-click a tab to + keep it

()( }), { name: 'Wordy', - version: 4, + version: 5, + migrate: (persistedState, version) => { + const state = (persistedState ?? {}) as Pick; + // v5: the split-view default changed before the feature shipped, so + // the only persisted 'current-pane' values come from pre-release builds. + if (version < 5 && state.ui) { + return { ...state, ui: { ...state.ui, documentLinkTarget: 'split-view' as const } }; + } + return state; + }, partialize: (state) => ({ app: state.app, tabs: { diff --git a/apps/web/src/store/ui-slice.ts b/apps/web/src/store/ui-slice.ts index de67b8d..fe870fc 100644 --- a/apps/web/src/store/ui-slice.ts +++ b/apps/web/src/store/ui-slice.ts @@ -70,7 +70,7 @@ const initialState: UiState = { folderColorsEnabled: false, folderDefaultColor: 'theme', folderColorSolid: false, - documentLinkTarget: 'current-pane', + documentLinkTarget: 'split-view', splitTabsArePreview: true, }; From 9828319b0ccfaa2ffc3c44557923184a755d69ef Mon Sep 17 00:00:00 2001 From: AdminTeamCoderz <164670747+AdminTeamCoderz@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:11:54 +0100 Subject: [PATCH 6/8] fix(tabs): resolve in-document link ids to handles before routing --- .../src/components/Layout/tabs/TabSync.tsx | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Layout/tabs/TabSync.tsx b/apps/web/src/components/Layout/tabs/TabSync.tsx index 03a0dd6..476116e 100644 --- a/apps/web/src/components/Layout/tabs/TabSync.tsx +++ b/apps/web/src/components/Layout/tabs/TabSync.tsx @@ -78,14 +78,45 @@ export function TabSync() { ]); useEffect(() => { + // In-document links address documents by id (`/view/?id=true`) so they + // survive renames. Resolve that to the handle before routing, so the tab is + // created with its canonical location: tab matching works against open + // tabs, and nothing ever looks the id up as a handle. + const resolveDocumentLocation = async ( + pathname: string, + search: Record, + ): Promise<{ pathname: string; search: Record }> => { + const isDocumentPath = pathname.startsWith('/view/') || pathname.startsWith('/edit/'); + const id = pathname.split('/').pop(); + if (!search.id || !isDocumentPath || !id) return { pathname, search }; + try { + const document = await queryClient.ensureQueryData( + getDocumentByIdQueryOptions(id, queryClient), + ); + const { id: _id, ...rest } = search; + return { pathname: pathname.replace(id, document.handle), search: rest }; + } catch { + return { pathname, search }; + } + }; + const handleLinkClick = (event: MouseEvent) => { const link = event.currentTarget as HTMLAnchorElement; - const { origin, pathname, searchParams, hash } = new URL(link.href); + const { origin, pathname: rawPathname, searchParams, hash } = new URL(link.href); if (origin !== location.origin) return; if (link.download) return; - const search = Object.fromEntries(searchParams.entries()); - const normalizedHash = hash.slice(1); + event.preventDefault(); + void resolveDocumentLocation(rawPathname, Object.fromEntries(searchParams.entries())).then( + ({ pathname, search }) => routeLink(link, pathname, search, hash.slice(1)), + ); + }; + const routeLink = ( + link: HTMLAnchorElement, + pathname: string, + search: Record, + normalizedHash: string, + ) => { const action = resolveTabAction({ pathname, search, @@ -103,8 +134,6 @@ export function TabSync() { splitTabsArePreview: splitTabsArePreviewRef.current, }); - event.preventDefault(); - switch (action.type) { case 'activate': setActiveTab(action.tabId); From 70ab643b7e11e21cf255601efd55f53230458cfe Mon Sep 17 00:00:00 2001 From: AdminTeamCoderz <164670747+AdminTeamCoderz@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:21:04 +0100 Subject: [PATCH 7/8] fix(tabs): activate an already-open target instead of duplicating it --- apps/web/src/components/Layout/tabs/utils.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Layout/tabs/utils.ts b/apps/web/src/components/Layout/tabs/utils.ts index d5f3e43..0b9451b 100644 --- a/apps/web/src/components/Layout/tabs/utils.ts +++ b/apps/web/src/components/Layout/tabs/utils.ts @@ -219,13 +219,14 @@ export const resolveTabAction = ({ : null; // Honor new-tab/new-split-tab requests across group and same-path reuse branches. - // Exact-match tabs may still be activated when splitting — only forced new-tab (Ctrl/newTab) - // blocks that, because the existing tab is already in the target pane. + // An exact-match tab in the target pane is activated unless Ctrl/Cmd forces a + // new tab: a data-new-tab link only forbids replacing the reader's tab, and + // activating the target's own tab does not — opening another would duplicate it. if (existingGroupTab && !shouldOpenNewTab && !shouldSplitTab) { return { type: 'activate-and-update', tabId: existingGroupTab.id, pathname, search, hash }; } else if (existingTabSamePath && !shouldOpenNewTab && !shouldSplitTab) { return { type: 'activate-and-update', tabId: existingTabSamePath.id, pathname, search, hash }; - } else if (existingTab && !shouldOpenNewTab) { + } else if (existingTab && !isModifierHeld) { return { type: 'activate', tabId: existingTab.id }; } else if (isPreviewEligible) { return { From 6c7ce9c86bcaac483a27f5a62d3e0405ff273313 Mon Sep 17 00:00:00 2001 From: AdminTeamCoderz <164670747+AdminTeamCoderz@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:39:40 +0100 Subject: [PATCH 8/8] fix(preferences): associate the select triggers with their labels --- .../Settings/Preferences/InterfacePreferencesSettings.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx b/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx index 97e7c58..0c76fa9 100644 --- a/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx +++ b/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx @@ -64,7 +64,7 @@ function InterfacePreferencesSettings() { defaultValue={appSidebar} onValueChange={(value) => setAppSidebar(value as 'expanded' | 'collapsed' | 'remember')} > - + @@ -97,7 +97,7 @@ function InterfacePreferencesSettings() { setDocumentSidebar(value as 'expanded' | 'collapsed' | 'remember') } > - + @@ -215,7 +215,7 @@ function InterfacePreferencesSettings() { value={documentLinkTarget} onValueChange={(value) => setDocumentLinkTarget(value as DocumentLinkTarget)} > - +