From bafb3c2be5a8cf093fac0763ad87c7408fc6fc67 Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 17 Apr 2026 18:15:17 -0400 Subject: [PATCH] feat(web): create-time Display/Quick Actions + live prompt preview (TASK-599) (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): create-time Display/Quick Actions + live prompt preview Closes TASK-599 in PLAN-593 — the last task. Closes the parity gap between Create and Edit modals by bringing the Display and Quick Actions editors to the Create flow (under an "Advanced" reveal so the default create path stays short), and adds a live substitution preview to the Quick Actions prompt editor in both modals. New shared code - web/src/lib/utils/quick-action-preview.ts: single source of truth for the template-variable list, kept in lockstep with the runtime substitution in QuickActionsMenu. Exports parsePrompt() that tokenizes a prompt into text / known-var / unknown-var segments, plus contextFromItem() (real items for Edit) and placeholderContext() (synthetic for Create or empty collections). - DisplaySettingsEditor.svelte: extracts the 5 display selects (default view, layout, board/list group-by, list sort-by) into a reusable pure-presentation block with bindable props. - QuickActionsEditor.svelte: extracts the full Quick Actions sub-UI (both Item and Collection sections) with add/remove/reorder logic internal to the component. Each action card now renders a live preview panel below the prompt input showing the resolved output with subtle blue highlights on known variables and red + wavy underline on unknown ones. An explicit warning line appears below the preview when typos are detected. EditCollectionModal - Replaces the inline Display tab markup with DisplaySettingsEditor. - Replaces the inline Quick Actions tab markup with QuickActionsEditor. - Fetches the first item in the collection on open (api.items.listByCollection limit=1) to build a realistic preview context; falls back to placeholder values if the collection is empty or the fetch fails. - Net result: ~390 lines removed (deduped into the components), local state for action list and group-by derivation remains here since it drives the schema save. CreateCollectionModal - New collapsible "Advanced" section below the fields area, collapsed by default. Contains DisplaySettingsEditor + QuickActionsEditor. - New state for default_view / layout / board_group_by / list_group_by / list_sort_by / quick_actions, wired into handleCreate's settings serialization. - Template selection now pre-fills the Advanced state from the template's settings (board_group_by, default_view, quick_actions etc.), so template-provided settings are preserved even for users who never open the Advanced section. - Derived selectFieldKeys / sortableFieldKeys from the (not-yet-saved) fields so the group-by pickers reflect what the user is building. - A small $effect auto-corrects boardGroupBy / listGroupBy when the user removes the select field they pointed at (Advanced only — doesn't mutate state behind the user's back while collapsed). - Preview context uses placeholderContext() since no items exist yet; the {collection} token updates live as the user types a name. Out of scope - Cross-field done-detection (separate, tracked in TASK-604). - Any new field types / schema additions. * fix(web): scope-aware previews and honest empty-resolution rendering Two Codex findings on PR #139, both about preview accuracy: P2: Use scope-aware context for collection action previews Collection-scope actions run with `item` unset in QuickActionsMenu, so item-only variables ({ref}, {title}, {status}, {priority}, {content}, {fields}, {plan}, {phase}) resolve to empty strings at runtime. The preview was parsing collection-scope prompts with the same item-populated context used for item-scope actions, so the preview could show rich substitutions the user would never actually get when clicking the action. Fix: add toCollectionScope() in quick-action-preview.ts that clears item-only variables and keeps only {collection}. QuickActionsEditor now derives itemScopeContext (verbatim) and collectionScopeContext (reshaped), and the two sections parse against the right one. P2: Render empty resolved variables as empty in preview The preview template `{seg.resolved || `{${seg.name}}`}` treated legit empty substitutions as falsy and fell through to the raw token, so a known variable that legitimately resolves to `""` at runtime (e.g. {plan} with no plan, or any item variable in a collection-scope action) was displayed as if the token would be copied literally. That's the opposite of what runtime actually does. Fix: when seg.resolved === '', render an italic muted "(empty)" pill with a tooltip explaining the variable resolves to an empty string. Non-empty resolutions render unchanged. This surfaces the emptiness to the user without lying about what gets copied. Both fixes pair with the scope-aware context change — collection- scope previews now correctly show all item variables as "(empty)" instead of rich values, matching runtime output exactly. * fix(web): drop template quick_actions from spread so user can clear them Codex P2 (PR #139): the Create modal merged `selectedSettings` into the final settings object and only wrote `quick_actions` when `savedActions.length > 0`. After picking a template with pre-shipped quick actions, a user who deleted every quick-action row would still end up saving the template's original quick_actions because they were re-introduced by `...selectedSettings`. "Remove all quick actions" was effectively impossible for templates that defined them. Fix: destructure `quick_actions` out of `selectedSettings` before the spread, leaving only the non-action template fields (default_view, board_group_by, etc.) to be merged. `quickActions` state is already the single source of truth for quick actions — it's populated from the template on pick and then edited by the user — so the spread no longer needs to contribute them. This makes `savedActions` ← the in-editor list authoritative, including when it's empty. --- .../collections/CreateCollectionModal.svelte | 240 ++++++++- .../collections/DisplaySettingsEditor.svelte | 141 +++++ .../collections/EditCollectionModal.svelte | 396 ++------------ .../collections/QuickActionsEditor.svelte | 494 ++++++++++++++++++ web/src/lib/utils/quick-action-preview.ts | 145 +++++ 5 files changed, 1057 insertions(+), 359 deletions(-) create mode 100644 web/src/lib/components/collections/DisplaySettingsEditor.svelte create mode 100644 web/src/lib/components/collections/QuickActionsEditor.svelte create mode 100644 web/src/lib/utils/quick-action-preview.ts diff --git a/web/src/lib/components/collections/CreateCollectionModal.svelte b/web/src/lib/components/collections/CreateCollectionModal.svelte index 2d319edd..8fa9b937 100644 --- a/web/src/lib/components/collections/CreateCollectionModal.svelte +++ b/web/src/lib/components/collections/CreateCollectionModal.svelte @@ -1,6 +1,6 @@ + +
+
+ + +
+ +
+ + +
+ + {#if selectFieldKeys.length > 0} +
+ + +
+ +
+ + +
+ {/if} + +
+ + +
+
+ + diff --git a/web/src/lib/components/collections/EditCollectionModal.svelte b/web/src/lib/components/collections/EditCollectionModal.svelte index c3e9661b..273a74a2 100644 --- a/web/src/lib/components/collections/EditCollectionModal.svelte +++ b/web/src/lib/components/collections/EditCollectionModal.svelte @@ -12,6 +12,13 @@ validateFieldKey, type EditableField } from './field-editor-types'; + import DisplaySettingsEditor from './DisplaySettingsEditor.svelte'; + import QuickActionsEditor, { type EditableQuickAction } from './QuickActionsEditor.svelte'; + import { + contextFromItem, + placeholderContext, + type PreviewContext + } from '$lib/utils/quick-action-preview'; import { toastStore } from '$lib/stores/toast.svelte'; interface Props { @@ -97,43 +104,33 @@ let listSortBy = $state(''); // ── Quick actions state ───────────────────────────────────────────────── - - interface EditableQuickAction { - label: string; - prompt: string; - scope: 'item' | 'collection'; - icon: string; - } + // Shape comes from QuickActionsEditor; the editor component owns the + // per-card add/remove/reorder logic. let quickActions = $state([]); - function addQuickAction(scope: 'item' | 'collection') { - quickActions.push({ label: '', prompt: '', scope, icon: '' }); - } + /** + * Preview context for the Quick Actions live preview. Populated from the + * first item in the collection on modal open; falls back to placeholder + * values when the collection is empty. + */ + let previewContext = $state(placeholderContext('')); - function removeQuickAction(index: number) { - quickActions.splice(index, 1); + async function loadPreviewContext() { + try { + const items = await api.items.listByCollection(wsSlug, collection.slug, { + limit: 1 + }); + if (items && items.length > 0) { + previewContext = contextFromItem(items[0], collection); + return; + } + } catch { + // Fall through to placeholder. + } + previewContext = placeholderContext(collection.name); } - function moveQuickAction(index: number, direction: -1 | 1) { - const target = index + direction; - if (target < 0 || target >= quickActions.length) return; - const temp = quickActions[index]; - quickActions[index] = quickActions[target]; - quickActions[target] = temp; - } - - let itemActions = $derived( - quickActions - .map((a, i) => ({ action: a, index: i })) - .filter(({ action }) => action.scope === 'item') - ); - let collectionActions = $derived( - quickActions - .map((a, i) => ({ action: a, index: i })) - .filter(({ action }) => action.scope === 'collection') - ); - // Select fields available for grouping (derived from current fields) let selectFieldKeys = $derived( existingFields @@ -201,6 +198,7 @@ })); void loadCollectionOptions(); + void loadPreviewContext(); } }); @@ -657,145 +655,20 @@ {:else if activeTab === 'display'}
-
-
- - -
- -
- - -
- - {#if selectFieldKeys.length > 0} -
- - -
- -
- - -
- {/if} - -
- - -
-
+
{:else if activeTab === 'actions'}
-

- Quick actions copy agent prompts to your clipboard. Use template variables: {'{ref}'}, {'{title}'}, {'{status}'}, {'{priority}'}, {'{collection}'}, {'{content}'}, {'{fields}'}. -

- -
-
- Item actions - -
- {#if itemActions.length > 0} - {#each itemActions as { action, index } (index)} -
-
- - -
- - - -
-
- -
- {/each} - {:else} -
-

No per-item actions yet.

-

- Add one to surface a one-click agent prompt on every item in this - collection — e.g. "Summarize for standup" or "Draft release notes". -

-
- {/if} -
- -
-
- Collection actions - -
- {#if collectionActions.length > 0} - {#each collectionActions as { action, index } (index)} -
-
- - -
- - - -
-
- -
- {/each} - {:else} -
-

No collection-level actions yet.

-

- Collection actions apply to the whole list — e.g. "Triage new items" - or "Archive completed". -

-
- {/if} -
+
{/if} @@ -1034,29 +907,6 @@ gap: var(--space-3); } - /* Shared reorder button — used by Quick Actions rows. The Fields tab - gets its reorder-btn styles from FieldEditor.svelte. */ - .reorder-btn { - background: none; - border: none; - color: var(--text-muted); - font-size: 0.6em; - cursor: pointer; - padding: 2px var(--space-1); - line-height: 1.2; - border-radius: var(--radius-sm); - } - - .reorder-btn:hover:not(:disabled) { - color: var(--text-primary); - background: var(--bg-hover); - } - - .reorder-btn:disabled { - opacity: 0.25; - cursor: default; - } - /* ── Empty state (Fields tab) ─────────────────────────────────────────── */ .empty-state { @@ -1109,46 +959,6 @@ border-color: var(--accent-blue); } - /* ── Display tab ───────────────────────────────────────────────────────── */ - - .settings-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--space-4); - } - - .setting-item { - display: flex; - flex-direction: column; - gap: var(--space-1); - } - - .setting-label { - font-size: 0.82em; - font-weight: 500; - color: var(--text-muted); - } - - .setting-select { - width: 100%; - padding: var(--space-2) var(--space-3); - background: var(--bg-tertiary); - border: 1px solid transparent; - border-radius: var(--radius); - font-size: 0.88em; - color: var(--text-primary); - cursor: pointer; - } - - .setting-select:hover { - border-color: var(--border); - } - - .setting-select:focus { - border-color: var(--accent-blue); - outline: none; - } - /* ── Footer ─────────────────────────────────────────────────────────────── */ .modal-footer { @@ -1309,130 +1119,6 @@ cursor: not-allowed; } - /* ── Quick Actions tab ─────────────────────────────────────────────────── */ - - .tab-description { - font-size: 0.82em; - color: var(--text-muted); - margin: 0; - line-height: 1.5; - } - - .tab-description code { - font-family: var(--font-mono); - font-size: 0.9em; - background: var(--bg-tertiary); - padding: 1px 5px; - border-radius: var(--radius-sm); - } - - .actions-section { - display: flex; - flex-direction: column; - gap: var(--space-2); - } - - .actions-section-header { - display: flex; - align-items: center; - justify-content: space-between; - } - - .actions-section-title { - font-size: 0.75em; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted); - } - - .add-action-btn { - padding: 2px var(--space-3); - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--text-secondary); - font-size: 0.8em; - cursor: pointer; - } - - .add-action-btn:hover { - background: var(--bg-secondary); - color: var(--text-primary); - } - - .action-card { - display: flex; - flex-direction: column; - gap: var(--space-2); - padding: var(--space-3); - background: var(--bg-tertiary); - border-radius: var(--radius); - border: 1px solid var(--border); - } - - .action-card-top { - display: flex; - align-items: center; - gap: var(--space-2); - } - - .action-icon-input { - width: 36px; - text-align: center; - padding: var(--space-1); - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - font-size: 1em; - color: var(--text-primary); - } - - .action-label-input { - flex: 1; - padding: var(--space-1) var(--space-2); - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - font-size: 0.85em; - color: var(--text-primary); - } - - .action-card-btns { - display: flex; - gap: 2px; - } - - .action-prompt-input { - width: 100%; - padding: var(--space-1) var(--space-2); - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - font-size: 0.82em; - font-family: var(--font-mono); - color: var(--text-primary); - } - - .empty-actions { - padding: var(--space-3) var(--space-4); - border: 1px dashed var(--border); - border-radius: var(--radius); - color: var(--text-secondary); - font-size: 0.85em; - line-height: 1.5; - } - - .empty-actions p { - margin: 0; - } - - .empty-actions .empty-actions-hint { - margin-top: var(--space-1); - color: var(--text-muted); - font-size: 0.92em; - } - /* ── Responsive ────────────────────────────────────────────────────────── */ @media (max-width: 640px) { @@ -1467,10 +1153,6 @@ flex-shrink: 0; } - .settings-grid { - grid-template-columns: 1fr; - } - .modal-footer { flex-wrap: wrap; gap: var(--space-2); diff --git a/web/src/lib/components/collections/QuickActionsEditor.svelte b/web/src/lib/components/collections/QuickActionsEditor.svelte new file mode 100644 index 00000000..23e42292 --- /dev/null +++ b/web/src/lib/components/collections/QuickActionsEditor.svelte @@ -0,0 +1,494 @@ + + + + +

+ Quick actions copy agent prompts to your clipboard. Template variables: + {variableHelp} +

+ +
+
+

Item actions

+ +
+ {#if itemActions.length > 0} + {#each itemActions as { action, index } (index)} +
+
+ + +
+ + + +
+
+ + {#if action.prompt.trim()} + {@const segments = parsePrompt(action.prompt, itemScopeContext)} + {@const hasUnknown = segments.some((s) => s.type === 'unknown')} +
+ Preview +
+ {#each segments as seg, i (i)} + {#if seg.type === 'text'}{seg.value}{:else if seg.type === 'known'}{#if seg.resolved === ''}(empty){:else}{seg.resolved}{/if}{:else}{'{' + seg.name + '}'}{/if} + {/each} +
+ {#if hasUnknown} +
+ Highlighted variables aren't recognized and will be copied verbatim. Check for typos. +
+ {/if} +
+ {/if} +
+ {/each} + {:else} +
+

No per-item actions yet.

+

+ Add one to surface a one-click agent prompt on every item in this collection — e.g. + "Summarize for standup" or "Draft release notes". +

+
+ {/if} +
+ +
+
+

Collection actions

+ +
+ {#if collectionActions.length > 0} + {#each collectionActions as { action, index } (index)} +
+
+ + +
+ + + +
+
+ + {#if action.prompt.trim()} + {@const segments = parsePrompt(action.prompt, collectionScopeContext)} + {@const hasUnknown = segments.some((s) => s.type === 'unknown')} +
+ Preview +
+ {#each segments as seg, i (i)} + {#if seg.type === 'text'}{seg.value}{:else if seg.type === 'known'}{#if seg.resolved === ''}(empty){:else}{seg.resolved}{/if}{:else}{'{' + seg.name + '}'}{/if} + {/each} +
+ {#if hasUnknown} +
+ Highlighted variables aren't recognized and will be copied verbatim. Check for typos. +
+ {/if} +
+ {/if} +
+ {/each} + {:else} +
+

No collection-level actions yet.

+

+ Collection actions apply to the whole list — e.g. "Triage new items" or "Archive + completed". +

+
+ {/if} +
+ + diff --git a/web/src/lib/utils/quick-action-preview.ts b/web/src/lib/utils/quick-action-preview.ts new file mode 100644 index 00000000..bcf77143 --- /dev/null +++ b/web/src/lib/utils/quick-action-preview.ts @@ -0,0 +1,145 @@ +import type { Collection, Item } from '$lib/types'; +import { formatItemRef, parseFields } from '$lib/types'; + +/** + * The set of template variables the QuickActionsMenu substitutes at + * runtime. Keep this list in lockstep with the runtime resolver in + * `$lib/components/common/QuickActionsMenu.svelte` so the preview shows + * exactly what users will get when they actually invoke the action. + */ +export const TEMPLATE_VARIABLES = [ + 'ref', + 'title', + 'status', + 'priority', + 'collection', + 'content', + 'fields', + 'plan', + 'phase' +] as const; + +export type TemplateVariable = (typeof TEMPLATE_VARIABLES)[number]; + +const TEMPLATE_VARIABLE_SET: ReadonlySet = new Set(TEMPLATE_VARIABLES); + +export type PreviewContext = Record; + +/** + * Reshape an item-scope preview context into a collection-scope one by + * clearing all item-only variables. Mirrors the runtime in + * QuickActionsMenu.resolvePrompt where `item` is unset for collection- + * scope actions: `ref`, `title`, `status`, `priority`, `content`, `fields`, + * `plan`, `phase` all resolve to empty strings. Only `{collection}` + * survives. + * + * Used so the Quick Actions preview renders the same output the user + * will actually get when they click a collection action. + */ +export function toCollectionScope(ctx: PreviewContext): PreviewContext { + return { + ref: '', + title: '', + status: '', + priority: '', + collection: ctx.collection, + content: '', + fields: '', + plan: '', + phase: '' + }; +} + +/** + * Placeholder context used when no real item is available — e.g. in the + * Create modal (collection doesn't exist yet) or when the collection is + * empty. + */ +export function placeholderContext(collectionName: string): PreviewContext { + return { + ref: 'TASK-42', + title: 'Example item title', + status: 'open', + priority: 'medium', + collection: collectionName || 'Your collection', + content: '(item content goes here)', + fields: 'status: open, priority: medium', + plan: '', + phase: '' + }; +} + +/** + * Build a preview context from a real Item + Collection pair. Mirrors the + * substitution logic in QuickActionsMenu.svelte so the preview is a true + * representation of what copying the prompt would produce. + */ +export function contextFromItem(item: Item, collection: Collection): PreviewContext { + const fields = parseFields(item); + return { + ref: formatItemRef(item) ?? '', + title: item.title ?? '', + status: String(fields['status'] ?? ''), + priority: String(fields['priority'] ?? ''), + collection: collection.name, + content: item.content ? item.content.slice(0, 200) : '', + fields: Object.entries(fields) + .map(([k, v]) => `${k}: ${v}`) + .join(', '), + plan: String(fields['plan'] ?? ''), + phase: String(fields['phase'] ?? fields['plan'] ?? '') + }; +} + +/** + * A single segment of a parsed prompt. + * + * - `text`: literal text between variable references. + * - `known`: a `{var}` that matches a known template variable. Includes + * the resolved value from the preview context so the preview can + * render exactly what the user would get. + * - `unknown`: a `{var}` whose name is NOT in the known set — likely a + * user typo. Rendered in red so the error is visible. + */ +export type PromptSegment = + | { type: 'text'; value: string } + | { type: 'known'; name: TemplateVariable; resolved: string } + | { type: 'unknown'; name: string }; + +// A variable reference starts with a letter or underscore and continues +// with letters / digits / underscores. Kept intentionally narrow so we +// don't accidentally treat JSON snippets or arbitrary braces as vars. +const VAR_PATTERN = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g; + +/** + * Tokenize a prompt into segments using the supplied context. Unknown + * variable names (typos, unsupported vars) are emitted as `unknown` + * segments so the UI can flag them. + */ +export function parsePrompt(prompt: string, ctx: PreviewContext): PromptSegment[] { + const segments: PromptSegment[] = []; + let lastIndex = 0; + // Reset regex lastIndex so repeated calls behave correctly. + VAR_PATTERN.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = VAR_PATTERN.exec(prompt)) !== null) { + if (match.index > lastIndex) { + segments.push({ type: 'text', value: prompt.slice(lastIndex, match.index) }); + } + const name = match[1]; + if (TEMPLATE_VARIABLE_SET.has(name)) { + segments.push({ + type: 'known', + name: name as TemplateVariable, + resolved: ctx[name as TemplateVariable] + }); + } else { + segments.push({ type: 'unknown', name }); + } + lastIndex = VAR_PATTERN.lastIndex; + } + if (lastIndex < prompt.length) { + segments.push({ type: 'text', value: prompt.slice(lastIndex) }); + } + return segments; +}