tuiboard 0.6.2 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tuiboard/config.example.yaml +14 -0
- package/CHANGELOG.md +98 -0
- package/README.md +53 -15
- package/package.json +2 -1
- package/src/app.tsx +24 -19
- package/src/config/loader.ts +51 -0
- package/src/input/handleKey.ts +23 -22
- package/src/store/index.test.ts +76 -10
- package/src/store/index.ts +112 -19
- package/src/store/{virtual-panel.ts → planner-panel.ts} +0 -0
- package/src/ui/AgentsBar.tsx +1 -1
- package/src/ui/BoardView.tsx +31 -18
- package/src/ui/Modal.tsx +38 -46
- package/src/ui/{VirtualPanel.tsx → PlannerPanel.tsx} +13 -13
- package/src/ui/TimelineView.tsx +1 -1
- package/src/ui/glyphs.ts +2 -2
- package/src/views/BoardOnly.tsx +5 -5
- package/src/views/Dashboard.tsx +15 -10
package/src/store/index.test.ts
CHANGED
|
@@ -10,7 +10,7 @@ describe("test runner smoke", () => {
|
|
|
10
10
|
});
|
|
11
11
|
|
|
12
12
|
/** Builds a minimal config with no boards — enough to exercise pure UI actions. */
|
|
13
|
-
function emptyConfig(): Config {
|
|
13
|
+
function emptyConfig(overrides: Partial<Config> = {}): Config {
|
|
14
14
|
return {
|
|
15
15
|
root: process.cwd(),
|
|
16
16
|
loaded: false,
|
|
@@ -18,6 +18,8 @@ function emptyConfig(): Config {
|
|
|
18
18
|
assignees: [],
|
|
19
19
|
doneColumn: "Done",
|
|
20
20
|
archiveColumn: "Archive",
|
|
21
|
+
zones: { planner: "on", agenda: "on", agents: "on" },
|
|
22
|
+
...overrides,
|
|
21
23
|
};
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -29,16 +31,16 @@ describe("UI activeZone", () => {
|
|
|
29
31
|
|
|
30
32
|
it("setActiveZone updates the zone", () => {
|
|
31
33
|
const store = createTuiStore({ config: emptyConfig() });
|
|
32
|
-
store.setActiveZone("
|
|
33
|
-
expect(store.state.ui.activeZone).toBe("
|
|
34
|
+
store.setActiveZone("planner");
|
|
35
|
+
expect(store.state.ui.activeZone).toBe("planner");
|
|
34
36
|
store.setActiveZone("timeline");
|
|
35
37
|
expect(store.state.ui.activeZone).toBe("timeline");
|
|
36
38
|
});
|
|
37
39
|
|
|
38
|
-
it("setActiveZone('
|
|
40
|
+
it("setActiveZone('planner') resets row to 0", () => {
|
|
39
41
|
const store = createTuiStore({ config: emptyConfig() });
|
|
40
42
|
store.setCursor(0, 7);
|
|
41
|
-
store.setActiveZone("
|
|
43
|
+
store.setActiveZone("planner");
|
|
42
44
|
expect(store.state.ui.row).toBe(0);
|
|
43
45
|
});
|
|
44
46
|
});
|
|
@@ -47,7 +49,7 @@ describe("UI visibleZones", () => {
|
|
|
47
49
|
it("defaults to all four zones visible", () => {
|
|
48
50
|
const store = createTuiStore({ config: emptyConfig() });
|
|
49
51
|
expect(store.state.ui.visibleZones).toEqual({
|
|
50
|
-
|
|
52
|
+
planner: true,
|
|
51
53
|
board: true,
|
|
52
54
|
timeline: true,
|
|
53
55
|
agents: true,
|
|
@@ -58,7 +60,7 @@ describe("UI visibleZones", () => {
|
|
|
58
60
|
const store = createTuiStore({ config: emptyConfig() });
|
|
59
61
|
store.setZoneVisible("timeline", false);
|
|
60
62
|
expect(store.state.ui.visibleZones.timeline).toBe(false);
|
|
61
|
-
expect(store.state.ui.visibleZones.
|
|
63
|
+
expect(store.state.ui.visibleZones.planner).toBe(true);
|
|
62
64
|
expect(store.state.ui.visibleZones.board).toBe(true);
|
|
63
65
|
expect(store.state.ui.visibleZones.agents).toBe(true);
|
|
64
66
|
});
|
|
@@ -80,7 +82,7 @@ describe("UI visibleZones", () => {
|
|
|
80
82
|
describe("UI cycleActiveZone", () => {
|
|
81
83
|
it("cycles through all visible zones in fixed order", () => {
|
|
82
84
|
const store = createTuiStore({ config: emptyConfig() });
|
|
83
|
-
store.setActiveZone("
|
|
85
|
+
store.setActiveZone("planner");
|
|
84
86
|
store.cycleActiveZone();
|
|
85
87
|
expect(store.state.ui.activeZone).toBe("board");
|
|
86
88
|
store.cycleActiveZone();
|
|
@@ -88,7 +90,7 @@ describe("UI cycleActiveZone", () => {
|
|
|
88
90
|
store.cycleActiveZone();
|
|
89
91
|
expect(store.state.ui.activeZone).toBe("agents");
|
|
90
92
|
store.cycleActiveZone();
|
|
91
|
-
expect(store.state.ui.activeZone).toBe("
|
|
93
|
+
expect(store.state.ui.activeZone).toBe("planner"); // wrap
|
|
92
94
|
});
|
|
93
95
|
|
|
94
96
|
it("skips hidden zones", () => {
|
|
@@ -101,7 +103,7 @@ describe("UI cycleActiveZone", () => {
|
|
|
101
103
|
|
|
102
104
|
it("is a no-op when only one zone is visible (board only)", () => {
|
|
103
105
|
const store = createTuiStore({ config: emptyConfig() });
|
|
104
|
-
store.setZoneVisible("
|
|
106
|
+
store.setZoneVisible("planner", false);
|
|
105
107
|
store.setZoneVisible("timeline", false);
|
|
106
108
|
store.setZoneVisible("agents", false);
|
|
107
109
|
store.cycleActiveZone();
|
|
@@ -156,3 +158,67 @@ describe("Agenda day navigation", () => {
|
|
|
156
158
|
expect(store.state.ui.row).toBe(0);
|
|
157
159
|
});
|
|
158
160
|
});
|
|
161
|
+
|
|
162
|
+
describe("zones config", () => {
|
|
163
|
+
it("enables and shows all zones by default", () => {
|
|
164
|
+
const s = createTuiStore({ config: emptyConfig() });
|
|
165
|
+
expect(s.state.ui.enabledZones).toEqual({ board: true, planner: true, timeline: true, agents: true });
|
|
166
|
+
expect(s.state.ui.visibleZones).toEqual({ board: true, planner: true, timeline: true, agents: true });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("disables a zone set to off (never enabled, never visible)", () => {
|
|
170
|
+
const s = createTuiStore({
|
|
171
|
+
config: emptyConfig({ zones: { planner: "on", agenda: "off", agents: "off" } }),
|
|
172
|
+
});
|
|
173
|
+
expect(s.state.ui.enabledZones.timeline).toBe(false);
|
|
174
|
+
expect(s.state.ui.enabledZones.agents).toBe(false);
|
|
175
|
+
expect(s.state.ui.visibleZones.timeline).toBe(false);
|
|
176
|
+
expect(s.state.ui.visibleZones.agents).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("a hidden zone is enabled but not visible at start; F-key reveals it", () => {
|
|
180
|
+
const s = createTuiStore({
|
|
181
|
+
config: emptyConfig({ zones: { planner: "hidden", agenda: "on", agents: "on" } }),
|
|
182
|
+
});
|
|
183
|
+
expect(s.state.ui.enabledZones.planner).toBe(true);
|
|
184
|
+
expect(s.state.ui.visibleZones.planner).toBe(false);
|
|
185
|
+
s.toggleZoneDesired("planner");
|
|
186
|
+
expect(s.state.ui.visibleZones.planner).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("F-key toggle is a no-op on a disabled zone", () => {
|
|
190
|
+
const s = createTuiStore({
|
|
191
|
+
config: emptyConfig({ zones: { planner: "on", agenda: "off", agents: "on" } }),
|
|
192
|
+
});
|
|
193
|
+
s.toggleZoneDesired("timeline");
|
|
194
|
+
expect(s.state.ui.visibleZones.timeline).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("Shift-Tab cycle skips disabled zones", () => {
|
|
198
|
+
const s = createTuiStore({
|
|
199
|
+
config: emptyConfig({ zones: { planner: "on", agenda: "off", agents: "off" } }),
|
|
200
|
+
});
|
|
201
|
+
s.setActiveZone("board");
|
|
202
|
+
s.cycleActiveZone();
|
|
203
|
+
expect(s.state.ui.activeZone).toBe("planner");
|
|
204
|
+
s.cycleActiveZone();
|
|
205
|
+
expect(s.state.ui.activeZone).toBe("board"); // timeline + agents skipped
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("responsive fits never force-show a disabled zone", () => {
|
|
209
|
+
const s = createTuiStore({
|
|
210
|
+
config: emptyConfig({ zones: { planner: "on", agenda: "off", agents: "on" } }),
|
|
211
|
+
});
|
|
212
|
+
s.applyResponsiveFits({ planner: true, timeline: true, agents: true });
|
|
213
|
+
expect(s.state.ui.visibleZones.timeline).toBe(false); // disabled stays off
|
|
214
|
+
expect(s.state.ui.visibleZones.agents).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("responsive hides a zone that doesn't fit; it returns when it fits again", () => {
|
|
218
|
+
const s = createTuiStore({ config: emptyConfig() });
|
|
219
|
+
s.applyResponsiveFits({ planner: true, timeline: false, agents: true });
|
|
220
|
+
expect(s.state.ui.visibleZones.timeline).toBe(false);
|
|
221
|
+
s.applyResponsiveFits({ planner: true, timeline: true, agents: true });
|
|
222
|
+
expect(s.state.ui.visibleZones.timeline).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
package/src/store/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* The store also owns:
|
|
9
9
|
* - The list of loaded boards (with their original mtime watermark)
|
|
10
10
|
* - UI state: active board index, cursor (col/row), collapse flags,
|
|
11
|
-
* filter, mode (kanban /
|
|
11
|
+
* filter, mode (kanban / planner / list)
|
|
12
12
|
* - The undo log
|
|
13
13
|
*
|
|
14
14
|
* Mutations always:
|
|
@@ -71,17 +71,28 @@ export type ModalKind =
|
|
|
71
71
|
| { kind: "help" };
|
|
72
72
|
|
|
73
73
|
/** Which dashboard zone owns the keyboard cursor. */
|
|
74
|
-
export type ActiveZone = "
|
|
74
|
+
export type ActiveZone = "planner" | "board" | "timeline" | "agents";
|
|
75
75
|
|
|
76
76
|
/** Fixed cycling order for Shift+Tab navigation. */
|
|
77
|
-
const ZONE_ORDER: readonly ActiveZone[] = ["
|
|
77
|
+
const ZONE_ORDER: readonly ActiveZone[] = ["planner", "board", "timeline", "agents"];
|
|
78
78
|
|
|
79
79
|
export interface UIState {
|
|
80
80
|
activeBoardIndex: number;
|
|
81
81
|
/** Which dashboard zone owns the keyboard cursor right now. */
|
|
82
82
|
activeZone: ActiveZone;
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Which zones are actually rendered right now. Derived from
|
|
85
|
+
* `enabledZones ∧ desiredVisible ∧ fits-at-current-width`. Read by the views;
|
|
86
|
+
* never set directly — go through setZoneVisible / toggleZoneDesired /
|
|
87
|
+
* applyResponsiveFits, which recompute it.
|
|
88
|
+
*/
|
|
84
89
|
visibleZones: Record<ActiveZone, boolean>;
|
|
90
|
+
/**
|
|
91
|
+
* Which zones are enabled at all (from the `zones:` config). A disabled zone
|
|
92
|
+
* is never rendered, is skipped by Shift-Tab, has an inert F-key, and its
|
|
93
|
+
* background work never started. `board` is always enabled.
|
|
94
|
+
*/
|
|
95
|
+
enabledZones: Record<ActiveZone, boolean>;
|
|
85
96
|
/** Column index (within active board) — meaningful only when `activeZone === "board"`. */
|
|
86
97
|
col: number;
|
|
87
98
|
/** Row index inside the active zone. */
|
|
@@ -109,7 +120,7 @@ export interface UIState {
|
|
|
109
120
|
armedTimelineRef?: TaskRef;
|
|
110
121
|
/**
|
|
111
122
|
* Persistent calendar "arm mode" (toggled with `c`). While on, clicking any
|
|
112
|
-
* task in the board /
|
|
123
|
+
* task in the board / planner panel arms it for the timeline, so you can
|
|
113
124
|
* schedule several tasks in a row — click a task, click a slot, repeat —
|
|
114
125
|
* without re-pressing `c`. `Esc` (or `c` again) exits. Distinct from
|
|
115
126
|
* `armedTimelineRef`, which is the single task currently armed.
|
|
@@ -150,7 +161,7 @@ export interface StoreState {
|
|
|
150
161
|
undo: UndoEntry[];
|
|
151
162
|
/**
|
|
152
163
|
* Monotonic mutation counter. Bumped on every board mutation (via
|
|
153
|
-
* `saveBoard`). Derived views (board columns,
|
|
164
|
+
* `saveBoard`). Derived views (board columns, planner panel, timeline) read
|
|
154
165
|
* it so their memos recompute on any change — a reliable top-level signal
|
|
155
166
|
* dependency, since OpenTUI/Solid's fine-grained tracking of deeply-nested
|
|
156
167
|
* store edits (e.g. growing a column's `children` array) doesn't always
|
|
@@ -169,12 +180,43 @@ export interface CreateStoreOptions {
|
|
|
169
180
|
export function createTuiStore({ config }: CreateStoreOptions) {
|
|
170
181
|
const initialBoards = loadAll(config);
|
|
171
182
|
|
|
183
|
+
// ─── Zone enablement (from `zones:` config) ──────────────────────────────
|
|
184
|
+
// `enabledZones` is a hard gate; `desiredVisible` is the user's runtime
|
|
185
|
+
// intent (F-keys flip it); `lastFits` is the terminal-width fit cache. The
|
|
186
|
+
// rendered `visibleZones` is the AND of all three. The board is always on.
|
|
187
|
+
const z = config.zones;
|
|
188
|
+
const enabledZones: Record<ActiveZone, boolean> = {
|
|
189
|
+
board: true,
|
|
190
|
+
planner: z.planner !== "off",
|
|
191
|
+
timeline: z.agenda !== "off",
|
|
192
|
+
agents: z.agents !== "off",
|
|
193
|
+
};
|
|
194
|
+
const desiredVisible: Record<ActiveZone, boolean> = {
|
|
195
|
+
board: true,
|
|
196
|
+
planner: z.planner === "on",
|
|
197
|
+
timeline: z.agenda === "on",
|
|
198
|
+
agents: z.agents === "on",
|
|
199
|
+
};
|
|
200
|
+
let lastFits: Record<ActiveZone, boolean> = {
|
|
201
|
+
board: true,
|
|
202
|
+
planner: true,
|
|
203
|
+
timeline: true,
|
|
204
|
+
agents: true,
|
|
205
|
+
};
|
|
206
|
+
const computeVisible = (): Record<ActiveZone, boolean> => ({
|
|
207
|
+
board: true,
|
|
208
|
+
planner: enabledZones.planner && desiredVisible.planner && lastFits.planner,
|
|
209
|
+
timeline: enabledZones.timeline && desiredVisible.timeline && lastFits.timeline,
|
|
210
|
+
agents: enabledZones.agents && desiredVisible.agents && lastFits.agents,
|
|
211
|
+
});
|
|
212
|
+
|
|
172
213
|
const [state, setState] = createStore<StoreState>({
|
|
173
214
|
boards: initialBoards,
|
|
174
215
|
ui: {
|
|
175
216
|
activeBoardIndex: 0,
|
|
176
217
|
activeZone: "board",
|
|
177
|
-
visibleZones:
|
|
218
|
+
visibleZones: computeVisible(),
|
|
219
|
+
enabledZones,
|
|
178
220
|
col: 0,
|
|
179
221
|
row: 0,
|
|
180
222
|
zoomed: false,
|
|
@@ -198,11 +240,17 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
198
240
|
);
|
|
199
241
|
|
|
200
242
|
// Agents store has its own lifecycle (chokidar watcher on ~/.claude).
|
|
201
|
-
// Shared dispose() boundary below so SIGINT cleans both.
|
|
202
|
-
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
243
|
+
// Shared dispose() boundary below so SIGINT cleans both. When the agents
|
|
244
|
+
// zone is disabled we skip the watcher entirely (no `~/.claude` reads at all)
|
|
245
|
+
// and hand back an inert stub.
|
|
246
|
+
const agentsStore: AgentsStore = enabledZones.agents
|
|
247
|
+
? createAgentsStore()
|
|
248
|
+
: noopAgentsStore();
|
|
249
|
+
// Calendar feeds (read-only) merged into the Agenda zone. Skipped entirely
|
|
250
|
+
// (no network) when the agenda zone is disabled.
|
|
251
|
+
const calendarStore: CalendarStore = enabledZones.timeline
|
|
252
|
+
? createCalendarStore(config.calendars, isoToday)
|
|
253
|
+
: noopCalendarStore();
|
|
206
254
|
watcher.onChange((filepath) => {
|
|
207
255
|
// External edit. Re-read this board from disk.
|
|
208
256
|
try {
|
|
@@ -716,19 +764,46 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
716
764
|
|
|
717
765
|
function setActiveZone(zone: ActiveZone): void {
|
|
718
766
|
setState("ui", "activeZone", zone);
|
|
719
|
-
// Moving to a vertical-list zone (
|
|
767
|
+
// Moving to a vertical-list zone (planner / agents / timeline) resets
|
|
720
768
|
// the row cursor to the top so the user always lands somewhere sensible.
|
|
721
769
|
if (zone !== "board") setState("ui", "row", 0);
|
|
722
770
|
}
|
|
723
771
|
|
|
772
|
+
/** Recompute `visibleZones` from enabled ∧ desired ∧ fits; bounce the cursor
|
|
773
|
+
* to the board if the active zone just became invisible. */
|
|
774
|
+
function recomputeVisible(): void {
|
|
775
|
+
const v = computeVisible();
|
|
776
|
+
setState("ui", "visibleZones", v);
|
|
777
|
+
if (!v[state.ui.activeZone]) setActiveZone("board");
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Set a zone's desired visibility (user intent). Showing a disabled zone is
|
|
781
|
+
* ignored; the board can never be hidden. Used by direct reveals (day-nav,
|
|
782
|
+
* arm flow) and the responsive layout's per-zone calls. */
|
|
724
783
|
function setZoneVisible(zone: ActiveZone, visible: boolean): void {
|
|
725
|
-
// Board is the load-bearing zone — never allow it to be hidden.
|
|
726
784
|
if (zone === "board" && !visible) return;
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
785
|
+
if (visible && !enabledZones[zone]) return; // can't reveal a disabled zone
|
|
786
|
+
desiredVisible[zone] = visible;
|
|
787
|
+
recomputeVisible();
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Flip a zone's visibility (the F1/F2/F3 toggles). No-op if disabled. */
|
|
791
|
+
function toggleZoneDesired(zone: ActiveZone): void {
|
|
792
|
+
if (zone === "board" || !enabledZones[zone]) return;
|
|
793
|
+
desiredVisible[zone] = !desiredVisible[zone];
|
|
794
|
+
recomputeVisible();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Update the terminal-width fit cache (called by the responsive layout) and
|
|
798
|
+
* recompute. Never overrides enablement or the user's desired visibility. */
|
|
799
|
+
function applyResponsiveFits(fits: Partial<Record<ActiveZone, boolean>>): void {
|
|
800
|
+
lastFits = {
|
|
801
|
+
board: true,
|
|
802
|
+
planner: fits.planner !== false,
|
|
803
|
+
timeline: fits.timeline !== false,
|
|
804
|
+
agents: fits.agents !== false,
|
|
805
|
+
};
|
|
806
|
+
recomputeVisible();
|
|
732
807
|
}
|
|
733
808
|
|
|
734
809
|
function cycleActiveZone(): void {
|
|
@@ -1034,6 +1109,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1034
1109
|
setCursor,
|
|
1035
1110
|
setActiveZone,
|
|
1036
1111
|
setZoneVisible,
|
|
1112
|
+
toggleZoneDesired,
|
|
1113
|
+
applyResponsiveFits,
|
|
1037
1114
|
cycleActiveZone,
|
|
1038
1115
|
toggleZoom,
|
|
1039
1116
|
toggleGrab,
|
|
@@ -1069,6 +1146,22 @@ export type TuiStore = ReturnType<typeof createTuiStore>;
|
|
|
1069
1146
|
|
|
1070
1147
|
// ─── Load helpers ────────────────────────────────────────────────────────────
|
|
1071
1148
|
|
|
1149
|
+
/** Inert agents store for when the agents zone is disabled — no chokidar
|
|
1150
|
+
* watcher, no `~/.claude` reads at all. */
|
|
1151
|
+
function noopAgentsStore(): AgentsStore {
|
|
1152
|
+
return { sessions: () => [], refresh: () => {}, dispose: async () => {} };
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** Inert calendar store for when the agenda zone is disabled — no fetching. */
|
|
1156
|
+
function noopCalendarStore(): CalendarStore {
|
|
1157
|
+
return {
|
|
1158
|
+
events: () => [],
|
|
1159
|
+
setActiveDate: () => {},
|
|
1160
|
+
refresh: () => {},
|
|
1161
|
+
dispose: async () => {},
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1072
1165
|
function loadAll(config: Config): LoadedBoard[] {
|
|
1073
1166
|
const out: LoadedBoard[] = [];
|
|
1074
1167
|
for (const b of config.boards) {
|
|
Binary file
|
package/src/ui/AgentsBar.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Compact agent status strip for the dashboard. The non-archived sessions
|
|
3
3
|
* render inside a vertical scrollbox so the list scrolls with the mouse wheel
|
|
4
|
-
* (like the board /
|
|
4
|
+
* (like the board / planner / timeline zones) AND follows the keyboard cursor
|
|
5
5
|
* via scrollChildIntoView — same pattern as BoardView's columns.
|
|
6
6
|
*
|
|
7
7
|
* The border color reflects activeZone === "agents" so the cursor ring is
|
package/src/ui/BoardView.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Kanban board view — horizontal scrolling row of full-height columns.
|
|
3
3
|
*
|
|
4
|
-
* Mirrors the Today/Tomorrow
|
|
4
|
+
* Mirrors the Today/Tomorrow planner panel: each column has a fixed width
|
|
5
5
|
* and stretches vertically to fill the board area. Columns that don't fit
|
|
6
6
|
* the available horizontal space are reached via horizontal scroll. Zoom
|
|
7
7
|
* mode collapses everything down to the single active column at full
|
|
@@ -31,6 +31,13 @@ interface SizedBoxLike {
|
|
|
31
31
|
|
|
32
32
|
/** Fixed column width when not zoomed. Single source of truth for layout. */
|
|
33
33
|
const COL_WIDTH = 42;
|
|
34
|
+
/**
|
|
35
|
+
* Minimum fraction of a column that must be inside the viewport for its task
|
|
36
|
+
* rows to render. Above this, a column that's mostly on-screen keeps its tasks
|
|
37
|
+
* (the last one may be cut at the edge); below it — a thin sliver clipped at the
|
|
38
|
+
* edge — only the title shows as a "more columns →" hint.
|
|
39
|
+
*/
|
|
40
|
+
const MIN_TASKS_VISIBLE_FRACTION = 0.5;
|
|
34
41
|
/**
|
|
35
42
|
* Width of a collapsed column — one with no OPEN tasks (an all-done lane like
|
|
36
43
|
* "Done", or an empty column). It shows just the `✓ N` counter; zoom (`z`)
|
|
@@ -88,7 +95,7 @@ export function BoardView(props: BoardViewProps) {
|
|
|
88
95
|
* (Python kanban `z`).
|
|
89
96
|
*/
|
|
90
97
|
const renderedColumns = createMemo(() => {
|
|
91
|
-
if (!ui().zoomed || ui().activeZone === "
|
|
98
|
+
if (!ui().zoomed || ui().activeZone === "planner") return visibleColumns();
|
|
92
99
|
const cols = visibleColumns();
|
|
93
100
|
// ui.col is a board.columns index (carries Archive); map it to the
|
|
94
101
|
// rendered list so zoom focuses the column actually under the cursor.
|
|
@@ -111,7 +118,7 @@ export function BoardView(props: BoardViewProps) {
|
|
|
111
118
|
// geometry used everywhere (COL_WIDTH + COL_GAP).
|
|
112
119
|
createEffect(() => {
|
|
113
120
|
const colIdx = ui().col;
|
|
114
|
-
if (ui().zoomed || ui().activeZone === "
|
|
121
|
+
if (ui().zoomed || ui().activeZone === "planner") {
|
|
115
122
|
setScrollX(0);
|
|
116
123
|
return;
|
|
117
124
|
}
|
|
@@ -140,20 +147,26 @@ export function BoardView(props: BoardViewProps) {
|
|
|
140
147
|
});
|
|
141
148
|
|
|
142
149
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
150
|
+
* Should the column at rendered index `i` render its task rows? Yes unless
|
|
151
|
+
* it's clipped down to a thin sliver at the viewport edge (less than
|
|
152
|
+
* MIN_TASKS_VISIBLE_FRACTION on-screen) — then only its title shows as a
|
|
153
|
+
* "more columns →" hint. A mostly-visible column keeps its tasks even if the
|
|
154
|
+
* last one is cut at the edge. Defaults to true while the viewport width is
|
|
155
|
+
* still unknown (first paint) and in zoom.
|
|
146
156
|
*/
|
|
147
|
-
const
|
|
157
|
+
const columnTasksVisible = (i: number): boolean => {
|
|
148
158
|
const vw = viewportW();
|
|
149
159
|
if (vw <= 0 || ui().zoomed) return true;
|
|
150
160
|
const stride = COL_WIDTH + COL_GAP;
|
|
151
161
|
const start = i * stride;
|
|
152
|
-
|
|
162
|
+
const left = Math.max(start, scrollX());
|
|
163
|
+
const right = Math.min(start + COL_WIDTH, scrollX() + vw);
|
|
164
|
+
const visibleFraction = Math.max(0, right - left) / COL_WIDTH;
|
|
165
|
+
return visibleFraction >= MIN_TASKS_VISIBLE_FRACTION;
|
|
153
166
|
};
|
|
154
167
|
|
|
155
168
|
// Measure the viewport width once at mount, regardless of the active zone.
|
|
156
|
-
// Otherwise — since tuiboard now starts focused on the
|
|
169
|
+
// Otherwise — since tuiboard now starts focused on the planner panel — the
|
|
157
170
|
// scroll effect early-returns and viewportW stays 0, so partly-clipped
|
|
158
171
|
// columns briefly show their task rows until the board is first touched.
|
|
159
172
|
onMount(() => {
|
|
@@ -199,7 +212,7 @@ export function BoardView(props: BoardViewProps) {
|
|
|
199
212
|
columnIndex={originalIndex}
|
|
200
213
|
active={isActive()}
|
|
201
214
|
zoomed={ui().zoomed && isActive()}
|
|
202
|
-
|
|
215
|
+
tasksVisible={columnTasksVisible(i())}
|
|
203
216
|
boxId={columnId(props.board.filepath, originalIndex)}
|
|
204
217
|
/>
|
|
205
218
|
);
|
|
@@ -223,11 +236,11 @@ interface ColumnViewProps {
|
|
|
223
236
|
*/
|
|
224
237
|
zoomed: boolean;
|
|
225
238
|
/**
|
|
226
|
-
* False when the column is
|
|
227
|
-
*
|
|
228
|
-
*
|
|
239
|
+
* False when the column is clipped down to a thin sliver at the viewport
|
|
240
|
+
* edge. Its title still renders as a "more columns" hint, but the task rows
|
|
241
|
+
* are blanked. Mostly-visible columns keep their tasks.
|
|
229
242
|
*/
|
|
230
|
-
|
|
243
|
+
tasksVisible?: boolean;
|
|
231
244
|
/** Stable DOM-equivalent id used by `scrollChildIntoView`. */
|
|
232
245
|
boxId: string;
|
|
233
246
|
}
|
|
@@ -328,7 +341,7 @@ function ColumnView(props: ColumnViewProps) {
|
|
|
328
341
|
flexShrink: 0,
|
|
329
342
|
// No explicit height — Yoga stretches us along the row's cross
|
|
330
343
|
// axis, so the column always fills the full board height. Same
|
|
331
|
-
// contract as the Today/Tomorrow
|
|
344
|
+
// contract as the Today/Tomorrow planner panel next door.
|
|
332
345
|
marginRight: COL_GAP,
|
|
333
346
|
border: true,
|
|
334
347
|
borderStyle: "rounded",
|
|
@@ -349,9 +362,9 @@ function ColumnView(props: ColumnViewProps) {
|
|
|
349
362
|
scrollbarOptions: { visible: false },
|
|
350
363
|
}}
|
|
351
364
|
>
|
|
352
|
-
{/* Blank the task rows when the column is
|
|
353
|
-
|
|
354
|
-
<Show when={props.
|
|
365
|
+
{/* Blank the task rows only when the column is a thin clipped sliver —
|
|
366
|
+
its title still shows as a "more columns" hint. */}
|
|
367
|
+
<Show when={props.tasksVisible !== false}>
|
|
355
368
|
{/*
|
|
356
369
|
Keyed on the task-list signature so a structural change (add/delete/
|
|
357
370
|
move) rebuilds the <For> fresh in the correct order, working around
|
package/src/ui/Modal.tsx
CHANGED
|
@@ -20,41 +20,21 @@ import {
|
|
|
20
20
|
parseTimeBlockShortcut,
|
|
21
21
|
} from "~/store/parsers";
|
|
22
22
|
import { ATTR, T } from "~/ui/glyphs";
|
|
23
|
+
import { TIMELINE_WIDTH } from "~/views/Dashboard";
|
|
23
24
|
import type { TuiStore } from "~/store/index";
|
|
24
25
|
import type { PriorityLevel, TimeBlock } from "~/types";
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
const MODAL_WIDTH =
|
|
27
|
+
/** The modal panel matches the Agenda's width so it can drop into the Agenda's
|
|
28
|
+
* slot (which the Dashboard vacates while a modal is open) with no reflow. */
|
|
29
|
+
const MODAL_WIDTH = TIMELINE_WIDTH;
|
|
29
30
|
|
|
30
31
|
export function ModalLayer(props: { store: TuiStore }) {
|
|
31
32
|
const modal = createMemo(() => props.store.state.ui.modal);
|
|
32
33
|
return (
|
|
33
34
|
<Show when={modal()}>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
width: MODAL_WIDTH,
|
|
38
|
-
minWidth: MODAL_WIDTH,
|
|
39
|
-
flexGrow: 0,
|
|
40
|
-
flexShrink: 0,
|
|
41
|
-
// Stretch to the parent's height so the modal panel matches
|
|
42
|
-
// the timeline / board zones it sits next to. Same contract as
|
|
43
|
-
// every other zone — fixed cross-axis size, full-height fill.
|
|
44
|
-
alignSelf: "stretch",
|
|
45
|
-
marginLeft: 1,
|
|
46
|
-
backgroundColor: T.panelBgActive,
|
|
47
|
-
border: true,
|
|
48
|
-
borderStyle: "rounded",
|
|
49
|
-
borderColor: T.borderActive,
|
|
50
|
-
paddingLeft: 1,
|
|
51
|
-
paddingRight: 1,
|
|
52
|
-
paddingTop: 1,
|
|
53
|
-
paddingBottom: 1,
|
|
54
|
-
}}
|
|
55
|
-
>
|
|
56
|
-
<ModalRouter store={props.store} modal={modal()!} />
|
|
57
|
-
</box>
|
|
35
|
+
{/* Each modal's DialogShell IS the panel box (border + title + slot
|
|
36
|
+
dimensions), so it drops into the Agenda's slot directly. */}
|
|
37
|
+
<ModalRouter store={props.store} modal={modal()!} />
|
|
58
38
|
</Show>
|
|
59
39
|
);
|
|
60
40
|
}
|
|
@@ -87,11 +67,31 @@ interface DialogShellProps {
|
|
|
87
67
|
function DialogShell(props: DialogShellProps) {
|
|
88
68
|
void props.width;
|
|
89
69
|
return (
|
|
90
|
-
<box
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
70
|
+
<box
|
|
71
|
+
style={{
|
|
72
|
+
// The modal panel: same width + marginLeft as the Agenda so it occupies
|
|
73
|
+
// the Agenda's slot (the Dashboard hides the Agenda while a modal is
|
|
74
|
+
// open) with no reflow. The title rides in the top border, exactly like
|
|
75
|
+
// the board columns and the other zones.
|
|
76
|
+
flexDirection: "column",
|
|
77
|
+
width: MODAL_WIDTH,
|
|
78
|
+
minWidth: MODAL_WIDTH,
|
|
79
|
+
flexGrow: 0,
|
|
80
|
+
flexShrink: 0,
|
|
81
|
+
alignSelf: "stretch",
|
|
82
|
+
marginLeft: 1,
|
|
83
|
+
backgroundColor: T.panelBgActive,
|
|
84
|
+
border: true,
|
|
85
|
+
borderStyle: "rounded",
|
|
86
|
+
borderColor: T.borderActive,
|
|
87
|
+
paddingLeft: 1,
|
|
88
|
+
paddingRight: 1,
|
|
89
|
+
paddingTop: 1,
|
|
90
|
+
paddingBottom: 1,
|
|
91
|
+
}}
|
|
92
|
+
title={`┤ ${props.title} ├`}
|
|
93
|
+
titleAlignment="left"
|
|
94
|
+
>
|
|
95
95
|
{props.children}
|
|
96
96
|
<Show when={props.hint}>
|
|
97
97
|
<text>
|
|
@@ -207,11 +207,7 @@ function ScheduleModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiS
|
|
|
207
207
|
|
|
208
208
|
return (
|
|
209
209
|
<DialogShell
|
|
210
|
-
title={
|
|
211
|
-
markedCount > 1
|
|
212
|
-
? `Schedule ${markedCount} selected tasks`
|
|
213
|
-
: `Schedule: ${task?.displayTitle.slice(0, 50) ?? ""}`
|
|
214
|
-
}
|
|
210
|
+
title={markedCount > 1 ? `Schedule · ${markedCount} tasks` : "Schedule"}
|
|
215
211
|
hint="t = today · tm = tomorrow · +3 = in 3 days · lun = next Monday · 2026-06-15 · empty/-clear · Esc to cancel"
|
|
216
212
|
width={70}
|
|
217
213
|
>
|
|
@@ -256,11 +252,7 @@ function TimeBlockModal(props: { store: TuiStore; modal: Extract<NonNullable<Tui
|
|
|
256
252
|
|
|
257
253
|
return (
|
|
258
254
|
<DialogShell
|
|
259
|
-
title={
|
|
260
|
-
markedCount > 1
|
|
261
|
-
? `Time block ${markedCount} selected tasks`
|
|
262
|
-
: `Time block: ${task?.displayTitle.slice(0, 50) ?? ""}`
|
|
263
|
-
}
|
|
255
|
+
title={markedCount > 1 ? `Time block · ${markedCount} tasks` : "Time block"}
|
|
264
256
|
hint="n = now+30 · 9:00 · 9-11 · 09:30-10:45 · - to clear · Esc to cancel"
|
|
265
257
|
width={70}
|
|
266
258
|
>
|
|
@@ -588,12 +580,12 @@ function HelpModal(props: { store: TuiStore }) {
|
|
|
588
580
|
<span style={{ fg: T.text }}>{" h j k l ←↑↓→ Move cursor inside the active zone\n"}</span>
|
|
589
581
|
<span style={{ fg: T.text }}>{" Tab Next board (kanban zone)\n"}</span>
|
|
590
582
|
<span style={{ fg: T.text }}>{" 1..9 Jump to board N\n"}</span>
|
|
591
|
-
<span style={{ fg: T.text }}>{" v Toggle Today/Tomorrow
|
|
592
|
-
<span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (
|
|
593
|
-
<span style={{ fg: T.text }}>{" F1 / F2 / F3 Toggle visibility of
|
|
583
|
+
<span style={{ fg: T.text }}>{" v Toggle Today/Tomorrow planner panel focus\n"}</span>
|
|
584
|
+
<span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (planner → board → timeline → agents)\n"}</span>
|
|
585
|
+
<span style={{ fg: T.text }}>{" F1 / F2 / F3 Toggle visibility of Planner / Timeline / Agents zones\n"}</span>
|
|
594
586
|
<span style={{ fg: T.text }}>{" z Zoom active zone (or column) to full screen\n"}</span>
|
|
595
587
|
<span style={{ fg: T.text }}>{" r Refresh everything (boards from disk, agents, agenda calendar)\n"}</span>
|
|
596
|
-
<span style={{ fg: T.textDim }}>{"\nTask actions (work in board,
|
|
588
|
+
<span style={{ fg: T.textDim }}>{"\nTask actions (work in board, planner, AND timeline zones)\n"}</span>
|
|
597
589
|
<span style={{ fg: T.text }}>{" Enter Toggle done\n"}</span>
|
|
598
590
|
<span style={{ fg: T.text }}>{" o Open detail view\n"}</span>
|
|
599
591
|
<span style={{ fg: T.text }}>{" e Edit task text\n"}</span>
|