feat: add workspace layout and window swapping

This commit is contained in:
2026-08-19 13:52:44 +08:00
parent f58e0a70f4
commit 1068304da5
5 changed files with 936 additions and 132 deletions
@@ -13,6 +13,7 @@ export type WorkspaceBounds = {
};
export type WorkspaceWindowMode = "docked" | "floating" | "maximized" | "minimized";
export type WorkspaceLayoutMode = "free" | "primary" | "split" | "grid" | "cascade";
export type AnalysisMetric = {
label: string;
@@ -85,25 +86,25 @@ export type AnalysisDocument = {
blocks: AnalysisBlock[];
};
export type AnalysisWorkspaceWindow = {
id: string;
kind: "agent-analysis";
title: string;
mode: Exclude<WorkspaceWindowMode, "docked">;
rect: WorkspaceRect;
restoreRect: WorkspaceRect;
zIndex: number;
document: AnalysisDocument;
};
export type MapWorkspaceWindow = {
id: "workspace-map";
kind: "map";
title: string;
type WorkspaceWindowState = {
mode: WorkspaceWindowMode;
rect: WorkspaceRect;
restoreRect: WorkspaceRect;
zIndex: number;
minimizedByLayout: boolean;
};
export type AnalysisWorkspaceWindow = WorkspaceWindowState & {
id: string;
kind: "agent-analysis";
title: string;
document: AnalysisDocument;
};
export type MapWorkspaceWindow = WorkspaceWindowState & {
id: "workspace-map";
kind: "map";
title: string;
};
export type WorkspaceState = {
@@ -111,25 +112,34 @@ export type WorkspaceState = {
analyses: AnalysisWorkspaceWindow[];
activeWindowId: string;
nextZIndex: number;
layout: {
mode: WorkspaceLayoutMode;
orderedWindowIds: string[];
};
};
const FALLBACK_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
const MAP_WINDOW_ID = "workspace-map";
const LAYOUT_INSET = 8;
const LAYOUT_GAP = 8;
export function createInitialWorkspaceState(bounds: WorkspaceBounds = FALLBACK_BOUNDS): WorkspaceState {
const mapRect = getDefaultMapRect(bounds);
return {
map: {
id: "workspace-map",
id: MAP_WINDOW_ID,
kind: "map",
title: "供水管网地图",
mode: "docked",
rect: mapRect,
restoreRect: mapRect,
zIndex: 1
zIndex: 1,
minimizedByLayout: false
},
analyses: [],
activeWindowId: "workspace-map",
nextZIndex: 2
activeWindowId: MAP_WINDOW_ID,
nextZIndex: 2,
layout: { mode: "free", orderedWindowIds: [MAP_WINDOW_ID] }
};
}
@@ -138,9 +148,7 @@ export function openAnalysisDocument(
document: AnalysisDocument,
bounds: WorkspaceBounds
): WorkspaceState {
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) {
return state;
}
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) return state;
const rect = getDefaultAnalysisRect(bounds, state.analyses.length);
const window: AnalysisWorkspaceWindow = {
@@ -151,93 +159,92 @@ export function openAnalysisDocument(
rect,
restoreRect: rect,
zIndex: state.nextZIndex,
minimizedByLayout: false,
document
};
return {
map: { ...state.map, mode: "minimized" },
const opened: WorkspaceState = {
...state,
analyses: [...state.analyses, window],
activeWindowId: window.id,
nextZIndex: state.nextZIndex + 1
nextZIndex: state.nextZIndex + 1,
layout: {
...state.layout,
orderedWindowIds: uniqueIds([window.id, MAP_WINDOW_ID, ...state.layout.orderedWindowIds])
}
};
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(opened, bounds);
return { ...opened, map: { ...opened.map, mode: "minimized", minimizedByLayout: false } };
}
export function closeAnalysisWindow(state: WorkspaceState, windowId: string): WorkspaceState {
export function closeAnalysisWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds = FALLBACK_BOUNDS
): WorkspaceState {
const analyses = state.analyses.filter((window) => window.id !== windowId);
if (analyses.length === state.analyses.length) {
return state;
}
if (analyses.length === state.analyses.length) return state;
if (analyses.length === 0) {
return {
...state,
map: { ...state.map, mode: "docked", zIndex: state.nextZIndex },
map: {
...state.map,
mode: "docked",
minimizedByLayout: false,
zIndex: state.nextZIndex
},
analyses,
activeWindowId: state.map.id,
nextZIndex: state.nextZIndex + 1
activeWindowId: MAP_WINDOW_ID,
nextZIndex: state.nextZIndex + 1,
layout: { mode: "free", orderedWindowIds: [MAP_WINDOW_ID] }
};
}
const nextActive = [...analyses].sort((a, b) => b.zIndex - a.zIndex)[0];
return {
const orderedWindowIds = state.layout.orderedWindowIds.filter((id) => id !== windowId);
const nextActive = state.activeWindowId === windowId
? orderedWindowIds.find((id) => getWindowById({ ...state, analyses }, id)?.mode !== "minimized")
: state.activeWindowId;
const closed: WorkspaceState = {
...state,
analyses,
activeWindowId: nextActive?.id ?? state.map.id
activeWindowId: nextActive ?? MAP_WINDOW_ID,
layout: { ...state.layout, orderedWindowIds }
};
return state.layout.mode === "free" ? closed : arrangeWorkspaceLayout(closed, bounds);
}
export function focusWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (windowId === state.map.id) {
return {
...state,
map: { ...state.map, zIndex: state.nextZIndex },
activeWindowId: windowId,
nextZIndex: state.nextZIndex + 1
};
}
if (!state.analyses.some((window) => window.id === windowId)) {
return state;
}
return {
...state,
analyses: state.analyses.map((window) =>
window.id === windowId ? { ...window, zIndex: state.nextZIndex } : window
),
activeWindowId: windowId,
nextZIndex: state.nextZIndex + 1
};
if (!getWindowById(state, windowId)) return state;
return updateWindowById(
{ ...state, activeWindowId: windowId, nextZIndex: state.nextZIndex + 1 },
windowId,
(window) => ({ ...window, zIndex: state.nextZIndex })
);
}
export function minimizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (windowId === state.map.id) {
return { ...state, map: { ...state.map, mode: "minimized" } };
}
return {
...state,
analyses: state.analyses.map((window) =>
window.id === windowId ? { ...window, mode: "minimized" } : window
)
};
export function minimizeWorkspaceWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds = FALLBACK_BOUNDS
): WorkspaceState {
if (!getWindowById(state, windowId)) return state;
const minimized = updateWindowById(state, windowId, (window) => ({
...window,
mode: "minimized",
minimizedByLayout: false
}));
return state.layout.mode === "free" ? minimized : arrangeWorkspaceLayout(minimized, bounds);
}
export function maximizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (windowId === state.map.id) {
return focusWorkspaceWindow(
{ ...state, map: { ...state.map, mode: "maximized" } },
windowId
);
}
if (!getWindowById(state, windowId)) return state;
return focusWorkspaceWindow(
{
...state,
analyses: state.analyses.map((window) =>
window.id === windowId ? { ...window, mode: "maximized" } : window
)
},
updateWindowById(state, windowId, (window) => ({
...window,
mode: "maximized",
minimizedByLayout: false
})),
windowId
);
}
@@ -247,27 +254,33 @@ export function restoreWorkspaceWindow(
windowId: string,
bounds: WorkspaceBounds
): WorkspaceState {
if (windowId === state.map.id) {
const rect = clampWorkspaceRect(state.map.restoreRect, bounds, "map");
return focusWorkspaceWindow(
{ ...state, map: { ...state.map, mode: "floating", rect } },
windowId
const window = getWindowById(state, windowId);
if (!window) return state;
if (state.layout.mode !== "free") {
const restored = updateWindowById(
{
...state,
layout: {
...state.layout,
orderedWindowIds: uniqueIds([windowId, ...state.layout.orderedWindowIds])
}
},
windowId,
(current) => ({ ...current, mode: "floating", minimizedByLayout: false })
);
return focusWorkspaceWindow(arrangeWorkspaceLayout(restored, bounds), windowId);
}
const kind = windowId === MAP_WINDOW_ID ? "map" : "analysis";
const rect = clampWorkspaceRect(window.restoreRect, bounds, kind);
return focusWorkspaceWindow(
{
...state,
analyses: state.analyses.map((window) =>
window.id === windowId
? {
...window,
mode: "floating",
rect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
}
: window
)
},
updateWindowById(state, windowId, (current) => ({
...current,
mode: "floating",
rect,
minimizedByLayout: false
})),
windowId
);
}
@@ -278,25 +291,110 @@ export function updateWorkspaceWindowRect(
rect: WorkspaceRect,
bounds: WorkspaceBounds
): WorkspaceState {
if (windowId === state.map.id) {
const nextRect = clampWorkspaceRect(rect, bounds, "map");
return {
if (!getWindowById(state, windowId)) return state;
const kind = windowId === MAP_WINDOW_ID ? "map" : "analysis";
const nextRect = clampWorkspaceRect(rect, bounds, kind);
return updateWindowById(
{
...state,
map: { ...state.map, mode: "floating", rect: nextRect, restoreRect: nextRect }
};
layout: { ...state.layout, mode: "free" }
},
windowId,
(window) => ({
...window,
mode: "floating",
rect: nextRect,
restoreRect: nextRect,
minimizedByLayout: false
})
);
}
export function applyWorkspaceLayout(
state: WorkspaceState,
mode: WorkspaceLayoutMode,
bounds: WorkspaceBounds
): WorkspaceState {
if (mode === "free") {
return { ...state, layout: { ...state.layout, mode } };
}
return {
const orderedWindowIds = uniqueIds([
state.activeWindowId,
MAP_WINDOW_ID,
...allWorkspaceWindows(state).sort((a, b) => b.zIndex - a.zIndex).map((window) => window.id),
...state.layout.orderedWindowIds
]);
const restored = mapAllWorkspaceWindows(
{
...state,
layout: { mode, orderedWindowIds }
},
(window) => ({ ...window, mode: "floating", minimizedByLayout: false })
);
return arrangeWorkspaceLayout(restored, bounds, true);
}
export function setWorkspacePrimaryWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds
): WorkspaceState {
if (!getWindowById(state, windowId) || state.layout.mode === "free") return state;
const next = {
...state,
analyses: state.analyses.map((window) => {
if (window.id !== windowId) return window;
const nextRect = clampWorkspaceRect(rect, bounds, "analysis");
return { ...window, mode: "floating", rect: nextRect, restoreRect: nextRect };
})
layout: {
...state.layout,
orderedWindowIds: uniqueIds([windowId, ...state.layout.orderedWindowIds])
}
};
return focusWorkspaceWindow(arrangeWorkspaceLayout(next, bounds), windowId);
}
export function swapWorkspaceWindows(
state: WorkspaceState,
sourceId: string,
targetId: string,
bounds: WorkspaceBounds
): WorkspaceState {
const source = getWindowById(state, sourceId);
const target = getWindowById(state, targetId);
if (!source || !target || sourceId === targetId || source.mode === "minimized" || target.mode === "minimized") {
return state;
}
if (state.layout.mode !== "free") {
const order = normalizeWindowOrder(state);
const sourceIndex = order.indexOf(sourceId);
const targetIndex = order.indexOf(targetId);
if (sourceIndex < 0 || targetIndex < 0) return state;
[order[sourceIndex], order[targetIndex]] = [order[targetIndex], order[sourceIndex]];
const swapped = { ...state, layout: { ...state.layout, orderedWindowIds: order } };
return focusWorkspaceWindow(arrangeWorkspaceLayout(swapped, bounds), sourceId);
}
const swapped = updateWindowById(
updateWindowById(state, sourceId, (window) => ({
...window,
mode: target.mode,
rect: target.rect,
restoreRect: target.restoreRect,
minimizedByLayout: false
})),
targetId,
(window) => ({
...window,
mode: source.mode,
rect: source.rect,
restoreRect: source.restoreRect,
minimizedByLayout: false
})
);
return focusWorkspaceWindow(swapped, sourceId);
}
export function resizeWorkspace(state: WorkspaceState, bounds: WorkspaceBounds): WorkspaceState {
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(state, bounds);
return {
...state,
map: {
@@ -317,8 +415,8 @@ export function clampWorkspaceRect(
bounds: WorkspaceBounds,
kind: "map" | "analysis"
): WorkspaceRect {
const minimumWidth = kind === "map" ? 360 : 640;
const minimumHeight = kind === "map" ? 280 : 420;
const minimumWidth = kind === "map" ? 320 : 360;
const minimumHeight = kind === "map" ? 240 : 280;
const availableWidth = Math.max(280, bounds.width - 24);
const availableHeight = Math.max(240, bounds.height - 24);
const width = clamp(rect.width, Math.min(minimumWidth, availableWidth), availableWidth);
@@ -331,6 +429,173 @@ export function clampWorkspaceRect(
};
}
function arrangeWorkspaceLayout(
state: WorkspaceState,
bounds: WorkspaceBounds,
restoreAll = false
): WorkspaceState {
if (state.layout.mode === "free") return state;
const orderedWindowIds = normalizeWindowOrder(state);
const eligibleIds = orderedWindowIds.filter((id) => {
const window = getWindowById(state, id);
return window && (restoreAll || window.mode !== "minimized" || window.minimizedByLayout);
});
const visibleIds = selectVisibleWindowIds(eligibleIds, state.layout.mode);
const visibleSet = new Set(visibleIds);
const eligibleSet = new Set(eligibleIds);
const rects = getLayoutRects(state.layout.mode, visibleIds.length, bounds);
let next = mapAllWorkspaceWindows(state, (window) => {
const visibleIndex = visibleIds.indexOf(window.id);
if (visibleIndex >= 0) {
const rect = rects[visibleIndex] ?? window.rect;
return {
...window,
mode: !restoreAll && window.mode === "maximized" ? "maximized" : "floating",
rect,
restoreRect: rect,
minimizedByLayout: false
};
}
if (eligibleSet.has(window.id) && !visibleSet.has(window.id)) {
return { ...window, mode: "minimized", minimizedByLayout: true };
}
return window;
});
const activeWindowId = visibleSet.has(next.activeWindowId)
? next.activeWindowId
: visibleIds[0] ?? next.activeWindowId;
next = {
...next,
activeWindowId,
layout: { ...next.layout, orderedWindowIds }
};
return next;
}
function selectVisibleWindowIds(ids: string[], mode: WorkspaceLayoutMode) {
const capacity = mode === "split" ? 2 : mode === "primary" ? 3 : ids.length;
const selected = ids.slice(0, capacity);
if (
(mode === "split" || mode === "primary") &&
ids.includes(MAP_WINDOW_ID) &&
!selected.includes(MAP_WINDOW_ID)
) {
selected[selected.length - 1] = MAP_WINDOW_ID;
}
return selected;
}
function getLayoutRects(mode: WorkspaceLayoutMode, count: number, bounds: WorkspaceBounds): WorkspaceRect[] {
if (count === 0) return [];
const width = Math.max(1, bounds.width - LAYOUT_INSET * 2);
const height = Math.max(1, bounds.height - LAYOUT_INSET * 2);
if (mode === "primary") {
if (count === 1) return [{ x: LAYOUT_INSET, y: LAYOUT_INSET, width, height }];
const primaryWidth = Math.floor((width - LAYOUT_GAP) * 0.68);
const secondaryWidth = width - LAYOUT_GAP - primaryWidth;
const secondaryHeight = count === 2 ? height : Math.floor((height - LAYOUT_GAP) / 2);
return [
{ x: LAYOUT_INSET, y: LAYOUT_INSET, width: primaryWidth, height },
{ x: LAYOUT_INSET + primaryWidth + LAYOUT_GAP, y: LAYOUT_INSET, width: secondaryWidth, height: secondaryHeight },
...(count === 3
? [{
x: LAYOUT_INSET + primaryWidth + LAYOUT_GAP,
y: LAYOUT_INSET + secondaryHeight + LAYOUT_GAP,
width: secondaryWidth,
height: height - secondaryHeight - LAYOUT_GAP
}]
: [])
];
}
if (mode === "split") {
if (count === 1) return [{ x: LAYOUT_INSET, y: LAYOUT_INSET, width, height }];
const firstWidth = Math.floor((width - LAYOUT_GAP) / 2);
return [
{ x: LAYOUT_INSET, y: LAYOUT_INSET, width: firstWidth, height },
{
x: LAYOUT_INSET + firstWidth + LAYOUT_GAP,
y: LAYOUT_INSET,
width: width - firstWidth - LAYOUT_GAP,
height
}
];
}
if (mode === "cascade") {
const windowWidth = Math.max(280, Math.floor(width * 0.78));
const windowHeight = Math.max(240, Math.floor(height * 0.78));
return Array.from({ length: count }, (_, index) => ({
x: Math.min(LAYOUT_INSET + index * 28, Math.max(LAYOUT_INSET, bounds.width - windowWidth - LAYOUT_INSET)),
y: Math.min(LAYOUT_INSET + index * 28, Math.max(LAYOUT_INSET, bounds.height - windowHeight - LAYOUT_INSET)),
width: Math.min(windowWidth, width),
height: Math.min(windowHeight, height)
}));
}
const columns = count === 1 ? 1 : count <= 4 ? 2 : 3;
const rows = Math.ceil(count / columns);
const cellWidth = Math.floor((width - LAYOUT_GAP * (columns - 1)) / columns);
const cellHeight = Math.floor((height - LAYOUT_GAP * (rows - 1)) / rows);
return Array.from({ length: count }, (_, index) => {
const column = index % columns;
const row = Math.floor(index / columns);
return {
x: LAYOUT_INSET + column * (cellWidth + LAYOUT_GAP),
y: LAYOUT_INSET + row * (cellHeight + LAYOUT_GAP),
width: column === columns - 1 ? width - column * (cellWidth + LAYOUT_GAP) : cellWidth,
height: row === rows - 1 ? height - row * (cellHeight + LAYOUT_GAP) : cellHeight
};
});
}
function normalizeWindowOrder(state: WorkspaceState) {
const ids = new Set(allWorkspaceWindows(state).map((window) => window.id));
return uniqueIds([
...state.layout.orderedWindowIds,
...allWorkspaceWindows(state).sort((a, b) => b.zIndex - a.zIndex).map((window) => window.id)
]).filter((id) => ids.has(id));
}
function getWindowById(state: WorkspaceState, windowId: string): MapWorkspaceWindow | AnalysisWorkspaceWindow | undefined {
return windowId === MAP_WINDOW_ID ? state.map : state.analyses.find((window) => window.id === windowId);
}
function updateWindowById(
state: WorkspaceState,
windowId: string,
update: <T extends MapWorkspaceWindow | AnalysisWorkspaceWindow>(window: T) => T
): WorkspaceState {
if (windowId === MAP_WINDOW_ID) {
return { ...state, map: update(state.map) };
}
return {
...state,
analyses: state.analyses.map((window) => window.id === windowId ? update(window) : window)
};
}
function mapAllWorkspaceWindows(
state: WorkspaceState,
update: <T extends MapWorkspaceWindow | AnalysisWorkspaceWindow>(window: T) => T
): WorkspaceState {
return {
...state,
map: update(state.map),
analyses: state.analyses.map((window) => update(window))
};
}
function allWorkspaceWindows(state: WorkspaceState) {
return [state.map, ...state.analyses] as Array<MapWorkspaceWindow | AnalysisWorkspaceWindow>;
}
function uniqueIds(ids: string[]) {
return [...new Set(ids)];
}
function getDefaultAnalysisRect(bounds: WorkspaceBounds, index: number): WorkspaceRect {
const width = Math.min(Math.max(bounds.width * 0.8, 640), Math.max(640, bounds.width - 48));
const height = Math.min(Math.max(bounds.height * 0.84, 420), Math.max(420, bounds.height - 48));