-
Notifications
You must be signed in to change notification settings - Fork 14
feat(app): edit automations inline and add full list row actions #1270
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
130c992
feat(app): edit automations inline and add full list row actions
Astro-Han 53201bc
fix(app): add chevron affordance to the Repeats editor trigger
Astro-Han f08d7ac
feat(app): move automations across projects from the detail page
Astro-Han ae3f921
fix(app): align the Project row hover with the other editable rows
Astro-Han 90d4f3f
fix(app): address automation editor review (schedule/move/text/model/…
Astro-Han File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
294 changes: 294 additions & 0 deletions
294
packages/app/src/pages/automations/automation-detail-editors.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,294 @@ | ||
| import { createEffect, createMemo, createSignal, Show, type Accessor, type JSX } from "solid-js" | ||
| import type { AutomationDefinition, AutomationUpdateInput } from "@opencode-ai/sdk/v2/client" | ||
| import { Icon } from "@opencode-ai/ui/icon" | ||
| import { Popover } from "@opencode-ai/ui/popover" | ||
| import { ProviderIcon } from "@opencode-ai/ui/provider-icon" | ||
| import { ModelSelectorPopover, type ModelPickerState } from "@/components/prompt-input/model-picker" | ||
| import { useScopedModels } from "@/context/models" | ||
| import type { useLanguage } from "@/context/language" | ||
| import { useLayout } from "@/context/layout" | ||
| import { AutomationFolderPicker, type AutomationProject } from "./automation-folder-picker" | ||
| import { AutomationScheduleControls } from "./automation-schedule-controls" | ||
| import { | ||
| buildScheduleInput, | ||
| cronForSchedule, | ||
| scheduleDraftFromDefinition, | ||
| DEFAULT_SCHEDULE, | ||
| type ScheduleDraft, | ||
| type ScheduleFrequency, | ||
| } from "./automation-schedule-form" | ||
| import { formatScheduleSummary } from "./automation-schedule" | ||
|
|
||
| type Translate = ReturnType<typeof useLanguage>["t"] | ||
|
|
||
| // Each edit commits its own one-field patch (resolved true on success); there | ||
| // is no edit mode and no save button. Failure rolls the control back to the | ||
| // definition, which the store still holds unchanged. | ||
| export type CommitPatch = (patch: AutomationUpdateInput) => Promise<boolean> | ||
|
|
||
| // The detail title / instructions rendered as a transparent always-editable | ||
| // control: blur commits when changed and non-empty, Escape or an empty value | ||
| // reverts. The DOM input owns the text while focused so an SSE refresh never | ||
| // stomps a half-typed edit. | ||
| export function EditableText(props: { | ||
| value: string | ||
| onCommit: (next: string) => Promise<boolean> | ||
| multiline?: boolean | ||
| class: string | ||
| ariaLabel: string | ||
| action: string | ||
| }): JSX.Element { | ||
| let element: HTMLInputElement | HTMLTextAreaElement | undefined | ||
|
|
||
| const autoresize = () => { | ||
| if (!props.multiline || !element) return | ||
| element.style.height = "auto" | ||
| element.style.height = `${element.scrollHeight}px` | ||
| } | ||
|
|
||
| createEffect(() => { | ||
| const value = props.value | ||
| if (!element || document.activeElement === element) return | ||
| element.value = value | ||
| autoresize() | ||
| }) | ||
|
|
||
| const commit = async () => { | ||
| if (!element) return | ||
| const next = element.value.trim() | ||
| if (!next || next === props.value) { | ||
| element.value = props.value | ||
| autoresize() | ||
| return | ||
| } | ||
| const ok = await props.onCommit(next) | ||
| // Roll back only if the control still shows the failed submission — the | ||
| // user may have refocused and retyped while the request was in flight. | ||
| if (!ok && element && element.value === next) { | ||
| element.value = props.value | ||
| autoresize() | ||
| } | ||
| } | ||
|
|
||
| const onKeyDown = (event: KeyboardEvent) => { | ||
| if (event.key === "Escape") { | ||
| if (element) element.value = props.value | ||
| autoresize() | ||
| element?.blur() | ||
| return | ||
| } | ||
| if (event.key === "Enter" && !props.multiline) element?.blur() | ||
| } | ||
|
|
||
| const shared = { | ||
| "aria-label": props.ariaLabel, | ||
| "data-action": props.action, | ||
| class: `${props.class} bg-transparent focus:outline-none`, | ||
| onBlur: () => void commit(), | ||
| onKeyDown, | ||
| } | ||
|
|
||
| return ( | ||
| <Show | ||
| when={props.multiline} | ||
| fallback={<input type="text" ref={(el) => (element = el)} value={props.value} {...shared} />} | ||
| > | ||
| <textarea | ||
| ref={(el) => { | ||
| element = el | ||
| queueMicrotask(autoresize) | ||
| }} | ||
| value={props.value} | ||
| rows={1} | ||
| onInput={autoresize} | ||
| {...shared} | ||
| class={`${props.class} resize-none bg-transparent focus:outline-none`} | ||
| /> | ||
| </Show> | ||
| ) | ||
| } | ||
|
|
||
| const ROW_VALUE_CLASS = | ||
| "min-w-0 truncate text-right text-body text-fg-base hover:text-fg-strong hover:underline focus-visible:text-fg-strong focus-visible:underline focus:outline-none cursor-default" | ||
|
|
||
| function EditorRow(props: { label: string; children: JSX.Element }): JSX.Element { | ||
| return ( | ||
| <div class="flex items-baseline justify-between gap-3"> | ||
| <span class="shrink-0 text-caption text-fg-weak">{props.label}</span> | ||
| {props.children} | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| // The "Project" row: there is no server-side move (each project is its own | ||
| // instance and update rejects a foreign projectID), so "move" is the create | ||
| // card's folder picker driving a create-in-target + delete-from-source pair | ||
| // (see moveToProject in automation-detail). A continue automation stays | ||
| // read-only — it loops inside a conversation that only exists in its source | ||
| // project — as does everything else when no other project is open. | ||
| export function ProjectEditorRow(props: { | ||
| directory: Accessor<string> | ||
| automation: Accessor<AutomationDefinition> | ||
| projectName: Accessor<string> | ||
| t: Translate | ||
| onMove: (project: AutomationProject) => void | ||
| }): JSX.Element { | ||
| const layout = useLayout() | ||
| const projects = createMemo<AutomationProject[]>(() => | ||
| layout.projects | ||
| .list() | ||
| .filter((project) => project.id && project.id !== "global" && project.worktree) | ||
| .map((project) => ({ id: project.id!, worktree: project.worktree, name: project.name })), | ||
| ) | ||
| const movable = createMemo( | ||
| () => | ||
| props.automation().context === "fresh" && | ||
| projects().some((project) => project.id !== props.automation().where.projectID), | ||
| ) | ||
| return ( | ||
| <EditorRow label={props.t("automations.detail.project")}> | ||
| <Show | ||
| when={movable()} | ||
| fallback={<span class="min-w-0 truncate text-right text-body text-fg-base">{props.projectName()}</span>} | ||
| > | ||
| <AutomationFolderPicker | ||
| variant="row" | ||
| action="automation-edit-project" | ||
| projects={projects()} | ||
| current={props.directory()} | ||
| onSelect={(project) => props.onMove(project)} | ||
| /> | ||
| </Show> | ||
| </EditorRow> | ||
| ) | ||
| } | ||
|
|
||
| // The "Repeats" row: the summary text opens the create card's schedule controls | ||
| // in a popover. Every knob change commits immediately — each intermediate state | ||
| // is itself a valid schedule. The kind is fixed by the server (no | ||
| // oneshot<->recurring conversion), so the frequency switch only offers what the | ||
| // kind supports. A rhythm the picker cannot express (hourly, interval, | ||
| // arbitrary cron) renders read-only instead: the segmented switch would have | ||
| // to pre-select a frequency the definition does not have, and any knob change | ||
| // would silently rewrite the rhythm to it. | ||
| export function ScheduleEditorRow(props: { | ||
| automation: Accessor<AutomationDefinition> | ||
| t: Translate | ||
| onPatch: CommitPatch | ||
| }): JSX.Element { | ||
| const draftFromDefinition = (): ScheduleDraft => scheduleDraftFromDefinition(props.automation()) ?? DEFAULT_SCHEDULE | ||
| const editable = createMemo(() => scheduleDraftFromDefinition(props.automation()) !== undefined) | ||
| const [draft, setDraft] = createSignal<ScheduleDraft>(draftFromDefinition()) | ||
| createEffect(() => setDraft(draftFromDefinition())) | ||
|
|
||
| const frequencies = (): ScheduleFrequency[] => | ||
| props.automation().kind === "oneshot" ? ["once"] : ["daily", "weekdays", "weekly"] | ||
|
|
||
| const change = async (next: ScheduleDraft) => { | ||
| setDraft(next) | ||
| const definition = props.automation() | ||
| const scheduled = buildScheduleInput(next, definition.timezone, Date.now()) | ||
| const ok = await props.onPatch( | ||
| scheduled.kind === "oneshot" | ||
| ? { fireAt: scheduled.fireAt } | ||
| : { rhythm: { kind: "cron", expression: cronForSchedule(next) } }, | ||
| ) | ||
| if (!ok) setDraft(draftFromDefinition()) | ||
| } | ||
|
|
||
| return ( | ||
| <EditorRow label={props.t("automations.detail.repeats")}> | ||
| <Show | ||
| when={editable()} | ||
| fallback={ | ||
| <span class="min-w-0 truncate text-right text-body text-fg-base"> | ||
| {formatScheduleSummary(props.automation(), props.t)} | ||
| </span> | ||
| } | ||
| > | ||
| <Popover | ||
| modal | ||
| placement="bottom-end" | ||
| class="w-max max-w-[440px]" | ||
| triggerAs="button" | ||
| triggerProps={ | ||
| { | ||
| type: "button", | ||
| "data-action": "automation-edit-schedule", | ||
| "aria-label": props.t("automations.detail.repeats"), | ||
| class: `flex min-w-0 items-center gap-1.5 ${ROW_VALUE_CLASS}`, | ||
| } as never | ||
| } | ||
| trigger={ | ||
| <> | ||
| <span class="min-w-0 truncate">{formatScheduleSummary(props.automation(), props.t)}</span> | ||
| <Icon name="chevron-down" class="size-3 shrink-0 text-icon-weak" /> | ||
| </> | ||
| } | ||
| > | ||
| <AutomationScheduleControls | ||
| value={draft()} | ||
| onChange={(next) => void change(next)} | ||
| t={props.t} | ||
| frequencies={frequencies()} | ||
| /> | ||
| </Popover> | ||
| </Show> | ||
| </EditorRow> | ||
| ) | ||
| } | ||
|
|
||
| // The "Model" row reuses the composer's model picker. Picking a model clears | ||
| // the variant (thinking levels are model-specific); picking a thinking level | ||
| // patches the variant alone. | ||
| export function ModelEditorRow(props: { | ||
| directory: Accessor<string> | ||
| automation: Accessor<AutomationDefinition> | ||
| t: Translate | ||
| onPatch: CommitPatch | ||
| }): JSX.Element { | ||
| const models = useScopedModels(props.directory) | ||
| const current = () => { | ||
| const model = props.automation().model | ||
| return models.find({ providerID: model.providerID, modelID: model.modelID }) | ||
| } | ||
| const state: ModelPickerState = { | ||
| list: models.list, | ||
| current, | ||
| visible: (item) => models.visible(item), | ||
| set: (item) => { | ||
| if (!item) return | ||
| models.setVisibility(item, true) | ||
| void props.onPatch({ model: { providerID: item.providerID, modelID: item.modelID }, variant: null }) | ||
| }, | ||
|
Astro-Han marked this conversation as resolved.
|
||
| variant: { | ||
| list: () => Object.keys(current()?.variants ?? {}), | ||
| current: () => props.automation().variant, | ||
| set: (value) => void props.onPatch({ variant: value ?? null }), | ||
| }, | ||
| } | ||
|
|
||
| return ( | ||
| <EditorRow label={props.t("automations.detail.model")}> | ||
| <ModelSelectorPopover | ||
| modal | ||
| model={state} | ||
| triggerProps={{ | ||
| "data-action": "automation-edit-model", | ||
| "aria-label": props.t("automations.detail.model"), | ||
| class: `flex min-w-0 items-center gap-1.5 ${ROW_VALUE_CLASS}`, | ||
| }} | ||
| > | ||
| <Show when={current()} fallback={<span class="min-w-0 truncate">{props.automation().model.modelID}</span>}> | ||
| {(selected) => ( | ||
| <> | ||
| <ProviderIcon id={selected().provider.id} class="size-3.5 shrink-0 text-icon-weak" /> | ||
| <span class="min-w-0 truncate">{selected().name}</span> | ||
| </> | ||
| )} | ||
| </Show> | ||
| <Icon name="chevron-down" class="size-3 shrink-0 text-icon-weak" /> | ||
| </ModelSelectorPopover> | ||
| </EditorRow> | ||
| ) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.