Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/app/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,34 @@ It persists until onboarding is complete and then disappears; it never gates the
_Avoid_: Onboarding modal, wizard (the wizard is the conversational interview; the nudge
only surfaces its state), setup wall

### Activity vocabulary

**Activity**:
The one semantic state set every status surface speaks — session dots (tab strip,
sessions dropdown, sidebar, home), run status (run window, run view), health dots
(connection, MCP, LSP, server, vault), build status, and status banners. Five roles,
each a `--activity-*` token in `src/design-polish.css` (solid dot colour plus a `-soft`
fill) aliasing the theme's v2 state tokens so light and dark move together:

| Role | Colour | Means |
| --- | --- | --- |
| `running` | blue | in progress — working, solving, pending, validating |
| `attention` | amber | waiting on you — permission, question, needs auth, expired, drift, stalled |
| `success` | green | done-unread, healthy, finished |
| `error` | red | failed, unreachable |
| `idle` | grey | done-seen, aborted, stopped, disabled |

A session's tab status ranks **error > attention > running > done-unread > idle**. Running
dots share one reduced-motion-aware pulse (`.amc-activity-pulse`). Colour is never the
only signal: every status dot carries an accessible label or sits beside text that
names the state.

**Yellow never encodes a state.** Yellow is brand and selection only (the `--accent`
family). Amico's presence — the thought-rail harmonic dot, the working lane, the
thinking wave, the mark — is brand, not status, and keeps the accent.
_Avoid_: yellow "running", gold/amber warning edges from the theme as an attention
colour, the agent identity tint on a status spinner, raw hex status colours

## Relationships

- The **Landing** is a **Chat**; the window never opens on anything else.
Expand Down
15 changes: 11 additions & 4 deletions packages/app/src/components/connection-banner.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { ActivityDot } from "@opencode-ai/ui/amicode-activity"

