Skip to content
Merged
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
66 changes: 66 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,72 @@ All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **Notes carry a severity.** `ToolStatus.notes` was a `Vec<String>` — an
untyped dumping ground that the panel rendered one way: every line behind the
same amber warning triangle. So "docker has no active registry (normal for
docker)" looked exactly like "credentials.db is unreadable", and a board of
healthy tools read as a wall of complaints. Each note is now a `Note` with a
`kind` of `info`, `warn` or `problem`. `info` draws no glyph and does not
count towards the card's badge; `warn` keeps the amber triangle; `problem`
gets a red one. `ToolStatus::note()` is gone, replaced by `info()`, `warn()`
and `problem()` so the judgement has to be made at every call site.

**Breaking JSON change.** `notes` in `pb --json`, in every MCP tool result,
and on `PermissionsReport` and the MCP-client report, is now an array of
`{"kind": "info"|"warn"|"problem", "text": "…"}` rather than an array of
strings. There is deliberately no back-compat shim: a consumer that keeps
treating notes as strings should fail loudly rather than print `[object
Object]`.

- **Expiry carries its own state.** `expires_at: null` meant three unrelated
things — this never expires, this expires but the timestamp is somewhere
patchbay will not read, and this expires but the CLI renews it silently — and
thirteen probes each wrote their own paragraph of prose explaining which one
applied. `Profile.expiry` is now an `Expiry`: `at`, `no_expiry`,
`unknown { reason }` or `refreshable { access_token_expires }`. The panel
shows "no expiry", "expiry unknown" or "auto-renewed" accordingly, with the
reason as the chip's tooltip, and only a real deadline takes a colour.

`Profile.expires_at` is still in the JSON, unchanged in meaning: a timestamp
for a real deadline, `null` for the other three. It is now *derived* from
`expiry` rather than stored beside it, so the two can never disagree.

- **"This tool has no active X" is a property, not a note.** New
`ToolStatus.active_concept`. rclone, npm, docker, ssh, stripe, flyctl, op and
supabase say it once, in the type; the panel renders an em dash with the
explanation as a tooltip instead of eight tools each filing a warning about
working as designed.

- **patchbay's own execution switch no longer surfaces as a caveat about your
login.** Five sites reported `command execution is disabled for this probe`
as a user-facing reason. New `SwitchOutcome::ExecDisabled` /
`VerifyOutcome::ExecDisabled` states instead: the panel greys the button and
explains in a tooltip, and the CLI prints one short line.

- Notes that only restated something already on the row are gone: profile
counts, tunnel-name counts, MCP-server counts, "AWS_PROFILE is not set, so
the default profile is in effect" (inverted — it now speaks up only when the
variable *is* set), and the neon config-directory trivia the advisory already
covers.

### Fixed

- **The panel was silently dropping every advisory.** Core has always
serialized `advisories` on `ToolStatus` and the CLI has always rendered them,
but the panel's TypeScript `ToolStatus` did not declare the field — so a tool
that had been *removed* or abandoned looked identical to a healthy one. The
drawer now has an advisories section above the notes, with the source link
and a louder treatment for the blocking kinds.

- **A purely informational MCP message was wearing the red error banner.** The
project-scope note in the MCP server drawer explains that patchbay declines
to write another project's config — a deliberate boundary, not a failure. It
now renders as a quiet notice.

## [0.3.4] - 2026-08-17

### Added
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,18 @@ patchbay works out **how each tool was installed** and asks the right place. Eve
Results are cached at `~/.config/patchbay/versions.json` for 24 hours. **`pb status` only ever reads that cache** — it never executes a binary and never touches the network, so the board stays in the tens of milliseconds whether the cache is warm or cold. A warm cache adds an update marker to the board:

```
gh github.com/YJack0000 2 — ↑ 2.95.0 → 2.97.0 · token expiry unknown…
neon default 1 — ⚠ advisory · ↑ 2.38.2 → 3.1.1
gh github.com/YJack0000 2 expiry unknown ↑ 2.95.0 → 2.97.0
neon default 1 auto-renewed ⚠ advisory · ↑ 2.38.2 → 3.1.1
kubectl — 5 — ✖ ~/.kube/config is a directory of…
```

