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..ae4aa0021 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'); @@ -678,8 +683,11 @@ export function DesktopShell({ sidebarShortcut={sidebarShortcut} rightPanelShortcut={rightPanelShortcut} bottomPanelShortcut={bottomPanelShortcut} + // Automations is a full-page overlay; suppress the session title/chip/menu and the + // right-rail panel toggles the same way settings-view.tsx does for its own overlay. + rightControls={automationsOpen ? null : undefined} titleContent={ - hideMainTitle ? ( + automationsOpen ? null : hideMainTitle ? ( // An untitled conversation hides the title area, which would also hide the error // badge with no banner fallback; keep the badge alone. The draft page stays bare — // it reports errors through its own in-page banner. @@ -747,9 +755,19 @@ export function DesktopShell({ > {/* Never paint this wrapper: it spans the sidebar column, whose translucent tint has to reach the native backdrop. Only the floating rail needs its own opaque gutter. */} -
+
+
+
+ +
+
+ ) : undefined + } right={workspaceRight} bottom={workspaceBottom} expandedPanel={expandedPanel} @@ -795,6 +813,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..74aa73519 100644 --- a/apps/webview/src/shell/web-workbench-shell.tsx +++ b/apps/webview/src/shell/web-workbench-shell.tsx @@ -1,9 +1,11 @@ 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, + useAutomationDraftState, useProvidersSettingsStore, useResourcesPanelStore, WorkspaceServicesMenu, @@ -13,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, 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; @@ -27,6 +30,21 @@ 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 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({ @@ -58,6 +76,19 @@ export function WebWorkbenchShell({
{ + useAutomationDraftState.getState().request(() => { + props.onSelectSession(sessionId); + void navigate('/'); + }); + }} + /> + ) : undefined + } showPlanInPromptDock={!resourcesSurfaceOpen} onOpenProviderSettings={() => { useProvidersSettingsStore.getState().startAdd(); @@ -66,76 +97,90 @@ export function WebWorkbenchShell({ onOpenAutomations={() => { void navigate('/automations'); }} + onSelectSession={(sessionId) => { + useAutomationDraftState.getState().request(() => { + props.onSelectSession(sessionId); + if (automationsOpen) void navigate('/'); + }); + }} + onStartDraft={(workspaceId) => { + useAutomationDraftState.getState().request(() => { + 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/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 73192f34b..69eacab7a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -249,6 +249,21 @@ pnpm -F @linkcode/mobile smoke:export ## Debugging and triage +### Automations + +Use the real daemon for Schedule and Loop development. The current `dev:mock` host does not implement automation requests. +Run a separate profile for manual experiments so test tasks do not enter your usual development state: + +```bash +LINKCODE_PROFILE=automations devenv shell -- daemon +devenv shell -- pnpm -F @linkcode/desktop dev --profile=automations +``` + +Schedule pause prevents future runs; it does not interrupt the current run. Deletion is rejected while a run is active. +Schedule editing preserves the execution target. Clearing optional limits requires a host with wire version 80 or newer. +Loop starts immediately, supports description-based or command-based verification, and stops on daemon restart. +Check the persisted result and verifier verdict, not only the task's terminal status. Provider failures must not appear as empty successful runs. + ### Daemon will not start 1. `curl http://127.0.0.1:19523/linkcode` — a JSON identity means it **is** up (possibly on a hunted port; the actual bound endpoint is in `~/.linkcode/runtime.json`). A development daemon answers on **19533** instead, and advertises in `~/.linkcode.development/runtime.json`. diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index 61d59728c..3bff1a3cc 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -489,6 +489,9 @@ export class LinkCodeSdkClient { } updateSchedule(scheduleId: ScheduleId, patch: ScheduleUpdate): RequestResult { + if (Object.values(patch).includes(null) && (this.raw.peerWireVersion ?? 0) < 80) { + return Promise.reject(new Error('Update the host to clear schedule settings')); + } return toResult(this.raw.updateSchedule(scheduleId, patch)); } diff --git a/packages/client/workbench/src/automations/actions.tsx b/packages/client/workbench/src/automations/actions.tsx new file mode 100644 index 000000000..a667fa52d --- /dev/null +++ b/packages/client/workbench/src/automations/actions.tsx @@ -0,0 +1,188 @@ +import type { LoopRecord, Schedule, SessionId } from '@linkcode/schema'; +import { + deleteLoop, + deleteSchedule, + pauseSchedule, + resumeSchedule, + runScheduleOnce, + stopLoop, +} from '@linkcode/sdk'; +import { + AlertDialog, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + 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/automations-view.tsx b/packages/client/workbench/src/automations/automations-view.tsx index a503d77b0..13cc5e5c7 100644 --- a/packages/client/workbench/src/automations/automations-view.tsx +++ b/packages/client/workbench/src/automations/automations-view.tsx @@ -1,14 +1,40 @@ -import type { SessionId } from '@linkcode/schema'; +import type { LoopId, ScheduleId, SessionId } from '@linkcode/schema'; +import { cn, ResizeHandle, 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 { PlusIcon } from 'lucide-react'; +import { useMediaQuery } from 'coss-ui/hooks/use-media-query'; +import { useEffect } from 'foxact/use-abortable-effect'; +import { clamp } from 'foxts/clamp'; +import { PlusIcon, SearchIcon, XIcon } from 'lucide-react'; +import { useRef, useState } from 'react'; import { useTranslations } from 'use-intl'; +import { useAutomationDefaults } from './defaults'; +import { DetailHeaderSlotProvider } from './detail-header-slot'; +import { + AUTOMATION_DETAIL_MAX_WIDTH, + AUTOMATION_DETAIL_MIN_WIDTH, + useAutomationDetailWidthStore, +} from './detail-width'; +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, AutomationPaneSkeleton } 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 +44,298 @@ 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 = 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 committedDetailWidth = useAutomationDetailWidthStore((state) => state.width); + const setDetailWidth = useAutomationDetailWidthStore((state) => state.setWidth); + const resetDetailWidth = useAutomationDetailWidthStore((state) => state.reset); + // Live drag frames stay local: writing every frame to the persisted store would hit + // localStorage on every pointermove. Only the settled size on release is persisted. + const [draggingWidth, setDraggingWidth] = useState(null); + const clampedDetailWidth = clamp( + draggingWidth ?? committedDetailWidth, + AUTOMATION_DETAIL_MIN_WIDTH, + AUTOMATION_DETAIL_MAX_WIDTH, + ); + const [detailHeaderSlot, setDetailHeaderSlot] = useState(null); + 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 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' ? ( - - ) : ( - +
{ + if (!(event.target instanceof Element)) return; + const opener = event.target.closest('[data-automation-open]'); + if (opener) openerRef.current = opener; + }} + className={cn( + 'relative 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-[100%_0%]', + )} + style={{ + ...(expanded && + splitLayout && { gridTemplateColumns: `minmax(0,1fr) ${clampedDetailWidth}px` }), + transitionDuration: + splitLayout && paneTransition.isAnimating && !paneTransition.reducedMotion + ? `${SHELL_TRANSITION.durationMs}ms` + : '0ms', + transitionTimingFunction: SHELL_TRANSITION.cssEase, + }} + onTransitionRun={handleTransitionRun} + onTransitionEnd={handleTransitionEnd} + onTransitionCancel={handleTransitionRun} + > + +
+
+
+
+ { + if (value === 'schedules' || value === 'loops') setTab(value); + }} + > + + {t('tabs.schedules')} + {t('tabs.loops')} + + + {creating ? null : ( + + )} +
+ + +
{list}
+
- {tab === 'schedules' ? ( - - ) : ( - - )} -
+ + {masterDetailVisible ? ( + { + setDetailWidth(next); + setDraggingWidth(null); + }} + onReset={() => { + resetDetailWidth(); + setDraggingWidth(null); + }} + /> + ) : null} + {masterDetailVisible ? ( +
+
+
+
+ +
+
+ {detail} +
+
+
+ ) : null}
); } + +function AutomationCreateForm({ kind }: { kind: 'schedule' | 'loop' }): React.ReactNode { + const defaults = useAutomationDefaults(); + const t = useTranslations('workbench.automations'); + if (defaults.error) { + return ( + { + void defaults.retry(); + }} + /> + ); + } + if (!defaults.ready) return ; + return kind === 'schedule' ? : ; +} + +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({ + query, + onQueryChange, +}: { + 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/cwd-field.tsx b/packages/client/workbench/src/automations/cwd-field.tsx index 6fd3850c6..458c255c0 100644 --- a/packages/client/workbench/src/automations/cwd-field.tsx +++ b/packages/client/workbench/src/automations/cwd-field.tsx @@ -1,33 +1,49 @@ +import { TaskSelect } from '@linkcode/ui'; import { Field, FieldError, FieldLabel } from 'coss-ui/components/field'; import { Input } from 'coss-ui/components/input'; -import type { UseFormRegisterReturn } from 'react-hook-form'; +import { useState } from 'react'; import { useTranslations } from 'use-intl'; import { useWorkspaces } from '../workspace/hooks'; -const CWD_OPTIONS_ID = 'automations-cwd-options'; - -/** Working-directory field with registered-workspace suggestions (MRU first), shared by both create forms. */ -export function CwdField({ inputProps }: { inputProps: UseFormRegisterReturn }): React.ReactNode { +export function CwdField({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}): React.ReactNode { const t = useTranslations('workbench.automations'); const { data: workspaces } = useWorkspaces(); + const [custom, setCustom] = useState(false); + const workspaceByCwd = new Map(workspaces?.map((workspace) => [workspace.cwd, workspace])); + const showPath = custom || !workspaceByCwd.has(value); return ( {t('cwdLabel')} - { + setCustom(next === 'custom'); + if (next !== 'custom') onChange(next); + }} + items={[ + ...(workspaces ?? []).map((workspace) => ({ + value: workspace.cwd, + label: workspace.name ?? workspace.cwd, + })), + { value: 'custom', label: t('customDirectory') }, + ]} /> - - {(workspaces ?? []).map((workspace) => ( - - ))} - + {showPath ? ( + onChange(event.target.value)} + /> + ) : null} ); 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/detail-header-slot.ts b/packages/client/workbench/src/automations/detail-header-slot.ts new file mode 100644 index 000000000..0beda393e --- /dev/null +++ b/packages/client/workbench/src/automations/detail-header-slot.ts @@ -0,0 +1,17 @@ +import { createContext, useContext } from 'react'; +import { createPortal } from 'react-dom'; + +const DetailHeaderSlotContext = createContext(null); + +export const DetailHeaderSlotProvider = DetailHeaderSlotContext.Provider; + +/** + * Portals children into the fixed top-right slot `AutomationsView` renders beside its close + * button, so a detail view's overflow menu stays anchored next to Close instead of scrolling + * away with the rest of its content. + */ +export function DetailHeaderPortal({ children }: React.PropsWithChildren): React.ReactNode { + const slot = useContext(DetailHeaderSlotContext); + if (!slot) return null; + return createPortal(children, slot); +} diff --git a/packages/client/workbench/src/automations/detail-width.ts b/packages/client/workbench/src/automations/detail-width.ts new file mode 100644 index 000000000..d1d68b4ae --- /dev/null +++ b/packages/client/workbench/src/automations/detail-width.ts @@ -0,0 +1,32 @@ +import { zodPersist } from '@linkcode/common/zustand'; +import { z } from 'zod'; +import { create } from 'zustand'; + +const PersistedDetailWidthSchema = z.object({ width: z.number() }).partial(); +type PersistedDetailWidth = z.infer; + +export const AUTOMATION_DETAIL_MIN_WIDTH = 420; +export const AUTOMATION_DETAIL_MAX_WIDTH = 960; +export const AUTOMATION_DETAIL_DEFAULT_WIDTH = 640; + +interface AutomationDetailWidthState { + width: number; + setWidth: (width: number) => void; + reset: () => void; +} + +/** The Automations detail pane's user-resized width, shared across Desktop and Web. */ +export const useAutomationDetailWidthStore = create()( + zodPersist( + (set) => ({ + width: AUTOMATION_DETAIL_DEFAULT_WIDTH, + setWidth: (width) => set({ width }), + reset: () => set({ width: AUTOMATION_DETAIL_DEFAULT_WIDTH }), + }), + { + name: 'linkcode.automations.detail-width:v1', + schema: PersistedDetailWidthSchema, + partialize: (state) => ({ width: state.width }), + }, + ), +); 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..8a7c099c6 --- /dev/null +++ b/packages/client/workbench/src/automations/draft-guard.tsx @@ -0,0 +1,60 @@ +import { + AlertDialog, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + 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/automations/draft-state.ts b/packages/client/workbench/src/automations/draft-state.ts new file mode 100644 index 000000000..c37bc3905 --- /dev/null +++ b/packages/client/workbench/src/automations/draft-state.ts @@ -0,0 +1,26 @@ +import { create } from 'zustand'; + +interface DraftState { + dirty: boolean; + pending: (() => void) | null; + setDirty: (dirty: boolean) => void; + request: (action: () => void) => void; + discard: () => void; + stay: () => void; +} + +export const useAutomationDraftState = create()((set, get) => ({ + dirty: false, + pending: null, + setDirty: (dirty) => set({ dirty }), + request(action) { + if (get().dirty) set({ pending: action }); + else action(); + }, + discard() { + const action = get().pending; + set({ dirty: false, pending: null }); + action?.(); + }, + stay: () => set({ pending: null }), +})); 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/detail.tsx b/packages/client/workbench/src/automations/loop/detail.tsx index 96f19661d..871363c80 100644 --- a/packages/client/workbench/src/automations/loop/detail.tsx +++ b/packages/client/workbench/src/automations/loop/detail.tsx @@ -1,18 +1,12 @@ import type { LoopId, LoopIteration, LoopStatus, SessionId } from '@linkcode/schema'; -import { deleteLoop, stopLoop } from '@linkcode/sdk'; -import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogPopup, - AlertDialogTitle, - AlertDialogTrigger, -} from 'coss-ui/components/alert-dialog'; +import { TaskDisclosure, TaskLoadError } from '@linkcode/ui'; import { Badge } from 'coss-ui/components/badge'; import { Button } from 'coss-ui/components/button'; import { Empty, EmptyTitle } from 'coss-ui/components/empty'; import { useTranslations } from 'use-intl'; -import { useMutation } from '../../runtime/tayori'; +import { AutomationActions } from '../actions'; +import { DetailHeaderPortal } from '../detail-header-slot'; +import { AutomationPaneSkeleton } from '../pane-layout'; import { useLoopInspection, useLoopLog } from './hooks'; import { LoopLogView } from './log-view'; @@ -37,12 +31,25 @@ export function LoopDetail({ }): React.ReactNode { const t = useTranslations('workbench.automations'); const tAgent = useTranslations('workbench.agentKind'); - const { data: inspection } = useLoopInspection(loopId); + const { data: inspection, error, mutate } = useLoopInspection(loopId); const logs = useLoopLog(loopId); - const stop = useMutation(stopLoop); - const remove = useMutation(deleteLoop); if (!inspection) { + if (error) { + return ( + { + void mutate(); + }} + /> + ); + } + return ; + } + + if (inspection.loop.loopId !== loopId) { return ( {t('notFound')} @@ -52,13 +59,31 @@ export function LoopDetail({ const { loop, iterations } = inspection; return ( -
+
+ {error ? ( + { + void mutate(); + }} + /> + ) : null} + + +
+
+ {t(`loopStatus.${loop.status}`)} +

{loop.spec.name ?? loop.spec.cwd}

- {t(`loopStatus.${loop.status}`)}

{loop.spec.prompt}

@@ -70,59 +95,22 @@ export function LoopDetail({ label={t('loop.iterations')} value={`${loop.iterationCount} / ${loop.spec.maxIterations}`} /> - + {loop.summary || loop.error ? ( +

+ {loop.status === 'failed' + ? (iterations.at(-1)?.error ?? loop.error) + : (loop.error ?? loop.summary)} +

+ ) : null} -
- {loop.status === 'running' ? ( - - ) : ( - - - {t('delete')} - - } - /> - - {t('deleteConfirmTitle')} - {t('deleteConfirmDescription')} -
- {t('cancel')}} /> - { - void remove.trigger({ loopId }); - }} - > - {t('delete')} - - } - /> -
-
-
- )} -
- -
-

{t('loop.log')}

- -
+ +
+ +
+
-
-

{t('loop.iterations')}

+ {iterations.length === 0 ? (

{t('loop.iterationsEmpty')}

) : ( @@ -137,7 +125,7 @@ export function LoopDetail({ ))} )} -
+
); } @@ -177,9 +165,13 @@ function IterationRow({ {iteration.checks.map((check, checkIndex) => ( // Checks are an append-only, never-reordered sequence per iteration; index is stable. // eslint-disable-next-line @eslint-react/no-array-index-key -- no natural id; order is fixed -
  • - {check.exitCode} - {check.command} +
  • + + {check.timedOut ?

    {t('loop.timedOut')}

    : null} +
    +                  {check.outputTail || t('loop.noOutput')}
    +                
    +
  • ))} diff --git a/packages/client/workbench/src/automations/loop/form.tsx b/packages/client/workbench/src/automations/loop/form.tsx index b8eea11f6..cc18cbb8b 100644 --- a/packages/client/workbench/src/automations/loop/form.tsx +++ b/packages/client/workbench/src/automations/loop/form.tsx @@ -2,11 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import type { LoopSpec } from '@linkcode/schema'; import { AgentKindSchema } from '@linkcode/schema'; import { startLoop } from '@linkcode/sdk'; +import { TaskDisclosure, TaskFormError, TaskSelect } from '@linkcode/ui'; import { Button } from 'coss-ui/components/button'; import { Field, FieldError, FieldLabel } from 'coss-ui/components/field'; import { Form } from 'coss-ui/components/form'; import { Input } from 'coss-ui/components/input'; -import { RadioGroup, RadioGroupItem } from 'coss-ui/components/radio-group'; import { Textarea } from 'coss-ui/components/textarea'; import { extractErrorMessage } from 'foxts/extract-error-message'; import { PlusIcon, XIcon } from 'lucide-react'; @@ -16,6 +16,9 @@ import { z } from 'zod'; import { rhfErrorsToFormErrors } from '../../lib/form'; import { useMutation } from '../../runtime/tayori'; import { CwdField } from '../cwd-field'; +import { useAutomationDefaults } from '../defaults'; +import { useAutomationDraft } from '../draft-guard'; +import { useAutomationDraftState } from '../draft-state'; import { useAutomationsViewStore } from '../store'; const loopFormSchema = z @@ -25,15 +28,19 @@ const loopFormSchema = z kind: AgentKindSchema, cwd: z.string().trim().min(1), checks: z.array(z.object({ command: z.string() })), + verification: z.enum(['agent', 'commands', 'both']), verifierPrompt: z.string().trim(), maxIterations: z.number().int().min(1).max(100), sleepSeconds: z.number().int().nonnegative(), }) .superRefine((draft, ctx) => { const hasCheck = draft.checks.some((check) => check.command.trim().length > 0); - if (!hasCheck && draft.verifierPrompt.length === 0) { + if (!hasCheck && draft.verification !== 'agent') { ctx.addIssue({ code: 'custom', path: ['checks'], message: 'needVerification' }); } + if (!draft.verifierPrompt && draft.verification !== 'commands') { + ctx.addIssue({ code: 'custom', path: ['verifierPrompt'], message: 'needVerification' }); + } }); type LoopFormDraft = z.infer; @@ -48,8 +55,8 @@ function toSpec(draft: LoopFormDraft): LoopSpec { kind: draft.kind, cwd: draft.cwd, prompt: draft.prompt, - verifyChecks, - verifier: draft.verifierPrompt ? { prompt: draft.verifierPrompt } : undefined, + verifyChecks: draft.verification === 'agent' ? [] : verifyChecks, + verifier: draft.verification === 'commands' ? undefined : { prompt: draft.verifierPrompt }, maxIterations: draft.maxIterations, sleepMs: draft.sleepSeconds * 1000, }; @@ -62,19 +69,22 @@ export function LoopForm(): React.ReactNode { const selectLoop = useAutomationsViewStore((state) => state.selectLoop); const closeCreate = useAutomationsViewStore((state) => state.closeCreate); const create = useMutation(startLoop); + const defaults = useAutomationDefaults(); const { control, register, handleSubmit, setError, - formState: { errors, isSubmitting }, + reset, + formState: { errors, isSubmitting, isDirty }, } = useForm({ resolver: zodResolver(loopFormSchema), defaultValues: { prompt: '', - kind: 'claude-code', - cwd: '', + kind: defaults.kind ?? 'claude-code', + cwd: defaults.cwd, + verification: 'agent', checks: [{ command: '' }], verifierPrompt: '', maxIterations: 10, @@ -82,13 +92,16 @@ export function LoopForm(): React.ReactNode { }, }); const checks = useFieldArray({ control, name: 'checks' }); + useAutomationDraft(isDirty); const onSubmit = handleSubmit(async (draft) => { try { const loop = await create.trigger({ spec: toSpec(draft) }); + reset(draft); + useAutomationDraftState.getState().setDirty(false); selectLoop(loop.loopId); } catch (error) { - setError('root', { message: extractErrorMessage(error, false) ?? 'Failed to create loop' }); + setError('root', { message: extractErrorMessage(error, false) ?? t('actionFailed') }); } }); @@ -97,128 +110,181 @@ export function LoopForm(): React.ReactNode { className="flex flex-col gap-4" errors={rhfErrorsToFormErrors(errors)} onSubmit={onSubmit} + aria-busy={isSubmitting} > - - {t('nameLabel')} - + + {t('nameLabel')} + + + + + {t('loop.goalLabel')} +