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; +}