openvisio-agent 0.25.9 → 0.25.10
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.
- package/CHANGELOG.md +6 -1
- package/package.json +1 -1
- package/scripts/certify.mjs +3 -1
- package/src/activity-reporter.mjs +13 -5
- package/src/watch.mjs +8 -5
- package/src/ws.mjs +36 -6
- package/studio/style.css +13 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## [0.25.10] — 2026-09-21
|
|
4
|
+
|
|
5
|
+
- Send `thinking`, `working`, and `typing` activity through the authenticated agent WebSocket, with the REST endpoint retained only while the socket is reconnecting.
|
|
6
|
+
- Accept documented and compatible WebSocket event envelopes on both the watcher and frontend, including flat bare-agent `agent_status` frames, and normalize them before routing.
|
|
7
|
+
- Keep active cycles running through ordinary peer-agent chatter; only an analyzed redirect or explicit stand-down cancels work in that thread.
|
|
8
|
+
- Compact Agent Studio activity and cycle rows into single-line summaries so more history remains visible at once.
|
|
4
9
|
|
|
5
10
|
## [0.25.9] — 2026-09-21
|
|
6
11
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.10",
|
|
4
4
|
"description": "Connect Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/scripts/certify.mjs
CHANGED
|
@@ -18,6 +18,7 @@ for (const directory of ['src', 'bin', 'studio']) {
|
|
|
18
18
|
}
|
|
19
19
|
run('unit and behavior tests', process.execPath, ['--test'])
|
|
20
20
|
run('frontend typecheck', 'npm', ['run', 'typecheck'], join(repo, 'frontend'))
|
|
21
|
+
run('frontend realtime frame tests', process.execPath, ['--test', 'lib/api/backend/realtimeFrame.test.mjs'], join(repo, 'frontend'))
|
|
21
22
|
run('package dry run', 'npm', ['pack', '--dry-run'])
|
|
22
23
|
run('diff whitespace check', 'git', ['diff', '--check'], repo)
|
|
23
24
|
|
|
@@ -32,6 +33,7 @@ const codexProxy = readFileSync(join(root, 'src', 'codex-mcp-proxy.mjs'), 'utf8'
|
|
|
32
33
|
const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
|
|
33
34
|
const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
|
|
34
35
|
const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
|
|
36
|
+
const activityReporter = readFileSync(join(root, 'src', 'activity-reporter.mjs'), 'utf8')
|
|
35
37
|
const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
|
|
36
38
|
const mastraHarness = readFileSync(join(root, 'src', 'mastra-harness.mjs'), 'utf8')
|
|
37
39
|
const runtimeProviders = readFileSync(join(root, 'src', 'runtime-providers.mjs'), 'utf8')
|
|
@@ -100,7 +102,7 @@ const assertions = [
|
|
|
100
102
|
['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
|
|
101
103
|
['fresh threads resolve the dedicated agent channel', watcher.includes('dedicatedChannel(Array.isArray(channels)') && watcher.includes('No dedicated agent channel available for a new thread')],
|
|
102
104
|
['presence follows runtime activity and stops refreshing with its cycle', watcher.includes('createActivityReporter') && watcher.includes("activity.set(data.cycleId, live.control.statusTargets, 'working')") && watcher.includes('activity.clear(cycleControl.cycleId)')],
|
|
103
|
-
['websocket
|
|
105
|
+
['agent activity prefers websocket with REST reconnect fallback', activityReporter.includes("type: 'agent_status'") && activityReporter.includes('sendFrame?.') && activityReporter.includes('agentStateRequest') && websocket.includes('return true')],
|
|
104
106
|
['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
|
|
105
107
|
['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
|
|
106
108
|
['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
|
|
@@ -2,11 +2,11 @@ import { agentStateRequest } from './events.mjs'
|
|
|
2
2
|
|
|
3
3
|
// Presence is ephemeral, independent of task completion and message delivery.
|
|
4
4
|
// The backend supports no "done" event; stopping refresh lets the UI TTL clear it.
|
|
5
|
-
export function createActivityReporter({ backend, apiKey, identifier, fetchImpl = fetch, now = Date.now, log = () => {}, intervalMs = 3_000 }) {
|
|
5
|
+
export function createActivityReporter({ backend, apiKey, identifier, sendFrame, fetchImpl = fetch, now = Date.now, log = () => {}, intervalMs = 3_000 }) {
|
|
6
6
|
const cycles = new Map(), channels = new Map(), pending = new Set()
|
|
7
7
|
let closed = false
|
|
8
8
|
const pulse = () => {
|
|
9
|
-
if (closed || !backend) return
|
|
9
|
+
if (closed || (!backend && !sendFrame)) return
|
|
10
10
|
const states = new Map()
|
|
11
11
|
const priority = { thinking: 0, working: 1, typing: 2 }
|
|
12
12
|
for (const cycle of cycles.values()) for (const channelId of cycle.channels) {
|
|
@@ -15,11 +15,19 @@ export function createActivityReporter({ backend, apiKey, identifier, fetchImpl
|
|
|
15
15
|
for (const [channelId, state] of states) {
|
|
16
16
|
const prior = channels.get(channelId)
|
|
17
17
|
if (prior?.busy || now() < (prior?.retryAt || 0) || (prior?.state === state && now() - prior.at < (state === 'working' ? 10_000 : 3_000))) continue
|
|
18
|
+
const entry = { state, at: now(), busy: false, controller: null }
|
|
19
|
+
channels.set(channelId, entry)
|
|
20
|
+
// The authenticated agent socket is the live event path. Older backend
|
|
21
|
+
// stages and reconnect gaps retain the REST endpoint as a fallback.
|
|
22
|
+
try {
|
|
23
|
+
if (sendFrame?.({ type: 'agent_status', channel_id: channelId, state }) === true) continue
|
|
24
|
+
} catch { /* fall through to REST */ }
|
|
25
|
+
if (!backend) continue
|
|
18
26
|
const controller = new AbortController()
|
|
19
27
|
const timer = setTimeout(() => controller.abort(), 5_000)
|
|
20
28
|
timer.unref?.()
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
entry.busy = true
|
|
30
|
+
entry.controller = controller
|
|
23
31
|
const request = agentStateRequest(backend, channelId, state, apiKey, identifier)
|
|
24
32
|
const operation = Promise.resolve().then(() => fetchImpl(request.url, { ...request.init, signal: controller.signal })).then(async response => {
|
|
25
33
|
await response.body?.cancel?.()
|
|
@@ -42,6 +50,6 @@ export function createActivityReporter({ backend, apiKey, identifier, fetchImpl
|
|
|
42
50
|
clear(cycleId) { cycles.delete(cycleId); pulse() },
|
|
43
51
|
pulse,
|
|
44
52
|
async settled() { await Promise.allSettled([...pending]) },
|
|
45
|
-
async close() { closed = true; clearInterval(timer); cycles.clear(); for (const entry of channels.values()) entry.controller
|
|
53
|
+
async close() { closed = true; clearInterval(timer); cycles.clear(); for (const entry of channels.values()) entry.controller?.abort(); await Promise.allSettled([...pending]) },
|
|
46
54
|
}
|
|
47
55
|
}
|
package/src/watch.mjs
CHANGED
|
@@ -619,7 +619,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
619
619
|
// Activity belongs to an individual cycle. Independent ticket workers must
|
|
620
620
|
// not clear or overwrite each other's status targets.
|
|
621
621
|
const replyStatusTargets = new Set()
|
|
622
|
-
const activity = dependencies.activityReporter || createActivityReporter({
|
|
622
|
+
const activity = dependencies.activityReporter || createActivityReporter({
|
|
623
|
+
backend, apiKey, identifier, log,
|
|
624
|
+
sendFrame: (frame) => handle?.send?.(frame) === true,
|
|
625
|
+
})
|
|
623
626
|
const sendStatus = (channelId, state) => {
|
|
624
627
|
const id = cycleContext.getStore()?.cycleId
|
|
625
628
|
const live = liveCycles.get(id)
|
|
@@ -2074,10 +2077,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2074
2077
|
const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
|
|
2075
2078
|
if (target.action === 'ignore') {
|
|
2076
2079
|
log('agent:mention ignored before model start (' + target.reason + ')')
|
|
2077
|
-
//
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2080
|
-
if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === '
|
|
2080
|
+
// Ordinary peer chatter is additional thread context, not cancellation.
|
|
2081
|
+
// Only a message whose recipient analysis shows an actual handoff may
|
|
2082
|
+
// invalidate older work; explicit stand-down is handled below.
|
|
2083
|
+
if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === 'redirected-to-later-agent')) {
|
|
2081
2084
|
cancelThread(cid, threadRoot, text || target.reason)
|
|
2082
2085
|
}
|
|
2083
2086
|
return
|
package/src/ws.mjs
CHANGED
|
@@ -18,6 +18,31 @@ const KEEPALIVE_MS = 30_000 // refresh last_seen_at well within any idle timeout
|
|
|
18
18
|
const BACKOFF_MIN_MS = 1_000
|
|
19
19
|
const BACKOFF_MAX_MS = 30_000
|
|
20
20
|
|
|
21
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key)
|
|
22
|
+
|
|
23
|
+
/** Accept the documented `{k,d}` event and compatible backend relay envelopes.
|
|
24
|
+
* Keeping this at the transport boundary prevents provider/watch logic from
|
|
25
|
+
* learning several wire formats. */
|
|
26
|
+
export function normalizeAgentWsEvent(raw) {
|
|
27
|
+
let msg = raw
|
|
28
|
+
if (typeof msg === 'string') { try { msg = JSON.parse(msg) } catch { return null } }
|
|
29
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return null
|
|
30
|
+
if (!msg.k && !msg.kind && !msg.event && !msg.event_type && !msg.eventType && !msg.type && own(msg, 'body')) {
|
|
31
|
+
return normalizeAgentWsEvent(msg.body)
|
|
32
|
+
}
|
|
33
|
+
const kind = [msg.k, msg.kind, msg.event, msg.event_type, msg.eventType, msg.type].find(value => typeof value === 'string' && value.trim())
|
|
34
|
+
if (!kind || kind === 'event') return null
|
|
35
|
+
let payload
|
|
36
|
+
if (own(msg, 'd')) payload = msg.d
|
|
37
|
+
else if (own(msg, 'payload')) payload = msg.payload
|
|
38
|
+
else if (own(msg, 'data')) payload = msg.data
|
|
39
|
+
else {
|
|
40
|
+
const { k: _k, kind: _kind, event: _event, event_type: _eventType, eventType: _eventTypeCamel, type: _type, ...body } = msg
|
|
41
|
+
payload = body
|
|
42
|
+
}
|
|
43
|
+
return { kind, payload, envelope: msg }
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
/** Bail early with a friendly message if the runtime has no global WebSocket. */
|
|
22
47
|
export function assertWebSocket(fail) {
|
|
23
48
|
if (typeof globalThis.WebSocket === 'function') return
|
|
@@ -86,13 +111,12 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
|
|
|
86
111
|
|
|
87
112
|
sock.addEventListener('message', (ev) => {
|
|
88
113
|
if (sock !== ws) return
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const kind = msg && msg.k
|
|
114
|
+
const event = normalizeAgentWsEvent(typeof ev.data === 'string' ? ev.data : String(ev.data))
|
|
115
|
+
const kind = event?.kind
|
|
92
116
|
if (!kind || kind === 'keepalive:ack') return // keepalive round-trip, nothing to do
|
|
93
117
|
// Error metadata often lives on the JSON envelope rather than `d`. Preserve
|
|
94
118
|
// it so callers do not receive an unhelpful empty object.
|
|
95
|
-
try { onEvent(kind, kind === 'error' ?
|
|
119
|
+
try { onEvent(kind, kind === 'error' ? event.envelope : event.payload) } catch (e) { log('event handler error: ' + (e && e.message ? e.message : e)) }
|
|
96
120
|
})
|
|
97
121
|
|
|
98
122
|
const down = (why) => {
|
|
@@ -119,8 +143,14 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
|
|
|
119
143
|
subscribe()
|
|
120
144
|
return true
|
|
121
145
|
},
|
|
122
|
-
/** Fire-and-forget send
|
|
123
|
-
send(obj) {
|
|
146
|
+
/** Fire-and-forget send. Returns whether the frame reached an open socket. */
|
|
147
|
+
send(obj) {
|
|
148
|
+
try {
|
|
149
|
+
if (!ws || ws.readyState !== 1) return false
|
|
150
|
+
ws.send(JSON.stringify(obj))
|
|
151
|
+
return true
|
|
152
|
+
} catch { return false }
|
|
153
|
+
},
|
|
124
154
|
close() {
|
|
125
155
|
closed = true
|
|
126
156
|
clearKeepalive()
|
package/studio/style.css
CHANGED
|
@@ -139,18 +139,19 @@
|
|
|
139
139
|
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 2px 15px; min-height: 37px; }
|
|
140
140
|
.section-heading h2 { font-size: 12px; }
|
|
141
141
|
.section-heading .muted { font-size: 12px; }
|
|
142
|
-
.activity-list { display: flex; flex-direction: column; gap:
|
|
143
|
-
.activity-row { display: flex; align-items:
|
|
142
|
+
.activity-list { display: flex; flex-direction: column; gap: 2px; max-height: min(67vh, 810px); min-height: 380px; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: 2px; }
|
|
143
|
+
.activity-row { display: flex; align-items: center; gap: 8px; text-align: left; width: 100%; min-height: 40px; padding: 6px 9px; border: 1px solid transparent; border-radius: 12px; corner-shape: squircle; background: transparent; }
|
|
144
144
|
.activity-row:hover { background: var(--hover); }
|
|
145
145
|
.activity-row.is-selected { background: var(--surface); border-color: transparent; box-shadow: 0 1px 2px #00000006; }
|
|
146
|
-
.event-icon { width:
|
|
147
|
-
.event-icon .icon { width:
|
|
148
|
-
.event-body { min-width: 0; flex: 1; }
|
|
149
|
-
.event-title {
|
|
150
|
-
.event-description {
|
|
151
|
-
.event-meta { display: flex; align-items: center; flex-wrap:
|
|
152
|
-
.event-meta .badge {
|
|
153
|
-
.event-
|
|
146
|
+
.event-icon { width: 27px; height: 27px; display: grid; place-items: center; flex-shrink: 0; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; corner-shape: squircle; }
|
|
147
|
+
.event-icon .icon { width: 14px; height: 14px; }
|
|
148
|
+
.event-body { min-width: 0; flex: 1; display: flex; align-items: center; gap: 7px; overflow: hidden; }
|
|
149
|
+
.event-title { flex: 0 1 auto; max-width: 38%; font-size: 12px; font-weight: 500; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
150
|
+
.event-description { flex: 1 1 auto; min-width: 24px; overflow: hidden; font-size: 11px; font-weight: 400; color: var(--muted); margin: 0; line-height: 1.35; white-space: nowrap; text-overflow: ellipsis; }
|
|
151
|
+
.event-meta { display: flex; align-items: center; flex: 0 1 auto; min-width: 0; flex-wrap: nowrap; gap: 5px; font-size: 11px; font-weight: 400; color: var(--muted); margin: 0; white-space: nowrap; overflow: hidden; }
|
|
152
|
+
.event-meta > span:not(.badge) { max-width: 86px; overflow: hidden; text-overflow: ellipsis; }
|
|
153
|
+
.event-meta .badge { font-size: 10px; padding: 1px 5px; }
|
|
154
|
+
.event-time { font-size: 10px; color: var(--subtle); white-space: nowrap; font-weight: 400; font-family: var(--font-mono); margin: 0; }
|
|
154
155
|
.badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 999px; background: var(--hover); color: var(--muted); font-size: 12px; font-weight: 400; white-space: nowrap; }
|
|
155
156
|
.badge .dot { width: 4px; height: 4px; }
|
|
156
157
|
.badge.active, .badge.completed, .badge.online { color: var(--success); }
|
|
@@ -353,10 +354,10 @@
|
|
|
353
354
|
.guide-callout { padding: 18px; border-radius: var(--radius-md); background: var(--stage); }
|
|
354
355
|
@media print { .guide-nav, .guide-header .button, .skip-link { display: none; } .guide-layout { display: block; } .guide-shell { max-width: none; padding: 0; } .guide-content section { break-inside: avoid; } }
|
|
355
356
|
@media (max-width: 640px) { .guide-shell { padding: 24px 20px; } .guide-layout { grid-template-columns: minmax(0, 1fr); gap: 24px; } .guide-nav { position: static; } .panel-toolbar { flex-wrap: wrap; } }
|
|
356
|
-
@media (min-width: 1550px) { .workspace { padding-bottom: 28px; } .content-grid { grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 24px; } .activity-row { padding:
|
|
357
|
+
@media (min-width: 1550px) { .workspace { padding-bottom: 28px; } .content-grid { grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 24px; } .activity-row { padding: 6px 9px; } .inspector { padding: 2px; } }
|
|
357
358
|
@media (max-width: 1120px) { .app-shell { grid-template-columns: 185px minmax(0, 1fr); gap: 20px; width: calc(100% - 40px); } .sidebar { padding-inline: 10px; } .workspace { padding: 8px 0 18px; } .content-grid { grid-template-columns: minmax(250px, 1fr) minmax(270px, 1fr); } .metrics { gap: 8px; } .header-actions { gap: 10px; } .metric-caption { font-size: 12px; } }
|
|
358
359
|
@media (max-width: 940px) { .content-grid { grid-template-columns: minmax(0, 1fr); } .inspector { max-height: none; min-height: 220px; } .inspector-empty { min-height: 200px; } .activity-list { min-height: 250px; max-height: 440px; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .page-header { align-items: flex-start; } .header-actions { flex-direction: column; align-items: flex-end; padding-top: 7px; gap: 13px; } .page-description { max-width: 230px; } }
|
|
359
|
-
@media (max-width: 640px) { .app-shell { grid-template-columns: minmax(0, 1fr); width: 100%; margin: 0 auto; gap: 0; min-height: 100dvh; } .sidebar { position: static; height: auto; padding: 16px 18px 8px; border: 0; border-radius: 0; overflow-y: visible; } .brand { margin: 0 0 16px; font-size: 13px; } .brand-caption { display: none; } .brand-mark { width: 28px; height: 28px; } .brand-mark img { width: 56px; height: 56px; } .sidebar-title, .agent-search, .sidebar-note { display: none; } .agent-list { flex-direction: row; overflow-x: auto; padding: 2px 2px 7px; gap: 6px; } .agent-item { width: auto; min-width: 132px; max-width: 170px; padding: 8px; flex-shrink: 0; } .agent-avatar { width: 25px; height: 25px; font-size: 12px; } .agent-name { font-size: 12px; } .agent-caption { font-size: 12px; } .workspace { padding: 17px 18px 20px; } .page-header { margin-bottom: 22px; gap: 10px; } h1 { font-size: 27px; } .page-description { font-size: 12px; max-width: 210px; } .eyebrow { font-size: 12px; } .header-actions .button { min-height: 40px; font-size: 12px; padding: 6px 10px; } .connection-status { font-size: 12px; } .metrics { margin-bottom: 22px; } .metric-value { font-size: 27px; } .metric-label { font-size: 12px; } .panel-toolbar { gap: 8px; } .tab { padding: 7px 9px; font-size: 12px; gap: 5px; } .tab .icon { width: 12px; height: 12px; } .follow-control { font-size: 12px; gap: 4px; } .filters { flex-wrap: wrap; gap: 7px; } .search-field { flex-basis: 100%; } .select-field { flex: 1; } .select-field select { font-size: 12px; } .content-grid { gap: 20px; } .page-footer { flex-direction: column; gap: 4px; } .event-time { font-size:
|
|
360
|
+
@media (max-width: 640px) { .app-shell { grid-template-columns: minmax(0, 1fr); width: 100%; margin: 0 auto; gap: 0; min-height: 100dvh; } .sidebar { position: static; height: auto; padding: 16px 18px 8px; border: 0; border-radius: 0; overflow-y: visible; } .brand { margin: 0 0 16px; font-size: 13px; } .brand-caption { display: none; } .brand-mark { width: 28px; height: 28px; } .brand-mark img { width: 56px; height: 56px; } .sidebar-title, .agent-search, .sidebar-note { display: none; } .agent-list { flex-direction: row; overflow-x: auto; padding: 2px 2px 7px; gap: 6px; } .agent-item { width: auto; min-width: 132px; max-width: 170px; padding: 8px; flex-shrink: 0; } .agent-avatar { width: 25px; height: 25px; font-size: 12px; } .agent-name { font-size: 12px; } .agent-caption { font-size: 12px; } .workspace { padding: 17px 18px 20px; } .page-header { margin-bottom: 22px; gap: 10px; } h1 { font-size: 27px; } .page-description { font-size: 12px; max-width: 210px; } .eyebrow { font-size: 12px; } .header-actions .button { min-height: 40px; font-size: 12px; padding: 6px 10px; } .connection-status { font-size: 12px; } .metrics { margin-bottom: 22px; } .metric-value { font-size: 27px; } .metric-label { font-size: 12px; } .panel-toolbar { gap: 8px; } .tab { padding: 7px 9px; font-size: 12px; gap: 5px; } .tab .icon { width: 12px; height: 12px; } .follow-control { font-size: 12px; gap: 4px; } .filters { flex-wrap: wrap; gap: 7px; } .search-field { flex-basis: 100%; } .select-field { flex: 1; } .select-field select { font-size: 12px; } .content-grid { gap: 20px; } .page-footer { flex-direction: column; gap: 4px; } .event-time { font-size: 10px; } .activity-row { gap: 6px; padding: 6px; } .event-description { display: none; } .inspector { padding: 2px; } }
|
|
360
361
|
.model-settings-summary:not([hidden]) { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; padding: 18px; margin-bottom: 22px; border: 1px solid var(--border); border-radius: 20px; background: var(--surface, white); }
|
|
361
362
|
.model-settings-summary h2 { margin: 0 0 6px; font-size: 13px; font-weight: 500; }
|
|
362
363
|
.model-settings-summary p { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|