hermes-web-ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +434 -0
  2. package/assets/logo.png +0 -0
  3. package/bin/hermes-web-ui.mjs +24 -0
  4. package/index.html +13 -0
  5. package/package.json +44 -0
  6. package/public/favicon.svg +1 -0
  7. package/public/icons.svg +24 -0
  8. package/src/App.vue +54 -0
  9. package/src/api/chat.ts +87 -0
  10. package/src/api/client.ts +44 -0
  11. package/src/api/jobs.ts +100 -0
  12. package/src/api/system.ts +25 -0
  13. package/src/assets/hero.png +0 -0
  14. package/src/assets/vite.svg +1 -0
  15. package/src/components/chat/ChatInput.vue +123 -0
  16. package/src/components/chat/ChatPanel.vue +289 -0
  17. package/src/components/chat/MarkdownRenderer.vue +187 -0
  18. package/src/components/chat/MessageItem.vue +189 -0
  19. package/src/components/chat/MessageList.vue +94 -0
  20. package/src/components/jobs/JobCard.vue +244 -0
  21. package/src/components/jobs/JobFormModal.vue +188 -0
  22. package/src/components/jobs/JobsPanel.vue +58 -0
  23. package/src/components/layout/AppSidebar.vue +169 -0
  24. package/src/composables/useKeyboard.ts +39 -0
  25. package/src/env.d.ts +7 -0
  26. package/src/main.ts +10 -0
  27. package/src/router/index.ts +24 -0
  28. package/src/stores/app.ts +66 -0
  29. package/src/stores/chat.ts +344 -0
  30. package/src/stores/jobs.ts +72 -0
  31. package/src/styles/global.scss +60 -0
  32. package/src/styles/theme.ts +71 -0
  33. package/src/styles/variables.scss +56 -0
  34. package/src/views/ChatView.vue +25 -0
  35. package/src/views/JobsView.vue +93 -0
  36. package/src/views/SettingsView.vue +257 -0
  37. package/tsconfig.app.json +17 -0
  38. package/tsconfig.json +7 -0
  39. package/tsconfig.node.json +24 -0
  40. package/vite.config.ts +39 -0