The EXPIRES column says which of four things it means — a countdown, `no expiry`,
`expiry unknown`, or `auto-renewed` for a token the CLI silently renews — rather
than showing one em dash for all four. Notes carry a severity too: `✖` is
something broken, `⚠` is a risk worth knowing, and the explanations of how a
tool works get no marker at all, because a glyph on every line is a glyph that
means nothing.

**Advisories** (`⚠`) are curated deprecation notices — renames, removals, end-of-life dates — and they show up whether or not the version cache is warm, because they are static data. Each one is gated so it only appears where it applies (the AWS CLI v1 end-of-support notice never shows on v2), carries a source URL, and `pb check-updates` exits non-zero when something is genuinely removed or unmaintained. Nothing goes in the table without vendor documentation behind it.
## Moving to a new machine

Expand Down
2 changes: 1 addition & 1 deletion app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export default function App() {
supported: false,
subject: null,
scopes: [],
notes: [String(e)],
notes: [{ kind: "problem" as const, text: String(e) }],
hint: null,
scope,
},
Expand Down
21 changes: 12 additions & 9 deletions app/src/components/Chip.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { countdown, levelOf } from "../expiry";
import { expiryLevel, expiryText, expiryTitle } from "../expiry";
import type { Expiry } from "../types";

/** Expiry chip: severity by colour, human countdown as text. */
export function ExpiryChip({
expiresAt,
now,
}: Readonly<{ expiresAt: string | null; now: number }>) {
const level = levelOf(expiresAt, now);
/**
* Expiry chip: severity by colour, human countdown as text.
*
* Only a real deadline gets a colour. "no expiry", "auto-renewed" and "expiry
* unknown" are three different answers and all three are calm ones — the chip
* says which, and the tooltip says why.
*/
export function ExpiryChip({ expiry, now }: Readonly<{ expiry: Expiry; now: number }>) {
return (
<span className={`chip chip-${level}`} title={expiresAt ?? "this tool does not expose an expiry"}>
{countdown(expiresAt, now)}
<span className={`chip chip-${expiryLevel(expiry, now)}`} title={expiryTitle(expiry)}>
{expiryText(expiry, now)}
</span>
);
}
12 changes: 8 additions & 4 deletions app/src/components/McpServerDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type McpTransportKind,
type McpWriteReport,
} from "../types";
import { NoteGlyph } from "./ToolDetail";

/**
* The drawer that writes MCP config: one server, one client's copy of it at a
Expand Down Expand Up @@ -623,8 +624,11 @@ function ProjectScopeNote({ entries }: Readonly<{ entries: McpServerEntry[] }>)
const keys = [...entries.flatMap((e) => e.env_keys), ...entries.flatMap((e) => e.header_keys)];
return (
<div className="field">
<div className="banner">
<span className="glyph">△</span>
{/* Nothing failed here: patchbay is declining to write someone else's
project config, which is the correct behaviour. This used to be the
red error banner, which made a deliberate boundary look like a
breakage. */}
<div className="notice">
<span>
this copy lives in a project scope ({scopes.join(", ")}), not the user scope. patchbay
only writes the user scope — a project's servers are that project's business. Edit it with{" "}
Expand Down Expand Up @@ -961,8 +965,8 @@ function WriteBlock({ report }: Readonly<{ report: McpWriteReport }>) {
)}
</span>
{report.notes.map((n) => (
<span className="write-note" key={n}>
<span className="glyph">△</span> {n}
<span className={`write-note note-${n.kind}`} key={n.text}>
<NoteGlyph kind={n.kind} /> {n.text}
</span>
))}
</div>
Expand Down
7 changes: 4 additions & 3 deletions app/src/components/McpView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { mcpList } from "../api";
import type { McpClient, McpServerEntry } from "../types";
import { McpServerDetail } from "./McpServerDetail";
import { NoteGlyph } from "./ToolDetail";

