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
31 changes: 22 additions & 9 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,8 +466,8 @@ so old bookmarks keep working.
list is deterministic too** (PROG-83): pickers and selects (create dialogs,
palette tag picker, palette container quick-jump, filter
dropdowns per PROG-66) list alphabetically by name — except the palette
**move/arc pickers**, which follow this same outline order and hint each row
with its parent container (PROG-123) — tag chips on action
**location picker**, which renders the Workspace → Focus → Arc tree in this
same outline order (PROG-123) — tag chips on action
pages and board/Agenda cards sort alphabetically (shared `tagsByAction`,
`src/client/tags.ts`), and Archive groups sort by name. Board **actions**
keep pure `rank` order — never alphabetized. The scope
Expand Down Expand Up @@ -637,8 +637,12 @@ so old bookmarks keep working.
(`actionAncestors`, `store.ts`) handles unbounded nesting and truncates on a
missing or cyclic parent (PROG-106). Also:
inline-editable title and description, sidebar fields
(status/due-date/priority/estimate in that order, then focus (its optional
gitUrl links out), arc, and tags with picker buttons; a **Work on this**
(status/due-date/priority/estimate in that order, then **Location** — one
field showing the Workspace → Focus → Arc position as a mini-tree, each
line led by its level's glyph (the workspace's doubles as the gutter
button) and linking to its page, opening the location picker (PROG-123b;
the focus's optional gitUrl links out) — and tags
with picker buttons; a **Work on this**
field — D35), a Git section
(linked PRs with state badges, commits with short shas, linking out to
GitHub), and comments + activity interleaved into one timeline. Each
Expand All @@ -655,10 +659,19 @@ so old bookmarks keep working.
actions by key (retired alias keys included) or title and containers by
name, and lists commands (create action/workspace/focus/arc,
pickers for the current action). Picker modes are filterable lists; tag
toggles keep the palette open for multi-edit. The move picker lists focuses
grouped by workspace in outline rank order with the parent workspace as the
row hint (a typed query matches it too); the arc picker lists the focus's
arcs in outline rank order, hinting the parent focus (PROG-123).
toggles keep the palette open for multi-edit. The **location picker**
(PROG-123) owns the action's whole outline position in one surface: it
renders the Workspace → Focus → Arc tree in outline rank order — workspaces
as greyed, inert headers that keyboard selection skips, focuses and arcs
indented beneath, each row led by its level's glyph (the shared
workspace-grid/focus-target/arc marks, `src/client/glyphs.tsx` — the same
iconography as the sidebar Location field). Picking a focus means "this focus, no arc" (there is no
separate "No arc" row); picking an arc sets focus + arc in one step
(same-focus picks are a plain arc update, cross-focus picks ride
`moveAction`, which accepts the landing arc per PROG-118). The current
location hints "current". A typed query matches a row or any ancestor — an
ancestor match keeps its whole subtree visible, and ancestors of a match
stay as context.
- **Create dialogs** — action and container creation; parents/containers
default from the current view (open container page, viewed action's
container, or active board filters). New actions default to **Todo** so
Expand All @@ -674,7 +687,7 @@ so old bookmarks keep working.
| `/` | Search modal (PROG-130) — separate from the palette; title/description hits paint instantly, comment hits stream in |
| `C` | Create action |
| `S` / `P` / `E` | Status / priority / estimate picker for the current action |
| `M` / `A` / `T` | Move / arc / tag picker for the current action |
| `L` / `T` | Location / tag picker for the current action (`L` replaced the pre-PROG-123b `M` move + `A` arc pair) |
| `D` | Due-date picker for the current action (relative quick-picks or a typed `YYYY-MM-DD`; clear) |
| `W` | Work on this — copy the bundle as a prompt or the `progress work` CLI line (D35) |
| `↑↓`, `Enter`, `Esc`, `Backspace` | Navigate / run / close / back-to-root inside the palette |
Expand Down
32 changes: 32 additions & 0 deletions docs/decisions/PROG-123.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,35 @@ Superseding PROG-83 **for these two pickers only**:
Every other picker (tags, create dialogs, filter dropdowns, quick-jump) keeps
PROG-83's alphabetical order — nothing else displays rank, so alpha remains
the scannable choice there.

### PROG-123b — one Location field replaces the Focus + Arc pair

