From 688434b4251f31a92e4323605d583ae2032a5104 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Tue, 1 Sep 2026 07:27:12 +0000 Subject: [PATCH 01/13] feat(automations): align layout with Codex --- .../src/__tests__/pane-transition.test.ts | 4 +- apps/desktop/src/renderer/src/app.tsx | 11 +- .../src/automations/automations-view.tsx | 43 --- apps/desktop/src/renderer/src/index.css | 2 +- .../src/renderer/src/shell/desktop-shell.tsx | 22 +- .../renderer/src/shell/layout/workspace.tsx | 13 +- apps/webview/src/router.tsx | 7 +- apps/webview/src/routes/automations.tsx | 34 --- apps/webview/src/routes/workbench-route.tsx | 8 +- .../webview/src/shell/web-workbench-shell.tsx | 160 ++++++----- .../src/automations/automations-view.tsx | 271 ++++++++++++++++-- .../workbench/src/automations/loop/pane.tsx | 65 ++--- .../workbench/src/automations/pane-layout.tsx | 20 +- .../src/automations/schedule/pane.tsx | 65 ++--- .../client/workbench/src/automations/store.ts | 11 +- packages/presentation/i18n/src/locales/en.ts | 4 + .../presentation/i18n/src/locales/zh-cn.ts | 4 + packages/presentation/ui/src/shell/index.ts | 1 + .../ui/src/shell}/pane-transition.ts | 9 +- .../ui/src/shell/session-sidebar.tsx | 4 + .../presentation/ui/src/shell/shell-frame.tsx | 12 +- 21 files changed, 473 insertions(+), 297 deletions(-) delete mode 100644 apps/desktop/src/renderer/src/automations/automations-view.tsx delete mode 100644 apps/webview/src/routes/automations.tsx rename {apps/desktop/src/renderer/src/shell/layout => packages/presentation/ui/src/shell}/pane-transition.ts (95%) diff --git a/apps/desktop/src/renderer/src/__tests__/pane-transition.test.ts b/apps/desktop/src/renderer/src/__tests__/pane-transition.test.ts index 83c191a61..6e94adf16 100644 --- a/apps/desktop/src/renderer/src/__tests__/pane-transition.test.ts +++ b/apps/desktop/src/renderer/src/__tests__/pane-transition.test.ts @@ -1,5 +1,5 @@ -import type { SplitTransitionState } from '@renderer/shell/layout/pane-transition'; -import { reconcileTransition, settleTransition } from '@renderer/shell/layout/pane-transition'; +import type { SplitTransitionState } from '@linkcode/ui'; +import { reconcileTransition, settleTransition } from '@linkcode/ui'; import { describe, expect, it } from 'vitest'; function transition(overrides?: Partial): SplitTransitionState { diff --git a/apps/desktop/src/renderer/src/app.tsx b/apps/desktop/src/renderer/src/app.tsx index d9cf11f69..c245b36b1 100644 --- a/apps/desktop/src/renderer/src/app.tsx +++ b/apps/desktop/src/renderer/src/app.tsx @@ -13,7 +13,6 @@ import { import { useEffect } from 'foxact/use-abortable-effect'; import { useState } from 'react'; import useSWRImmutable from 'swr/immutable'; -import { DesktopAutomationsView } from './automations/automations-view'; import { cloudDataBridge } from './cloud-auth/bridges'; import { desktopDaemonConnectionSource } from './daemon-connection-source'; import { systemBridge } from './ipc'; @@ -32,7 +31,6 @@ const cloudImSource = cloudDataBridge.im; export function DesktopApp(): React.ReactNode { const localeOverride = useDesktopSettingsStore((state) => state.localeOverride); const settingsOpen = useNavigationHistoryStore((state) => state.overlay === 'settings'); - const automationsOpen = useNavigationHistoryStore((state) => state.overlay === 'automations'); return ( @@ -49,8 +47,6 @@ export function DesktopApp(): React.ReactNode { - {/* Gated: Automations lists schedules over the data plane, so it mounts inside the gate. */} - {automationsOpen ? : null} {/* Window controls live above the connection gate and the settings overlay so Windows/Linux can always minimize/maximize/close — including while the daemon is connecting or down. */} @@ -62,12 +58,11 @@ export function DesktopApp(): React.ReactNode { } /** - * Hides (never unmounts) the workbench-side layer while a full-page overlay (Settings, Automations) - * covers it: both shells are translucent over the native backdrop, so painted pixels underneath - * ghost through the overlay. `visibility` keeps layout/PTY state intact; `inert` blocks focus. + * Hides (never unmounts) the workbench-side layer while Settings covers it. Automations stays + * inside the shell so its app sidebar remains visible. */ function OverlayUnderlay({ children }: React.PropsWithChildren): React.ReactNode { - const overlayOpen = useNavigationHistoryStore((state) => state.overlay !== null); + const overlayOpen = useNavigationHistoryStore((state) => state.overlay === 'settings'); return (
{children} diff --git a/apps/desktop/src/renderer/src/automations/automations-view.tsx b/apps/desktop/src/renderer/src/automations/automations-view.tsx deleted file mode 100644 index 0f97c8598..000000000 --- a/apps/desktop/src/renderer/src/automations/automations-view.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { SessionId } from '@linkcode/schema'; -import { - AutomationsView, - useNavigationHistoryStore, - useSessionSelectionStore, -} from '@linkcode/workbench'; -import { Button } from 'coss-ui/components/button'; -import { ChevronLeftIcon } from 'lucide-react'; -import { useTranslations } from 'use-intl'; -import { DESKTOP_CHROME_SPACER_CLASS } from '../shell/chrome/metrics'; - -/** - * The Automations surface as a full-page desktop overlay (like Settings), raised over the workbench - * by the navigation store. Mounted inside the connection gate — it needs the daemon to list - * schedules. Opening a run's thread selects it and drops the overlay. - */ -export function DesktopAutomationsView(): React.ReactNode { - const t = useTranslations('workbench.automations'); - const backFromOverlay = useNavigationHistoryStore((state) => state.backFromOverlay); - const setOverlay = useNavigationHistoryStore((state) => state.setOverlay); - const setSelectedId = useSessionSelectionStore((state) => state.setSelectedId); - - const openThread = (sessionId: SessionId): void => { - setSelectedId(sessionId); - setOverlay(null); - }; - - return ( -
-
-
- - {t('title')} -
-
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/src/index.css b/apps/desktop/src/renderer/src/index.css index bd39615af..88b892d33 100644 --- a/apps/desktop/src/renderer/src/index.css +++ b/apps/desktop/src/renderer/src/index.css @@ -127,7 +127,7 @@ * subtree every frame.) DesktopShell sets one axis-specific animation attribute only while * that axis is in flight, so sash drags and resting window resizes keep tracking live. * Row and column durations are independent so a bottom toggle cannot animate unrelated chrome - * geometry. Keep duration/bezier in sync with SHELL_TRANSITION in pane-transition.ts. + * geometry. Keep duration/bezier in sync with @linkcode/ui's SHELL_TRANSITION. */ .linkcode-desktop-shell[data-shell-horizontal-animating] { --lc-shell-horizontal-duration: 300ms; diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index a15d80afa..ba25462bc 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -10,6 +10,7 @@ import { SessionSidebar, SessionTitleMenu, useKeyboardShortcutLabel, + usePaneTransition, } from '@linkcode/ui'; import { getChromeSurface, @@ -19,6 +20,7 @@ import { import type { WorkbenchShellProps } from '@linkcode/workbench'; import { AttachedTerminalPanel, + AutomationsView, getResourcesPanelPresentation, isAbsoluteFilePath, locateFileArtifact, @@ -29,6 +31,7 @@ import { TerminalPanel, useBrowserHostRegistration, useCloudHosts, + useNavigationHistoryStore, useResourcesPanelStore, useSelectedHostStore, WorkspaceServicesMenu, @@ -50,7 +53,6 @@ import { BrowserWebviewPane } from './browser/browser-webview-pane'; import { DesktopChrome } from './chrome/chrome'; import { DiffStatChip } from './chrome/diff-stat-chip'; import { DESKTOP_CHROME_SPACER_CLASS } from './chrome/metrics'; -import { usePaneTransition } from './layout/pane-transition'; import { DesktopPanelRegion } from './layout/panel-region'; import { DesktopRightPanelRegion } from './layout/right-panel-region'; import type { DesktopShellStyle } from './layout/shell-style'; @@ -172,6 +174,7 @@ export function DesktopShell({ })), ); const cloudAuth = useCloudAccount(); + const automationsOpen = useNavigationHistoryStore((state) => state.overlay === 'automations'); const resourcesOpen = useResourcesPanelStore((state) => state.open); const setResourcesOpen = useResourcesPanelStore((state) => state.setOpen); const toggleResources = useResourcesPanelStore((state) => state.toggle); @@ -297,13 +300,15 @@ export function DesktopShell({ const active = activeSession; const activeSessionId = active?.sessionId ?? null; - const resourcesAvailable = draft === null && active !== null && resourcesPanel !== undefined; + const resourcesAvailable = + !automationsOpen && draft === null && active !== null && resourcesPanel !== undefined; const resourcesPresentation = getResourcesPanelPresentation({ available: resourcesAvailable, floatingSpaceAvailable, rightPanelOpen: rightPanel.open, }); - const resourcesFloatingOpen = resourcesPresentation === 'floating' && resourcesOpen; + const resourcesFloatingOpen = + !automationsOpen && resourcesPresentation === 'floating' && resourcesOpen; const resourcesSurfaceOpen = (resourcesPresentation !== 'hidden' && resourcesOpen) || (rightPanel.open && rightPanel.activeSection === 'resources'); @@ -750,6 +755,16 @@ export function DesktopShell({
+
+
+ +
+
+ ) : undefined + } right={workspaceRight} bottom={workspaceBottom} expandedPanel={expandedPanel} @@ -795,6 +810,7 @@ export function DesktopShell({ onPickDirectory={pickDirectory} onOpenSearch={onOpenSearch} onOpenAutomations={onOpenAutomations} + automationsActive={automationsOpen} searchShortcut={searchShortcut} onRegisterWorkspace={onRegisterWorkspace} onImportHistory={onImportHistory} diff --git a/apps/desktop/src/renderer/src/shell/layout/workspace.tsx b/apps/desktop/src/renderer/src/shell/layout/workspace.tsx index 235b0789f..ece2f84c9 100644 --- a/apps/desktop/src/renderer/src/shell/layout/workspace.tsx +++ b/apps/desktop/src/renderer/src/shell/layout/workspace.tsx @@ -1,3 +1,4 @@ +import type { PaneTransition } from '@linkcode/ui'; import { cn } from '@linkcode/ui'; import type { LayoutState, PanelSide } from '@renderer/shell/store/model'; import { @@ -11,7 +12,6 @@ import { SIDEBAR_MIN_SIZE, } from '@renderer/shell/store/model'; import { useTranslations } from 'use-intl'; -import type { PaneTransition } from './pane-transition'; import { Sash } from './sash'; /** One dockable side of the workspace: its transition plus the docked and maximized-overlay nodes. */ @@ -44,6 +44,7 @@ const PANE_ID = { export function DesktopWorkspace({ sidebar, main, + workspaceOverlay, right, bottom, expandedPanel, @@ -55,6 +56,8 @@ export function DesktopWorkspace({ }: { sidebar: WorkspaceSidebar; main: React.ReactNode; + /** Covers every workspace pane except the persistent app sidebar. */ + workspaceOverlay?: React.ReactNode; right: WorkspaceSide; bottom: WorkspaceSide; expandedPanel: PanelSide | null; @@ -66,11 +69,12 @@ export function DesktopWorkspace({ onBottomResize: (size: number) => void; }): React.ReactNode { const tPanel = useTranslations('workbench.panel'); + const workspaceOverlayOpen = workspaceOverlay !== undefined; const rowOverlayPanel = getExpandedPanelForTarget(expandedPanel, 'editor-row'); const workbenchOverlayPanel = getExpandedPanelForTarget(expandedPanel, 'workbench'); // Expanded panels render as direct overlays. Docked panels stay mounted so // they keep owning chrome portals and panel state. - const dockedInert = workbenchOverlayPanel !== null; + const dockedInert = workbenchOverlayPanel !== null || workspaceOverlayOpen; const editorInert = rowOverlayPanel !== null || dockedInert; const anyAnimating = @@ -290,6 +294,11 @@ export function DesktopWorkspace({ {workbenchOverlayPanel === 'right' ? right.expandedNode : bottom.expandedNode} )} + {workspaceOverlayOpen ? ( +
+ {workspaceOverlay} +
+ ) : null}
); } diff --git a/apps/webview/src/router.tsx b/apps/webview/src/router.tsx index 4130d2953..0371d9cbd 100644 --- a/apps/webview/src/router.tsx +++ b/apps/webview/src/router.tsx @@ -1,4 +1,3 @@ -import { AutomationsRoute } from '@webview/routes/automations'; import { RootLayout } from '@webview/routes/root-layout'; import { AgentsSettings } from '@webview/routes/settings/agents'; import { AppearanceSettings } from '@webview/routes/settings/appearance'; @@ -22,8 +21,10 @@ export function createWebviewRouter( { element: , children: [ - { index: true, element: }, - { path: 'automations', element: }, + { + element: , + children: [{ index: true }, { path: 'automations' }], + }, { path: 'settings', element: , diff --git a/apps/webview/src/routes/automations.tsx b/apps/webview/src/routes/automations.tsx deleted file mode 100644 index 5c0956ce9..000000000 --- a/apps/webview/src/routes/automations.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { SessionId } from '@linkcode/schema'; -import { AutomationsView, useSessionSelectionStore } from '@linkcode/workbench'; -import { usePageTitle } from '@webview/hooks/use-page-title'; -import { Button } from 'coss-ui/components/button'; -import { ChevronLeftIcon } from 'lucide-react'; -import { Link, useNavigate } from 'react-router'; -import { useTranslations } from 'use-intl'; - -/** Full-page Automations surface (webview route). Opening a run's thread returns to the workbench. */ -export function AutomationsRoute(): React.ReactNode { - const t = useTranslations('workbench.automations'); - const navigate = useNavigate(); - usePageTitle(t('pageTitle')); - - const openThread = (sessionId: SessionId): void => { - useSessionSelectionStore.getState().setSelectedId(sessionId); - void navigate('/'); - }; - - return ( -
-
- - {t('title')} -
-
- -
-
- ); -} diff --git a/apps/webview/src/routes/workbench-route.tsx b/apps/webview/src/routes/workbench-route.tsx index fc551557b..25a997d11 100644 --- a/apps/webview/src/routes/workbench-route.tsx +++ b/apps/webview/src/routes/workbench-route.tsx @@ -2,15 +2,19 @@ import { useCommandPaletteStore, Workbench } from '@linkcode/workbench'; import { usePageTitle } from '@webview/hooks/use-page-title'; import { WebWorkbenchShell } from '@webview/shell/web-workbench-shell'; import { useEffect } from 'react'; -import { useNavigate } from 'react-router'; +import { useLocation, useNavigate } from 'react-router'; import { useTranslations } from 'use-intl'; /** Index route: the workbench surface (session / conversation / composer). */ export function WorkbenchRoute(): React.ReactNode { const navigate = useNavigate(); + const location = useLocation(); const t = useTranslations('workbench.palette'); const tWorkbench = useTranslations('workbench'); - usePageTitle(tWorkbench('pageTitle')); + const tAutomations = useTranslations('workbench.automations'); + usePageTitle( + location.pathname === '/automations' ? tAutomations('pageTitle') : tWorkbench('pageTitle'), + ); useEffect(() => { const { registerCommands, unregisterCommands } = useCommandPaletteStore.getState(); registerCommands('webview', [ diff --git a/apps/webview/src/shell/web-workbench-shell.tsx b/apps/webview/src/shell/web-workbench-shell.tsx index f05cf0701..4607c5d7d 100644 --- a/apps/webview/src/shell/web-workbench-shell.tsx +++ b/apps/webview/src/shell/web-workbench-shell.tsx @@ -1,6 +1,7 @@ import { ErrorBadge, ShellFrame, ShellIconButton, ThreadTitle, TitleStrip } from '@linkcode/ui'; import type { WorkbenchShellProps } from '@linkcode/workbench'; import { + AutomationsView, getResourcesPanelPresentation, RESOURCES_FLOATING_COLUMN_WIDTH, RESOURCES_FLOATING_MIN_WORKSPACE_WIDTH, @@ -13,7 +14,7 @@ import { Card } from 'coss-ui/components/card'; import { Popover, PopoverPopup, PopoverTrigger } from 'coss-ui/components/popover'; import { useMediaQuery } from 'coss-ui/hooks/use-media-query'; import { ChevronLeftIcon, ChevronRightIcon, Settings2Icon, SettingsIcon } from 'lucide-react'; -import { Link, useNavigate } from 'react-router'; +import { Link, useLocation, useNavigate } from 'react-router'; import { useTranslations } from 'use-intl'; const WEB_SIDEBAR_WIDTH = 288; @@ -27,6 +28,8 @@ export function WebWorkbenchShell({ const t = useTranslations('workbench.palette'); const tPanel = useTranslations('workbench.panel.window'); const navigate = useNavigate(); + const location = useLocation(); + const automationsOpen = location.pathname === '/automations'; const resourcesOpen = useResourcesPanelStore((state) => state.open); const setResourcesOpen = useResourcesPanelStore((state) => state.setOpen); const floatingSpaceAvailable = useMediaQuery({ @@ -58,6 +61,17 @@ export function WebWorkbenchShell({
{ + props.onSelectSession(sessionId); + void navigate('/'); + }} + /> + ) : undefined + } showPlanInPromptDock={!resourcesSurfaceOpen} onOpenProviderSettings={() => { useProvidersSettingsStore.getState().startAdd(); @@ -66,76 +80,86 @@ export function WebWorkbenchShell({ onOpenAutomations={() => { void navigate('/automations'); }} + onSelectSession={(sessionId) => { + props.onSelectSession(sessionId); + if (automationsOpen) void navigate('/'); + }} + onStartDraft={(workspaceId) => { + props.onStartDraft(workspaceId); + if (automationsOpen) void navigate('/'); + }} header={ - - - - - - - -
- {/* data-conversation-title is the browser-smoke E2E's header selector. */} - + - {header.title} - - {header.subtitle && ( -
{header.subtitle}
- )} -
- {/* The draft page reports errors through its own banner. */} - -
- {/* No in-app browser in the web client: preview links always open a new tab. */} - - {hasUsage && ( - - {header.usage?.inputTokens ?? 0} in / {header.usage?.outputTokens ?? 0} out - - )} - {resourcesAvailable && - (resourcesPresentation === 'popover' ? ( - - - -
- {resourcesPanel} -
-
-
- ) : ( - resourcesButton - ))} - -
-
+ + +
+ {/* data-conversation-title is the browser-smoke E2E's header selector. */} + + {header.title} + + {header.subtitle && ( +
{header.subtitle}
+ )} +
+ {/* The draft page reports errors through its own banner. */} + +
+ {/* No in-app browser in the web client: preview links always open a new tab. */} + + {hasUsage && ( + + {header.usage?.inputTokens ?? 0} in / {header.usage?.outputTokens ?? 0} out + + )} + {resourcesAvailable && + (resourcesPresentation === 'popover' ? ( + + + +
+ {resourcesPanel} +
+
+
+ ) : ( + resourcesButton + ))} + +
+ + ) } />
diff --git a/packages/client/workbench/src/automations/automations-view.tsx b/packages/client/workbench/src/automations/automations-view.tsx index a503d77b0..e6eddb946 100644 --- a/packages/client/workbench/src/automations/automations-view.tsx +++ b/packages/client/workbench/src/automations/automations-view.tsx @@ -1,14 +1,29 @@ -import type { SessionId } from '@linkcode/schema'; +import type { LoopId, ScheduleId, SessionId } from '@linkcode/schema'; +import { cn, SHELL_TRANSITION, usePaneTransition } from '@linkcode/ui'; import { Button } from 'coss-ui/components/button'; +import { InputGroup, InputGroupAddon, InputGroupInput } from 'coss-ui/components/input-group'; import { Tabs, TabsList, TabsTab } from 'coss-ui/components/tabs'; -import { PlusIcon } from 'lucide-react'; +import { useMediaQuery } from 'coss-ui/hooks/use-media-query'; +import { PlusIcon, SearchIcon, XIcon } from 'lucide-react'; +import { useState } from 'react'; import { useTranslations } from 'use-intl'; +import { LoopDetail } from './loop/detail'; +import { LoopForm } from './loop/form'; import { LoopPane } from './loop/pane'; +import { AutomationCreatePane } from './pane-layout'; +import { ScheduleDetail } from './schedule/detail'; +import { ScheduleForm } from './schedule/form'; import { SchedulePane } from './schedule/pane'; -import type { AutomationTab } from './store'; +import type { AutomationsPane, AutomationTab } from './store'; import { useAutomationsViewStore } from './store'; -/** The Automations management surface: a Schedules / Loops tab switcher over a master-detail pane. */ +type AutomationDetailTarget = + | { kind: 'create-schedule' } + | { kind: 'create-loop' } + | { kind: 'schedule'; scheduleId: ScheduleId } + | { kind: 'loop'; loopId: LoopId }; + +/** The Automations management surface: a compact index that expands into master-detail on demand. */ export function AutomationsView({ onOpenSession, }: { @@ -18,37 +33,235 @@ export function AutomationsView({ const tab = useAutomationsViewStore((state) => state.tab); const setTab = useAutomationsViewStore((state) => state.setTab); const view = useAutomationsViewStore((state) => state.view); + const selectedScheduleId = useAutomationsViewStore((state) => state.selectedScheduleId); + const selectedLoopId = useAutomationsViewStore((state) => state.selectedLoopId); const startCreate = useAutomationsViewStore((state) => state.startCreate); const startCreateLoop = useAutomationsViewStore((state) => state.startCreateLoop); + const collapse = useAutomationsViewStore((state) => state.collapse); + const [query, setQuery] = useState(''); + const creating = view.kind !== 'browse'; + const expanded = + creating || (tab === 'schedules' ? selectedScheduleId !== null : selectedLoopId !== null); + const splitLayout = useMediaQuery({ min: 1024 }); + const paneTransition = usePaneTransition({ open: expanded && splitLayout }); + const masterDetailVisible = splitLayout ? paneTransition.paneVisible : expanded; + const startCurrentCreate = tab === 'schedules' ? startCreate : startCreateLoop; + const createDisabled = view.kind === (tab === 'schedules' ? 'create-schedule' : 'create-loop'); + const createLabel = tab === 'schedules' ? t('schedule.new') : t('loop.new'); + const list = tab === 'schedules' ? : ; + const detailTarget = getAutomationDetailTarget({ + tab, + view, + selectedScheduleId, + selectedLoopId, + }); + const [renderedDetailTarget, setRenderedDetailTarget] = useState(detailTarget); + if ( + detailTarget !== null && + detailTargetIdentity(detailTarget) !== detailTargetIdentity(renderedDetailTarget) + ) { + setRenderedDetailTarget(detailTarget); + } + + let detail: React.ReactNode; + switch (renderedDetailTarget?.kind) { + case 'create-schedule': { + detail = ( + + + + ); + + break; + } + case 'create-loop': { + detail = ( + + + + ); + + break; + } + case 'schedule': { + detail = ( + + ); + + break; + } + case 'loop': { + detail = ; + + break; + } + default: { + detail = null; + } + } + + const handleTransitionRun = (event: React.TransitionEvent): void => { + if (event.target !== event.currentTarget || event.propertyName !== 'grid-template-columns') { + return; + } + paneTransition.rearmFallback(); + }; + const handleTransitionEnd = (event: React.TransitionEvent): void => { + if (event.target !== event.currentTarget || event.propertyName !== 'grid-template-columns') { + return; + } + paneTransition.settle(); + }; return ( -
-
-
- setTab(value as AutomationTab)}> - - {t('tabs.schedules')} - {t('tabs.loops')} - - - {tab === 'schedules' ? ( - - ) : ( - - )} -
- {tab === 'schedules' ? ( - +
+
+ {masterDetailVisible ? ( +
+
+ setTab(value as AutomationTab)}> + + {t('tabs.schedules')} + {t('tabs.loops')} + + + +
+ + {list} +
) : ( - +
+
+
+
+

{t('title')}

+

{t('description')}

+
+ +
+ +
+ setTab(value as AutomationTab)}> + + {t('tabs.schedules')} + {t('tabs.loops')} + + +
+
+ {list} +
+
+
)} -
+ + {masterDetailVisible ? ( +
+
+ +
+ {detail} +
+
+
+ ) : null}
); } + +function getAutomationDetailTarget({ + tab, + view, + selectedScheduleId, + selectedLoopId, +}: { + tab: AutomationTab; + view: AutomationsPane; + selectedScheduleId: ScheduleId | null; + selectedLoopId: LoopId | null; +}): AutomationDetailTarget | null { + if (view.kind === 'create-schedule' || view.kind === 'create-loop') return view; + if (tab === 'schedules' && selectedScheduleId !== null) { + return { kind: 'schedule', scheduleId: selectedScheduleId }; + } + if (tab === 'loops' && selectedLoopId !== null) return { kind: 'loop', loopId: selectedLoopId }; + return null; +} + +function detailTargetIdentity(target: AutomationDetailTarget | null): string | null { + if (target === null) return null; + if (target.kind === 'schedule') return `schedule:${target.scheduleId}`; + if (target.kind === 'loop') return `loop:${target.loopId}`; + return target.kind; +} + +function AutomationSearch({ + compact = false, + query, + onQueryChange, +}: { + compact?: boolean; + query: string; + onQueryChange: (query: string) => void; +}): React.ReactNode { + const t = useTranslations('workbench.automations'); + return ( + + + + + onQueryChange(event.currentTarget.value)} + /> + + ); +} diff --git a/packages/client/workbench/src/automations/loop/pane.tsx b/packages/client/workbench/src/automations/loop/pane.tsx index 01fb6648b..8c0e673bf 100644 --- a/packages/client/workbench/src/automations/loop/pane.tsx +++ b/packages/client/workbench/src/automations/loop/pane.tsx @@ -1,4 +1,4 @@ -import type { LoopStatus, SessionId } from '@linkcode/schema'; +import type { LoopStatus } from '@linkcode/schema'; import { Badge } from 'coss-ui/components/badge'; import { Button } from 'coss-ui/components/button'; import { @@ -10,14 +10,8 @@ import { } from 'coss-ui/components/empty'; import { PlusIcon, RepeatIcon } from 'lucide-react'; import { useTranslations } from 'use-intl'; -import { - AutomationCreatePane, - AutomationMasterButton, - AutomationPaneSkeleton, -} from '../pane-layout'; +import { AutomationMasterButton, AutomationPaneSkeleton } from '../pane-layout'; import { useAutomationsViewStore } from '../store'; -import { LoopDetail } from './detail'; -import { LoopForm } from './form'; import { useLoops } from './hooks'; import type { LoopListItem } from './items'; import { buildLoopItems } from './items'; @@ -29,30 +23,25 @@ const STATUS_BADGE: Record void; -}): React.ReactNode { +export function LoopPane({ query }: { query: string }): React.ReactNode { const t = useTranslations('workbench.automations'); const { data: loops, isLoading } = useLoops(); - const view = useAutomationsViewStore((state) => state.view); const selectedLoopId = useAutomationsViewStore((state) => state.selectedLoopId); const selectLoop = useAutomationsViewStore((state) => state.selectLoop); const startCreateLoop = useAutomationsViewStore((state) => state.startCreateLoop); - - if (view.kind === 'create-loop') { - return ( - - - - ); - } - - const items = buildLoopItems(loops); + const normalizedQuery = query.trim().toLowerCase(); + const allItems = buildLoopItems(loops); + const items = normalizedQuery + ? allItems.filter((item) => item.name.toLowerCase().includes(normalizedQuery)) + : allItems; if (items.length === 0) { if (isLoading) return ; + if (normalizedQuery) { + return ( +

{t('noMatches')}

+ ); + } return ( @@ -70,24 +59,18 @@ export function LoopPane({ ); } - const activeId = selectedLoopId ?? items[0].loopId; return ( -
-
    - {items.map((item) => ( -
  • - selectLoop(item.loopId)} - /> -
  • - ))} -
-
- -
-
+
    + {items.map((item) => ( +
  • + selectLoop(item.loopId)} + /> +
  • + ))} +
); } diff --git a/packages/client/workbench/src/automations/pane-layout.tsx b/packages/client/workbench/src/automations/pane-layout.tsx index 1ae86cc15..f01e7d65f 100644 --- a/packages/client/workbench/src/automations/pane-layout.tsx +++ b/packages/client/workbench/src/automations/pane-layout.tsx @@ -42,8 +42,10 @@ export function AutomationMasterButton({ return ( +
+ ); +} + +export function TaskFormError({ message }: { message?: string | null }): React.ReactNode { + return message ? ( +

+ {message} +

+ ) : null; +} From b4a7f03d3087b4e92b8b22f48d86ae0be23a8bcf Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Tue, 8 Sep 2026 23:55:01 +0800 Subject: [PATCH 03/13] feat(automations): model schedule drafts and repeat presets --- .../workbench/src/automations/defaults.ts | 33 ++++ .../src/automations/schedule/form-model.ts | 169 ++++++++++++++++++ .../automations/schedule/frequency-fields.tsx | 134 ++++++++++++++ .../src/automations/schedule/labels.ts | 18 +- 4 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 packages/client/workbench/src/automations/defaults.ts create mode 100644 packages/client/workbench/src/automations/schedule/form-model.ts create mode 100644 packages/client/workbench/src/automations/schedule/frequency-fields.tsx diff --git a/packages/client/workbench/src/automations/defaults.ts b/packages/client/workbench/src/automations/defaults.ts new file mode 100644 index 000000000..104f7939d --- /dev/null +++ b/packages/client/workbench/src/automations/defaults.ts @@ -0,0 +1,33 @@ +import { getProviderConfig } from '@linkcode/sdk'; +import { useAgentRuntimes } from '../agent-runtime/hooks'; +import { useData } from '../runtime/tayori'; +import { selectableHarnessKinds } from '../settings/providers/model-options'; +import { useNewSessionDefaultsStore } from '../surface/new-session-defaults-store'; +import { useWorkspaces } from '../workspace/hooks'; + +export function useAutomationDefaults() { + const { data: runtimes, error: runtimeError, mutate: refreshRuntimes } = useAgentRuntimes(); + const { + data: providers, + error: providerError, + mutate: refreshProviders, + } = useData(getProviderConfig, {}); + const { data: workspaces, error: workspaceError, mutate: refreshWorkspaces } = useWorkspaces(); + const lastHarness = useNewSessionDefaultsStore((state) => state.lastHarness); + const lastWorkspace = useNewSessionDefaultsStore((state) => state.lastWorkspaceId); + const kinds = providers + ? selectableHarnessKinds(providers).filter((kind) => runtimes?.[kind]?.status === 'available') + : []; + const kind = lastHarness && kinds.includes(lastHarness) ? lastHarness : kinds.at(0); + const workspaceMap = new Map(workspaces?.map((entry) => [entry.workspaceId, entry])); + const workspace = + (lastWorkspace ? workspaceMap.get(lastWorkspace) : undefined) ?? workspaces?.at(0); + return { + ready: runtimes !== undefined && providers !== undefined && workspaces !== undefined, + error: runtimeError ?? providerError ?? workspaceError, + kind, + kinds, + cwd: workspace?.cwd ?? '', + retry: () => Promise.all([refreshRuntimes(), refreshProviders(), refreshWorkspaces()]), + }; +} diff --git a/packages/client/workbench/src/automations/schedule/form-model.ts b/packages/client/workbench/src/automations/schedule/form-model.ts new file mode 100644 index 000000000..2c80c7617 --- /dev/null +++ b/packages/client/workbench/src/automations/schedule/form-model.ts @@ -0,0 +1,169 @@ +import type { Schedule, ScheduleSpec, ScheduleUpdate } from '@linkcode/schema'; +import { AgentKindSchema } from '@linkcode/schema'; +import { z } from 'zod'; + +const RE_INTEGER = /^\d+$/; +const RE_WEEKDAY = /^[0-6]$/; + +export const scheduleFormSchema = z + .object({ + name: z.string().trim(), + prompt: z.string().trim().min(1), + kind: AgentKindSchema, + cwd: z.string().trim(), + targetSession: z.string(), + cadenceKind: z.enum(['hourly', 'daily', 'weekdays', 'weekly', 'monthly', 'interval', 'cron']), + intervalMinutes: z.number().or(z.nan()), + hour: z.number().or(z.nan()), + minute: z.number().or(z.nan()), + weekday: z.number().or(z.nan()), + monthDay: z.number().or(z.nan()), + cronExpression: z.string().trim(), + timezone: z.string().trim(), + maxRuns: z.string().regex(/^$|^[1-9]\d*$/), + expiresAt: z.string(), + misfire: z.enum(['default', 'skip', 'catch-up']), + }) + .superRefine((draft, ctx) => { + const check = ( + field: 'intervalMinutes' | 'hour' | 'minute' | 'weekday' | 'monthDay', + min: number, + max: number, + ): void => { + const schema = + field === 'intervalMinutes' + ? z.number().min(min).max(max) + : z.number().int().min(min).max(max); + const result = schema.safeParse(draft[field]); + if (!result.success) { + for (let i = 0, len = result.error.issues.length; i < len; i++) { + const issue = result.error.issues[i]; + ctx.addIssue({ ...issue, path: [field] }); + } + } + }; + if (draft.cadenceKind === 'interval') { + check('intervalMinutes', 1, Number.MAX_SAFE_INTEGER / 60000); + } else if (draft.cadenceKind !== 'cron') { + check('minute', 0, 59); + if (draft.cadenceKind !== 'hourly') check('hour', 0, 23); + if (draft.cadenceKind === 'weekly') check('weekday', 0, 6); + if (draft.cadenceKind === 'monthly') check('monthDay', 1, 31); + } + if (!draft.targetSession && !draft.cwd) { + ctx.addIssue({ code: 'custom', path: ['cwd'], message: 'required' }); + } + if (draft.cadenceKind === 'cron' && !draft.cronExpression) { + ctx.addIssue({ code: 'custom', path: ['cronExpression'], message: 'required' }); + } + if (draft.expiresAt && !Number.isFinite(Date.parse(draft.expiresAt))) { + ctx.addIssue({ code: 'custom', path: ['expiresAt'], message: 'Invalid date' }); + } + }); +export type ScheduleFormDraft = z.infer; + +export function scheduleDraft(schedule?: Schedule): ScheduleFormDraft { + const cadence = schedule?.spec.cadence; + const config = + schedule?.spec.target.type === 'new-session' ? schedule.spec.target.config : undefined; + return { + name: schedule?.spec.name ?? '', + prompt: schedule?.spec.prompt ?? '', + kind: config?.kind ?? 'claude-code', + cwd: config?.cwd ?? '', + targetSession: schedule?.spec.target.type === 'session' ? schedule.spec.target.sessionId : '', + cadenceKind: cadence?.type ?? 'daily', + intervalMinutes: cadence?.type === 'interval' ? cadence.everyMs / 60000 : 60, + hour: 9, + minute: 0, + weekday: 1, + monthDay: 1, + cronExpression: cadence?.type === 'cron' ? cadence.expression : '', + timezone: + cadence?.type === 'cron' + ? (cadence.timezone ?? '') + : new Intl.DateTimeFormat().resolvedOptions().timeZone, + maxRuns: schedule?.spec.maxRuns?.toString() ?? '', + expiresAt: schedule?.spec.expiresAt ? localDateTime(schedule.spec.expiresAt) : '', + misfire: schedule?.spec.misfirePolicy ?? 'default', + ...(cadence?.type === 'cron' && recognizePreset(cadence.expression)), + }; +} + +function localDateTime(timestamp: number): string { + const date = new Date(timestamp); + return new Date(timestamp - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16); +} + +export function scheduleCadence(draft: ScheduleFormDraft): ScheduleSpec['cadence'] { + if (draft.cadenceKind === 'interval') { + return { type: 'interval', everyMs: draft.intervalMinutes * 60000 }; + } + const { hour, minute, weekday, monthDay } = draft; + const expressions = { + hourly: `${minute} * * * *`, + daily: `${minute} ${hour} * * *`, + weekdays: `${minute} ${hour} * * 1-5`, + weekly: `${minute} ${hour} * * ${weekday}`, + monthly: `${minute} ${hour} ${monthDay} * *`, + cron: draft.cronExpression, + }; + return { + type: 'cron', + expression: expressions[draft.cadenceKind], + ...(draft.timezone && { timezone: draft.timezone }), + }; +} + +export function schedulePatch( + draft: ScheduleFormDraft, + current: Schedule, + dirty: Partial>, +): ScheduleUpdate { + const cadence = scheduleCadence(draft); + const unchangedCadence = + cadence.type === current.spec.cadence.type && + (cadence.type === 'interval' && current.spec.cadence.type === 'interval' + ? cadence.everyMs === current.spec.cadence.everyMs + : cadence.type === 'cron' && + current.spec.cadence.type === 'cron' && + cadence.expression === current.spec.cadence.expression && + cadence.timezone === current.spec.cadence.timezone); + return { + ...(dirty.name && { name: draft.name || null }), + ...(dirty.prompt && { prompt: draft.prompt }), + ...(!unchangedCadence && + (dirty.cadenceKind || + dirty.hour || + dirty.minute || + dirty.weekday || + dirty.monthDay || + dirty.cronExpression || + dirty.timezone || + dirty.intervalMinutes) && { cadence }), + ...(dirty.maxRuns && { maxRuns: draft.maxRuns ? Number(draft.maxRuns) : null }), + ...(dirty.expiresAt && { expiresAt: draft.expiresAt ? Date.parse(draft.expiresAt) : null }), + ...(dirty.misfire && { misfirePolicy: draft.misfire === 'default' ? null : draft.misfire }), + }; +} + +export function recognizePreset(expression: string): Partial { + const parts = expression.split(' '); + if (parts.length !== 5) return {}; + const [minute, hour, day, month, weekday] = parts; + if (month !== '*' || !RE_INTEGER.test(minute)) return {}; + const time = { minute: Number(minute), hour: Number(hour) }; + if (hour === '*' && day === '*' && weekday === '*') { + return { cadenceKind: 'hourly', minute: Number(minute) }; + } + if (!RE_INTEGER.test(hour)) return {}; + if (day === '*' && weekday === '*') return { cadenceKind: 'daily', ...time }; + if (day === '*' && weekday === '1-5') return { cadenceKind: 'weekdays', ...time }; + if (day === '*' && RE_WEEKDAY.test(weekday)) { + return { cadenceKind: 'weekly', weekday: Number(weekday), ...time }; + } + if (weekday === '*' && RE_INTEGER.test(day)) { + return { cadenceKind: 'monthly', monthDay: Number(day), ...time }; + } + return {}; +} diff --git a/packages/client/workbench/src/automations/schedule/frequency-fields.tsx b/packages/client/workbench/src/automations/schedule/frequency-fields.tsx new file mode 100644 index 000000000..5240f0a81 --- /dev/null +++ b/packages/client/workbench/src/automations/schedule/frequency-fields.tsx @@ -0,0 +1,134 @@ +import { TaskSelect } from '@linkcode/ui'; +import { Field, FieldError, FieldLabel } from 'coss-ui/components/field'; +import { Input } from 'coss-ui/components/input'; +import type { Control, UseFormRegister } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; +import { useTranslations } from 'use-intl'; +import type { ScheduleFormDraft } from './form-model'; + +const MODES: Array = [ + 'hourly', + 'daily', + 'weekdays', + 'weekly', + 'monthly', + 'interval', + 'cron', +]; + +export function ScheduleFrequencyFields({ + control, + register, +}: { + control: Control; + register: UseFormRegister; +}): React.ReactNode { + const t = useTranslations('workbench.automations'); + return ( + ( + <> + + {t('schedule.cadenceLabel')} + ({ value, label: t(`schedule.${value}`) }))} + /> + + + {field.value === 'interval' ? ( + + {t('schedule.intervalMinutes')} + + + + ) : field.value === 'cron' ? ( + + {t('schedule.cron')} + + + + ) : ( +
+ {field.value === 'monthly' ? ( + + {t('schedule.monthDay')} + + + + ) : null} + {field.value === 'weekly' ? ( + + {t('schedule.weekday')} + ( + day.onChange(Number(value))} + items={[0, 1, 2, 3, 4, 5, 6].map((value) => ({ + value: String(value), + label: t(`schedule.weekdayNames.${value}`), + }))} + /> + )} + /> + + ) : null} + {field.value === 'hourly' ? null : ( + + {t('schedule.hour')} + + + + )} + + {t('schedule.minute')} + + + +
+ )} + {field.value === 'monthly' ? ( +

{t('schedule.shortMonth')}

+ ) : null} + {field.value === 'interval' ? null : ( + + {t('schedule.timezone')} + + + + )} + + )} + /> + ); +} diff --git a/packages/client/workbench/src/automations/schedule/labels.ts b/packages/client/workbench/src/automations/schedule/labels.ts index 6fb2c1793..ec5c38298 100644 --- a/packages/client/workbench/src/automations/schedule/labels.ts +++ b/packages/client/workbench/src/automations/schedule/labels.ts @@ -1,4 +1,5 @@ import type { ScheduleCadence } from '@linkcode/schema'; +import { recognizePreset } from './form-model'; /** Human cadence summary shared by the schedule list rows and detail facts. */ export function cadenceLabel( @@ -6,7 +7,20 @@ export function cadenceLabel( t: (key: string, values?: Record) => string, ): string { if (cadence.type === 'interval') { - return t('schedule.everyMinutes', { minutes: Math.round(cadence.everyMs / 60000) }); + return t('schedule.everyMinutes', { minutes: cadence.everyMs / 60000 }); } - return cadence.timezone ? `${cadence.expression} (${cadence.timezone})` : cadence.expression; + const preset = recognizePreset(cadence.expression); + let label = cadence.expression; + if (preset.cadenceKind) { + const time = `${String(preset.hour ?? 0).padStart(2, '0')}:${String(preset.minute ?? 0).padStart(2, '0')}`; + label = + preset.cadenceKind === 'hourly' + ? `${t('schedule.hourly')} · :${String(preset.minute ?? 0).padStart(2, '0')}` + : `${t(`schedule.${preset.cadenceKind}`)} · ${time}`; + if (preset.weekday !== undefined) label += ` · ${t(`schedule.weekdayNames.${preset.weekday}`)}`; + if (preset.monthDay !== undefined) { + label += ` · ${t('schedule.onDay', { day: preset.monthDay })}`; + } + } + return cadence.timezone ? `${label} (${cadence.timezone})` : label; } From 8039fc7eb57ee6f498174664d302cd0b218a9e87 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Tue, 8 Sep 2026 23:55:43 +0800 Subject: [PATCH 04/13] feat(automations): guard drafts and expose task actions --- .../webview/src/shell/web-workbench-shell.tsx | 35 +++- .../workbench/src/automations/actions.tsx | 184 ++++++++++++++++++ .../workbench/src/automations/draft-guard.tsx | 56 ++++++ packages/client/workbench/src/index.ts | 1 + .../client/workbench/src/navigation/store.ts | 9 + .../src/surface/use-workbench-sessions.ts | 13 ++ 6 files changed, 291 insertions(+), 7 deletions(-) create mode 100644 packages/client/workbench/src/automations/actions.tsx create mode 100644 packages/client/workbench/src/automations/draft-guard.tsx diff --git a/apps/webview/src/shell/web-workbench-shell.tsx b/apps/webview/src/shell/web-workbench-shell.tsx index 4607c5d7d..74aa73519 100644 --- a/apps/webview/src/shell/web-workbench-shell.tsx +++ b/apps/webview/src/shell/web-workbench-shell.tsx @@ -5,6 +5,7 @@ import { getResourcesPanelPresentation, RESOURCES_FLOATING_COLUMN_WIDTH, RESOURCES_FLOATING_MIN_WORKSPACE_WIDTH, + useAutomationDraftState, useProvidersSettingsStore, useResourcesPanelStore, WorkspaceServicesMenu, @@ -14,7 +15,8 @@ import { Card } from 'coss-ui/components/card'; import { Popover, PopoverPopup, PopoverTrigger } from 'coss-ui/components/popover'; import { useMediaQuery } from 'coss-ui/hooks/use-media-query'; import { ChevronLeftIcon, ChevronRightIcon, Settings2Icon, SettingsIcon } from 'lucide-react'; -import { Link, useLocation, useNavigate } from 'react-router'; +import { useEffect } from 'react'; +import { Link, useBlocker, useLocation, useNavigate } from 'react-router'; import { useTranslations } from 'use-intl'; const WEB_SIDEBAR_WIDTH = 288; @@ -30,6 +32,19 @@ export function WebWorkbenchShell({ const navigate = useNavigate(); const location = useLocation(); const automationsOpen = location.pathname === '/automations'; + const dirty = useAutomationDraftState((state) => state.dirty); + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + automationsOpen && dirty && currentLocation.pathname !== nextLocation.pathname, + ); + useEffect(() => { + if (blocker.state !== 'blocked') return; + useAutomationDraftState.getState().request(() => blocker.proceed()); + + return useAutomationDraftState.subscribe((state, previous) => { + if (previous.pending && !state.pending && state.dirty) blocker.reset(); + }); + }, [blocker]); const resourcesOpen = useResourcesPanelStore((state) => state.open); const setResourcesOpen = useResourcesPanelStore((state) => state.setOpen); const floatingSpaceAvailable = useMediaQuery({ @@ -66,8 +81,10 @@ export function WebWorkbenchShell({ automationsOpen ? ( { - props.onSelectSession(sessionId); - void navigate('/'); + useAutomationDraftState.getState().request(() => { + props.onSelectSession(sessionId); + void navigate('/'); + }); }} /> ) : undefined @@ -81,12 +98,16 @@ export function WebWorkbenchShell({ void navigate('/automations'); }} onSelectSession={(sessionId) => { - props.onSelectSession(sessionId); - if (automationsOpen) void navigate('/'); + useAutomationDraftState.getState().request(() => { + props.onSelectSession(sessionId); + if (automationsOpen) void navigate('/'); + }); }} onStartDraft={(workspaceId) => { - props.onStartDraft(workspaceId); - if (automationsOpen) void navigate('/'); + useAutomationDraftState.getState().request(() => { + props.onStartDraft(workspaceId); + if (automationsOpen) void navigate('/'); + }); }} header={ automationsOpen ? null : ( diff --git a/packages/client/workbench/src/automations/actions.tsx b/packages/client/workbench/src/automations/actions.tsx new file mode 100644 index 000000000..071cf94d3 --- /dev/null +++ b/packages/client/workbench/src/automations/actions.tsx @@ -0,0 +1,184 @@ +import type { LoopRecord, Schedule, SessionId } from '@linkcode/schema'; +import { + deleteLoop, + deleteSchedule, + pauseSchedule, + resumeSchedule, + runScheduleOnce, + stopLoop, +} from '@linkcode/sdk'; +import { + AlertDialog, + AlertDialogDescription, + AlertDialogPopup, + AlertDialogTitle, +} from 'coss-ui/components/alert-dialog'; +import { Button } from 'coss-ui/components/button'; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from 'coss-ui/components/menu'; +import { toastManager } from 'coss-ui/components/toast'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { EllipsisIcon } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslations } from 'use-intl'; +import { useMutation } from '../runtime/tayori'; +import { useAutomationDraftState } from './draft-state'; +import { useAutomationsViewStore } from './store'; + +export function AutomationActions({ + task, + sessionId, + onOpenSession, +}: { + task: Schedule | LoopRecord; + sessionId?: SessionId; + onOpenSession?: (id: SessionId) => void; +}): React.ReactNode { + const t = useTranslations('workbench.automations'); + const pause = useMutation(pauseSchedule); + const resume = useMutation(resumeSchedule); + const run = useMutation(runScheduleOnce); + const stop = useMutation(stopLoop); + const removeSchedule = useMutation(deleteSchedule); + const removeLoop = useMutation(deleteLoop); + const [confirmDelete, setConfirmDelete] = useState(false); + const [error, setError] = useState(null); + const [stopping, setStopping] = useState(false); + const [pending, setPending] = useState(false); + const schedule = 'scheduleId' in task ? task : null; + const loop = 'loopId' in task ? task : null; + const waitingForStop = stopping && loop?.status === 'running'; + + async function perform(action: () => Promise, deleted = false): Promise { + setPending(true); + setError(null); + try { + await action(); + if (deleted) { + setConfirmDelete(false); + const state = useAutomationsViewStore.getState(); + if ( + state.selectedScheduleId === schedule?.scheduleId || + state.selectedLoopId === loop?.loopId + ) { + useAutomationDraftState.getState().setDirty(false); + state.collapse(); + } + } + } catch (error_) { + const message = extractErrorMessage(error_, false) ?? t('actionFailed'); + setError(message); + setStopping(false); + if (!deleted) { + toastManager.add({ type: 'error', title: t('actionFailed'), description: message }); + } + } finally { + setPending(false); + } + } + + return ( + <> + + + } + > + + + + {sessionId && onOpenSession ? ( + onOpenSession(sessionId)}>{t('openThread')} + ) : null} + {schedule && schedule.status !== 'completed' ? ( + <> + { + void perform(() => + schedule.status === 'active' + ? pause.trigger({ scheduleId: schedule.scheduleId }) + : resume.trigger({ scheduleId: schedule.scheduleId }), + ); + }} + > + {t(schedule.status === 'active' ? 'schedule.pause' : 'schedule.resume')} + + { + void perform(() => run.trigger({ scheduleId: schedule.scheduleId })); + }} + > + {t('schedule.runNow')} + + + ) : null} + {loop?.status === 'running' ? ( + { + setStopping(true); + void perform(() => stop.trigger({ loopId: loop.loopId })); + }} + > + {t('loop.stop')} + + ) : ( + { + setError(null); + setConfirmDelete(true); + }} + > + {t('delete')} + + )} + + + {waitingForStop ? ( + + {t('loop.stopping')} + + ) : null} + { + if (!pending) setConfirmDelete(open); + }} + > + + {t('deleteConfirmTitle')} + {t('deleteConfirmDescription')} + {error ? ( +

+ {error} +

+ ) : null} +
+ + +
+
+
+ + ); +} diff --git a/packages/client/workbench/src/automations/draft-guard.tsx b/packages/client/workbench/src/automations/draft-guard.tsx new file mode 100644 index 000000000..932d03bdc --- /dev/null +++ b/packages/client/workbench/src/automations/draft-guard.tsx @@ -0,0 +1,56 @@ +import { + AlertDialog, + AlertDialogDescription, + AlertDialogPopup, + AlertDialogTitle, +} from 'coss-ui/components/alert-dialog'; +import { Button } from 'coss-ui/components/button'; +import { useEffect } from 'foxact/use-abortable-effect'; +import { useTranslations } from 'use-intl'; +import { useAutomationDraftState } from './draft-state'; + +export function useAutomationDraft(dirty: boolean): void { + useEffect(() => { + useAutomationDraftState.getState().setDirty(dirty); + return () => useAutomationDraftState.getState().setDirty(false); + }, [dirty]); +} + +export function AutomationDraftGuard(): React.ReactNode { + const t = useTranslations('workbench.automations'); + const pending = useAutomationDraftState((state) => state.pending); + const discard = useAutomationDraftState((state) => state.discard); + const stay = useAutomationDraftState((state) => state.stay); + const dirty = useAutomationDraftState((state) => state.dirty); + useEffect( + (signal) => { + if (!dirty) return; + const handler = (event: BeforeUnloadEvent): void => { + event.preventDefault(); + }; + window.addEventListener('beforeunload', handler, { signal }); + }, + [dirty], + ); + return ( + { + if (!open) stay(); + }} + > + + {t('discardTitle')} + {t('discardDescription')} +
+ + +
+
+
+ ); +} diff --git a/packages/client/workbench/src/index.ts b/packages/client/workbench/src/index.ts index 9a72a33cc..a64fd2df4 100644 --- a/packages/client/workbench/src/index.ts +++ b/packages/client/workbench/src/index.ts @@ -7,6 +7,7 @@ export * from './app/connection-state'; export * from './app/workbench-providers'; export * from './assets/hooks'; export * from './automations/automations-view'; +export { useAutomationDraftState } from './automations/draft-state'; export * from './automations/loop/hooks'; export * from './automations/loop/items'; export * from './automations/schedule/hooks'; diff --git a/packages/client/workbench/src/navigation/store.ts b/packages/client/workbench/src/navigation/store.ts index cf3a66e97..f22bca6a3 100644 --- a/packages/client/workbench/src/navigation/store.ts +++ b/packages/client/workbench/src/navigation/store.ts @@ -1,5 +1,6 @@ import { trueFn } from 'foxts/noop'; import { create } from 'zustand'; +import { useAutomationDraftState } from '../automations/draft-state'; import { useSessionSelectionStore } from '../surface/selection-store'; import type { NavHistoryStacks, NavLocation, WorkbenchOverlaySurface } from './history'; import { recordTransition, travel } from './history'; @@ -62,6 +63,10 @@ export const useNavigationHistoryStore = create()((set, setOverlay: (overlay) => set({ overlay }), openOverlay(surface) { if (get().overlay === surface) return; + if (useAutomationDraftState.getState().dirty) { + useAutomationDraftState.getState().request(() => get().openOverlay(surface)); + return; + } // Module-scope callers can't see the hook's fallback-resolved thread, so the origin is the // open draft, the explicit selection, or nothing — Esc still visually returns either way. const { selectedId, draft } = useSessionSelectionStore.getState(); @@ -74,6 +79,10 @@ export const useNavigationHistoryStore = create()((set, set({ overlay: surface }); }, backFromOverlay() { + if (useAutomationDraftState.getState().dirty) { + useAutomationDraftState.getState().request(() => get().backFromOverlay()); + return; + } const { overlay } = get(); if (overlay === null) return; // Pops exactly one entry via `travel` (which keeps the bookkeeping). Overlay targets re-raise diff --git a/packages/client/workbench/src/surface/use-workbench-sessions.ts b/packages/client/workbench/src/surface/use-workbench-sessions.ts index fef42d39c..1cf49704d 100644 --- a/packages/client/workbench/src/surface/use-workbench-sessions.ts +++ b/packages/client/workbench/src/surface/use-workbench-sessions.ts @@ -20,6 +20,7 @@ import { useEffect } from 'foxact/use-abortable-effect'; import { useMemo, useRef } from 'react'; import { useTranslations } from 'use-intl'; import { captureProductEvent } from '../analytics/product-analytics'; +import { useAutomationDraftState } from '../automations/draft-state'; import type { NavLocation } from '../navigation/history'; import { useNavigationHistoryStore } from '../navigation/store'; import { useData, useMutation } from '../runtime/tayori'; @@ -156,11 +157,19 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench } function select(id: SessionId): void { + if (useAutomationDraftState.getState().dirty) { + useAutomationDraftState.getState().request(() => select(id)); + return; + } recordNavigation(currentLocation, { surface: 'thread', sessionId: id }); applySelection(id); } function startDraft(workspaceId?: WorkspaceId): void { + if (useAutomationDraftState.getState().dirty) { + useAutomationDraftState.getState().request(() => startDraft(workspaceId)); + return; + } recordNavigation(currentLocation, { surface: 'new-thread', workspaceId: workspaceId ?? null }); setOverlay(null); startExplicitDraft({ workspaceId: workspaceId ?? null }); @@ -169,6 +178,10 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench // Threads must still exist in the list to be traversal targets (closed ones drop out of the // stacks on the way); the draft page and the overlay surfaces are always reachable. function traverse(dir: 'back' | 'forward'): void { + if (useAutomationDraftState.getState().dirty) { + useAutomationDraftState.getState().request(() => traverse(dir)); + return; + } const target = travelHistory(dir, currentLocation, (location) => location.surface === 'thread' ? sessionById(sessions, location.sessionId) !== null : true, ); From 559ccf19075054ce71a46b070cd49eb5960f80eb Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Tue, 8 Sep 2026 23:56:26 +0800 Subject: [PATCH 05/13] feat(automations): keep filtered task lists mounted --- .../src/automations/automations-view.tsx | 157 ++++++++++++------ .../workbench/src/automations/filters.tsx | 45 +++++ .../workbench/src/automations/loop/pane.tsx | 70 +++++--- .../workbench/src/automations/pane-layout.tsx | 10 +- .../src/automations/schedule/pane.tsx | 58 +++++-- 5 files changed, 243 insertions(+), 97 deletions(-) create mode 100644 packages/client/workbench/src/automations/filters.tsx diff --git a/packages/client/workbench/src/automations/automations-view.tsx b/packages/client/workbench/src/automations/automations-view.tsx index e6eddb946..8d72fbbe9 100644 --- a/packages/client/workbench/src/automations/automations-view.tsx +++ b/packages/client/workbench/src/automations/automations-view.tsx @@ -1,16 +1,20 @@ import type { LoopId, ScheduleId, SessionId } from '@linkcode/schema'; -import { cn, SHELL_TRANSITION, usePaneTransition } from '@linkcode/ui'; +import { cn, SHELL_TRANSITION, TaskLoadError, usePaneTransition } from '@linkcode/ui'; import { Button } from 'coss-ui/components/button'; import { InputGroup, InputGroupAddon, InputGroupInput } from 'coss-ui/components/input-group'; import { Tabs, TabsList, TabsTab } from 'coss-ui/components/tabs'; import { useMediaQuery } from 'coss-ui/hooks/use-media-query'; +import { useEffect } from 'foxact/use-abortable-effect'; import { PlusIcon, SearchIcon, XIcon } from 'lucide-react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useTranslations } from 'use-intl'; +import { useAutomationDefaults } from './defaults'; +import { AutomationDraftGuard } from './draft-guard'; +import { AutomationFilters } from './filters'; import { LoopDetail } from './loop/detail'; import { LoopForm } from './loop/form'; import { LoopPane } from './loop/pane'; -import { AutomationCreatePane } from './pane-layout'; +import { AutomationCreatePane, AutomationPaneSkeleton } from './pane-layout'; import { ScheduleDetail } from './schedule/detail'; import { ScheduleForm } from './schedule/form'; import { SchedulePane } from './schedule/pane'; @@ -38,15 +42,26 @@ export function AutomationsView({ const startCreate = useAutomationsViewStore((state) => state.startCreate); const startCreateLoop = useAutomationsViewStore((state) => state.startCreateLoop); const collapse = useAutomationsViewStore((state) => state.collapse); - const [query, setQuery] = useState(''); + const query = useAutomationsViewStore((state) => state.queries[tab]); + const updateQuery = useAutomationsViewStore((state) => state.setQuery); + const setQuery = (value: string): void => updateQuery(tab, value); const creating = view.kind !== 'browse'; const expanded = creating || (tab === 'schedules' ? selectedScheduleId !== null : selectedLoopId !== null); const splitLayout = useMediaQuery({ min: 1024 }); const paneTransition = usePaneTransition({ open: expanded && splitLayout }); const masterDetailVisible = splitLayout ? paneTransition.paneVisible : expanded; + const rootRef = useRef(null); + const openerRef = useRef(null); + useEffect(() => { + if (masterDetailVisible || !openerRef.current) return; + const target = openerRef.current.isConnected + ? openerRef.current + : rootRef.current?.querySelector('[data-automation-open]'); + target?.focus(); + openerRef.current = null; + }, [masterDetailVisible]); const startCurrentCreate = tab === 'schedules' ? startCreate : startCreateLoop; - const createDisabled = view.kind === (tab === 'schedules' ? 'create-schedule' : 'create-loop'); const createLabel = tab === 'schedules' ? t('schedule.new') : t('loop.new'); const list = tab === 'schedules' ? : ; const detailTarget = getAutomationDetailTarget({ @@ -67,11 +82,8 @@ export function AutomationsView({ switch (renderedDetailTarget?.kind) { case 'create-schedule': { detail = ( - - + + ); @@ -79,8 +91,8 @@ export function AutomationsView({ } case 'create-loop': { detail = ( - - + + ); @@ -89,6 +101,7 @@ export function AutomationsView({ case 'schedule': { detail = ( @@ -97,7 +110,13 @@ export function AutomationsView({ break; } case 'loop': { - detail = ; + detail = ( + + ); break; } @@ -121,6 +140,12 @@ export function AutomationsView({ return (
{ + if (!(event.target instanceof Element)) return; + const opener = event.target.closest('[data-automation-open]'); + if (opener) openerRef.current = opener; + }} className={cn( 'grid h-full min-h-0 grid-cols-1 bg-background lg:[container-type:inline-size] lg:transition-[grid-template-columns] motion-reduce:transition-none', expanded ? 'lg:grid-cols-[22rem_calc(100%_-_22rem)]' : 'lg:grid-cols-[100%_0%]', @@ -136,56 +161,60 @@ export function AutomationsView({ onTransitionEnd={handleTransitionEnd} onTransitionCancel={handleTransitionRun} > -
- {masterDetailVisible ? ( -
-
- setTab(value as AutomationTab)}> + +
+
+
+ {masterDetailVisible ? null : ( +

{t('title')}

+ )} +
+ { + if (value === 'schedules' || value === 'loops') setTab(value); + }} + > {t('tabs.schedules')} {t('tabs.loops')} - -
- - {list} -
- ) : ( -
-
-
-
-

{t('title')}

-

{t('description')}

-
- -
- -
- setTab(value as AutomationTab)}> - - {t('tabs.schedules')} - {t('tabs.loops')} - - -
-
- {list} -
-
+ )} + + + +
{list}
- )} +
{masterDetailVisible ? (
{ + void defaults.retry(); + }} + /> + ); + } + if (!defaults.ready) return ; + return kind === 'schedule' ? : ; +} + function getAutomationDetailTarget({ tab, view, diff --git a/packages/client/workbench/src/automations/filters.tsx b/packages/client/workbench/src/automations/filters.tsx new file mode 100644 index 000000000..56bdb143b --- /dev/null +++ b/packages/client/workbench/src/automations/filters.tsx @@ -0,0 +1,45 @@ +import { Tabs, TabsList, TabsTab } from 'coss-ui/components/tabs'; +import { useTranslations } from 'use-intl'; +import { useAutomationsViewStore } from './store'; + +export function AutomationFilters(): React.ReactNode { + const t = useTranslations('workbench.automations'); + const tab = useAutomationsViewStore((state) => state.tab); + const schedule = useAutomationsViewStore((state) => state.scheduleFilter); + const loop = useAutomationsViewStore((state) => state.loopFilter); + const setSchedule = useAutomationsViewStore((state) => state.setScheduleFilter); + const setLoop = useAutomationsViewStore((state) => state.setLoopFilter); + return ( + { + if ( + tab === 'schedules' && + (value === 'all' || value === 'active' || value === 'paused' || value === 'completed') + ) { + setSchedule(value); + } + if (tab === 'loops' && (value === 'all' || value === 'running' || value === 'finished')) { + setLoop(value); + } + }} + > + + {t('all')} + {tab === 'schedules' ? ( + <> + {t('status.active')} + {t('status.paused')} + {t('status.completed')} + + ) : ( + <> + {t('loopStatus.running')} + {t('finished')} + + )} + + + ); +} diff --git a/packages/client/workbench/src/automations/loop/pane.tsx b/packages/client/workbench/src/automations/loop/pane.tsx index 8c0e673bf..df37cfaa6 100644 --- a/packages/client/workbench/src/automations/loop/pane.tsx +++ b/packages/client/workbench/src/automations/loop/pane.tsx @@ -1,4 +1,5 @@ import type { LoopStatus } from '@linkcode/schema'; +import { TaskLoadError } from '@linkcode/ui'; import { Badge } from 'coss-ui/components/badge'; import { Button } from 'coss-ui/components/button'; import { @@ -10,6 +11,7 @@ import { } from 'coss-ui/components/empty'; import { PlusIcon, RepeatIcon } from 'lucide-react'; import { useTranslations } from 'use-intl'; +import { AutomationActions } from '../actions'; import { AutomationMasterButton, AutomationPaneSkeleton } from '../pane-layout'; import { useAutomationsViewStore } from '../store'; import { useLoops } from './hooks'; @@ -25,19 +27,36 @@ const STATUS_BADGE: Record state.loopFilter); const selectedLoopId = useAutomationsViewStore((state) => state.selectedLoopId); const selectLoop = useAutomationsViewStore((state) => state.selectLoop); const startCreateLoop = useAutomationsViewStore((state) => state.startCreateLoop); const normalizedQuery = query.trim().toLowerCase(); - const allItems = buildLoopItems(loops); - const items = normalizedQuery - ? allItems.filter((item) => item.name.toLowerCase().includes(normalizedQuery)) - : allItems; + const tasksById = new Map(loops?.map((task) => [task.loopId, task])); + const items = buildLoopItems( + loops?.filter( + (loop) => + (filter === 'all' || + (filter === 'running' ? loop.status === 'running' : loop.status !== 'running')) && + `${loop.spec.name ?? ''} ${loop.spec.prompt}`.toLowerCase().includes(normalizedQuery), + ), + ); + if (error && !loops) { + return ( + { + void mutate(); + }} + /> + ); + } if (items.length === 0) { if (isLoading) return ; - if (normalizedQuery) { + if (normalizedQuery || filter !== 'all') { return (

{t('noMatches')}

); @@ -61,24 +80,33 @@ export function LoopPane({ query }: { query: string }): React.ReactNode { return (
    - {items.map((item) => ( -
  • - selectLoop(item.loopId)} - /> -
  • - ))} + {items.map((item) => { + const task = tasksById.get(item.loopId); + return ( +
  • +
    + selectLoop(item.loopId)} + /> +
    + {task ? : null} +
  • + ); + })}
); } function LoopRow({ + result, item, active, onSelect, }: { + result?: string; item: LoopListItem; active: boolean; onSelect: () => void; @@ -90,10 +118,14 @@ function LoopRow({ onClick={onSelect} icon={} name={item.name} - subtitle={t('loop.iterationProgress', { - count: item.iterationCount, - max: item.maxIterations, - })} + subtitle={ + result && item.status !== 'running' + ? result + : t('loop.iterationProgress', { + count: item.iterationCount, + max: item.maxIterations, + }) + } badge={{t(`loopStatus.${item.status}`)}} /> ); diff --git a/packages/client/workbench/src/automations/pane-layout.tsx b/packages/client/workbench/src/automations/pane-layout.tsx index f01e7d65f..54c847165 100644 --- a/packages/client/workbench/src/automations/pane-layout.tsx +++ b/packages/client/workbench/src/automations/pane-layout.tsx @@ -4,20 +4,15 @@ import { createFixedArray } from 'foxts/create-fixed-array'; /** The create form's full-pane wrapper: centered column with a heading. */ export function AutomationCreatePane({ title, - description, children, }: { title: string; - description: string; children: React.ReactNode; }): React.ReactNode { return (
-
-

{title}

-

{description}

-
+

{title}

{children}
@@ -41,8 +36,9 @@ export function AutomationMasterButton({ }): React.ReactNode { return (