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 Sidebar from "./components/layout/Sidebar";
|
||||||
import TopBar from "./components/layout/TopBar";
|
import TopBar from "./components/layout/TopBar";
|
||||||
import StatusBar from "./components/layout/StatusBar";
|
import StatusBar from "./components/layout/StatusBar";
|
||||||
|
import NotesDock from "./components/layout/NotesDock";
|
||||||
import TerminalView from "./components/terminal/TerminalView";
|
import TerminalView from "./components/terminal/TerminalView";
|
||||||
import DockerInstallDialog from "./components/DockerInstallDialog";
|
import DockerInstallDialog from "./components/DockerInstallDialog";
|
||||||
import ProjectHome from "./components/projects/home/ProjectHome";
|
import ProjectHome from "./components/projects/home/ProjectHome";
|
||||||
@@ -161,6 +162,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
<NotesDock />
|
||||||
</div>
|
</div>
|
||||||
<StatusBar stt={stt} />
|
<StatusBar stt={stt} />
|
||||||
<ToastHost />
|
<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) {
|
export default function StatusBar({ stt }: Props) {
|
||||||
const {
|
const {
|
||||||
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||||
terminalAtBottom, scrollActiveToBottom,
|
terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock,
|
||||||
} = useAppState(
|
} = useAppState(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
@@ -20,6 +20,8 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
sttEnabled: s.appSettings?.stt?.enabled,
|
sttEnabled: s.appSettings?.stt?.enabled,
|
||||||
terminalAtBottom: s.terminalAtBottom,
|
terminalAtBottom: s.terminalAtBottom,
|
||||||
scrollActiveToBottom: s.scrollActiveToBottom,
|
scrollActiveToBottom: s.scrollActiveToBottom,
|
||||||
|
notesDockOpen: s.notesDockOpen,
|
||||||
|
toggleNotesDock: s.toggleNotesDock,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
@@ -69,6 +71,14 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
Jump to Current ↓
|
Jump to Current ↓
|
||||||
</button>
|
</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 && (
|
{sttEnabled && activeSessionId && (
|
||||||
<SttButton
|
<SttButton
|
||||||
state={stt.state}
|
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
|
* 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
|
* single ordered strip. Tabs are addressed by a string key so one array can
|
||||||
@@ -127,6 +175,13 @@ interface AppState {
|
|||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
toggleSidebarCollapsed: () => 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;
|
dockerAvailable: boolean | null;
|
||||||
setDockerAvailable: (available: boolean | null) => void;
|
setDockerAvailable: (available: boolean | null) => void;
|
||||||
imageExists: boolean | null;
|
imageExists: boolean | null;
|
||||||
@@ -396,6 +451,23 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
persistSidebarCollapsed(next);
|
persistSidebarCollapsed(next);
|
||||||
return { sidebarCollapsed: 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,
|
dockerAvailable: null,
|
||||||
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
||||||
imageExists: null,
|
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