Owner review of PROG-123 (above) pushed further: flat lists with a
parent-name hint were still inconsistent with the app's nested-tree surfaces,
and two separate fields hid a real coupling — setting the focus limits the
available arcs, and a focus change silently clears the arc. Settled:

- **The field is "Location"** (owner-picked over Place/Nest/Context): the
action's whole outline position, Workspace → Focus → Arc. The action page
shows one Location field — focus line (gitUrl beneath), arc nested with an
indent — replacing the separate Focus and Arc fields, so the clearing
coupling is explicit instead of a surprise.
- **One palette picker renders the whole tree** in outline rank order:
workspace rows are greyed, **inert headers** (keyboard selection skips
them; supersedes PROG-123's hint-on-the-right presentation), focuses indent
beneath, arcs beneath those. Picking a focus = "this focus, no arc" — the
old pinned "No arc" row is gone — and picking an arc lands focus + arc in
one step: same-focus picks are a plain `arcId` update, cross-focus picks
ride `moveAction`, which already carries a landing `arcId` (PROG-118). The
current location hints "current" and stays offered (moving within the
focus, or up to "just the focus", is legitimate).
- **Filtering is tree-aware**: a query matches a row or any ancestor; an
ancestor match keeps its subtree, and a match keeps its ancestors visible
as context.
- **The shortcut is `L`** (owner-picked): M (move) and A (arc) are retired
outright rather than aliased — one field, one key; D25's keyboard map
amended in REFERENCE.

Scope: action page + palette only. The create-action dialog keeps its
separate focus/arc selects (and board filters their separate dropdowns — they
filter rather than set) for a follow-up under the consistency arc.
189 changes: 112 additions & 77 deletions src/client/commands/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
// The command palette (SPEC §4): ⌘K jumps to anything (actions by key —
// including retired alias keys — or title, containers by name) and exposes
// commands. The single-key actions (s/p/e/m) open the same palette directly
// commands. The single-key actions (s/p/e/l) open the same palette directly
// in a picker mode scoped to the current action, so there's exactly one
// keyboard-driven surface to learn.

import { useEffect, useMemo, useRef, useState } from "react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "wouter";
import { ACTION_ESTIMATES, ACTION_PRIORITIES, ACTION_STATUSES } from "../../shared/constants";
import type { WireAction, SnapshotPayload } from "../../shared/types";
import { sortByName } from "../boardFilters";
import { byRankThenName, sortContainers } from "../containerReorder";
import { ArcGlyph, FocusGlyph, WorkspaceGlyph } from "../glyphs";
import { addDays, formatDueDate, relativeDue, todayISO } from "../dates";
import { PRIORITY_LABELS, STATUS_LABELS } from "../labels";
import {
Expand All @@ -29,16 +30,25 @@ import {
} from "./controller";

// run() returning "keep" leaves the palette open (used by commands that
// switch it into a picker mode, and by the tag toggles).
type Item = { id: string; label: string; hint?: string; run: () => void | "keep" };
// switch it into a picker mode, and by the tag toggles). `header` rows are
// inert group labels (the workspace level of the location tree, PROG-123b):
// greyed out, skipped by keyboard selection, no run. `indent` nests tree
// levels visually (1 = focus, 2 = arc); `icon` leads the label (the location
// tree carries the shared container glyphs, matching the sidebar field).
type Item = {
id: string;
label: string;
hint?: string;
icon?: ReactNode;
indent?: 1 | 2;
} & ({ header: true; run?: undefined } | { header?: undefined; run: () => void | "keep" });

const MODE_TITLES: Record<Exclude<PaletteMode["kind"], "root">, string> = {
status: "Change status",
priority: "Set priority",
estimate: "Set estimate",
move: "Move to",
location: "Set location",
tag: "Tags",
arc: "Set arc",
due: "Set due date",
workon: "Work on this",
};
Expand All @@ -64,7 +74,10 @@ export default function CommandPalette({ snapshot }: { snapshot: SnapshotPayload
[snapshot, mode, query, navigate],
);

const sel = Math.min(selected, Math.max(items.length - 1, 0));
// Keyboard selection walks only the actionable rows — inert group headers
// (PROG-123b) render in place but can't be landed on.
const selectables = useMemo(() => items.filter((it) => !it.header), [items]);
const sel = Math.min(selected, Math.max(selectables.length - 1, 0));

useEffect(() => {
listRef.current?.querySelector("[data-selected]")?.scrollIntoView({ block: "nearest" });
Expand All @@ -74,7 +87,7 @@ export default function CommandPalette({ snapshot }: { snapshot: SnapshotPayload

const close = () => setMode(null);
const execute = (item: Item) => {
if (item.run() !== "keep") close();
if (item.run && item.run() !== "keep") close();
};

const onKeyDown = (e: React.KeyboardEvent) => {
Expand All @@ -83,13 +96,13 @@ export default function CommandPalette({ snapshot }: { snapshot: SnapshotPayload
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setSelected(Math.min(sel + 1, items.length - 1));
setSelected(Math.min(sel + 1, selectables.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelected(Math.max(sel - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const item = items[sel];
const item = selectables[sel];
if (item) execute(item);
} else if (e.key === "Backspace" && query === "" && mode.kind !== "root") {
e.preventDefault();
Expand Down Expand Up @@ -125,21 +138,37 @@ export default function CommandPalette({ snapshot }: { snapshot: SnapshotPayload
className="w-full border-b border-line px-4 py-3 text-sm focus:outline-none"
/>
<ul ref={listRef} className="max-h-80 overflow-y-auto p-1">
{items.map((item, i) => (
<li key={item.id}>
<button
type="button"
onClick={() => execute(item)}
onMouseMove={() => setSelected(i)}
data-selected={i === sel || undefined}
className="flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm data-selected:bg-line"
>
<span className="truncate">{item.label}</span>
{item.hint && <span className="shrink-0 text-xs text-ink-faint">{item.hint}</span>}
</button>
</li>
))}
{items.length === 0 && (
{items.map((item) =>
item.header ? (
<li key={item.id}>
<div className="flex items-center gap-1.5 py-1.5 pl-3 pr-3 text-sm text-ink-faint">
{item.icon}
<span className="truncate">{item.label}</span>
</div>
</li>
) : (
<li key={item.id}>
<button
type="button"
onClick={() => execute(item)}
onMouseMove={() => setSelected(selectables.indexOf(item))}
data-selected={item === selectables[sel] || undefined}
className={`flex w-full items-center justify-between gap-3 rounded-md py-2 pr-3 text-left text-sm data-selected:bg-line ${
item.indent === 2 ? "pl-11" : item.indent === 1 ? "pl-7" : "pl-3"
}`}
>
<span className="flex min-w-0 items-center gap-1.5">
{item.icon && <span className="shrink-0 text-ink-faint">{item.icon}</span>}
<span className="truncate">{item.label}</span>
</span>
{item.hint && (
<span className="shrink-0 text-xs text-ink-faint">{item.hint}</span>
)}
</button>
</li>
),
)}
{selectables.length === 0 && (
<li className="px-3 py-6 text-center text-sm text-ink-faint">No matches.</li>
)}
</ul>
Expand Down Expand Up @@ -216,56 +245,63 @@ function buildItems(
}
return items;
}
case "arc": {
// Outline order, not alphabetical (PROG-123 supersedes PROG-83 here):
// arcs list in the manual rank set on the outline/structure pages, and
// each row's hint names the parent focus one level up. "No arc" stays
// pinned first.
const parentFocus = ws.focuses.find((p) => p.id === action.focusId);
const focusArcs = ws.arcs
.filter((a) => a.focusId === action.focusId && !a.archivedAt)
.sort(byRankThenName);
// "No arc" filters like any option, so a typed query can't leave it
// sitting first and steal the Enter.
return [
{
id: "arc:none",
label: "No arc",
hint: action.arcId === null ? "current" : undefined,
run: () => void updateAction(action.id, { arcId: null }),
},
...focusArcs.map((a) => ({
id: a.id,
label: a.name,
hint: a.id === action.arcId ? "current" : parentFocus?.name,
run: () => void updateAction(action.id, { arcId: a.id }),
})),
].filter((item) => matches(item.label));
}
case "move": {
// A move now only changes the focus (PROG-102). Archived focuses aren't
// valid destinations (D26); the action's current focus is excluded.
// Structure order, not alphabetical (PROG-123 supersedes PROG-83 here):
// focuses group under their workspace in the outline's manual rank, and
// the hint names the parent workspace. A typed query also matches the
// workspace name, since it's on screen.
const workspaceOrder = new Map(sortContainers(ws.workspaces).map((w, i) => [w.id, i]));
const workspaceName = (focus: { workspaceId: string }) =>
ws.workspaces.find((w) => w.id === focus.workspaceId)?.name;
return ws.focuses
.filter((focus) => !focus.archivedAt && focus.id !== action.focusId)
.sort(
(a, b) =>
(workspaceOrder.get(a.workspaceId) ?? 0) - (workspaceOrder.get(b.workspaceId) ?? 0) ||
byRankThenName(a, b),
)
.filter((focus) => matches(focus.name) || matches(workspaceName(focus) ?? ""))
.map((focus) => ({
id: focus.id,
label: focus.name,
hint: workspaceName(focus) ?? "Focus",
run: () => moveAction(action.id, { focusId: focus.id }),
}));
case "location": {
// One picker owns the whole outline position (PROG-123b, replacing the
// separate move + arc modes): the tree renders Workspace (inert, greyed
// header) → Focus → Arc in the manual rank order set on the
// outline/structure pages (supersedes PROG-83's alphabetical rule
// here). Picking a focus row means "this focus, no arc" — there's no
// separate "No arc" row — and picking an arc lands focus + arc in one
// step (moveAction already carries an arcId, PROG-118). Same-focus
// picks are a plain field update; the current location hints "current".
// Archived containers aren't destinations (D26). A query matches a row
// or any ancestor (an ancestor match keeps its whole subtree), and
// ancestors of a match stay visible as context.
const items: Item[] = [];
for (const workspace of sortContainers(ws.workspaces)) {
const wsMatch = matches(workspace.name);
const group: Item[] = [];
for (const focus of ws.focuses
.filter((p) => p.workspaceId === workspace.id && !p.archivedAt)
.sort(byRankThenName)) {
const focusMatch = wsMatch || matches(focus.name);
const visibleArcs = ws.arcs
.filter((a) => a.focusId === focus.id && !a.archivedAt)
.sort(byRankThenName)
.filter((a) => focusMatch || matches(a.name));
if (!focusMatch && visibleArcs.length === 0) continue;
group.push({
id: focus.id,
label: focus.name,
icon: <FocusGlyph />,
indent: 1,
hint: focus.id === action.focusId && action.arcId === null ? "current" : undefined,
run: () =>
focus.id === action.focusId
? void updateAction(action.id, { arcId: null })
: moveAction(action.id, { focusId: focus.id }),
});
group.push(
...visibleArcs.map((a): Item => ({
id: a.id,
label: a.name,
icon: <ArcGlyph />,
indent: 2,
hint: a.id === action.arcId ? "current" : undefined,
run: () =>
focus.id === action.focusId
? void updateAction(action.id, { arcId: a.id })
: moveAction(action.id, { focusId: focus.id, arcId: a.id }),
})),
);
}
if (group.length > 0)
items.push(
{ id: workspace.id, label: workspace.name, icon: <WorkspaceGlyph />, header: true },
...group,
);
}
return items;
}
case "due": {
const today = todayISO();
Expand Down Expand Up @@ -371,9 +407,8 @@ function rootItems(
picker("status", "S"),
picker("priority", "P"),
picker("estimate", "E"),
picker("move", "M"),
picker("location", "L"),
picker("tag", "T"),
picker("arc", "A"),
picker("due", "D"),
picker("workon", "W"),
);
Expand Down
2 changes: 1 addition & 1 deletion src/client/commands/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
export type PaletteMode =
| { kind: "root"; actionId: string | null }
| {
kind: "status" | "priority" | "estimate" | "move" | "tag" | "arc" | "due" | "workon";
kind: "status" | "priority" | "estimate" | "location" | "tag" | "due" | "workon";
actionId: string;
};

Expand Down
6 changes: 3 additions & 3 deletions src/client/commands/useGlobalKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// ⌘K / Ctrl+K command palette
// / search (PROG-130 — title/description instantly, comments stream)
// C create action (container defaults from the current view)
// S / P / E / M / T / A status / priority / estimate / move / tag / arc
// S / P / E / L / T status / priority / estimate / location / tag
// (L replaced the pre-PROG-123b M move + A arc pair)
// D due date (quick relative options or a typed YYYY-MM-DD)
// W work on this (copy bundle as prompt / CLI one-liner)
// picker for the current action (action page, or the
Expand All @@ -17,9 +18,8 @@ const PICKER_KEYS = {
s: "status",
p: "priority",
e: "estimate",
m: "move",
l: "location",
t: "tag",
a: "arc",
d: "due",
w: "workon",
} as const;
Expand Down
Loading