diff --git a/app/src/App.tsx b/app/src/App.tsx
index c345a8c..bfebfe4 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -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() {
)}
+
diff --git a/app/src/components/layout/NotesDock.test.tsx b/app/src/components/layout/NotesDock.test.tsx
new file mode 100644
index 0000000..d825eb3
--- /dev/null
+++ b/app/src/components/layout/NotesDock.test.tsx
@@ -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 }) => (
+
{`panel:${projectId}`}
+ ),
+}));
+
+let state: Record = {};
+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();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("follows a project home tab", () => {
+ state.activeTabKey = "home:p1";
+ render();
+ 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();
+ expect(screen.getByTestId("panel")).toHaveTextContent("panel:p9");
+ });
+
+ it("explains itself when no project is active", () => {
+ state.activeTabKey = null;
+ render();
+ 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();
+ expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
+ });
+
+ it("renders at the stored width", () => {
+ state.activeTabKey = "home:p1";
+ state.notesDockWidth = 420;
+ render();
+ 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();
+ const handle = screen.getByRole("separator", { name: /resize notes/i });
+ fireEvent.keyDown(handle, { key: "ArrowLeft" });
+ expect(state.setNotesDockWidth).toHaveBeenCalled();
+ });
+});
diff --git a/app/src/components/layout/NotesDock.tsx b/app/src/components/layout/NotesDock.tsx
new file mode 100644
index 0000000..3ce4916
--- /dev/null
+++ b/app/src/components/layout/NotesDock.tsx
@@ -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) => {
+ 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) => {
+ 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 (
+
+ );
+}
diff --git a/app/src/components/layout/StatusBar.tsx b/app/src/components/layout/StatusBar.tsx
index 4b10d86..b6748f1 100644
--- a/app/src/components/layout/StatusBar.tsx
+++ b/app/src/components/layout/StatusBar.tsx
@@ -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 ↓
)}
+
{sttEnabled && activeSessionId && (
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((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,
diff --git a/app/src/store/dockWidth.test.ts b/app/src/store/dockWidth.test.ts
new file mode 100644
index 0000000..9bb0361
--- /dev/null
+++ b/app/src/store/dockWidth.test.ts
@@ -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);
+ });
+});