fix: restore local Agent discovery startup animations

What changed
- Restore PixelBlast and WavePhysicsLoader with the three dependency.
- Show the discovery overlay and block workspace interaction while local Agent
  CLI detection runs; release the overlay on both success and failure.
- Restore theme styles and Chinese/English startup copy, with App coverage for
  pending detection, completion, errors and animation wiring.

Why
Restore the intended local Agent discovery startup experience while retaining
the existing workbench once detection settles. Keep this separate from P0 delivery.

Checks
- Final vitest run of DeliveryPanel.spec.js and App.spec.js: 54/54 passed.
- Previously completed combined-tree validation: frontend 362/362, web build,
  desktop build, desktop pack and Electron smoke passed. Pack lacks a Developer
  ID signing identity; deterministic delivery smoke does not test live providers.
- git diff --check and git diff --cached --check passed.

README.md, README.zh-CN.md and meldwork-landing/index.html remain uncommitted.
This commit is contained in:
Ryder Sun committed 2026-10-19 22:28:22 +08:00
1 parent c01f96b4c7
commit 6983e58791
10 files changed
+680 -54

No files matched your search

+7
View File
@@ -12,6 +12,7 @@
"@vicons/ionicons5": "^0.12.0",
"dompurify": "^3.4.13",
"marked": "^9.0.0",
"three": "^0.185.1",
"vue": "^3.4.0"
},
"devDependencies": {
@@ -2240,6 +2241,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+1
View File
@@ -20,6 +20,7 @@
"@vicons/ionicons5": "^0.12.0",
"dompurify": "^3.4.13",
"marked": "^9.0.0",
"three": "^0.185.1",
"vue": "^3.4.0"
},
"overrides": {
+31 -4
View File
@@ -12,7 +12,7 @@
<section v-if="booting" class="boot-state" aria-live="polite">
<img class="boot-logo" :src="productAppIcon" alt="Meldwork" />
<p>{{ t('app.loading') }}</p>
<span class="boot-progress" aria-hidden="true"><span /></span>
<WavePhysicsLoader :theme="theme" />
</section>
<section v-else-if="bridgeMissing" class="bridge-state">
@@ -26,6 +26,33 @@
:t="t" :theme="theme" :active-view="activeView" :blocked="contentInteractionBlocked"
:open-system-settings="openSystemSettings" :toggle-locale="toggleLocale" :toggle-theme="toggleTheme"
:shortcut-definitions="shortcutDefinitions" />
<transition name="agent-discovery" appear>
<section
v-if="agentDiscoveryPending"
class="agent-discovery-overlay"
role="status"
aria-live="polite"
:aria-label="t('agentDiscovery.title')"
>
<PixelBlast :theme="theme" />
<div class="agent-discovery-content">
<img class="agent-discovery-mark" :src="productMark" alt="" />
<div class="agent-discovery-copy">
<p class="agent-discovery-kicker">{{ t('agentDiscovery.kicker') }}</p>
<h1>{{ t('agentDiscovery.title') }}</h1>
<p>{{ t('agentDiscovery.body') }}</p>
<div class="agent-discovery-progress" aria-hidden="true">
<span />
</div>
<div class="agent-discovery-status">
<span class="agent-discovery-status-dot" aria-hidden="true" />
<span>{{ t('agentDiscovery.status') }}</span>
</div>
</div>
</div>
</section>
</transition>
<WorkspaceSidebar :controller="workspaceSidebarController" />
<section
@@ -173,6 +200,8 @@
</template>
<script setup>
import PixelBlast from './components/PixelBlast.vue'
import WavePhysicsLoader from './components/WavePhysicsLoader.vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import DeliveryPanel from './components/DeliveryPanel.vue'
import {
@@ -601,10 +630,8 @@ const {
snapshot,
})
const { messageDeleteArmedId } = messageActions
// Agent discovery no longer blocks the window: history is local and usable at
// once, and the sidebar, home and composer each show that detection is running.
const contentInteractionBlocked = computed(() => (
blockingOverlayOpen.value || traceDrawerBlocking.value
blockingOverlayOpen.value || traceDrawerBlocking.value || agentDiscoveryPending.value
))
const composerContext = useComposerContext({
activeGroup,
+31 -20
View File
@@ -45,7 +45,7 @@ afterEach(() => {
})
describe('Meldwork workbench', () => {
it('keeps the workspace usable while the first Agent detection runs', async () => {
it('shows the startup animation and blocks the workspace until Agent detection finishes', async () => {
const fixture = createBridge()
const pendingRefresh = deferred()
fixture.state.agents = []
@@ -55,16 +55,11 @@ describe('Meldwork workbench', () => {
const wrapper = mount(App, { attachTo: document.body })
await flushPromises()
// No blocking overlay: local history stays reachable and nothing is inert.
expect(wrapper.find('.agent-discovery-overlay').exists()).toBe(false)
expect(wrapper.get('.workspace-pane').attributes()).not.toHaveProperty('inert')
expect(wrapper.get('.sidebar').attributes()).not.toHaveProperty('inert')
expect(wrapper.get('.sidebar-discovery').attributes('role')).toBe('status')
expect(wrapper.get('.sidebar-discovery').text()).toContain('Detecting local Agents')
// An empty Agent list is not a setup problem yet.
expect(wrapper.get('.home-workspace-state').text()).toBe('Detecting local Agents')
expect(wrapper.get('.home-workspace-state').classes()).not.toContain('attention')
expect(wrapper.find('.setup-guide').exists()).toBe(false)
expect(wrapper.get('.agent-discovery-overlay').attributes('role')).toBe('status')
expect(wrapper.get('.agent-discovery-overlay').text()).toContain('Agent CLI')
expect(wrapper.find('.pixel-blast-container').exists()).toBe(true)
expect(wrapper.get('.workspace-pane').attributes()).toHaveProperty('inert')
expect(wrapper.get('.sidebar').attributes()).toHaveProperty('inert')
pendingRefresh.resolve(structuredClone({
...fixture.state,
@@ -72,11 +67,30 @@ describe('Meldwork workbench', () => {
}))
await flushPromises()
expect(wrapper.find('.agent-discovery-overlay').exists()).toBe(false)
expect(wrapper.get('.workspace-pane').attributes()).not.toHaveProperty('inert')
expect(wrapper.get('.sidebar').attributes()).not.toHaveProperty('inert')
expect(wrapper.find('.sidebar-discovery').exists()).toBe(false)
expect(wrapper.get('.home-workspace-state').text()).not.toBe('Detecting local Agents')
wrapper.unmount()
})
it('leaves the startup animation and shows an error if detection fails', async () => {
const pendingRefresh = deferred()
const { wrapper } = await mountApp(({ bridge }) => {
bridge.localWorkspace.refreshAgents.mockReturnValueOnce(pendingRefresh.promise)
})
expect(wrapper.find('.agent-discovery-overlay').exists()).toBe(true)
pendingRefresh.reject(new Error('LOCAL_AGENT_PROCESS_FAILED'))
await flushPromises()
expect(wrapper.find('.agent-discovery-overlay').exists()).toBe(false)
expect(wrapper.get('.workspace-pane').attributes()).not.toHaveProperty('inert')
expect(wrapper.get('.toast-message').attributes('role')).toBe('alert')
wrapper.unmount()
})
it('shows the workspace home, keeps full Agent management in Settings, and switches language and theme', async () => {
const { wrapper } = await mountApp()
@@ -836,20 +850,17 @@ describe('Meldwork workbench', () => {
expect(source).toMatch(/\.composer-zone\s*\{[^}]*background:\s*transparent;/s)
})
it('keeps the first-run onboarding transition and loads no WebGL at startup', () => {
it('keeps the first-run onboarding transition and the original startup animations', () => {
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
const appSource = readFileSync(resolve(process.cwd(), 'src/App.vue'), 'utf8')
const packageSource = readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')
expect(appSource).toContain('<transition name="onboarding" appear>')
// Startup used to stack a physics loader and a full-screen WebGL field; it is one small bar now.
expect(appSource).not.toContain('PixelBlast')
expect(appSource).not.toContain('WavePhysicsLoader')
expect(appSource).not.toContain('agent-discovery-overlay')
expect(appSource).toContain('<span class="boot-progress" aria-hidden="true"><span /></span>')
expect(packageSource).not.toContain('"three"')
expect(source).not.toContain('--pixel-blast-')
expect(source).toMatch(/@keyframes boot-progress\s*\{[^}]*transform:\s*translateX/s)
expect(appSource).toContain('<PixelBlast :theme="theme" />')
expect(appSource).toContain('<WavePhysicsLoader :theme="theme" />')
expect(appSource).toContain('agent-discovery-overlay')
expect(packageSource).toContain('"three"')
expect(source).toContain('--pixel-blast-')
expect(source).toMatch(/\.onboarding-leave-active\s*\{[^}]*transition:\s*opacity 0\.42s ease;/s)
expect(source).toMatch(/\.onboarding-leave-active \.onboarding-backdrop\s*\{[^}]*transition:\s*background-color 0\.42s ease;/s)
expect(source).toMatch(/\.onboarding-leave-to\s*\{[^}]*opacity:\s*0;/s)
+273
View File
@@ -0,0 +1,273 @@
<template>
<div
ref="container"
class="pixel-blast-container"
:class="{ 'pixel-blast-ready': rendererReady }"
aria-hidden="true"
>
<div class="pixel-blast-fallback" />
<canvas ref="canvas" />
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
theme: { type: String, default: 'light' },
})
const container = ref(null)
const canvas = ref(null)
const rendererReady = ref(false)
let runtime = null
let disposed = false
const VERTEX_SHADER = `
void main() {
gl_Position = vec4(position, 1.0);
}
`
// Adapted from the React Bits PixelBlast shader (MIT).
const FRAGMENT_SHADER = `
precision highp float;
uniform vec2 uResolution;
uniform float uTime;
uniform float uPixelSize;
uniform float uScale;
uniform float uDensity;
uniform float uPixelJitter;
uniform float uEdgeFade;
uniform float uOpacity;
uniform vec3 uPrimaryColor;
uniform vec3 uSecondaryColor;
uniform vec3 uTertiaryColor;
out vec4 fragColor;
float Bayer2(vec2 a) {
a = floor(a);
return fract(a.x / 2.0 + a.y * a.y * 0.75);
}
#define Bayer4(a) (Bayer2(0.5 * (a)) * 0.25 + Bayer2(a))
#define Bayer8(a) (Bayer4(0.5 * (a)) * 0.25 + Bayer2(a))
float hash11(float n) {
return fract(sin(n) * 43758.5453);
}
float vnoise(vec3 p) {
vec3 ip = floor(p);
vec3 fp = fract(p);
float n000 = hash11(dot(ip + vec3(0.0, 0.0, 0.0), vec3(1.0, 57.0, 113.0)));
float n100 = hash11(dot(ip + vec3(1.0, 0.0, 0.0), vec3(1.0, 57.0, 113.0)));
float n010 = hash11(dot(ip + vec3(0.0, 1.0, 0.0), vec3(1.0, 57.0, 113.0)));
float n110 = hash11(dot(ip + vec3(1.0, 1.0, 0.0), vec3(1.0, 57.0, 113.0)));
float n001 = hash11(dot(ip + vec3(0.0, 0.0, 1.0), vec3(1.0, 57.0, 113.0)));
float n101 = hash11(dot(ip + vec3(1.0, 0.0, 1.0), vec3(1.0, 57.0, 113.0)));
float n011 = hash11(dot(ip + vec3(0.0, 1.0, 1.0), vec3(1.0, 57.0, 113.0)));
float n111 = hash11(dot(ip + vec3(1.0, 1.0, 1.0), vec3(1.0, 57.0, 113.0)));
vec3 weight = fp * fp * fp * (fp * (fp * 6.0 - 15.0) + 10.0);
float x00 = mix(n000, n100, weight.x);
float x10 = mix(n010, n110, weight.x);
float x01 = mix(n001, n101, weight.x);
float x11 = mix(n011, n111, weight.x);
return mix(mix(x00, x10, weight.y), mix(x01, x11, weight.y), weight.z) * 2.0 - 1.0;
}
float fbm(vec2 uv, float time) {
vec3 point = vec3(uv * uScale, time);
float amplitude = 1.0;
float frequency = 1.0;
float sum = 1.0;
for (int index = 0; index < 5; ++index) {
sum += amplitude * vnoise(point * frequency);
frequency *= 1.25;
}
return sum * 0.5 + 0.5;
}
void main() {
vec2 fragCoord = gl_FragCoord.xy - uResolution * 0.5;
float aspectRatio = uResolution.x / uResolution.y;
vec2 pixelId = floor(fragCoord / uPixelSize);
vec2 pixelUv = fract(fragCoord / uPixelSize);
float cellPixelSize = 8.0 * uPixelSize;
vec2 cellCoord = floor(fragCoord / cellPixelSize) * cellPixelSize;
vec2 fieldUv = cellCoord / uResolution * vec2(aspectRatio, 1.0);
float motionTime = uTime * (1.0 + 0.12 * sin(uTime * 0.17));
vec2 drift = vec2(sin(uTime * 0.41), cos(uTime * 0.33)) * 0.045;
float field = fbm(fieldUv + drift, motionTime * 0.05) * 0.5 - 0.65;
float feed = field + (uDensity - 0.5) * 0.3;
float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;
float enabled = step(0.5, feed + bayer);
float hash = fract(sin(dot(pixelId, vec2(127.1, 311.7))) * 43758.5453);
float jitter = 1.0 + (hash - 0.5) * uPixelJitter;
float inset = step(0.08, pixelUv.x) * step(0.08, pixelUv.y)
* step(pixelUv.x, 0.92) * step(pixelUv.y, 0.92);
float mask = enabled * jitter * inset;
vec2 normalized = gl_FragCoord.xy / uResolution;
float edge = min(min(normalized.x, normalized.y), min(1.0 - normalized.x, 1.0 - normalized.y));
mask *= smoothstep(0.0, uEdgeFade, edge);
float centerDistance = length((normalized - 0.5) * vec2(aspectRatio, 1.0));
// Keep the message area visually calm while preserving the animated field
// around the edges of the discovery screen.
mask *= mix(0.1, 1.0, smoothstep(0.14, 0.46, centerDistance));
float colorNoise = clamp(0.5 + 0.5 * vnoise(vec3((fieldUv + drift * 1.8) * 1.35, motionTime * 0.035)), 0.0, 1.0);
vec3 color = mix(uPrimaryColor, uSecondaryColor, colorNoise);
color = mix(color, uTertiaryColor, smoothstep(0.56, 1.0, normalized.y + colorNoise * 0.18));
fragColor = vec4(color, mask * uOpacity);
}
`
function cssColor(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return value || fallback
}
function updateThemeColors() {
if (!runtime) return
runtime.uniforms.uPrimaryColor.value.set(cssColor('--pixel-blast-primary', '#d92f24'))
runtime.uniforms.uSecondaryColor.value.set(cssColor('--pixel-blast-secondary', '#007d91'))
runtime.uniforms.uTertiaryColor.value.set(cssColor('--pixel-blast-tertiary', '#ed512f'))
}
function disposeRuntime() {
if (!runtime) return
cancelAnimationFrame(runtime.animationFrame)
runtime.resizeObserver.disconnect()
runtime.geometry.dispose()
runtime.material.dispose()
runtime.renderer.dispose()
runtime = null
rendererReady.value = false
}
async function initializeRenderer() {
if (!container.value || !canvas.value || typeof window.WebGL2RenderingContext === 'undefined') return
try {
const THREE = await import('three')
if (disposed || !container.value || !canvas.value) return
const renderer = new THREE.WebGLRenderer({
canvas: canvas.value,
alpha: true,
antialias: false,
powerPreference: 'high-performance',
})
renderer.setClearAlpha(0)
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5))
const uniforms = {
uResolution: { value: new THREE.Vector2(1, 1) },
uTime: { value: Math.random() * 1000 },
uPixelSize: { value: 3.7 * renderer.getPixelRatio() },
uScale: { value: 2.2 },
uDensity: { value: 1.26 },
uPixelJitter: { value: 0.32 },
uEdgeFade: { value: 0.12 },
uOpacity: { value: 0.42 },
uPrimaryColor: { value: new THREE.Color() },
uSecondaryColor: { value: new THREE.Color() },
uTertiaryColor: { value: new THREE.Color() },
}
const scene = new THREE.Scene()
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)
const material = new THREE.ShaderMaterial({
vertexShader: VERTEX_SHADER,
fragmentShader: FRAGMENT_SHADER,
uniforms,
transparent: true,
depthTest: false,
depthWrite: false,
glslVersion: THREE.GLSL3,
})
const geometry = new THREE.PlaneGeometry(2, 2)
scene.add(new THREE.Mesh(geometry, material))
const resize = () => {
const width = Math.max(1, container.value?.clientWidth || 1)
const height = Math.max(1, container.value?.clientHeight || 1)
renderer.setSize(width, height, false)
uniforms.uResolution.value.set(renderer.domElement.width, renderer.domElement.height)
uniforms.uPixelSize.value = 3.7 * renderer.getPixelRatio()
}
const resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(container.value)
runtime = {
animationFrame: 0,
geometry,
material,
renderer,
resizeObserver,
uniforms,
}
resize()
updateThemeColors()
rendererReady.value = true
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
let previousFrame = performance.now()
const animate = (now) => {
if (!runtime) return
runtime.uniforms.uTime.value += Math.min(32, now - previousFrame) * 0.00036
previousFrame = now
renderer.render(scene, camera)
if (!reducedMotion) runtime.animationFrame = requestAnimationFrame(animate)
}
runtime.animationFrame = requestAnimationFrame(animate)
} catch {
disposeRuntime()
}
}
watch(() => props.theme, () => requestAnimationFrame(updateThemeColors))
onMounted(() => { void initializeRenderer() })
onBeforeUnmount(() => {
disposed = true
disposeRuntime()
})
</script>
<style scoped>
.pixel-blast-container,
.pixel-blast-container canvas,
.pixel-blast-fallback {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.pixel-blast-container {
overflow: hidden;
pointer-events: none;
}
.pixel-blast-container canvas {
display: block;
opacity: 0;
transition: opacity 0.22s ease;
}
.pixel-blast-ready canvas {
opacity: 1;
}
.pixel-blast-fallback {
background-image:
repeating-linear-gradient(90deg, transparent 0 5px, color-mix(in srgb, var(--pixel-blast-secondary) 38%, transparent) 5px 8px),
repeating-linear-gradient(0deg, transparent 0 5px, color-mix(in srgb, var(--pixel-blast-primary) 34%, transparent) 5px 8px);
opacity: 0.32;
transition: opacity 0.22s ease;
mask-image: linear-gradient(135deg, transparent 2%, black 34%, black 66%, transparent 98%);
}
.pixel-blast-ready .pixel-blast-fallback {
opacity: 0;
}
</style>
@@ -0,0 +1,170 @@
<template>
<div class="wave-physics-loader" aria-hidden="true">
<div class="wave-physics-track">
<span
v-for="bar in bars"
:key="bar.index"
class="wave-physics-bar"
:style="{ height: bar.height, backgroundColor: bar.color }"
/>
<span
class="wave-physics-ball"
:style="{
transform: `translate3d(${ball.x}, ${ball.y}, 0) scaleX(${ball.scaleX}) scaleY(${ball.scaleY})`,
backgroundColor: ball.color,
}"
/>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
const props = defineProps({
theme: { type: String, default: 'light' },
})
const numBars = 15
const barWidth = 12
const barGap = 8
const barTotalWidth = barWidth + barGap
const B = 4
const maxBounce = 60
const baseBarH = 16
const wavePeakH = 48
const duration = 4000
const progress = ref(0.5)
let animationFrame = 0
function interpolate(start, end, value) {
return Math.round(start + (end - start) * value)
}
function barColor(theme, waveValue) {
if (theme === 'dark') {
return `rgb(${interpolate(39, 228, waveValue)} ${interpolate(39, 228, waveValue)} ${interpolate(42, 231, waveValue)})`
}
return `rgb(${interpolate(228, 39, waveValue)} ${interpolate(228, 39, waveValue)} ${interpolate(231, 42, waveValue)})`
}
function frameAt(t) {
const xFrac = t < 0.5 ? t / 0.5 : (1 - t) / 0.5
const ballIndex = xFrac * (numBars - 1)
let bounceF = (xFrac * B) % 1
if (xFrac === 0 || xFrac === 1) bounceF = 0
const bounceH = 4 * bounceF * (1 - bounceF)
const heightFactor = Math.max(0, 1 - bounceH * 2)
const ballIndent = heightFactor * 20
const ballY = (baseBarH + wavePeakH - ballIndent) + bounceH * maxBounce
const bars = Array.from({ length: numBars }, (_unused, index) => {
const dist = Math.abs(index - ballIndex)
let waveValue = 0
if (dist < 3) waveValue = Math.cos((dist / 3) * (Math.PI / 2))
let indent = 0
if (dist < 1.5) {
const indentDist = Math.cos((dist / 1.5) * (Math.PI / 2))
indent = indentDist * heightFactor * 20
}
const barHeight = Math.max(4, baseBarH + waveValue * wavePeakH - indent)
return {
color: barColor(props.theme, waveValue),
height: `${barHeight}px`,
index,
}
})
return {
bars,
ball: {
color: props.theme === 'dark' ? 'rgb(244, 244, 245)' : 'rgb(24, 24, 27)',
scaleX: 1 + heightFactor * 0.25,
scaleY: 1 - heightFactor * 0.3,
x: `${ballIndex * barTotalWidth}px`,
y: `-${ballY}px`,
},
}
}
const frame = computed(() => frameAt(progress.value))
const bars = computed(() => frame.value.bars)
const ball = computed(() => frame.value.ball)
onMounted(() => {
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return
if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const start = performance.now()
const tick = (now) => {
progress.value = ((now - start) % duration) / duration
animationFrame = window.requestAnimationFrame(tick)
}
animationFrame = window.requestAnimationFrame(tick)
})
onBeforeUnmount(() => {
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(animationFrame)
}
})
</script>
<style scoped>
.wave-physics-loader {
width: 100%;
display: flex;
justify-content: center;
}
.wave-physics-track {
position: relative;
width: 292px;
height: 192px;
display: flex;
align-items: flex-end;
justify-content: flex-start;
gap: 8px;
}
.wave-physics-bar {
width: 12px;
border-radius: 999px;
transform-origin: bottom center;
will-change: height, background-color;
}
.wave-physics-ball {
position: absolute;
left: 0;
bottom: 0;
width: 12px;
height: 12px;
border-radius: 999px;
transform-origin: bottom center;
box-shadow: 0 1px 3px color-mix(in srgb, currentColor 24%, transparent);
will-change: transform, background-color;
}
@media (max-width: 640px) {
.wave-physics-track {
width: 260px;
transform: scale(0.9);
transform-origin: center;
}
}
@media (prefers-reduced-motion: reduce) {
.wave-physics-bar,
.wave-physics-ball {
will-change: auto;
}
}
</style>
+4
View File
@@ -6,6 +6,10 @@ export default {
'delivery.run.budget-exhausted': 'Budget exhausted',
'delivery.run.circuit-breaker': 'Stopped by run protection',
'delivery.viewing': 'Viewing delivery; next message is regular chat',
'agentDiscovery.kicker': 'Local workspace startup',
'agentDiscovery.title': 'Finding your local Agents',
'agentDiscovery.body': 'Meldwork is checking the Agent CLIs available on this computer.',
'agentDiscovery.status': 'Preparing your workspace',
"delivery.title": "Tasks & delivery",
"delivery.bound": "Next message runs this task",
"delivery.select": "Select task",
+4
View File
@@ -6,6 +6,10 @@ export default {
'delivery.run.budget-exhausted': '预算已用尽',
'delivery.run.circuit-breaker': '运行保护已停止执行',
'delivery.viewing': '查看交付中;下一次发送为普通聊天',
'agentDiscovery.kicker': '本地工作台启动中',
'agentDiscovery.title': '正在寻找本地 Agent',
'agentDiscovery.body': 'Meldwork 正在检查这台电脑上可用的 Agent CLI。',
'agentDiscovery.status': '正在准备工作台',
"delivery.title": "任务与交付",
"delivery.bound": "下一次发送用于此任务",
"delivery.select": "选择任务",
+153 -30
View File
@@ -1,4 +1,155 @@
/* Agent discovery runs in the background: the sidebar shows skeleton rows
.agent-discovery-overlay {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
overflow: hidden;
isolation: isolate;
padding: 32px;
background: var(--bg);
color: var(--text);
cursor: progress;
}
.agent-discovery-content {
position: relative;
z-index: 1;
width: min(430px, 100%);
display: grid;
justify-items: center;
gap: 0;
padding: 12px 20px;
text-align: center;
}
.agent-discovery-mark {
position: relative;
display: block;
width: 52px;
height: 52px;
margin-bottom: 28px;
object-fit: contain;
filter: drop-shadow(0 9px 20px color-mix(in srgb, var(--accent) 18%, transparent));
}
.agent-discovery-copy {
position: relative;
z-index: 1;
display: grid;
justify-items: center;
text-shadow: 0 1px 14px color-mix(in srgb, var(--bg) 72%, transparent);
}
.agent-discovery-kicker {
margin: 0 0 9px;
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.agent-discovery-copy h1 {
margin: 0;
font-size: 30px;
line-height: 1.2;
}
.agent-discovery-copy > p:not(.agent-discovery-kicker) {
max-width: 360px;
margin: 10px 0 0;
color: var(--muted);
line-height: 1.55;
}
.agent-discovery-progress {
width: min(260px, 100%);
height: 3px;
margin-top: 26px;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--border) 68%, transparent);
}
.agent-discovery-progress span {
display: block;
width: 46%;
height: 100%;
border-radius: inherit;
background: var(--onboarding-frame-gradient);
box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 34%, transparent);
animation: agent-discovery-progress 1.35s ease-in-out infinite;
}
.agent-discovery-status {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 13px;
color: var(--muted);
font-size: 11px;
}
.agent-discovery-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent);
animation: agent-discovery-dot 1.25s ease-in-out infinite;
}
.agent-discovery-enter-active,
.agent-discovery-leave-active {
transition: opacity 0.28s ease;
}
.agent-discovery-enter-active .agent-discovery-content,
.agent-discovery-leave-active .agent-discovery-content {
transition: opacity 0.28s ease, transform 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
.agent-discovery-enter-from,
.agent-discovery-leave-to {
opacity: 0;
}
.agent-discovery-enter-from .agent-discovery-content,
.agent-discovery-leave-to .agent-discovery-content {
opacity: 0;
transform: translateY(8px) scale(0.985);
}
@keyframes agent-discovery-progress {
0% { transform: translateX(-125%); opacity: 0.4; }
55% { opacity: 1; }
100% { transform: translateX(300%); opacity: 0.4; }
}
@keyframes agent-discovery-dot {
0%, 100% { opacity: 0.42; transform: scale(0.86); }
50% { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.agent-discovery-progress span,
.agent-discovery-status-dot {
animation: none;
}
.agent-discovery-progress span {
width: 58%;
transform: translateX(36%);
}
}
@media (max-width: 480px) {
.agent-discovery-overlay { padding: 24px; }
.agent-discovery-copy h1 { font-size: 26px; }
.agent-discovery-mark { margin-bottom: 20px; }
}
/* The sidebar also shows skeleton rows during Agent discovery
until the first detection returns. Only transform and opacity animate. */
.sidebar-discovery {
display: grid;
@@ -53,37 +204,9 @@
color: var(--muted);
}
.boot-progress {
width: 140px;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--border) 70%, transparent);
}
.boot-progress span {
display: block;
width: 40%;
height: 100%;
border-radius: inherit;
background: var(--accent);
animation: boot-progress 1.2s var(--ease-standard) infinite;
}
@keyframes boot-progress {
from { transform: translateX(-110%); }
to { transform: translateX(260%); }
}
@media (prefers-reduced-motion: reduce) {
.sidebar-discovery-row i,
.sidebar-discovery-row b,
.boot-progress span {
.sidebar-discovery-row b {
animation: none;
}
.boot-progress span {
width: 100%;
opacity: 0.5;
}
}
+6
View File
@@ -1,4 +1,7 @@
:root {
--pixel-blast-primary: #d92f24;
--pixel-blast-secondary: #007d91;
--pixel-blast-tertiary: #ed512f;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -90,6 +93,9 @@
}
:root[data-theme="dark"] {
--pixel-blast-primary: #ff836d;
--pixel-blast-secondary: #54f5ff;
--pixel-blast-tertiary: #ffa36f;
color-scheme: dark;
--bg: #111618;
--sidebar: #161c1e;