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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
[ OK ] CONTEXT BUFFER
[ -- ] AWAITING SESSION

TRACE // BUILD 0.40.0
TRACE // BUILD 0.41.0

· · · · · · · · · · · · · · · · · · · · · · · ·
```
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": "trace",
"version": "0.40.0",
"version": "0.41.0",
"description": "Local-first meeting capture and intelligence. Conversations leave traces.",
"license": "MIT",
"private": true,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "trace"
version = "0.40.0"
version = "0.41.0"
description = "Local-first meeting capture and intelligence"
authors = ["Nick"]
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "TRACE",
"version": "0.40.0",
"version": "0.41.0",
"identifier": "computer.trace.app",
"build": {
"beforeDevCommand": "pnpm dev",
Expand Down
149 changes: 76 additions & 73 deletions src/app/Shell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type ReactNode, type RefObject, useCallback, useEffect, useRef, useState } from "react";
import { ConfirmProvider } from "../components/ui/Confirm";
import { ContextMenuProvider } from "../components/ui/ContextMenu";
import { Grain } from "../components/ui/Grain";
import { formatElapsed } from "../components/ui/terminal";
import { ActivityToast } from "../features/activity/ActivityToast";
Expand Down Expand Up @@ -103,96 +104,98 @@ export function Shell({

return (
<div ref={shell} className="trace-shell flex h-full flex-col bg-surface-0">
{/* Inside the shell, so the dialog inherits whichever theme the shell
{/* Inside the shell, so the dialog and menus inherit whichever theme the shell
is wearing — the app's, or a gallery preview's. */}
<ConfirmProvider>
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative flex min-h-0 flex-1">
{docked && (
<Sidebar
current={current}
onNavigate={go}
recording={recording}
paused={paused}
notes={notes}
onHide={() => setHidden(true)}
/>
)}
<div className="flex min-w-0 flex-1 flex-col">
{!docked && (
<SidebarStrip
toggle={toggle}
open={overlay}
// Hidden by hand on a wide window: the toggle brings it back
// for good. Too narrow to dock: it can only ever overlay.
onToggle={() => (narrow ? setOverlay((o) => !o) : setHidden(false))}
onHome={() => go("library")}
<ContextMenuProvider>
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative flex min-h-0 flex-1">
{docked && (
<Sidebar
current={current}
onNavigate={go}
recording={recording}
paused={paused}
onRecording={() => go("capture")}
notes={notes}
onHide={() => setHidden(true)}
/>
)}
{/* The canvas: the one part of the window the screen effects
<div className="flex min-w-0 flex-1 flex-col">
{!docked && (
<SidebarStrip
toggle={toggle}
open={overlay}
// Hidden by hand on a wide window: the toggle brings it back
// for good. Too narrow to dock: it can only ever overlay.
onToggle={() => (narrow ? setOverlay((o) => !o) : setHidden(false))}
onHome={() => go("library")}
recording={recording}
paused={paused}
onRecording={() => go("capture")}
/>
)}
{/* The canvas: the one part of the window the screen effects
are drawn on. The sidebar, its strip and the status bar are
the instrument's chrome and sit above the glass, untouched —
so the canvas is its own stacking context, and effects
inside it can never reach them. */}
<main className="trace-canvas relative min-h-0 min-w-0 flex-1 bg-surface-0">
{children}
{/* The screen effects' layers (screen.css), in painting order.
<main className="trace-canvas relative min-h-0 min-w-0 flex-1 bg-surface-0">
{children}
{/* The screen effects' layers (screen.css), in painting order.
Always rendered, drawn only when their effect is on, so
changing the screen changes attributes, not the tree. */}
<span aria-hidden className="trace-fx trace-fx-dots" />
<span aria-hidden className="trace-fx trace-fx-scanlines" />
<Grain />
<span aria-hidden className="trace-fx trace-fx-vignette" />
<span aria-hidden className="trace-fx trace-fx-glass" />
<span aria-hidden className="trace-fx trace-fx-roll" />
{/* Always present, so a toast appearing inside it is announced:
<span aria-hidden className="trace-fx trace-fx-dots" />
<span aria-hidden className="trace-fx trace-fx-scanlines" />
<Grain />
<span aria-hidden className="trace-fx trace-fx-vignette" />
<span aria-hidden className="trace-fx trace-fx-glass" />
<span aria-hidden className="trace-fx trace-fx-roll" />
{/* Always present, so a toast appearing inside it is announced:
a live region created with its content often is not. Above the
effects: news should be read, not seen through the glass. */}
<div
aria-live="polite"
className="pointer-events-none absolute right-4 bottom-3 z-60 flex justify-end"
>
<ActivityToast jobs={jobs} openNote={openNote} onOpenNote={onOpenNote} />
</div>
</main>
</div>
<div
aria-live="polite"
className="pointer-events-none absolute right-4 bottom-3 z-60 flex justify-end"
>
<ActivityToast jobs={jobs} openNote={openNote} onOpenNote={onOpenNote} />
</div>
</main>
</div>

{!docked && overlay && (
<>
<button
type="button"
aria-label="Close sidebar"
tabIndex={-1}
onClick={closeOverlay}
className="trace-scrim absolute inset-0 z-40 cursor-default bg-black/50"
/>
<div className="trace-slide-in absolute inset-y-0 left-0 z-40 flex shadow-(--elevation-overlay)">
<Sidebar
current={current}
onNavigate={go}
recording={recording}
paused={paused}
notes={notes}
onHide={closeOverlay}
overlay
{!docked && overlay && (
<>
<button
type="button"
aria-label="Close sidebar"
tabIndex={-1}
onClick={closeOverlay}
className="trace-scrim absolute inset-0 z-40 cursor-default bg-black/50"
/>
</div>
</>
)}
<div className="trace-slide-in absolute inset-y-0 left-0 z-40 flex shadow-(--elevation-overlay)">
<Sidebar
current={current}
onNavigate={go}
recording={recording}
paused={paused}
notes={notes}
onHide={closeOverlay}
overlay
/>
</div>
</>
)}
</div>
<StatusBar
page={current}
jobs={jobs}
onManageModels={() => onNavigate("models")}
onOpenNote={onOpenNote}
onWhatsNew={onWhatsNew}
recording={recording !== null}
initialUpdate={initialUpdate}
/>
</div>
<StatusBar
page={current}
jobs={jobs}
onManageModels={() => onNavigate("models")}
onOpenNote={onOpenNote}
onWhatsNew={onWhatsNew}
recording={recording !== null}
initialUpdate={initialUpdate}
/>
</div>
</ContextMenuProvider>
</ConfirmProvider>
</div>
);
Expand Down
10 changes: 10 additions & 0 deletions src/app/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ReactNode, type RefObject, useEffect, useRef } from "react";
import { useContextMenu } from "../components/ui/ContextMenu";
import { formatElapsed, Spinner, SystemLabel } from "../components/ui/terminal";
import { MiniIcon } from "../features/mini/MiniIcon";
import { shortcutLabel, useMiniShortcut } from "../features/mini/shortcut";
Expand Down Expand Up @@ -123,9 +124,18 @@ export function Sidebar({
*/
function MiniFoot() {
const shortcut = useMiniShortcut();
const menu = useContextMenu("Mini window", () => [
{
label: "Open",
...(shortcut ? { hint: shortcutLabel(shortcut) } : {}),
onSelect: () => void ipc.openMini().catch(() => {}),
},
{ label: "Bring back to its corner", onSelect: () => void ipc.resetMini().catch(() => {}) },
]);
return (
<div className="group/mini mx-3 mt-auto mb-3 flex items-center gap-1">
<button
{...menu}
type="button"
onClick={() => void ipc.openMini().catch(() => {})}
title={`Mini window — floats over everything${shortcut ? ` (${shortcutLabel(shortcut)})` : ""}`}
Expand Down
15 changes: 15 additions & 0 deletions src/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ export interface Change {
}

export const CHANGELOG: Change[] = [
{
version: "0.41.0",
date: "2026-10-18",
title: "Right-click menus",
notes: [
"Right-click a meeting in the library for Open, Rename, Show in File Explorer and Delete; a search result for Open and Show in File Explorer; a tag on a note to find the others or remove it; a section of a note to copy it or fold it away; Mini window in the sidebar to bring it back to its corner.",
"The menu key and Shift+F10 open the same menus, and the arrow keys move through them.",
"The browser's own menu — Back, Refresh, Print — is gone, except in text fields and over selected text, where Copy and Paste still are.",
],
screens: {
library: "Right-click a meeting. Try a retro theme too: the menu wears it.",
"note-enhanced":
"Right-click a section to copy it or collapse it, and a tag to find or remove it.",
},
},
{
version: "0.40.0",
date: "2026-10-18",
Expand Down
90 changes: 90 additions & 0 deletions src/components/ui/ContextMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ContextMenuProvider, useContextMenu } from "./ContextMenu";

function Row({ onRename }: { onRename: () => void }) {
const menu = useContextMenu("Actions for Weekly sync", () => [
{ label: "Open", onSelect: () => {} },
{ label: "Rename", hint: "F2", onSelect: onRename },
"divider",
{ label: "Delete", danger: true, disabled: true, onSelect: () => {} },
]);
return (
<div {...menu} data-testid="row">
Weekly sync
</div>
);
}

function setup() {
const onRename = vi.fn();
render(
<ContextMenuProvider>
<Row onRename={onRename} />
<input aria-label="Search" />
<p>Elsewhere</p>
</ContextMenuProvider>,
);
return { onRename, user: userEvent.setup() };
}

describe("ContextMenu", () => {
it("opens on right-click with the row's actions, and acts on one", async () => {
const { onRename, user } = setup();
expect(screen.queryByRole("menu")).toBeNull();

fireEvent.contextMenu(screen.getByTestId("row"));
expect(screen.getByRole("menu", { name: "Actions for Weekly sync" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeDisabled();

await user.click(screen.getByRole("menuitem", { name: /Rename/ }));
expect(onRename).toHaveBeenCalledOnce();
expect(screen.queryByRole("menu")).toBeNull();
});

it("moves with the arrows, skipping what cannot be chosen, and Escape closes it", async () => {
const { user } = setup();
fireEvent.contextMenu(screen.getByTestId("row"));
expect(screen.getByRole("menuitem", { name: "Open" })).toHaveFocus();

await user.keyboard("{ArrowDown}");
expect(screen.getByRole("menuitem", { name: /Rename/ })).toHaveFocus();
// Delete is disabled, so down again comes round to the top.
await user.keyboard("{ArrowDown}");
expect(screen.getByRole("menuitem", { name: "Open" })).toHaveFocus();

await user.keyboard("{Escape}");
expect(screen.queryByRole("menu")).toBeNull();
});

it("closes on a click outside it", async () => {
const { user } = setup();
fireEvent.contextMenu(screen.getByTestId("row"));
await user.click(screen.getByText("Elsewhere"));
expect(screen.queryByRole("menu")).toBeNull();
});

it("turns off the web page's menu, except in fields", () => {
setup();
// fireEvent returns false when the default was prevented.
expect(fireEvent.contextMenu(screen.getByText("Elsewhere"))).toBe(false);
expect(fireEvent.contextMenu(screen.getByRole("textbox", { name: "Search" }))).toBe(true);
});

it("leaves selected text to the browser, for Copy", () => {
render(
<ContextMenuProvider>
<Row onRename={() => {}} />
</ContextMenuProvider>,
);
const row = screen.getByTestId("row");
const range = document.createRange();
range.selectNodeContents(row);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
expect(fireEvent.contextMenu(row)).toBe(true);
expect(screen.queryByRole("menu")).toBeNull();
window.getSelection()?.removeAllRanges();
});
});
Loading
Loading