feat(workbench): dock task and chart workspaces
This commit is contained in:
@@ -11,6 +11,7 @@ import type { InvestigationChartGroup } from "../utils/investigation-chart-group
|
||||
import { presentationChartBlocks } from "../utils/presentation-chart-blocks";
|
||||
import { InvestigationChartGroup as ChartGroup } from "./investigation-chart-group";
|
||||
import { ChartDropSlot, ManagedChartSlot } from "./investigation-chart-slot";
|
||||
import { useChartWorkspace } from "../workspace/chart-workspace-context";
|
||||
|
||||
type ManagedChart = { id: string; title: string } & ({ group: InvestigationChartGroup; block?: never } | { block: AnswerUiBlock; group?: never });
|
||||
const layouts = [{ id: "one", label: "单图布局", icon: Square }, { id: "two", label: "左右布局", icon: Columns2 }, { id: "four", label: "四宫格布局", icon: LayoutGrid }] as const;
|
||||
@@ -25,6 +26,7 @@ export function InvestigationChartManager({ groups, blocks, autoOpen, taskTitle,
|
||||
onCloseChart: (id: string) => void;
|
||||
references: (ids: string[]) => ReactNode;
|
||||
}) {
|
||||
const dock = useChartWorkspace();
|
||||
const entries = useMemo<ManagedChart[]>(() => [
|
||||
...groups.map((group) => ({ id: `command:${group.id}`, title: formatChartDisplayText(group.title), group })),
|
||||
...presentationChartBlocks(blocks).map((block) => ({ id: `report:${block.block_id}`, title: formatChartDisplayText(String(block.spec.elements[block.spec.root].props.title)), block }))
|
||||
@@ -54,13 +56,13 @@ export function InvestigationChartManager({ groups, blocks, autoOpen, taskTitle,
|
||||
const count = entries.length;
|
||||
return <span className="inline-flex shrink-0 [&>button]:w-8 [&>button]:px-0 @min-[340px]:[&>button]:w-auto @min-[340px]:[&>button]:px-2">
|
||||
<FloatingWindow title="图表管理" navigationTitle={`${taskTitle} · 图表管理`} triggerLabel="打开图表管理" triggerContent={<span className="hidden @min-[340px]:inline">图表窗口 <span className="tabular-nums">{count}</span></span>}
|
||||
initialSize={{ width: 1120, height: 760 }} fillContent
|
||||
initialSize={{ width: 1120, height: 760 }} fillContent dock={dock}
|
||||
// A session gets one automatic request. Updates never revoke a user's dismissal.
|
||||
openRequestKey={autoOpen ? "live-charts" : undefined}>
|
||||
{() => <section ref={arrangement.containerRef} aria-label="全部图表" className="@container flex h-full min-h-0 min-w-0 flex-col gap-2">
|
||||
<div className="shrink-0 space-y-2 border-b border-border pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{taskTitle}</p><p className="mt-1 text-xs text-muted-foreground">{reviewing ? "历史回看" : "当前结果"} · 拖到固定位置交换,支持单图放大</p></div>
|
||||
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{taskTitle}</p><p className="mt-1 text-xs text-muted-foreground">{reviewing ? "历史回看" : "当前结果"} · 拖动交换位置,支持单图放大</p></div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{count} 张图表</span>
|
||||
</div>
|
||||
<div role="toolbar" aria-label="图表管理工具" className="flex min-w-0 flex-wrap items-center gap-1 rounded-lg bg-muted/50 p-1">
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
History,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
RefreshCw,
|
||||
X,
|
||||
XCircle
|
||||
@@ -61,6 +63,7 @@ const CONDITION_CONTROL_SURFACE_CLASS_NAME =
|
||||
const MINIMUM_REFRESH_FEEDBACK_MS = 3_000;
|
||||
|
||||
export type ScheduledConditionFeedPresentation =
|
||||
| "workspace"
|
||||
| "desktop-dock"
|
||||
| "desktop-floating"
|
||||
| "mobile-sheet";
|
||||
@@ -82,6 +85,7 @@ type ScheduledConditionFeedProps = {
|
||||
onSelectedConditionChange?: (conditionId: string | null) => void;
|
||||
onExpandAgent: () => void;
|
||||
onClose?: () => void;
|
||||
reserveAgentRail?: boolean;
|
||||
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
|
||||
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
||||
onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>;
|
||||
@@ -124,6 +128,7 @@ export function ScheduledConditionFeed({
|
||||
onSelectedConditionChange,
|
||||
onExpandAgent,
|
||||
onClose,
|
||||
reserveAgentRail = false,
|
||||
onLoadHistorySession,
|
||||
onSubmitPrompt,
|
||||
onOpenTaskCollaboration,
|
||||
@@ -160,8 +165,10 @@ export function ScheduledConditionFeed({
|
||||
const desktopDock = presentation === "desktop-dock";
|
||||
const compactPresentation = mobileSheet || desktopDock;
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const workspace = presentation === "workspace";
|
||||
const expanded = controlledExpanded ?? uncontrolledExpanded;
|
||||
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
|
||||
const selectedConditionId = controlledSelectedConditionId === undefined
|
||||
? uncontrolledSelectedConditionId : controlledSelectedConditionId;
|
||||
const conditionsByCategory = useMemo(
|
||||
() => groupConditionsByTaskCategory(conditions),
|
||||
[conditions]
|
||||
@@ -219,17 +226,14 @@ export function ScheduledConditionFeed({
|
||||
: `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`;
|
||||
const selectedCondition =
|
||||
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
|
||||
timelineConditions[0] ??
|
||||
null;
|
||||
const showCompactDetail = expanded && Boolean(selectedCondition);
|
||||
const focusConditionId = focusRequest?.conditionId;
|
||||
const focusRequestId = focusRequest?.requestId;
|
||||
const [focusedAction, setFocusedAction] = useState<{ conditionId: string; actionId: string } | null>(null);
|
||||
const updateExpanded = useCallback(
|
||||
(nextExpanded: boolean) => {
|
||||
if (controlledExpanded === undefined) {
|
||||
setUncontrolledExpanded(nextExpanded);
|
||||
}
|
||||
|
||||
if (controlledExpanded === undefined) setUncontrolledExpanded(nextExpanded);
|
||||
onExpandedChange?.(nextExpanded);
|
||||
},
|
||||
[controlledExpanded, onExpandedChange]
|
||||
@@ -256,10 +260,7 @@ export function ScheduledConditionFeed({
|
||||
}, [taskFilter, taskFilterOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expanded) return;
|
||||
// A controlled task selection survives panel remounts and detail-loading windows.
|
||||
const selected = conditions.find((condition) => condition.id === controlledSelectedConditionId);
|
||||
if (selected) {
|
||||
@@ -308,9 +309,6 @@ export function ScheduledConditionFeed({
|
||||
|
||||
function handleToggleExpanded() {
|
||||
updateExpanded(!expanded);
|
||||
updateSelectedConditionId(
|
||||
selectedConditionId ?? timelineConditions[0]?.id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) {
|
||||
@@ -318,9 +316,7 @@ export function ScheduledConditionFeed({
|
||||
setTaskFilter(CONDITION_FILTER_ALL);
|
||||
setStatusFilter(CONDITION_FILTER_ALL);
|
||||
setRetainedScheduledCondition(null);
|
||||
updateSelectedConditionId(
|
||||
sortConditionsNewestFirst(conditionsByCategory[nextCategory])[0]?.id ?? null
|
||||
);
|
||||
updateSelectedConditionId(null);
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
@@ -360,9 +356,7 @@ export function ScheduledConditionFeed({
|
||||
);
|
||||
}
|
||||
updateSelectedConditionId(conditionId);
|
||||
if (!expanded) {
|
||||
updateExpanded(true);
|
||||
}
|
||||
if (!expanded) updateExpanded(true);
|
||||
}
|
||||
|
||||
function handleContinueConversation(condition: ScheduledConditionItem, discussion?: InvestigationDiscussion) {
|
||||
@@ -427,22 +421,20 @@ export function ScheduledConditionFeed({
|
||||
<section
|
||||
aria-label="工况任务"
|
||||
data-task-category={taskCategory}
|
||||
data-presentation={presentation}
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
|
||||
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
|
||||
mobileSheet
|
||||
workspace
|
||||
? "surface-dock flex h-full w-full min-w-0 flex-col border-l border-[var(--surface-divider)] pb-16"
|
||||
: mobileSheet
|
||||
? "scheduled-feed-mobile flex h-full w-full max-w-none flex-col border-0 bg-transparent shadow-none"
|
||||
: cn(
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
"max-w-[calc(100vw-7rem)]",
|
||||
desktopFloating
|
||||
? cn(
|
||||
"rounded-2xl",
|
||||
expanded
|
||||
? "h-[calc(100dvh-8rem)]"
|
||||
: "max-h-[calc(100dvh-8rem)]"
|
||||
)
|
||||
? cn("rounded-2xl", expanded ? "h-[calc(100dvh-8rem)]" : "max-h-[calc(100dvh-8rem)]")
|
||||
: "h-full rounded-l-2xl rounded-r-none",
|
||||
expanded && !desktopDock
|
||||
? "scheduled-feed-panel-expanded flex flex-col"
|
||||
@@ -453,7 +445,8 @@ export function ScheduledConditionFeed({
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-2.5 py-2",
|
||||
CONDITION_CONTROL_SURFACE_CLASS_NAME
|
||||
CONDITION_CONTROL_SURFACE_CLASS_NAME,
|
||||
workspace && reserveAgentRail && "pl-20"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -486,18 +479,13 @@ export function ScheduledConditionFeed({
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
data-condition-dock-toggle
|
||||
aria-expanded={expanded}
|
||||
title={expanded ? "收起工况任务" : "展开工况任务"}
|
||||
onClick={handleToggleExpanded}
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center text-[var(--ink-muted)] transition hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]",
|
||||
"rounded-lg"
|
||||
)}
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-[var(--ink-muted)] transition-colors hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)] active:scale-95 focus-visible:outline-2 focus-visible:outline-[var(--action-blue)]"
|
||||
>
|
||||
<ChevronDown
|
||||
size={17}
|
||||
aria-hidden="true"
|
||||
className={cn("transition-transform duration-150", expanded && "rotate-180")}
|
||||
/>
|
||||
{expanded ? <Minimize2 size={17} aria-hidden="true" /> : <Maximize2 size={17} aria-hidden="true" />}
|
||||
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
|
||||
</button>
|
||||
{mobileSheet && onClose ? (
|
||||
@@ -513,8 +501,7 @@ export function ScheduledConditionFeed({
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{expanded ? (
|
||||
<TaskCategorySwitch
|
||||
{expanded ? <TaskCategorySwitch
|
||||
activeCategory={taskCategory}
|
||||
counts={{
|
||||
scheduled: conditionsByCategory.scheduled.length,
|
||||
@@ -522,29 +509,22 @@ export function ScheduledConditionFeed({
|
||||
appointment: conditionsByCategory.appointment.length
|
||||
}}
|
||||
onChange={handleTaskCategoryChange}
|
||||
/>
|
||||
) : null}
|
||||
/> : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||
compactPresentation
|
||||
? "flex flex-1"
|
||||
: expanded
|
||||
? "scheduled-feed-layout-expanded flex-1"
|
||||
: "scheduled-feed-layout-collapsed"
|
||||
: expanded ? "scheduled-feed-layout-expanded flex-1" : "scheduled-feed-layout-collapsed"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 overflow-hidden",
|
||||
compactPresentation
|
||||
? expanded
|
||||
? "scheduled-feed-detail-enter h-full w-full"
|
||||
: "hidden"
|
||||
: expanded
|
||||
? "scheduled-feed-detail-enter h-full"
|
||||
: "pointer-events-none opacity-0"
|
||||
? showCompactDetail ? "scheduled-feed-detail-enter h-full w-full" : "hidden"
|
||||
: expanded ? "scheduled-feed-detail-enter h-full" : "pointer-events-none opacity-0"
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
@@ -571,12 +551,19 @@ export function ScheduledConditionFeed({
|
||||
onReportViewed={onReportViewed}
|
||||
focusActionId={focusedAction?.conditionId === selectedConditionId ? focusedAction.actionId : undefined}
|
||||
/>
|
||||
) : expanded && !compactPresentation ? (
|
||||
<div className={cn("flex h-full items-center justify-center p-6 text-center", CONDITION_CONTENT_SURFACE_CLASS_NAME)}>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">请选择工况任务</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-muted)]">从右侧列表选择条目后查看详情。</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden",
|
||||
compactPresentation && (expanded ? "hidden" : "h-full w-full")
|
||||
compactPresentation && (showCompactDetail ? "hidden" : "h-full w-full")
|
||||
)}
|
||||
>
|
||||
<ConditionTimelinePanel
|
||||
@@ -720,9 +707,7 @@ function ConditionTimelinePanel({
|
||||
"flex min-h-0 flex-col overflow-hidden p-2.5",
|
||||
presentation === "mobile-sheet"
|
||||
? "h-full max-h-none"
|
||||
: expanded
|
||||
? "h-full max-h-none"
|
||||
: "h-[420px] max-h-[420px]",
|
||||
: expanded ? "h-full max-h-none" : "h-[420px] max-h-[420px]",
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/** Move the live panel without resetting filters, report state or pending input. */
|
||||
export function ScheduledConditionWorkspace({
|
||||
docked, dockTarget, hidden, children
|
||||
}: {
|
||||
docked: boolean;
|
||||
dockTarget: RefObject<HTMLDivElement | null>;
|
||||
hidden: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const floating = useRef<HTMLDivElement>(null);
|
||||
const restoreFocus = useRef(false);
|
||||
const [host] = useState(() => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "h-full min-h-0 min-w-0";
|
||||
return element;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const target = docked ? dockTarget.current : floating.current;
|
||||
target?.append(host);
|
||||
// Moving the host can drop keyboard focus. Restore the corresponding control.
|
||||
if (restoreFocus.current && !hidden) host.querySelector<HTMLButtonElement>("[data-condition-dock-toggle]")?.focus({ preventScroll: true });
|
||||
return () => {
|
||||
restoreFocus.current = host.contains(document.activeElement);
|
||||
host.remove();
|
||||
};
|
||||
}, [dockTarget, docked, hidden, host]);
|
||||
|
||||
return <>
|
||||
<div ref={floating}
|
||||
className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]"
|
||||
style={hidden || docked ? { display: "none" } : undefined} />
|
||||
{createPortal(children, host)}
|
||||
</>;
|
||||
}
|
||||
Reference in New Issue
Block a user