feat(web): create-time Display/Quick Actions + live prompt preview (TASK-599) (#139)

* 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.
This commit is contained in:
xarmian
2026-04-17 18:15:17 -04:00
committed by GitHub
parent 6d5fa969e2
commit bafb3c2be5
5 changed files with 1057 additions and 359 deletions
@@ -1,6 +1,6 @@
<script lang="ts">
import { api } from '$lib/api/client';
import type { CollectionCreate, FieldDef, CollectionSettings } from '$lib/types';
import type { CollectionCreate, FieldDef, CollectionSettings, QuickAction } from '$lib/types';
import { COLLECTION_TEMPLATES, type CollectionTemplate } from './collection-templates';
import EmojiPickerButton from '$lib/components/common/EmojiPickerButton.svelte';
import FieldEditor, { type CollectionOption } from './FieldEditor.svelte';
@@ -12,6 +12,9 @@
validateFieldKey,
type EditableField
} from './field-editor-types';
import DisplaySettingsEditor from './DisplaySettingsEditor.svelte';
import QuickActionsEditor, { type EditableQuickAction } from './QuickActionsEditor.svelte';
import { placeholderContext, type PreviewContext } from '$lib/utils/quick-action-preview';
import { toastStore } from '$lib/stores/toast.svelte';
interface Props {
@@ -42,6 +45,56 @@
let collectionOptions = $state<CollectionOption[]>([]);
let collectionsRequestToken = 0;
// ── Advanced settings (Display + Quick Actions) ─────────────────────────
// Default view / layout / group-by / sort-by / quick-actions can all be
// configured at create time under a collapsible Advanced reveal. The
// reveal defaults closed so the fast-path "pick a template, name it,
// create" flow stays uncluttered.
let showAdvanced = $state(false);
let defaultView = $state<'list' | 'board' | 'table'>('list');
let layout = $state<'fields-primary' | 'content-primary' | 'balanced'>('balanced');
let boardGroupBy = $state('status');
let listGroupBy = $state('');
let listSortBy = $state('');
let quickActions = $state<EditableQuickAction[]>([]);
// The collection doesn't exist yet, so the preview context falls back to
// representative placeholder values. Updates live as the collection name
// changes so the {collection} token reflects what will be saved.
const previewContext = $derived<PreviewContext>(placeholderContext(name.trim()));
// Derive group-by / sort options from the fields the user has added so
// far. Mirrors the Edit modal's derivation but against EditableField[].
const selectFieldKeys = $derived(
fields
.filter((f) => (f.type === 'select' || f.type === 'multi_select') && f.key.trim())
.map((f) => ({ key: f.key.trim(), label: f.label.trim() || f.key.trim() }))
);
const sortableFieldKeys = $derived([
...fields
.filter((f) => f.key.trim())
.map((f) => ({ key: f.key.trim(), label: f.label.trim() || f.key.trim() })),
{ key: 'created_at', label: 'Created date' },
{ key: 'updated_at', label: 'Updated date' },
{ key: 'sort_order', label: 'Manual order' }
]);
// If the current boardGroupBy points at a field that no longer exists
// (or the user removed all select fields), fall back to the first
// available select field so the Display UI stays valid. We only do this
// when the advanced section is open to avoid surprise state mutations
// while the user hasn't engaged with it.
$effect(() => {
if (!showAdvanced) return;
if (selectFieldKeys.length === 0) return;
if (!selectFieldKeys.some((f) => f.key === boardGroupBy)) {
boardGroupBy = selectFieldKeys[0].key;
}
if (listGroupBy && !selectFieldKeys.some((f) => f.key === listGroupBy)) {
listGroupBy = '';
}
});
// Track previous open state to detect open transitions
let prevOpen = $state(false);
@@ -54,6 +107,13 @@
description = '';
fields = [];
selectedSettings = null;
showAdvanced = false;
defaultView = 'list';
layout = 'balanced';
boardGroupBy = 'status';
listGroupBy = '';
listSortBy = '';
quickActions = [];
error = '';
void loadCollectionOptions();
}
@@ -89,6 +149,13 @@
description = '';
fields = [];
selectedSettings = null;
showAdvanced = false;
defaultView = 'list';
layout = 'balanced';
boardGroupBy = 'status';
listGroupBy = '';
listSortBy = '';
quickActions = [];
error = '';
}
@@ -102,6 +169,31 @@
// Template fields already have valid keys — use fieldFromDef with
// existing=true so keyTouched=true and slugify doesn't overwrite.
fields = template.fields.map((f) => fieldFromDef(f, true));
// Pre-fill advanced state from the template's settings so the
// Display tab reflects what the template ships with. The user can
// still inspect/override via the Advanced reveal.
const s = template.settings;
if (s.default_view === 'list' || s.default_view === 'board' || s.default_view === 'table') {
defaultView = s.default_view;
}
if (
s.layout === 'fields-primary' ||
s.layout === 'content-primary' ||
s.layout === 'balanced'
) {
layout = s.layout;
}
if (s.board_group_by) boardGroupBy = s.board_group_by;
if (s.list_group_by) listGroupBy = s.list_group_by;
if (s.list_sort_by) listSortBy = s.list_sort_by;
if (s.quick_actions) {
quickActions = s.quick_actions.map((a) => ({
label: a.label,
prompt: a.prompt,
scope: a.scope,
icon: a.icon ?? ''
}));
}
}
step = 'editor';
}
@@ -236,12 +328,44 @@
return def;
});
// Bundle the Advanced reveal state into the saved settings. Start
// from any template-provided settings so template defaults (e.g.
// Bug Tracker's board_group_by) are preserved, then overlay the
// user's explicit choices. Quick actions are filtered to those
// with both a label and a prompt so we don't persist empty rows.
const savedActions: QuickAction[] = quickActions
.filter((a) => a.label.trim() && a.prompt.trim())
.map((a) => ({
label: a.label.trim(),
prompt: a.prompt.trim(),
scope: a.scope,
...(a.icon.trim() ? { icon: a.icon.trim() } : {})
}));
// Strip `quick_actions` from the template base so it can't survive
// the "user removed all quick actions" case. Template quick actions
// are already mirrored into `quickActions` state when the template
// is picked, so `savedActions` is the single source of truth here —
// taking anything from selectedSettings.quick_actions would let
// stale rows resurrect.
const { quick_actions: _templateActions, ...baseSettings } = selectedSettings ?? {};
const settingsObj: CollectionSettings = {
...baseSettings,
default_view: defaultView,
layout,
board_group_by: boardGroupBy || undefined,
list_group_by: listGroupBy || undefined,
list_sort_by: listSortBy || undefined,
...(savedActions.length > 0 ? { quick_actions: savedActions } : {})
};
const data: CollectionCreate = {
name: name.trim(),
icon: selectedIcon || undefined,
description: description.trim() || undefined,
schema: JSON.stringify({ fields: fieldDefs }),
settings: selectedSettings ? JSON.stringify(selectedSettings) : undefined
settings: JSON.stringify(settingsObj)
};
await api.collections.create(wsSlug, data);
toastStore.show(`Created ${name.trim()}`, 'success');
@@ -356,6 +480,57 @@
{/if}
<button class="add-field-btn" type="button" onclick={addField}>+ Add field</button>
</div>
<!-- ── Advanced: Display settings + Quick Actions ─────────
Defaults collapsed so the simple path stays short. The
template pre-fill already lives in these states, so even
if the user never opens Advanced the saved settings from
a picked template are preserved.
-->
<section class="advanced-section">
<button
type="button"
class="advanced-toggle"
onclick={() => (showAdvanced = !showAdvanced)}
aria-expanded={showAdvanced}
>
<span class="advanced-chevron" class:open={showAdvanced} aria-hidden="true">
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path
d="M3 2L7 5L3 8"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span>Advanced</span>
<span class="advanced-sub">Display settings · Quick actions</span>
</button>
{#if showAdvanced}
<div class="advanced-content">
<div class="advanced-block">
<h3 class="advanced-block-title">Display</h3>
<DisplaySettingsEditor
bind:defaultView
bind:layout
bind:boardGroupBy
bind:listGroupBy
bind:listSortBy
{selectFieldKeys}
{sortableFieldKeys}
/>
</div>
<div class="advanced-block">
<h3 class="advanced-block-title">Quick actions</h3>
<QuickActionsEditor bind:actions={quickActions} {previewContext} />
</div>
</div>
{/if}
</section>
</div>
<div class="modal-footer">
@@ -719,6 +894,67 @@
cursor: not-allowed;
}
/* ── Advanced reveal ──────────────────────────────────────────────────── */
.advanced-section {
margin-top: var(--space-4);
border-top: 1px solid var(--border);
padding-top: var(--space-3);
}
.advanced-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) 0;
background: none;
border: none;
color: var(--text-secondary);
font-size: 0.85em;
font-weight: 500;
cursor: pointer;
text-align: left;
}
.advanced-toggle:hover {
color: var(--text-primary);
}
.advanced-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease;
color: var(--text-muted);
}
.advanced-chevron.open {
transform: rotate(90deg);
}
.advanced-sub {
color: var(--text-muted);
font-size: 0.82em;
font-weight: 400;
}
.advanced-content {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-3) 0;
}
.advanced-block-title {
margin: 0 0 var(--space-2);
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
/* ── Responsive ────────────────────────────────────────────────────────── */
@media (max-width: 640px) {
@@ -0,0 +1,141 @@
<script lang="ts">
/**
* Display settings block shared by CreateCollectionModal (inside its
* "Advanced" reveal) and EditCollectionModal (as its Display tab
* content). Pure presentation — all state is bindable and owned by
* the parent modal.
*
* Select-field-dependent controls (board / list group-by) are only
* rendered when the parent passes in at least one select-type field;
* otherwise there's nothing sensible to group by.
*/
export interface DisplayFieldOption {
key: string;
label: string;
}
type DefaultView = 'list' | 'board' | 'table';
type Layout = 'fields-primary' | 'content-primary' | 'balanced';
interface Props {
defaultView: DefaultView;
layout: Layout;
boardGroupBy: string;
listGroupBy: string;
listSortBy: string;
/** Keys of select / multi_select fields — used to populate group-by options. */
selectFieldKeys: DisplayFieldOption[];
/** Keys available for sorting — typically all fields + created/updated/manual. */
sortableFieldKeys: DisplayFieldOption[];
}
let {
defaultView = $bindable(),
layout = $bindable(),
boardGroupBy = $bindable(),
listGroupBy = $bindable(),
listSortBy = $bindable(),
selectFieldKeys,
sortableFieldKeys
}: Props = $props();
</script>
<div class="settings-grid">
<div class="setting-item">
<label class="setting-label" for="ds-default-view">Default view</label>
<select id="ds-default-view" class="setting-select" bind:value={defaultView}>
<option value="list">List</option>
<option value="board">Board</option>
<option value="table">Table</option>
</select>
</div>
<div class="setting-item">
<label class="setting-label" for="ds-layout">Item layout</label>
<select id="ds-layout" class="setting-select" bind:value={layout}>
<option value="balanced">Balanced</option>
<option value="fields-primary">Fields primary</option>
<option value="content-primary">Content primary</option>
</select>
</div>
{#if selectFieldKeys.length > 0}
<div class="setting-item">
<label class="setting-label" for="ds-board-group">Board group by</label>
<select id="ds-board-group" class="setting-select" bind:value={boardGroupBy}>
{#each selectFieldKeys as f (f.key)}
<option value={f.key}>{f.label}</option>
{/each}
</select>
</div>
<div class="setting-item">
<label class="setting-label" for="ds-list-group">List group by</label>
<select id="ds-list-group" class="setting-select" bind:value={listGroupBy}>
<option value="">None</option>
{#each selectFieldKeys as f (f.key)}
<option value={f.key}>{f.label}</option>
{/each}
</select>
</div>
{/if}
<div class="setting-item">
<label class="setting-label" for="ds-list-sort">List sort by</label>
<select id="ds-list-sort" class="setting-select" bind:value={listSortBy}>
<option value="">Default</option>
{#each sortableFieldKeys as f (f.key)}
<option value={f.key}>{f.label}</option>
{/each}
</select>
</div>
</div>
<style>
.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.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
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;
}
@media (max-width: 640px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -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<EditableQuickAction[]>([]);
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<PreviewContext>(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'}
<!-- ── Display Tab ─────────────────────────────────────── -->
<div class="tab-content">
<div class="settings-grid">
<div class="setting-item">
<label class="setting-label" for="edit-default-view">Default view</label>
<select id="edit-default-view" class="setting-select" bind:value={defaultView}>
<option value="list">List</option>
<option value="board">Board</option>
<option value="table">Table</option>
</select>
</div>
<div class="setting-item">
<label class="setting-label" for="edit-layout">Item layout</label>
<select id="edit-layout" class="setting-select" bind:value={layout}>
<option value="balanced">Balanced</option>
<option value="fields-primary">Fields primary</option>
<option value="content-primary">Content primary</option>
</select>
</div>
{#if selectFieldKeys.length > 0}
<div class="setting-item">
<label class="setting-label" for="edit-board-group">Board group by</label>
<select id="edit-board-group" class="setting-select" bind:value={boardGroupBy}>
{#each selectFieldKeys as f (f.key)}
<option value={f.key}>{f.label}</option>
{/each}
</select>
</div>
<div class="setting-item">
<label class="setting-label" for="edit-list-group">List group by</label>
<select id="edit-list-group" class="setting-select" bind:value={listGroupBy}>
<option value="">None</option>
{#each selectFieldKeys as f (f.key)}
<option value={f.key}>{f.label}</option>
{/each}
</select>
</div>
{/if}
<div class="setting-item">
<label class="setting-label" for="edit-list-sort">List sort by</label>
<select id="edit-list-sort" class="setting-select" bind:value={listSortBy}>
<option value="">Default</option>
{#each sortableFieldKeys as f (f.key)}
<option value={f.key}>{f.label}</option>
{/each}
</select>
</div>
</div>
<DisplaySettingsEditor
bind:defaultView
bind:layout
bind:boardGroupBy
bind:listGroupBy
bind:listSortBy
{selectFieldKeys}
{sortableFieldKeys}
/>
</div>
{:else if activeTab === 'actions'}
<!-- ── Quick Actions Tab ──────────────────────────────── -->
<div class="tab-content">
<p class="tab-description">
Quick actions copy agent prompts to your clipboard. Use template variables: <code>{'{ref}'}</code>, <code>{'{title}'}</code>, <code>{'{status}'}</code>, <code>{'{priority}'}</code>, <code>{'{collection}'}</code>, <code>{'{content}'}</code>, <code>{'{fields}'}</code>.
</p>
<div class="actions-section">
<div class="actions-section-header">
<span class="actions-section-title">Item actions</span>
<button class="add-action-btn" type="button" onclick={() => addQuickAction('item')}>+ Add</button>
</div>
{#if itemActions.length > 0}
{#each itemActions as { action, index } (index)}
<div class="action-card">
<div class="action-card-top">
<EmojiPickerButton bind:value={action.icon} placeholder="⚡" />
<input
class="action-label-input"
type="text"
placeholder="Action label"
bind:value={action.label}
/>
<div class="action-card-btns">
<button class="reorder-btn" type="button" disabled={index === 0} onclick={() => moveQuickAction(index, -1)} title="Move up">&#9650;</button>
<button class="reorder-btn" type="button" disabled={index === quickActions.length - 1} onclick={() => moveQuickAction(index, 1)} title="Move down">&#9660;</button>
<button class="remove-field-btn" type="button" onclick={() => removeQuickAction(index)} title="Remove">&#10005;</button>
</div>
</div>
<input
class="action-prompt-input"
type="text"
placeholder="/pad implement {'{ref}'} &quot;{'{title}'}&quot;"
bind:value={action.prompt}
/>
</div>
{/each}
{:else}
<div class="empty-actions">
<p>No per-item actions yet.</p>
<p class="empty-actions-hint">
Add one to surface a one-click agent prompt on every item in this
collection — e.g. "Summarize for standup" or "Draft release notes".
</p>
</div>
{/if}
</div>
<div class="actions-section">
<div class="actions-section-header">
<span class="actions-section-title">Collection actions</span>
<button class="add-action-btn" type="button" onclick={() => addQuickAction('collection')}>+ Add</button>
</div>
{#if collectionActions.length > 0}
{#each collectionActions as { action, index } (index)}
<div class="action-card">
<div class="action-card-top">
<EmojiPickerButton bind:value={action.icon} placeholder="⚡" />
<input
class="action-label-input"
type="text"
placeholder="Action label"
bind:value={action.label}
/>
<div class="action-card-btns">
<button class="reorder-btn" type="button" disabled={index === 0} onclick={() => moveQuickAction(index, -1)} title="Move up">&#9650;</button>
<button class="reorder-btn" type="button" disabled={index === quickActions.length - 1} onclick={() => moveQuickAction(index, 1)} title="Move down">&#9660;</button>
<button class="remove-field-btn" type="button" onclick={() => removeQuickAction(index)} title="Remove">&#10005;</button>
</div>
</div>
<input
class="action-prompt-input"
type="text"
placeholder="/pad triage all new items"
bind:value={action.prompt}
/>
</div>
{/each}
{:else}
<div class="empty-actions">
<p>No collection-level actions yet.</p>
<p class="empty-actions-hint">
Collection actions apply to the whole list — e.g. "Triage new items"
or "Archive completed".
</p>
</div>
{/if}
</div>
<QuickActionsEditor bind:actions={quickActions} {previewContext} />
</div>
{/if}
</div>
@@ -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);
@@ -0,0 +1,494 @@
<script lang="ts" module>
/**
* Shape of an editable quick action — identical to QuickAction in $lib/types
* but with `icon` as a required string (empty when unset) to avoid
* undefined-vs-empty churn in the bound inputs.
*/
export interface EditableQuickAction {
label: string;
prompt: string;
scope: 'item' | 'collection';
icon: string;
}
</script>
<script lang="ts">
import EmojiPickerButton from '$lib/components/common/EmojiPickerButton.svelte';
import {
parsePrompt,
TEMPLATE_VARIABLES,
toCollectionScope,
type PreviewContext
} from '$lib/utils/quick-action-preview';
interface Props {
/** The full list of quick actions — bindable so the parent owns the state. */
actions: EditableQuickAction[];
/**
* Preview context used to render the live substitution below each
* prompt input. Parent decides whether this came from a real item
* or a placeholder.
*/
previewContext: PreviewContext;
}
let { actions = $bindable(), previewContext }: Props = $props();
// Scope-aware preview contexts. Collection-scope actions run without
// an item in QuickActionsMenu, so item-only variables resolve to
// empty strings. We reshape the incoming context per scope so the
// preview matches runtime output exactly.
const itemScopeContext = $derived(previewContext);
const collectionScopeContext = $derived(toCollectionScope(previewContext));
let itemActions = $derived(
actions.map((a, i) => ({ action: a, index: i })).filter(({ action }) => action.scope === 'item')
);
let collectionActions = $derived(
actions
.map((a, i) => ({ action: a, index: i }))
.filter(({ action }) => action.scope === 'collection')
);
function addAction(scope: 'item' | 'collection') {
actions.push({ label: '', prompt: '', scope, icon: '' });
}
function removeAction(index: number) {
actions.splice(index, 1);
}
function moveAction(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= actions.length) return;
const temp = actions[index];
actions[index] = actions[target];
actions[target] = temp;
}
// Human-readable list of supported variables, used in the help line.
const variableHelp = TEMPLATE_VARIABLES.map((v) => `{${v}}`).join(' · ');
</script>
<p class="qa-hint">
Quick actions copy agent prompts to your clipboard. Template variables:
<code class="qa-var-help">{variableHelp}</code>
</p>
<section class="qa-section">
<header class="qa-section-header">
<h4 class="qa-section-title">Item actions</h4>
<button type="button" class="qa-add-btn" onclick={() => addAction('item')}>+ Add</button>
</header>
{#if itemActions.length > 0}
{#each itemActions as { action, index } (index)}
<div class="qa-card">
<div class="qa-card-top">
<EmojiPickerButton bind:value={actions[index].icon} placeholder="⚡" />
<input
class="qa-label-input"
type="text"
placeholder="Action label"
bind:value={actions[index].label}
/>
<div class="qa-card-btns">
<button
type="button"
class="qa-reorder-btn"
disabled={index === 0}
onclick={() => moveAction(index, -1)}
title="Move up"
aria-label="Move action up"
>&#9650;</button>
<button
type="button"
class="qa-reorder-btn"
disabled={index === actions.length - 1}
onclick={() => moveAction(index, 1)}
title="Move down"
aria-label="Move action down"
>&#9660;</button>
<button
type="button"
class="qa-remove-btn"
onclick={() => removeAction(index)}
title="Remove"
aria-label="Remove action"
>&#10005;</button>
</div>
</div>
<input
class="qa-prompt-input"
type="text"
placeholder={'/pad implement {ref} "{title}"'}
bind:value={actions[index].prompt}
/>
{#if action.prompt.trim()}
{@const segments = parsePrompt(action.prompt, itemScopeContext)}
{@const hasUnknown = segments.some((s) => s.type === 'unknown')}
<div class="qa-preview" class:has-error={hasUnknown}>
<span class="qa-preview-label">Preview</span>
<div class="qa-preview-body">
{#each segments as seg, i (i)}
{#if seg.type === 'text'}<span class="qa-seg-text"
>{seg.value}</span
>{:else if seg.type === 'known'}{#if seg.resolved === ''}<span
class="qa-seg-empty"
title={'{' + seg.name + '} resolves to an empty string at runtime'}
>(empty)</span
>{:else}<span
class="qa-seg-known"
title={'{' + seg.name + '}'}>{seg.resolved}</span
>{/if}{:else}<span
class="qa-seg-unknown"
title="Unknown variable — will copy literally">{'{' + seg.name + '}'}</span
>{/if}
{/each}
</div>
{#if hasUnknown}
<div class="qa-preview-warn">
Highlighted variables aren't recognized and will be copied verbatim. Check for typos.
</div>
{/if}
</div>
{/if}
</div>
{/each}
{:else}
<div class="qa-empty">
<p>No per-item actions yet.</p>
<p class="qa-empty-hint">
Add one to surface a one-click agent prompt on every item in this collection — e.g.
"Summarize for standup" or "Draft release notes".
</p>
</div>
{/if}
</section>
<section class="qa-section">
<header class="qa-section-header">
<h4 class="qa-section-title">Collection actions</h4>
<button type="button" class="qa-add-btn" onclick={() => addAction('collection')}>+ Add</button>
</header>
{#if collectionActions.length > 0}
{#each collectionActions as { action, index } (index)}
<div class="qa-card">
<div class="qa-card-top">
<EmojiPickerButton bind:value={actions[index].icon} placeholder="⚡" />
<input
class="qa-label-input"
type="text"
placeholder="Action label"
bind:value={actions[index].label}
/>
<div class="qa-card-btns">
<button
type="button"
class="qa-reorder-btn"
disabled={index === 0}
onclick={() => moveAction(index, -1)}
title="Move up"
aria-label="Move action up"
>&#9650;</button>
<button
type="button"
class="qa-reorder-btn"
disabled={index === actions.length - 1}
onclick={() => moveAction(index, 1)}
title="Move down"
aria-label="Move action down"
>&#9660;</button>
<button
type="button"
class="qa-remove-btn"
onclick={() => removeAction(index)}
title="Remove"
aria-label="Remove action"
>&#10005;</button>
</div>
</div>
<input
class="qa-prompt-input"
type="text"
placeholder="/pad triage all new items"
bind:value={actions[index].prompt}
/>
{#if action.prompt.trim()}
{@const segments = parsePrompt(action.prompt, collectionScopeContext)}
{@const hasUnknown = segments.some((s) => s.type === 'unknown')}
<div class="qa-preview" class:has-error={hasUnknown}>
<span class="qa-preview-label">Preview</span>
<div class="qa-preview-body">
{#each segments as seg, i (i)}
{#if seg.type === 'text'}<span class="qa-seg-text"
>{seg.value}</span
>{:else if seg.type === 'known'}{#if seg.resolved === ''}<span
class="qa-seg-empty"
title={'{' + seg.name + '} resolves to an empty string at runtime'}
>(empty)</span
>{:else}<span
class="qa-seg-known"
title={'{' + seg.name + '}'}>{seg.resolved}</span
>{/if}{:else}<span
class="qa-seg-unknown"
title="Unknown variable — will copy literally">{'{' + seg.name + '}'}</span
>{/if}
{/each}
</div>
{#if hasUnknown}
<div class="qa-preview-warn">
Highlighted variables aren't recognized and will be copied verbatim. Check for typos.
</div>
{/if}
</div>
{/if}
</div>
{/each}
{:else}
<div class="qa-empty">
<p>No collection-level actions yet.</p>
<p class="qa-empty-hint">
Collection actions apply to the whole list — e.g. "Triage new items" or "Archive
completed".
</p>
</div>
{/if}
</section>
<style>
.qa-hint {
margin: 0 0 var(--space-2);
font-size: 0.82em;
color: var(--text-muted);
line-height: 1.5;
}
.qa-var-help {
display: block;
margin-top: var(--space-1);
font-family: var(--font-mono);
font-size: 0.92em;
color: var(--text-secondary);
line-height: 1.6;
}
.qa-section {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-4);
}
.qa-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0;
}
.qa-section-title {
margin: 0;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.qa-add-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;
}
.qa-add-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.qa-card {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.qa-card-top {
display: flex;
align-items: center;
gap: var(--space-2);
}
.qa-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);
}
.qa-label-input:focus {
border-color: var(--accent-blue);
outline: none;
}
.qa-card-btns {
display: flex;
gap: 2px;
}
.qa-reorder-btn,
.qa-remove-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px var(--space-1);
border-radius: var(--radius-sm);
line-height: 1.2;
}
.qa-reorder-btn {
font-size: 0.6em;
}
.qa-remove-btn {
font-size: 0.82em;
}
.qa-reorder-btn:hover:not(:disabled),
.qa-remove-btn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.qa-remove-btn:hover {
color: var(--accent-red, #ef4444);
background: color-mix(in srgb, var(--accent-red, #ef4444) 10%, transparent);
}
.qa-reorder-btn:disabled {
opacity: 0.25;
cursor: default;
}
.qa-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);
}
.qa-prompt-input:focus {
border-color: var(--accent-blue);
outline: none;
}
/* ── Live preview ─────────────────────────────────────────────────────── */
.qa-preview {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.qa-preview.has-error {
border-color: color-mix(in srgb, var(--accent-amber, #fbbf24) 50%, var(--border));
}
.qa-preview-label {
font-size: 0.68em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.qa-preview-body {
font-family: var(--font-mono);
font-size: 0.82em;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
}
.qa-seg-text {
color: var(--text-secondary);
}
.qa-seg-known {
color: var(--text-primary);
background: color-mix(in srgb, var(--accent-blue) 14%, transparent);
padding: 0 2px;
border-radius: 2px;
}
/*
* A known variable that resolves to an empty string at runtime (e.g.
* {plan} with no plan set, or any item-only variable in a collection-
* scope action). Renders "(empty)" in muted italic so the user sees
* the variable was recognized but produces nothing — matches runtime
* behavior exactly (runtime copies nothing for that position).
*/
.qa-seg-empty {
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
padding: 0 2px;
border-radius: 2px;
font-style: italic;
}
.qa-seg-unknown {
color: var(--accent-red, #ef4444);
background: color-mix(in srgb, var(--accent-red, #ef4444) 14%, transparent);
padding: 0 2px;
border-radius: 2px;
text-decoration: underline wavy currentColor;
text-underline-offset: 2px;
}
.qa-preview-warn {
font-size: 0.72em;
color: var(--accent-amber, #fbbf24);
line-height: 1.4;
}
/* ── Empty state ─────────────────────────────────────────────────────── */
.qa-empty {
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;
}
.qa-empty p {
margin: 0;
}
.qa-empty .qa-empty-hint {
margin-top: var(--space-1);
color: var(--text-muted);
font-size: 0.92em;
}
</style>
+145
View File
@@ -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<string> = new Set(TEMPLATE_VARIABLES);
export type PreviewContext = Record<TemplateVariable, string>;
/**
* 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;
}