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
40 changes: 30 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,37 @@

## Unreleased

### Design
### Vault popover (header)

- **feat(ui): vault popover + hub-discovery + OAuth `vault=<name>` hint
(0.3.15-rc.1).** Replaces the bare `<select>` switcher in the header
with a popover that surfaces the operator's full hub-side vault list
alongside the locally-connected vaults. Implements §2 of
[`design/2026-05-12-notes-ui-audit.md`](./design/2026-05-12-notes-ui-audit.md);
the first item in the §5 ship sequence.
- **Two sections.** "Connected" lists vaults Notes has tokens for
(active vault gets a filled accent dot + "current" tag; the rest
are one click to switch). "Available from your hub" lists vaults
published at `<hub>/.well-known/parachute.json` that Notes hasn't
connected to yet, each with an inline "Connect" button. Footer
links to the existing `/vaults` management page.
- **Hub-origin discovery.** Derived from `VaultRecord.issuer` (which
under hub-as-issuer is the hub origin itself, captured at OAuth
time in `OAuthCallback.tsx`) — no schema change to the stored
record, no migration. For a standalone-vault deployment the
well-known fetch returns no peers and the Available section is
omitted (graceful degradation).
- **OAuth `vault=<name>` hint (Path A).** `beginOAuth` now accepts an
`options.params` bag appended to the authorize URL last, guarded
so caller-supplied params can never overwrite standard OAuth/PKCE
params. Notes sends `vault=<name>` so future hubs that adopt the
hint can pre-select on the consent screen; pre-#240 hubs ignore it
and the picker renders as today.
- **Mobile.** Same component, rendered as `variant="inline"` inside
the existing hamburger menu — replaces the mobile `<select>` plus
the standalone "Manage vaults" button.

