feat: add adaptive agent workspace windows

This commit is contained in:
2026-08-19 12:57:37 +08:00
parent d08cf2abc1
commit f58e0a70f4
18 changed files with 1797 additions and 309 deletions
@@ -0,0 +1,360 @@
export const MAX_ANALYSIS_WINDOWS = 6;
export type WorkspaceRect = {
x: number;
y: number;
width: number;
height: number;
};
export type WorkspaceBounds = {
width: number;
height: number;
};
export type WorkspaceWindowMode = "docked" | "floating" | "maximized" | "minimized";
export type AnalysisMetric = {
label: string;
value: string;
detail: string;
tone: "neutral" | "info" | "success" | "warning" | "danger";
};
export type AnalysisChartSeries = {
name: string;
values: number[];
color: string;
type?: "line" | "bar";
};
export type AnalysisBlock =
| {
id: string;
kind: "metric-grid";
title: string;
span: 12;
metrics: AnalysisMetric[];
}
| {
id: string;
kind: "chart";
title: string;
description: string;
span: 5 | 7 | 12;
chartType: "line" | "bar";
categories: string[];
unit: string;
series: AnalysisChartSeries[];
}
| {
id: string;
kind: "process-flow";
title: string;
description: string;
span: 5 | 7 | 12;
nodes: Array<{
id: string;
label: string;
detail: string;
status: "complete" | "active" | "pending";
}>;
}
| {
id: string;
kind: "data-table";
title: string;
description: string;
span: 5 | 7 | 12;
columns: Array<{ key: string; label: string; numeric?: boolean }>;
rows: Array<Record<string, string | number>>;
}
| {
id: string;
kind: "narrative";
title: string;
span: 5 | 7 | 12;
paragraphs: string[];
};
export type AnalysisDocument = {
id: string;
title: string;
subtitle: string;
generatedAt: string;
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;
mode: WorkspaceWindowMode;
rect: WorkspaceRect;
restoreRect: WorkspaceRect;
zIndex: number;
};
export type WorkspaceState = {
map: MapWorkspaceWindow;
analyses: AnalysisWorkspaceWindow[];
activeWindowId: string;
nextZIndex: number;
};
const FALLBACK_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
export function createInitialWorkspaceState(bounds: WorkspaceBounds = FALLBACK_BOUNDS): WorkspaceState {
const mapRect = getDefaultMapRect(bounds);
return {
map: {
id: "workspace-map",
kind: "map",
title: "供水管网地图",
mode: "docked",
rect: mapRect,
restoreRect: mapRect,
zIndex: 1
},
analyses: [],
activeWindowId: "workspace-map",
nextZIndex: 2
};
}
export function openAnalysisDocument(
state: WorkspaceState,
document: AnalysisDocument,
bounds: WorkspaceBounds
): WorkspaceState {
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) {
return state;
}
const rect = getDefaultAnalysisRect(bounds, state.analyses.length);
const window: AnalysisWorkspaceWindow = {
id: document.id,
kind: "agent-analysis",
title: document.title,
mode: "floating",
rect,
restoreRect: rect,
zIndex: state.nextZIndex,
document
};
return {
map: { ...state.map, mode: "minimized" },
analyses: [...state.analyses, window],
activeWindowId: window.id,
nextZIndex: state.nextZIndex + 1
};
}
export function closeAnalysisWindow(state: WorkspaceState, windowId: string): WorkspaceState {
const analyses = state.analyses.filter((window) => window.id !== windowId);
if (analyses.length === state.analyses.length) {
return state;
}
if (analyses.length === 0) {
return {
...state,
map: { ...state.map, mode: "docked", zIndex: state.nextZIndex },
analyses,
activeWindowId: state.map.id,
nextZIndex: state.nextZIndex + 1
};
}
const nextActive = [...analyses].sort((a, b) => b.zIndex - a.zIndex)[0];
return {
...state,
analyses,
activeWindowId: nextActive?.id ?? state.map.id
};
}
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
};
}
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 maximizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (windowId === state.map.id) {
return focusWorkspaceWindow(
{ ...state, map: { ...state.map, mode: "maximized" } },
windowId
);
}
return focusWorkspaceWindow(
{
...state,
analyses: state.analyses.map((window) =>
window.id === windowId ? { ...window, mode: "maximized" } : window
)
},
windowId
);
}
export function restoreWorkspaceWindow(
state: WorkspaceState,
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
);
}
return focusWorkspaceWindow(
{
...state,
analyses: state.analyses.map((window) =>
window.id === windowId
? {
...window,
mode: "floating",
rect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
}
: window
)
},
windowId
);
}
export function updateWorkspaceWindowRect(
state: WorkspaceState,
windowId: string,
rect: WorkspaceRect,
bounds: WorkspaceBounds
): WorkspaceState {
if (windowId === state.map.id) {
const nextRect = clampWorkspaceRect(rect, bounds, "map");
return {
...state,
map: { ...state.map, mode: "floating", rect: nextRect, restoreRect: nextRect }
};
}
return {
...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 };
})
};
}
export function resizeWorkspace(state: WorkspaceState, bounds: WorkspaceBounds): WorkspaceState {
return {
...state,
map: {
...state.map,
rect: clampWorkspaceRect(state.map.rect, bounds, "map"),
restoreRect: clampWorkspaceRect(state.map.restoreRect, bounds, "map")
},
analyses: state.analyses.map((window) => ({
...window,
rect: clampWorkspaceRect(window.rect, bounds, "analysis"),
restoreRect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
}))
};
}
export function clampWorkspaceRect(
rect: WorkspaceRect,
bounds: WorkspaceBounds,
kind: "map" | "analysis"
): WorkspaceRect {
const minimumWidth = kind === "map" ? 360 : 640;
const minimumHeight = kind === "map" ? 280 : 420;
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);
const height = clamp(rect.height, Math.min(minimumHeight, availableHeight), availableHeight);
return {
x: clamp(rect.x, 12, Math.max(12, bounds.width - width - 12)),
y: clamp(rect.y, 12, Math.max(12, bounds.height - height - 12)),
width,
height
};
}
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));
const cascade = (index % 5) * 28;
return clampWorkspaceRect(
{ x: 24 + cascade, y: 20 + cascade, width, height },
bounds,
"analysis"
);
}
function getDefaultMapRect(bounds: WorkspaceBounds): WorkspaceRect {
return clampWorkspaceRect(
{
x: Math.max(12, bounds.width * 0.36),
y: Math.max(12, bounds.height * 0.18),
width: bounds.width * 0.6,
height: bounds.height * 0.72
},
bounds,
"map"
);
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}