/** What one client has to say about one server name. */
type Cell = "user" | "project" | "none";
Expand Down Expand Up @@ -184,10 +185,10 @@ export function McpView() {
<ul className="notes notes-full">
{clients.flatMap((c) =>
c.notes.map((n, i) => (
<li key={`${c.client}-${i}`}>
<span className="glyph">△</span>
<li className={`note-${n.kind}`} key={`${c.client}-${i}`}>
<NoteGlyph kind={n.kind} />
<span>
{c.label}: {n}
{c.label}: {n.text}
</span>
</li>
)),
Expand Down
43 changes: 34 additions & 9 deletions app/src/components/ToolCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { headlineExpiry } from "../expiry";
import type { Panel } from "../panel";
import { STATE_LABEL, type ToolStatus } from "../types";
import { isAlarming, STATE_LABEL, type ToolStatus } from "../types";
import { ExpiryChip } from "./Chip";
import { KeyGlyph } from "./Glyphs";
import { ToolLogo } from "./ToolLogo";
Expand Down Expand Up @@ -31,6 +31,13 @@ export function ToolCard({ status, panel }: Readonly<{ status: ToolStatus; panel
const keysWanting = keys.filter(
(k) => k.expiry_state === "expired" || k.expiry_state === "expiring_soon",
).length;
// Info notes are explanations, not complaints. Counting them here is what
// put a warning triangle on tools that were working exactly as designed.
const alarming = status.notes.filter(isAlarming);
// Set only when this tool keeps no selection at all; carries the reason.
const notApplicable =
status.active_concept.kind === "not_applicable" ? status.active_concept.reason : null;
const worst = alarming.some((n) => n.kind === "problem") ? "problem" : "warn";

return (
<button
Expand All @@ -46,11 +53,26 @@ export function ToolCard({ status, panel }: Readonly<{ status: ToolStatus; panel
</span>

<span className="card-active">
{active ? (
<span className="active-id" title={active.id}>
{/* A tool with no selection can still have a default worth naming —
op's last sign-in, stripe's [default] table. The value shows; the
tooltip is what says it is a default rather than a choice. */}
{active && (
<span
className="active-id"
title={notApplicable ? `${active.id} — ${notApplicable}` : active.id}
>
{active.label}
</span>
) : (
)}
{/* An empty slot on rclone or docker is the right answer, not a
missing one: those tools have no active anything. Saying "not
connected" there was simply wrong. */}
{!active && notApplicable && (
<span className="active-id muted" title={notApplicable}>
</span>
)}
{!active && !notApplicable && (
<span className="active-id muted">
{status.installed ? "— not connected" : "— not installed"}
</span>
Expand All @@ -60,7 +82,7 @@ export function ToolCard({ status, panel }: Readonly<{ status: ToolStatus; panel
<span className="card-foot">
{/* No profiles means no credential to date — a "no expiry" chip there
would read as a fact about a login that does not exist. */}
{profiles > 0 && <ExpiryChip expiresAt={headlineExpiry(status)} now={panel.now} />}
{profiles > 0 && <ExpiryChip expiry={headlineExpiry(status)} now={panel.now} />}
<span className="card-count">
{profiles} {profiles === 1 ? "profile" : "profiles"}
</span>
Expand All @@ -75,10 +97,13 @@ export function ToolCard({ status, panel }: Readonly<{ status: ToolStatus; panel
{keys.length}
</span>
)}
{status.notes.length > 0 && (
<span className="warn-badge" title={`${status.notes.length} note(s) — open for detail`}>
<span className="glyph">△</span>
{status.notes.length}
{alarming.length > 0 && (
<span
className={`warn-badge is-${worst}`}
title={`${alarming.length} note${alarming.length === 1 ? "" : "s"} needing a look — open for detail`}
>
<span className={`glyph glyph-${worst}`}>{worst === "problem" ? "▲" : "△"}</span>
{alarming.length}
</span>
)}
</span>
Expand Down
Loading
Loading