mateclaw/mateclaw-ui/src/views/Workflows.vue

1001 lines
29 KiB
Vue

<template>
<div class="mc-page-shell">
<div class="mc-page-frame">
<div class="mc-page-inner workflows-page">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">{{ t('workflows.kicker') }}</div>
<h1 class="mc-page-title">{{ t('workflows.title') }}</h1>
<p class="mc-page-desc">{{ t('workflows.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreate">{{ t('workflows.newWorkflow') }}</button>
</div>
<div class="workflows-grid">
<!-- left: list -->
<aside class="workflows-list mc-surface-card">
<div class="list-header">
<span>{{ t('workflows.defined', { count: workflows.length }) }}</span>
<button class="btn-ghost" @click="reload">{{ t('workflows.refresh') }}</button>
</div>
<ul class="list-body">
<li
v-for="wf in workflows"
:key="wf.id"
class="list-row"
:class="{ active: selectedId === wf.id }"
@click="select(wf.id)"
>
<div class="list-row-name">
{{ wf.name || t('workflows.unnamed') }}
<span v-if="wf.latestRevisionId" class="badge published">{{ t('workflows.publishedBadge', { rev: wf.latestRevisionId }) }}</span>
<span v-else class="badge draft">{{ t('workflows.draftBadge') }}</span>
</div>
<div class="list-row-desc">{{ wf.description || '-' }}</div>
</li>
<li v-if="!workflows.length" class="list-empty">{{ t('workflows.listEmpty') }}</li>
</ul>
</aside>
<!-- middle: editor -->
<section class="workflows-editor mc-surface-card" v-if="selected">
<header class="editor-header">
<input v-model="selected.name" class="editor-name" :placeholder="t('workflows.namePlaceholder')" />
<input v-model="selected.description" class="editor-desc" :placeholder="t('workflows.descPlaceholder')" />
<div class="editor-actions">
<button class="btn-ghost" :disabled="busy" @click="saveMeta">{{ t('workflows.actions.saveMeta') }}</button>
<button class="btn-ghost" :disabled="busy" @click="saveDraft">{{ t('workflows.actions.saveDraft') }}</button>
<button class="btn-ghost" :disabled="busy" @click="compile">{{ t('workflows.actions.compile') }}</button>
<button class="btn-primary" :disabled="busy" @click="publish">{{ t('workflows.actions.publish') }}</button>
<button class="btn-danger" :disabled="busy" @click="remove">{{ t('workflows.actions.delete') }}</button>
</div>
</header>
<div class="editor-tabs">
<button class="tab-btn" :class="{ active: editorTab === 'canvas' }" @click="editorTab = 'canvas'">
{{ t('workflows.tabs.canvas') }}
</button>
<button class="tab-btn" :class="{ active: editorTab === 'json' }" @click="editorTab = 'json'">
{{ t('workflows.tabs.json') }}
</button>
</div>
<div v-if="editorTab === 'canvas'" class="canvas-pane">
<WorkflowCanvas
v-model="canvasModel"
:canvas-id="`wf-${selected.id}`"
@select-step="onCanvasSelect"
/>
<StepPropertyPanel
v-if="canvasSelection"
:step="selectedStep"
:index="canvasSelection.index"
@patch="onStepPatch"
@duplicate="onStepDuplicate"
@delete="onStepDelete"
/>
</div>
<div v-else class="json-pane">
<div class="editor-toolbar">
<label class="template-picker">
<span>{{ t('workflows.templates.label') }}</span>
<select v-model="templateChoice" @change="insertTemplate">
<option value="">{{ t('workflows.templates.placeholder') }}</option>
<option value="sequential">{{ t('workflows.templates.sequential') }}</option>
<option value="fan_out">{{ t('workflows.templates.fan_out') }}</option>
<option value="collect">{{ t('workflows.templates.collect') }}</option>
<option value="conditional">{{ t('workflows.templates.conditional') }}</option>
<option value="await_approval">{{ t('workflows.templates.await_approval') }}</option>
<option value="dispatch_channel">{{ t('workflows.templates.dispatch_channel') }}</option>
<option value="write_memory">{{ t('workflows.templates.write_memory') }}</option>
</select>
</label>
<span class="json-hint" :class="jsonHintKind">{{ jsonHint }}</span>
</div>
<textarea
v-model="draftJson"
class="editor-body"
spellcheck="false"
:placeholder="t('workflows.bodyPlaceholder')"
/>
</div>
<div v-if="compileErrors.length" class="errors-panel">
<div class="errors-title">{{ t('workflows.compileErrorsTitle', { count: compileErrors.length }) }}</div>
<ul>
<li v-for="(err, idx) in compileErrors" :key="idx">
<code>{{ err.code }}</code>
<span class="err-path">@ {{ err.path }}</span>
<span class="err-msg">— {{ err.message }}</span>
</li>
</ul>
</div>
<div v-else-if="lastStatus" class="status-panel" :class="lastStatusKind">
{{ lastStatus }}
</div>
</section>
<section class="workflows-empty mc-surface-card" v-else>
<p>{{ t('workflows.selectHint') }}</p>
</section>
<!-- right: runs -->
<aside class="workflows-runs mc-surface-card" v-if="selected">
<header class="runs-header">
<span>{{ t('workflows.runs.header', { count: runs.length }) }}</span>
<button class="btn-ghost" @click="reloadRuns">{{ t('workflows.refresh') }}</button>
</header>
<ul class="runs-list">
<li v-for="run in runs" :key="run.id" class="run-row" @click="loadRun(run.id)">
<div class="run-row-line">
<span class="run-state" :class="'state-' + run.state">{{ run.state }}</span>
<span class="run-time">{{ formatTime(run.startedAt) }}</span>
</div>
<div class="run-row-meta">
<span>{{ t('workflows.runs.runHash', { id: run.id }) }}</span>
<span v-if="run.triggeredBy">· {{ run.triggeredBy }}</span>
<span v-if="run.errorMessage" class="run-err">· {{ run.errorMessage }}</span>
</div>
</li>
<li v-if="!runs.length" class="runs-empty">{{ t('workflows.runs.empty') }}</li>
</ul>
<section v-if="runDetail" class="run-detail">
<div class="run-detail-title">{{ t('workflows.runs.detailTitle', { id: runDetail.run.id, state: runDetail.run.state }) }}</div>
<ol class="run-steps">
<li v-for="step in runDetail.steps" :key="step.id">
<span class="step-state" :class="'state-' + step.state">{{ step.state }}</span>
<span class="step-name">{{ step.stepName || t('workflows.unnamed') }}</span>
<span v-if="step.durationMs != null" class="step-duration">{{ step.durationMs }} ms</span>
<span v-if="step.errorMessage" class="step-err">{{ step.errorMessage }}</span>
</li>
</ol>
</section>
</aside>
</div>
</div>
</div>
<CreateWorkflowDialog v-model="createDialogOpen" :loading="busy" @submit="onCreateSubmit" />
<PublishDialog v-model="publishDialogOpen" :loading="busy" @submit="onPublishSubmit" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcConfirm } from '@/components/common/useConfirm'
import {
workflowApi,
type WorkflowSummary,
type WorkflowRun,
type WorkflowRunStep,
type WorkflowCompileError,
type WorkflowCompileFailure,
} from '@/api'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import WorkflowCanvas from '@/components/workflow/WorkflowCanvas.vue'
import StepPropertyPanel from '@/components/workflow/StepPropertyPanel.vue'
import CreateWorkflowDialog from '@/components/workflow/CreateWorkflowDialog.vue'
import PublishDialog from '@/components/workflow/PublishDialog.vue'
import type { StepNodeData, RawStep } from '@/composables/useWorkflowGraph'
import {
readStepAtIndex,
updateStepAtIndex,
deleteStepAtIndex,
duplicateStepAtIndex,
} from '@/composables/useWorkflowDraft'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workspaceId = computed(() => workspaceStore.currentWorkspaceId)
const workflows = ref<WorkflowSummary[]>([])
const selectedId = ref<number | null>(null)
const selected = ref<WorkflowSummary | null>(null)
const draftJson = ref('')
const compileErrors = ref<WorkflowCompileError[]>([])
const lastStatus = ref('')
const lastStatusKind = ref<'ok' | 'err'>('ok')
const busy = ref(false)
const runs = ref<WorkflowRun[]>([])
const runDetail = ref<{ run: WorkflowRun; steps: WorkflowRunStep[] } | null>(null)
const templateChoice = ref('')
// View-mode toggle between the canvas (read-only graph derived from
// the JSON) and the raw JSON editor. Canvas is the default — most
// authors visit the page to make sense of an existing flow rather
// than to type fresh JSON.
const editorTab = ref<'canvas' | 'json'>('canvas')
// The canvas reads from `draftJson` directly. We keep the model write
// path on the JSON editor only — the canvas is purely a derived view
// to avoid two sources of truth drifting apart.
const canvasModel = computed({
get: () => draftJson.value,
set: (v: string) => { draftJson.value = v },
})
const canvasSelection = ref<StepNodeData | null>(null)
function onCanvasSelect(payload: StepNodeData | null) {
canvasSelection.value = payload
}
// The currently-selected step, re-resolved from the JSON whenever the
// JSON changes — this is what keeps the property panel in sync after
// the user edits a field. Falls back to the StepNodeData snapshot the
// canvas captured if the JSON parse fails (e.g. mid-edit invalid JSON).
const selectedStep = computed<RawStep | null>(() => {
const sel = canvasSelection.value
if (!sel) return null
const live = readStepAtIndex(draftJson.value, sel.index)
return live ?? (sel.raw as RawStep)
})
function onStepPatch(payload: { index: number; patch: Partial<RawStep> }) {
draftJson.value = updateStepAtIndex(draftJson.value, payload.index, payload.patch)
// Keep the selection alive so the panel doesn't blink between renders.
// The re-derived selectedStep computed will pick up the new fields.
}
function onStepDuplicate(payload: { index: number }) {
draftJson.value = duplicateStepAtIndex(draftJson.value, payload.index)
}
function onStepDelete(payload: { index: number }) {
draftJson.value = deleteStepAtIndex(draftJson.value, payload.index)
canvasSelection.value = null
}
const createDialogOpen = ref(false)
const publishDialogOpen = ref(false)
// Live JSON-syntax check on the textarea so the operator sees parse errors
// immediately, instead of waiting for compile to round-trip.
const jsonHint = computed(() => {
if (!draftJson.value.trim()) return ''
try {
JSON.parse(draftJson.value)
return t('workflows.jsonOk')
} catch (e) {
return t('workflows.jsonInvalid', { msg: (e as Error).message })
}
})
const jsonHintKind = computed(() => (jsonHint.value === t('workflows.jsonOk') ? 'ok' : 'err'))
const STEP_TEMPLATES: Record<string, object> = {
sequential: {
name: 'step-sequential',
agentName: 'agent-name',
mode: { type: 'sequential' },
promptTemplate: 'Process: {{ inputs.payload }}',
},
fan_out: {
name: 'step-fan',
agentName: 'agent-name',
mode: { type: 'fan_out' },
promptTemplate: 'Branch task',
},
collect: {
name: 'step-collect',
agentName: 'agent-name',
mode: { type: 'collect' },
promptTemplate: 'Combine: {{ inputs.payload }}',
},
conditional: {
name: 'step-conditional',
agentName: 'agent-name',
mode: { type: 'conditional', expression: '{{ inputs.payload != null }}' },
promptTemplate: 'Run only when condition holds',
},
await_approval: {
name: 'step-approval',
mode: {
type: 'await_approval',
approvalKind: 'manual',
// approverChannels is required by the schema validator. Default
// to ['web'] — the operator UI surface — so an inserted template
// compiles right away. Authors can swap to feishu / dingtalk /
// wecom etc. once they wire those channels.
approverChannels: ['web'],
approvalMessage: 'Please review and approve',
timeoutSecs: 3600,
},
},
dispatch_channel: {
name: 'step-dispatch',
mode: {
type: 'dispatch_channel',
channels: ['feishu'],
targets: { feishu: 'group-id-here' },
content: 'Notification: {{ inputs.payload }}',
},
},
write_memory: {
name: 'step-memory',
mode: {
type: 'write_memory',
employeeId: 'employee-id',
file: 'workspace.md',
mergeStrategy: 'append',
content: '{{ inputs.payload }}',
},
},
}
function insertTemplate() {
const choice = templateChoice.value
if (!choice) return
const stepBlock = STEP_TEMPLATES[choice]
if (!stepBlock) return
// Try to inject into an existing draft's `steps` array; fall back to a
// fresh skeleton if the current text isn't valid JSON or doesn't have
// the expected shape.
let next: string
try {
const parsed = JSON.parse(draftJson.value || '{}') as { steps?: unknown[] }
if (!Array.isArray(parsed.steps)) parsed.steps = []
parsed.steps.push(stepBlock)
next = JSON.stringify(parsed, null, 2)
} catch {
next = JSON.stringify({ steps: [stepBlock] }, null, 2)
}
draftJson.value = next
templateChoice.value = ''
}
async function reload() {
if (!workspaceId.value) return
try {
const res = await workflowApi.list(workspaceId.value)
workflows.value = (res.data as unknown as WorkflowSummary[]) ?? []
} catch (e) {
console.error('listWorkflows failed', e)
}
}
async function select(id: number) {
selectedId.value = id
try {
const res = await workflowApi.get(id)
selected.value = res.data as unknown as WorkflowSummary
draftJson.value = selected.value?.draftJson ?? ''
compileErrors.value = []
lastStatus.value = ''
await reloadRuns()
} catch (e) {
console.error('getWorkflow failed', e)
}
}
async function reloadRuns() {
if (!selectedId.value) return
try {
const res = await workflowApi.runs(selectedId.value, 50)
runs.value = (res.data as unknown as WorkflowRun[]) ?? []
} catch (e) {
console.error('listRuns failed', e)
}
}
async function loadRun(runId: number) {
try {
const res = await workflowApi.runDetail(runId)
runDetail.value = res.data as unknown as { run: WorkflowRun; steps: WorkflowRunStep[] }
} catch (e) {
console.error('getRun failed', e)
}
}
function openCreate() {
if (!workspaceId.value) return
createDialogOpen.value = true
}
async function onCreateSubmit(payload: { name: string; description: string }) {
if (!workspaceId.value) return
busy.value = true
try {
const res = await workflowApi.create({
workspaceId: workspaceId.value,
name: payload.name,
description: payload.description || undefined,
enabled: true,
})
const created = res.data as unknown as WorkflowSummary
createDialogOpen.value = false
await reload()
if (created?.id) {
await select(created.id)
// Seed a starter step so the canvas renders something the user
// can immediately edit, instead of opening on a blank slate.
// The compile button also has something real to validate.
if (!draftJson.value || !draftJson.value.trim()) {
draftJson.value = JSON.stringify({
steps: [
{
name: 'first-step',
agentName: 'agent-name',
mode: { type: 'sequential' },
promptTemplate: 'Hello {{ inputs.payload }}',
},
],
}, null, 2)
try {
await workflowApi.saveDraft(created.id, draftJson.value)
} catch (e) {
// Non-fatal: the user can still hit Save Draft manually.
console.warn('seed draft save failed', e)
}
}
}
} catch (e) {
setStatus(t('workflows.status.createFailed', { msg: (e as Error).message }), 'err')
} finally {
busy.value = false
}
}
async function saveMeta() {
if (!selected.value) return
busy.value = true
try {
await workflowApi.update(selected.value.id, {
name: selected.value.name,
description: selected.value.description,
enabled: selected.value.enabled,
})
setStatus(t('workflows.status.metaSaved'), 'ok')
await reload()
} catch (e) {
setStatus(t('workflows.status.saveFailed', { msg: (e as Error).message }), 'err')
} finally {
busy.value = false
}
}
async function saveDraft() {
if (!selected.value) return
busy.value = true
try {
await workflowApi.saveDraft(selected.value.id, draftJson.value)
setStatus(t('workflows.status.draftSaved'), 'ok')
} catch (e) {
setStatus(t('workflows.status.saveDraftFailed', { msg: (e as Error).message }), 'err')
} finally {
busy.value = false
}
}
async function compile() {
if (!selected.value) return
busy.value = true
compileErrors.value = []
try {
await workflowApi.saveDraft(selected.value.id, draftJson.value)
await workflowApi.compile(selected.value.id)
setStatus(t('workflows.status.compileOk'), 'ok')
} catch (e) {
handleCompileError(e)
} finally {
busy.value = false
}
}
function publish() {
if (!selected.value) return
publishDialogOpen.value = true
}
async function onPublishSubmit(payload: { note: string }) {
if (!selected.value) return
busy.value = true
compileErrors.value = []
try {
await workflowApi.saveDraft(selected.value.id, draftJson.value)
await workflowApi.publish(selected.value.id, payload.note || undefined)
publishDialogOpen.value = false
setStatus(t('workflows.status.published'), 'ok')
await reload()
} catch (e) {
handleCompileError(e)
} finally {
busy.value = false
}
}
async function remove() {
if (!selected.value) return
const name = selected.value.name ?? ''
const ok = await mcConfirm({
title: t('workflows.dialogs.deleteTitle'),
message: t('workflows.dialogs.deleteContent', { name }),
confirmText: t('workflows.actions.delete'),
tone: 'danger',
})
if (!ok) return
busy.value = true
try {
await workflowApi.delete(selected.value.id)
selected.value = null
selectedId.value = null
draftJson.value = ''
await reload()
setStatus(t('workflows.status.deleted'), 'ok')
} catch (e) {
setStatus(t('workflows.status.deleteFailed', { msg: (e as Error).message }), 'err')
} finally {
busy.value = false
}
}
function handleCompileError(e: unknown) {
// The HTTP layer rejects with an `Error` whose message is the body's
// msg field. The structured errors list lives on the response body's
// data field; we have to dig it out of the raw axios error if present.
const err = e as { response?: { data?: { data?: WorkflowCompileFailure } }; message?: string }
const failure = err.response?.data?.data
if (failure?.errors?.length) {
compileErrors.value = failure.errors
setStatus(t('workflows.status.compileFailed', { count: failure.errorCount ?? failure.errors.length }), 'err')
} else {
setStatus(err.message || t('workflows.status.compileFallback'), 'err')
}
}
function setStatus(msg: string, kind: 'ok' | 'err') {
lastStatus.value = msg
lastStatusKind.value = kind
}
function formatTime(iso?: string) {
if (!iso) return '-'
return iso.replace('T', ' ').slice(0, 19)
}
onMounted(reload)
watch(workspaceId, reload)
</script>
<style scoped>
.workflows-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.workflows-grid {
display: grid;
grid-template-columns: 280px 1fr 320px;
gap: 16px;
align-items: stretch;
min-height: 480px;
}
.workflows-list,
.workflows-editor,
.workflows-runs,
.workflows-empty {
padding: 12px;
display: flex;
flex-direction: column;
}
.list-header,
.runs-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
opacity: 0.85;
}
.list-body,
.runs-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
flex: 1;
}
.list-row,
.run-row {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
transition: background 0.12s ease;
}
.list-row:hover,
.run-row:hover {
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.05));
}
.list-row.active {
background: var(--mc-primary-bg, rgba(64, 132, 255, 0.18));
}
.list-row-name {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
.list-row-desc {
font-size: 12px;
opacity: 0.7;
margin-top: 2px;
}
.badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.06));
}
.badge.published {
background: #2ecc71;
color: white;
}
.badge.draft {
background: #ffb84d;
color: white;
}
.list-empty,
.runs-empty {
font-size: 13px;
opacity: 0.6;
padding: 12px 4px;
}
.editor-header {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
.editor-name {
flex: 0 0 200px;
font-weight: 600;
}
.editor-desc {
flex: 1;
}
.editor-name,
.editor-desc {
padding: 6px 8px;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.1));
border-radius: 6px;
background: transparent;
color: inherit;
}
.editor-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
font-size: 12px;
}
.template-picker {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
}
.template-picker select {
padding: 4px 6px;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
border-radius: 6px;
background: transparent;
color: inherit;
font-size: 12px;
}
.json-hint {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11px;
opacity: 0.85;
flex: 1;
}
.json-hint.ok { color: #1e8449; }
.json-hint.err { color: #c0392b; }
.editor-body {
flex: 1;
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
line-height: 1.5;
padding: 12px;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.1));
border-radius: 6px;
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.02));
color: inherit;
resize: vertical;
min-height: 320px;
}
.errors-panel {
margin-top: 12px;
padding: 10px;
border-radius: 6px;
background: rgba(255, 80, 80, 0.08);
border: 1px solid rgba(255, 80, 80, 0.4);
}
.errors-title {
font-weight: 600;
margin-bottom: 6px;
}
.errors-panel ul {
margin: 0;
padding-left: 16px;
font-size: 12px;
list-style: disc;
}
.errors-panel code {
font-weight: 600;
margin-right: 6px;
}
.err-path {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11px;
opacity: 0.85;
}
.err-msg {
margin-left: 4px;
}
.status-panel {
margin-top: 12px;
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
}
.status-panel.ok {
background: rgba(46, 204, 113, 0.12);
border: 1px solid rgba(46, 204, 113, 0.4);
}
.status-panel.err {
background: rgba(255, 80, 80, 0.08);
border: 1px solid rgba(255, 80, 80, 0.4);
}
.runs-list {
max-height: 340px;
}
.run-row-line {
display: flex;
justify-content: space-between;
font-size: 12px;
}
.run-state,
.step-state {
text-transform: uppercase;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.08);
}
.state-succeeded { background: rgba(46, 204, 113, 0.2); color: #1e8449; }
.state-failed { background: rgba(231, 76, 60, 0.18); color: #c0392b; }
.state-paused { background: rgba(255, 184, 77, 0.22); color: #b8730a; }
.state-skipped { background: rgba(149, 165, 166, 0.22); color: #444; }
.state-running { background: rgba(52, 152, 219, 0.18); color: #1a5276; }
.run-row-meta {
font-size: 11px;
opacity: 0.7;
margin-top: 2px;
}
.run-err {
color: #c0392b;
}
.run-detail {
margin-top: 12px;
padding: 10px;
border-radius: 6px;
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.04));
}
.run-detail-title {
font-weight: 600;
margin-bottom: 6px;
}
.run-steps {
list-style: none;
margin: 0;
padding: 0;
font-size: 12px;
}
.run-steps li {
padding: 4px 0;
display: flex;
align-items: center;
gap: 6px;
border-top: 1px dashed rgba(0, 0, 0, 0.06);
}
.step-name {
font-weight: 500;
}
.step-duration,
.step-err {
margin-left: auto;
font-size: 11px;
opacity: 0.7;
}
.step-err {
color: #c0392b;
}
.btn-primary,
.btn-ghost,
.btn-danger {
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
background: transparent;
color: inherit;
}
.btn-primary {
background: var(--mc-primary, #4084ff);
border-color: var(--mc-primary, #4084ff);
color: white;
}
.btn-danger {
background: rgba(231, 76, 60, 0.12);
border-color: rgba(231, 76, 60, 0.6);
color: #c0392b;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.editor-tabs {
display: flex;
gap: 4px;
padding: 4px 0 8px;
border-bottom: 1px solid var(--mc-border, rgba(0, 0, 0, 0.06));
margin-bottom: 8px;
}
.tab-btn {
padding: 6px 14px;
border: none;
background: transparent;
color: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border-radius: 6px;
opacity: 0.7;
}
.tab-btn:hover {
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.04));
opacity: 1;
}
.tab-btn.active {
opacity: 1;
background: var(--mc-primary-bg, rgba(64, 132, 255, 0.14));
color: var(--mc-primary, #4084ff);
}
.canvas-pane {
flex: 1;
display: flex;
flex-direction: row;
gap: 12px;
min-height: 420px;
align-items: stretch;
}
.canvas-pane > .workflow-canvas {
/* fill available width when no inspector is visible — earlier the
pane reserved 240px for an inspector via grid-template-columns
even when it was v-if hidden, leaving the canvas confined. */
flex: 1 1 auto;
min-width: 0;
}
.canvas-pane > .step-panel {
flex: 0 0 280px;
max-height: 540px;
}
@media (max-width: 1100px) {
.canvas-pane > .step-panel {
flex: 0 0 auto;
max-height: 360px;
}
}
.canvas-inspector {
background: var(--mc-bg-elevated, rgba(0, 0, 0, 0.02));
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.08));
border-radius: 8px;
padding: 10px;
font-size: 12px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
.canvas-inspector header {
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
}
.inspector-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 8px;
margin: 0;
font-size: 12px;
}
.inspector-grid dt {
opacity: 0.6;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.04em;
align-self: center;
}
.inspector-grid dd {
margin: 0;
word-break: break-word;
}
.inspector-grid code {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11px;
}
.inspector-raw {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 10.5px;
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.04));
border-radius: 6px;
padding: 8px;
white-space: pre-wrap;
word-break: break-word;
max-height: 220px;
overflow: auto;
margin-top: 6px;
}
.json-pane {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 360px;
}
/* Responsive: collapse to a single column below 1100px so the editor
can breathe on tablets, and stack everything vertically below 720px
so the page is usable on a phone. The runs panel becomes a
collapsible details element on small screens. */
@media (max-width: 1100px) {
.workflows-grid {
grid-template-columns: 1fr;
}
.canvas-pane {
grid-template-columns: 1fr;
}
.canvas-inspector {
max-height: 240px;
}
}
@media (max-width: 720px) {
.mc-page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.editor-header {
flex-direction: column;
align-items: stretch;
}
.editor-name,
.editor-desc {
flex: none;
width: 100%;
}
.editor-actions {
justify-content: flex-end;
}
.editor-body {
min-height: 240px;
}
.canvas-pane {
min-height: 320px;
}
}
</style>