- **docs(design): Notes UI audit + vault-selector design proposal**
at [`design/2026-05-12-notes-ui-audit.md`](./design/2026-05-12-notes-ui-audit.md).
Inventories the current routes, navigation primitives, and primary
user flows; designs a vault popover that surfaces the hub's
well-known vault list to fix the multi-vault-on-one-hub gap;
surfaces ten broader improvement candidates with scope and leverage
reads; engages with the surface-direction research note
(parachute-patterns#54) on how Notes might evolve as a configured
surface instance. No code changes.
### Design

## 0.3.14 (2026-05-11)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openparachute/notes",
"version": "0.3.14",
"version": "0.3.15-rc.1",
"private": false,
"type": "module",
"description": "Parachute Notes — the default frontend for Parachute. Browse, edit, and capture in any Parachute Vault.",
Expand Down
5 changes: 4 additions & 1 deletion scripts/info-endpoint-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ export function infoEndpointPlugin(options: PluginOptions): Plugin {
const rootPath = `/${ENDPOINT}`;

function attach(server: ViteDevServer | PreviewServer): void {
const handler = (_req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => {
const handler = (
_req: import("node:http").IncomingMessage,
res: import("node:http").ServerResponse,
) => {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
Expand Down
23 changes: 17 additions & 6 deletions src/components/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useVaultStore } from "@/lib/vault/store";
import type { VaultRecord } from "@/lib/vault/types";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

function makeVault(partial: Partial<VaultRecord> & Pick<VaultRecord, "id" | "url">): VaultRecord {
return {
Expand All @@ -28,10 +28,21 @@ function renderHeader() {
describe("Header vault label fallback", () => {
beforeEach(() => {
useVaultStore.setState({ vaults: {}, activeVaultId: null });
// Stub fetch so the popover's well-known fetcher doesn't escape into a
// real network call during component render.
global.fetch = vi.fn(
async () =>
({
ok: true,
status: 200,
json: async () => ({ vaults: [], services: [] }),
}) as Response,
) as unknown as typeof fetch;
});

afterEach(() => {
useVaultStore.setState({ vaults: {}, activeVaultId: null });
vi.restoreAllMocks();
});

it("renders the vault name when present", () => {
Expand All @@ -40,7 +51,7 @@ describe("Header vault label fallback", () => {
activeVaultId: "a",
});
renderHeader();
expect(screen.getByRole("combobox", { name: /active vault/i })).toHaveTextContent("default");
expect(screen.getByRole("button", { name: /active vault: default/i })).toBeInTheDocument();
});

it("falls back to the URL host when name is empty", () => {
Expand All @@ -49,9 +60,9 @@ describe("Header vault label fallback", () => {
activeVaultId: "a",
});
renderHeader();
expect(screen.getByRole("combobox", { name: /active vault/i })).toHaveTextContent(
"vault.example.com:8443",
);
expect(
screen.getByRole("button", { name: /active vault: vault\.example\.com:8443/i }),
).toBeInTheDocument();
});

it("falls back to the raw URL when both name and URL are unparseable", () => {
Expand All @@ -60,6 +71,6 @@ describe("Header vault label fallback", () => {
activeVaultId: "a",
});
renderHeader();
expect(screen.getByRole("combobox", { name: /active vault/i })).toHaveTextContent("not a url");
expect(screen.getByRole("button", { name: /active vault: not a url/i })).toBeInTheDocument();
});
});
73 changes: 11 additions & 62 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { InstallPrompt } from "@/components/InstallPrompt";
import { SyncStatusIndicator } from "@/components/SyncStatusIndicator";
import { ThemeToggle } from "@/components/ThemeToggle";
import { type VaultRecord, useVaultStore } from "@/lib/vault";
import { VaultPopover } from "@/components/VaultPopover";
import { useVaultStore } from "@/lib/vault";
import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { Link, useLocation } from "react-router";

export function Header() {
const navigate = useNavigate();
const location = useLocation();
const vaults = useVaultStore((s) => s.vaults);
const activeVaultId = useVaultStore((s) => s.activeVaultId);
const setActiveVault = useVaultStore((s) => s.setActiveVault);
const hasVaults = useVaultStore((s) => Object.keys(s.vaults).length > 0);
const [menuOpen, setMenuOpen] = useState(false);

// Close the mobile menu whenever the route changes — otherwise a tap on a
Expand All @@ -20,19 +18,6 @@ export function Header() {
setMenuOpen(false);
}, [location.pathname]);

const vaultLabel = (v: VaultRecord): string => {
if (v.name) return v.name;
try {
return new URL(v.url).host;
} catch {
return v.url;
}
};
const vaultList = Object.values(vaults).sort((a, b) =>
vaultLabel(a).localeCompare(vaultLabel(b)),
);
const hasVaults = vaultList.length > 0;

return (
<header
className="sticky top-0 z-10 border-b border-border bg-bg/90 backdrop-blur"
Expand Down Expand Up @@ -65,28 +50,7 @@ export function Header() {
<Link to="/capture" className="text-sm text-fg-muted hover:text-accent">
+ Capture
</Link>
<label htmlFor="vault-switcher" className="sr-only">
Active vault
</label>
<select
id="vault-switcher"
value={activeVaultId ?? ""}
onChange={(e) => setActiveVault(e.target.value || null)}
className="rounded-md border border-border bg-card px-2.5 py-1.5 text-sm text-fg"
>
{vaultList.map((v) => (
<option key={v.id} value={v.id}>
{vaultLabel(v)}
</option>
))}
</select>
<button
type="button"
onClick={() => navigate("/vaults")}
className="text-sm text-fg-muted hover:text-accent"
>
Manage
</button>
<VaultPopover />
<Link to="/settings" className="text-sm text-fg-muted hover:text-accent">
Settings
</Link>
Expand Down Expand Up @@ -131,27 +95,12 @@ export function Header() {
<Link to="/activity" className="py-1 text-sm text-fg hover:text-accent">
Activity
</Link>
<button
type="button"
onClick={() => navigate("/vaults")}
className="py-1 text-left text-sm text-fg hover:text-accent"
>
Manage vaults
</button>
<label className="mt-1 block text-xs text-fg-dim">
<span className="mb-1 block uppercase tracking-wider">Active vault</span>
<select
value={activeVaultId ?? ""}
onChange={(e) => setActiveVault(e.target.value || null)}
className="w-full rounded-md border border-border bg-card px-2.5 py-2 text-sm text-fg"
>
{vaultList.map((v) => (
<option key={v.id} value={v.id}>
{vaultLabel(v)}
</option>
))}
</select>
</label>
<div className="mt-1">
<span className="mb-1 block text-xs uppercase tracking-wider text-fg-dim">
Active vault
</span>
<VaultPopover variant="inline" />
</div>
<div className="mt-1 flex items-center gap-3">
<InstallPrompt />
<ThemeToggle />
Expand Down
4 changes: 1 addition & 3 deletions src/components/ReconnectBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ import { useState } from "react";
export function ReconnectBanner() {
const activeVaultId = useVaultStore((s) => s.activeVaultId);
const vault = useVaultStore((s) => s.getActiveVault());
const halt = useAuthHaltStore((s) =>
activeVaultId ? (s.byVault[activeVaultId] ?? null) : null,
);
const halt = useAuthHaltStore((s) => (activeVaultId ? (s.byVault[activeVaultId] ?? null) : null));
const [reconnecting, setReconnecting] = useState(false);
const [error, setError] = useState<string | null>(null);

Expand Down
12 changes: 3 additions & 9 deletions src/components/TranscriptionStatus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,19 @@ describe("TranscriptionStatus", () => {
});

it("shows 'Transcribing…' when the note still carries the pending marker", () => {
render(
<TranscriptionStatus content="# 🎙️ Voice memo\n\n_Transcript pending._\n" />,
);
render(<TranscriptionStatus content="# 🎙️ Voice memo\n\n_Transcript pending._\n" />);
expect(screen.getByText(/transcribing/i)).toBeInTheDocument();
});

it("shows the unavailable chip when the note carries the unavailable marker", () => {
render(
<TranscriptionStatus
content="Some preamble.\n\n_Transcription unavailable._\n\nrest"
/>,
<TranscriptionStatus content="Some preamble.\n\n_Transcription unavailable._\n\nrest" />,
);
expect(screen.getByText(/transcription unavailable/i)).toBeInTheDocument();
});

it("prefers the pending chip when both markers coexist", () => {
render(
<TranscriptionStatus content="_Transcript pending._\n_Transcription unavailable._" />,
);
render(<TranscriptionStatus content="_Transcript pending._\n_Transcription unavailable._" />);
expect(screen.getByText(/transcribing/i)).toBeInTheDocument();
expect(screen.queryByText(/transcription unavailable/i)).not.toBeInTheDocument();
});
Expand Down
Loading