@@ -0,0 +1,187 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import MarkdownIt from 'markdown-it'
4
+ import hljs from 'highlight.js'
5
+
6
+ const props = defineProps<{ content: string }>()
7
+
8
+ const md: MarkdownIt = new MarkdownIt({
9
+ html: false,
10
+ linkify: true,
11
+ typographer: true,
12
+ highlight(str: string, lang: string): string {
13
+ if (lang && hljs.getLanguage(lang)) {
14
+ try {
15
+ return `<pre class="hljs-code-block"><div class="code-header"><span class="code-lang">${lang}</span><button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.hljs-code-block').querySelector('code').textContent)">Copy</button></div><code class="hljs language-${lang}">${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`
16
+ } catch {
17
+ // fall through
18
+ }
19
+ }
20
+ return `<pre class="hljs-code-block"><div class="code-header"><button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.hljs-code-block').querySelector('code').textContent)">Copy</button></div><code class="hljs">${md.utils.escapeHtml(str)}</code></pre>`
21
+ },
22
+ })
23
+
24
+ const renderedHtml = computed(() => md.render(props.content))
25
+ </script>
26
+
27
+ <template>
28
+ <div class="markdown-body" v-html="renderedHtml"></div>
29
+ </template>
30
+
31
+ <style lang="scss">
32
+ @use '@/styles/variables' as *;
33
+
34
+ .markdown-body {
35
+ font-size: 14px;
36
+ line-height: 1.65;
37
+
38
+ p {
39
+ margin: 0 0 8px;
40
+
41
+ &:last-child {
42
+ margin-bottom: 0;
43
+ }
44
+ }
45
+
46
+ ul, ol {
47
+ padding-left: 20px;
48
+ margin: 4px 0 8px;
49
+ }
50
+
51
+ li {
52
+ margin: 2px 0;
53
+ }
54
+
55
+ strong {
56
+ color: $text-primary;
57
+ font-weight: 600;
58
+ }
59
+
60
+ em {
61
+ color: $text-secondary;
62
+ }
63
+
64
+ a {
65
+ color: $accent-primary;
66
+ text-decoration: underline;
67
+ text-underline-offset: 2px;
68
+
69
+ &:hover {
70
+ color: $accent-hover;
71
+ }
72
+ }
73
+
74
+ blockquote {
75
+ margin: 8px 0;
76
+ padding: 4px 12px;
77
+ border-left: 3px solid $border-color;
78
+ color: $text-secondary;
79
+ }
80
+
81
+ code:not(.hljs) {
82
+ background: $code-bg;
83
+ padding: 2px 6px;
84
+ border-radius: 4px;
85
+ font-family: $font-code;
86
+ font-size: 13px;
87
+ color: $accent-primary;
88
+ }
89
+
90
+ table {
91
+ width: 100%;
92
+ border-collapse: collapse;
93
+ margin: 8px 0;
94
+
95
+ th, td {
96
+ padding: 6px 12px;
97
+ border: 1px solid $border-color;
98
+ text-align: left;
99
+ font-size: 13px;
100
+ }
101
+
102
+ th {
103
+ background: rgba($accent-primary, 0.08);
104
+ color: $text-primary;
105
+ font-weight: 600;
106
+ }
107
+
108
+ td {
109
+ color: $text-secondary;
110
+ }
111
+ }
112
+
113
+ hr {
114
+ border: none;
115
+ border-top: 1px solid $border-color;
116
+ margin: 12px 0;
117
+ }
118
+ }
119
+
120
+ .hljs-code-block {
121
+ margin: 8px 0;
122
+ border-radius: $radius-sm;
123
+ overflow: hidden;
124
+ background: $code-bg;
125
+ border: 1px solid $border-color;
126
+
127
+ .code-header {
128
+ display: flex;
129
+ justify-content: space-between;
130
+ align-items: center;
131
+ padding: 6px 12px;
132
+ background: rgba(0, 0, 0, 0.03);
133
+ border-bottom: 1px solid $border-color;
134
+
135
+ .code-lang {
136
+ font-size: 11px;
137
+ color: $text-muted;
138
+ text-transform: uppercase;
139
+ }
140
+
141
+ .copy-btn {
142
+ font-size: 11px;
143
+ color: $text-muted;
144
+ background: none;
145
+ border: none;
146
+ cursor: pointer;
147
+ padding: 2px 6px;
148
+ border-radius: 3px;
149
+ transition: all $transition-fast;
150
+
151
+ &:hover {
152
+ color: $text-primary;
153
+ background: rgba(0, 0, 0, 0.05);
154
+ }
155
+ }
156
+ }
157
+
158
+ code.hljs {
159
+ display: block;
160
+ padding: 12px;
161
+ font-family: $font-code;
162
+ font-size: 13px;
163
+ line-height: 1.5;
164
+ overflow-x: auto;
165
+ }
166
+ }
167
+
168
+ // highlight.js theme override — pure ink B&W
169
+ .hljs {
170
+ color: #2a2a2a;
171
+ background: none;
172
+ }
173
+
174
+ .hljs-keyword,
175
+ .hljs-selector-tag { color: #1a1a1a; font-weight: 600; }
176
+ .hljs-string,
177
+ .hljs-attr { color: #555555; }
178
+ .hljs-number { color: #333333; }
179
+ .hljs-comment { color: #999999; font-style: italic; }
180
+ .hljs-built_in { color: #444444; }
181
+ .hljs-type { color: #3a3a3a; }
182
+ .hljs-variable { color: #1a1a1a; }
183
+ .hljs-title,
184
+ .hljs-title\.function_ { color: #1a1a1a; }
185
+ .hljs-params { color: #2a2a2a; }
186
+ .hljs-meta { color: #999999; }
187
+ </style>
@@ -0,0 +1,189 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { Message } from '@/stores/chat'
4
+ import MarkdownRenderer from './MarkdownRenderer.vue'
5
+
6
+ const props = defineProps<{ message: Message }>()
7
+
8
+ const isSystem = computed(() => props.message.role === 'system')
9
+
10
+ const timeStr = computed(() => {
11
+ const d = new Date(props.message.timestamp)
12
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
13
+ })
14
+ </script>
15
+
16
+ <template>
17
+ <div class="message" :class="[message.role]">
18
+ <template v-if="message.role === 'tool'">
19
+ <div class="tool-line">
20
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="tool-icon"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
21
+ <span class="tool-name">{{ message.toolName }}</span>
22
+ <span v-if="message.toolPreview" class="tool-preview">{{ message.toolPreview }}</span>
23
+ </div>
24
+ </template>
25
+ <template v-else>
26
+ <div class="msg-body">
27
+ <img v-if="message.role === 'assistant'" src="/assets/logo.png" alt="Hermes" class="msg-avatar" />
28
+ <div class="msg-content" :class="message.role">
29
+ <div class="message-bubble" :class="{ system: isSystem }">
30
+ <MarkdownRenderer v-if="message.content" :content="message.content" />
31
+ <span v-if="message.isStreaming" class="streaming-cursor"></span>
32
+ <div v-if="message.isStreaming && !message.content" class="streaming-dots">
33
+ <span></span><span></span><span></span>
34
+ </div>
35
+ </div>
36
+ <div class="message-time">{{ timeStr }}</div>
37
+ </div>
38
+ </div>
39
+ </template>
40
+ </div>
41
+ </template>
42
+
43
+ <style scoped lang="scss">
44
+ @use '@/styles/variables' as *;
45
+
46
+ .message {
47
+ display: flex;
48
+ flex-direction: column;
49
+
50
+ &.user {
51
+ align-items: flex-end;
52
+
53
+ .msg-body {
54
+ max-width: 75%;
55
+ }
56
+
57
+ .msg-content.user {
58
+ align-items: flex-end;
59
+ }
60
+
61
+ .message-bubble {
62
+ background-color: $msg-user-bg;
63
+ border-radius: $radius-md $radius-md 4px $radius-md;
64
+ }
65
+ }
66
+
67
+ &.assistant {
68
+ flex-direction: row;
69
+ align-items: flex-start;
70
+ gap: 8px;
71
+
72
+ .msg-body {
73
+ max-width: 80%;
74
+ }
75
+
76
+ .msg-avatar {
77
+ width: 28px;
78
+ height: 28px;
79
+ border-radius: 4px;
80
+ flex-shrink: 0;
81
+ margin-top: 2px;
82
+ }
83
+
84
+ .message-bubble {
85
+ background-color: $msg-assistant-bg;
86
+ border-radius: $radius-md $radius-md $radius-md 4px;
87
+ }
88
+ }
89
+
90
+ &.tool {
91
+ align-items: flex-start;
92
+ }
93
+
94
+ &.system {
95
+ align-items: flex-start;
96
+
97
+ .message-bubble.system {
98
+ border-left: 3px solid $warning;
99
+ border-radius: $radius-sm;
100
+ max-width: 80%;
101
+ background-color: rgba($warning, 0.06);
102
+ }
103
+ }
104
+ }
105
+
106
+ .msg-body {
107
+ display: flex;
108
+ align-items: flex-start;
109
+ gap: 8px;
110
+ max-width: 85%;
111
+ }
112
+
113
+ .msg-content {
114
+ display: flex;
115
+ flex-direction: column;
116
+ min-width: 0;
117
+ }
118
+
119
+ .message-bubble {
120
+ padding: 10px 14px;
121
+ font-size: 14px;
122
+ line-height: 1.65;
123
+ word-break: break-word;
124
+ }
125
+
126
+ .message-time {
127
+ font-size: 11px;
128
+ color: $text-muted;
129
+ margin-top: 4px;
130
+ padding: 0 4px;
131
+ }
132
+
133
+ .tool-line {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 6px;
137
+ font-size: 11px;
138
+ color: $text-muted;
139
+ padding: 0 4px;
140
+
141
+ .tool-name {
142
+ font-family: $font-code;
143
+ }
144
+
145
+ .tool-preview {
146
+ overflow: hidden;
147
+ text-overflow: ellipsis;
148
+ white-space: nowrap;
149
+ max-width: 400px;
150
+ }
151
+ }
152
+
153
+ .streaming-cursor {
154
+ display: inline-block;
155
+ width: 2px;
156
+ height: 1em;
157
+ background-color: $text-muted;
158
+ margin-left: 2px;
159
+ vertical-align: text-bottom;
160
+ animation: blink 0.8s infinite;
161
+ }
162
+
163
+ .streaming-dots {
164
+ display: flex;
165
+ gap: 4px;
166
+ padding: 4px 0;
167
+
168
+ span {
169
+ width: 6px;
170
+ height: 6px;
171
+ background-color: $text-muted;
172
+ border-radius: 50%;
173
+ animation: pulse 1.4s infinite ease-in-out;
174
+
175
+ &:nth-child(2) { animation-delay: 0.2s; }
176
+ &:nth-child(3) { animation-delay: 0.4s; }
177
+ }
178
+ }
179
+
180
+ @keyframes blink {
181
+ 0%, 50% { opacity: 1; }
182
+ 51%, 100% { opacity: 0; }
183
+ }
184
+
185
+ @keyframes pulse {
186
+ 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
187
+ 40% { opacity: 1; transform: scale(1); }
188
+ }
189
+ </style>
@@ -0,0 +1,94 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, nextTick } from 'vue'
3
+ import MessageItem from './MessageItem.vue'
4
+ import { useChatStore } from '@/stores/chat'
5
+
6
+ const chatStore = useChatStore()
7
+ const listRef = ref<HTMLElement>()
8
+
9
+ function scrollToBottom() {
10
+ nextTick(() => {
11
+ if (listRef.value) {
12
+ listRef.value.scrollTop = listRef.value.scrollHeight
13
+ }
14
+ })
15
+ }
16
+
17
+ watch(() => chatStore.messages.length, scrollToBottom)
18
+ watch(() => chatStore.messages[chatStore.messages.length - 1]?.content, scrollToBottom)
19
+ watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
20
+ </script>
21
+
22
+ <template>
23
+ <div ref="listRef" class="message-list">
24
+ <div v-if="chatStore.messages.length === 0" class="empty-state">
25
+ <img src="/assets/logo.png" alt="Hermes" class="empty-logo" />
26
+ <p>Start a conversation with Hermes Agent</p>
27
+ </div>
28
+ <MessageItem
29
+ v-for="msg in chatStore.messages"
30
+ :key="msg.id"
31
+ :message="msg"
32
+ />
33
+ <div v-if="chatStore.isStreaming" class="streaming-indicator">
34
+ <span></span><span></span><span></span>
35
+ </div>
36
+ </div>
37
+ </template>
38
+
39
+ <style scoped lang="scss">
40
+ @use '@/styles/variables' as *;
41
+
42
+ .message-list {
43
+ flex: 1;
44
+ overflow-y: auto;
45
+ padding: 20px;
46
+ display: flex;
47
+ flex-direction: column;
48
+ gap: 16px;
49
+ }
50
+
51
+ .empty-state {
52
+ flex: 1;
53
+ display: flex;
54
+ flex-direction: column;
55
+ align-items: center;
56
+ justify-content: center;
57
+ color: $text-muted;
58
+ gap: 12px;
59
+
60
+ .empty-logo {
61
+ width: 48px;
62
+ height: 48px;
63
+ opacity: 0.25;
64
+ }
65
+
66
+ p {
67
+ font-size: 14px;
68
+ }
69
+ }
70
+
71
+ .streaming-indicator {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 4px;
75
+ padding: 4px;
76
+ color: $text-muted;
77
+
78
+ span {
79
+ width: 5px;
80
+ height: 5px;
81
+ background-color: $text-muted;
82
+ border-radius: 50%;
83
+ animation: stream-pulse 1.4s infinite ease-in-out;
84
+
85
+ &:nth-child(2) { animation-delay: 0.2s; }
86
+ &:nth-child(3) { animation-delay: 0.4s; }
87
+ }
88
+ }
89
+
90
+ @keyframes stream-pulse {
91
+ 0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
92
+ 40% { opacity: 1; transform: scale(1); }
93
+ }
94
+ </style>
@@ -0,0 +1,244 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { NButton, NTooltip, useMessage } from 'naive-ui'
4
+ import type { Job } from '@/api/jobs'
5
+ import { useJobsStore } from '@/stores/jobs'
6
+
7
+ const props = defineProps<{ job: Job }>()
8
+ const emit = defineEmits<{
9
+ edit: [jobId: string]
10
+ }>()
11
+
12
+ const jobsStore = useJobsStore()
13
+ const message = useMessage()
14
+
15
+ const jobId = computed(() => props.job.job_id || props.job.id)
16
+
17
+ const statusLabel = computed(() => {
18
+ if (props.job.state === 'running') return 'Running'
19
+ if (props.job.state === 'paused') return 'Paused'
20
+ if (!props.job.enabled) return 'Disabled'
21
+ return 'Scheduled'
22
+ })
23
+
24
+ const statusType = computed(() => {
25
+ if (props.job.state === 'running') return 'info' as const
26
+ if (props.job.state === 'paused') return 'warning' as const
27
+ if (!props.job.enabled) return 'error' as const
28
+ return 'success' as const
29
+ })
30
+
31
+ const scheduleExpr = computed(() => {
32
+ const s = props.job.schedule
33
+ if (typeof s === 'string') return s
34
+ return s?.expr || props.job.schedule_display || '—'
35
+ })
36
+
37
+ const formatTime = (t?: string | null) => {
38
+ if (!t) return '—'
39
+ return new Date(t).toLocaleString()
40
+ }
41
+
42
+ async function handlePause() {
43
+ try {
44
+ await jobsStore.pauseJob(jobId.value)
45
+ message.success('Job paused')
46
+ } catch (e: any) {
47
+ message.error(e.message)
48
+ }
49
+ }
50
+
51
+ async function handleResume() {
52
+ try {
53
+ await jobsStore.resumeJob(jobId.value)
54
+ message.success('Job resumed')
55
+ } catch (e: any) {
56
+ message.error(e.message)
57
+ }
58
+ }
59
+
60
+ async function handleRun() {
61
+ try {
62
+ await jobsStore.runJob(jobId.value)
63
+ message.info('Job triggered')
64
+ } catch (e: any) {
65
+ message.error(e.message)
66
+ }
67
+ }
68
+
69
+ async function handleDelete() {
70
+ try {
71
+ await jobsStore.deleteJob(jobId.value)
72
+ message.success('Job deleted')
73
+ } catch (e: any) {
74
+ message.error(e.message)
75
+ }
76
+ }
77
+ </script>
78
+
79
+ <template>
80
+ <div class="job-card">
81
+ <div class="card-header">
82
+ <h3 class="job-name">{{ job.name }}</h3>
83
+ <span class="status-badge" :class="statusType">{{ statusLabel }}</span>
84
+ </div>
85
+
86
+ <div class="card-body">
87
+ <div class="info-row">
88
+ <span class="info-label">Schedule</span>
89
+ <code class="info-value mono">{{ scheduleExpr }}</code>
90
+ </div>
91
+ <div class="info-row">
92
+ <span class="info-label">Last Run</span>
93
+ <span class="info-value">
94
+ {{ formatTime(job.last_run_at) }}
95
+ <span v-if="job.last_status" class="run-status" :class="{ ok: job.last_status === 'ok', err: job.last_status !== 'ok' }">
96
+ {{ job.last_status === 'ok' ? 'OK' : job.last_status }}
97
+ </span>
98
+ </span>
99
+ </div>
100
+ <div class="info-row">
101
+ <span class="info-label">Next Run</span>
102
+ <span class="info-value">{{ formatTime(job.next_run_at) }}</span>
103
+ </div>
104
+ <div class="info-row">
105
+ <span class="info-label">Deliver</span>
106
+ <span class="info-value">{{ job.deliver }}<template v-if="job.origin"> ({{ job.origin.platform }})</template></span>
107
+ </div>
108
+ <div v-if="job.repeat" class="info-row">
109
+ <span class="info-label">Repeat</span>
110
+ <span class="info-value">
111
+ <template v-if="typeof job.repeat === 'string'">{{ job.repeat }}</template>
112
+ <template v-else>{{ job.repeat.completed }} / {{ job.repeat.times ?? '∞' }}</template>
113
+ </span>
114
+ </div>
115
+ </div>
116
+
117
+ <div class="card-actions">
118
+ <NTooltip v-if="job.state !== 'paused' && job.enabled">
119
+ <template #trigger>
120
+ <NButton size="tiny" quaternary @click="handlePause">Pause</NButton>
121
+ </template>
122
+ Pause job
123
+ </NTooltip>
124
+ <NTooltip v-else-if="job.state === 'paused'">
125
+ <template #trigger>
126
+ <NButton size="tiny" quaternary @click="handleResume">Resume</NButton>
127
+ </template>
128
+ Resume job
129
+ </NTooltip>
130
+ <NTooltip>
131
+ <template #trigger>
132
+ <NButton size="tiny" quaternary @click="handleRun">Run Now</NButton>
133
+ </template>
134
+ Trigger immediately
135
+ </NTooltip>
136
+ <NButton size="tiny" quaternary @click="emit('edit', jobId)">Edit</NButton>
137
+ <NButton size="tiny" quaternary type="error" @click="handleDelete">Delete</NButton>
138
+ </div>
139
+ </div>
140
+ </template>
141
+
142
+ <style scoped lang="scss">
143
+ @use '@/styles/variables' as *;
144
+
145
+ .job-card {
146
+ background-color: $bg-card;
147
+ border: 1px solid $border-color;
148
+ border-radius: $radius-md;
149
+ padding: 16px;
150
+ transition: border-color $transition-fast;
151
+
152
+ &:hover {
153
+ border-color: rgba($accent-primary, 0.3);
154
+ }
155
+ }
156
+
157
+ .card-header {
158
+ display: flex;
159
+ align-items: center;
160
+ justify-content: space-between;
161
+ margin-bottom: 12px;
162
+ }
163
+
164
+ .job-name {
165
+ font-size: 15px;
166
+ font-weight: 600;
167
+ color: $text-primary;
168
+ overflow: hidden;
169
+ text-overflow: ellipsis;
170
+ white-space: nowrap;
171
+ max-width: 70%;
172
+ }
173
+
174
+ .status-badge {
175
+ font-size: 11px;
176
+ padding: 2px 8px;
177
+ border-radius: 10px;
178
+ font-weight: 500;
179
+
180
+ &.success {
181
+ background: rgba($success, 0.12);
182
+ color: $success;
183
+ }
184
+
185
+ &.info {
186
+ background: rgba($accent-primary, 0.12);
187
+ color: $accent-primary;
188
+ }
189
+
190
+ &.warning {
191
+ background: rgba($warning, 0.12);
192
+ color: $warning;
193
+ }
194
+
195
+ &.error {
196
+ background: rgba($error, 0.12);
197
+ color: $error;
198
+ }
199
+ }
200
+
201
+ .card-body {
202
+ display: flex;
203
+ flex-direction: column;
204
+ gap: 6px;
205
+ margin-bottom: 14px;
206
+ }
207
+
208
+ .info-row {
209
+ display: flex;
210
+ justify-content: space-between;
211
+ align-items: center;
212
+ }
213
+
214
+ .info-label {
215
+ font-size: 12px;
216
+ color: $text-muted;
217
+ }
218
+
219
+ .info-value {
220
+ font-size: 12px;
221
+ color: $text-secondary;
222
+ }
223
+
224
+ .run-status {
225
+ margin-left: 6px;
226
+ font-size: 11px;
227
+ font-weight: 500;
228
+
229
+ &.ok { color: $success; }
230
+ &.err { color: $error; }
231
+ }
232
+
233
+ .mono {
234
+ font-family: $font-code;
235
+ font-size: 12px;
236
+ }
237
+
238
+ .card-actions {
239
+ display: flex;
240
+ gap: 4px;
241
+ border-top: 1px solid $border-light;
242
+ padding-top: 10px;
243
+ }
244
+ </style>