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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ not an exhaustive list; the in-app **What's New** is the complete, current recor
own working hours — with schedule-aware chat answers and an automatic "Due soon" when an
event names a project. Your milestones and pinboard timelines show alongside as markers you
can toggle off. PM only reads your calendars unless you turn on editing for a Google account;
then you can create events on its calendars (+ New event, or press c), and change and delete
events you organise without guests, repeating ones included. Every change goes straight to
then you can create events on its calendars (+ New event, or press c; Shift+C opens a quick box,
and in the day, week, month and agenda views the new event shows as you type, except in the
Terminal look), and change and
delete events you organise without guests, repeating ones included. Every change goes straight to
Google, a save never overwrites a field someone changed there in the meantime, and a new
event whose save loses the connection is looked for in Google before it's ever sent again.
- **Bring your own model.** Choose your model through OpenRouter — separate chat and
Expand Down
2 changes: 1 addition & 1 deletion design-system-docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ Drive it with a single `depth` value; gate optional blocks on it. Don't fork lay
Slate = cards; Terminal = table. Depth scales it from name+status only → +meta → full table with cost/active.
- **Project** — file list beside a project-scoped chat thread (two-pane). Treatment per System.
- **Documents** — ingestion: a drop zone + recent items with type/date/index-state. Rules / cards / mono table per System.
- **Calendar** — week/agenda; "+ New event" (or `c`) creates a Google event, and one you organise can be edited from its details. Serif agenda / day cards / mono list per System.
- **Calendar** — week/agenda; "+ New event" (or `c`) creates a Google event, and one you organise can be edited from its details. Shift+C opens quick create, a small non-modal box under the button whose draft is drawn as a dashed ghost (`DRAFT_EVENT_CLASS`: `outline-ink3`, dashed inside the edge with `-outline-offset-1` so a clipping list can't cut it, never greyed as past; a dashed ring among Minimal's month dots) in the day, week, month and agenda views, except in the Terminal look, which draws only what's in the calendar; its accessible name starts with what it is (`draftNote`). Serif agenda / day cards / mono list per System.
- **Review** — queue of proposed actions as inline approval cards (Approve / Edit / Dismiss).
- **Settings** — appearance (System / Accent / Depth / Mode), models & keys (key field → keychain), help-mode toggle. Editorial = ruled sections, Slate = card panel, Terminal = key/value list.
- **Command palette** (`⌘K`) — overlay with a query line + result rows.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "pm",
"private": true,
"version": "3.150.0-alpha",
"version": "3.151.0-alpha",
"license": "AGPL-3.0-or-later",
"type": "module",
"engines": {
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 = "pm"
version = "3.150.0-alpha"
version = "3.151.0-alpha"
description = "PM — a local-first personal manager (Archivist + Personal Assistant)"
authors = ["Bobby Yu"]
edition = "2021"
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": "PM",
"version": "3.150.0-alpha",
"version": "3.151.0-alpha",
"identifier": "org.itsatlas.pm",
"build": {
"beforeDevCommand": "npm run dev",
Expand Down
55 changes: 54 additions & 1 deletion src/components/ModelPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

// ModelPicker is a hand-rolled menu inside the Settings dialog (Settings › AI › "Add a model…"). Its
// Escape listener sits on `document` and the dialog's on `window`, so without stopping the key one
// Escape closed the menu AND Settings (or raised the unsaved-changes guard).
// Escape closed the menu AND Settings (or raised the unsaved-changes guard). It follows the Popover
// primitive's rule: a modal dialog over it owns the key, a non-modal layer only a key pressed in it.

import { useRef } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useDialogLayer } from "../lib/useDialogLayer";

vi.mock("../lib/ipc", () => ({
listModels: () => Promise.resolve([]),
Expand Down Expand Up @@ -57,4 +60,54 @@ describe("ModelPicker inside a dialog", () => {
fireEvent.keyDown(screen.getByText("Add a model…"), { key: "Escape" });
expect(onDialogClose).toHaveBeenCalledTimes(1);
});

// The calendar's quick create stays open through a click in the sidebar, so it can sit behind
// Settings: a non-modal layer, which owns only a key pressed inside it.
it("closes only itself on Escape beside a non-modal layer", async () => {
function Layer() {
const ref = useRef<HTMLDivElement>(null);
useDialogLayer(true, ref);
return <div ref={ref} role="dialog" aria-label="New event" />;
}
const onDialogClose = vi.fn();
render(
<>
<Layer />
<Modal open onClose={onDialogClose} label="Settings">
<ModelPicker value="" onChange={() => {}} triggerLabel="Add a model…" />
</Modal>
</>,
);
fireEvent.click(screen.getByText("Add a model…"));
const search = await screen.findByPlaceholderText("Search models…");
fireEvent.keyDown(search, { key: "Escape" });
await waitFor(() => expect(screen.queryByPlaceholderText("Search models…")).toBeNull());
expect(onDialogClose).not.toHaveBeenCalled();
fireEvent.keyDown(screen.getByText("Add a model…"), { key: "Escape" });
expect(onDialogClose).toHaveBeenCalledTimes(1);
});

// A modal dialog opened over the open menu (one of Settings' own questions) holds the keyboard.
it("leaves Escape to a modal dialog opened over it", async () => {
const onDialogClose = vi.fn();
const onQuestionClose = vi.fn();
const page = (asking: boolean) => (
<Modal open onClose={onDialogClose} label="Settings">
<ModelPicker value="" onChange={() => {}} triggerLabel="Add a model…" />
{asking && (
<Modal open onClose={onQuestionClose} label="Reset these settings?">
<button type="button">Reset</button>
</Modal>
)}
</Modal>
);
const { rerender } = render(page(false));
fireEvent.click(screen.getByText("Add a model…"));
await screen.findByPlaceholderText("Search models…");
rerender(page(true));
fireEvent.keyDown(screen.getByText("Reset"), { key: "Escape" });
expect(onQuestionClose).toHaveBeenCalledTimes(1);
expect(screen.queryByPlaceholderText("Search models…")).not.toBeNull();
expect(onDialogClose).not.toHaveBeenCalled();
});
});
10 changes: 6 additions & 4 deletions src/components/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { useEffect, useMemo, useRef, useState } from "react";
import { listModels } from "../lib/ipc";
import { isCoveredByDialog } from "../lib/useDialogLayer";
import { isKeyForDialogOver } from "../lib/useDialogLayer";
import type { ModelInfo } from "../lib/types";
import { Button, Input, Skeleton } from "./ui";

Expand Down Expand Up @@ -52,9 +52,11 @@ export function ModelPicker({ value, onChange, triggerLabel = "Choose a model…
};
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
// Same rule as the Popover primitive: a dialog opened over this menu owns the key; otherwise
// the menu closes and the key stops here, so the Settings dialog around it stays open.
if (isCoveredByDialog(rootRef.current)) return;
// Same rule as the Popover primitive: a modal dialog opened over this menu owns the key, and a
// non-modal layer beside it (the calendar's quick create, left open behind Settings) only a key
// pressed inside it; otherwise the menu closes and the key stops here, so the Settings dialog
// around it stays open.
if (isKeyForDialogOver(rootRef.current, e.target)) return;
e.stopPropagation();
setOpen(false);
};
Expand Down
19 changes: 16 additions & 3 deletions src/components/calendar/CalendarHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// account is connected, and when no calendar can take an event it stays focusable and says why
// rather than going grey and silent.

import { createRef, type RefObject } from "react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

Expand All @@ -21,11 +22,17 @@ afterEach(cleanup);

const REASON = "None of your calendars can take new events.";

function header(over: { onCreate?: () => void; createReason?: string | null } = {}) {
interface Over {
onCreate?: () => void;
createReason?: string | null;
createRef?: RefObject<HTMLButtonElement | null>;
}

function header(over: Over = {}) {
return render(element(over));
}

function element(over: { onCreate?: () => void; createReason?: string | null } = {}) {
function element(over: Over = {}) {
return (
<CalendarHeader
view="week"
Expand Down Expand Up @@ -80,7 +87,7 @@ describe("CalendarHeader", () => {
expect(screen.queryByRole("status")).toBeNull();
});

// One primary button on screen: the editor's Save (C1-L5).
// One primary button on screen: the editor's or quick create's Save (C1-L5).
it("New event is a secondary button", () => {
for (const createReason of [null, REASON]) {
header({ onCreate: vi.fn(), createReason });
Expand Down Expand Up @@ -128,6 +135,12 @@ describe("CalendarHeader", () => {
expect(screen.getByRole("status").textContent).toBe("");
});

it("hands quick create the New event button, to open under and give focus back to", () => {
const button = createRef<HTMLButtonElement>();
header({ onCreate: vi.fn(), createReason: null, createRef: button });
expect(button.current).toBe(screen.getByRole("button", { name: "+ New event" }));
});

it("has no New event without a Google account", () => {
header();
expect(screen.queryByRole("button", { name: "+ New event" })).toBeNull();
Expand Down
17 changes: 11 additions & 6 deletions src/components/calendar/CalendarHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
// date-range label, the view switcher (active = accent), and Refresh-now.
//
// "+ New event" (#884, C10) is the one control here that changes a calendar: it opens the editor for
// a new Google event, as `c` does, and sits first, where Google's Create does. It shows once a Google
// account is connected; when no calendar can take an event it stays focusable and says why (its
// tooltip, and a click), rather than going grey and silent. It's secondary, so the editor's Save stays
// the one primary button on screen. Everything else here only changes what you see.
// a new Google event, as `c` does, and sits first, where Google's Create does; Shift+C's quick create
// opens under it (`createRef`). It shows once a Google account is connected; when no calendar can
// take an event it stays focusable and says why (its tooltip, and a click), rather than going grey
// and silent. It's secondary, so the editor's or quick create's Save stays the one primary button on
// screen. Everything else here only changes what you see.
//
// The last-synced time rides ON the Refresh button rather than beside it: it is the one fact that
// tells you whether pressing Refresh is worth it.

import { useEffect, useId, useState } from "react";
import { useEffect, useId, useState, type Ref } from "react";
import {
DAY_COUNT_MAX,
DAY_COUNT_MIN,
Expand Down Expand Up @@ -57,6 +58,8 @@ interface Props {
onCreate?: () => void;
/** Why no new event can be made now, or null when one can. */
createReason?: string | null;
/** The "+ New event" button: quick create opens under it, and gives focus back to it. */
createRef?: Ref<HTMLButtonElement>;
}

/** How long a click's "why not" stays beside the button. */
Expand Down Expand Up @@ -92,6 +95,7 @@ export function CalendarHeader({
lastSync,
onCreate,
createReason = null,
createRef,
}: Props) {
const { showMeta } = useDepth();
const { system } = useTheme();
Expand Down Expand Up @@ -126,8 +130,9 @@ export function CalendarHeader({
>
{onCreate && (
<div className="flex items-center gap-2">
<Tooltip label={createReason ?? "New event · c"}>
<Tooltip label={createReason ?? "New event · c, or Shift+C for a quick one"}>
<Button
ref={createRef}
variant="secondary"
size="sm"
aria-disabled={createReason !== null || undefined}
Expand Down
Loading
Loading