Implement resizable Markdown table of contents panel (#5444)

* Implement resizable Markdown table of contents panel

Introduce resizable behavior to the Markdown table of contents panel,
allowing users to adjust its width via a draggable handle.

- Persist the custom panel width in the UI state and main process store.
- Clamp the width to a safe range (200px to 600px) based on layout space.
- Ensure the remaining editor workspace does not shrink below 320px
  by computing constraints dynamically against the parent container width.

* Clone process env without undefined values for Windows preflight

- Filter out undefined values when cloning process.env to prevent
  potential serialization or typing issues.
- Update buildLocalPreflightEnv return type to Record<string, string>.
This commit is contained in:
Jinjing
2026-06-15 19:59:44 -07:00
committed by GitHub
parent 7361bff698
commit 996284241d
16 changed files with 172 additions and 14 deletions
+5
View File
@@ -140,6 +140,7 @@ import {
normalizePersistedWorkspaceStatuses,
normalizeWorkspaceStatuses
} from '../shared/workspace-statuses'
import { clampMarkdownTocPanelWidth } from '../shared/markdown-toc-panel-width'
import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ownership'
import { sanitizeRepoIcon } from '../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../shared/repo-badge-color'
@@ -4527,6 +4528,7 @@ export class Store {
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
this.state.ui?.workspaceBoardColumnWidth
),
markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth),
visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds(
this.state.ui?.visibleWorkspaceHostIds
),
@@ -4597,6 +4599,9 @@ export class Store {
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
sanitizedUpdates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth
),
markdownTocPanelWidth: clampMarkdownTocPanelWidth(
sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth
),
visibleWorkspaceHostIds:
updates.visibleWorkspaceHostIds !== undefined
? normalizeVisibleExecutionHostIds(updates.visibleWorkspaceHostIds)
@@ -150,6 +150,7 @@ const UiUpdate = z
.optional(),
rightSidebarExplorerView: z.enum(['files', 'search']).optional(),
rightSidebarWidth: z.number().finite().optional(),
markdownTocPanelWidth: z.number().finite().optional(),
groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(),
showWorkspaceLineage: z.boolean().optional(),
sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(),
+3
View File
@@ -544,6 +544,7 @@ function App(): React.JSX.Element {
const shouldMountSetupGuideTelemetryObserver = persistedUIReady
const shouldMountUpdateCard = shouldMountUpdateCardForStatus(updateStatus)
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
const markdownTocPanelWidth = useAppStore((s) => s.markdownTocPanelWidth)
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
const rightSidebarExplorerView = useAppStore((s) => s.rightSidebarExplorerView)
@@ -1174,6 +1175,7 @@ function App(): React.JSX.Element {
rightSidebarTab,
rightSidebarExplorerView,
rightSidebarWidth,
markdownTocPanelWidth,
groupBy,
sortBy,
projectOrderBy,
@@ -1200,6 +1202,7 @@ function App(): React.JSX.Element {
rightSidebarTab,
rightSidebarExplorerView,
rightSidebarWidth,
markdownTocPanelWidth,
groupBy,
sortBy,
projectOrderBy,
+1 -13
View File
@@ -93,10 +93,8 @@
}
.markdown-toc-panel {
position: relative;
display: flex;
width: 240px;
min-width: 200px;
max-width: 30%;
flex-shrink: 0;
flex-direction: column;
border-right: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
@@ -228,13 +226,6 @@
font-size: 12px;
}
@media (max-width: 900px) {
.markdown-toc-panel {
width: 200px;
max-width: 42%;
}
}
@container (max-width: 560px) {
.markdown-toc-panel {
position: absolute;
@@ -242,9 +233,6 @@
left: 0;
bottom: 0;
z-index: 30;
width: min(240px, 72cqw);
min-width: 0;
max-width: calc(100cqw - 44px);
box-shadow: 10px 0 24px rgb(0 0 0 / 0.14);
}
}
@@ -30,5 +30,7 @@ describe('MarkdownTableOfContentsPanel', () => {
expect(html).toContain('Collapse Intro')
expect(html).toContain('Intro')
expect(html).toContain('Setup')
expect(html).toContain('data-markdown-toc-resize-handle')
expect(html).toContain('Resize table of contents')
})
})
@@ -10,6 +10,14 @@ import {
toggleMarkdownTocCollapsedId
} from './markdown-toc-collapse-state'
import { translate } from '@/i18n/i18n'
import { useSidebarResize } from '@/hooks/useSidebarResize'
import { useAppStore } from '@/store'
import {
MARKDOWN_TOC_PANEL_MIN_WIDTH,
MARKDOWN_TOC_RESIZE_HANDLE_CLASS_NAME,
clampMarkdownTocPanelWidth,
computeMaxMarkdownTocPanelWidth
} from './markdown-toc-panel-width'
type MarkdownTableOfContentsPanelProps = {
items: MarkdownTocItem[]
@@ -106,11 +114,44 @@ export function MarkdownTableOfContentsPanel({
onNavigate
}: MarkdownTableOfContentsPanelProps): React.JSX.Element {
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => new Set())
const markdownTocPanelWidth = useAppStore((s) => s.markdownTocPanelWidth)
const setMarkdownTocPanelWidth = useAppStore((s) => s.setMarkdownTocPanelWidth)
const [layoutWidth, setLayoutWidth] = useState<number | null>(null)
const maxPanelWidth = computeMaxMarkdownTocPanelWidth(layoutWidth ?? 0)
const renderedPanelWidth = clampMarkdownTocPanelWidth(
markdownTocPanelWidth,
layoutWidth ?? undefined
)
const { containerRef, onResizeStart } = useSidebarResize<HTMLElement>({
isOpen: true,
width: renderedPanelWidth,
minWidth: MARKDOWN_TOC_PANEL_MIN_WIDTH,
maxWidth: maxPanelWidth,
deltaSign: 1,
setWidth: setMarkdownTocPanelWidth
})
useEffect(() => {
setCollapsedIds((current) => pruneMarkdownTocCollapsedIds(current, items))
}, [items])
useEffect(() => {
const container = containerRef.current
const layout = container?.parentElement
if (!layout) {
return
}
const updateMaxWidth = (): void => {
setLayoutWidth(layout.clientWidth)
}
updateMaxWidth()
const observer = new ResizeObserver(updateMaxWidth)
observer.observe(layout)
return () => observer.disconnect()
}, [containerRef])
const collapseToLevel = (level: MarkdownTocLevel): void => {
setCollapsedIds(collapseMarkdownTocToLevel(items, level))
}
@@ -121,6 +162,7 @@ export function MarkdownTableOfContentsPanel({
return (
<aside
ref={containerRef}
className="markdown-toc-panel"
aria-label={translate(
'auto.components.editor.MarkdownTableOfContentsPanel.27d0a9c49a',
@@ -220,6 +262,17 @@ export function MarkdownTableOfContentsPanel({
</div>
)}
</div>
<div
data-markdown-toc-resize-handle=""
className={MARKDOWN_TOC_RESIZE_HANDLE_CLASS_NAME}
role="separator"
aria-orientation="vertical"
aria-label={translate(
'auto.components.editor.MarkdownTableOfContentsPanel.8f4d2c1a9b',
'Resize table of contents'
)}
onMouseDown={onResizeStart}
/>
</aside>
)
}
@@ -0,0 +1,11 @@
export {
MARKDOWN_TOC_PANEL_DEFAULT_WIDTH,
MARKDOWN_TOC_PANEL_MAX_WIDTH,
MARKDOWN_TOC_PANEL_MIN_WIDTH,
clampMarkdownTocPanelWidth,
computeMaxMarkdownTocPanelWidth
} from '../../../../shared/markdown-toc-panel-width'
// Why: match the worktree/right sidebar 4px resize target; a 1px seam is too hard to acquire.
export const MARKDOWN_TOC_RESIZE_HANDLE_CLASS_NAME =
'absolute top-0 right-0 z-10 h-full w-1 cursor-col-resize transition-colors hover:bg-ring/20 active:bg-ring/30'
+2 -1
View File
@@ -10058,7 +10058,8 @@
"06357eea60": "Table of Contents",
"27d0a9c49a": "Table of contents",
"65b036a6c8": "Expand {{value0}}",
"97ad46f11f": "Collapse {{value0}}"
"97ad46f11f": "Collapse {{value0}}",
"8f4d2c1a9b": "Resize table of contents"
},
"MarkdownTemplatePicker": {
"22cd94426f": "untitled.md",
@@ -39,6 +39,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState
rightSidebarTab: 'explorer',
rightSidebarExplorerView: 'files',
rightSidebarWidth: 350,
markdownTocPanelWidth: 240,
groupBy: 'repo',
sortBy: 'name',
projectOrderBy: 'manual',
+12
View File
@@ -35,6 +35,7 @@ import {
stripCredentialsFromMessage
} from '../../../../shared/git-remote-error'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { clampMarkdownTocPanelWidth } from '../../../../shared/markdown-toc-panel-width'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action'
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
@@ -333,6 +334,10 @@ export type EditorSlice = {
markdownFrontmatterVisible: Record<string, boolean>
setMarkdownFrontmatterVisible: (fileId: string, visible: boolean) => void
// Markdown table of contents
markdownTocPanelWidth: number
setMarkdownTocPanelWidth: (width: number) => void
// Right sidebar
rightSidebarOpen: boolean
rightSidebarWidth: number
@@ -1451,6 +1456,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
return { markdownFrontmatterVisible: { ...s.markdownFrontmatterVisible, [fileId]: true } }
}),
// Markdown table of contents
markdownTocPanelWidth: 240,
setMarkdownTocPanelWidth: (width) =>
set((s) => ({
markdownTocPanelWidth: clampMarkdownTocPanelWidth(width, undefined, s.markdownTocPanelWidth)
})),
// Right sidebar
rightSidebarOpen: false,
rightSidebarWidth: 280,
+13
View File
@@ -70,6 +70,7 @@ function createUIStore(): StoreApi<AppState> {
worktreesByRepo: {},
rightSidebarOpen: false,
rightSidebarWidth: 280,
markdownTocPanelWidth: 240,
rightSidebarTab: 'explorer',
rightSidebarExplorerView: 'files',
...createSettingsSearchState(args[0]),
@@ -868,6 +869,18 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().rightSidebarWidth).toBe(220)
})
it('clamps persisted markdown toc panel widths into the supported range', () => {
const store = createUIStore()
store.getState().hydratePersistedUI(
makePersistedUI({
markdownTocPanelWidth: 100
})
)
expect(store.getState().markdownTocPanelWidth).toBe(200)
})
it('preserves right sidebar widths above the former 500px cap', () => {
const store = createUIStore()
+6
View File
@@ -81,6 +81,7 @@ import {
cloneDefaultWorkspaceStatuses,
normalizeWorkspaceStatuses
} from '../../../../shared/workspace-statuses'
import { clampMarkdownTocPanelWidth } from '../../../../shared/markdown-toc-panel-width'
import { normalizeKagiSessionLink } from '../../../../shared/browser-url'
import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust'
import type { SettingsNavTarget } from '@/lib/settings-navigation-types'
@@ -2135,6 +2136,11 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
s.rightSidebarWidth,
MAX_RIGHT_SIDEBAR_WIDTH
),
markdownTocPanelWidth: clampMarkdownTocPanelWidth(
ui.markdownTocPanelWidth,
undefined,
s.markdownTocPanelWidth
),
rightSidebarOpen: typeof ui.rightSidebarOpen === 'boolean' ? ui.rightSidebarOpen : true,
rightSidebarTab: rightSidebarRoute.rightSidebarTab,
rightSidebarExplorerView: rightSidebarRoute.rightSidebarExplorerView,
+1
View File
@@ -424,6 +424,7 @@ export function getDefaultUIState(): PersistedUIState {
rightSidebarTab: 'explorer',
rightSidebarExplorerView: 'files',
rightSidebarWidth: 350,
markdownTocPanelWidth: 240,
groupBy: 'repo',
sortBy: 'recent',
projectOrderBy: 'manual',
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
MARKDOWN_TOC_PANEL_DEFAULT_WIDTH,
MARKDOWN_TOC_PANEL_MAX_WIDTH,
MARKDOWN_TOC_PANEL_MIN_WIDTH,
clampMarkdownTocPanelWidth,
computeMaxMarkdownTocPanelWidth
} from './markdown-toc-panel-width'
describe('markdown toc panel width', () => {
it('clamps widths into the supported range', () => {
expect(clampMarkdownTocPanelWidth(undefined)).toBe(MARKDOWN_TOC_PANEL_DEFAULT_WIDTH)
expect(clampMarkdownTocPanelWidth(100)).toBe(MARKDOWN_TOC_PANEL_MIN_WIDTH)
expect(clampMarkdownTocPanelWidth(900)).toBe(MARKDOWN_TOC_PANEL_MAX_WIDTH)
})
it('respects the remaining editor width when a container size is known', () => {
expect(computeMaxMarkdownTocPanelWidth(700)).toBe(380)
expect(clampMarkdownTocPanelWidth(500, 700)).toBe(380)
expect(clampMarkdownTocPanelWidth(350, 700)).toBe(350)
})
it('treats the second argument as container width, not a precomputed max', () => {
const maxFor700 = computeMaxMarkdownTocPanelWidth(700)
expect(clampMarkdownTocPanelWidth(350, maxFor700)).toBe(200)
expect(clampMarkdownTocPanelWidth(350, 700)).toBe(350)
})
})
+32
View File
@@ -0,0 +1,32 @@
export const MARKDOWN_TOC_PANEL_MIN_WIDTH = 200
export const MARKDOWN_TOC_PANEL_DEFAULT_WIDTH = 240
export const MARKDOWN_TOC_PANEL_MIN_EDITOR_WIDTH = 320
export const MARKDOWN_TOC_PANEL_MAX_WIDTH = 600
export function computeMaxMarkdownTocPanelWidth(containerWidth: number): number {
if (!Number.isFinite(containerWidth) || containerWidth <= 0) {
return MARKDOWN_TOC_PANEL_MAX_WIDTH
}
return Math.min(
MARKDOWN_TOC_PANEL_MAX_WIDTH,
Math.max(MARKDOWN_TOC_PANEL_MIN_WIDTH, containerWidth - MARKDOWN_TOC_PANEL_MIN_EDITOR_WIDTH)
)
}
export function clampMarkdownTocPanelWidth(
width: unknown,
containerWidth?: number,
fallback = MARKDOWN_TOC_PANEL_DEFAULT_WIDTH
): number {
if (typeof width !== 'number' || !Number.isFinite(width)) {
return fallback
}
const maxWidth =
containerWidth !== undefined
? computeMaxMarkdownTocPanelWidth(containerWidth)
: MARKDOWN_TOC_PANEL_MAX_WIDTH
return Math.min(maxWidth, Math.max(MARKDOWN_TOC_PANEL_MIN_WIDTH, width))
}
+1
View File
@@ -2931,6 +2931,7 @@ export type PersistedUIState = {
rightSidebarTab: RightSidebarTab
rightSidebarExplorerView: RightSidebarExplorerView
rightSidebarWidth: number
markdownTocPanelWidth?: number
groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status'
sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual'
/** Project header ordering in `groupBy: 'repo'`, independent of workspace