Add the notes dock
This commit is contained in:
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import Sidebar from "./components/layout/Sidebar";
|
||||
import TopBar from "./components/layout/TopBar";
|
||||
import StatusBar from "./components/layout/StatusBar";
|
||||
import NotesDock from "./components/layout/NotesDock";
|
||||
import TerminalView from "./components/terminal/TerminalView";
|
||||
import DockerInstallDialog from "./components/DockerInstallDialog";
|
||||
import ProjectHome from "./components/projects/home/ProjectHome";
|
||||
@@ -161,6 +162,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<NotesDock />
|
||||
</div>
|
||||
<StatusBar stt={stt} />
|
||||
<ToastHost />
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import NotesDock from "./NotesDock";
|
||||
import type { Project, TerminalSession } from "../../lib/types";
|
||||
|
||||
vi.mock("../notes/NotesPanel", () => ({
|
||||
default: ({ projectId }: { projectId: string }) => (
|
||||
<div data-testid="panel">{`panel:${projectId}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
let state: Record<string, unknown> = {};
|
||||
vi.mock("../../store/appState", () => ({
|
||||
useAppState: Object.assign(
|
||||
(selector: (s: unknown) => unknown) => selector(state),
|
||||
{ getState: () => state },
|
||||
),
|
||||
isHomeTab: (k: string) => k.startsWith("home:"),
|
||||
isTerminalTab: (k: string) => k.startsWith("term:"),
|
||||
tabKeyId: (k: string) => k.slice(k.indexOf(":") + 1),
|
||||
// The mocked store module still needs to supply the width constants the
|
||||
// dock imports from it for the separator's aria-value attributes.
|
||||
NOTES_DOCK_MIN_WIDTH: 260,
|
||||
NOTES_DOCK_MAX_WIDTH: 720,
|
||||
}));
|
||||
|
||||
const session: TerminalSession = {
|
||||
id: "s1",
|
||||
projectId: "p9",
|
||||
projectName: "api",
|
||||
sessionType: "claude",
|
||||
sessionName: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state = {
|
||||
notesDockOpen: true,
|
||||
setNotesDockOpen: vi.fn(),
|
||||
toggleNotesDock: vi.fn(),
|
||||
notesDockWidth: 352,
|
||||
setNotesDockWidth: vi.fn(),
|
||||
activeTabKey: null,
|
||||
sessions: [session],
|
||||
projects: [{ id: "p9", name: "api" } as unknown as Project],
|
||||
};
|
||||
});
|
||||
|
||||
describe("NotesDock", () => {
|
||||
it("renders nothing when closed", () => {
|
||||
state.notesDockOpen = false;
|
||||
const { container } = render(<NotesDock />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("follows a project home tab", () => {
|
||||
state.activeTabKey = "home:p1";
|
||||
render(<NotesDock />);
|
||||
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p1");
|
||||
});
|
||||
|
||||
it("follows the project of the active terminal tab", () => {
|
||||
// The dock exists to be visible while the agent runs, so a terminal tab
|
||||
// must resolve to its project, not to nothing.
|
||||
state.activeTabKey = "term:s1";
|
||||
render(<NotesDock />);
|
||||
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p9");
|
||||
});
|
||||
|
||||
it("explains itself when no project is active", () => {
|
||||
state.activeTabKey = null;
|
||||
render(<NotesDock />);
|
||||
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/open a project/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows nothing for a terminal whose session has gone", () => {
|
||||
state.activeTabKey = "term:vanished";
|
||||
render(<NotesDock />);
|
||||
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders at the stored width", () => {
|
||||
state.activeTabKey = "home:p1";
|
||||
state.notesDockWidth = 420;
|
||||
render(<NotesDock />);
|
||||
expect(screen.getByLabelText("Notes")).toHaveStyle({ width: "420px" });
|
||||
});
|
||||
|
||||
it("has a keyboard-reachable resize handle", () => {
|
||||
// Drag is a mouse gesture; a separator that only responds to pointer
|
||||
// events is unusable without one.
|
||||
state.activeTabKey = "home:p1";
|
||||
render(<NotesDock />);
|
||||
const handle = screen.getByRole("separator", { name: /resize notes/i });
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
expect(state.setNotesDockWidth).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import {
|
||||
useAppState,
|
||||
isHomeTab,
|
||||
isTerminalTab,
|
||||
tabKeyId,
|
||||
NOTES_DOCK_MIN_WIDTH,
|
||||
NOTES_DOCK_MAX_WIDTH,
|
||||
} from "../../store/appState";
|
||||
import NotesPanel from "../notes/NotesPanel";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
/**
|
||||
* Notes beside whatever is on screen.
|
||||
*
|
||||
* Project Home and Terminal are sibling top-level tabs, so notes living only
|
||||
* in a sub-tab would be hidden exactly when the agent is running — which is
|
||||
* when a note is worth sending. The dock is the answer to that.
|
||||
*
|
||||
* **It takes space from inside the window and never resizes it.** Growing the
|
||||
* OS window was tried and rejected on evidence: honoured under XWayland,
|
||||
* silently corrupting under native Wayland, where `outer_position()` returns a
|
||||
* confident `Ok(0,0)` for a window that is somewhere else. See the design doc,
|
||||
* §6.1. Narrowing the terminal instead costs nothing — `TerminalView`'s
|
||||
* ResizeObserver already reflows xterm and resizes the container PTY.
|
||||
*/
|
||||
export default function NotesDock() {
|
||||
const {
|
||||
notesDockOpen,
|
||||
setNotesDockOpen,
|
||||
notesDockWidth,
|
||||
setNotesDockWidth,
|
||||
activeTabKey,
|
||||
sessions,
|
||||
} = useAppState(
|
||||
useShallow((s) => ({
|
||||
notesDockOpen: s.notesDockOpen,
|
||||
setNotesDockOpen: s.setNotesDockOpen,
|
||||
notesDockWidth: s.notesDockWidth,
|
||||
setNotesDockWidth: s.setNotesDockWidth,
|
||||
activeTabKey: s.activeTabKey,
|
||||
sessions: s.sessions,
|
||||
})),
|
||||
);
|
||||
|
||||
// Dragging the separator. Pointer capture rather than window listeners, so
|
||||
// the drag survives the pointer crossing the terminal — which swallows
|
||||
// events — and ends correctly if the button is released outside the window.
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const handle = e.currentTarget;
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
const startX = e.clientX;
|
||||
const startWidth = notesDockWidth;
|
||||
// The dock is on the right, so dragging left widens it.
|
||||
const onMove = (move: PointerEvent) =>
|
||||
setNotesDockWidth(startWidth + (startX - move.clientX));
|
||||
const onUp = () => {
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
handle.removeEventListener("pointermove", onMove);
|
||||
handle.removeEventListener("pointerup", onUp);
|
||||
};
|
||||
handle.addEventListener("pointermove", onMove);
|
||||
handle.addEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
const onHandleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const step = e.shiftKey ? 64 : 16;
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
setNotesDockWidth(notesDockWidth + step);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
setNotesDockWidth(notesDockWidth - step);
|
||||
}
|
||||
};
|
||||
|
||||
if (!notesDockOpen) return null;
|
||||
|
||||
// Follow whatever is in front: a home tab is its own project, a terminal tab
|
||||
// is the project it belongs to.
|
||||
let projectId: string | null = null;
|
||||
if (activeTabKey && isHomeTab(activeTabKey)) {
|
||||
projectId = tabKeyId(activeTabKey);
|
||||
} else if (activeTabKey && isTerminalTab(activeTabKey)) {
|
||||
projectId =
|
||||
sessions.find((s) => s.id === tabKeyId(activeTabKey))?.projectId ?? null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Notes"
|
||||
style={{ width: `${notesDockWidth}px` }}
|
||||
className="relative flex-shrink-0 flex flex-col min-h-0 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden"
|
||||
>
|
||||
{/* Separator, not decoration: it carries a role and arrow keys, because
|
||||
a resize that only answers to a drag is unavailable to anyone not
|
||||
using a mouse. */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize notes panel"
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={notesDockWidth}
|
||||
aria-valuemin={NOTES_DOCK_MIN_WIDTH}
|
||||
aria-valuemax={NOTES_DOCK_MAX_WIDTH}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onHandleKeyDown}
|
||||
className="absolute left-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-[var(--accent-muted)] transition-colors"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 px-3 h-9 flex-shrink-0 border-b border-[var(--border-color)]">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">Notes</h2>
|
||||
<Button variant="ghost" onClick={() => setNotesDockOpen(false)} aria-label="Close notes">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
{projectId ? (
|
||||
<NotesPanel projectId={projectId} />
|
||||
) : (
|
||||
<p className="p-4 text-[13px] text-[var(--text-secondary)]">
|
||||
Open a project or a terminal to see its notes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
export default function StatusBar({ stt }: Props) {
|
||||
const {
|
||||
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||
terminalAtBottom, scrollActiveToBottom,
|
||||
terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock,
|
||||
} = useAppState(
|
||||
useShallow(s => ({
|
||||
projects: s.projects,
|
||||
@@ -20,6 +20,8 @@ export default function StatusBar({ stt }: Props) {
|
||||
sttEnabled: s.appSettings?.stt?.enabled,
|
||||
terminalAtBottom: s.terminalAtBottom,
|
||||
scrollActiveToBottom: s.scrollActiveToBottom,
|
||||
notesDockOpen: s.notesDockOpen,
|
||||
toggleNotesDock: s.toggleNotesDock,
|
||||
}))
|
||||
);
|
||||
const running = projects.filter((p) => p.status === "running").length;
|
||||
@@ -69,6 +71,14 @@ export default function StatusBar({ stt }: Props) {
|
||||
Jump to Current ↓
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleNotesDock}
|
||||
aria-pressed={notesDockOpen}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
||||
title="Show or hide the notes panel beside the current tab"
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
{sttEnabled && activeSessionId && (
|
||||
<SttButton
|
||||
state={stt.state}
|
||||
|
||||
@@ -19,6 +19,54 @@ function persistSidebarCollapsed(value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
const NOTES_DOCK_KEY = "triple-c.notes.dock";
|
||||
const NOTES_DOCK_WIDTH_KEY = "triple-c.notes.dock.width";
|
||||
|
||||
/** Wide enough for a note, narrow enough to leave a usable terminal. */
|
||||
export const NOTES_DOCK_MIN_WIDTH = 260;
|
||||
export const NOTES_DOCK_MAX_WIDTH = 720;
|
||||
export const NOTES_DOCK_DEFAULT_WIDTH = 352;
|
||||
|
||||
function loadNotesDockOpen(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(NOTES_DOCK_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function persistNotesDockOpen(value: boolean) {
|
||||
try {
|
||||
localStorage.setItem(NOTES_DOCK_KEY, value ? "1" : "0");
|
||||
} catch {
|
||||
// ignore — storage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamped on the way in as well as out: a stored value can be anything a
|
||||
* previous version, a hand edit, or a different screen left behind. */
|
||||
export function clampDockWidth(value: number): number {
|
||||
if (!Number.isFinite(value)) return NOTES_DOCK_DEFAULT_WIDTH;
|
||||
return Math.min(NOTES_DOCK_MAX_WIDTH, Math.max(NOTES_DOCK_MIN_WIDTH, Math.round(value)));
|
||||
}
|
||||
|
||||
function loadNotesDockWidth(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(NOTES_DOCK_WIDTH_KEY);
|
||||
return raw === null ? NOTES_DOCK_DEFAULT_WIDTH : clampDockWidth(Number(raw));
|
||||
} catch {
|
||||
return NOTES_DOCK_DEFAULT_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
function persistNotesDockWidth(value: number) {
|
||||
try {
|
||||
localStorage.setItem(NOTES_DOCK_WIDTH_KEY, String(value));
|
||||
} catch {
|
||||
// ignore — storage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main area hosts two tab kinds — terminals and Project Home views — in a
|
||||
* single ordered strip. Tabs are addressed by a string key so one array can
|
||||
@@ -127,6 +175,13 @@ interface AppState {
|
||||
sidebarCollapsed: boolean;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
toggleSidebarCollapsed: () => void;
|
||||
/** The notes dock, visible over any tab including a terminal. */
|
||||
notesDockOpen: boolean;
|
||||
setNotesDockOpen: (open: boolean) => void;
|
||||
toggleNotesDock: () => void;
|
||||
/** Dock width in CSS px, clamped and persisted per machine. */
|
||||
notesDockWidth: number;
|
||||
setNotesDockWidth: (width: number) => void;
|
||||
dockerAvailable: boolean | null;
|
||||
setDockerAvailable: (available: boolean | null) => void;
|
||||
imageExists: boolean | null;
|
||||
@@ -396,6 +451,23 @@ export const useAppState = create<AppState>((set) => ({
|
||||
persistSidebarCollapsed(next);
|
||||
return { sidebarCollapsed: next };
|
||||
}),
|
||||
notesDockOpen: loadNotesDockOpen(),
|
||||
setNotesDockOpen: (open) => {
|
||||
persistNotesDockOpen(open);
|
||||
set({ notesDockOpen: open });
|
||||
},
|
||||
toggleNotesDock: () =>
|
||||
set((state) => {
|
||||
const open = !state.notesDockOpen;
|
||||
persistNotesDockOpen(open);
|
||||
return { notesDockOpen: open };
|
||||
}),
|
||||
notesDockWidth: loadNotesDockWidth(),
|
||||
setNotesDockWidth: (width) => {
|
||||
const clamped = clampDockWidth(width);
|
||||
persistNotesDockWidth(clamped);
|
||||
set({ notesDockWidth: clamped });
|
||||
},
|
||||
dockerAvailable: null,
|
||||
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
||||
imageExists: null,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
clampDockWidth,
|
||||
NOTES_DOCK_MIN_WIDTH,
|
||||
NOTES_DOCK_MAX_WIDTH,
|
||||
NOTES_DOCK_DEFAULT_WIDTH,
|
||||
} from "./appState";
|
||||
|
||||
describe("clampDockWidth", () => {
|
||||
it("keeps a sensible width", () => {
|
||||
expect(clampDockWidth(400)).toBe(400);
|
||||
});
|
||||
|
||||
it("refuses to squeeze the dock into uselessness", () => {
|
||||
expect(clampDockWidth(10)).toBe(NOTES_DOCK_MIN_WIDTH);
|
||||
});
|
||||
|
||||
it("refuses to squeeze the terminal into uselessness", () => {
|
||||
expect(clampDockWidth(5000)).toBe(NOTES_DOCK_MAX_WIDTH);
|
||||
});
|
||||
|
||||
it("falls back for a stored value that is not a number", () => {
|
||||
// localStorage holds strings and can carry anything a previous version,
|
||||
// a hand edit, or a different screen left behind.
|
||||
expect(clampDockWidth(Number("banana"))).toBe(NOTES_DOCK_DEFAULT_WIDTH);
|
||||
});
|
||||
|
||||
it("rounds, because a fractional px width blurs the border", () => {
|
||||
expect(clampDockWidth(400.6)).toBe(401);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user