mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
feat: refresh the app UI and surface what needs the user
The chat page (header, messages, composer, run status and the animated empty
state) and the modal dialogs are deliberately left as they were before this
refresh: those files are byte-identical to 17c57f5.
What changed
- Design tokens: an 8-step type scale (readable text no smaller than 12px
outside badges), semantic success/warning/danger/info colors for both themes,
AA-contrast muted and accent text, a focus-ring token, elevation, layer and
motion tokens, and a dark backdrop that is darker than the workspace card.
- Startup is no longer blocked by Agent detection. The full-screen WebGL overlay
and the physics loader are removed (three is dropped from the dependencies);
the sidebar, home page and composer show "Detecting local Agents" instead, and
an empty Agent list is not reported as a setup problem while detection runs.
- Needs your decision: conversations waiting on a permission or decision are
marked in the sidebar (rows, Agent icons, collapsed switcher) and listed on the
home page. Group runs that finish while you are elsewhere get the same finished
mark direct chats already had. Collapsed sidebar sections show a count.
- Settings: a new General tab (language, appearance: System/Light/Dark). The
theme now follows the OS until one is chosen; it used to freeze on first
launch. Agent cards explain why an installed Agent is unavailable (installed
version, verified range, failed check) with a Re-detect action. Cloud
connections ask before deleting.
- Gemini: a detected Gemini CLI gets its own logo. It stays out of the built-in
list so nobody sees a "Not installed" card for an Agent Meldwork cannot install.
- Toasts: the duration grows with the message length and pauses on hover; errors
use the alert role.
- Onboarding: a Skip button and arrow-key navigation.
- Unsent text is kept per conversation while the app is open.
- Copy: engineering terms (circuit breaker, hard budget, delivery owner) are
gone from user-facing status text.
- Status colors, focus ring and the run-details waiting state use the new tokens.
Why
Small text and low-contrast grey carried the most important state (Agent
availability, time, hints); the brand color meant ready, done, running and
warning at once; and the app could not be used until local Agent detection
finished although history is local. This makes states legible and separable and
shows the ones that need the user.
Checks
- npm --prefix frontend test: 43 files, 357 tests passed
- npm --prefix frontend run build and build:desktop: passed
- Not verified in a running Electron window; the changed screens need a visual
check in light and dark, Chinese and English.
This commit is contained in:
1 parent
ef9933d571
commit
ce995cb93d
51 files changed
+2097
-1046
No files matched your search
+1
-1
@@ -7,7 +7,7 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: meldwork-media:; media-src 'self' meldwork-media:; font-src 'self' data:; connect-src 'self'; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'"
|
||||
/>
|
||||
<meta name="theme-color" content="#f6f3ed" />
|
||||
<meta name="theme-color" content="#f3f6f8" />
|
||||
<title>Meldwork</title>
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="./logos/meldwork-favicon-16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="./logos/meldwork-favicon-32.png" />
|
||||
|
||||
Generated
-7
@@ -12,7 +12,6 @@
|
||||
"@vicons/ionicons5": "^0.12.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"marked": "^9.0.0",
|
||||
"three": "^0.185.1",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2241,12 +2240,6 @@
|
||||
"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",
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"@vicons/ionicons5": "^0.12.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"marked": "^9.0.0",
|
||||
"three": "^0.185.1",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
+50
-33
@@ -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>
|
||||
<WavePhysicsLoader :theme="theme" />
|
||||
<span class="boot-progress" aria-hidden="true"><span /></span>
|
||||
</section>
|
||||
|
||||
<section v-else-if="bridgeMissing" class="bridge-state">
|
||||
@@ -26,33 +26,6 @@
|
||||
: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
|
||||
@@ -68,9 +41,12 @@
|
||||
<HomeDashboard
|
||||
v-else-if="activeView === 'home'"
|
||||
:agent-description="agentDescription"
|
||||
:awaiting-decision-gates="awaitingDecisionGates"
|
||||
:discovering="agentDiscoveryPending"
|
||||
:group-name="groupName"
|
||||
:groups="snapshot.groups"
|
||||
:installed-count="installedCount"
|
||||
:is-group-awaiting-decision="isGroupAwaitingDecision"
|
||||
:is-group-running="isGroupRunning"
|
||||
:onboarding-completed="onboardingCompleted"
|
||||
:ready-agents="readyAgents"
|
||||
@@ -157,8 +133,19 @@
|
||||
</transition>
|
||||
|
||||
<transition name="toast">
|
||||
<div v-if="toastMessage" class="toast-message" role="status" aria-live="polite">
|
||||
<WarningOutline />
|
||||
<div
|
||||
v-if="toastMessage"
|
||||
class="toast-message"
|
||||
:data-tone="toastTone"
|
||||
:role="toastTone === 'error' ? 'alert' : 'status'"
|
||||
:aria-live="toastTone === 'error' ? 'assertive' : 'polite'"
|
||||
@mouseenter="pauseToast"
|
||||
@mouseleave="resumeToast"
|
||||
@focusin="pauseToast"
|
||||
@focusout="resumeToast"
|
||||
>
|
||||
<AlertCircleOutline v-if="toastTone === 'error'" />
|
||||
<WarningOutline v-else />
|
||||
<span>{{ toastMessage }}</span>
|
||||
<button
|
||||
class="toast-dismiss-button"
|
||||
@@ -184,6 +171,7 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import {
|
||||
AlertCircleOutline,
|
||||
CheckmarkCircleOutline,
|
||||
CloseOutline,
|
||||
WarningOutline,
|
||||
@@ -194,8 +182,6 @@ import ConversationHeader from './components/ConversationHeader.vue'
|
||||
import ConversationTimelineView from './components/ConversationTimelineView.vue'
|
||||
import HomeDashboard from './components/HomeDashboard.vue'
|
||||
import OnboardingDialog from './components/OnboardingDialog.vue'
|
||||
import PixelBlast from './components/PixelBlast.vue'
|
||||
import WavePhysicsLoader from './components/WavePhysicsLoader.vue'
|
||||
import RunTracePanel from './components/RunTracePanel.vue'
|
||||
import SystemSettingsView from './components/SystemSettingsView.vue'
|
||||
import WorkspaceModalContent from './components/WorkspaceModalContent.vue'
|
||||
@@ -281,9 +267,12 @@ const {
|
||||
copyToastMessage,
|
||||
dismissToast,
|
||||
notify,
|
||||
pauseToast,
|
||||
resumeToast,
|
||||
showCopyToast,
|
||||
showError,
|
||||
toastMessage,
|
||||
toastTone,
|
||||
} = useAppNotifications()
|
||||
const collapsedGroupMenuController = useCollapsedGroupMenu({ sidebarCollapsed, shortcutMenuOpen })
|
||||
const {
|
||||
@@ -340,7 +329,9 @@ const {
|
||||
productAppIcon,
|
||||
productMark,
|
||||
productWordmark,
|
||||
setThemePreference,
|
||||
theme,
|
||||
themePreference,
|
||||
toggleLocale,
|
||||
toggleTheme,
|
||||
} = useAppPreferences()
|
||||
@@ -507,6 +498,16 @@ const {
|
||||
const pendingHumanGates = computed(() => (
|
||||
snapshot.value.humanGates.filter(gate => gate.status === 'pending')
|
||||
))
|
||||
// Conversations whose run is paused on a decision only the user can make.
|
||||
const awaitingDecisionGates = computed(() => pendingHumanGates.value.map((gate) => {
|
||||
const groupId = snapshot.value.runs.find(run => run.runId === gate.runId)?.groupId || ''
|
||||
const group = snapshot.value.groups.find(item => item.id === groupId)
|
||||
return group ? { ...gate, groupId, groupName: groupName(group) } : null
|
||||
}).filter(Boolean))
|
||||
const awaitingDecisionGroupIds = computed(() => new Set(awaitingDecisionGates.value.map(gate => gate.groupId)))
|
||||
function isGroupAwaitingDecision(id) {
|
||||
return awaitingDecisionGroupIds.value.has(id)
|
||||
}
|
||||
const activeRunHumanGates = computed(() => (
|
||||
activeRun.value
|
||||
? pendingHumanGates.value.filter(gate => gate.runId === activeRun.value.runId)
|
||||
@@ -584,8 +585,10 @@ 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 || agentDiscoveryPending.value
|
||||
blockingOverlayOpen.value || traceDrawerBlocking.value
|
||||
))
|
||||
const composerContext = useComposerContext({
|
||||
activeGroup,
|
||||
@@ -625,11 +628,14 @@ const {
|
||||
discussionMode,
|
||||
disposeComposerContext,
|
||||
draft,
|
||||
pruneComposerDrafts,
|
||||
resetComposerContext,
|
||||
restoreComposerContext,
|
||||
restoreComposerDraft,
|
||||
scheduleComposerResize,
|
||||
serializeComposerContext,
|
||||
skillMenuOpen,
|
||||
stashComposerDraft,
|
||||
} = composerContext
|
||||
const composerTargetsReady = computed(() => (
|
||||
composerTargetKinds.value.length > 0
|
||||
@@ -851,6 +857,7 @@ const {
|
||||
confirmUnlimitedRounds,
|
||||
contentInteractionBlocked,
|
||||
customAgentDeleteArmed,
|
||||
discoveringAgents: agentDiscoveryPending,
|
||||
focusedAgentKind,
|
||||
formError,
|
||||
goHome,
|
||||
@@ -858,6 +865,7 @@ const {
|
||||
handleSidebarAgentMain,
|
||||
installConfirmKind,
|
||||
installerState,
|
||||
isGroupAwaitingDecision,
|
||||
isGroupRunning,
|
||||
modal,
|
||||
openAgentManager,
|
||||
@@ -868,6 +876,7 @@ const {
|
||||
selectGroup,
|
||||
selectedGroupId,
|
||||
selectSystemSettingsSection,
|
||||
setThemePreference,
|
||||
sidebarAgentsCollapsed,
|
||||
sidebarGroupsCollapsed,
|
||||
sidebarCollapsed,
|
||||
@@ -876,6 +885,7 @@ const {
|
||||
systemSettingsSection,
|
||||
t,
|
||||
theme,
|
||||
themePreference,
|
||||
toggleLocale,
|
||||
toggleTheme,
|
||||
},
|
||||
@@ -1056,10 +1066,17 @@ watch(activeGroupMemberSignature, (value, previous) => {
|
||||
messageDeleteArmedId.value = ''
|
||||
clearActiveTurn()
|
||||
roundSettingsOpen.value = false
|
||||
stashComposerDraft(previousGroupId)
|
||||
resetComposerContext(group)
|
||||
restoreComposerDraft(currentGroupId)
|
||||
pruneComposerDrafts(snapshot.value.groups.map(item => item.id))
|
||||
composerAttachments.value = []
|
||||
void discardAttachments(abandonedAttachments)
|
||||
})
|
||||
// A deleted conversation takes its unsent text with it.
|
||||
watch(() => snapshot.value.groups.map(group => group.id).join('\u0000'), () => {
|
||||
pruneComposerDrafts(snapshot.value.groups.map(group => group.id))
|
||||
})
|
||||
watch(activeRun, (value) => {
|
||||
if (!value) return
|
||||
messageDeleteArmedId.value = ''
|
||||
|
||||
@@ -315,7 +315,9 @@ describe('Meldwork workbench', () => {
|
||||
|
||||
await wrapper.get('[aria-label="Attach files"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.toast-message').attributes('role')).toBe('status')
|
||||
// A failure is announced assertively; plain notices stay polite.
|
||||
expect(wrapper.get('.toast-message').attributes('role')).toBe('alert')
|
||||
expect(wrapper.get('.toast-message').attributes('data-tone')).toBe('error')
|
||||
expect(wrapper.get('.toast-dismiss-button').attributes('aria-label')).toBe('Dismiss')
|
||||
await wrapper.get('.toast-dismiss-button').trigger('click')
|
||||
expect(wrapper.find('.toast-message').exists()).toBe(false)
|
||||
|
||||
@@ -303,7 +303,8 @@ describe('Meldwork workbench', () => {
|
||||
await wrapper.get('.run-agent-row').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.trace-waiting-state').text()).toBe('Waiting')
|
||||
// The system is waiting on the user, so the state says so instead of rotating "thinking" words.
|
||||
expect(wrapper.get('.trace-waiting-state').text()).toBe('Waiting for your decision')
|
||||
expect(wrapper.get('.trace-human-gate-section').text())
|
||||
.toContain('workspace-write synthesis attempt may have produced side effects')
|
||||
expect(wrapper.find('.trace-budget-section').exists()).toBe(false)
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('Meldwork workbench', () => {
|
||||
|
||||
expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('agent-collaboration.png')
|
||||
expect(wrapper.get('.onboarding-primary').attributes()).not.toHaveProperty('disabled')
|
||||
expect(wrapper.get('.onboarding-primary').text()).toContain('Start using')
|
||||
expect(wrapper.get('.onboarding-primary').text()).toContain('Get started')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_200)
|
||||
await flushPromises()
|
||||
|
||||
@@ -45,26 +45,35 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('Meldwork workbench', () => {
|
||||
it('shows a calm full-screen Agent discovery state until the first refresh completes', async () => {
|
||||
it('keeps the workspace usable while the first Agent detection runs', async () => {
|
||||
const fixture = createBridge()
|
||||
const pendingRefresh = deferred()
|
||||
fixture.state.agents = []
|
||||
fixture.bridge.localWorkspace.refreshAgents.mockReturnValueOnce(pendingRefresh.promise)
|
||||
window.meldworkDesktop = fixture.bridge
|
||||
|
||||
const wrapper = mount(App, { attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.agent-discovery-overlay').exists()).toBe(true)
|
||||
expect(wrapper.get('.agent-discovery-overlay').attributes('role')).toBe('status')
|
||||
expect(wrapper.find('.pixel-blast-container').exists()).toBe(true)
|
||||
expect(wrapper.find('.pixel-blast-container canvas').exists()).toBe(true)
|
||||
expect(wrapper.find('.agent-discovery-orbit').exists()).toBe(false)
|
||||
expect(wrapper.get('.agent-discovery-copy h1').text()).toBe('Finding your local Agents')
|
||||
// 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)
|
||||
|
||||
pendingRefresh.resolve(structuredClone(fixture.state))
|
||||
pendingRefresh.resolve(structuredClone({
|
||||
...fixture.state,
|
||||
agents: [{ kind: 'codex', installed: true, available: true, credentialState: 'ready', version: '1.0.0' }],
|
||||
}))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.agent-discovery-overlay').exists()).toBe(false)
|
||||
expect(wrapper.find('.sidebar-discovery').exists()).toBe(false)
|
||||
expect(wrapper.get('.home-workspace-state').text()).not.toBe('Detecting local Agents')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -522,7 +531,9 @@ describe('Meldwork workbench', () => {
|
||||
await wrapper.get('.sidebar-toggle').trigger('click')
|
||||
const switcher = wrapper.get('.collapsed-group-switcher-button')
|
||||
expect(switcher.attributes('aria-label')).toBe('Switch group chat')
|
||||
expect(switcher.text()).toContain('10')
|
||||
// A total count read as "10 unread"; the switcher only shows a dot when a group needs the user.
|
||||
expect(switcher.text()).toBe('')
|
||||
expect(switcher.find('.attention-dot').exists()).toBe(false)
|
||||
|
||||
await switcher.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -810,7 +821,8 @@ describe('Meldwork workbench', () => {
|
||||
expect(appSource).toContain(':data-platform="desktopPlatform"')
|
||||
expect(appSource).toContain("const desktopPlatform = computed(() => api.value?.platform || installCatalog.value.platform || '')")
|
||||
expect(source).toMatch(/--shell-gradient:\s*linear-gradient\(145deg,\s*#f1f8f7 0%,\s*#e2efef 48%,\s*#c9dde0 100%\);/)
|
||||
expect(source).toMatch(/:root\[data-theme="dark"\]\s*\{[^}]*--shell-gradient:\s*linear-gradient\(145deg,\s*#2d3a3b 0%,\s*#222e32 48%,\s*#172226 100%\);/s)
|
||||
// The dark backdrop stays darker than the workspace card so the card reads as raised.
|
||||
expect(source).toMatch(/:root\[data-theme="dark"\]\s*\{[^}]*--shell-gradient:\s*linear-gradient\(145deg,\s*#1f292b 0%,\s*#182124 48%,\s*#111a1d 100%\);[^}]*--workspace-surface:\s*#1c2124;/s)
|
||||
expect(source).toMatch(/--onboarding-frame-gradient:\s*linear-gradient\(145deg,\s*#dcefeb 0%,\s*#9fc2ca 48%,\s*#ddb5aa 100%\);/)
|
||||
expect(source).toMatch(/:root\[data-theme="dark"\]\s*\{[^}]*--onboarding-frame-gradient:\s*linear-gradient\(145deg,\s*#4a696b 0%,\s*#2e4751 52%,\s*#775047 100%\);/s)
|
||||
expect(source).toMatch(/\.onboarding-dialog\s*\{[^}]*border:\s*1px solid transparent;[^}]*background:\s*linear-gradient\(var\(--surface\), var\(--surface\)\) padding-box,\s*var\(--onboarding-frame-gradient\) border-box;/s)
|
||||
@@ -824,38 +836,24 @@ describe('Meldwork workbench', () => {
|
||||
expect(source).toMatch(/\.composer-zone\s*\{[^}]*background:\s*transparent;/s)
|
||||
})
|
||||
|
||||
it('uses compact gradient transitions for discovery and first-run onboarding', () => {
|
||||
it('keeps the first-run onboarding transition and loads no WebGL at startup', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
|
||||
const appSource = readFileSync(resolve(process.cwd(), 'src/App.vue'), 'utf8')
|
||||
const pixelBlastSource = readFileSync(resolve(process.cwd(), 'src/components/PixelBlast.vue'), 'utf8')
|
||||
const packageSource = readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')
|
||||
|
||||
expect(appSource).toContain('<transition name="onboarding" appear>')
|
||||
expect(appSource).toContain('<PixelBlast :theme="theme" />')
|
||||
expect(source).toContain('--pixel-blast-primary: #d92f24;')
|
||||
expect(source).toContain('--pixel-blast-secondary: #007d91;')
|
||||
expect(source).toContain('--pixel-blast-tertiary: #ed512f;')
|
||||
expect(source).toContain('--pixel-blast-primary: #ff836d;')
|
||||
expect(source).toContain('--pixel-blast-secondary: #54f5ff;')
|
||||
expect(source).toContain('--pixel-blast-tertiary: #ffa36f;')
|
||||
expect(pixelBlastSource).toContain('uPixelSize: { value: 3.7 * renderer.getPixelRatio() }')
|
||||
expect(pixelBlastSource).toContain('vec2 pixelId = floor(fragCoord / uPixelSize);')
|
||||
expect(pixelBlastSource).toContain('float cellPixelSize = 8.0 * uPixelSize;')
|
||||
expect(pixelBlastSource).toContain('float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;')
|
||||
expect(pixelBlastSource).toContain('float motionTime = uTime * (1.0 + 0.12 * sin(uTime * 0.17));')
|
||||
expect(pixelBlastSource).toContain('vec2 drift = vec2(sin(uTime * 0.41), cos(uTime * 0.33)) * 0.045;')
|
||||
expect(pixelBlastSource).toContain('uDensity: { value: 1.26 }')
|
||||
expect(pixelBlastSource).toContain('uPixelJitter: { value: 0.32 }')
|
||||
expect(pixelBlastSource).toContain('uOpacity: { value: 0.42 }')
|
||||
expect(pixelBlastSource).toContain('Math.min(32, now - previousFrame) * 0.00036')
|
||||
expect(pixelBlastSource).not.toContain('forceContextLoss()')
|
||||
expect(source).toMatch(/\.agent-discovery-leave-active\s*\{[^}]*transition:\s*opacity 0\.28s ease;/s)
|
||||
// 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(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)
|
||||
expect(source).toMatch(/\.onboarding-leave-to \.onboarding-dialog\s*\{[^}]*opacity:\s*0;[^}]*transform:\s*translateY\(14px\) scale\(0\.975\);/s)
|
||||
expect(source).not.toContain('@keyframes agent-discovery-spin')
|
||||
expect(source).not.toContain('.agent-discovery-orbit-ring')
|
||||
expect(source).not.toContain('.agent-discovery-pixel-field')
|
||||
})
|
||||
|
||||
it('uses one settings entry and a borderless Agent mention menu', () => {
|
||||
@@ -881,17 +879,20 @@ describe('Meldwork workbench', () => {
|
||||
expect(source).toMatch(/\.direct-session-open\s*\{[^}]*grid-column:\s*1;/s)
|
||||
expect(source).toMatch(/\.direct-session-action\s*\{[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s)
|
||||
expect(source).toMatch(/\.direct-session-row:hover \.direct-session-action,[^{]+\.direct-session-row:focus-within \.direct-session-action\s*\{[^}]*opacity:\s*0\.62;[^}]*pointer-events:\s*auto;/s)
|
||||
expect(source).toMatch(/\.direct-session-row > \.run-mark,[^{]+\.direct-session-row > \.run-finished-mark\s*\{[^}]*grid-column:\s*4;[^}]*grid-row:\s*1;[^}]*justify-self:\s*end;/s)
|
||||
expect(source).toMatch(/\.direct-session-row > \.run-mark,[^{]+\.direct-session-row > \.run-finished-mark,[^{]+\.direct-session-row > \.attention-mark\s*\{[^}]*grid-column:\s*4;[^}]*grid-row:\s*1;[^}]*justify-self:\s*end;/s)
|
||||
expect(source).toMatch(/\.run-agent-row\s*\{[^}]*grid-template-columns:\s*32px 20px;[^}]*grid-template-areas:\s*"avatar state"/s)
|
||||
expect(source).toMatch(/\.run-agent-state\s*\{[^}]*justify-content:\s*flex-end;/s)
|
||||
})
|
||||
|
||||
it('hides all visible scrollbars while preserving scroll behavior', () => {
|
||||
it('hides scrollbars on the shell, chat page and dialogs but shows thin ones on long lists and panels', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8')
|
||||
|
||||
expect(source).toMatch(/html,\s*body,\s*#app\s*\{[^}]*scrollbar-width:\s*none;[^}]*-ms-overflow-style:\s*none;/s)
|
||||
expect(source).toMatch(/html::-webkit-scrollbar,[^}]+body::-webkit-scrollbar,[^}]+#app::-webkit-scrollbar,[^}]+\*::\-webkit-scrollbar\s*\{[^}]*width:\s*0;[^}]*height:\s*0;/s)
|
||||
expect(source).not.toMatch(/scrollbar-width:\s*thin;/)
|
||||
// Long sidebar lists, run details and settings show where you are; the chat page and dialogs stay clean.
|
||||
expect(source).toMatch(/\.conversation-nav,[^{]*\.trace-panel-scroll,[^{]*\.settings-panel,[^{]*\{[^}]*scrollbar-width:\s*thin;[^}]*scrollbar-color:\s*var\(--scrollbar-thumb\) transparent;/s)
|
||||
expect(source).not.toMatch(/\.message-scroll,[^{]*\{[^}]*scrollbar-width:\s*thin;/s)
|
||||
expect(source).not.toMatch(/\.modal-body,[^{]*\{[^}]*scrollbar-width:\s*thin;/s)
|
||||
})
|
||||
|
||||
it('uses a distinct Meldwork palette and separates composer controls', () => {
|
||||
@@ -917,7 +918,9 @@ describe('Meldwork workbench', () => {
|
||||
expect(source).toMatch(/\.message-row\.agent \.message-body:hover \.message-meta-actions > button,[^{]+\.message-row\.agent \.message-body:hover \.message-footer-actions > button/s)
|
||||
expect(source).toMatch(/\.message-meta-actions > button::after,[^{]+\.message-footer-actions > button::after,[^{]+\.code-copy-button::after\s*\{[^}]*bottom:\s*calc\(100% \+ 6px\);[^}]*background:\s*var\(--surface-raised\);/s)
|
||||
expect(source).toMatch(/\.message-footer-actions > button::after\s*\{[^}]*top:\s*calc\(100% \+ 6px\);[^}]*bottom:\s*auto;/s)
|
||||
expect(source).toMatch(/\.conversation-link\s*\{[^}]*grid-template-columns:\s*26px minmax\(0, 1fr\) 58px;/s)
|
||||
// Group names use the full row; row actions float over the end with a fade instead of reserving a column.
|
||||
expect(source).toMatch(/\.conversation-link\s*\{[^}]*grid-template-columns:\s*26px minmax\(0, 1fr\) auto;/s)
|
||||
expect(source).toMatch(/\.group-conversation-actions\s*\{[^}]*position:\s*absolute;[^}]*background:\s*linear-gradient/s)
|
||||
expect(source).toMatch(/\.conversation-empty-wordmark\s*\{[^}]*width:\s*min\(276px, 60vw\);/s)
|
||||
expect(source).toMatch(/\.conversation-empty-copy\s*\{[^}]*width:\s*min\(920px, 100%\);[^}]*min-height:\s*60px;/s)
|
||||
expect(source).not.toContain('img[src$="/hermes.svg"],\n:root[data-theme="dark"] img[src$="/opencode.svg"]')
|
||||
|
||||
@@ -91,6 +91,55 @@ describe('Agent compatibility state', () => {
|
||||
expect(agentLogo('cloud-0123456789abcdef01234567')).toContain('agent-logos/codex.svg')
|
||||
})
|
||||
|
||||
it('gives Gemini its own logo and name instead of the generic custom-Agent icon', () => {
|
||||
setLocale('en')
|
||||
const cloudAgent = {
|
||||
kind: 'cloud-fedcba987654321012345678',
|
||||
sourceKind: 'gemini',
|
||||
label: 'Gemini @ Server',
|
||||
custom: false,
|
||||
connector: true,
|
||||
cloud: true,
|
||||
installed: true,
|
||||
available: true,
|
||||
credentialState: 'ready',
|
||||
showInSidebar: true,
|
||||
}
|
||||
const catalog = useAgentCatalog({
|
||||
activeGroup: ref(null),
|
||||
directGroupsFor: () => [],
|
||||
installCatalog: ref({ agents: [{ kind: 'gemini', name: 'Gemini CLI', installed: true }, cloudAgent] }),
|
||||
snapshot: ref({ agents: [
|
||||
{ kind: 'gemini', name: 'Gemini CLI', installed: true, available: true, credentialState: 'ready' },
|
||||
cloudAgent,
|
||||
] }),
|
||||
t,
|
||||
theme: ref('light'),
|
||||
})
|
||||
|
||||
const gemini = catalog.mergedCatalog.value.find(agent => agent.kind === 'gemini')
|
||||
expect(gemini.label).toBe('Gemini CLI')
|
||||
expect(gemini.logo).toContain('agent-logos/gemini.svg')
|
||||
expect(catalog.agentCatalogGroups.value.find(group => group.id === 'official').agents.map(agent => agent.kind))
|
||||
.toContain('gemini')
|
||||
expect(catalog.mergedCatalog.value.find(agent => agent.kind === cloudAgent.kind).logo)
|
||||
.toContain('agent-logos/gemini.svg')
|
||||
setCloudAgentProfiles([cloudAgent])
|
||||
expect(agentLogo(cloudAgent.kind)).toContain('agent-logos/gemini.svg')
|
||||
expect(agentLogo('gemini', 'dark')).toContain('agent-logos/gemini.svg')
|
||||
setCloudAgentProfiles([])
|
||||
|
||||
const withoutGemini = useAgentCatalog({
|
||||
activeGroup: ref(null),
|
||||
directGroupsFor: () => [],
|
||||
installCatalog: ref({ agents: [] }),
|
||||
snapshot: ref({ agents: [] }),
|
||||
t,
|
||||
theme: ref('light'),
|
||||
})
|
||||
expect(withoutGemini.mergedCatalog.value.map(agent => agent.kind)).toEqual(AGENTS.map(agent => agent.kind))
|
||||
})
|
||||
|
||||
it('keeps an installed cloud Agent that needs login in management but out of chat choices', () => {
|
||||
setLocale('en')
|
||||
const cloudAgent = {
|
||||
@@ -167,4 +216,65 @@ describe('Agent compatibility state', () => {
|
||||
})
|
||||
expect(state.label).toBe('Detection timed out; retry')
|
||||
})
|
||||
|
||||
describe('explaining why an installed Agent is unavailable', () => {
|
||||
function noteFor(agent) {
|
||||
setLocale('en')
|
||||
const catalog = useAgentCatalog({
|
||||
activeGroup: ref(null),
|
||||
directGroupsFor: () => [],
|
||||
installCatalog: ref({ agents: [] }),
|
||||
snapshot: ref({ agents: [agent] }),
|
||||
t,
|
||||
theme: ref('light'),
|
||||
})
|
||||
return catalog.agentStateNote(catalog.mergedCatalog.value.find(item => item.kind === agent.kind))
|
||||
}
|
||||
|
||||
it('names the installed version, the verified range, and why the CLI failed its startup check', () => {
|
||||
const note = noteFor({
|
||||
kind: 'workbuddy', installed: true, available: false,
|
||||
resolvedVersion: '2.161.0', supportedVersionRange: '2.115.0..2.132.0',
|
||||
compatibilityState: 'incompatible', incompatibilityReason: 'LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING',
|
||||
})
|
||||
expect(note).toContain('2.161.0')
|
||||
expect(note).toContain('did not pass the startup check')
|
||||
expect(note).toContain('Verified versions: 2.115.0..2.132.0.')
|
||||
})
|
||||
|
||||
it('distinguishes an unsupported version, a missing sign-in, and a detection timeout', () => {
|
||||
expect(noteFor({
|
||||
kind: 'codex', installed: true, available: false, resolvedVersion: '9.0.0',
|
||||
supportedVersionRange: '0.137.0..0.146.0',
|
||||
compatibilityState: 'incompatible', incompatibilityReason: 'LOCAL_AGENT_VERSION_UNSUPPORTED',
|
||||
})).toContain('outside the range Meldwork has verified')
|
||||
expect(noteFor({
|
||||
kind: 'codex', installed: true, available: false, credentialState: 'missing',
|
||||
})).toContain('finish signing in')
|
||||
expect(noteFor({
|
||||
kind: 'codex', installed: true, available: false,
|
||||
incompatibilityReason: 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT',
|
||||
})).toContain('Detection timed out')
|
||||
})
|
||||
|
||||
it('sends a cloud Agent to its server to sign in, not to a local terminal', () => {
|
||||
const note = noteFor({
|
||||
kind: 'cloud-0123456789abcdef01234567', sourceKind: 'claude', cloud: true,
|
||||
installed: true, available: false, credentialState: 'missing',
|
||||
})
|
||||
expect(note).toContain('cloud Agent')
|
||||
expect(note).toContain('on its server')
|
||||
expect(note).not.toContain('terminal')
|
||||
})
|
||||
|
||||
it('stays silent when there is nothing to explain', () => {
|
||||
expect(noteFor({ kind: 'codex', installed: true, available: true })).toBe('')
|
||||
expect(noteFor({ kind: 'codex', installed: false, available: false })).toBe('')
|
||||
// Without a verified range it still explains, just without the range sentence.
|
||||
expect(noteFor({
|
||||
kind: 'codex', installed: true, available: false,
|
||||
compatibilityState: 'incompatible', incompatibilityReason: 'LOCAL_AGENT_VERSION_UNSUPPORTED',
|
||||
})).not.toContain('Verified versions')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { useAppNotifications } from '../../composables/useAppNotifications.js'
|
||||
import { setLocale } from '../../i18n.js'
|
||||
|
||||
function mountNotifications() {
|
||||
let api = null
|
||||
const wrapper = mount(defineComponent({
|
||||
setup() {
|
||||
api = useAppNotifications()
|
||||
return () => h('div')
|
||||
},
|
||||
}))
|
||||
return { api, wrapper }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setLocale('en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('app notifications', () => {
|
||||
it('keeps short notices for four seconds and longer ones long enough to read', () => {
|
||||
const { api, wrapper } = mountNotifications()
|
||||
|
||||
api.notify('Saved.')
|
||||
vi.advanceTimersByTime(3900)
|
||||
expect(api.toastMessage.value).toBe('Saved.')
|
||||
vi.advanceTimersByTime(600)
|
||||
expect(api.toastMessage.value).toBe('')
|
||||
|
||||
const long = 'x'.repeat(80)
|
||||
api.notify(long)
|
||||
// 4 s + 80 × 60 ms = 8.8 s.
|
||||
vi.advanceTimersByTime(8700)
|
||||
expect(api.toastMessage.value).toBe(long)
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(api.toastMessage.value).toBe('')
|
||||
|
||||
api.notify('y'.repeat(1000))
|
||||
vi.advanceTimersByTime(10_100)
|
||||
expect(api.toastMessage.value).toBe('')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('pauses while hovered and restarts the countdown when the pointer leaves', () => {
|
||||
const { api, wrapper } = mountNotifications()
|
||||
|
||||
api.notify('Check the folder permissions.')
|
||||
api.pauseToast()
|
||||
vi.advanceTimersByTime(20_000)
|
||||
expect(api.toastMessage.value).toBe('Check the folder permissions.')
|
||||
|
||||
api.resumeToast()
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(api.toastMessage.value).toBe('Check the folder permissions.')
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(api.toastMessage.value).toBe('')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('marks failures as errors and plain notices as warnings', () => {
|
||||
const { api, wrapper } = mountNotifications()
|
||||
|
||||
api.notify('Select at least one Agent.')
|
||||
expect(api.toastTone.value).toBe('warning')
|
||||
|
||||
api.showError(new Error('LOCAL_AGENT_TIMEOUT'))
|
||||
expect(api.toastTone.value).toBe('error')
|
||||
expect(api.toastMessage.value).toContain('took too long to respond')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '../../i18n.js'
|
||||
import { mountApp } from './app-test-harness.js'
|
||||
|
||||
const gateId = `human-gate-${'e'.repeat(64)}`
|
||||
|
||||
function addPendingDecision(state) {
|
||||
state.groups.push(
|
||||
{
|
||||
id: 'group-needs-you',
|
||||
conversationType: 'group',
|
||||
name: 'Release review',
|
||||
topic: '',
|
||||
agentKinds: ['codex', 'hermes'],
|
||||
workdir: '/tmp/meldwork-workspace',
|
||||
allowWrite: false,
|
||||
createdAt: '2026-08-04T08:00:00.000Z',
|
||||
updatedAt: '2026-08-04T08:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'group-quiet',
|
||||
conversationType: 'group',
|
||||
name: 'Quiet group',
|
||||
topic: '',
|
||||
agentKinds: ['codex', 'hermes'],
|
||||
workdir: '/tmp/meldwork-workspace',
|
||||
allowWrite: false,
|
||||
createdAt: '2026-08-04T07:00:00.000Z',
|
||||
updatedAt: '2026-08-04T07:00:00.000Z',
|
||||
},
|
||||
)
|
||||
state.messages.push({
|
||||
id: 'needs-you-root',
|
||||
groupId: 'group-needs-you',
|
||||
role: 'user',
|
||||
content: 'Check the release',
|
||||
targetKinds: ['codex', 'hermes'],
|
||||
createdAt: '2026-08-04T08:00:00.000Z',
|
||||
})
|
||||
state.runningGroupIds = ['group-needs-you']
|
||||
state.runs = [{
|
||||
runId: 'run-needs-you',
|
||||
groupId: 'group-needs-you',
|
||||
threadRootId: 'needs-you-root',
|
||||
targetKinds: ['codex', 'hermes'],
|
||||
currentKind: 'codex',
|
||||
waitingGateIds: [gateId],
|
||||
agentRuns: [{
|
||||
agentRunId: 'agent-needs-you',
|
||||
kind: 'codex',
|
||||
round: 1,
|
||||
status: 'waiting',
|
||||
output: '',
|
||||
events: [],
|
||||
}],
|
||||
}]
|
||||
state.humanGates = [{
|
||||
gateId,
|
||||
type: 'permission',
|
||||
runId: 'run-needs-you',
|
||||
agentRunId: 'agent-needs-you',
|
||||
agentKind: 'codex',
|
||||
summary: 'Agent requests permission to continue a tool action.',
|
||||
options: [
|
||||
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
|
||||
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
|
||||
],
|
||||
status: 'pending',
|
||||
createdAt: '2026-08-04T08:00:00.000Z',
|
||||
}]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
localStorage.setItem('meldwork-theme', 'light')
|
||||
localStorage.setItem('meldwork-onboarding-seen-v1', '1')
|
||||
setLocale('en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.meldworkDesktop
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('decisions waiting on the user', () => {
|
||||
it('marks only the waiting conversation in the sidebar and lists it on the home page', async () => {
|
||||
const { wrapper } = await mountApp(({ state }) => addPendingDecision(state))
|
||||
|
||||
const rows = wrapper.findAll('.group-conversation-row')
|
||||
const waitingRow = rows.find(row => row.text().includes('Release review'))
|
||||
const quietRow = rows.find(row => row.text().includes('Quiet group'))
|
||||
// The decision mark replaces the running bars, so the row reads "needs you", not "busy".
|
||||
expect(waitingRow.find('.attention-mark').attributes('aria-label')).toBe('Waiting for your decision')
|
||||
expect(waitingRow.find('.run-mark').exists()).toBe(false)
|
||||
expect(quietRow.find('.attention-mark').exists()).toBe(false)
|
||||
|
||||
const item = wrapper.get('.home-attention-item')
|
||||
expect(wrapper.get('.home-attention-header').text()).toContain('Needs your attention')
|
||||
expect(item.text()).toContain('Codex is waiting for you: Permission')
|
||||
expect(item.text()).toContain('Release review')
|
||||
|
||||
await item.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.conversation-header h1').text()).toBe('Release review')
|
||||
// Once the conversation is open, its own row no longer needs the mark.
|
||||
expect(wrapper.findAll('.group-conversation-row')
|
||||
.find(row => row.text().includes('Release review'))
|
||||
.find('.attention-mark').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows a dot on the collapsed group switcher instead of a total count', async () => {
|
||||
const { wrapper } = await mountApp(({ state }) => addPendingDecision(state))
|
||||
|
||||
await wrapper.get('.sidebar-toggle').trigger('click')
|
||||
const switcher = wrapper.get('.collapsed-group-switcher-button')
|
||||
expect(switcher.text()).toBe('')
|
||||
expect(switcher.find('.attention-dot').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the home page free of the attention list when nothing is waiting', async () => {
|
||||
const { wrapper } = await mountApp()
|
||||
|
||||
expect(wrapper.find('.home-attention').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('marks a group discussion that finished while you were elsewhere, then clears it when opened', async () => {
|
||||
const { wrapper, state, emitRunFinished } = await mountApp(({ state: fixture }) => {
|
||||
fixture.groups.push(
|
||||
{
|
||||
id: 'group-done',
|
||||
conversationType: 'group',
|
||||
name: 'Finished discussion',
|
||||
topic: '',
|
||||
agentKinds: ['codex', 'hermes'],
|
||||
workdir: '/tmp/meldwork-workspace',
|
||||
allowWrite: false,
|
||||
createdAt: '2026-08-04T08:00:00.000Z',
|
||||
updatedAt: '2026-08-04T08:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'group-other',
|
||||
conversationType: 'group',
|
||||
name: 'Where I am now',
|
||||
topic: '',
|
||||
agentKinds: ['codex', 'hermes'],
|
||||
workdir: '/tmp/meldwork-workspace',
|
||||
allowWrite: false,
|
||||
createdAt: '2026-08-04T07:00:00.000Z',
|
||||
updatedAt: '2026-08-04T07:00:00.000Z',
|
||||
},
|
||||
)
|
||||
fixture.messages.push({
|
||||
id: 'done-root',
|
||||
groupId: 'group-done',
|
||||
role: 'user',
|
||||
content: 'Discuss the plan',
|
||||
targetKinds: ['codex', 'hermes'],
|
||||
createdAt: '2026-08-04T08:00:00.000Z',
|
||||
})
|
||||
})
|
||||
void state
|
||||
wrapper.vm.selectGroup('group-other')
|
||||
await flushPromises()
|
||||
|
||||
// A failed or stopped run is not a "finished" cue.
|
||||
emitRunFinished({ groupId: 'group-done', threadRootId: 'done-root', status: 'failed' })
|
||||
await flushPromises()
|
||||
const doneRow = () => wrapper.findAll('.group-conversation-row').find(row => row.text().includes('Finished discussion'))
|
||||
expect(doneRow().find('.run-finished-mark').exists()).toBe(false)
|
||||
|
||||
emitRunFinished({ groupId: 'group-done', threadRootId: 'done-root', status: 'completed' })
|
||||
await flushPromises()
|
||||
expect(doneRow().get('.run-finished-mark').attributes('aria-label')).toBe('Run finished')
|
||||
|
||||
wrapper.vm.selectGroup('group-done')
|
||||
await flushPromises()
|
||||
wrapper.vm.selectGroup('group-other')
|
||||
await flushPromises()
|
||||
expect(doneRow().find('.run-finished-mark').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CloudAgentBridgePanel from '../../components/CloudAgentBridgePanel.vue'
|
||||
import { useCloudAgentBridges } from '../../composables/useCloudAgentBridges.js'
|
||||
import { setLocale } from '../../i18n.js'
|
||||
|
||||
describe('Cloud Agent bridges', () => {
|
||||
it('refreshes the local Agent snapshot after connecting a server', async () => {
|
||||
@@ -17,4 +20,37 @@ describe('Cloud Agent bridges', () => {
|
||||
expect(bridgeApi.value.connect).toHaveBeenCalledWith({ address: '10.1.132.21' })
|
||||
expect(refreshAgents).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('asks before deleting a connection and deletes only after confirming', async () => {
|
||||
setLocale('en')
|
||||
const remove = vi.fn(async () => {})
|
||||
const wrapper = mount(CloudAgentBridgePanel, {
|
||||
props: {
|
||||
controller: {
|
||||
address: ref(''),
|
||||
bridges: ref([{ bridgeId: 'bridge-1', label: 'Lab server', address: '203.0.113.10', available: true, transport: 'ssh', agents: [] }]),
|
||||
connect: vi.fn(),
|
||||
error: ref(''),
|
||||
label: ref(''),
|
||||
loading: ref(false),
|
||||
refresh: vi.fn(),
|
||||
remove,
|
||||
saving: ref(false),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[aria-label="Delete connection"]').trigger('click')
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('.cloud-agent-bridge-confirm').text()).toContain('Delete this connection?')
|
||||
|
||||
await wrapper.get('.cloud-agent-bridge-confirm .secondary-button').trigger('click')
|
||||
expect(wrapper.find('.cloud-agent-bridge-confirm').exists()).toBe(false)
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.get('[aria-label="Delete connection"]').trigger('click')
|
||||
await wrapper.get('.cloud-agent-bridge-confirm .danger-button').trigger('click')
|
||||
expect(remove).toHaveBeenCalledWith('bridge-1')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '../../i18n.js'
|
||||
import { mountApp } from './app-test-harness.js'
|
||||
|
||||
function addGroups(state) {
|
||||
for (const [id, name] of [['group-a', 'Alpha review'], ['group-b', 'Beta review']]) {
|
||||
state.groups.push({
|
||||
id,
|
||||
conversationType: 'group',
|
||||
name,
|
||||
topic: '',
|
||||
agentKinds: ['codex', 'hermes'],
|
||||
workdir: '/tmp/meldwork-workspace',
|
||||
allowWrite: false,
|
||||
createdAt: '2026-08-04T08:00:00.000Z',
|
||||
updatedAt: '2026-08-04T08:00:00.000Z',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
localStorage.setItem('meldwork-theme', 'light')
|
||||
localStorage.setItem('meldwork-onboarding-seen-v1', '1')
|
||||
setLocale('en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.meldworkDesktop
|
||||
document.body.className = ''
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function open(wrapper, groupId) {
|
||||
wrapper.vm.selectGroup(groupId)
|
||||
await flushPromises()
|
||||
return wrapper.get('.composer-box textarea')
|
||||
}
|
||||
|
||||
describe('composer drafts', () => {
|
||||
it('keeps unsent text for each conversation when switching between them', async () => {
|
||||
const { wrapper } = await mountApp(({ state }) => addGroups(state))
|
||||
|
||||
let input = await open(wrapper, 'group-a')
|
||||
await input.setValue('Compare the two migration plans')
|
||||
input = await open(wrapper, 'group-b')
|
||||
// Another conversation starts clean...
|
||||
expect(input.element.value).toBe('')
|
||||
await input.setValue('Summarise the open risks')
|
||||
|
||||
// ...and each one gets its own text back.
|
||||
input = await open(wrapper, 'group-a')
|
||||
expect(input.element.value).toBe('Compare the two migration plans')
|
||||
input = await open(wrapper, 'group-b')
|
||||
expect(input.element.value).toBe('Summarise the open risks')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('forgets a draft once it has been sent', async () => {
|
||||
const { wrapper, bridge } = await mountApp(({ state }) => addGroups(state))
|
||||
|
||||
let input = await open(wrapper, 'group-a')
|
||||
await input.setValue('Review the release notes')
|
||||
await wrapper.get('.send-button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(bridge.localWorkspace.send).toHaveBeenCalledTimes(1)
|
||||
|
||||
await open(wrapper, 'group-b')
|
||||
input = await open(wrapper, 'group-a')
|
||||
expect(input.element.value).toBe('')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not bring back text typed into a conversation that was deleted', async () => {
|
||||
const { wrapper, state, emitWorkspaceChanged } = await mountApp(({ state: fixture }) => addGroups(fixture))
|
||||
|
||||
const input = await open(wrapper, 'group-a')
|
||||
await input.setValue('This conversation is about to disappear')
|
||||
await open(wrapper, 'group-b')
|
||||
|
||||
state.groups = state.groups.filter(group => group.id !== 'group-a')
|
||||
emitWorkspaceChanged()
|
||||
await flushPromises()
|
||||
state.groups.push({
|
||||
id: 'group-a',
|
||||
conversationType: 'group',
|
||||
name: 'Alpha review',
|
||||
topic: '',
|
||||
agentKinds: ['codex', 'hermes'],
|
||||
workdir: '/tmp/meldwork-workspace',
|
||||
allowWrite: false,
|
||||
createdAt: '2026-08-04T09:00:00.000Z',
|
||||
updatedAt: '2026-08-04T09:00:00.000Z',
|
||||
})
|
||||
emitWorkspaceChanged()
|
||||
await flushPromises()
|
||||
|
||||
expect((await open(wrapper, 'group-a')).element.value).toBe('')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -44,8 +44,6 @@ describe('Meldwork i18n', () => {
|
||||
it('localizes Human Gates, Agent controls, and budget metadata', () => {
|
||||
setLocale('en')
|
||||
expect(t('humanGate.waiting')).toBe('Waiting for your decision')
|
||||
expect(t('humanGate.waitingWordOne')).toBe('Waiting')
|
||||
expect(t('humanGate.waitingWordFour')).toBe('Reviewing')
|
||||
expect(t('humanGate.option.allowOnce')).toBe('Allow once')
|
||||
expect(t('humanGate.summary.decision')).toBe('This run requires your decision.')
|
||||
expect(t('humanGate.summary.retry')).toContain('may already have changed')
|
||||
@@ -68,13 +66,12 @@ describe('Meldwork i18n', () => {
|
||||
expect(t('run.phase.reconcile')).toBe('Reconciling results')
|
||||
expect(t('run.phase.commit')).toBe('Committing delivery')
|
||||
expect(t('run.phase.failed')).toBe('Failed')
|
||||
expect(t('run.status.circuitBreaker')).toBe('Circuit breaker stopped')
|
||||
expect(t('system.agentBudgetExhausted', { agent: 'Codex' })).toContain('hard run budget')
|
||||
// Status text says what happened instead of naming the mechanism.
|
||||
expect(t('run.status.circuitBreaker')).toBe('Stopped after repeated failures')
|
||||
expect(t('system.agentBudgetExhausted', { agent: 'Codex' })).toContain('usage limit')
|
||||
|
||||
setLocale('zh')
|
||||
expect(t('humanGate.waiting')).toBe('等待你的决定')
|
||||
expect(t('humanGate.waitingWordOne')).toBe('等待中')
|
||||
expect(t('humanGate.waitingWordFour')).toBe('整理中')
|
||||
expect(t('humanGate.option.allowOnce')).toBe('允许一次')
|
||||
expect(t('humanGate.summary.decision')).toBe('该运行需要你做出决定。')
|
||||
expect(t('humanGate.summary.retry')).toContain('可能已经修改')
|
||||
@@ -97,8 +94,9 @@ describe('Meldwork i18n', () => {
|
||||
expect(t('run.phase.dispatch')).toBe('分发 Agent')
|
||||
expect(t('run.phase.running')).toBe('运行中')
|
||||
expect(t('run.phase.failed')).toBe('失败')
|
||||
expect(t('run.status.circuitBreaker')).toBe('已触发熔断器')
|
||||
expect(t('system.agentBudgetExhausted', { agent: 'Codex' })).toContain('硬预算上限')
|
||||
// User-facing status avoids engineering terms like 熔断器 and 硬预算.
|
||||
expect(t('run.status.circuitBreaker')).toBe('失败过多,已自动停止')
|
||||
expect(t('system.agentBudgetExhausted', { agent: 'Codex' })).toContain('用量上限')
|
||||
})
|
||||
|
||||
it('maps every stable desktop error code from direct and wrapped errors', () => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setLocale } from '../../i18n.js'
|
||||
import { deferred, mountApp } from './app-test-harness.js'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
localStorage.setItem('meldwork-theme', 'light')
|
||||
setLocale('en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
delete window.meldworkDesktop
|
||||
document.body.className = ''
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('first-run onboarding', () => {
|
||||
it('can be skipped with a visible button while Agent detection is still running', async () => {
|
||||
vi.spyOn(window.history, 'back').mockImplementation(() => {})
|
||||
const pendingDetection = deferred()
|
||||
const { wrapper } = await mountApp(({ bridge }) => {
|
||||
bridge.localWorkspace.refreshAgents.mockReturnValueOnce(pendingDetection.promise)
|
||||
})
|
||||
|
||||
expect(wrapper.get('.onboarding-primary').attributes()).toHaveProperty('disabled')
|
||||
const skip = wrapper.get('.onboarding-skip')
|
||||
expect(skip.text()).toBe('Skip')
|
||||
await skip.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.onboarding-dialog').exists()).toBe(false)
|
||||
expect(localStorage.getItem('meldwork-onboarding-seen-v1')).toBe('1')
|
||||
// Detection carries on in the background and the window is usable.
|
||||
expect(wrapper.get('.sidebar').attributes()).not.toHaveProperty('inert')
|
||||
pendingDetection.resolve(undefined)
|
||||
await flushPromises()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('moves between slides with the arrow keys', async () => {
|
||||
const { wrapper } = await mountApp()
|
||||
const dialog = wrapper.get('.onboarding-dialog')
|
||||
|
||||
await dialog.trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(wrapper.findAll('.onboarding-dot')[1].attributes('aria-current')).toBe('step')
|
||||
await dialog.trigger('keydown', { key: 'ArrowLeft' })
|
||||
expect(wrapper.findAll('.onboarding-dot')[0].attributes('aria-current')).toBe('step')
|
||||
// The first slide is the floor.
|
||||
await dialog.trigger('keydown', { key: 'ArrowLeft' })
|
||||
expect(wrapper.findAll('.onboarding-dot')[0].attributes('aria-current')).toBe('step')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
import { useAppPreferences } from '../../composables/useAppPreferences.js'
|
||||
|
||||
function fakeColorScheme(initialDark) {
|
||||
const listeners = new Set()
|
||||
const query = {
|
||||
matches: initialDark,
|
||||
addEventListener: vi.fn((_type, listener) => listeners.add(listener)),
|
||||
removeEventListener: vi.fn((_type, listener) => listeners.delete(listener)),
|
||||
}
|
||||
return {
|
||||
query,
|
||||
matchMedia: vi.fn(() => query),
|
||||
setDark(value) {
|
||||
query.matches = value
|
||||
for (const listener of listeners) listener({ matches: value })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mountPreferences() {
|
||||
let api = null
|
||||
const wrapper = mount(defineComponent({
|
||||
setup() {
|
||||
api = useAppPreferences()
|
||||
return () => h('div')
|
||||
},
|
||||
}))
|
||||
return { api, wrapper }
|
||||
}
|
||||
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
delete document.documentElement.dataset.theme
|
||||
})
|
||||
|
||||
describe('theme preference', () => {
|
||||
it('follows the system on first launch without saving a fixed theme', async () => {
|
||||
const scheme = fakeColorScheme(false)
|
||||
vi.stubGlobal('matchMedia', scheme.matchMedia)
|
||||
const { api, wrapper } = mountPreferences()
|
||||
|
||||
expect(api.themePreference.value).toBe('system')
|
||||
expect(api.theme.value).toBe('light')
|
||||
expect(localStorage.getItem('meldwork-theme')).toBeNull()
|
||||
|
||||
scheme.setDark(true)
|
||||
await nextTick()
|
||||
expect(api.theme.value).toBe('dark')
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
expect(localStorage.getItem('meldwork-theme')).toBeNull()
|
||||
wrapper.unmount()
|
||||
expect(scheme.query.removeEventListener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps an explicit choice and stops following the system until System is chosen again', async () => {
|
||||
const scheme = fakeColorScheme(false)
|
||||
vi.stubGlobal('matchMedia', scheme.matchMedia)
|
||||
const { api, wrapper } = mountPreferences()
|
||||
|
||||
api.toggleTheme()
|
||||
await nextTick()
|
||||
expect(api.theme.value).toBe('dark')
|
||||
expect(localStorage.getItem('meldwork-theme')).toBe('dark')
|
||||
|
||||
scheme.setDark(false)
|
||||
await nextTick()
|
||||
expect(api.theme.value).toBe('dark')
|
||||
|
||||
api.setThemePreference('system')
|
||||
await nextTick()
|
||||
expect(api.theme.value).toBe('light')
|
||||
expect(localStorage.getItem('meldwork-theme')).toBe('system')
|
||||
scheme.setDark(true)
|
||||
await nextTick()
|
||||
expect(api.theme.value).toBe('dark')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('restores a saved explicit theme without listening to the system', () => {
|
||||
localStorage.setItem('meldwork-theme', 'dark')
|
||||
const scheme = fakeColorScheme(false)
|
||||
vi.stubGlobal('matchMedia', scheme.matchMedia)
|
||||
const { api, wrapper } = mountPreferences()
|
||||
|
||||
expect(api.theme.value).toBe('dark')
|
||||
expect(scheme.query.addEventListener).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,12 @@ export const AGENTS = Object.freeze([
|
||||
{ kind: 'qwen', label: 'Qwen Code', logo: publicAsset('agent-logos/qwen.svg'), imageLimit: 0, attachmentTypes: ['file'] },
|
||||
])
|
||||
|
||||
const BY_KIND = new Map(AGENTS.map(agent => [agent.kind, agent]))
|
||||
// Detected and runnable, but not in AGENTS: listing it there would show a "Not installed" card to everyone.
|
||||
const DETECTED_ONLY_AGENTS = Object.freeze([
|
||||
{ kind: 'gemini', label: 'Gemini CLI', logo: publicAsset('agent-logos/gemini.svg') },
|
||||
])
|
||||
|
||||
const BY_KIND = new Map([...AGENTS, ...DETECTED_ONLY_AGENTS].map(agent => [agent.kind, agent]))
|
||||
const CUSTOM_BY_KIND = new Map()
|
||||
const CLOUD_BY_KIND = new Map()
|
||||
const CUSTOM_AGENT_KIND = /^custom-[a-f0-9]{16}$/
|
||||
|
||||
@@ -57,6 +57,12 @@
|
||||
<div v-if="installerState.kind === agent.kind && installerState.phase !== 'idle'" class="install-progress">
|
||||
<span>{{ installerPhaseLabel }}</span>
|
||||
</div>
|
||||
<p v-if="agentStateNote(agent)" class="agent-card-note" :data-tone="agentState(agent).tone">
|
||||
<span>{{ agentStateNote(agent) }}</span>
|
||||
<button type="button" :disabled="refreshing" @click.stop="$emit('refresh-agents')">
|
||||
{{ t('home.refresh') }}
|
||||
</button>
|
||||
</p>
|
||||
<div class="agent-card-meta-row">
|
||||
<span
|
||||
v-if="agent.version"
|
||||
@@ -125,6 +131,7 @@ defineProps({
|
||||
agentImageLabel: { type: Function, required: true },
|
||||
agentSkillLabel: { type: Function, required: true },
|
||||
agentState: { type: Function, required: true },
|
||||
agentStateNote: { type: Function, default: () => '' },
|
||||
focusedAgentKind: { type: String, required: true },
|
||||
installConfirmKind: { type: String, required: true },
|
||||
installedCount: { type: Number, required: true },
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@click.self="close"
|
||||
>
|
||||
<section
|
||||
ref="dialog"
|
||||
class="attachment-media-preview-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -20,7 +21,7 @@
|
||||
:title="closeLabel"
|
||||
@click="close"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
<CloseOutline aria-hidden="true" />
|
||||
</button>
|
||||
<img
|
||||
v-if="type === 'image'"
|
||||
@@ -36,6 +37,7 @@
|
||||
playsinline
|
||||
preload="auto"
|
||||
/>
|
||||
<p class="attachment-media-preview-caption" :title="attachment.name">{{ attachment.name }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -43,6 +45,7 @@
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { CloseOutline } from '@vicons/ionicons5'
|
||||
|
||||
const props = defineProps({
|
||||
attachment: { type: Object, required: true },
|
||||
@@ -54,6 +57,7 @@ const emit = defineEmits(['close'])
|
||||
|
||||
const backdrop = ref(null)
|
||||
const closeButton = ref(null)
|
||||
const dialog = ref(null)
|
||||
let returnFocus = null
|
||||
|
||||
function close() {
|
||||
@@ -67,8 +71,15 @@ function handleKeydown(event) {
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab') return
|
||||
// Keep focus inside the preview, but let it reach the video controls.
|
||||
const focusable = [...(dialog.value?.querySelectorAll('button, video[controls]') || [])]
|
||||
if (!focusable.length) return
|
||||
const index = focusable.indexOf(document.activeElement)
|
||||
const next = event.shiftKey
|
||||
? focusable[(index - 1 + focusable.length) % focusable.length]
|
||||
: focusable[(index + 1) % focusable.length]
|
||||
event.preventDefault()
|
||||
closeButton.value?.focus()
|
||||
next.focus()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -95,12 +106,25 @@ body.media-preview-open {
|
||||
|
||||
.attachment-media-preview-backdrop {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-lightbox);
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgb(10 14 20 / 78%);
|
||||
animation: media-preview-fade var(--dur-base) var(--ease-out) both;
|
||||
}
|
||||
|
||||
.attachment-media-preview-dialog {
|
||||
animation: media-preview-zoom var(--dur-slow) var(--ease-out) both;
|
||||
}
|
||||
|
||||
@keyframes media-preview-fade {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes media-preview-zoom {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
}
|
||||
|
||||
.attachment-media-preview-dialog {
|
||||
@@ -115,7 +139,8 @@ body.media-preview-open {
|
||||
.attachment-media-preview-dialog video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 48px);
|
||||
max-height: calc(100vh - 88px);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #080b10;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -136,13 +161,30 @@ body.media-preview-open {
|
||||
right: 10px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(0 0 0 / 62%);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 28px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.attachment-media-preview-close svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.attachment-media-preview-caption {
|
||||
max-width: 100%;
|
||||
margin: 10px 0 0;
|
||||
overflow: hidden;
|
||||
color: rgb(255 255 255 / 82%);
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-media-preview-close:hover,
|
||||
|
||||
@@ -58,13 +58,23 @@
|
||||
</p>
|
||||
<small v-if="!bridge.available && bridge.lastError">{{ translateError(bridge.lastError) }}</small>
|
||||
</div>
|
||||
<div v-if="confirmingBridgeId === bridge.bridgeId" class="cloud-agent-bridge-confirm" role="group" :aria-label="t('cloudAgents.deletePrompt')">
|
||||
<span>{{ t('cloudAgents.deletePrompt') }}</span>
|
||||
<button class="secondary-button compact" type="button" :disabled="saving" @click="confirmingBridgeId = ''">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button class="danger-button compact" type="button" :disabled="saving" @click="confirmRemove(bridge.bridgeId)">
|
||||
{{ t('cloudAgents.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="icon-button"
|
||||
type="button"
|
||||
:title="t('cloudAgents.delete')"
|
||||
:aria-label="t('cloudAgents.delete')"
|
||||
:disabled="saving"
|
||||
@click="remove(bridge.bridgeId)"
|
||||
@click="confirmingBridgeId = bridge.bridgeId"
|
||||
>
|
||||
<TrashOutline />
|
||||
</button>
|
||||
@@ -75,6 +85,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { CloudOutline, RefreshOutline, TrashOutline } from '@vicons/ionicons5'
|
||||
import { t, translateError } from '../i18n.js'
|
||||
|
||||
@@ -94,6 +105,13 @@ const {
|
||||
saving,
|
||||
} = props.controller
|
||||
|
||||
const confirmingBridgeId = ref('')
|
||||
|
||||
async function confirmRemove(bridgeId) {
|
||||
confirmingBridgeId.value = ''
|
||||
await remove(bridgeId)
|
||||
}
|
||||
|
||||
function transportLabel(transport) {
|
||||
return t(`cloudAgents.transport.${transport || 'bridge'}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<section class="settings-panel general-settings-panel">
|
||||
<div class="general-settings-row">
|
||||
<div class="general-settings-copy">
|
||||
<h2 id="general-settings-language">{{ t('generalSettings.languageTitle') }}</h2>
|
||||
<p>{{ t('generalSettings.languageHint') }}</p>
|
||||
</div>
|
||||
<div class="choice-segmented" role="radiogroup" aria-labelledby="general-settings-language">
|
||||
<button
|
||||
v-for="option in languageOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="String(locale === option.value)"
|
||||
:class="{ active: locale === option.value }"
|
||||
:lang="option.lang"
|
||||
@click="setLocale(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="general-settings-row">
|
||||
<div class="general-settings-copy">
|
||||
<h2 id="general-settings-theme">{{ t('generalSettings.themeTitle') }}</h2>
|
||||
<p>{{ t('generalSettings.themeHint') }}</p>
|
||||
</div>
|
||||
<div class="choice-segmented" role="radiogroup" aria-labelledby="general-settings-theme">
|
||||
<button
|
||||
v-for="option in themeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="String(themePreference === option.value)"
|
||||
:class="{ active: themePreference === option.value }"
|
||||
@click="setThemePreference(option.value)"
|
||||
>
|
||||
<component :is="option.icon" aria-hidden="true" />
|
||||
{{ t(option.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, unref } from 'vue'
|
||||
import { DesktopOutline, MoonOutline, SunnyOutline } from '@vicons/ionicons5'
|
||||
import { locale as currentLocale, setLocale, t } from '../i18n.js'
|
||||
|
||||
const props = defineProps({
|
||||
setThemePreference: { type: Function, required: true },
|
||||
themePreference: { type: [String, Object], required: true },
|
||||
})
|
||||
|
||||
// Language names are shown in their own language, whatever the UI locale is.
|
||||
const languageOptions = [
|
||||
{ value: 'zh', label: '中文', lang: 'zh-CN' },
|
||||
{ value: 'en', label: 'English', lang: 'en' },
|
||||
]
|
||||
const themeOptions = [
|
||||
{ value: 'system', labelKey: 'generalSettings.themeSystem', icon: DesktopOutline },
|
||||
{ value: 'light', labelKey: 'generalSettings.themeLight', icon: SunnyOutline },
|
||||
{ value: 'dark', labelKey: 'generalSettings.themeDark', icon: MoonOutline },
|
||||
]
|
||||
|
||||
const locale = currentLocale
|
||||
const themePreference = computed(() => unref(props.themePreference))
|
||||
|
||||
function setThemePreference(value) {
|
||||
props.setThemePreference(value)
|
||||
}
|
||||
</script>
|
||||
@@ -5,13 +5,49 @@
|
||||
<h1>{{ t('home.dashboardTitle') }}</h1>
|
||||
<p>{{ t('home.dashboardSubtitle') }}</p>
|
||||
</div>
|
||||
<div class="home-workspace-state" :class="{ attention: homeNeedsAttention }" role="status">
|
||||
<WarningOutline v-if="homeNeedsAttention" />
|
||||
<div
|
||||
class="home-workspace-state"
|
||||
:class="{ attention: homeNeedsAttention, discovering }"
|
||||
role="status"
|
||||
>
|
||||
<RefreshOutline v-if="discovering" class="spinning" />
|
||||
<WarningOutline v-else-if="homeNeedsAttention" />
|
||||
<CheckmarkCircleOutline v-else />
|
||||
<span>{{ homeWorkspaceSummary }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section
|
||||
v-if="awaitingDecisionGates.length"
|
||||
class="home-attention"
|
||||
aria-labelledby="home-attention-title"
|
||||
>
|
||||
<header class="home-attention-header">
|
||||
<span class="attention-dot" aria-hidden="true" />
|
||||
<h2 id="home-attention-title">{{ t('home.attentionTitle') }}</h2>
|
||||
<small>{{ awaitingDecisionGates.length }}</small>
|
||||
</header>
|
||||
<div class="home-attention-list">
|
||||
<button
|
||||
v-for="gate in awaitingDecisionGates"
|
||||
:key="gate.gateId"
|
||||
class="home-attention-item"
|
||||
type="button"
|
||||
@click="$emit('select-group', gate.groupId)"
|
||||
>
|
||||
<img :src="agentLogo(gate.agentKind, theme)" alt="" />
|
||||
<span class="home-attention-copy">
|
||||
<strong>{{ t('home.attentionItem', { agent: agentLabel(gate.agentKind), type: t(`humanGate.type.${gate.type}`) }) }}</strong>
|
||||
<small>{{ gate.groupName }}</small>
|
||||
</span>
|
||||
<span class="home-attention-action">
|
||||
{{ t('home.attentionOpen') }}
|
||||
<ChevronForwardOutline aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showRecoveryGuide" class="home-recovery-notice" aria-live="polite">
|
||||
<WarningOutline />
|
||||
<div>
|
||||
@@ -48,8 +84,17 @@
|
||||
<strong>{{ groupName(group) }}</strong>
|
||||
<small>{{ recentGroupMeta(group) }}</small>
|
||||
</span>
|
||||
<span v-if="isGroupRunning(group.id)" class="run-mark" :title="t('conversation.runningGeneric')">
|
||||
<span class="run-pulse" />
|
||||
<span
|
||||
v-if="isGroupAwaitingDecision(group.id)"
|
||||
class="attention-mark"
|
||||
role="img"
|
||||
:title="t('humanGate.waiting')"
|
||||
:aria-label="t('humanGate.waiting')"
|
||||
>
|
||||
<span class="attention-dot" />
|
||||
</span>
|
||||
<span v-else-if="isGroupRunning(group.id)" class="run-mark" :title="t('conversation.runningGeneric')">
|
||||
<span class="run-agent-bars" aria-hidden="true"><i /><i /><i /></span>
|
||||
</span>
|
||||
<ChevronForwardOutline />
|
||||
</button>
|
||||
@@ -61,7 +106,8 @@
|
||||
v-if="homeMode === 'first-task'"
|
||||
class="primary-button compact"
|
||||
type="button"
|
||||
:disabled="readyCount < 2"
|
||||
:disabled="readyCount < 1"
|
||||
:title="readyCount < 1 ? t('home.noReadyAgents') : undefined"
|
||||
@click="$emit('open-new-group')"
|
||||
>
|
||||
<AddOutline />
|
||||
@@ -80,7 +126,8 @@
|
||||
v-if="homeMode !== 'first-task'"
|
||||
class="secondary-button compact home-new-group-button"
|
||||
type="button"
|
||||
:disabled="readyCount < 2"
|
||||
:disabled="readyCount < 1"
|
||||
:title="readyCount < 1 ? t('home.noReadyAgents') : undefined"
|
||||
@click="$emit('open-new-group')"
|
||||
>
|
||||
<AddOutline />
|
||||
@@ -104,8 +151,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="home-panel-empty">
|
||||
<TerminalOutline />
|
||||
<p>{{ t('home.noReadyAgents') }}</p>
|
||||
<RefreshOutline v-if="discovering" class="spinning" />
|
||||
<TerminalOutline v-else />
|
||||
<p>{{ discovering ? t('onboarding.detecting') : t('home.noReadyAgents') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -161,14 +209,17 @@ import {
|
||||
TerminalOutline,
|
||||
WarningOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { agentLogo } from '../catalog.js'
|
||||
import { agentLabel, agentLogo } from '../catalog.js'
|
||||
import { t } from '../i18n.js'
|
||||
|
||||
const props = defineProps({
|
||||
agentDescription: { type: Function, required: true },
|
||||
awaitingDecisionGates: { type: Array, default: () => [] },
|
||||
discovering: { type: Boolean, default: false },
|
||||
groupName: { type: Function, required: true },
|
||||
groups: { type: Array, required: true },
|
||||
installedCount: { type: Number, required: true },
|
||||
isGroupAwaitingDecision: { type: Function, default: () => false },
|
||||
isGroupRunning: { type: Function, required: true },
|
||||
onboardingCompleted: { type: Boolean, required: true },
|
||||
readyAgents: { type: Array, required: true },
|
||||
@@ -195,13 +246,19 @@ const homeMode = computed(() => {
|
||||
if (props.groups.length) return 'workspace'
|
||||
return readyCount.value > 0 ? 'first-task' : 'setup'
|
||||
})
|
||||
const homeNeedsAttention = computed(() => readyCount.value === 0)
|
||||
const showSetupGuide = computed(() => props.onboardingCompleted && homeMode.value === 'setup')
|
||||
// While the first detection runs there is no Agent data yet; that is not a
|
||||
// setup problem, so no warning or setup guide until it finishes.
|
||||
const homeNeedsAttention = computed(() => !props.discovering && readyCount.value === 0)
|
||||
const showSetupGuide = computed(() => (
|
||||
props.onboardingCompleted && homeMode.value === 'setup' && !props.discovering
|
||||
))
|
||||
const showRecoveryGuide = computed(() => (
|
||||
props.onboardingCompleted && homeMode.value === 'workspace' && homeNeedsAttention.value
|
||||
))
|
||||
const homeWorkspaceSummary = computed(() => (
|
||||
homeNeedsAttention.value
|
||||
props.discovering
|
||||
? t('onboarding.detecting')
|
||||
: homeNeedsAttention.value
|
||||
? t('home.setupNeeded')
|
||||
: t('home.workspaceSummary', {
|
||||
agents: readyCount.value,
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
aria-modal="true"
|
||||
aria-labelledby="onboarding-title"
|
||||
tabindex="-1"
|
||||
@keydown.left="selectOnboardingSlide(onboardingIndex - 1)"
|
||||
@keydown.right="selectOnboardingSlide(onboardingIndex + 1)"
|
||||
>
|
||||
<div class="onboarding-slide-viewport">
|
||||
<transition name="onboarding-slide">
|
||||
@@ -32,6 +34,10 @@
|
||||
@click="selectOnboardingSlide(index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="onboarding-actions">
|
||||
<button class="onboarding-skip" type="button" @click="completeOnboarding">
|
||||
{{ t('onboarding.skip') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary-button onboarding-primary"
|
||||
type="button"
|
||||
@@ -43,6 +49,7 @@
|
||||
<CheckmarkCircleOutline v-else />
|
||||
{{ onboardingReady ? t('onboarding.start') : onboardingLoadingLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
<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>
|
||||
@@ -148,7 +148,10 @@
|
||||
<span>{{ roundLabel(selectedItem) }}</span>
|
||||
</div>
|
||||
<span class="trace-summary-statuses">
|
||||
<TextMorph v-if="waiting" class="trace-waiting-state" :words="waitingWords" />
|
||||
<span v-if="waiting" class="trace-waiting-state">
|
||||
<span class="attention-dot" aria-hidden="true" />
|
||||
{{ t('humanGate.waiting') }}
|
||||
</span>
|
||||
<span class="trace-status" :data-status="statusTone(selectedItem.status)">
|
||||
{{ statusLabel(selectedItem.status) }}
|
||||
</span>
|
||||
@@ -186,9 +189,12 @@
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<div v-if="selectedItem.context && hasTraceContext(selectedItem.context)" class="trace-context-disclosure">
|
||||
<div class="trace-context-stats" data-context-section="attempt">
|
||||
<details v-if="selectedItem.context && hasTraceContext(selectedItem.context)" class="trace-context-disclosure">
|
||||
<summary>
|
||||
<span>{{ t('trace.attemptInjection') }}</span>
|
||||
<ChevronDownOutline aria-hidden="true" />
|
||||
</summary>
|
||||
<div class="trace-context-stats" data-context-section="attempt">
|
||||
<span>{{ t('trace.contextIncluded', { count: selectedItem.context.includedCount }) }}</span>
|
||||
<span>{{ t('trace.contextOmitted', { count: selectedItem.context.omittedCount }) }}</span>
|
||||
<span>{{ t('trace.contextChars', { count: selectedItem.context.charCount }) }}</span>
|
||||
@@ -273,7 +279,7 @@
|
||||
</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
<p v-if="hasOnlyBareEvents(selectedItem)" class="trace-detail-unavailable">
|
||||
{{ t('trace.detailUnavailable') }}
|
||||
</p>
|
||||
@@ -415,7 +421,6 @@ import {
|
||||
CloseOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { agentLabel, agentLogo } from '../catalog.js'
|
||||
import TextMorph from './TextMorph.vue'
|
||||
import { retainedTraceEvents } from '../conversationTimelineModel.js'
|
||||
import { orchestrationPhase, orchestrationSlotCompleted } from '../desktop-normalization.js'
|
||||
import { locale, t } from '../i18n.js'
|
||||
@@ -445,12 +450,6 @@ const roundSelectTrigger = ref(null)
|
||||
const openSelector = ref('')
|
||||
const humanGateResponses = reactive({})
|
||||
const selectedItem = computed(() => props.items.find(item => item.agentRunId === props.selectedAgentRunId) || props.items.at(-1) || null)
|
||||
const waitingWords = computed(() => [
|
||||
t('humanGate.waitingWordOne'),
|
||||
t('humanGate.waitingWordTwo'),
|
||||
t('humanGate.waitingWordThree'),
|
||||
t('humanGate.waitingWordFour'),
|
||||
])
|
||||
const agentGroups = computed(() => {
|
||||
const groups = new Map()
|
||||
for (const item of props.items) {
|
||||
@@ -641,8 +640,9 @@ function handleDocumentPointerDown(event) {
|
||||
onMounted(() => document.addEventListener('pointerdown', handleDocumentPointerDown))
|
||||
onBeforeUnmount(() => document.removeEventListener('pointerdown', handleDocumentPointerDown))
|
||||
|
||||
// "Always allow" grants a lasting permission, so it is never the default-looking button.
|
||||
function optionApprovesHumanGate(option) {
|
||||
return ['allow_once', 'allow_always', 'accept', 'respond'].includes(option?.kind)
|
||||
return ['allow_once', 'accept', 'respond'].includes(option?.kind)
|
||||
}
|
||||
|
||||
function humanGateResponse(gateId) {
|
||||
@@ -880,6 +880,42 @@ defineExpose({ focus })
|
||||
.trace-context-disclosure {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.trace-context-disclosure > summary {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trace-context-disclosure > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.trace-context-disclosure > summary:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.trace-context-disclosure > summary svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
transition: transform var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.trace-context-disclosure[open] > summary svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.trace-context-disclosure code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
|
||||
.trace-context-record code {
|
||||
@@ -916,8 +952,8 @@ defineExpose({ focus })
|
||||
}
|
||||
|
||||
.trace-phase-current strong {
|
||||
color: var(--accent-hover);
|
||||
font-size: 11px;
|
||||
color: var(--accent-text);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.trace-phase-slot-list {
|
||||
@@ -936,7 +972,7 @@ defineExpose({ focus })
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--text-soft);
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.trace-phase-slot-list li > span {
|
||||
@@ -950,7 +986,7 @@ defineExpose({ focus })
|
||||
min-width: 0;
|
||||
flex: 1 1 142px;
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.35;
|
||||
text-align: right;
|
||||
white-space: normal;
|
||||
@@ -958,9 +994,12 @@ defineExpose({ focus })
|
||||
}
|
||||
|
||||
.trace-waiting-state {
|
||||
color: var(--accent-hover);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--warning);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -973,10 +1012,10 @@ defineExpose({ focus })
|
||||
.trace-human-gate-card {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-raised);
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--warning) 34%, var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--warning-soft) 50%, var(--surface-raised));
|
||||
}
|
||||
|
||||
.trace-human-gate-card header {
|
||||
@@ -997,21 +1036,21 @@ defineExpose({ focus })
|
||||
|
||||
.trace-human-gate-card header strong {
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trace-human-gate-card header small {
|
||||
color: var(--accent-hover);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--warning);
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.trace-human-gate-card p {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@
|
||||
<LibraryOutline />
|
||||
{{ t('systemSettings.knowledgeBases') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="systemSettingsSection === 'general'"
|
||||
:class="{ active: systemSettingsSection === 'general' }"
|
||||
@click="selectSystemSettingsSection('general')"
|
||||
>
|
||||
<OptionsOutline />
|
||||
{{ t('systemSettings.general') }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<AgentSettingsPanel
|
||||
@@ -48,6 +58,7 @@
|
||||
:agent-image-label="agentImageLabel"
|
||||
:agent-skill-label="agentSkillLabel"
|
||||
:agent-state="agentState"
|
||||
:agent-state-note="agentStateNote"
|
||||
:focused-agent-kind="focusedAgentKind"
|
||||
:install-confirm-kind="installConfirmKind"
|
||||
:installed-count="installedCount"
|
||||
@@ -85,14 +96,21 @@
|
||||
@primary-action="runKnowledgeBasePrimaryAction"
|
||||
@refresh="loadKnowledgeBaseStatuses"
|
||||
/>
|
||||
|
||||
<GeneralSettingsPanel
|
||||
v-else-if="systemSettingsSection === 'general'"
|
||||
:set-theme-preference="setThemePreference"
|
||||
:theme-preference="themePreference"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CloudOutline, LibraryOutline, TerminalOutline } from '@vicons/ionicons5'
|
||||
import { CloudOutline, LibraryOutline, OptionsOutline, TerminalOutline } from '@vicons/ionicons5'
|
||||
import AgentSettingsPanel from './AgentSettingsPanel.vue'
|
||||
import CloudAgentBridgePanel from './CloudAgentBridgePanel.vue'
|
||||
import GeneralSettingsPanel from './GeneralSettingsPanel.vue'
|
||||
import KnowledgeBaseSettingsPanel from './KnowledgeBaseSettingsPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -105,6 +123,7 @@ const {
|
||||
agentImageLabel,
|
||||
agentSkillLabel,
|
||||
agentState,
|
||||
agentStateNote,
|
||||
cancelInstall,
|
||||
cloudAgentBridges,
|
||||
focusedAgentKind,
|
||||
@@ -134,7 +153,9 @@ const {
|
||||
requestInstall,
|
||||
runKnowledgeBasePrimaryAction,
|
||||
selectSystemSettingsSection,
|
||||
setThemePreference,
|
||||
systemSettingsSection,
|
||||
t,
|
||||
themePreference,
|
||||
} = props.controller
|
||||
</script>
|
||||
@@ -1,85 +0,0 @@
|
||||
<template>
|
||||
<span class="text-morph" aria-live="polite" aria-atomic="true">
|
||||
<Transition name="text-morph-swap" mode="out-in">
|
||||
<span :key="currentWord" class="text-morph-word">{{ currentWord }}</span>
|
||||
</Transition>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
intervalMs: { type: Number, default: 2200 },
|
||||
words: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const index = ref(0)
|
||||
let timer = null
|
||||
|
||||
const normalizedWords = computed(() => (
|
||||
Array.isArray(props.words)
|
||||
? props.words.map(value => String(value || '').trim()).filter(Boolean)
|
||||
: []
|
||||
))
|
||||
|
||||
const currentWord = computed(() => {
|
||||
const words = normalizedWords.value
|
||||
if (!words.length) return ''
|
||||
return words[index.value % words.length]
|
||||
})
|
||||
|
||||
function clearTimer() {
|
||||
if (timer) clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
function syncTimer() {
|
||||
clearTimer()
|
||||
index.value = 0
|
||||
const words = normalizedWords.value
|
||||
if (typeof window === 'undefined' || words.length < 2) return
|
||||
if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
|
||||
timer = window.setInterval(() => {
|
||||
index.value = (index.value + 1) % words.length
|
||||
}, Math.max(800, props.intervalMs))
|
||||
}
|
||||
|
||||
watch([normalizedWords, () => props.intervalMs], syncTimer, { immediate: true })
|
||||
|
||||
onBeforeUnmount(clearTimer)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-morph {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
min-width: 7.5em;
|
||||
height: 1.5em;
|
||||
color: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-morph-word {
|
||||
grid-area: 1 / 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-morph-swap-enter-active,
|
||||
.text-morph-swap-leave-active {
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
|
||||
.text-morph-swap-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
.text-morph-swap-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-16px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,170 +0,0 @@
|
||||
<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>
|
||||
@@ -8,7 +8,11 @@
|
||||
<span class="preference-icon-frame"><LanguageOutline /></span>
|
||||
</button>
|
||||
<button class="icon-button" type="button" :title="theme === 'dark' ? t('common.themeLight') : t('common.themeDark')" :aria-label="theme === 'dark' ? t('common.themeLight') : t('common.themeDark')" @click="toggleTheme">
|
||||
<span class="preference-icon-frame"><SunnyOutline v-if="theme === 'dark'" /><MoonOutline v-else /></span>
|
||||
<span class="preference-icon-frame">
|
||||
<Transition name="preference-icon">
|
||||
<span :key="theme" class="preference-icon"><SunnyOutline v-if="theme === 'dark'" /><MoonOutline v-else /></span>
|
||||
</Transition>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -50,11 +50,21 @@
|
||||
@click="sidebarAgentsCollapsed = !sidebarAgentsCollapsed"
|
||||
>
|
||||
<span>{{ t('nav.sidebarAgents') }}</span>
|
||||
<small v-if="sidebarAgentsCollapsed" class="nav-heading-count">{{ sidebarAgentCount }}</small>
|
||||
</button>
|
||||
<div v-if="!sidebarAgentsCollapsed" id="sidebar-agent-list">
|
||||
<div v-if="discoveringAgents && !sidebarAgentGroups.length" class="sidebar-discovery" role="status">
|
||||
<span v-for="index in 3" :key="index" class="sidebar-discovery-row" aria-hidden="true"><i /><b /></span>
|
||||
<p>{{ t('onboarding.detecting') }}</p>
|
||||
</div>
|
||||
<div v-for="agentGroup in sidebarAgentGroups" :key="agentGroup.id" class="sidebar-agent-location-group">
|
||||
<p class="sidebar-agent-location-heading">{{ t(agentGroup.titleKey) }}</p>
|
||||
<article v-for="agent in agentGroup.agents" :key="agent.kind" class="sidebar-agent">
|
||||
<article
|
||||
v-for="agent in agentGroup.agents"
|
||||
:key="agent.kind"
|
||||
class="sidebar-agent"
|
||||
:class="{ unavailable: !agent.ready }"
|
||||
>
|
||||
<header class="sidebar-agent-header">
|
||||
<button
|
||||
class="sidebar-agent-main"
|
||||
@@ -68,10 +78,15 @@
|
||||
<span class="agent-logo-cloud" :class="{ cloud: agent.cloud }">
|
||||
<img :src="agent.logo" :alt="agent.label" />
|
||||
<CloudOutline v-if="agent.cloud" aria-hidden="true" />
|
||||
<span
|
||||
v-if="agentAwaitingDecision(agent.kind)"
|
||||
class="attention-dot agent-attention-dot"
|
||||
:title="t('humanGate.waiting')"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{{ agent.label }}</strong>
|
||||
<small>
|
||||
<small :data-tone="agent.ready ? undefined : agentState(agent).tone">
|
||||
{{ !agent.ready ? agentState(agent).label : directGroupsFor(agent.kind).length
|
||||
? t('nav.agentSessions', { count: directGroupsFor(agent.kind).length })
|
||||
: t('nav.noAgentSessions') }}
|
||||
@@ -110,7 +125,16 @@
|
||||
<span>{{ groupName(group) }}</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="isGroupRunning(group.id) && !isSelectedConversation(group)"
|
||||
v-if="isGroupAwaitingDecision(group.id) && !isSelectedConversation(group)"
|
||||
class="attention-mark"
|
||||
role="img"
|
||||
:title="t('humanGate.waiting')"
|
||||
:aria-label="t('humanGate.waiting')"
|
||||
>
|
||||
<span class="attention-dot" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isGroupRunning(group.id) && !isSelectedConversation(group)"
|
||||
class="run-mark"
|
||||
:title="t('conversation.runningGeneric')"
|
||||
>
|
||||
@@ -169,7 +193,7 @@
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!sidebarAgentGroups.length && !sidebarAgentsCollapsed" class="nav-empty">{{ t('nav.noSidebarAgents') }}</p>
|
||||
<p v-if="!sidebarAgentGroups.length && !sidebarAgentsCollapsed && !discoveringAgents" class="nav-empty">{{ t('nav.noSidebarAgents') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="nav-section group-nav-section">
|
||||
@@ -182,6 +206,7 @@
|
||||
@click="sidebarGroupsCollapsed = !sidebarGroupsCollapsed"
|
||||
>
|
||||
<span>{{ t('nav.groups') }}</span>
|
||||
<small v-if="sidebarGroupsCollapsed" class="nav-heading-count">{{ groupGroups.length }}</small>
|
||||
</button>
|
||||
<div v-if="!sidebarGroupsCollapsed" id="sidebar-group-list">
|
||||
<div v-if="groupGroups.length" class="group-conversation-list">
|
||||
@@ -203,12 +228,30 @@
|
||||
</span>
|
||||
<span>{{ groupName(group) }}</span>
|
||||
<span
|
||||
v-if="isGroupRunning(group.id) && !isSelectedConversation(group)"
|
||||
v-if="isGroupAwaitingDecision(group.id) && !isSelectedConversation(group)"
|
||||
class="attention-mark"
|
||||
role="img"
|
||||
:title="t('humanGate.waiting')"
|
||||
:aria-label="t('humanGate.waiting')"
|
||||
>
|
||||
<span class="attention-dot" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isGroupRunning(group.id) && !isSelectedConversation(group)"
|
||||
class="run-mark"
|
||||
:title="t('conversation.runningGeneric')"
|
||||
>
|
||||
<span class="run-agent-bars" aria-hidden="true"><i /><i /><i /></span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="hasFinishedDirectRun(group.id) && !isSelectedConversation(group)"
|
||||
class="run-finished-mark"
|
||||
role="img"
|
||||
:title="t('nav.runFinished')"
|
||||
:aria-label="t('nav.runFinished')"
|
||||
>
|
||||
<CheckmarkCircleOutline aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
<span v-if="!isGroupRunning(group.id) || isSelectedConversation(group)" class="group-conversation-actions">
|
||||
<button
|
||||
@@ -270,9 +313,11 @@
|
||||
@click="toggleCollapsedGroupMenu"
|
||||
>
|
||||
<ChatbubblesOutline />
|
||||
<span v-if="groupGroups.length" class="collapsed-group-count" aria-hidden="true">
|
||||
{{ groupGroups.length > 99 ? '99+' : groupGroups.length }}
|
||||
</span>
|
||||
<span
|
||||
v-if="groupGroups.some(group => isGroupAwaitingDecision(group.id))"
|
||||
class="attention-dot collapsed-group-attention"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -316,12 +361,30 @@
|
||||
</span>
|
||||
<span class="collapsed-group-option-state">
|
||||
<span
|
||||
v-if="isGroupRunning(group.id) && !isSelectedConversation(group)"
|
||||
v-if="isGroupAwaitingDecision(group.id) && !isSelectedConversation(group)"
|
||||
class="attention-mark"
|
||||
role="img"
|
||||
:title="t('humanGate.waiting')"
|
||||
:aria-label="t('humanGate.waiting')"
|
||||
>
|
||||
<span class="attention-dot" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isGroupRunning(group.id) && !isSelectedConversation(group)"
|
||||
class="run-mark"
|
||||
:title="t('conversation.runningGeneric')"
|
||||
>
|
||||
<span class="run-agent-bars" aria-hidden="true"><i /><i /><i /></span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="hasFinishedDirectRun(group.id) && !isSelectedConversation(group)"
|
||||
class="run-finished-mark"
|
||||
role="img"
|
||||
:title="t('nav.runFinished')"
|
||||
:aria-label="t('nav.runFinished')"
|
||||
>
|
||||
<CheckmarkCircleOutline aria-hidden="true" />
|
||||
</span>
|
||||
<CheckmarkCircleOutline v-else-if="isSelectedConversation(group)" aria-hidden="true" />
|
||||
<time v-else>{{ formatNavTime(group.updatedAt || group.createdAt) }}</time>
|
||||
</span>
|
||||
@@ -355,7 +418,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { unref } from 'vue'
|
||||
import { computed, unref } from 'vue'
|
||||
import {
|
||||
AddOutline,
|
||||
ChatbubblesOutline,
|
||||
@@ -384,6 +447,7 @@ const {
|
||||
createDirectSession,
|
||||
deleteSidebarConversation,
|
||||
directGroupsFor,
|
||||
discoveringAgents,
|
||||
dismissSidebarDeleteConfirmation,
|
||||
formatNavTime,
|
||||
goHome,
|
||||
@@ -397,6 +461,7 @@ const {
|
||||
hasMoreGroupGroups,
|
||||
isDirectCreationPending,
|
||||
isDirectSessionListExpanded,
|
||||
isGroupAwaitingDecision,
|
||||
isGroupRunning,
|
||||
isSidebarAgentExpanded,
|
||||
openConversationRename,
|
||||
@@ -427,4 +492,12 @@ const {
|
||||
function isSelectedConversation(group) {
|
||||
return unref(activeView) === 'conversation' && unref(selectedGroupId) === group.id
|
||||
}
|
||||
|
||||
const sidebarAgentCount = computed(() => (
|
||||
unref(sidebarAgentGroups).reduce((total, group) => total + group.agents.length, 0)
|
||||
))
|
||||
|
||||
function agentAwaitingDecision(kind) {
|
||||
return directGroupsFor(kind).some(group => isGroupAwaitingDecision(group.id))
|
||||
}
|
||||
</script>
|
||||
@@ -156,6 +156,33 @@ export function useAgentCatalog({
|
||||
return { label: t('agent.unverified'), tone: 'neutral', icon: WarningOutline }
|
||||
}
|
||||
|
||||
// One plain sentence on why an installed Agent cannot be used and what to do
|
||||
// next. Empty when there is nothing to explain (ready, or not installed).
|
||||
function agentStateNote(agent) {
|
||||
if (!agent || agent.ready || agent.custom || !agent.installed) return ''
|
||||
const version = agent.resolvedVersion || agent.version || ''
|
||||
const params = { version: version || t('agent.versionUnknown') }
|
||||
const verified = agent.supportedVersionRange
|
||||
? ` ${t('agent.note.verifiedRange', { range: agent.supportedVersionRange })}`
|
||||
: ''
|
||||
if (agent.incompatibilityReason === 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT') {
|
||||
return t('agent.note.probeTimeout')
|
||||
}
|
||||
if (agent.compatibilityState === 'incompatible') {
|
||||
const key = ({
|
||||
LOCAL_AGENT_VERSION_UNSUPPORTED: 'agent.note.versionUnsupported',
|
||||
LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING: 'agent.note.cannotStart',
|
||||
LOCAL_AGENT_PROTOCOL_UNAVAILABLE: 'agent.note.protocolUnavailable',
|
||||
})[agent.incompatibilityReason] || 'agent.note.versionUnsupported'
|
||||
return `${t(key, params)}${verified}`
|
||||
}
|
||||
if (agent.credentialState === 'missing') {
|
||||
// A cloud Agent runs on the connected server, so that is where it signs in.
|
||||
return t(agent.cloud ? 'agent.note.needsLoginCloud' : 'agent.note.needsLogin')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
return {
|
||||
activeDirectAgent,
|
||||
agentCatalogGroups,
|
||||
@@ -163,6 +190,7 @@ export function useAgentCatalog({
|
||||
agentImageLabel,
|
||||
agentSoul,
|
||||
agentState,
|
||||
agentStateNote,
|
||||
installedCount,
|
||||
mergedCatalog,
|
||||
readyAgentKinds,
|
||||
|
||||
@@ -2,16 +2,42 @@ import { onBeforeUnmount, ref } from 'vue'
|
||||
import { errorCode } from '../desktop.js'
|
||||
import { t, translateError } from '../i18n.js'
|
||||
|
||||
// Long messages stay up long enough to read: 4 s plus about 60 ms per
|
||||
// character, at most 10 s. Hovering the toast pauses the countdown.
|
||||
const TOAST_MIN_MS = 4000
|
||||
const TOAST_MAX_MS = 10000
|
||||
const TOAST_MS_PER_CHARACTER = 60
|
||||
|
||||
function toastDuration(message) {
|
||||
const length = String(message || '').length
|
||||
return Math.min(TOAST_MAX_MS, TOAST_MIN_MS + length * TOAST_MS_PER_CHARACTER)
|
||||
}
|
||||
|
||||
export function useAppNotifications() {
|
||||
const toastMessage = ref('')
|
||||
const toastTone = ref('warning')
|
||||
const copyToastMessage = ref('')
|
||||
let toastTimer = null
|
||||
let copyToastTimer = null
|
||||
|
||||
function notify(message) {
|
||||
toastMessage.value = message
|
||||
function scheduleToastDismiss() {
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => { toastMessage.value = '' }, 3600)
|
||||
toastTimer = setTimeout(() => { toastMessage.value = '' }, toastDuration(toastMessage.value))
|
||||
}
|
||||
|
||||
function notify(message, { tone = 'warning' } = {}) {
|
||||
toastMessage.value = message
|
||||
toastTone.value = tone === 'error' ? 'error' : 'warning'
|
||||
scheduleToastDismiss()
|
||||
}
|
||||
|
||||
function pauseToast() {
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = null
|
||||
}
|
||||
|
||||
function resumeToast() {
|
||||
if (toastMessage.value) scheduleToastDismiss()
|
||||
}
|
||||
|
||||
function showCopyToast() {
|
||||
@@ -27,7 +53,7 @@ export function useAppNotifications() {
|
||||
|
||||
function showError(error) {
|
||||
console.error('[Meldwork]', errorCode(error))
|
||||
notify(translateError(error))
|
||||
notify(translateError(error), { tone: 'error' })
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -39,8 +65,11 @@ export function useAppNotifications() {
|
||||
copyToastMessage,
|
||||
dismissToast,
|
||||
notify,
|
||||
pauseToast,
|
||||
resumeToast,
|
||||
showCopyToast,
|
||||
showError,
|
||||
toastMessage,
|
||||
toastTone,
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { publicAsset } from '../catalog.js'
|
||||
import { locale, setLocale } from '../i18n.js'
|
||||
import { readProductPreference, writeProductPreference } from '../product-preferences.js'
|
||||
|
||||
function initialTheme() {
|
||||
const THEME_PREFERENCES = new Set(['system', 'light', 'dark'])
|
||||
// Matches --bg in base-foundation.css for each theme.
|
||||
const THEME_COLORS = { light: '#f3f6f8', dark: '#111618' }
|
||||
|
||||
function initialThemePreference() {
|
||||
try {
|
||||
const saved = readProductPreference('theme')
|
||||
if (saved === 'light' || saved === 'dark') return saved
|
||||
if (THEME_PREFERENCES.has(saved)) return saved
|
||||
} catch { /* noop */ }
|
||||
return 'system'
|
||||
}
|
||||
|
||||
function systemTheme() {
|
||||
return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function useAppPreferences() {
|
||||
const theme = ref(initialTheme())
|
||||
// 'system' follows the OS appearance; 'light' and 'dark' are explicit.
|
||||
// Only an explicit choice by the user is written back, so a first launch
|
||||
// keeps following the system instead of freezing whatever it was then.
|
||||
const themePreference = ref(initialThemePreference())
|
||||
const theme = ref(themePreference.value === 'system' ? systemTheme() : themePreference.value)
|
||||
let colorSchemeQuery = null
|
||||
const productMark = computed(() => publicAsset(
|
||||
theme.value === 'dark' ? 'logos/meldwork-mark-v3-dark.svg' : 'logos/meldwork-mark-v3.svg',
|
||||
))
|
||||
@@ -24,12 +37,32 @@ export function useAppPreferences() {
|
||||
function applyTheme(value) {
|
||||
document.documentElement.dataset.theme = value
|
||||
document.documentElement.style.colorScheme = value
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', value === 'dark' ? '#0e171d' : '#f3f6f8')
|
||||
try { writeProductPreference('theme', value) } catch { /* noop */ }
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', THEME_COLORS[value] || THEME_COLORS.light)
|
||||
}
|
||||
|
||||
function syncSystemTheme() {
|
||||
if (themePreference.value === 'system') theme.value = systemTheme()
|
||||
}
|
||||
|
||||
function followSystemTheme(enabled) {
|
||||
if (enabled && !colorSchemeQuery && typeof matchMedia === 'function') {
|
||||
colorSchemeQuery = matchMedia('(prefers-color-scheme: dark)')
|
||||
colorSchemeQuery.addEventListener?.('change', syncSystemTheme)
|
||||
} else if (!enabled && colorSchemeQuery) {
|
||||
colorSchemeQuery.removeEventListener?.('change', syncSystemTheme)
|
||||
colorSchemeQuery = null
|
||||
}
|
||||
}
|
||||
|
||||
function setThemePreference(value) {
|
||||
const next = THEME_PREFERENCES.has(value) ? value : 'system'
|
||||
themePreference.value = next
|
||||
try { writeProductPreference('theme', next) } catch { /* noop */ }
|
||||
theme.value = next === 'system' ? systemTheme() : next
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
setThemePreference(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
function toggleLocale() {
|
||||
@@ -37,13 +70,17 @@ export function useAppPreferences() {
|
||||
}
|
||||
|
||||
watch(theme, applyTheme)
|
||||
watch(themePreference, value => followSystemTheme(value === 'system'), { immediate: true })
|
||||
onBeforeUnmount(() => followSystemTheme(false))
|
||||
|
||||
return {
|
||||
applyTheme,
|
||||
productAppIcon,
|
||||
productMark,
|
||||
productWordmark,
|
||||
setThemePreference,
|
||||
theme,
|
||||
themePreference,
|
||||
toggleLocale,
|
||||
toggleTheme,
|
||||
}
|
||||
|
||||
@@ -248,6 +248,29 @@ export function useComposerContext({
|
||||
automaticTeamFormation.value = context?.automaticTeamFormation === true
|
||||
}
|
||||
|
||||
// Unsent text is kept per conversation while the app is open, so a quick look
|
||||
// at another conversation (or saving this one's settings) does not cost what
|
||||
// was typed. Attachments and mentions are still reset: they are tied to the
|
||||
// conversation's members and to files staged for it.
|
||||
const draftsByGroupId = new Map()
|
||||
|
||||
function stashComposerDraft(groupId) {
|
||||
const id = String(groupId || '')
|
||||
if (!id) return
|
||||
if (draft.value.trim()) draftsByGroupId.set(id, draft.value)
|
||||
else draftsByGroupId.delete(id)
|
||||
}
|
||||
|
||||
function restoreComposerDraft(groupId) {
|
||||
const text = draftsByGroupId.get(String(groupId || ''))
|
||||
if (text) draft.value = text
|
||||
}
|
||||
|
||||
function pruneComposerDrafts(groupIds) {
|
||||
const live = new Set((Array.isArray(groupIds) ? groupIds : []).map(String))
|
||||
for (const id of draftsByGroupId.keys()) if (!live.has(id)) draftsByGroupId.delete(id)
|
||||
}
|
||||
|
||||
function resetComposerContext(group) {
|
||||
discussionMode.value = group?.conversationType === 'direct' ? 'manual' : 'auto'
|
||||
automaticTeamFormation.value = false
|
||||
@@ -308,11 +331,13 @@ export function useComposerContext({
|
||||
isComposerTargetSelected,
|
||||
manualModeLabel,
|
||||
openSkillMenu,
|
||||
pruneComposerDrafts,
|
||||
removeAgentMention,
|
||||
removeKnowledgeBase,
|
||||
removeSkill,
|
||||
resetComposerContext,
|
||||
restoreComposerContext,
|
||||
restoreComposerDraft,
|
||||
scheduleComposerResize,
|
||||
selectComposerMenuOption,
|
||||
selectedAgentKinds,
|
||||
@@ -323,6 +348,7 @@ export function useComposerContext({
|
||||
skillActiveIndex,
|
||||
skillMenuOpen,
|
||||
skillsLoading,
|
||||
stashComposerDraft,
|
||||
targetKinds,
|
||||
toggleTarget,
|
||||
toggleAutomaticTeamFormation,
|
||||
|
||||
@@ -86,7 +86,6 @@ export function useRunFinishedNotifications({
|
||||
pendingEvents.delete(key)
|
||||
}
|
||||
clearFinishedRun(normalizedEvent)
|
||||
if (group.conversationType !== 'direct') return
|
||||
if (normalizedEvent.status !== 'completed') {
|
||||
if (hasFinishedDirectRun(groupId)) setFinishedDirectRun(groupId, false)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
function normalizedSection(section) {
|
||||
if (section === 'cloud-agents') return 'cloud-agents'
|
||||
if (section === 'knowledge-bases') return 'knowledge-bases'
|
||||
if (section === 'general') return 'general'
|
||||
return 'agents'
|
||||
}
|
||||
|
||||
|
||||
+32
-19
@@ -3,10 +3,6 @@ export default {
|
||||
'app.desktopRequired': 'Open Meldwork in the desktop app.',
|
||||
'app.desktopRequiredDetail': 'The local Agent bridge is unavailable in this window.',
|
||||
'app.loading': 'Reading local Agents',
|
||||
'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',
|
||||
'onboarding.discoverTitle': 'Bring your local Agents together',
|
||||
'onboarding.discoverBody': 'Meldwork finds the supported Agent CLIs already available on this computer.',
|
||||
'onboarding.collaborationTitle': 'Share one task across Agents',
|
||||
@@ -22,7 +18,8 @@ export default {
|
||||
'onboarding.detecting': 'Detecting local Agents',
|
||||
'onboarding.loading': 'Preparing workspace',
|
||||
'onboarding.continue': 'Next',
|
||||
'onboarding.start': 'Start using',
|
||||
'onboarding.start': 'Get started',
|
||||
'onboarding.skip': 'Skip',
|
||||
'nav.newGroup': 'New group',
|
||||
'nav.collapseSidebar': 'Collapse sidebar',
|
||||
'nav.expandSidebar': 'Expand sidebar',
|
||||
@@ -74,6 +71,9 @@ export default {
|
||||
'home.recentTitle': 'Continue working',
|
||||
'home.recentSubtitle': 'Open a recent conversation and continue from its local context.',
|
||||
'home.recentEmpty': 'Create a group or direct chat to start building your local history.',
|
||||
'home.attentionTitle': 'Needs your attention',
|
||||
'home.attentionItem': '{agent} is waiting for you: {type}',
|
||||
'home.attentionOpen': 'Review',
|
||||
'home.firstTaskTitle': 'Start your first task',
|
||||
'home.firstTaskSubtitle': 'There is no local conversation history yet.',
|
||||
'home.agentStatusTitle': 'Start a new task',
|
||||
@@ -99,6 +99,14 @@ export default {
|
||||
'agent.incompatibleVersion': 'Unsupported version',
|
||||
'agent.incompatibleCapability': 'Required capability missing',
|
||||
'agent.incompatibleProtocol': 'Required protocol unavailable',
|
||||
'agent.versionUnknown': 'unknown',
|
||||
'agent.note.versionUnsupported': 'The installed version ({version}) is outside the range Meldwork has verified, so it is unavailable for now.',
|
||||
'agent.note.cannotStart': 'The installed version ({version}) did not pass the startup check: its command line cannot run, or it lacks a capability Meldwork needs.',
|
||||
'agent.note.protocolUnavailable': 'The installed version ({version}) does not support a protocol Meldwork needs.',
|
||||
'agent.note.verifiedRange': 'Verified versions: {range}.',
|
||||
'agent.note.probeTimeout': 'Detection timed out. Make sure it starts normally in a terminal, then detect again.',
|
||||
'agent.note.needsLogin': 'Run it in a terminal and finish signing in, then detect again.',
|
||||
'agent.note.needsLoginCloud': 'This is a cloud Agent: sign in to it on its server, then detect again.',
|
||||
'agent.detectedVersion': 'Version {version}',
|
||||
'agent.localSkills': '{count} local skills',
|
||||
'agent.cloudSkills': '{count} remote skills',
|
||||
@@ -218,9 +226,9 @@ export default {
|
||||
'run.status.interrupted': 'Interrupted',
|
||||
'run.status.notStarted': 'Not started',
|
||||
'run.status.roundLimit': 'Round limit reached',
|
||||
'run.status.budgetExhausted': 'Hard budget reached',
|
||||
'run.status.circuitBreaker': 'Circuit breaker stopped',
|
||||
'run.status.unknown': 'Updated',
|
||||
'run.status.budgetExhausted': 'Usage limit reached',
|
||||
'run.status.circuitBreaker': 'Stopped after repeated failures',
|
||||
'run.status.unknown': 'Unknown',
|
||||
'run.step.reasoning': 'Analyze task',
|
||||
'run.step.process': 'Run process',
|
||||
'run.step.writeFile': 'Write file',
|
||||
@@ -255,7 +263,7 @@ export default {
|
||||
'trace.detailUnavailable': 'This earlier run retained tool names and statuses, but not detailed inputs or results.',
|
||||
'trace.eventDetailUnavailable': 'Detailed input and result data was not captured for this event.',
|
||||
'trace.truncated': 'Some execution details were compacted to keep this conversation stable.',
|
||||
'trace.attemptInjection': 'Attempt injection',
|
||||
'trace.attemptInjection': 'Context and diagnostics',
|
||||
'trace.contextIncluded': '{count} messages injected for this attempt',
|
||||
'trace.contextOmitted': '{count} messages compacted',
|
||||
'trace.contextChars': '{count} context characters',
|
||||
@@ -292,10 +300,6 @@ export default {
|
||||
'trace.sessionUnknownLegacyWarning': 'Legacy native Session history is unknown. The injected message IDs for this attempt are not the complete context.',
|
||||
'humanGate.pendingTitle': 'Action required',
|
||||
'humanGate.waiting': 'Waiting for your decision',
|
||||
'humanGate.waitingWordOne': 'Waiting',
|
||||
'humanGate.waitingWordTwo': 'Thinking',
|
||||
'humanGate.waitingWordThree': 'Preparing',
|
||||
'humanGate.waitingWordFour': 'Reviewing',
|
||||
'humanGate.type.permission': 'Permission',
|
||||
'humanGate.type.budget': 'Budget',
|
||||
'humanGate.type.decision': 'Review decision',
|
||||
@@ -411,9 +415,9 @@ export default {
|
||||
'composer.autoRounds': '{count} rounds',
|
||||
'composer.maxRounds': 'Maximum rounds',
|
||||
'composer.unlimitedRounds': 'No round limit',
|
||||
'composer.unlimitedHint': 'The discussion continues until collaboration ends or you stop it. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. Agent timeouts and the mandatory circuit breaker still apply.',
|
||||
'composer.unlimitedHint': 'The discussion continues until collaboration ends or you stop it. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. A single Agent timing out, or too many failures in a row, still stops it automatically.',
|
||||
'composer.unlimitedConfirmTitle': 'Continue without a round limit?',
|
||||
'composer.unlimitedConfirm': 'The discussion can continue until collaboration ends or you stop it and may consume more resources. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. Agent timeouts and the mandatory circuit breaker still apply. Continue?',
|
||||
'composer.unlimitedConfirm': 'The discussion can continue until collaboration ends or you stop it and may use more resources. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. A single Agent timing out, or too many failures in a row, still stops it automatically.',
|
||||
'composer.useBoundedRounds': 'Use bounded rounds',
|
||||
'composer.addTarget': 'Include {agent}',
|
||||
'composer.removeTarget': 'Exclude {agent}',
|
||||
@@ -489,6 +493,14 @@ export default {
|
||||
'systemSettings.agents': 'Agents',
|
||||
'systemSettings.cloudServers': 'Cloud servers',
|
||||
'systemSettings.knowledgeBases': 'Knowledge bases',
|
||||
'systemSettings.general': 'General',
|
||||
'generalSettings.languageTitle': 'Language',
|
||||
'generalSettings.languageHint': 'The language used across the interface.',
|
||||
'generalSettings.themeTitle': 'Appearance',
|
||||
'generalSettings.themeHint': 'System follows your operating system’s light or dark appearance.',
|
||||
'generalSettings.themeSystem': 'System',
|
||||
'generalSettings.themeLight': 'Light',
|
||||
'generalSettings.themeDark': 'Dark',
|
||||
'systemSettings.openAgentDetail': 'View {agent} details',
|
||||
'systemSettings.openAgentDetailDefault': 'Agent details',
|
||||
'systemSettings.officialAgents': 'Official Agents',
|
||||
@@ -531,6 +543,7 @@ export default {
|
||||
'cloudAgents.connect': 'Connect and discover',
|
||||
'cloudAgents.refresh': 'Refresh connections',
|
||||
'cloudAgents.delete': 'Delete connection',
|
||||
'cloudAgents.deletePrompt': 'Delete this connection?',
|
||||
'cloudAgents.online': 'Online',
|
||||
'cloudAgents.offline': 'Unavailable',
|
||||
'cloudAgents.agentCount': '{count} Agent CLIs',
|
||||
@@ -642,7 +655,7 @@ export default {
|
||||
'agentDetail.location': 'Location',
|
||||
'agentDetail.cloudLocation': '{server} · Version {version}',
|
||||
'agentDetail.images': 'Images',
|
||||
'agentDetail.soul': 'Soul',
|
||||
'agentDetail.soul': 'About',
|
||||
'agentDetail.localSkills': 'Local skills',
|
||||
'agentDetail.remoteSkills': 'Remote skills',
|
||||
'agentDetail.showInSidebar': 'Show in sidebar',
|
||||
@@ -684,13 +697,13 @@ export default {
|
||||
'common.more': 'More options',
|
||||
'common.dismiss': 'Dismiss',
|
||||
'system.agentCallFailed': '{agent} failed: {reason}',
|
||||
'system.agentBudgetExhausted': '{agent} stopped because a hard run budget was exceeded.',
|
||||
'system.agentBudgetExhausted': '{agent} stopped after reaching this run’s usage limit.',
|
||||
'system.agentStopped': '{agent} was stopped by the user.',
|
||||
'system.agentInterrupted': '{agent} was interrupted when Meldwork restarted.',
|
||||
'system.autoStopped': 'Automatic discussion stopped: {reason}',
|
||||
'system.autoDiscussionStalled': 'Automatic discussion stopped because repeated handoffs produced no new contribution.',
|
||||
'system.autoRoundLimit': 'Automatic discussion reached its {rounds}-round limit. The latest replies above are the current result; raise the round limit or send a follow-up to continue.',
|
||||
'system.autoCrossReview': 'The delivery owner signaled completion before peer discussion; one cross-review round runs first.',
|
||||
'system.autoCrossReview': 'The Agent leading delivery thinks the task is done, so the other Agents cross-check it once first.',
|
||||
'system.autoOwnerChanged': '{agent} took over as delivery owner because {previous} is no longer available.',
|
||||
'system.autoRouteIgnored': 'Requests for {agents} were skipped because they are not active in this discussion.',
|
||||
'system.autoPeerBlockerForwarded': '{agent} reported a blocker; the delivery owner will decide how to proceed. Reason: {reason}',
|
||||
@@ -703,7 +716,7 @@ export default {
|
||||
'system.agentPausedAfterFailure': '{agent} is paused for the rest of this discussion: {reason} It stays in the group and will be tried again on the next message.',
|
||||
'system.agentRemovedAfterFailure': '{agent} was removed from the group after a terminal failure. The discussion continued with the remaining Agents.',
|
||||
'system.autoTimeout': 'Automatic discussion stopped after a runtime timeout before consensus.',
|
||||
'system.runCircuitBreaker': 'The mandatory circuit breaker stopped this run after {maxAttempts} Agent attempts.',
|
||||
'system.runCircuitBreaker': 'This run stopped automatically after {maxAttempts} failed Agent attempts.',
|
||||
'error.generic': 'The operation could not be completed.',
|
||||
'error.cloudAgentBridgeAddress': 'Enter an HTTPS IP address for the server.',
|
||||
'error.cloudAgentBridgeManifest': 'The server did not return a recognized Meldwork Agent Bridge manifest.',
|
||||
|
||||
+32
-19
@@ -1,12 +1,8 @@
|
||||
export default {
|
||||
'app.localWorkspace': '多智能体协作工作台',
|
||||
'app.localWorkspace': '多 Agent 协作工作台',
|
||||
'app.desktopRequired': '请在 Meldwork 桌面端打开。',
|
||||
'app.desktopRequiredDetail': '当前窗口无法访问本地 Agent 桥接。',
|
||||
'app.loading': '正在读取本地 Agent',
|
||||
'agentDiscovery.kicker': '本地工作台启动中',
|
||||
'agentDiscovery.title': '正在寻找本地 Agent',
|
||||
'agentDiscovery.body': 'Meldwork 正在检查这台电脑上可用的 Agent CLI。',
|
||||
'agentDiscovery.status': '正在准备工作台',
|
||||
'onboarding.discoverTitle': '把本地 Agent 汇聚到一起',
|
||||
'onboarding.discoverBody': 'Meldwork 会检测这台电脑上已经可用的受支持 Agent CLI。',
|
||||
'onboarding.collaborationTitle': '让多个 Agent 共享同一任务',
|
||||
@@ -23,6 +19,7 @@ export default {
|
||||
'onboarding.loading': '正在准备工作台',
|
||||
'onboarding.continue': '下一步',
|
||||
'onboarding.start': '开始使用',
|
||||
'onboarding.skip': '跳过',
|
||||
'nav.newGroup': '新建群聊',
|
||||
'nav.collapseSidebar': '收起侧边栏',
|
||||
'nav.expandSidebar': '展开侧边栏',
|
||||
@@ -74,6 +71,9 @@ export default {
|
||||
'home.recentTitle': '继续工作',
|
||||
'home.recentSubtitle': '打开最近会话,并沿用已有的本地上下文。',
|
||||
'home.recentEmpty': '创建群聊或单聊后,最近会话会显示在这里。',
|
||||
'home.attentionTitle': '需要你处理',
|
||||
'home.attentionItem': '{agent} 在等你确认:{type}',
|
||||
'home.attentionOpen': '去处理',
|
||||
'home.firstTaskTitle': '开始第一个任务',
|
||||
'home.firstTaskSubtitle': '当前还没有本地会话记录。',
|
||||
'home.agentStatusTitle': '开始新任务',
|
||||
@@ -99,6 +99,14 @@ export default {
|
||||
'agent.incompatibleVersion': '版本不受支持',
|
||||
'agent.incompatibleCapability': '缺少必需能力',
|
||||
'agent.incompatibleProtocol': '必需协议不可用',
|
||||
'agent.versionUnknown': '未知版本',
|
||||
'agent.note.versionUnsupported': '已安装的版本 {version} 不在 Meldwork 已验证的范围内,暂时无法使用。',
|
||||
'agent.note.cannotStart': '已安装的版本 {version} 没能通过启动检查:它的命令行无法运行,或缺少 Meldwork 需要的能力。',
|
||||
'agent.note.protocolUnavailable': '已安装的版本 {version} 不支持 Meldwork 需要的通信协议。',
|
||||
'agent.note.verifiedRange': '已验证的版本:{range}。',
|
||||
'agent.note.probeTimeout': '检测超时。请确认它能在终端里正常启动,然后重新检测。',
|
||||
'agent.note.needsLogin': '请先在终端运行它并完成登录,然后重新检测。',
|
||||
'agent.note.needsLoginCloud': '这是云端 Agent:请到对应的服务器上完成登录,然后重新检测。',
|
||||
'agent.detectedVersion': '版本 {version}',
|
||||
'agent.localSkills': '{count} 个本地技能',
|
||||
'agent.cloudSkills': '{count} 个远端技能',
|
||||
@@ -218,9 +226,9 @@ export default {
|
||||
'run.status.interrupted': '已中断',
|
||||
'run.status.notStarted': '未开始',
|
||||
'run.status.roundLimit': '达到轮数上限',
|
||||
'run.status.budgetExhausted': '达到硬预算上限',
|
||||
'run.status.circuitBreaker': '已触发熔断器',
|
||||
'run.status.unknown': '已更新',
|
||||
'run.status.budgetExhausted': '已达到用量上限',
|
||||
'run.status.circuitBreaker': '失败过多,已自动停止',
|
||||
'run.status.unknown': '状态未知',
|
||||
'run.step.reasoning': '分析任务',
|
||||
'run.step.process': '运行进程',
|
||||
'run.step.writeFile': '写入文件',
|
||||
@@ -255,7 +263,7 @@ export default {
|
||||
'trace.detailUnavailable': '这条早期运行仅保留了工具名称和状态,未记录具体输入与结果。',
|
||||
'trace.eventDetailUnavailable': '该事件未记录具体输入与结果。',
|
||||
'trace.truncated': '为保持会话稳定,部分执行详情已被压缩。',
|
||||
'trace.attemptInjection': '本次尝试注入',
|
||||
'trace.attemptInjection': '上下文与诊断',
|
||||
'trace.contextIncluded': '本次尝试注入 {count} 条消息',
|
||||
'trace.contextOmitted': '压缩 {count} 条消息',
|
||||
'trace.contextChars': '上下文 {count} 字符',
|
||||
@@ -292,10 +300,6 @@ export default {
|
||||
'trace.sessionUnknownLegacyWarning': '旧版原生 Session 的历史来源未知。本次尝试注入的消息 ID 并不代表完整上下文。',
|
||||
'humanGate.pendingTitle': '需要你的决定',
|
||||
'humanGate.waiting': '等待你的决定',
|
||||
'humanGate.waitingWordOne': '等待中',
|
||||
'humanGate.waitingWordTwo': '思考中',
|
||||
'humanGate.waitingWordThree': '准备中',
|
||||
'humanGate.waitingWordFour': '整理中',
|
||||
'humanGate.type.permission': '权限',
|
||||
'humanGate.type.budget': '预算',
|
||||
'humanGate.type.decision': '评审决定',
|
||||
@@ -411,9 +415,9 @@ export default {
|
||||
'composer.autoRounds': '{count} 轮',
|
||||
'composer.maxRounds': '最大轮数',
|
||||
'composer.unlimitedRounds': '不限轮数',
|
||||
'composer.unlimitedHint': '讨论会持续到协作自然结束或你手动停止。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口;Agent 超时与不可关闭的熔断器仍然生效。',
|
||||
'composer.unlimitedHint': '讨论会持续到协作自然结束或你手动停止。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口。单个 Agent 超时,或连续失败过多时,仍会自动停止。',
|
||||
'composer.unlimitedConfirmTitle': '继续使用不限制轮数吗?',
|
||||
'composer.unlimitedConfirm': '讨论会持续到协作自然结束或你手动停止,并可能消耗更多资源。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口;Agent 超时与不可关闭的熔断器仍然生效。是否继续?',
|
||||
'composer.unlimitedConfirm': '讨论会持续到协作自然结束或你手动停止,并可能消耗更多资源。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口。单个 Agent 超时,或连续失败过多时,仍会自动停止。',
|
||||
'composer.useBoundedRounds': '改回固定轮数',
|
||||
'composer.addTarget': '加入 {agent}',
|
||||
'composer.removeTarget': '移除 {agent}',
|
||||
@@ -489,6 +493,14 @@ export default {
|
||||
'systemSettings.agents': 'Agent',
|
||||
'systemSettings.cloudServers': '云服务器',
|
||||
'systemSettings.knowledgeBases': '知识库',
|
||||
'systemSettings.general': '通用',
|
||||
'generalSettings.languageTitle': '语言',
|
||||
'generalSettings.languageHint': '界面文字使用的语言。',
|
||||
'generalSettings.themeTitle': '外观',
|
||||
'generalSettings.themeHint': '选择“跟随系统”时,会随系统的浅色与深色外观自动切换。',
|
||||
'generalSettings.themeSystem': '跟随系统',
|
||||
'generalSettings.themeLight': '浅色',
|
||||
'generalSettings.themeDark': '深色',
|
||||
'systemSettings.openAgentDetail': '查看 {agent} 详情',
|
||||
'systemSettings.openAgentDetailDefault': 'Agent 详情',
|
||||
'systemSettings.officialAgents': '官方 Agent',
|
||||
@@ -531,6 +543,7 @@ export default {
|
||||
'cloudAgents.connect': '连接并探测',
|
||||
'cloudAgents.refresh': '刷新连接',
|
||||
'cloudAgents.delete': '删除连接',
|
||||
'cloudAgents.deletePrompt': '删除这个连接?',
|
||||
'cloudAgents.online': '在线',
|
||||
'cloudAgents.offline': '无法连接',
|
||||
'cloudAgents.agentCount': '{count} 个 Agent CLI',
|
||||
@@ -642,7 +655,7 @@ export default {
|
||||
'agentDetail.location': '位置',
|
||||
'agentDetail.cloudLocation': '{server} · 版本 {version}',
|
||||
'agentDetail.images': '图片',
|
||||
'agentDetail.soul': '角色',
|
||||
'agentDetail.soul': '简介',
|
||||
'agentDetail.localSkills': '本地技能',
|
||||
'agentDetail.remoteSkills': '远端技能',
|
||||
'agentDetail.showInSidebar': '在侧边栏显示',
|
||||
@@ -684,13 +697,13 @@ export default {
|
||||
'common.more': '更多操作',
|
||||
'common.dismiss': '关闭',
|
||||
'system.agentCallFailed': '{agent} 调用失败:{reason}',
|
||||
'system.agentBudgetExhausted': '{agent} 因达到运行硬预算上限而停止。',
|
||||
'system.agentBudgetExhausted': '{agent} 已达到本次运行的用量上限,已停止。',
|
||||
'system.agentStopped': '{agent} 已由用户停止。',
|
||||
'system.agentInterrupted': 'Meldwork 重启时,{agent} 的运行已中断。',
|
||||
'system.autoStopped': '自动讨论已停止:{reason}',
|
||||
'system.autoDiscussionStalled': '自动讨论因重复转交且没有新增内容而停止。',
|
||||
'system.autoRoundLimit': '自动讨论已达到 {rounds} 轮上限,以上最新回复即为当前结果;如需继续,可调高轮数上限或追加消息。',
|
||||
'system.autoCrossReview': '交付负责人在同伴讨论前判断任务完成;将先进行一轮交叉审查。',
|
||||
'system.autoCrossReview': '负责交付的 Agent 认为任务已完成,先请其他 Agent 交叉检查一轮。',
|
||||
'system.autoOwnerChanged': '{previous} 已不可用,由 {agent} 接任交付负责人。',
|
||||
'system.autoRouteIgnored': '已跳过对 {agents} 的请求,因为它们不在本次讨论的活跃成员中。',
|
||||
'system.autoPeerBlockerForwarded': '{agent} 报告了阻塞,将由交付负责人决定如何继续。原因:{reason}',
|
||||
@@ -703,7 +716,7 @@ export default {
|
||||
'system.agentPausedAfterFailure': '{agent} 在本次讨论中暂停:{reason} 它仍在群聊中,下一条消息会再次尝试。',
|
||||
'system.agentRemovedAfterFailure': '{agent} 发生终止性故障,已自动移出群聊;其余 Agent 将继续讨论。',
|
||||
'system.autoTimeout': '自动讨论在达成共识前因运行超时而停止。',
|
||||
'system.runCircuitBreaker': '不可关闭的熔断器已在 {maxAttempts} 次 Agent 尝试后停止本次运行。',
|
||||
'system.runCircuitBreaker': '连续 {maxAttempts} 次 Agent 尝试都失败了,本次运行已自动停止。',
|
||||
'error.generic': '操作未能完成。',
|
||||
'error.cloudAgentBridgeAddress': '请输入服务器的 HTTPS IP 地址。',
|
||||
'error.cloudAgentBridgeManifest': '服务器未返回可识别的 Meldwork Agent Bridge 信息。',
|
||||
|
||||
@@ -1,151 +1,89 @@
|
||||
.agent-discovery-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
/* Agent discovery runs in the background: the sidebar shows skeleton rows
|
||||
until the first detection returns. Only transform and opacity animate. */
|
||||
.sidebar-discovery {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
padding: 32px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
cursor: progress;
|
||||
gap: 4px;
|
||||
padding: 4px 3px 6px;
|
||||
}
|
||||
|
||||
.agent-discovery-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(430px, 100%);
|
||||
.sidebar-discovery-row {
|
||||
height: 38px;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0;
|
||||
padding: 12px 20px;
|
||||
text-align: center;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.agent-discovery-mark {
|
||||
position: relative;
|
||||
.sidebar-discovery-row i,
|
||||
.sidebar-discovery-row b {
|
||||
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));
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface-active) 80%, transparent);
|
||||
animation: discovery-shimmer 1.4s var(--ease-standard) infinite;
|
||||
}
|
||||
|
||||
.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);
|
||||
.sidebar-discovery-row i {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.agent-discovery-kicker {
|
||||
margin: 0 0 9px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
.sidebar-discovery-row b {
|
||||
width: 62%;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.agent-discovery-copy h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.sidebar-discovery-row:nth-child(2) b { width: 48%; animation-delay: 0.12s; }
|
||||
.sidebar-discovery-row:nth-child(3) b { width: 56%; animation-delay: 0.24s; }
|
||||
|
||||
.agent-discovery-copy > p:not(.agent-discovery-kicker) {
|
||||
max-width: 360px;
|
||||
margin: 10px 0 0;
|
||||
.sidebar-discovery p {
|
||||
margin: 4px 8px 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.agent-discovery-progress {
|
||||
width: min(260px, 100%);
|
||||
@keyframes discovery-shimmer {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.home-workspace-state.discovering,
|
||||
.home-workspace-state.discovering svg {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.boot-progress {
|
||||
width: 140px;
|
||||
height: 3px;
|
||||
margin-top: 26px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--border) 68%, transparent);
|
||||
background: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
.agent-discovery-progress span {
|
||||
.boot-progress span {
|
||||
display: block;
|
||||
width: 46%;
|
||||
width: 40%;
|
||||
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;
|
||||
animation: boot-progress 1.2s var(--ease-standard) 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); }
|
||||
@keyframes boot-progress {
|
||||
from { transform: translateX(-110%); }
|
||||
to { transform: translateX(260%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-discovery-progress span,
|
||||
.agent-discovery-status-dot {
|
||||
.sidebar-discovery-row i,
|
||||
.sidebar-discovery-row b,
|
||||
.boot-progress span {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.agent-discovery-progress span {
|
||||
width: 58%;
|
||||
transform: translateX(36%);
|
||||
.boot-progress span {
|
||||
width: 100%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.agent-discovery-overlay { padding: 24px; }
|
||||
.agent-discovery-copy h1 { font-size: 26px; }
|
||||
.agent-discovery-mark { margin-bottom: 20px; }
|
||||
}
|
||||
@@ -3,6 +3,21 @@
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
color-scheme: light;
|
||||
/* Type scale. Nothing that has to be read goes below --text-xs; --text-2xs
|
||||
is only for badges, tags and keyboard hints. */
|
||||
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--text-2xs: 11px;
|
||||
--text-xs: 12px;
|
||||
--text-sm: 13px;
|
||||
--text-md: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 18px;
|
||||
--text-2xl: 22px;
|
||||
--text-3xl: 28px;
|
||||
/* PingFang SC stops at Semibold, so anything above 600 renders the same in
|
||||
Chinese. Hierarchy comes from size and color, not from heavier weights. */
|
||||
--weight-medium: 500;
|
||||
--weight-semibold: 600;
|
||||
--bg: #f3f6f8;
|
||||
--sidebar: #e9eef1;
|
||||
--surface: #f9fbfc;
|
||||
@@ -13,20 +28,55 @@
|
||||
--border-strong: #b9c8cf;
|
||||
--text: #26343c;
|
||||
--text-soft: #50616a;
|
||||
--muted: #788b94;
|
||||
--faint: #9aaab1;
|
||||
/* ≥4.5:1 on every light surface; small secondary text must stay readable. */
|
||||
--muted: #5a6b75;
|
||||
/* ≈3.3:1. Placeholders, disabled text and decorative icons only. */
|
||||
--faint: #7d8d95;
|
||||
--accent: #d45f52;
|
||||
--accent-hover: #b94e45;
|
||||
--accent-soft: #f7e3df;
|
||||
--accent-contrast: #fffaf8;
|
||||
--danger: #b74743;
|
||||
--danger-soft: #fbe8e6;
|
||||
/* Accent as text (≥4.5:1, also on --accent-soft) and as a filled button behind light text (≈4.6:1). */
|
||||
--accent-text: #ad4840;
|
||||
--accent-strong: #bd5047;
|
||||
--accent-strong-hover: #a9463e;
|
||||
--focus-ring: color-mix(in srgb, var(--accent) 72%, var(--surface-raised));
|
||||
--scrollbar-thumb: rgb(38 52 60 / 0.22);
|
||||
/* Status colors. The accent is for actions, selection and brand moments;
|
||||
it never means "ready" or "done". */
|
||||
--success: #2a7353;
|
||||
--success-soft: #e6f4ec;
|
||||
--warning: #9a5d08;
|
||||
--warning-soft: #fdf1dc;
|
||||
--danger: #b42318;
|
||||
--danger-soft: #fdeceb;
|
||||
--info: #2f6db3;
|
||||
--info-soft: #e8f0fb;
|
||||
--shadow: 0 12px 32px rgb(41 62 73 / 0.12);
|
||||
--elev-1: 0 1px 2px rgb(32 48 58 / 0.06), 0 1px 1px rgb(32 48 58 / 0.04);
|
||||
--elev-2: 0 4px 14px rgb(32 48 58 / 0.1), 0 1px 3px rgb(32 48 58 / 0.06);
|
||||
--elev-3: 0 12px 30px rgb(32 48 58 / 0.14), 0 2px 6px rgb(32 48 58 / 0.06);
|
||||
--radius-xs: 4px;
|
||||
--radius-lg: 14px;
|
||||
/* Layers, lowest to highest. */
|
||||
--z-dropdown: 50;
|
||||
--z-drawer: 60;
|
||||
--z-overlay: 90;
|
||||
--z-modal: 100;
|
||||
--z-onboarding: 110;
|
||||
--z-toast: 120;
|
||||
--z-popover: 140;
|
||||
--z-lightbox: 1000;
|
||||
/* Motion. Enter with --ease-out, leave faster with --ease-in. */
|
||||
--dur-press: 80ms;
|
||||
--dur-fast: 120ms;
|
||||
--dur-base: 180ms;
|
||||
--dur-slow: 240ms;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--shell-gradient: linear-gradient(145deg, #f1f8f7 0%, #e2efef 48%, #c9dde0 100%);
|
||||
--onboarding-frame-gradient: linear-gradient(145deg, #dcefeb 0%, #9fc2ca 48%, #ddb5aa 100%);
|
||||
--pixel-blast-primary: #d92f24;
|
||||
--pixel-blast-secondary: #007d91;
|
||||
--pixel-blast-tertiary: #ed512f;
|
||||
--workspace-surface: #fbfcfd;
|
||||
--workspace-shadow: 0 1px 2px rgb(41 62 73 / 0.08), 0 10px 28px rgb(41 62 73 / 0.08);
|
||||
--radius-sm: 5px;
|
||||
@@ -41,32 +91,44 @@
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #121516;
|
||||
--sidebar: #191c1e;
|
||||
--surface: #1b1f21;
|
||||
--surface-raised: #24292c;
|
||||
--surface-hover: #292e31;
|
||||
--surface-active: #30363a;
|
||||
--bg: #111618;
|
||||
--sidebar: #161c1e;
|
||||
--surface: #1f2427;
|
||||
--surface-raised: #262c2f;
|
||||
--surface-hover: #2b3134;
|
||||
--surface-active: #32393c;
|
||||
--border: #3a4246;
|
||||
--border-strong: #515b60;
|
||||
--text: #e6ebed;
|
||||
--text-soft: #c3cdd1;
|
||||
--muted: #93a0a5;
|
||||
--faint: #707b80;
|
||||
--muted: #9aa6ab;
|
||||
--faint: #808b90;
|
||||
--accent: #d98568;
|
||||
--accent-hover: #efa080;
|
||||
--accent-soft: #3b2a24;
|
||||
--accent-contrast: #1b110d;
|
||||
--danger: #ee817c;
|
||||
--accent-text: #e2906f;
|
||||
--accent-strong: #d98568;
|
||||
--accent-strong-hover: #efa080;
|
||||
--scrollbar-thumb: rgb(230 235 237 / 0.2);
|
||||
--success: #6cc3a0;
|
||||
--success-soft: #17302a;
|
||||
--warning: #e0a54a;
|
||||
--warning-soft: #35290f;
|
||||
--danger: #f97066;
|
||||
--danger-soft: #402326;
|
||||
--info: #7fb0ea;
|
||||
--info-soft: #172a40;
|
||||
--shadow: 0 16px 42px rgb(0 0 0 / 0.42);
|
||||
--shell-gradient: linear-gradient(145deg, #2d3a3b 0%, #222e32 48%, #172226 100%);
|
||||
--elev-1: 0 1px 2px rgb(0 0 0 / 0.3);
|
||||
--elev-2: 0 6px 18px rgb(0 0 0 / 0.32), 0 1px 3px rgb(0 0 0 / 0.24);
|
||||
--elev-3: 0 14px 34px rgb(0 0 0 / 0.4), 0 2px 6px rgb(0 0 0 / 0.24);
|
||||
/* The backdrop stays darker than the workspace card so the card reads as
|
||||
raised, the same way it does in the light theme. */
|
||||
--shell-gradient: linear-gradient(145deg, #1f292b 0%, #182124 48%, #111a1d 100%);
|
||||
--onboarding-frame-gradient: linear-gradient(145deg, #4a696b 0%, #2e4751 52%, #775047 100%);
|
||||
--pixel-blast-primary: #ff836d;
|
||||
--pixel-blast-secondary: #54f5ff;
|
||||
--pixel-blast-tertiary: #ffa36f;
|
||||
--workspace-surface: #181c1e;
|
||||
--workspace-shadow: 0 1px 2px rgb(0 0 0 / 0.42), 0 14px 34px rgb(0 0 0 / 0.24);
|
||||
--workspace-surface: #1c2124;
|
||||
--workspace-shadow: 0 0 0 1px rgb(255 255 255 / 0.05), 0 14px 34px rgb(0 0 0 / 0.32);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -97,7 +159,7 @@ body {
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-size: var(--text-md);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
@@ -124,7 +186,7 @@ button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -141,6 +203,18 @@ select:disabled {
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.conversation-nav,
|
||||
.trace-panel-scroll,
|
||||
.settings-panel,
|
||||
.agent-home,
|
||||
.collapsed-group-menu-list,
|
||||
.trace-select-menu,
|
||||
.onboarding-slide > div,
|
||||
.trace-event-body pre {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -295,5 +369,5 @@ button svg {
|
||||
|
||||
.bridge-state h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 24px;
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
@@ -69,7 +69,7 @@
|
||||
|
||||
.onboarding-slide h1 {
|
||||
margin: 0;
|
||||
font-size: 25px;
|
||||
font-size: var(--text-2xl);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@@ -149,6 +149,31 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.onboarding-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Skipping used to need Escape, which nobody discovers. */
|
||||
.onboarding-skip {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast) var(--ease-standard), background-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.onboarding-skip:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.onboarding-primary {
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
width: min(300px, calc(100vw - 96px));
|
||||
max-height: min(420px, calc(100vh - 32px));
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
z-index: var(--z-overlay);
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
padding: 8px;
|
||||
@@ -29,13 +29,13 @@
|
||||
|
||||
.collapsed-group-menu-header strong {
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.collapsed-group-menu-header small {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.collapsed-group-menu-icon {
|
||||
@@ -104,13 +104,13 @@
|
||||
}
|
||||
|
||||
.collapsed-group-option-main strong {
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.collapsed-group-option-main small {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.collapsed-group-option-state {
|
||||
@@ -129,7 +129,7 @@
|
||||
|
||||
.collapsed-group-option-state time {
|
||||
overflow: hidden;
|
||||
font-size: 9px;
|
||||
font-size: var(--text-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -138,7 +138,7 @@
|
||||
margin: 0;
|
||||
padding: 14px 8px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.conversation-link > span:nth-child(2) {
|
||||
@@ -160,13 +160,38 @@
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.run-pulse {
|
||||
/* "Needs your decision": an amber dot with a soft halo. */
|
||||
.attention-mark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.attention-dot {
|
||||
position: relative;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: run-pulse 1.2s ease-in-out infinite;
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.attention-dot::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning);
|
||||
opacity: 0.22;
|
||||
animation: attention-halo 1.8s var(--ease-standard) infinite;
|
||||
}
|
||||
|
||||
@keyframes attention-halo {
|
||||
0% { opacity: 0.4; transform: scale(0.7); }
|
||||
70%, 100% { opacity: 0; transform: scale(1.7); }
|
||||
}
|
||||
|
||||
.run-finished-mark {
|
||||
@@ -175,7 +200,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
color: var(--success);
|
||||
animation: run-finished-in 0.22s ease-out both;
|
||||
}
|
||||
|
||||
@@ -184,10 +209,6 @@
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
@keyframes run-pulse {
|
||||
50% { opacity: 0.42; transform: scale(0.78); }
|
||||
}
|
||||
|
||||
@keyframes run-finished-in {
|
||||
from { opacity: 0; transform: scale(0.7); }
|
||||
}
|
||||
@@ -195,7 +216,7 @@
|
||||
.nav-empty {
|
||||
margin: 2px 8px 0;
|
||||
color: var(--faint);
|
||||
font-size: 12px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
@@ -303,5 +324,5 @@
|
||||
|
||||
.footer-count {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
@@ -81,18 +81,18 @@
|
||||
|
||||
.brand-button strong {
|
||||
color: var(--text-soft);
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-button.active strong {
|
||||
color: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.brand-button small {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -222,22 +222,10 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.collapsed-group-count {
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
.collapsed-group-attention {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -3px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
@@ -260,28 +248,38 @@
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: 2px 12px 12px;
|
||||
border: 1px solid var(--accent);
|
||||
border: 1px solid var(--accent-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
background: var(--accent-strong);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 650;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease-standard),
|
||||
border-color var(--dur-fast) var(--ease-standard),
|
||||
transform var(--dur-press) var(--ease-standard);
|
||||
}
|
||||
|
||||
.new-group-button:hover,
|
||||
.primary-button:hover,
|
||||
.send-button:hover {
|
||||
border-color: var(--accent-hover);
|
||||
background: var(--accent-hover);
|
||||
.primary-button:hover:not(:disabled),
|
||||
.send-button:hover:not(:disabled) {
|
||||
border-color: var(--accent-strong-hover);
|
||||
background: var(--accent-strong-hover);
|
||||
}
|
||||
|
||||
/* Pressing scales slightly instead of shifting, so nothing next to the
|
||||
button moves with it. The composer's send and stop buttons keep their
|
||||
original one-pixel press. */
|
||||
.new-group-button:active,
|
||||
.primary-button:active,
|
||||
.secondary-button:active,
|
||||
.send-button:active,
|
||||
.stop-button:active,
|
||||
.danger-button:active,
|
||||
.primary-button:active:not(:disabled),
|
||||
.secondary-button:active:not(:disabled),
|
||||
.danger-button:active:not(:disabled),
|
||||
.sidebar-more-button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.send-button:active,
|
||||
.stop-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
@@ -301,14 +299,33 @@
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
/* A collapsed section shows how much it hides. */
|
||||
.nav-heading-count {
|
||||
min-width: 18px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-active);
|
||||
color: var(--muted);
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.nav-heading:hover,
|
||||
@@ -326,9 +343,9 @@
|
||||
|
||||
.sidebar-agent-location-heading {
|
||||
margin: 6px 8px 3px;
|
||||
color: var(--faint);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -390,6 +407,22 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-attention-dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -3px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sidebar-agent.unavailable .sidebar-agent-main img {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.sidebar-agent-main small[data-tone="warning"] {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.agent-logo-cloud > svg {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
@@ -418,12 +451,12 @@
|
||||
|
||||
.sidebar-agent-main strong {
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.sidebar-agent-main small {
|
||||
color: var(--faint);
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.sidebar-agent-new,
|
||||
@@ -547,6 +580,7 @@
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--text-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -612,7 +646,8 @@
|
||||
}
|
||||
|
||||
.direct-session-row > .run-mark,
|
||||
.direct-session-row > .run-finished-mark {
|
||||
.direct-session-row > .run-finished-mark,
|
||||
.direct-session-row > .attention-mark {
|
||||
grid-column: 4;
|
||||
grid-row: 1;
|
||||
width: 18px;
|
||||
@@ -628,7 +663,7 @@
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(0, 1fr) 58px;
|
||||
grid-template-columns: 26px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 9px;
|
||||
@@ -636,16 +671,24 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.group-conversation-row {
|
||||
--row-background: var(--bg);
|
||||
position: relative;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.group-conversation-row:hover,
|
||||
.group-conversation-row:focus-within {
|
||||
--row-background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.group-conversation-row.active {
|
||||
--row-background: var(--surface-active);
|
||||
background: var(--surface-active);
|
||||
}
|
||||
|
||||
@@ -654,17 +697,24 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Row actions float over the end of the name with a short fade, so the name
|
||||
can use the full row width when they are hidden. */
|
||||
.group-conversation-actions {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 6px;
|
||||
right: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-left: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(90deg, transparent, var(--row-background) 16px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
transition: opacity 0.14s ease, transform 0.16s ease;
|
||||
transition:
|
||||
opacity var(--dur-fast) var(--ease-standard),
|
||||
transform var(--dur-base) var(--ease-out);
|
||||
}
|
||||
|
||||
.group-conversation-row.active .group-conversation-actions,
|
||||
@@ -675,15 +725,13 @@
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
.group-conversation-row.active .conversation-link > .run-mark,
|
||||
.group-conversation-row:hover .conversation-link > .run-mark,
|
||||
.group-conversation-row:focus-within .conversation-link > .run-mark {
|
||||
opacity: 0;
|
||||
.conversation-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.conversation-link:hover {
|
||||
.group-conversation-row:hover:not(.active),
|
||||
.group-conversation-row:focus-within:not(.active) {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-more-button {
|
||||
@@ -699,8 +747,8 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.14s ease, color 0.14s ease, transform 0.14s ease;
|
||||
@@ -755,3 +803,29 @@
|
||||
.titlebar-preferences { display: flex; gap: 2px; }
|
||||
.app-shell:not([data-platform="darwin"]) { --desktop-titlebar-height: 32px; }
|
||||
.titlebar-actions .shortcut-menu { top: calc(100% + 8px); right: 0; }
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.sidebar:not(.collapsed) .brand-button span,
|
||||
.sidebar:not(.collapsed) .new-group-button span,
|
||||
.sidebar:not(.collapsed) .nav-heading,
|
||||
.sidebar:not(.collapsed) .sidebar-agent-location-heading,
|
||||
.sidebar:not(.collapsed) .sidebar-agent-main > span,
|
||||
.sidebar:not(.collapsed) .sidebar-agent-new,
|
||||
.sidebar:not(.collapsed) .direct-session-list,
|
||||
.sidebar:not(.collapsed) .group-nav-section {
|
||||
transition: opacity var(--dur-base) var(--ease-standard) 80ms;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.sidebar:not(.collapsed) .brand-button span,
|
||||
.sidebar:not(.collapsed) .new-group-button span,
|
||||
.sidebar:not(.collapsed) .nav-heading,
|
||||
.sidebar:not(.collapsed) .sidebar-agent-location-heading,
|
||||
.sidebar:not(.collapsed) .sidebar-agent-main > span,
|
||||
.sidebar:not(.collapsed) .sidebar-agent-new,
|
||||
.sidebar:not(.collapsed) .direct-session-list,
|
||||
.sidebar:not(.collapsed) .group-nav-section {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
gap: 14px;
|
||||
margin-bottom: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.manager-toolbar-actions {
|
||||
@@ -78,13 +78,13 @@
|
||||
display: block;
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.manager-note {
|
||||
max-width: 190px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-xs);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -94,8 +94,8 @@
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 7px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
color: var(--accent-text);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.install-progress {
|
||||
|
||||
@@ -2,25 +2,30 @@
|
||||
position: fixed;
|
||||
right: 22px;
|
||||
bottom: 22px;
|
||||
z-index: 120;
|
||||
z-index: var(--z-toast);
|
||||
max-width: min(420px, calc(100vw - 44px));
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid var(--border-strong);
|
||||
gap: 10px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--text-soft);
|
||||
line-height: 1.45;
|
||||
box-shadow: var(--elev-3);
|
||||
color: var(--text);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toast-message[data-tone="error"] {
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, var(--border));
|
||||
}
|
||||
|
||||
.copy-toast-message {
|
||||
position: fixed;
|
||||
top: calc(var(--desktop-titlebar-height) + 14px);
|
||||
left: 50%;
|
||||
z-index: 130;
|
||||
z-index: var(--z-toast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -29,14 +34,14 @@
|
||||
background: color-mix(in srgb, var(--surface-raised) 92%, var(--accent-soft));
|
||||
box-shadow: 0 6px 18px rgb(23 35 40 / 0.16);
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.copy-toast-message svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--accent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.toast-message > span {
|
||||
@@ -47,8 +52,12 @@
|
||||
.toast-message svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
margin-top: 1px;
|
||||
color: var(--accent);
|
||||
margin-top: 2px;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.toast-message[data-tone="error"] > svg {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.toast-message .toast-dismiss-button svg {
|
||||
@@ -58,15 +67,18 @@
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-enter-active {
|
||||
transition: opacity var(--dur-base) var(--ease-out), transform var(--dur-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
.toast-leave-active {
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
transition: opacity var(--dur-fast) var(--ease-in), transform var(--dur-fast) var(--ease-in);
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
|
||||
.copy-toast-enter-active,
|
||||
@@ -132,19 +144,13 @@
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow);
|
||||
animation: shortcut-menu-in 0.12s ease-out;
|
||||
}
|
||||
|
||||
@keyframes shortcut-menu-in {
|
||||
from { opacity: 0; transform: translateY(-3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.shortcut-menu header {
|
||||
padding: 2px 4px 8px;
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shortcut-menu ul {
|
||||
@@ -163,7 +169,7 @@
|
||||
gap: 12px;
|
||||
padding: 0 4px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.shortcut-menu kbd {
|
||||
@@ -171,7 +177,7 @@
|
||||
border-radius: 4px;
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-soft);
|
||||
font: 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font: var(--text-2xs)/1.2 var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -182,3 +188,20 @@
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Shared entrance for menus outside the chat page. Menus that open upward
|
||||
grow from their bottom edge. */
|
||||
.collapsed-group-menu {
|
||||
transform-origin: bottom left;
|
||||
animation: popover-in var(--dur-base) var(--ease-out) both;
|
||||
}
|
||||
|
||||
.trace-select-menu,
|
||||
.shortcut-menu {
|
||||
transform-origin: top center;
|
||||
animation: popover-in var(--dur-base) var(--ease-out) both;
|
||||
}
|
||||
|
||||
@keyframes popover-in {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
|
||||
.home-header h1 {
|
||||
font-size: 29px;
|
||||
font-size: var(--text-3xl);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -45,17 +45,22 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 13px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 650;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease-standard),
|
||||
border-color var(--dur-fast) var(--ease-standard),
|
||||
color var(--dur-fast) var(--ease-standard),
|
||||
transform var(--dur-press) var(--ease-standard);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.send-button {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent-strong);
|
||||
background: var(--accent-strong);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
@@ -74,14 +79,14 @@
|
||||
.compact {
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.home-summary {
|
||||
max-width: 1180px;
|
||||
margin: 32px auto 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.home-summary strong {
|
||||
@@ -108,7 +113,7 @@
|
||||
|
||||
.setup-guide h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.setup-guide p {
|
||||
@@ -120,7 +125,7 @@
|
||||
|
||||
.setup-guide ol {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -146,7 +151,7 @@
|
||||
}
|
||||
|
||||
.setup-guide li.complete > svg {
|
||||
color: var(--accent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.setup-guide li strong,
|
||||
@@ -156,13 +161,13 @@
|
||||
|
||||
.setup-guide li strong {
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.setup-guide li small {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@@ -235,7 +240,7 @@
|
||||
|
||||
.agent-name-row strong {
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -243,7 +248,7 @@
|
||||
.agent-card-description {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -255,7 +260,7 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -266,17 +271,17 @@
|
||||
}
|
||||
|
||||
.agent-state.ready {
|
||||
color: var(--accent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.agent-state.warning {
|
||||
color: var(--danger);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.agent-version {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--text-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -296,7 +301,7 @@
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--surface-hover) 54%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-size: var(--text-2xs);
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -307,7 +312,7 @@
|
||||
max-width: min(20ch, 40%);
|
||||
flex: 0 1 20ch;
|
||||
color: var(--faint);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.agent-card-meta-row {
|
||||
@@ -337,9 +342,9 @@
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
color: var(--accent-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -431,13 +436,13 @@
|
||||
|
||||
.agent-catalog-category-header h2 {
|
||||
color: var(--text-soft);
|
||||
font-size: 14px;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.agent-catalog-category-header p,
|
||||
.agent-catalog-category-header small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.agent-catalog-category-header p {
|
||||
@@ -454,7 +459,7 @@
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--surface) 58%, var(--bg));
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.settings-agent-card .manager-note,
|
||||
@@ -467,3 +472,42 @@
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Why an installed Agent is unavailable, and what to do about it. */
|
||||
.agent-card-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0 16px 10px 77px;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.agent-card-note > span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-card-note > button {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--accent-text);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.agent-card-note > button:hover:not(:disabled) {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.agent-card-note[data-tone="warning"] > span {
|
||||
border-left: 2px solid var(--warning);
|
||||
padding-left: 8px;
|
||||
}
|
||||
@@ -42,7 +42,7 @@
|
||||
}
|
||||
|
||||
.home-dashboard-header h1 {
|
||||
font-size: 28px;
|
||||
font-size: var(--text-3xl);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -60,20 +60,131 @@
|
||||
gap: 7px;
|
||||
padding-bottom: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-workspace-state svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--accent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.home-workspace-state.attention,
|
||||
.home-workspace-state.attention svg {
|
||||
color: var(--danger);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.home-attention {
|
||||
width: min(1080px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 4px 12px 10px;
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--warning-soft) 70%, var(--workspace-surface));
|
||||
}
|
||||
|
||||
.home-attention-header {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.home-attention-header h2 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.home-attention-header small {
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--warning);
|
||||
color: var(--workspace-surface);
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.home-attention-list {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.home-attention-item {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.home-attention-item:hover,
|
||||
.home-attention-item:focus-visible {
|
||||
background: color-mix(in srgb, var(--surface-raised) 72%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.home-attention-item > img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.home-attention-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-attention-copy strong,
|
||||
.home-attention-copy small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-attention-copy strong {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.home-attention-copy small {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.home-attention-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--warning);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-attention-action svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.home-recovery-notice {
|
||||
@@ -85,13 +196,13 @@
|
||||
margin: 0 auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--danger-soft) 58%, var(--bg));
|
||||
background: color-mix(in srgb, var(--warning-soft) 70%, var(--workspace-surface));
|
||||
}
|
||||
|
||||
.home-recovery-notice > svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
color: var(--danger);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.home-recovery-notice h2,
|
||||
@@ -101,13 +212,13 @@
|
||||
|
||||
.home-recovery-notice h2 {
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.home-recovery-notice p {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@@ -145,13 +256,13 @@
|
||||
}
|
||||
|
||||
.home-panel-header h2 {
|
||||
font-size: 15px;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.home-panel-header p {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@@ -225,14 +336,14 @@
|
||||
|
||||
.home-recent-copy strong,
|
||||
.home-agent-item strong {
|
||||
font-size: 12px;
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.home-recent-copy small,
|
||||
.home-agent-item small {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.home-recent-item > svg {
|
||||
@@ -283,7 +394,7 @@
|
||||
.home-panel-empty p {
|
||||
max-width: 300px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
.knowledge-base-ready-summary {
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -33,14 +33,14 @@
|
||||
.knowledge-base-group-header h3 {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.knowledge-base-group-header p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--faint);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.knowledge-base-list,
|
||||
@@ -98,7 +98,7 @@
|
||||
.knowledge-base-item-copy strong {
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-size: var(--text-md);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -117,8 +117,8 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -129,11 +129,11 @@
|
||||
}
|
||||
|
||||
.knowledge-base-status.connected {
|
||||
color: var(--accent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.knowledge-base-status.warning {
|
||||
color: var(--danger);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.knowledge-base-status.checking {
|
||||
@@ -143,7 +143,7 @@
|
||||
.knowledge-base-card-description {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -153,7 +153,7 @@
|
||||
.knowledge-base-path {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -173,21 +173,21 @@
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--surface-hover) 54%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.knowledge-base-tag.connected {
|
||||
background: color-mix(in srgb, var(--accent-soft) 62%, transparent);
|
||||
color: var(--accent);
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.knowledge-base-tag.warning {
|
||||
background: color-mix(in srgb, var(--danger-soft, var(--surface-hover)) 60%, transparent);
|
||||
color: var(--danger);
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.knowledge-base-tag.checking {
|
||||
@@ -227,9 +227,9 @@
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
color: var(--accent-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -252,7 +252,7 @@
|
||||
.knowledge-base-action:disabled,
|
||||
.knowledge-base-action:disabled:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
color: var(--accent-text);
|
||||
cursor: wait;
|
||||
opacity: 0.72;
|
||||
}
|
||||
@@ -264,5 +264,5 @@
|
||||
|
||||
.knowledge-base-action.ghost:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
.system-settings-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-size: var(--text-2xl);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-weight: 650;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -88,13 +88,13 @@
|
||||
|
||||
.cloud-agent-bridge-panel h2 {
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.cloud-agent-bridge-panel .manager-toolbar p {
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.cloud-agent-connect-form input {
|
||||
@@ -130,8 +130,8 @@
|
||||
|
||||
.cloud-agent-bridge-error {
|
||||
margin: 8px 0;
|
||||
color: var(--danger, #b34d4d);
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.cloud-agent-bridge-list {
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
.cloud-agent-bridge-item-main > strong {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.cloud-agent-bridge-agent-list {
|
||||
@@ -168,7 +168,7 @@
|
||||
margin: -1px 0 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-soft) !important;
|
||||
font-size: 11px !important;
|
||||
font-size: var(--text-xs) !important;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -176,13 +176,109 @@
|
||||
.cloud-agent-bridge-item-main > span,
|
||||
.cloud-agent-bridge-item-main > small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.bridge-online { color: var(--success, #4f7564) !important; }
|
||||
.bridge-offline { color: var(--danger, #b34d4d) !important; }
|
||||
.bridge-online { color: var(--success) !important; }
|
||||
.bridge-offline { color: var(--danger) !important; }
|
||||
|
||||
.cloud-agent-bridge-empty {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.general-settings-panel {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.general-settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.general-settings-row:first-child {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.general-settings-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.general-settings-copy h2 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.general-settings-copy p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Shared segmented choice: one selected option, same look in every mode. */
|
||||
.choice-segmented {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.choice-segmented button {
|
||||
min-height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 3px);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease-standard),
|
||||
color var(--dur-fast) var(--ease-standard),
|
||||
box-shadow var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.choice-segmented button svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.choice-segmented button:hover:not(.active) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.choice-segmented button.active {
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--elev-1);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cloud-agent-bridge-confirm {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.cloud-agent-bridge-confirm span {
|
||||
margin-right: 4px;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export function createWorkspaceControllers({
|
||||
createDirectSession: conversationActions.createDirectSession,
|
||||
deleteSidebarConversation: conversationActions.deleteSidebarConversation,
|
||||
directGroupsFor: conversationNavigation.directGroupsFor,
|
||||
discoveringAgents: app.discoveringAgents,
|
||||
dismissSidebarDeleteConfirmation: conversationActions.dismissSidebarDeleteConfirmation,
|
||||
formatNavTime: conversationNavigation.formatNavTime,
|
||||
goHome: app.goHome,
|
||||
@@ -34,6 +35,7 @@ export function createWorkspaceControllers({
|
||||
hasMoreGroupGroups: conversationNavigation.hasMoreGroupGroups,
|
||||
isDirectCreationPending: conversationNavigation.isDirectCreationPending,
|
||||
isDirectSessionListExpanded: conversationNavigation.isDirectSessionListExpanded,
|
||||
isGroupAwaitingDecision: app.isGroupAwaitingDecision,
|
||||
isGroupRunning: app.isGroupRunning,
|
||||
isSidebarAgentExpanded: conversationNavigation.isSidebarAgentExpanded,
|
||||
openConversationRename: conversationActions.openConversationRename,
|
||||
@@ -67,6 +69,7 @@ export function createWorkspaceControllers({
|
||||
agentImageLabel: agentCatalog.agentImageLabel,
|
||||
agentSkillLabel: agentSkills.agentSkillLabel,
|
||||
agentState: agentCatalog.agentState,
|
||||
agentStateNote: agentCatalog.agentStateNote,
|
||||
cancelInstall: agentManagement.cancelInstall,
|
||||
cloudAgentBridges: app.cloudAgentBridges,
|
||||
focusedAgentKind: app.focusedAgentKind,
|
||||
@@ -96,8 +99,10 @@ export function createWorkspaceControllers({
|
||||
requestInstall: agentManagement.requestInstall,
|
||||
runKnowledgeBasePrimaryAction: knowledgeBase.runKnowledgeBasePrimaryAction,
|
||||
selectSystemSettingsSection: app.selectSystemSettingsSection,
|
||||
setThemePreference: app.setThemePreference,
|
||||
systemSettingsSection: app.systemSettingsSection,
|
||||
t: app.t,
|
||||
themePreference: app.themePreference,
|
||||
}
|
||||
|
||||
const workspaceModalController = {
|
||||
|
||||
@@ -13,7 +13,6 @@ export default defineConfig(() => ({
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
{ name: 'pixel-blast', test: /node_modules[\\/]three[\\/]/ },
|
||||
{ name: 'vendor', test: /node_modules[\\/]/ },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user