mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(ai): Missions UI under /companion/missions
Create, review, and control AI Missions from the app. Closes the last
UX gap in the end-to-end pipeline — users no longer need the Dexie
console to drive the Runner.
- `data/ai/missions/queries.ts` — `useMissions({ state? })` live query
+ single-mission `useMission(id)`. Decryption-ready via `decryptRecords`
wrapper (no-op today, future-proof when/if Missions get added to the
crypto registry).
- `routes/(app)/companion/missions/+page.svelte`
- Inline create form: title + objective + markdown concept + cadence
picker (manual / interval-minutes / daily-hour). Weekly + cron are
wired in state but not exposed until a richer picker is worth it.
- List / detail layout, responsive to narrow viewports.
- Detail view: run-now button (invokes runMission with productionDeps),
pause/resume/complete/delete lifecycle actions, iteration history
with per-iteration feedback form.
- Uses shared-icons + scoped CSS with theme-token fallbacks.
- `routes/(app)/companion/+page.svelte` — footer nav links to
/companion/missions and /companion/rituals from the chat sidebar.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b7a76ca24f
commit
3a8c019ab0
3 changed files with 740 additions and 0 deletions
39
apps/mana/apps/web/src/lib/data/ai/missions/queries.ts
Normal file
39
apps/mana/apps/web/src/lib/data/ai/missions/queries.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Svelte 5 reactive queries over the `aiMissions` Dexie table.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '../../database';
|
||||
import { decryptRecords } from '../../crypto';
|
||||
import type { Mission, MissionState } from './types';
|
||||
import { MISSIONS_TABLE } from './types';
|
||||
|
||||
export interface UseMissionsOptions {
|
||||
state?: MissionState;
|
||||
}
|
||||
|
||||
/** All non-deleted missions, newest first. */
|
||||
export function useMissions(options: UseMissionsOptions = {}) {
|
||||
const { state } = options;
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table<Mission>(MISSIONS_TABLE).orderBy('createdAt').reverse().toArray();
|
||||
const visible = all.filter((m) => !m.deletedAt);
|
||||
const filtered = state ? visible.filter((m) => m.state === state) : visible;
|
||||
// Decrypt user-typed fields if the table ever gets added to the crypto
|
||||
// registry. Today it isn't, so this is a no-op; keeps the hook future-proof.
|
||||
return decryptRecords(MISSIONS_TABLE, filtered) as Promise<Mission[]>;
|
||||
}, [] as Mission[]);
|
||||
}
|
||||
|
||||
/** Single mission by id, reactively. */
|
||||
export function useMission(id: string) {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const m = await db.table<Mission>(MISSIONS_TABLE).get(id);
|
||||
if (!m || m.deletedAt) return null;
|
||||
const [decrypted] = await decryptRecords(MISSIONS_TABLE, [m]);
|
||||
return decrypted as Mission;
|
||||
},
|
||||
null as Mission | null
|
||||
);
|
||||
}
|
||||
|
|
@ -88,6 +88,11 @@
|
|||
<p class="empty-hint">Noch keine Gespraeche. Starte mit dem + Button.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-footer">
|
||||
<a href="/companion/missions">AI Missions →</a>
|
||||
<a href="/companion/rituals">Rituale →</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Chat Area -->
|
||||
|
|
@ -283,4 +288,20 @@
|
|||
.start-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--color-border, #ddd);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.sidebar-footer a {
|
||||
color: var(--color-muted, #888);
|
||||
text-decoration: none;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.sidebar-footer a:hover {
|
||||
color: var(--color-primary, #6b5bff);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,680 @@
|
|||
<!--
|
||||
/companion/missions — Create and review AI Missions.
|
||||
|
||||
Minimal MVP: list on the left, expanded detail on the right (or below
|
||||
on narrow screens). Inline create form at the top. Clicking a mission
|
||||
shows its iteration history + controls (pause/resume/complete/delete).
|
||||
|
||||
No input picker yet — missions can be created with only concept +
|
||||
objective + cadence. Linking to notes / goals / kontext is a follow-up.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Sparkle, Play, Pause, Check, Trash, Plus, ArrowLeft } from '@mana/shared-icons';
|
||||
import { useMissions } from '$lib/data/ai/missions/queries';
|
||||
import {
|
||||
createMission,
|
||||
pauseMission,
|
||||
resumeMission,
|
||||
completeMission,
|
||||
deleteMission,
|
||||
addIterationFeedback,
|
||||
} from '$lib/data/ai/missions/store';
|
||||
import { runMission } from '$lib/data/ai/missions/runner';
|
||||
import { productionDeps } from '$lib/data/ai/missions/setup';
|
||||
import type { Mission, MissionCadence } from '$lib/data/ai/missions/types';
|
||||
|
||||
const missions = $derived(useMissions());
|
||||
|
||||
let selectedId = $state<string | null>(null);
|
||||
const selected = $derived(missions.value.find((m) => m.id === selectedId) ?? null);
|
||||
|
||||
// ── Create form ────────────────────────────────────────
|
||||
let showForm = $state(false);
|
||||
let formTitle = $state('');
|
||||
let formObjective = $state('');
|
||||
let formConcept = $state('');
|
||||
let formCadenceKind = $state<MissionCadence['kind']>('manual');
|
||||
let formIntervalMin = $state(60);
|
||||
let formDailyHour = $state(9);
|
||||
let creating = $state(false);
|
||||
|
||||
function buildCadence(): MissionCadence {
|
||||
switch (formCadenceKind) {
|
||||
case 'manual':
|
||||
return { kind: 'manual' };
|
||||
case 'interval':
|
||||
return { kind: 'interval', everyMinutes: formIntervalMin };
|
||||
case 'daily':
|
||||
return { kind: 'daily', atHour: formDailyHour, atMinute: 0 };
|
||||
case 'weekly':
|
||||
return { kind: 'weekly', dayOfWeek: 1, atHour: formDailyHour };
|
||||
case 'cron':
|
||||
return { kind: 'cron', expression: '' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!formTitle.trim() || !formObjective.trim()) return;
|
||||
creating = true;
|
||||
try {
|
||||
const m = await createMission({
|
||||
title: formTitle.trim(),
|
||||
objective: formObjective.trim(),
|
||||
conceptMarkdown: formConcept,
|
||||
cadence: buildCadence(),
|
||||
});
|
||||
formTitle = '';
|
||||
formObjective = '';
|
||||
formConcept = '';
|
||||
formCadenceKind = 'manual';
|
||||
showForm = false;
|
||||
selectedId = m.id;
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detail actions ─────────────────────────────────────
|
||||
let runningNow = $state(false);
|
||||
async function handleRunNow(m: Mission) {
|
||||
runningNow = true;
|
||||
try {
|
||||
await runMission(m.id, productionDeps);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
runningNow = false;
|
||||
}
|
||||
}
|
||||
|
||||
let feedbackDraft = $state('');
|
||||
async function handleFeedback(m: Mission, iterationId: string) {
|
||||
if (!feedbackDraft.trim()) return;
|
||||
await addIterationFeedback(m.id, iterationId, feedbackDraft.trim());
|
||||
feedbackDraft = '';
|
||||
}
|
||||
|
||||
function describeCadence(c: MissionCadence): string {
|
||||
switch (c.kind) {
|
||||
case 'manual':
|
||||
return 'manuell';
|
||||
case 'interval':
|
||||
return `alle ${c.everyMinutes} min`;
|
||||
case 'daily':
|
||||
return `täglich ${String(c.atHour).padStart(2, '0')}:${String(c.atMinute).padStart(2, '0')}`;
|
||||
case 'weekly':
|
||||
return `wöchentlich (Tag ${c.dayOfWeek}, ${c.atHour}:00)`;
|
||||
case 'cron':
|
||||
return `cron: ${c.expression}`;
|
||||
}
|
||||
}
|
||||
|
||||
function describeState(s: Mission['state']): string {
|
||||
return { active: 'aktiv', paused: 'pausiert', done: 'abgeschlossen', archived: 'archiviert' }[
|
||||
s
|
||||
];
|
||||
}
|
||||
|
||||
function formatRelative(iso: string | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
const deltaMs = d.getTime() - Date.now();
|
||||
const abs = Math.abs(deltaMs);
|
||||
const mins = Math.round(abs / 60_000);
|
||||
const hours = Math.round(mins / 60);
|
||||
const days = Math.round(hours / 24);
|
||||
const value = mins < 60 ? `${mins}m` : hours < 24 ? `${hours}h` : `${days}d`;
|
||||
return deltaMs < 0 ? `vor ${value}` : `in ${value}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Missions - Companion</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<a href="/companion" class="back">
|
||||
<ArrowLeft size={16} />
|
||||
<span>Companion</span>
|
||||
</a>
|
||||
<div class="title">
|
||||
<Sparkle size={20} weight="fill" />
|
||||
<h1>AI Missions</h1>
|
||||
</div>
|
||||
<button type="button" class="primary-btn" onclick={() => (showForm = !showForm)}>
|
||||
<Plus size={16} />
|
||||
<span>{showForm ? 'Abbrechen' : 'Neue Mission'}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showForm}
|
||||
<form class="create-form" onsubmit={(e) => (e.preventDefault(), handleCreate())}>
|
||||
<label>
|
||||
<span>Titel</span>
|
||||
<input bind:value={formTitle} placeholder="z.B. Wöchentlicher Goals-Review" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Konkretes Ziel</span>
|
||||
<input bind:value={formObjective} placeholder="Was genau soll die KI erreichen?" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Konzept (Markdown, optional)</span>
|
||||
<textarea
|
||||
bind:value={formConcept}
|
||||
placeholder="# Rahmen Erkläre der KI Kontext, Regeln, Grenzen…"
|
||||
rows="6"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>Cadence</legend>
|
||||
<div class="cadence-row">
|
||||
<label class="inline">
|
||||
<input type="radio" bind:group={formCadenceKind} value="manual" />
|
||||
<span>Manuell</span>
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input type="radio" bind:group={formCadenceKind} value="interval" />
|
||||
<span>Intervall</span>
|
||||
{#if formCadenceKind === 'interval'}
|
||||
<input
|
||||
class="inline-num"
|
||||
type="number"
|
||||
bind:value={formIntervalMin}
|
||||
min="5"
|
||||
max="1440"
|
||||
/>
|
||||
<span>min</span>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input type="radio" bind:group={formCadenceKind} value="daily" />
|
||||
<span>Täglich um</span>
|
||||
{#if formCadenceKind === 'daily'}
|
||||
<input class="inline-num" type="number" bind:value={formDailyHour} min="0" max="23" />
|
||||
<span>:00</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="primary-btn" disabled={creating}>
|
||||
{creating ? 'Erstelle…' : 'Mission anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="layout">
|
||||
<aside class="list">
|
||||
{#if missions.value.length === 0}
|
||||
<p class="empty">Noch keine Missions. Starte mit "Neue Mission".</p>
|
||||
{:else}
|
||||
{#each missions.value as m (m.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="list-item"
|
||||
class:selected={selectedId === m.id}
|
||||
onclick={() => (selectedId = m.id)}
|
||||
>
|
||||
<div class="row-1">
|
||||
<strong>{m.title}</strong>
|
||||
<span class="badge badge-{m.state}">{describeState(m.state)}</span>
|
||||
</div>
|
||||
<div class="row-2">
|
||||
<span>{describeCadence(m.cadence)}</span>
|
||||
{#if m.nextRunAt}
|
||||
<span>nächster Run {formatRelative(m.nextRunAt)}</span>
|
||||
{/if}
|
||||
<span>{m.iterations.length} Iteration{m.iterations.length === 1 ? '' : 'en'}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<section class="detail">
|
||||
{#if selected}
|
||||
<header class="detail-header">
|
||||
<h2>{selected.title}</h2>
|
||||
<div class="detail-actions">
|
||||
<button type="button" onclick={() => handleRunNow(selected)} disabled={runningNow}>
|
||||
<Play size={14} />
|
||||
<span>{runningNow ? 'Läuft…' : 'Jetzt ausführen'}</span>
|
||||
</button>
|
||||
{#if selected.state === 'active'}
|
||||
<button type="button" onclick={() => pauseMission(selected.id)}>
|
||||
<Pause size={14} />
|
||||
<span>Pausieren</span>
|
||||
</button>
|
||||
{:else if selected.state === 'paused'}
|
||||
<button type="button" onclick={() => resumeMission(selected.id)}>
|
||||
<Play size={14} />
|
||||
<span>Fortsetzen</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if selected.state !== 'done'}
|
||||
<button type="button" onclick={() => completeMission(selected.id)}>
|
||||
<Check size={14} />
|
||||
<span>Abschließen</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
onclick={() => {
|
||||
if (confirm('Mission löschen?')) {
|
||||
deleteMission(selected.id);
|
||||
selectedId = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl class="meta">
|
||||
<dt>Ziel</dt>
|
||||
<dd>{selected.objective}</dd>
|
||||
<dt>Cadence</dt>
|
||||
<dd>{describeCadence(selected.cadence)}</dd>
|
||||
<dt>Nächster Run</dt>
|
||||
<dd>{formatRelative(selected.nextRunAt)}</dd>
|
||||
</dl>
|
||||
|
||||
{#if selected.conceptMarkdown}
|
||||
<section class="concept">
|
||||
<h3>Konzept</h3>
|
||||
<pre>{selected.conceptMarkdown}</pre>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="iterations">
|
||||
<h3>Iterationen</h3>
|
||||
{#if selected.iterations.length === 0}
|
||||
<p class="empty">Noch keine Iteration gelaufen.</p>
|
||||
{:else}
|
||||
{#each [...selected.iterations].reverse() as it (it.id)}
|
||||
<article class="iteration">
|
||||
<header>
|
||||
<span class="it-date">{new Date(it.startedAt).toLocaleString('de-DE')}</span>
|
||||
<span class="badge badge-status-{it.overallStatus}">{it.overallStatus}</span>
|
||||
</header>
|
||||
{#if it.summary}
|
||||
<p class="it-summary">{it.summary}</p>
|
||||
{/if}
|
||||
{#if it.plan.length > 0}
|
||||
<ul class="plan">
|
||||
{#each it.plan as step}
|
||||
<li>
|
||||
<span class="step-status">[{step.status}]</span>
|
||||
{#if step.summary}
|
||||
{step.summary}
|
||||
{:else if step.intent.kind === 'toolCall'}
|
||||
{step.intent.toolName}
|
||||
{:else}
|
||||
Notiz
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if it.userFeedback}
|
||||
<blockquote class="feedback">{it.userFeedback}</blockquote>
|
||||
{:else if !it.finishedAt || it.overallStatus === 'awaiting-review'}
|
||||
<form
|
||||
class="feedback-form"
|
||||
onsubmit={(e) => (e.preventDefault(), handleFeedback(selected, it.id))}
|
||||
>
|
||||
<textarea
|
||||
bind:value={feedbackDraft}
|
||||
placeholder="Feedback für die nächste Iteration…"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" disabled={!feedbackDraft.trim()}>Speichern</button>
|
||||
</form>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<p class="empty">Wähle links eine Mission aus, oder erstelle eine neue.</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--color-muted, #888);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--color-fg, inherit);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.title h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary, #6b5bff) 45%, transparent);
|
||||
border-radius: 0.375rem;
|
||||
background: color-mix(in oklab, var(--color-primary, #6b5bff) 12%, var(--color-bg, #fff));
|
||||
color: color-mix(in oklab, var(--color-primary, #6b5bff) 85%, var(--color-fg, #000));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--color-border, #ddd);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.create-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.create-form label > span {
|
||||
font-weight: 500;
|
||||
}
|
||||
.create-form input:not([type]),
|
||||
.create-form textarea {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
font: inherit;
|
||||
}
|
||||
.create-form textarea {
|
||||
resize: vertical;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
.create-form fieldset {
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.cadence-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.inline {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.375rem !important;
|
||||
}
|
||||
.inline-num {
|
||||
width: 4.5rem;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.list-item {
|
||||
text-align: left;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-bg, #fff);
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-item.selected {
|
||||
border-color: color-mix(in oklab, var(--color-primary, #6b5bff) 55%, transparent);
|
||||
background: color-mix(in oklab, var(--color-primary, #6b5bff) 6%, var(--color-bg, #fff));
|
||||
}
|
||||
.row-1 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.row-2 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-muted, #888);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--color-muted-bg, #eee);
|
||||
}
|
||||
.badge-active {
|
||||
background: #d7f7e3;
|
||||
color: #1b7a3a;
|
||||
}
|
||||
.badge-paused {
|
||||
background: #fef0c9;
|
||||
color: #8a5a00;
|
||||
}
|
||||
.badge-done {
|
||||
background: #e0e5ec;
|
||||
color: #3b4252;
|
||||
}
|
||||
.badge-archived {
|
||||
background: #e8e8e8;
|
||||
color: #666;
|
||||
}
|
||||
.badge-status-running {
|
||||
background: #d7ecff;
|
||||
color: #0a548b;
|
||||
}
|
||||
.badge-status-awaiting-review {
|
||||
background: #fef0c9;
|
||||
color: #8a5a00;
|
||||
}
|
||||
.badge-status-approved {
|
||||
background: #d7f7e3;
|
||||
color: #1b7a3a;
|
||||
}
|
||||
.badge-status-rejected,
|
||||
.badge-status-failed {
|
||||
background: #f7d7d7;
|
||||
color: #8a1b1b;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.detail-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.detail-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-bg, #fff);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.detail-actions button.danger {
|
||||
color: #8a1b1b;
|
||||
}
|
||||
.detail-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.25rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.meta dt {
|
||||
color: var(--color-muted, #888);
|
||||
}
|
||||
.meta dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.concept pre {
|
||||
white-space: pre-wrap;
|
||||
padding: 0.75rem;
|
||||
background: color-mix(in oklab, var(--color-fg, #000) 3%, transparent);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.iterations h3,
|
||||
.concept h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.iteration {
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.iteration header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
.it-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted, #888);
|
||||
}
|
||||
.it-summary {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.plan {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.plan li {
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
.step-status {
|
||||
color: var(--color-muted, #888);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.75rem;
|
||||
margin-right: 0.375rem;
|
||||
}
|
||||
.feedback {
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-left: 3px solid var(--color-border, #ddd);
|
||||
font-style: italic;
|
||||
color: var(--color-muted, #666);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.feedback-form {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.feedback-form textarea {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
.feedback-form button {
|
||||
align-self: flex-end;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-bg, #fff);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-muted, #888);
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue