feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
+8 -5
View File
@@ -29,10 +29,13 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
overlayClassName?: string
showCloseButton?: boolean
}
>(({ className, children, overlayClassName, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
className={cn(
@@ -42,10 +45,10 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
{showCloseButton ? <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Close> : null}
</DialogPrimitive.Content>
</DialogPortal>
))
+3 -1
View File
@@ -17,6 +17,7 @@ export interface FieldSelectProps {
placeholder?: string;
disabled?: boolean;
className?: string;
contentClassName?: string;
id?: string;
}
@@ -28,6 +29,7 @@ export function FieldSelect({
placeholder = "请选择",
disabled,
className,
contentClassName,
id
}: FieldSelectProps) {
const generatedId = useId();
@@ -53,7 +55,7 @@ export function FieldSelect({
<SelectContent
position="popper"
sideOffset={4}
className="z-[90] max-w-[calc(100vw-1.5rem)]"
className={cn("z-[90] max-w-[calc(100vw-1.5rem)]", contentClassName)}
>
{selectableOptions.map((option) => (
<SelectItem
@@ -0,0 +1,13 @@
import { createContext, useContext } from "react";
export type ReadingDocument = { id: string; title: string; host: HTMLDivElement };
export type ReadingWorkspace = {
active: ReadingDocument | null;
view: "focus" | "map_split" | "map_only";
open: (document: ReadingDocument) => void;
close: (id?: string) => void;
setView: (view: ReadingWorkspace["view"]) => void;
};
export const ReadingWorkspaceContext = createContext<ReadingWorkspace | null>(null);
export const useReadingWorkspace = () => useContext(ReadingWorkspaceContext);
+79
View File
@@ -0,0 +1,79 @@
import {
useCallback, useEffect, useId, useLayoutEffect,
useRef, useState, type ReactNode
} from "react";
import { createPortal } from "react-dom";
import { Maximize2 } from "lucide-react";
import { Button } from "./button";
import { ReadingWorkspaceContext as Context, useReadingWorkspace, type ReadingDocument, type ReadingWorkspace } from "./reading-workspace-context";
export function ReadingWorkspaceProvider({ children }: { children: ReactNode }) {
const [active, setActive] = useState<ReadingDocument | null>(null);
const [view, setView] = useState<ReadingWorkspace["view"]>("focus");
const open = useCallback((document: ReadingDocument) => { setActive(document); setView("focus"); }, []);
const close = useCallback((id?: string) => {
setActive((current) => !id || current?.id === id ? null : current);
}, []);
useEffect(() => {
if (!active) return;
const escape = (event: KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); close(); }
};
window.addEventListener("keydown", escape);
return () => window.removeEventListener("keydown", escape);
}, [active, close]);
return <Context.Provider value={{ active, view, open, close, setView }}>{children}</Context.Provider>;
}
/** One live subtree moves between the conversation/report and the workspace. */
export function ExpandableReading({
title, children, summary, enabled = true
}: { title: string; children: ReactNode; summary?: ReactNode; enabled?: boolean }) {
const workspace = useReadingWorkspace();
const id = useId();
const [host] = useState(() => document.createElement("div"));
const inline = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const wasActive = useRef(false);
const expanded = workspace?.active?.id === id;
const close = workspace?.close;
useLayoutEffect(() => {
if (!expanded && inline.current && host.parentElement !== inline.current) inline.current.append(host);
if (wasActive.current && !expanded) trigger.current?.focus({ preventScroll: true });
wasActive.current = expanded;
}, [expanded, host, enabled]);
useEffect(() => () => close?.(id), [close, id]);
if (!workspace || !enabled) return <>{children}</>;
return <section className="min-w-0" data-expandable-reading>
<div className="mb-2 flex items-center justify-end">
<Button ref={trigger} size="sm" variant="ghost" className="h-9 gap-1.5 text-xs text-muted-foreground"
onClick={() => workspace.open({ id, title, host })}>
<Maximize2 size={14} aria-hidden />{expanded ? "返回展开内容" : "展开查看"}
</Button>
</div>
{expanded ? <div className="rounded-lg border bg-muted/30 p-3 text-xs leading-6 text-muted-foreground">
{summary ?? `${title}已在工作区展开。`}
</div> : null}
<div ref={inline} className={expanded ? "hidden" : "min-w-0"} />
{createPortal(children, host)}
</section>;
}
export function ReadingWorkspaceContent() {
const workspace = useReadingWorkspace();
const slot = useRef<HTMLDivElement>(null);
const host = workspace?.active?.host;
useLayoutEffect(() => {
if (!host || !slot.current) return;
slot.current.append(host);
host.tabIndex = -1;
host.style.outline = "none";
host.focus({ preventScroll: true });
const scroll = slot.current.closest<HTMLElement>("[data-reading-scroll]");
if (scroll) scroll.scrollTop = Number(host.dataset.readingScroll ?? 0);
return () => { if (scroll) host.dataset.readingScroll = String(scroll.scrollTop); };
}, [host]);
return <div ref={slot} className="min-w-0" />;
}
+46
View File
@@ -0,0 +1,46 @@
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "./cn";
// shadcn/ui composition, adapted to this Vite application's paths and tokens.
export function ResizablePanelGroup({ className, ...props }: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full min-h-0 min-w-0 aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
);
}
export function ResizablePanel(props: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
export function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"resizable-handle relative flex w-px items-center justify-center bg-border focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full",
className
)}
{...props}
>
{withHandle ? (
<span className="resizable-grip pointer-events-none z-10 flex h-8 w-3 shrink-0 items-center justify-center rounded border bg-background">
<GripVertical className="size-3" aria-hidden />
</span>
) : null}
</ResizablePrimitive.Separator>
);
}
+40 -24
View File
@@ -1,6 +1,7 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/shared/ui/cn"
@@ -67,34 +68,49 @@ SelectScrollDownButton.displayName =
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-select-content-transform-origin)",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
/** Opt into Motion; false keeps the same rendering without animation. */
animateContent?: boolean
}
>(({ className, children, position = "popper", animateContent, ...props }, ref) => {
const reducedMotion = useReducedMotion()
const useMotion = animateContent !== undefined
const instant = reducedMotion || !animateContent
// Radix Viewport owns scrolling; the outer surface only clips animated overflow.
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"p-1",
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md origin-(--radix-select-content-transform-origin)",
!useMotion && "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
<SelectScrollUpButton />
<SelectPrimitive.Viewport
asChild={useMotion}
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{useMotion ? <motion.div
initial={instant ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: instant ? 0 : 0.16, ease: [0.22, 1, 0.36, 1] }}
>{children}</motion.div> : children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
})
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
+70
View File
@@ -0,0 +1,70 @@
import type { ReactNode } from "react";
import { Check, Circle, LoaderCircle, RefreshCw, TriangleAlert } from "lucide-react";
import { Button } from "./button";
import { Card, CardContent, CardHeader } from "./card";
import { StatusIcon } from "./status";
export type StartupStep = {
id: string;
label: string;
detail: string;
state: "waiting" | "loading" | "complete";
};
type StartupScreenProps = {
brand: { name: string; description: string; icon: ReactNode };
title: string;
description: string;
progressLabel: string;
steps: StartupStep[];
error?: string | null;
retryLabel?: string;
onRetry?: () => void;
};
const stepTone = { waiting: "neutral", loading: "info", complete: "success" } as const;
const stepStateLabel = { waiting: "等待中", loading: "进行中", complete: "已完成" };
export function StartupScreen({ brand, title, description, progressLabel, steps, error, retryLabel = "重新加载", onRetry }: StartupScreenProps) {
return (
<main aria-label="应用启动" className="grid min-h-dvh place-items-center bg-[var(--surface-dock)] px-4 py-8 text-foreground">
<Card data-slot="startup-panel" className="surface-reading w-full max-w-md rounded-2xl border-[var(--surface-border)] shadow-none">
<CardHeader className="space-y-0 p-6 pb-0 sm:p-8 sm:pb-0">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="shrink-0 text-[var(--action-blue)] [&>svg]:size-5 [&>svg]:fill-[var(--action-blue)]" aria-hidden>{brand.icon}</span>
<span><span className="font-semibold text-foreground">{brand.name}</span><span className="mx-2 text-[var(--surface-border)]" aria-hidden>/</span>{brand.description}</span>
</div>
<div role="status" aria-live="polite" aria-atomic="true" className="pt-6">
<h1 className="text-xl font-semibold leading-7">{title}</h1>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{description}</p>
</div>
</CardHeader>
<CardContent className="p-6 pt-6 sm:p-8 sm:pt-6">
{error ? (
<>
<div role="alert" className="flex items-start gap-3 rounded-xl border border-border bg-[var(--surface-control)] p-3">
<StatusIcon tone="danger"><TriangleAlert size={14} /></StatusIcon>
<p className="min-w-0 flex-1 py-0.5 text-xs leading-5 text-foreground [overflow-wrap:anywhere]">{error}</p>
</div>
{onRetry ? <Button type="button" className="mt-6 h-10 w-full" onClick={onRetry}>
<RefreshCw size={14} aria-hidden />{retryLabel}
</Button> : null}
</>
) : (
<ol aria-label={progressLabel} className="divide-y divide-[var(--surface-divider)] rounded-xl border border-[var(--surface-divider)] bg-[var(--surface-control)] px-4">
{steps.map((step) => <li key={step.id} className="flex items-start gap-3 py-4" aria-current={step.state === "loading" ? "step" : undefined}>
<StatusIcon tone={stepTone[step.state]} activity={step.state === "loading" ? "loading" : "static"}>
{step.state === "loading" ? <LoaderCircle size={14} /> : step.state === "complete" ? <Check size={14} /> : <Circle size={12} />}
</StatusIcon>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium leading-6">{step.label}<span className="sr-only">,{stepStateLabel[step.state]}</span></p>
<p className="mt-0.5 whitespace-pre-line text-xs leading-5 text-muted-foreground [overflow-wrap:anywhere]">{step.detail}</p>
</div>
</li>)}
</ol>
)}
</CardContent>
</Card>
</main>
);
}
+78
View File
@@ -0,0 +1,78 @@
import {
forwardRef,
type HTMLAttributes,
type TableHTMLAttributes,
type ThHTMLAttributes,
type TdHTMLAttributes
} from "react";
import { cn } from "./cn";
const Table = forwardRef<HTMLTableElement, TableHTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<table
ref={ref}
className={cn("w-full caption-bottom border-collapse text-sm", className)}
{...props}
/>
)
);
Table.displayName = "Table";
const TableHeader = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
)
);
TableHeader.displayName = "TableHeader";
const TableBody = forwardRef<HTMLTableSectionElement, HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
)
);
TableBody.displayName = "TableBody";
const TableRow = forwardRef<HTMLTableRowElement, HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn("border-b border-border data-[state=selected]:bg-muted", className)}
{...props}
/>
)
);
TableRow.displayName = "TableRow";
const TableHead = forwardRef<HTMLTableCellElement, ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-3 text-left align-middle text-xs font-medium text-muted-foreground",
className
)}
{...props}
/>
)
);
TableHead.displayName = "TableHead";
const TableCell = forwardRef<HTMLTableCellElement, TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn("p-3 align-top text-xs tabular-nums", className)} {...props} />
)
);
TableCell.displayName = "TableCell";
const TableCaption = forwardRef<HTMLTableCaptionElement, HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("px-3 py-2 text-left text-xs text-muted-foreground", className)}
{...props}
/>
)
);
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption };
+52
View File
@@ -0,0 +1,52 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
import { cn } from "./cn";
const Tabs = TabsPrimitive.Root;
const TabsList = forwardRef<
ElementRef<typeof TabsPrimitive.List>,
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex min-h-10 items-center gap-1 rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = "TabsList";
const TabsTrigger = forwardRef<
ElementRef<typeof TabsPrimitive.Trigger>,
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex min-h-10 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-xs motion-safe:active:scale-[0.97]",
className
)}
{...props}
/>
));
TabsTrigger.displayName = "TabsTrigger";
const TabsContent = forwardRef<
ElementRef<typeof TabsPrimitive.Content>,
ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"min-w-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
className
)}
{...props}
/>
));
TabsContent.displayName = "TabsContent";
export { Tabs, TabsList, TabsTrigger, TabsContent };