// Amicode webview: a dead server (e.g. an opencode:build restart) used to read
// as an endless "thinking" wave — the event loop retries silently every 250ms.
Expand Down Expand Up @@ -64,12 +65,18 @@ export function ConnectionBanner() {
"border-radius": "var(--radius-full)",
"font-size": "12px",
"font-weight": 600,
border: "1px solid var(--v2-border-border-base, #3c3c3c)",
background: "var(--v2-background-bg-layer-01, #1e1e1e)",
"box-shadow": "0 8px 24px rgba(0, 0, 0, 0.35)",
color: down() ? "var(--v2-state-fg-warning, #d29922)" : "var(--v2-state-fg-success, #3fb950)",
display: "flex",
"align-items": "center",
gap: "var(--space-2)",
border: "1px solid var(--v2-border-border-base)",
background: "var(--v2-background-bg-layer-01)",
"box-shadow": "var(--elev-float)",
color: "var(--v2-text-text-base)",
}}
>
{/* activity dot: attention while the link is down, success once back.
The words carry the state; the dot is the at-a-glance colour. */}
<ActivityDot activity={down() ? "attention" : "success"} pulse={down()} />
{down() ? language.t("app.server.connectionLost") : language.t("app.server.reconnected")}
</div>
</Show>
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/components/dialog-new-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function DialogNewProject(props: {

<Show when={error()}>
<div class="flex items-center gap-2" role="alert" aria-live="polite" data-slot="new-project-error">
<div class="size-1.5 rounded-full shrink-0 bg-icon-critical-base" />
<div aria-hidden="true" class="size-1.5 rounded-full shrink-0 bg-[var(--activity-error)]" />
<span class="text-12-regular text-text-weak">{error()}</span>
</div>
</Show>
Expand Down
28 changes: 18 additions & 10 deletions packages/app/src/components/server/server-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { useLanguage } from "@/context/language"
import { type ServerConnection, serverName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
import { ActivityDot, type Activity } from "@opencode-ai/ui/amicode-activity"

interface ServerRowProps extends ParentProps {
conn: ServerConnection.Any
Expand Down Expand Up @@ -114,14 +115,21 @@ export function ServerRow(props: ServerRowProps) {
}

export function ServerHealthIndicator(props: { health?: ServerHealth }) {
return (
<div
classList={{
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
"bg-icon-success-base": props.health?.healthy === true,
"bg-icon-critical-base": props.health?.healthy === false,
"bg-border-weak-base": props.health === undefined,
}}
/>
)
const activity = () => serverHealthActivity(props.health?.healthy)
return <ActivityDot activity={activity()} label={SERVER_HEALTH_LABEL[activity()]} class="size-1.5 my-[3.5px]" />
}

/** healthy → success, unreachable → error, not yet probed → running (pending). */
export function serverHealthActivity(healthy: boolean | undefined): Activity {
if (healthy === true) return "success"
if (healthy === false) return "error"
return "running"
}

export const SERVER_HEALTH_LABEL: Record<Activity, string> = {
idle: "Server status unknown",
running: "Checking server",
attention: "Server needs attention",
success: "Server healthy",
error: "Server unreachable",
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function ContextWarningBanner() {
return (
<>
<Show when={warning.visibleLevel() === "warn"}>
<div class="flex h-7 items-center justify-between gap-3 border-b border-[var(--v2-border-border-warning)] bg-[var(--v2-background-bg-warning-subtle)] px-3 text-12-regular" role="status" aria-live="polite" data-component="context-warning-banner" data-level="warn">
<div class="flex h-7 items-center justify-between gap-3 border-b border-[var(--activity-attention)] bg-[var(--activity-attention-soft)] px-3 text-12-regular" role="status" aria-live="polite" data-component="context-warning-banner" data-level="warn">
<span class="truncate">{language.t("context.warning.banner.warn", { percent: warning.usage() })}</span>
<span class="flex shrink-0 items-center gap-1">
<ButtonV2 size="small" variant="neutral" onClick={compact}>{language.t("command.session.compact")}</ButtonV2>
Expand All @@ -41,7 +41,7 @@ export function ContextWarningBanner() {
</div>
</Show>
<Show when={warning.visibleLevel() === "critical"}>
<div class="flex h-7 items-center justify-between gap-3 border-b border-[var(--v2-border-border-danger)] bg-[var(--v2-background-bg-danger-subtle)] px-3 text-12-regular" role="alert" data-component="context-warning-banner" data-level="critical">
<div class="flex h-7 items-center justify-between gap-3 border-b border-[var(--activity-error)] bg-[var(--activity-error-soft)] px-3 text-12-regular" role="alert" data-component="context-warning-banner" data-level="critical">
<span class="truncate">{language.t("context.warning.banner.critical", { percent: warning.usage() })}</span>
<ButtonV2 size="small" variant="danger" onClick={compact}>{language.t("command.session.compact")}</ButtonV2>
</div>
Expand Down
22 changes: 11 additions & 11 deletions packages/app/src/components/settings-v2/developer-tools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,20 @@ const RebuildStatusBadge: Component<{ controller: DeveloperToolsController }> =
return (
<Switch>
<Match when={props.controller.rebuildState() === "rebuilding"}>
<div class="devtools-rebuild-status devtools-rebuild-status--building">
<span class="devtools-status-dot devtools-status-dot--orange" />
<div class="devtools-rebuild-status devtools-rebuild-status--running">
<span class="devtools-status-dot devtools-status-dot--running amc-activity-pulse" aria-hidden="true" />
<span>{language.t("settings.general.row.devTools.rebuilding")}</span>
</div>
</Match>
<Match when={props.controller.rebuildState() === "rebuilt"}>
<div class="devtools-rebuild-status devtools-rebuild-status--done">
<span class="devtools-status-dot devtools-status-dot--green" />
<div class="devtools-rebuild-status devtools-rebuild-status--success">
<span class="devtools-status-dot devtools-status-dot--success" aria-hidden="true" />
<span>{language.t("settings.general.row.devTools.rebuilt")}</span>
</div>
</Match>
<Match when={props.controller.rebuildState() === "failed"}>
<div class="devtools-rebuild-status devtools-rebuild-status--failed">
<span class="devtools-status-dot devtools-status-dot--red" />
<div class="devtools-rebuild-status devtools-rebuild-status--error">
<span class="devtools-status-dot devtools-status-dot--error" aria-hidden="true" />
<span>Failed</span>
</div>
</Match>
Expand All @@ -47,7 +47,7 @@ const RebuildErrorPanel: Component<{ controller: DeveloperToolsController }> = (
<Show when={props.controller.rebuildState() === "failed" && err()}>
<div class="devtools-error-panel">
<div class="devtools-error-message">
<span class="devtools-status-dot devtools-status-dot--red" />
<span class="devtools-status-dot devtools-status-dot--error" aria-hidden="true" />
<span>{err()!.message}</span>
</div>
<Show when={err()!.fix.length > 0}>
Expand Down Expand Up @@ -123,14 +123,14 @@ const DeveloperToolsContent: Component<{ controller: DeveloperToolsController }>
</ButtonV2>
</div>
<Show when={props.controller.vsixBuildState() === "rebuilt"}>
<div class="devtools-rebuild-status devtools-rebuild-status--done">
<span class="devtools-status-dot devtools-status-dot--green" />
<div class="devtools-rebuild-status devtools-rebuild-status--success">
<span class="devtools-status-dot devtools-status-dot--success" aria-hidden="true" />
<span>VSIX built: {props.controller.vsixPath() ?? ""}</span>
</div>
</Show>
<Show when={props.controller.vsixBuildState() === "failed"}>
<div class="devtools-rebuild-status devtools-rebuild-status--failed">
<span class="devtools-status-dot devtools-status-dot--red" />
<div class="devtools-rebuild-status devtools-rebuild-status--error">
<span class="devtools-status-dot devtools-status-dot--error" aria-hidden="true" />
<span>{props.controller.vsixBuildError()}</span>
</div>
</Show>
Expand Down
22 changes: 4 additions & 18 deletions packages/app/src/components/status-popover-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { authTokenFromCredentials } from "@/utils/server"
import { GLOBAL_STATUS_DEFAULT_TAB } from "./status-popover-model"
import { useServerProtocol } from "@/context/server-sdk"
import { beginSolverSwitch } from "@/components/solver-switch-banner"
import { ActivityDot, lspActivity, mcpActivity } from "@opencode-ai/ui/amicode-activity"

const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
Expand Down Expand Up @@ -465,16 +466,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean>; onClose?: (
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
<ActivityDot activity={mcpActivity(status() ?? "")} label={`MCP ${status() ?? "unknown"}`} />
<span class="flex flex-col min-w-0 flex-1">
<span class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-base truncate">{name}</span>
Expand Down Expand Up @@ -516,13 +508,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean>; onClose?: (
<For each={lspItems()}>
{(item) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": item.status === "connected",
"bg-icon-critical-base": item.status === "error",
}}
/>
<ActivityDot activity={lspActivity(item.status)} label={`LSP ${item.status}`} />
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
</div>
)}
Expand All @@ -543,7 +529,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean>; onClose?: (
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<ActivityDot activity="success" label="Plugin loaded" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
Expand Down
45 changes: 27 additions & 18 deletions packages/app/src/components/status-popover-indicator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,43 @@ import { describe, expect, test } from "bun:test"
import {
hasNonBlockingServiceIssue,
hasServiceNeedingAttention,
serverStatusDotClass,
serverStatusActivity,
serverStatusLabel,
} from "./status-popover-indicator"

describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
describe("serverStatusActivity", () => {
test("success while the server and services are healthy", () => {
expect(serverStatusActivity({ ready: true, serverHealth: true, issue: false })).toBe("success")
})

test("uses the session attention token when a service needs attention", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
// a bare yellow fill is 1.07:1 on the light ground, so the ink ring is what
// makes the highest-urgency state visible at all
"bg-v2-background-bg-accent ring-1 ring-[var(--accent-edge-ink)]",
)
test("attention (amber, never yellow) when a service needs the user", () => {
expect(serverStatusActivity({ ready: true, serverHealth: true, attention: true, issue: true })).toBe("attention")
})

test("attention for non-blocking service issues while the server is online", () => {
expect(serverStatusActivity({ ready: true, serverHealth: true, issue: true })).toBe("attention")
})

test("uses the warning token for non-blocking issues while the server is online", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
test("error only after the server connection drops", () => {
expect(serverStatusActivity({ ready: true, serverHealth: false, issue: false })).toBe("error")
expect(serverStatusActivity({ ready: true, serverHealth: false, issue: true })).toBe("error")
})

test("uses the critical token only after the server connection drops", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
test("running (pending) before status is ready", () => {
expect(serverStatusActivity({ ready: false, serverHealth: true, issue: false })).toBe("running")
expect(serverStatusActivity({ ready: false, serverHealth: undefined, issue: false })).toBe("running")
})
})

test("stays neutral before status is ready", () => {
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
describe("serverStatusLabel", () => {
test("every activity carries a distinct accessible label", () => {
const labels = (["idle", "running", "attention", "success", "error"] as const).map((activity) =>
serverStatusLabel({ activity, attention: false }),
)
expect(new Set(labels).size).toBe(labels.length)
expect(serverStatusLabel({ activity: "attention", attention: true })).not.toBe(
serverStatusLabel({ activity: "attention", attention: false }),
)
})
})

Expand Down
33 changes: 23 additions & 10 deletions packages/app/src/components/status-popover-indicator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
import type { McpServer } from "@opencode-ai/client/promise"
import type { Activity } from "@opencode-ai/ui/amicode-activity"

export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
Expand All @@ -15,18 +16,30 @@ export function hasNonBlockingServiceIssue(input: {
)
}

export function serverStatusDotClass(input: {
export function serverStatusActivity(input: {
ready: boolean
serverHealth: boolean | undefined
attention?: boolean
issue: boolean
}) {
if (input.serverHealth === false) return "bg-icon-critical-base"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
// A bare yellow fill is 1.07:1 on the light ground, so the most urgent state
// was the only invisible one. The ink ring is what makes it readable.
if (input.attention) return "bg-v2-background-bg-accent ring-1 ring-[var(--accent-edge-ink)]"
if (input.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base"
}): Activity {
if (input.serverHealth === false) return "error"
// still checking — the pending state of the health probe
if (!input.ready || input.serverHealth === undefined) return "running"
// waiting on the user (sign-in) or a non-blocking service issue: amber.
// Yellow never encodes a state.
if (input.attention || input.issue) return "attention"
return "success"
}

const SERVER_STATUS_LABEL: Record<Activity, string> = {
idle: "Server status unknown",
running: "Checking server status",
attention: "A service has an issue",
success: "Server and services healthy",
error: "Server unreachable",
}

export function serverStatusLabel(input: { activity: Activity; attention?: boolean }) {
if (input.activity === "attention" && input.attention) return "A service needs you to sign in"
return SERVER_STATUS_LABEL[input.activity]
}
Loading
Loading