From 44b0f0be9626da087480f50827cc6e5fa01349ed Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Mon, 16 Feb 2026 05:35:26 +0000 Subject: [PATCH 01/25] Add design doc for Create Person command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompted by discussion #35 — users have no way to create person notes from the command palette. Design mirrors the existing Create Project pattern with template support. --- ...2026-02-16-create-person-command-design.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/plans/2026-02-16-create-person-command-design.md diff --git a/docs/plans/2026-02-16-create-person-command-design.md b/docs/plans/2026-02-16-create-person-command-design.md new file mode 100644 index 00000000..653f7ca8 --- /dev/null +++ b/docs/plans/2026-02-16-create-person-command-design.md @@ -0,0 +1,49 @@ +# Create Person Command + +## Summary + +Add a "Create person" command to the Flow plugin so users can scaffold person notes via the command palette, mirroring the existing "Create new project" command pattern. + +Prompted by [Discussion #35](https://github.com/tavva/flow/discussions/35). + +## Components + +### 1. Settings additions + +- `personsFolderPath: string` (default `"People"`) — folder where person notes are created +- `personTemplateFilePath: string` (default `"Templates/Person.md"`) — path to person template file + +Both configurable in the settings tab. + +### 2. Default person template + +```markdown +--- +creation-date: {{ date }}T{{ time }} +tags: person +--- + +## Discuss next +``` + +Template variables: `{{ date }}` (YYYY-MM-DD), `{{ time }}` (HH:MM:00) — same as the project template. + +### 3. NewPersonModal + +A simple modal with: +- Name text input (required) +- Creates file at `{personsFolderPath}/{sanitizedName}.md` +- Reads template from `personTemplateFilePath`, falls back to hardcoded default if template file missing +- Replaces `{{ name }}`, `{{ date }}`, `{{ time }}` template variables +- Errors if file already exists +- Opens the file after creation + +### 4. Command registration + +`addCommand` in `main.ts` with id `create-person`, name `Create person`, wired to open `NewPersonModal`. Same pattern as "Create new project". + +## What's excluded + +- No sphere/priority/status — person notes don't use these +- No AI integration +- No pre-population of "Discuss next" items From 912e3e1a7107dd5bfed1e60d4b6d97f3805b6ce7 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Mon, 16 Feb 2026 05:37:08 +0000 Subject: [PATCH 02/25] Add implementation plan for Create Person command --- .../plans/2026-02-16-create-person-command.md | 649 ++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100644 docs/plans/2026-02-16-create-person-command.md diff --git a/docs/plans/2026-02-16-create-person-command.md b/docs/plans/2026-02-16-create-person-command.md new file mode 100644 index 00000000..866079a2 --- /dev/null +++ b/docs/plans/2026-02-16-create-person-command.md @@ -0,0 +1,649 @@ +# Create Person Command Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a "Create person" command that scaffolds person notes with template support, mirroring the existing "Create new project" pattern. + +**Architecture:** New settings (`personsFolderPath`, `personTemplateFilePath`) feed a `NewPersonModal` that reads a template, replaces variables, creates the file, and opens it. `FileWriter` gains a `createPerson` method for the file creation logic (template reading, variable substitution, fallback). + +**Tech Stack:** Obsidian API (Modal, Setting, TFile, normalizePath), existing FileWriter patterns, Jest for testing. + +--- + +### Task 1: Add settings fields + +**Files:** +- Modify: `src/types/settings.ts` + +**Step 1: Add the two new fields to `PluginSettings` interface** + +Add after `projectTemplateFilePath` (line 23): + +```typescript + personsFolderPath: string; + personTemplateFilePath: string; +``` + +**Step 2: Add defaults to `DEFAULT_SETTINGS`** + +Add after `projectTemplateFilePath: "Templates/Project.md"` (line 56): + +```typescript + personsFolderPath: "People", + personTemplateFilePath: "Templates/Person.md", +``` + +**Step 3: Run build to verify types** + +Run: `npm run build` +Expected: PASS (no type errors) + +**Step 4: Commit** + +```bash +git add src/types/settings.ts +git commit -m "Add personsFolderPath and personTemplateFilePath settings" +``` + +--- + +### Task 2: Add settings UI + +**Files:** +- Modify: `src/settings-tab.ts` + +**Step 1: Add "People Folder" setting** + +Add after the "Project Template File" setting block (after line 372), following the same pattern as "Projects Folder" (lines 342-355): + +```typescript + // People Folder + new Setting(containerEl) + .setName("People Folder") + .setDesc("Folder where new person notes will be created.") + .addText((text) => { + text + .setPlaceholder("People") + .setValue(this.plugin.settings.personsFolderPath) + .onChange(async (value) => { + this.plugin.settings.personsFolderPath = value; + await this.plugin.saveSettings(); + }); + new FolderPathSuggest(this.app, text.inputEl); + }); + + // Person Template File + new Setting(containerEl) + .setName("Person Template File") + .setDesc( + "Template file used when creating new person notes. Supports {{date}}, {{time}}, and {{name}} variables." + ) + .addText((text) => { + text + .setPlaceholder("Templates/Person.md") + .setValue(this.plugin.settings.personTemplateFilePath) + .onChange(async (value) => { + this.plugin.settings.personTemplateFilePath = value; + await this.plugin.saveSettings(); + }); + new FilePathSuggest(this.app, text.inputEl, ["md"]); + }); +``` + +**Step 2: Run build to verify** + +Run: `npm run build` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/settings-tab.ts +git commit -m "Add People Folder and Person Template settings to settings tab" +``` + +--- + +### Task 3: Add `createPerson` to FileWriter (TDD) + +**Files:** +- Create: `tests/file-writer-create-person.test.ts` +- Modify: `src/file-writer.ts` + +**Step 1: Write failing tests** + +Create `tests/file-writer-create-person.test.ts`: + +```typescript +// ABOUTME: Tests for FileWriter.createPerson method +// ABOUTME: Verifies person note creation with template support and fallback + +import { App, TFile } from "obsidian"; +import { FileWriter } from "../src/file-writer"; +import { DEFAULT_SETTINGS, PluginSettings } from "../src/types"; + +describe("FileWriter.createPerson", () => { + let app: App; + let settings: PluginSettings; + let fileWriter: FileWriter; + + beforeEach(() => { + app = new App(); + settings = { ...DEFAULT_SETTINGS }; + fileWriter = new FileWriter(app, settings); + + // Default: no existing file, no template file + (app.vault.getAbstractFileByPath as jest.Mock).mockReturnValue(null); + (app.vault.create as jest.Mock).mockImplementation((path: string, content: string) => { + const file = new TFile(); + file.path = path; + file.basename = path.split("/").pop()?.replace(".md", "") || ""; + return Promise.resolve(file); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create person note in configured folder with fallback template", async () => { + const file = await fileWriter.createPerson("Alice Smith"); + + expect(app.vault.createFolder).toHaveBeenCalledWith("People"); + expect(app.vault.create).toHaveBeenCalledWith( + "People/Alice Smith.md", + expect.stringContaining("tags: person") + ); + expect(file.path).toBe("People/Alice Smith.md"); + }); + + it("should include creation-date in fallback template", async () => { + await fileWriter.createPerson("Bob"); + + const content = (app.vault.create as jest.Mock).mock.calls[0][1]; + expect(content).toMatch(/creation-date: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00/); + }); + + it("should include Discuss next section in fallback template", async () => { + await fileWriter.createPerson("Bob"); + + const content = (app.vault.create as jest.Mock).mock.calls[0][1]; + expect(content).toContain("## Discuss next"); + }); + + it("should use template file when available", async () => { + const templateFile = new TFile(); + templateFile.path = "Templates/Person.md"; + + (app.vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => { + if (path === "Templates/Person.md") return templateFile; + return null; + }); + (app.vault.read as jest.Mock).mockResolvedValue( + "---\ncreation-date: {{ date }}T{{ time }}\ntags: person\n---\n\nHello {{ name }}\n" + ); + + await fileWriter.createPerson("Alice"); + + const content = (app.vault.create as jest.Mock).mock.calls[0][1]; + expect(content).toContain("Hello Alice"); + expect(content).toMatch(/creation-date: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00/); + expect(content).not.toContain("{{ name }}"); + expect(content).not.toContain("{{ date }}"); + expect(content).not.toContain("{{ time }}"); + }); + + it("should throw if file already exists", async () => { + const existingFile = new TFile(); + existingFile.path = "People/Alice.md"; + + (app.vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => { + if (path === "People/Alice.md") return existingFile; + return null; + }); + + await expect(fileWriter.createPerson("Alice")).rejects.toThrow("already exists"); + }); + + it("should sanitize the filename", async () => { + await fileWriter.createPerson("Alice / Bob"); + + expect(app.vault.create).toHaveBeenCalledWith( + "People/Alice Bob.md", + expect.any(String) + ); + }); + + it("should use configured folder path", async () => { + settings.personsFolderPath = "Contacts"; + fileWriter = new FileWriter(app, settings); + + await fileWriter.createPerson("Alice"); + + expect(app.vault.createFolder).toHaveBeenCalledWith("Contacts"); + expect(app.vault.create).toHaveBeenCalledWith( + "Contacts/Alice.md", + expect.any(String) + ); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm test -- file-writer-create-person` +Expected: FAIL — `createPerson` does not exist on `FileWriter` + +**Step 3: Implement `createPerson` in `FileWriter`** + +Add to `src/file-writer.ts`, after the `createProject` method (after line 62): + +```typescript + /** + * Create a new person note file + */ + async createPerson(name: string): Promise { + const fileName = this.generateFileName(name); + const folderPath = normalizePath(this.settings.personsFolderPath); + await this.ensureFolderExists(folderPath); + const filePath = normalizePath(`${folderPath}/${fileName}.md`); + + const existingFile = this.app.vault.getAbstractFileByPath(filePath); + if (existingFile) { + throw new ValidationError(`File ${filePath} already exists`); + } + + const content = await this.buildPersonContent(name); + const file = await this.app.vault.create(filePath, content); + + return file; + } +``` + +Add the template/fallback methods near the other `build*Content` methods (after `buildProjectContentFallback`, around line 590): + +```typescript + /** + * Build person note content from template or fallback + */ + private async buildPersonContent(name: string): Promise { + const templateFile = this.app.vault.getAbstractFileByPath( + this.settings.personTemplateFilePath + ); + + if (!templateFile || !(templateFile instanceof TFile)) { + return this.buildPersonContentFallback(name); + } + + let templateContent = await this.app.vault.read(templateFile); + + const now = new Date(); + const date = this.formatDate(now); + const time = this.formatTime(now); + + templateContent = templateContent + .replace(/{{\s*date\s*}}/g, date) + .replace(/{{\s*time\s*}}/g, time) + .replace(/{{\s*name\s*}}/g, name); + + return templateContent; + } + + /** + * Fallback content when person template file is not available + */ + private buildPersonContentFallback(name: string): string { + const now = new Date(); + const dateTime = this.formatDateTime(now); + + return `---\ncreation-date: ${dateTime}\ntags: person\n---\n\n## Discuss next\n`; + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm test -- file-writer-create-person` +Expected: PASS + +**Step 5: Commit** + +```bash +git add tests/file-writer-create-person.test.ts src/file-writer.ts +git commit -m "Add FileWriter.createPerson with template support" +``` + +--- + +### Task 4: Create NewPersonModal (TDD) + +**Files:** +- Create: `tests/new-person-modal.test.ts` +- Create: `src/new-person-modal.ts` + +**Step 1: Write failing tests** + +Create `tests/new-person-modal.test.ts`: + +```typescript +// ABOUTME: Tests for the NewPersonModal class +// ABOUTME: Verifies person creation flow and validation logic + +import { App } from "obsidian"; +import { NewPersonModal } from "../src/new-person-modal"; +import { DEFAULT_SETTINGS } from "../src/types"; + +jest.mock("../src/file-writer", () => ({ + FileWriter: jest.fn().mockImplementation(() => ({ + createPerson: jest.fn().mockImplementation((name: string) => { + return Promise.resolve({ + path: `People/${name}.md`, + name: `${name}.md`, + }); + }), + })), +})); + +describe("NewPersonModal", () => { + let mockApp: App; + let modal: NewPersonModal; + + beforeEach(() => { + mockApp = new App(); + (mockApp.workspace.getLeaf as jest.Mock).mockReturnValue({ + openFile: jest.fn().mockResolvedValue(undefined), + }); + + const settings = { ...DEFAULT_SETTINGS }; + modal = new NewPersonModal(mockApp, settings); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("validation", () => { + it("should require person name", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = ""; + + await (modal as any).createPerson(); + + const { FileWriter } = require("../src/file-writer"); + const writerInstance = FileWriter.mock.results[0].value; + expect(writerInstance.createPerson).not.toHaveBeenCalled(); + }); + + it("should reject names with only invalid characters", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = "///"; + + await (modal as any).createPerson(); + + const { FileWriter } = require("../src/file-writer"); + const writerInstance = FileWriter.mock.results[0].value; + expect(writerInstance.createPerson).not.toHaveBeenCalled(); + }); + }); + + describe("person creation", () => { + it("should create person with trimmed name", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = " Alice Smith "; + + await (modal as any).createPerson(); + + const { FileWriter } = require("../src/file-writer"); + const writerInstance = FileWriter.mock.results[0].value; + expect(writerInstance.createPerson).toHaveBeenCalledWith("Alice Smith"); + }); + + it("should open the created file", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = "Alice"; + + await (modal as any).createPerson(); + + const leaf = (mockApp.workspace.getLeaf as jest.Mock).mock.results[0].value; + expect(leaf.openFile).toHaveBeenCalled(); + }); + + it("should close the modal after creation", async () => { + await modal.onOpen(); + const closeSpy = jest.spyOn(modal, "close"); + + const data = (modal as any).data; + data.name = "Alice"; + + await (modal as any).createPerson(); + + expect(closeSpy).toHaveBeenCalled(); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm test -- new-person-modal` +Expected: FAIL — module `../src/new-person-modal` not found + +**Step 3: Implement `NewPersonModal`** + +Create `src/new-person-modal.ts`: + +```typescript +// ABOUTME: Modal for creating a new person note with name input. +// ABOUTME: Uses configurable template file with fallback for person note scaffolding. + +import { App, Modal, Setting } from "obsidian"; +import { PluginSettings } from "./types"; +import { FileWriter } from "./file-writer"; +import { sanitizeFileName } from "./validation"; + +interface NewPersonData { + name: string; +} + +export class NewPersonModal extends Modal { + private settings: PluginSettings; + private fileWriter: FileWriter; + private data: NewPersonData; + + constructor(app: App, settings: PluginSettings) { + super(app); + this.settings = settings; + this.fileWriter = new FileWriter(app, settings); + this.data = { + name: "", + }; + } + + async onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("flow-gtd-new-person-modal"); + this.render(); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } + + private render() { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h2", { text: "Create Person" }); + + new Setting(contentEl) + .setName("Name") + .setDesc("The person's name") + .addText((text) => + text + .setPlaceholder("Enter name...") + .setValue(this.data.name) + .onChange((value) => { + this.data.name = value; + }) + ); + + const buttonContainer = contentEl.createDiv({ cls: "flow-gtd-modal-buttons" }); + buttonContainer.style.display = "flex"; + buttonContainer.style.justifyContent = "flex-end"; + buttonContainer.style.gap = "8px"; + buttonContainer.style.marginTop = "16px"; + + const cancelButton = buttonContainer.createEl("button", { text: "Cancel" }); + cancelButton.addEventListener("click", () => this.close()); + + const createButton = buttonContainer.createEl("button", { + text: "Create Person", + cls: "mod-cta", + }); + createButton.addEventListener("click", () => this.createPerson()); + } + + private async createPerson() { + if (!this.data.name.trim()) { + this.showError("Person name is required"); + return; + } + + const sanitizedName = sanitizeFileName(this.data.name.trim()); + if (sanitizedName.length === 0) { + this.showError( + "Name contains only invalid characters. Please use letters, numbers, or spaces." + ); + return; + } + + try { + const file = await this.fileWriter.createPerson(this.data.name.trim()); + this.close(); + + const leaf = this.app.workspace.getLeaf(false); + await leaf.openFile(file); + } catch (error) { + console.error("Failed to create person:", error); + this.showError( + `Failed to create person: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + private showError(message: string) { + const { contentEl } = this; + const existingError = contentEl.querySelector(".flow-gtd-modal-error"); + if (existingError) { + existingError.remove(); + } + + const errorEl = contentEl.createDiv({ cls: "flow-gtd-modal-error" }); + errorEl.style.color = "var(--text-error)"; + errorEl.style.marginTop = "8px"; + errorEl.style.padding = "8px"; + errorEl.style.backgroundColor = "var(--background-modifier-error)"; + errorEl.style.borderRadius = "4px"; + errorEl.setText(message); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm test -- new-person-modal` +Expected: PASS + +**Step 5: Commit** + +```bash +git add tests/new-person-modal.test.ts src/new-person-modal.ts +git commit -m "Add NewPersonModal for creating person notes" +``` + +--- + +### Task 5: Register command in main.ts + +**Files:** +- Modify: `main.ts` + +**Step 1: Add import** + +Add after the `NewProjectModal` import (line 6): + +```typescript +import { NewPersonModal } from "./src/new-person-modal"; +``` + +**Step 2: Add command registration** + +Add after the "create-project" command block (after line 120): + +```typescript + // Add create person command + this.addCommand({ + id: "create-person", + name: "Create person", + callback: () => { + this.openNewPersonModal(); + }, + }); +``` + +**Step 3: Add the private method** + +Add after `openNewProjectModal` (after line 414): + +```typescript + private openNewPersonModal() { + const modal = new NewPersonModal(this.app, this.settings); + modal.open(); + } +``` + +**Step 4: Run build and all tests** + +Run: `npm run build && npm test` +Expected: PASS + +**Step 5: Commit** + +```bash +git add main.ts +git commit -m "Register create-person command in plugin" +``` + +--- + +### Task 6: Format, build, and verify + +**Step 1: Run formatter** + +Run: `npm run format` + +**Step 2: Run build** + +Run: `npm run build` +Expected: PASS + +**Step 3: Run all tests** + +Run: `npm test` +Expected: PASS, all existing tests still green + +**Step 4: Commit any formatting changes** + +```bash +git add -A && git commit -m "Format code" +``` +(Only if there are changes from formatting.) From b9c022753d64d01a0e74a63c872671f8b08a9843 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Mon, 16 Feb 2026 05:38:25 +0000 Subject: [PATCH 03/25] Add personsFolderPath and personTemplateFilePath settings --- src/types/settings.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/types/settings.ts b/src/types/settings.ts index 663701dc..10d01e9b 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -21,6 +21,8 @@ export interface PluginSettings { somedayFilePath: string; projectsFolderPath: string; projectTemplateFilePath: string; + personsFolderPath: string; + personTemplateFilePath: string; defaultInboxFile: string; // Filename for built-in Flow quick capture (will be created in Flow Inbox Files folder) coverImagesFolderPath: string; // Folder path for generated project cover images autoCreateCoverImage: boolean; // Automatically create cover images for new projects @@ -54,6 +56,8 @@ export const DEFAULT_SETTINGS: PluginSettings = { somedayFilePath: "Someday.md", projectsFolderPath: "Projects", projectTemplateFilePath: "Templates/Project.md", + personsFolderPath: "People", + personTemplateFilePath: "Templates/Person.md", defaultInboxFile: "Inbox.md", coverImagesFolderPath: "Assets/flow-project-cover-images", autoCreateCoverImage: false, From 627d460ac9a260eff80b0827ab05b87f4d3e6fd1 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Mon, 16 Feb 2026 05:39:08 +0000 Subject: [PATCH 04/25] Add People Folder and Person Template settings to settings tab --- src/settings-tab.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/settings-tab.ts b/src/settings-tab.ts index dc7d8583..84a55905 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -371,6 +371,38 @@ export class FlowGTDSettingTab extends PluginSettingTab { new FilePathSuggest(this.app, text.inputEl, ["md"]); }); + // People Folder + new Setting(containerEl) + .setName("People Folder") + .setDesc("Folder where new person notes will be created.") + .addText((text) => { + text + .setPlaceholder("People") + .setValue(this.plugin.settings.personsFolderPath) + .onChange(async (value) => { + this.plugin.settings.personsFolderPath = value; + await this.plugin.saveSettings(); + }); + new FolderPathSuggest(this.app, text.inputEl); + }); + + // Person Template File + new Setting(containerEl) + .setName("Person Template File") + .setDesc( + "Template file used when creating new person notes. Supports {{date}}, {{time}}, and {{name}} variables." + ) + .addText((text) => { + text + .setPlaceholder("Templates/Person.md") + .setValue(this.plugin.settings.personTemplateFilePath) + .onChange(async (value) => { + this.plugin.settings.personTemplateFilePath = value; + await this.plugin.saveSettings(); + }); + new FilePathSuggest(this.app, text.inputEl, ["md"]); + }); + // Default Inbox File new Setting(containerEl) .setName("Default Inbox File") From 619cd0eb617d40d18e414f1166be0d7627507fe8 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Mon, 16 Feb 2026 05:42:12 +0000 Subject: [PATCH 05/25] Add FileWriter.createPerson with template support --- src/file-writer.ts | 56 +++++++++++++ tests/file-writer-create-person.test.ts | 106 ++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/file-writer-create-person.test.ts diff --git a/src/file-writer.ts b/src/file-writer.ts index 37b4f3c6..e4aa193a 100644 --- a/src/file-writer.ts +++ b/src/file-writer.ts @@ -61,6 +61,26 @@ export class FileWriter { return file; } + /** + * Create a new person note file + */ + async createPerson(name: string): Promise { + const fileName = this.generateFileName(name); + const folderPath = normalizePath(this.settings.personsFolderPath); + await this.ensureFolderExists(folderPath); + const filePath = normalizePath(`${folderPath}/${fileName}.md`); + + const existingFile = this.app.vault.getAbstractFileByPath(filePath); + if (existingFile) { + throw new ValidationError(`File ${filePath} already exists`); + } + + const content = await this.buildPersonContent(name); + const file = await this.app.vault.create(filePath, content); + + return file; + } + private async ensureFolderExists(folderPath: string): Promise { const normalizedPath = normalizePath(folderPath); const existing = this.app.vault.getAbstractFileByPath(normalizedPath); @@ -612,6 +632,42 @@ ${description} return content; } + /** + * Build person note content from template or fallback + */ + private async buildPersonContent(name: string): Promise { + const templateFile = this.app.vault.getAbstractFileByPath( + this.settings.personTemplateFilePath + ); + + if (!templateFile || !(templateFile instanceof TFile)) { + return this.buildPersonContentFallback(); + } + + let templateContent = await this.app.vault.read(templateFile); + + const now = new Date(); + const date = this.formatDate(now); + const time = this.formatTime(now); + + templateContent = templateContent + .replace(/{{\s*date\s*}}/g, date) + .replace(/{{\s*time\s*}}/g, time) + .replace(/{{\s*name\s*}}/g, name); + + return templateContent; + } + + /** + * Fallback content when person template file is not available + */ + private buildPersonContentFallback(): string { + const now = new Date(); + const dateTime = this.formatDateTime(now); + + return `---\ncreation-date: ${dateTime}\ntags: person\n---\n\n## Discuss next\n`; + } + /** * Add an action item to a specific section */ diff --git a/tests/file-writer-create-person.test.ts b/tests/file-writer-create-person.test.ts new file mode 100644 index 00000000..f0d8471f --- /dev/null +++ b/tests/file-writer-create-person.test.ts @@ -0,0 +1,106 @@ +// ABOUTME: Tests for FileWriter.createPerson method +// ABOUTME: Verifies person note creation with template support and fallback + +import { App, TFile } from "obsidian"; +import { FileWriter } from "../src/file-writer"; +import { DEFAULT_SETTINGS, PluginSettings } from "../src/types"; + +describe("FileWriter.createPerson", () => { + let app: App; + let settings: PluginSettings; + let fileWriter: FileWriter; + + beforeEach(() => { + app = new App(); + settings = { ...DEFAULT_SETTINGS }; + fileWriter = new FileWriter(app, settings); + + // Default: no existing file, no template file + (app.vault.getAbstractFileByPath as jest.Mock).mockReturnValue(null); + (app.vault.create as jest.Mock).mockImplementation((path: string, content: string) => { + const file = new TFile(); + file.path = path; + file.basename = path.split("/").pop()?.replace(".md", "") || ""; + return Promise.resolve(file); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create person note in configured folder with fallback template", async () => { + const file = await fileWriter.createPerson("Alice Smith"); + + expect(app.vault.createFolder).toHaveBeenCalledWith("People"); + expect(app.vault.create).toHaveBeenCalledWith( + "People/Alice Smith.md", + expect.stringContaining("tags: person") + ); + expect(file.path).toBe("People/Alice Smith.md"); + }); + + it("should include creation-date in fallback template", async () => { + await fileWriter.createPerson("Bob"); + + const content = (app.vault.create as jest.Mock).mock.calls[0][1]; + expect(content).toMatch(/creation-date: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00/); + }); + + it("should include Discuss next section in fallback template", async () => { + await fileWriter.createPerson("Bob"); + + const content = (app.vault.create as jest.Mock).mock.calls[0][1]; + expect(content).toContain("## Discuss next"); + }); + + it("should use template file when available", async () => { + const templateFile = new TFile(); + templateFile.path = "Templates/Person.md"; + + (app.vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => { + if (path === "Templates/Person.md") return templateFile; + return null; + }); + (app.vault.read as jest.Mock).mockResolvedValue( + "---\ncreation-date: {{ date }}T{{ time }}\ntags: person\n---\n\nHello {{ name }}\n" + ); + + await fileWriter.createPerson("Alice"); + + const content = (app.vault.create as jest.Mock).mock.calls[0][1]; + expect(content).toContain("Hello Alice"); + expect(content).toMatch(/creation-date: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}/); + expect(content).not.toContain("{{ name }}"); + expect(content).not.toContain("{{ date }}"); + expect(content).not.toContain("{{ time }}"); + }); + + it("should throw if file already exists", async () => { + const existingFile = new TFile(); + existingFile.path = "People/Alice.md"; + + (app.vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => { + if (path === "People/Alice.md") return existingFile; + return null; + }); + + await expect(fileWriter.createPerson("Alice")).rejects.toThrow("already exists"); + }); + + it("should sanitize the filename", async () => { + await fileWriter.createPerson("Alice / Bob"); + + expect(app.vault.create).toHaveBeenCalledWith("People/Alice Bob.md", expect.any(String)); + }); + + it("should use configured folder path", async () => { + settings.personsFolderPath = "Contacts"; + fileWriter = new FileWriter(app, settings); + + await fileWriter.createPerson("Alice"); + + expect(app.vault.createFolder).toHaveBeenCalledWith("Contacts"); + expect(app.vault.create).toHaveBeenCalledWith("Contacts/Alice.md", expect.any(String)); + }); +}); From ee6ff1e792020acf44fb8f68153b8969f768dd96 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Mon, 16 Feb 2026 05:44:35 +0000 Subject: [PATCH 06/25] Add NewPersonModal for creating person notes --- src/new-person-modal.ts | 120 +++++++++++++++++++++++++++++++++ tests/new-person-modal.test.ts | 104 ++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 src/new-person-modal.ts create mode 100644 tests/new-person-modal.test.ts diff --git a/src/new-person-modal.ts b/src/new-person-modal.ts new file mode 100644 index 00000000..7f8ab0a6 --- /dev/null +++ b/src/new-person-modal.ts @@ -0,0 +1,120 @@ +// ABOUTME: Modal for creating a person note with name input. +// ABOUTME: Uses FileWriter.createPerson to scaffold the note and opens it afterwards. + +import { App, Modal, Setting } from "obsidian"; +import { PluginSettings } from "./types"; +import { FileWriter } from "./file-writer"; +import { sanitizeFileName } from "./validation"; + +interface NewPersonData { + name: string; +} + +export class NewPersonModal extends Modal { + private settings: PluginSettings; + private fileWriter: FileWriter; + private data: NewPersonData; + + constructor(app: App, settings: PluginSettings) { + super(app); + this.settings = settings; + this.fileWriter = new FileWriter(app, settings); + this.data = { + name: "", + }; + } + + async onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("flow-gtd-new-person-modal"); + this.render(); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } + + private render() { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h2", { text: "Create Person" }); + + new Setting(contentEl) + .setName("Name") + .setDesc("The person's name") + .addText((text) => + text + .setPlaceholder("Enter name...") + .setValue(this.data.name) + .onChange((value) => { + this.data.name = value; + }) + ); + + const buttonContainer = contentEl.createDiv({ + cls: "flow-gtd-modal-buttons", + }); + buttonContainer.style.display = "flex"; + buttonContainer.style.justifyContent = "flex-end"; + buttonContainer.style.gap = "8px"; + buttonContainer.style.marginTop = "16px"; + + const cancelButton = buttonContainer.createEl("button", { + text: "Cancel", + }); + cancelButton.addEventListener("click", () => this.close()); + + const createButton = buttonContainer.createEl("button", { + text: "Create Person", + cls: "mod-cta", + }); + createButton.addEventListener("click", () => this.createPerson()); + } + + private async createPerson() { + if (!this.data.name.trim()) { + this.showError("Person name is required"); + return; + } + + const sanitizedName = sanitizeFileName(this.data.name.trim()); + if (sanitizedName.length === 0) { + this.showError( + "Name contains only invalid characters. Please use letters, numbers, or spaces." + ); + return; + } + + try { + const file = await this.fileWriter.createPerson(this.data.name.trim()); + this.close(); + + const leaf = this.app.workspace.getLeaf(false); + await leaf.openFile(file); + } catch (error) { + console.error("Failed to create person:", error); + this.showError( + `Failed to create person: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + private showError(message: string) { + const { contentEl } = this; + const existingError = contentEl.querySelector(".flow-gtd-modal-error"); + if (existingError) { + existingError.remove(); + } + + const errorEl = contentEl.createDiv({ cls: "flow-gtd-modal-error" }); + errorEl.style.color = "var(--text-error)"; + errorEl.style.marginTop = "8px"; + errorEl.style.padding = "8px"; + errorEl.style.backgroundColor = "var(--background-modifier-error)"; + errorEl.style.borderRadius = "4px"; + errorEl.setText(message); + } +} diff --git a/tests/new-person-modal.test.ts b/tests/new-person-modal.test.ts new file mode 100644 index 00000000..b2fae8d5 --- /dev/null +++ b/tests/new-person-modal.test.ts @@ -0,0 +1,104 @@ +// ABOUTME: Tests for the NewPersonModal class +// ABOUTME: Verifies person creation flow and validation logic + +import { App } from "obsidian"; +import { NewPersonModal } from "../src/new-person-modal"; +import { DEFAULT_SETTINGS } from "../src/types"; + +jest.mock("../src/file-writer", () => ({ + FileWriter: jest.fn().mockImplementation(() => ({ + createPerson: jest.fn().mockImplementation((name: string) => { + return Promise.resolve({ + path: `People/${name}.md`, + name: `${name}.md`, + }); + }), + })), +})); + +describe("NewPersonModal", () => { + let mockApp: App; + let modal: NewPersonModal; + + beforeEach(() => { + mockApp = new App(); + (mockApp.workspace.getLeaf as jest.Mock).mockReturnValue({ + openFile: jest.fn().mockResolvedValue(undefined), + }); + + const settings = { ...DEFAULT_SETTINGS }; + modal = new NewPersonModal(mockApp, settings); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("validation", () => { + it("should require person name", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = ""; + + await (modal as any).createPerson(); + + const { FileWriter } = require("../src/file-writer"); + const writerInstance = FileWriter.mock.results[0].value; + expect(writerInstance.createPerson).not.toHaveBeenCalled(); + }); + + it("should reject names with only invalid characters", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = "///"; + + await (modal as any).createPerson(); + + const { FileWriter } = require("../src/file-writer"); + const writerInstance = FileWriter.mock.results[0].value; + expect(writerInstance.createPerson).not.toHaveBeenCalled(); + }); + }); + + describe("person creation", () => { + it("should create person with trimmed name", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = " Alice Smith "; + + await (modal as any).createPerson(); + + const { FileWriter } = require("../src/file-writer"); + const writerInstance = FileWriter.mock.results[0].value; + expect(writerInstance.createPerson).toHaveBeenCalledWith("Alice Smith"); + }); + + it("should open the created file", async () => { + await modal.onOpen(); + + const data = (modal as any).data; + data.name = "Alice"; + + await (modal as any).createPerson(); + + const leaf = (mockApp.workspace.getLeaf as jest.Mock).mock.results[0] + .value; + expect(leaf.openFile).toHaveBeenCalled(); + }); + + it("should close the modal after creation", async () => { + await modal.onOpen(); + const closeSpy = jest.spyOn(modal, "close"); + + const data = (modal as any).data; + data.name = "Alice"; + + await (modal as any).createPerson(); + + expect(closeSpy).toHaveBeenCalled(); + }); + }); +}); From 6d7d0dd7ef4d67a4836200d1c5a12c9d6644dc2c Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Mon, 16 Feb 2026 05:45:55 +0000 Subject: [PATCH 07/25] Register create-person command in plugin --- main.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/main.ts b/main.ts index 430c2702..a177f6f8 100644 --- a/main.ts +++ b/main.ts @@ -4,6 +4,7 @@ import { FlowGTDSettingTab } from "./src/settings-tab"; import { SphereView, SPHERE_VIEW_TYPE } from "./src/sphere-view"; import { InboxProcessingView, INBOX_PROCESSING_VIEW_TYPE } from "./src/inbox-processing-view"; import { NewProjectModal } from "./src/new-project-modal"; +import { NewPersonModal } from "./src/new-person-modal"; import { AddToInboxModal } from "./src/add-to-inbox-modal"; import { cycleTaskStatus } from "./src/task-status-cycler"; import { WaitingForView, WAITING_FOR_VIEW_TYPE } from "./src/waiting-for-view"; @@ -119,6 +120,15 @@ export default class FlowGTDCoachPlugin extends Plugin { }, }); + // Add create person command + this.addCommand({ + id: "create-person", + name: "Create person", + callback: () => { + this.openNewPersonModal(); + }, + }); + // Add to inbox command this.addCommand({ id: "add-to-inbox", @@ -413,6 +423,11 @@ export default class FlowGTDCoachPlugin extends Plugin { modal.open(); } + private openNewPersonModal() { + const modal = new NewPersonModal(this.app, this.settings); + modal.open(); + } + private openAddToInboxModal() { const modal = new AddToInboxModal(this.app, this.settings); modal.open(); From bab0e4ea6607fdd8ac9f7f3291756b6e132a8efc Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Mon, 16 Feb 2026 05:46:33 +0000 Subject: [PATCH 08/25] Format code --- src/file-writer.ts | 4 +--- tests/new-person-modal.test.ts | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/file-writer.ts b/src/file-writer.ts index e4aa193a..c0f3081a 100644 --- a/src/file-writer.ts +++ b/src/file-writer.ts @@ -636,9 +636,7 @@ ${description} * Build person note content from template or fallback */ private async buildPersonContent(name: string): Promise { - const templateFile = this.app.vault.getAbstractFileByPath( - this.settings.personTemplateFilePath - ); + const templateFile = this.app.vault.getAbstractFileByPath(this.settings.personTemplateFilePath); if (!templateFile || !(templateFile instanceof TFile)) { return this.buildPersonContentFallback(); diff --git a/tests/new-person-modal.test.ts b/tests/new-person-modal.test.ts index b2fae8d5..76c09f32 100644 --- a/tests/new-person-modal.test.ts +++ b/tests/new-person-modal.test.ts @@ -84,8 +84,7 @@ describe("NewPersonModal", () => { await (modal as any).createPerson(); - const leaf = (mockApp.workspace.getLeaf as jest.Mock).mock.results[0] - .value; + const leaf = (mockApp.workspace.getLeaf as jest.Mock).mock.results[0].value; expect(leaf.openFile).toHaveBeenCalled(); }); From ca5feb4f24b57c218e4c93e7d21ea667034ac52b Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Mon, 16 Feb 2026 05:49:15 +0000 Subject: [PATCH 09/25] Add Templater integration to createPerson Matches the createProject pattern which calls processWithTemplater after file creation, enabling Templater syntax in person templates. --- src/file-writer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/file-writer.ts b/src/file-writer.ts index c0f3081a..c3e77338 100644 --- a/src/file-writer.ts +++ b/src/file-writer.ts @@ -77,6 +77,7 @@ export class FileWriter { const content = await this.buildPersonContent(name); const file = await this.app.vault.create(filePath, content); + await this.processWithTemplater(file); return file; } From e36d1244ee83cc06fd0a932c49338c6050a796b8 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Mon, 16 Feb 2026 11:06:00 +0000 Subject: [PATCH 10/25] Auto-select sphere when only one is configured Fixes #38 --- src/inbox-processing-controller.ts | 2 +- src/new-project-modal.ts | 2 +- tests/inbox-processing-controller.test.ts | 61 +++++++++++++++++++++++ tests/new-project-modal.test.ts | 18 ++++++- 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/inbox-processing-controller.ts b/src/inbox-processing-controller.ts index 77498729..6a017dd1 100644 --- a/src/inbox-processing-controller.ts +++ b/src/inbox-processing-controller.ts @@ -78,7 +78,7 @@ export class InboxProcessingController { original: item.content, inboxItem: item, selectedAction: "next-actions-file", - selectedSpheres: [], + selectedSpheres: this.settings.spheres.length === 1 ? [...this.settings.spheres] : [], })); } diff --git a/src/new-project-modal.ts b/src/new-project-modal.ts index 2b8e3e4f..c31c23cf 100644 --- a/src/new-project-modal.ts +++ b/src/new-project-modal.ts @@ -38,7 +38,7 @@ export class NewProjectModal extends Modal { title: "", nextAction: "", description: "", - spheres: [], + spheres: settings.spheres.length === 1 ? [...settings.spheres] : [], priority: settings.defaultPriority, isSubProject: false, parentProject: null, diff --git a/tests/inbox-processing-controller.test.ts b/tests/inbox-processing-controller.test.ts index 7cd248eb..8b228a16 100644 --- a/tests/inbox-processing-controller.test.ts +++ b/tests/inbox-processing-controller.test.ts @@ -104,6 +104,67 @@ describe("InboxProcessingController discardInboxItem", () => { }); }); +describe("InboxProcessingController createEditableItemsFromInbox", () => { + const createController = (settings: PluginSettings) => { + const app = new App(); + return new InboxProcessingController(app as unknown as any, settings, { + scanner: { scanProjects: jest.fn() } as any, + personScanner: { scanPersons: jest.fn() } as any, + writer: {} as any, + inboxScanner: { + deleteInboxItem: jest.fn(), + getAllInboxItems: jest.fn(), + } as any, + persistenceService: { persist: jest.fn() } as any, + }); + }; + + const sampleInboxItems: InboxItem[] = [ + { + type: "line" as const, + content: "Buy milk", + sourceFile: { path: "inbox.md" } as any, + lineNumber: 1, + }, + { + type: "line" as const, + content: "Call dentist", + sourceFile: { path: "inbox.md" } as any, + lineNumber: 2, + }, + ]; + + it("auto-selects the sphere when only one sphere is configured", () => { + const settings = { ...DEFAULT_SETTINGS, spheres: ["personal"] }; + const controller = createController(settings); + + const items = controller.createEditableItemsFromInbox(sampleInboxItems); + + expect(items[0].selectedSpheres).toEqual(["personal"]); + expect(items[1].selectedSpheres).toEqual(["personal"]); + }); + + it("leaves spheres empty when multiple spheres are configured", () => { + const settings = { ...DEFAULT_SETTINGS, spheres: ["personal", "work"] }; + const controller = createController(settings); + + const items = controller.createEditableItemsFromInbox(sampleInboxItems); + + expect(items[0].selectedSpheres).toEqual([]); + expect(items[1].selectedSpheres).toEqual([]); + }); + + it("leaves spheres empty when no spheres are configured", () => { + const settings = { ...DEFAULT_SETTINGS, spheres: [] }; + const controller = createController(settings); + + const items = controller.createEditableItemsFromInbox(sampleInboxItems); + + expect(items[0].selectedSpheres).toEqual([]); + expect(items[1].selectedSpheres).toEqual([]); + }); +}); + describe("InboxProcessingController with AI disabled", () => { const createControllerWithAIDisabled = () => { const app = new App(); diff --git a/tests/new-project-modal.test.ts b/tests/new-project-modal.test.ts index df49822f..b5cd49e4 100644 --- a/tests/new-project-modal.test.ts +++ b/tests/new-project-modal.test.ts @@ -283,13 +283,29 @@ describe("NewProjectModal", () => { expect(data.priority).toBe(2); // Default from settings }); - it("should initialize with empty spheres", async () => { + it("should initialize with empty spheres when multiple spheres configured", async () => { await modal.onOpen(); const data = (modal as any).data; expect(data.spheres).toEqual([]); }); + it("should auto-select sphere when only one sphere is configured", async () => { + const singleSphereSettings = { + ...DEFAULT_SETTINGS, + spheres: ["personal"], + }; + const singleSphereModal = new NewProjectModal( + mockApp, + singleSphereSettings, + mockSaveSettings + ); + await singleSphereModal.onOpen(); + + const data = (singleSphereModal as any).data; + expect(data.spheres).toEqual(["personal"]); + }); + it("should not be sub-project by default", async () => { await modal.onOpen(); From 2cca593e626fdce58b5f7ebb8b24b93fa8753d80 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Mon, 16 Feb 2026 11:49:58 +0000 Subject: [PATCH 11/25] Release v1.2.2 --- manifest.json | 2 +- package.json | 2 +- versions.json | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 6fb15326..f477ae99 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "flow", "name": "Flow", - "version": "1.2.1", + "version": "1.2.2", "minAppVersion": "0.15.0", "description": "Implements key processes in David Allen's Getting Things Done (GTD) methodology", "author": "Ben Phillips", diff --git a/package.json b/package.json index 2d00f681..dea99a60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flow", - "version": "1.2.1", + "version": "1.2.2", "description": "Implements key processes in David Allen's Getting Things Done (GTD) methodology", "main": "main.js", "scripts": { diff --git a/versions.json b/versions.json index 49a6cb42..360b4a17 100644 --- a/versions.json +++ b/versions.json @@ -8,5 +8,6 @@ "1.1.2": "0.15.0", "1.1.3": "0.15.0", "1.2.0": "0.15.0", - "1.2.1": "0.15.0" + "1.2.1": "0.15.0", + "1.2.2": "0.15.0" } From a5679c0f4bec800e06770ce985390d1fc053b211 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Tue, 17 Feb 2026 07:35:38 +0000 Subject: [PATCH 12/25] Preserve scroll position when adding actions to Focus When adding a focus item from the SphereView, saving the focus file triggered a metadata cache change that caused a full view refresh, resetting scroll to top. Instead, focus file changes now trigger a lightweight CSS-only update that toggles sphere-action-in-focus classes on existing DOM elements without re-rendering. Fixes #41 --- src/sphere-view.ts | 28 ++++-- tests/sphere-view.test.ts | 185 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 5 deletions(-) diff --git a/src/sphere-view.ts b/src/sphere-view.ts index 34df065a..99851b2f 100644 --- a/src/sphere-view.ts +++ b/src/sphere-view.ts @@ -162,6 +162,10 @@ export class SphereView extends ItemView { this.app.metadataCache.offref(this.metadataCacheEventRef); } this.metadataCacheEventRef = this.app.metadataCache.on("changed", (file) => { + if (file.path === FOCUS_FILE_PATH) { + void this.refreshFocusHighlighting(); + return; + } if (this.isRelevantFile(file)) { this.scheduleAutoRefresh(); } @@ -169,11 +173,6 @@ export class SphereView extends ItemView { } private isRelevantFile(file: TFile): boolean { - // Refresh when focus file changes (for "in focus" highlighting) - if (file.path === FOCUS_FILE_PATH) { - return true; - } - // Refresh when any markdown file in the vault changes that might be a project // We check the metadata cache for project tags const metadata = this.app.metadataCache.getFileCache(file); @@ -595,6 +594,8 @@ export class SphereView extends ItemView { const displayText = isWaitingFor ? `🤝 ${action}` : action; const item = list.createEl("li"); + item.setAttribute("data-focus-file", file); + item.setAttribute("data-focus-action", action); await MarkdownRenderer.renderMarkdown(displayText, item, "", this); item.style.cursor = "pointer"; @@ -806,6 +807,23 @@ export class SphereView extends ItemView { } } + private async refreshFocusHighlighting(): Promise { + const focusItems = await loadFocusItems(this.app.vault); + const actionElements = this.contentEl.querySelectorAll("li[data-focus-file]"); + + actionElements.forEach((el: Element) => { + const file = el.getAttribute("data-focus-file"); + const action = el.getAttribute("data-focus-action"); + const inFocus = focusItems.some((item) => item.file === file && item.text === action); + + if (inFocus) { + el.classList.add("sphere-action-in-focus"); + } else { + el.classList.remove("sphere-action-in-focus"); + } + }); + } + private async refreshFocusView(): Promise { const { workspace } = this.app; const leaves = workspace.getLeavesOfType(FOCUS_VIEW_TYPE); diff --git a/tests/sphere-view.test.ts b/tests/sphere-view.test.ts index 5120d115..f216d858 100644 --- a/tests/sphere-view.test.ts +++ b/tests/sphere-view.test.ts @@ -19,6 +19,7 @@ jest.mock("../src/focus-persistence", () => ({ mockFocusItems = items; return Promise.resolve(); }), + FOCUS_FILE_PATH: "flow-focus-data/focus.md", })); import { saveFocusItems as mockSaveFocusItems } from "../src/focus-persistence"; @@ -668,6 +669,189 @@ describe("SphereView", () => { }); }); + describe("action item data attributes", () => { + it("should set data-focus-file and data-focus-action on rendered action items", async () => { + mockLineFinder.findActionLine.mockResolvedValue({ + found: true, + lineNumber: 5, + lineContent: "- [ ] Call client about meeting", + }); + + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + // Create a real DOM list element with Obsidian methods + const listEl = document.createElement("ul"); + (listEl as any).createEl = function (tag: string, opts?: any) { + const el = document.createElement(tag); + if (opts?.cls) el.className = opts.cls; + if (opts?.text) el.textContent = opts.text; + this.appendChild(el); + (el as any).createSpan = function (spanOpts?: any) { + const span = document.createElement("span"); + if (spanOpts?.cls) span.className = spanOpts.cls; + if (spanOpts?.text) span.textContent = spanOpts.text; + this.appendChild(span); + return span; + }; + (el as any).addClass = function (cls: string) { + this.classList.add(cls); + }; + return el; + }; + + await (view as any).renderActionItem( + listEl, + "Call client about meeting", + "Projects/Test.md", + "work", + false + ); + + const item = listEl.querySelector("li"); + expect(item).toBeTruthy(); + expect(item!.getAttribute("data-focus-file")).toBe("Projects/Test.md"); + expect(item!.getAttribute("data-focus-action")).toBe("Call client about meeting"); + }); + }); + + describe("focus highlighting refresh", () => { + it("should add sphere-action-in-focus class to items matching current focus", async () => { + const project = { + file: "Projects/Test.md", + title: "Test Project", + tags: ["project/work"], + status: "live" as const, + priority: 1, + nextActions: ["Call client", "Send email"], + mtime: Date.now(), + }; + + mockScanner.scanProjects.mockResolvedValue([project]); + mockLineFinder.findActionLine.mockResolvedValue({ + found: true, + lineNumber: 5, + lineContent: "- [ ] Call client", + }); + + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + + // Now set focus items so "Call client" is in focus + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Call client", + text: "Call client", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }, + ]; + + await (view as any).refreshFocusHighlighting(); + + const container = view.containerEl.children[1] as HTMLElement; + const items = container.querySelectorAll("li[data-focus-file]"); + + let focusedCount = 0; + let unfocusedCount = 0; + items.forEach((item) => { + if (item.classList.contains("sphere-action-in-focus")) { + focusedCount++; + expect(item.getAttribute("data-focus-action")).toBe("Call client"); + } else { + unfocusedCount++; + } + }); + + expect(focusedCount).toBe(1); + expect(unfocusedCount).toBe(1); + }); + + it("should remove sphere-action-in-focus class from items no longer in focus", async () => { + const project = { + file: "Projects/Test.md", + title: "Test Project", + tags: ["project/work"], + status: "live" as const, + priority: 1, + nextActions: ["Call client"], + mtime: Date.now(), + }; + + mockScanner.scanProjects.mockResolvedValue([project]); + + // Start with item in focus + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Call client", + text: "Call client", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }, + ]; + + mockLineFinder.findActionLine.mockResolvedValue({ + found: true, + lineNumber: 5, + lineContent: "- [ ] Call client", + }); + + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + // Wait for fire-and-forget renderActionItem calls to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + const container = view.containerEl.children[1] as HTMLElement; + const item = container.querySelector("li[data-focus-action='Call client']"); + expect(item).toBeTruthy(); + expect(item!.classList.contains("sphere-action-in-focus")).toBe(true); + + // Now remove from focus + mockFocusItems = []; + await (view as any).refreshFocusHighlighting(); + + expect(item!.classList.contains("sphere-action-in-focus")).toBe(false); + }); + }); + + describe("focus file change handling", () => { + it("should not trigger full refresh when focus file changes", async () => { + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + + const refreshSpy = jest.spyOn(view as any, "scheduleAutoRefresh"); + const highlightSpy = jest.spyOn(view as any, "refreshFocusHighlighting"); + + // Simulate metadata cache change for focus file + const metadataCacheOnCall = (app.metadataCache.on as jest.Mock).mock.calls.find( + (call: any[]) => call[0] === "changed" + ); + expect(metadataCacheOnCall).toBeTruthy(); + + const changeHandler = metadataCacheOnCall[1]; + const focusFile = new TFile("flow-focus-data/focus.md"); + changeHandler(focusFile); + + expect(refreshSpy).not.toHaveBeenCalled(); + expect(highlightSpy).toHaveBeenCalled(); + + refreshSpy.mockRestore(); + highlightSpy.mockRestore(); + }); + }); + describe("waiting-for visual indicator", () => { it("should display handshake emoji for waiting-for items", async () => { // Mock line finder to return waiting-for checkbox for one action @@ -693,6 +877,7 @@ describe("SphereView", () => { add: jest.fn(), }, addEventListener: jest.fn(), + setAttribute: jest.fn(), }), }; From d76a294a3ebd194f238b9a1af8766554b147f561 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Tue, 17 Feb 2026 07:45:29 +0000 Subject: [PATCH 13/25] Preserve scroll via data-before-empty instead of CSS-only updates The CSS-only approach for focus file changes broke sphere view updates when items were completed from the Focus view, because the focus file change was the only reliable trigger for a full refresh (Obsidian's metadata cache doesn't fire for checkbox body changes). Instead, preserve scroll position by loading sphere data before emptying the container, so the empty-and-rebuild happens in one synchronous block with no async gap (no visible flash). Scroll position is saved and restored around the refresh. --- src/sphere-view.ts | 27 ++-------- tests/sphere-view.test.ts | 107 ++++++++++++++------------------------ 2 files changed, 44 insertions(+), 90 deletions(-) diff --git a/src/sphere-view.ts b/src/sphere-view.ts index 99851b2f..f834e311 100644 --- a/src/sphere-view.ts +++ b/src/sphere-view.ts @@ -162,11 +162,7 @@ export class SphereView extends ItemView { this.app.metadataCache.offref(this.metadataCacheEventRef); } this.metadataCacheEventRef = this.app.metadataCache.on("changed", (file) => { - if (file.path === FOCUS_FILE_PATH) { - void this.refreshFocusHighlighting(); - return; - } - if (this.isRelevantFile(file)) { + if (file.path === FOCUS_FILE_PATH || this.isRelevantFile(file)) { this.scheduleAutoRefresh(); } }); @@ -305,9 +301,11 @@ export class SphereView extends ItemView { private async refresh(): Promise { const container = this.contentEl; - container.empty(); + const scrollTop = container.scrollTop; const data = await this.loadSphereData(); + container.empty(); this.renderContent(container, data); + container.scrollTop = scrollTop; } private async refreshContent(): Promise { @@ -807,23 +805,6 @@ export class SphereView extends ItemView { } } - private async refreshFocusHighlighting(): Promise { - const focusItems = await loadFocusItems(this.app.vault); - const actionElements = this.contentEl.querySelectorAll("li[data-focus-file]"); - - actionElements.forEach((el: Element) => { - const file = el.getAttribute("data-focus-file"); - const action = el.getAttribute("data-focus-action"); - const inFocus = focusItems.some((item) => item.file === file && item.text === action); - - if (inFocus) { - el.classList.add("sphere-action-in-focus"); - } else { - el.classList.remove("sphere-action-in-focus"); - } - }); - } - private async refreshFocusView(): Promise { const { workspace } = this.app; const leaves = workspace.getLeavesOfType(FOCUS_VIEW_TYPE); diff --git a/tests/sphere-view.test.ts b/tests/sphere-view.test.ts index f216d858..f4f4f9b9 100644 --- a/tests/sphere-view.test.ts +++ b/tests/sphere-view.test.ts @@ -715,8 +715,8 @@ describe("SphereView", () => { }); }); - describe("focus highlighting refresh", () => { - it("should add sphere-action-in-focus class to items matching current focus", async () => { + describe("focus highlighting on refresh", () => { + it("should update sphere-action-in-focus class on refresh", async () => { const project = { file: "Projects/Test.md", title: "Test Project", @@ -734,12 +734,23 @@ describe("SphereView", () => { lineContent: "- [ ] Call client", }); + // Start with no focus items + mockFocusItems = []; + const view = new SphereView(leaf, "work", settings, mockSaveSettings); view.app = app; await view.onOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const container = view.containerEl.children[1] as HTMLElement; + let items = container.querySelectorAll("li[data-focus-file]"); + expect(items.length).toBe(2); + items.forEach((item) => { + expect(item.classList.contains("sphere-action-in-focus")).toBe(false); + }); - // Now set focus items so "Call client" is in focus + // Add "Call client" to focus and refresh mockFocusItems = [ { file: "Projects/Test.md", @@ -752,87 +763,30 @@ describe("SphereView", () => { }, ]; - await (view as any).refreshFocusHighlighting(); - - const container = view.containerEl.children[1] as HTMLElement; - const items = container.querySelectorAll("li[data-focus-file]"); + await (view as any).refresh(); + await new Promise((resolve) => setTimeout(resolve, 10)); + items = container.querySelectorAll("li[data-focus-file]"); let focusedCount = 0; - let unfocusedCount = 0; items.forEach((item) => { if (item.classList.contains("sphere-action-in-focus")) { focusedCount++; expect(item.getAttribute("data-focus-action")).toBe("Call client"); - } else { - unfocusedCount++; } }); expect(focusedCount).toBe(1); - expect(unfocusedCount).toBe(1); - }); - - it("should remove sphere-action-in-focus class from items no longer in focus", async () => { - const project = { - file: "Projects/Test.md", - title: "Test Project", - tags: ["project/work"], - status: "live" as const, - priority: 1, - nextActions: ["Call client"], - mtime: Date.now(), - }; - - mockScanner.scanProjects.mockResolvedValue([project]); - - // Start with item in focus - mockFocusItems = [ - { - file: "Projects/Test.md", - lineNumber: 5, - lineContent: "- [ ] Call client", - text: "Call client", - sphere: "work", - isGeneral: false, - addedAt: Date.now(), - }, - ]; - - mockLineFinder.findActionLine.mockResolvedValue({ - found: true, - lineNumber: 5, - lineContent: "- [ ] Call client", - }); - - const view = new SphereView(leaf, "work", settings, mockSaveSettings); - view.app = app; - - await view.onOpen(); - // Wait for fire-and-forget renderActionItem calls to complete - await new Promise((resolve) => setTimeout(resolve, 10)); - - const container = view.containerEl.children[1] as HTMLElement; - const item = container.querySelector("li[data-focus-action='Call client']"); - expect(item).toBeTruthy(); - expect(item!.classList.contains("sphere-action-in-focus")).toBe(true); - - // Now remove from focus - mockFocusItems = []; - await (view as any).refreshFocusHighlighting(); - - expect(item!.classList.contains("sphere-action-in-focus")).toBe(false); }); }); describe("focus file change handling", () => { - it("should not trigger full refresh when focus file changes", async () => { + it("should trigger full refresh when focus file changes", async () => { const view = new SphereView(leaf, "work", settings, mockSaveSettings); view.app = app; await view.onOpen(); const refreshSpy = jest.spyOn(view as any, "scheduleAutoRefresh"); - const highlightSpy = jest.spyOn(view as any, "refreshFocusHighlighting"); // Simulate metadata cache change for focus file const metadataCacheOnCall = (app.metadataCache.on as jest.Mock).mock.calls.find( @@ -844,11 +798,30 @@ describe("SphereView", () => { const focusFile = new TFile("flow-focus-data/focus.md"); changeHandler(focusFile); - expect(refreshSpy).not.toHaveBeenCalled(); - expect(highlightSpy).toHaveBeenCalled(); + expect(refreshSpy).toHaveBeenCalled(); refreshSpy.mockRestore(); - highlightSpy.mockRestore(); + }); + }); + + describe("scroll position preservation", () => { + it("should preserve scroll position across refresh", async () => { + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + + // Simulate a scrolled state + const container = view.contentEl; + Object.defineProperty(container, "scrollTop", { + value: 350, + writable: true, + configurable: true, + }); + + await (view as any).refresh(); + + expect(container.scrollTop).toBe(350); }); }); From 3a9ea46f52cc000ba4486880826cba040d2792b6 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Tue, 17 Feb 2026 07:55:58 +0000 Subject: [PATCH 14/25] Wait for async renders before restoring scroll position The fire-and-forget renderActionItem calls mean the DOM isn't fully built when scrollTop is set, causing the browser to clamp the value. Use MutationObserver to wait for 50ms of DOM stability before restoring the scroll position. --- src/sphere-view.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/sphere-view.ts b/src/sphere-view.ts index f834e311..7845b120 100644 --- a/src/sphere-view.ts +++ b/src/sphere-view.ts @@ -305,9 +305,33 @@ export class SphereView extends ItemView { const data = await this.loadSphereData(); container.empty(); this.renderContent(container, data); + await this.waitForRenderSettled(container); container.scrollTop = scrollTop; } + private waitForRenderSettled(container: HTMLElement): Promise { + if (typeof MutationObserver === "undefined") { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const observer = new MutationObserver(() => { + clearTimeout(timeout); + timeout = setTimeout(() => { + observer.disconnect(); + resolve(); + }, 50); + }); + + let timeout = setTimeout(() => { + observer.disconnect(); + resolve(); + }, 50); + + observer.observe(container, { childList: true, subtree: true, characterData: true }); + }); + } + private async refreshContent(): Promise { // Prevent overlapping refresh calls if (this.refreshInProgress) { From 7670d9c5a8a4d3d926d9e44569291134753d646c Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Tue, 17 Feb 2026 08:03:48 +0000 Subject: [PATCH 15/25] Suppress rerender when focus change originates from sphere view Instead of re-rendering and restoring scroll (which causes a flash), set a flag before saving focus items so the metadata cache handler skips the auto-refresh. The CSS class update already happens locally. External focus changes (e.g. completing items in FocusView) still trigger a full refresh as before. --- src/sphere-view.ts | 37 ++++++++++--------------------------- tests/sphere-view.test.ts | 29 ++++++++++++++++------------- 2 files changed, 26 insertions(+), 40 deletions(-) diff --git a/src/sphere-view.ts b/src/sphere-view.ts index 7845b120..0eaf9b47 100644 --- a/src/sphere-view.ts +++ b/src/sphere-view.ts @@ -29,6 +29,7 @@ export class SphereView extends ItemView { private showNextActions: boolean = true; private metadataCacheEventRef: EventRef | null = null; private scheduledRefreshTimeout: ReturnType | null = null; + private suppressFocusRefresh: boolean = false; constructor( leaf: WorkspaceLeaf, @@ -162,7 +163,13 @@ export class SphereView extends ItemView { this.app.metadataCache.offref(this.metadataCacheEventRef); } this.metadataCacheEventRef = this.app.metadataCache.on("changed", (file) => { - if (file.path === FOCUS_FILE_PATH || this.isRelevantFile(file)) { + if (file.path === FOCUS_FILE_PATH) { + if (this.suppressFocusRefresh) { + this.suppressFocusRefresh = false; + return; + } + this.scheduleAutoRefresh(); + } else if (this.isRelevantFile(file)) { this.scheduleAutoRefresh(); } }); @@ -301,35 +308,9 @@ export class SphereView extends ItemView { private async refresh(): Promise { const container = this.contentEl; - const scrollTop = container.scrollTop; const data = await this.loadSphereData(); container.empty(); this.renderContent(container, data); - await this.waitForRenderSettled(container); - container.scrollTop = scrollTop; - } - - private waitForRenderSettled(container: HTMLElement): Promise { - if (typeof MutationObserver === "undefined") { - return Promise.resolve(); - } - - return new Promise((resolve) => { - const observer = new MutationObserver(() => { - clearTimeout(timeout); - timeout = setTimeout(() => { - observer.disconnect(); - resolve(); - }, 50); - }); - - let timeout = setTimeout(() => { - observer.disconnect(); - resolve(); - }, 50); - - observer.observe(container, { childList: true, subtree: true, characterData: true }); - }); } private async refreshContent(): Promise { @@ -774,6 +755,7 @@ export class SphereView extends ItemView { const focusItems = await loadFocusItems(this.app.vault); focusItems.push(item); + this.suppressFocusRefresh = true; await saveFocusItems(this.app.vault, focusItems); await this.activateFocusView(); await this.refreshFocusView(); @@ -793,6 +775,7 @@ export class SphereView extends ItemView { const updatedFocus = focusItems.filter( (item) => !(item.file === file && item.lineNumber === lineNumber) ); + this.suppressFocusRefresh = true; await saveFocusItems(this.app.vault, updatedFocus); await this.activateFocusView(); await this.refreshFocusView(); diff --git a/tests/sphere-view.test.ts b/tests/sphere-view.test.ts index f4f4f9b9..87b2c3e1 100644 --- a/tests/sphere-view.test.ts +++ b/tests/sphere-view.test.ts @@ -780,7 +780,7 @@ describe("SphereView", () => { }); describe("focus file change handling", () => { - it("should trigger full refresh when focus file changes", async () => { + it("should trigger full refresh when focus file changes externally", async () => { const view = new SphereView(leaf, "work", settings, mockSaveSettings); view.app = app; @@ -802,26 +802,29 @@ describe("SphereView", () => { refreshSpy.mockRestore(); }); - }); - describe("scroll position preservation", () => { - it("should preserve scroll position across refresh", async () => { + it("should suppress refresh when focus file change originated from this view", async () => { const view = new SphereView(leaf, "work", settings, mockSaveSettings); view.app = app; await view.onOpen(); - // Simulate a scrolled state - const container = view.contentEl; - Object.defineProperty(container, "scrollTop", { - value: 350, - writable: true, - configurable: true, - }); + const refreshSpy = jest.spyOn(view as any, "scheduleAutoRefresh"); - await (view as any).refresh(); + const metadataCacheOnCall = (app.metadataCache.on as jest.Mock).mock.calls.find( + (call: any[]) => call[0] === "changed" + ); + const changeHandler = metadataCacheOnCall[1]; + const focusFile = new TFile("flow-focus-data/focus.md"); + + // Set the suppress flag (as addToFocus/removeFromFocus would) + (view as any).suppressFocusRefresh = true; + changeHandler(focusFile); - expect(container.scrollTop).toBe(350); + expect(refreshSpy).not.toHaveBeenCalled(); + expect((view as any).suppressFocusRefresh).toBe(false); + + refreshSpy.mockRestore(); }); }); From 41092555b0e6dcf250299fda7f48002ac5af1191 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Tue, 17 Feb 2026 08:18:25 +0000 Subject: [PATCH 16/25] Replace full refresh with targeted updates for focus changes Focus file changes now trigger CSS-only updates in the sphere view (toggling sphere-action-in-focus classes) instead of a full re-render. When an item is completed in FocusView, a flow:action-completed workspace event removes the specific action from the sphere view DOM. This eliminates the jarring flash when interacting with the focus view while the sphere view is visible alongside it. --- src/focus-view.ts | 4 + src/sphere-view.ts | 53 +++++++++++- tests/__mocks__/obsidian.ts | 2 + tests/focus-integration.test.ts | 1 + tests/focus-view.test.ts | 32 +++++++ tests/sphere-view.test.ts | 148 ++++++++++++++++++++++++++++++-- 6 files changed, 232 insertions(+), 8 deletions(-) diff --git a/src/focus-view.ts b/src/focus-view.ts index fa62e336..4a864854 100644 --- a/src/focus-view.ts +++ b/src/focus-view.ts @@ -892,6 +892,10 @@ export class FocusView extends RefreshingView { if (focusIndex !== -1) { this.focusItems[focusIndex].completedAt = Date.now(); await this.saveFocus(); + (this.app.workspace as any).trigger("flow:action-completed", { + file: item.file, + action: item.text, + }); await this.onOpen(); // Re-render } } diff --git a/src/sphere-view.ts b/src/sphere-view.ts index 0eaf9b47..83b1ebc2 100644 --- a/src/sphere-view.ts +++ b/src/sphere-view.ts @@ -28,6 +28,7 @@ export class SphereView extends ItemView { private refreshInProgress: boolean = false; private showNextActions: boolean = true; private metadataCacheEventRef: EventRef | null = null; + private workspaceEventRefs: EventRef[] = []; private scheduledRefreshTimeout: ReturnType | null = null; private suppressFocusRefresh: boolean = false; @@ -73,6 +74,7 @@ export class SphereView extends ItemView { // Register metadata cache listener for automatic refresh when files change this.registerMetadataCacheListener(); + this.registerWorkspaceEvents(); try { const data = await this.loadSphereData(); @@ -115,6 +117,10 @@ export class SphereView extends ItemView { this.app.metadataCache.offref(this.metadataCacheEventRef); this.metadataCacheEventRef = null; } + for (const ref of this.workspaceEventRefs) { + this.app.workspace.offref(ref); + } + this.workspaceEventRefs = []; if (this.scheduledRefreshTimeout) { clearTimeout(this.scheduledRefreshTimeout); this.scheduledRefreshTimeout = null; @@ -168,13 +174,58 @@ export class SphereView extends ItemView { this.suppressFocusRefresh = false; return; } - this.scheduleAutoRefresh(); + void this.refreshFocusHighlighting(); } else if (this.isRelevantFile(file)) { this.scheduleAutoRefresh(); } }); } + private registerWorkspaceEvents(): void { + const completedRef = (this.app.workspace as any).on( + "flow:action-completed", + (detail: { file: string; action: string }) => { + this.removeActionFromDom(detail.file, detail.action); + } + ); + this.workspaceEventRefs.push(completedRef); + } + + private removeActionFromDom(file: string, action: string): void { + const container = this.contentEl; + const items = container.querySelectorAll("li[data-focus-file]"); + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if ( + item.getAttribute("data-focus-file") === file && + item.getAttribute("data-focus-action") === action + ) { + item.remove(); + return; + } + } + } + + private async refreshFocusHighlighting(): Promise { + const focusItems = await loadFocusItems(this.app.vault); + const container = this.contentEl; + const items = container.querySelectorAll("li[data-focus-file]"); + + for (let i = 0; i < items.length; i++) { + const item = items[i] as HTMLElement; + const file = item.getAttribute("data-focus-file"); + const action = item.getAttribute("data-focus-action"); + const inFocus = focusItems.some( + (focusItem) => focusItem.file === file && focusItem.text === action + ); + if (inFocus) { + item.classList.add("sphere-action-in-focus"); + } else { + item.classList.remove("sphere-action-in-focus"); + } + } + } + private isRelevantFile(file: TFile): boolean { // Refresh when any markdown file in the vault changes that might be a project // We check the metadata cache for project tags diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts index 12c0d13d..5eac209e 100644 --- a/tests/__mocks__/obsidian.ts +++ b/tests/__mocks__/obsidian.ts @@ -62,6 +62,8 @@ export class Workspace { detachLeavesOfType = jest.fn(); getActiveFile = jest.fn(); on = jest.fn(() => ({ unload: jest.fn() })); + offref = jest.fn(); + trigger = jest.fn(); onLayoutReady = jest.fn((callback: () => void) => callback()); iterateRootLeaves = jest.fn(); viewRegistry: Record = {}; diff --git a/tests/focus-integration.test.ts b/tests/focus-integration.test.ts index fe5acc2f..95bc46f8 100644 --- a/tests/focus-integration.test.ts +++ b/tests/focus-integration.test.ts @@ -311,6 +311,7 @@ describe("Focus Manual Reordering Integration", () => { workspace: { getLeaf: jest.fn(), getLeavesOfType: jest.fn().mockReturnValue([]), + trigger: jest.fn(), }, metadataCache: { on: jest.fn(), diff --git a/tests/focus-view.test.ts b/tests/focus-view.test.ts index 30e39386..197da3d0 100644 --- a/tests/focus-view.test.ts +++ b/tests/focus-view.test.ts @@ -46,6 +46,7 @@ describe("FocusView", () => { workspace: { getLeaf: jest.fn(), getLeavesOfType: jest.fn().mockReturnValue([]), + trigger: jest.fn(), }, metadataCache: { on: jest.fn(), @@ -1084,6 +1085,37 @@ describe("FocusView", () => { expect(items[0].completedAt).toBeDefined(); expect(items[0].completedAt).toBeGreaterThan(Date.now() - 1000); }); + + it("should trigger flow:action-completed workspace event", async () => { + const mockItem: FocusItem = { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Test action", + text: "Test action", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }; + + const TFile = require("obsidian").TFile; + const mockTFile = Object.create(TFile.prototype); + mockTFile.path = "Projects/Test.md"; + + mockApp.vault.getAbstractFileByPath.mockReturnValue(mockTFile); + mockApp.vault.read.mockResolvedValue("line1\nline2\nline3\nline4\n- [ ] Test action\nline6"); + + (view as any).focusItems = [mockItem]; + (view as any).validator = { + validateItem: jest.fn().mockResolvedValue({ found: true }), + }; + + await (view as any).markItemComplete(mockItem); + + expect(mockApp.workspace.trigger).toHaveBeenCalledWith("flow:action-completed", { + file: "Projects/Test.md", + action: "Test action", + }); + }); }); describe("getMidnightTimestamp", () => { diff --git a/tests/sphere-view.test.ts b/tests/sphere-view.test.ts index 87b2c3e1..6d756f8d 100644 --- a/tests/sphere-view.test.ts +++ b/tests/sphere-view.test.ts @@ -780,15 +780,15 @@ describe("SphereView", () => { }); describe("focus file change handling", () => { - it("should trigger full refresh when focus file changes externally", async () => { + it("should update CSS classes without full refresh when focus file changes", async () => { const view = new SphereView(leaf, "work", settings, mockSaveSettings); view.app = app; await view.onOpen(); const refreshSpy = jest.spyOn(view as any, "scheduleAutoRefresh"); + const highlightSpy = jest.spyOn(view as any, "refreshFocusHighlighting"); - // Simulate metadata cache change for focus file const metadataCacheOnCall = (app.metadataCache.on as jest.Mock).mock.calls.find( (call: any[]) => call[0] === "changed" ); @@ -798,18 +798,20 @@ describe("SphereView", () => { const focusFile = new TFile("flow-focus-data/focus.md"); changeHandler(focusFile); - expect(refreshSpy).toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + expect(highlightSpy).toHaveBeenCalled(); refreshSpy.mockRestore(); + highlightSpy.mockRestore(); }); - it("should suppress refresh when focus file change originated from this view", async () => { + it("should suppress CSS update when focus file change originated from this view", async () => { const view = new SphereView(leaf, "work", settings, mockSaveSettings); view.app = app; await view.onOpen(); - const refreshSpy = jest.spyOn(view as any, "scheduleAutoRefresh"); + const highlightSpy = jest.spyOn(view as any, "refreshFocusHighlighting"); const metadataCacheOnCall = (app.metadataCache.on as jest.Mock).mock.calls.find( (call: any[]) => call[0] === "changed" @@ -821,10 +823,142 @@ describe("SphereView", () => { (view as any).suppressFocusRefresh = true; changeHandler(focusFile); - expect(refreshSpy).not.toHaveBeenCalled(); + expect(highlightSpy).not.toHaveBeenCalled(); expect((view as any).suppressFocusRefresh).toBe(false); - refreshSpy.mockRestore(); + highlightSpy.mockRestore(); + }); + + it("should add/remove sphere-action-in-focus via CSS-only update", async () => { + const project = { + file: "Projects/Test.md", + title: "Test Project", + tags: ["project/work"], + status: "live" as const, + priority: 1, + nextActions: ["Call client", "Send email"], + mtime: Date.now(), + }; + + mockScanner.scanProjects.mockResolvedValue([project]); + mockLineFinder.findActionLine.mockResolvedValue({ + found: true, + lineNumber: 5, + lineContent: "- [ ] Call client", + }); + + mockFocusItems = []; + + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const container = view.containerEl.children[1] as HTMLElement; + let items = container.querySelectorAll("li[data-focus-file]"); + expect(items.length).toBe(2); + items.forEach((item) => { + expect(item.classList.contains("sphere-action-in-focus")).toBe(false); + }); + + // Add "Call client" to focus and run CSS-only update + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Call client", + text: "Call client", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }, + ]; + + await (view as any).refreshFocusHighlighting(); + + items = container.querySelectorAll("li[data-focus-file]"); + let focusedCount = 0; + items.forEach((item) => { + if (item.classList.contains("sphere-action-in-focus")) { + focusedCount++; + expect(item.getAttribute("data-focus-action")).toBe("Call client"); + } + }); + expect(focusedCount).toBe(1); + + // Remove from focus and run CSS-only update + mockFocusItems = []; + await (view as any).refreshFocusHighlighting(); + + items = container.querySelectorAll("li[data-focus-file]"); + items.forEach((item) => { + expect(item.classList.contains("sphere-action-in-focus")).toBe(false); + }); + }); + }); + + describe("workspace event handling", () => { + it("should remove action from DOM on flow:action-completed event", async () => { + const project = { + file: "Projects/Test.md", + title: "Test Project", + tags: ["project/work"], + status: "live" as const, + priority: 1, + nextActions: ["Call client", "Send email"], + mtime: Date.now(), + }; + + mockScanner.scanProjects.mockResolvedValue([project]); + mockLineFinder.findActionLine.mockResolvedValue({ + found: true, + lineNumber: 5, + lineContent: "- [ ] Call client", + }); + + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const container = view.containerEl.children[1] as HTMLElement; + let items = container.querySelectorAll("li[data-focus-file]"); + expect(items.length).toBe(2); + + // Find the workspace.on call for flow:action-completed + const workspaceOnCalls = (app.workspace.on as jest.Mock).mock.calls; + const completedHandler = workspaceOnCalls.find( + (call: any[]) => call[0] === "flow:action-completed" + ); + expect(completedHandler).toBeTruthy(); + + // Simulate completion event for "Call client" + completedHandler[1]({ file: "Projects/Test.md", action: "Call client" }); + + items = container.querySelectorAll("li[data-focus-file]"); + expect(items.length).toBe(1); + expect(items[0].getAttribute("data-focus-action")).toBe("Send email"); + }); + + it("should clean up workspace event listeners on close", async () => { + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + + // Verify workspace events were registered + const workspaceOnCalls = (app.workspace.on as jest.Mock).mock.calls; + const completedHandler = workspaceOnCalls.find( + (call: any[]) => call[0] === "flow:action-completed" + ); + expect(completedHandler).toBeTruthy(); + + await view.onClose(); + + // offref should have been called for workspace event refs + expect(app.workspace.offref).toBeDefined(); }); }); From 15702f44dba919c379a1eaa32e72165290976bf5 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Tue, 17 Feb 2026 08:31:19 +0000 Subject: [PATCH 17/25] Update sphere view dynamically when action converts to waiting-for FocusView now triggers a flow:action-waiting workspace event when converting an action to waiting-for. SphereView listens and re-renders the specific action's content with the handshake emoji, avoiding a full view refresh. --- src/focus-view.ts | 4 ++++ src/sphere-view.ts | 24 ++++++++++++++++++++ tests/focus-view.test.ts | 34 ++++++++++++++++++++++++++++ tests/sphere-view.test.ts | 47 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+) diff --git a/src/focus-view.ts b/src/focus-view.ts index 4a864854..b277a78e 100644 --- a/src/focus-view.ts +++ b/src/focus-view.ts @@ -931,6 +931,10 @@ export class FocusView extends RefreshingView { if (focusIndex !== -1) { this.focusItems[focusIndex].lineContent = updatedLine; await this.saveFocus(); + (this.app.workspace as any).trigger("flow:action-waiting", { + file: item.file, + action: item.text, + }); await this.onOpen(); // Re-render } } diff --git a/src/sphere-view.ts b/src/sphere-view.ts index 83b1ebc2..52936aee 100644 --- a/src/sphere-view.ts +++ b/src/sphere-view.ts @@ -189,6 +189,14 @@ export class SphereView extends ItemView { } ); this.workspaceEventRefs.push(completedRef); + + const waitingRef = (this.app.workspace as any).on( + "flow:action-waiting", + (detail: { file: string; action: string }) => { + void this.markActionWaitingInDom(detail.file, detail.action); + } + ); + this.workspaceEventRefs.push(waitingRef); } private removeActionFromDom(file: string, action: string): void { @@ -206,6 +214,22 @@ export class SphereView extends ItemView { } } + private async markActionWaitingInDom(file: string, action: string): Promise { + const container = this.contentEl; + const items = container.querySelectorAll("li[data-focus-file]"); + for (let i = 0; i < items.length; i++) { + const item = items[i] as HTMLElement; + if ( + item.getAttribute("data-focus-file") === file && + item.getAttribute("data-focus-action") === action + ) { + item.empty(); + await MarkdownRenderer.renderMarkdown(`🤝 ${action}`, item, "", this); + return; + } + } + } + private async refreshFocusHighlighting(): Promise { const focusItems = await loadFocusItems(this.app.vault); const container = this.contentEl; diff --git a/tests/focus-view.test.ts b/tests/focus-view.test.ts index 197da3d0..30c856ff 100644 --- a/tests/focus-view.test.ts +++ b/tests/focus-view.test.ts @@ -297,6 +297,40 @@ describe("FocusView", () => { (view as any).refreshSphereViews = originalRefreshSphereViews; }); + it("should trigger flow:action-waiting workspace event", async () => { + const item: FocusItem = { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Call client about proposal", + text: "Call client about proposal", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }; + + (view as any).focusItems = [item]; + + const { TFile } = require("obsidian"); + const mockFile = new TFile(); + mockFile.path = "Projects/Test.md"; + + mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile); + mockApp.vault.read.mockResolvedValue( + "line1\nline2\nline3\nline4\n- [ ] Call client about proposal\nline6" + ); + + (view as any).validator = { + validateItem: jest.fn().mockResolvedValue({ found: true, updatedLineNumber: 5 }), + }; + + await (view as any).convertToWaitingFor((view as any).focusItems[0]); + + expect(mockApp.workspace.trigger).toHaveBeenCalledWith("flow:action-waiting", { + file: "Projects/Test.md", + action: "Call client about proposal", + }); + }); + it("should extract checkbox status from line content", () => { const getCheckboxStatusChar = (view as any).getCheckboxStatusChar.bind(view); diff --git a/tests/sphere-view.test.ts b/tests/sphere-view.test.ts index 6d756f8d..829e0a3e 100644 --- a/tests/sphere-view.test.ts +++ b/tests/sphere-view.test.ts @@ -942,6 +942,53 @@ describe("SphereView", () => { expect(items[0].getAttribute("data-focus-action")).toBe("Send email"); }); + it("should add handshake emoji on flow:action-waiting event", async () => { + const project = { + file: "Projects/Test.md", + title: "Test Project", + tags: ["project/work"], + status: "live" as const, + priority: 1, + nextActions: ["Call client", "Send email"], + mtime: Date.now(), + }; + + mockScanner.scanProjects.mockResolvedValue([project]); + mockLineFinder.findActionLine.mockResolvedValue({ + found: true, + lineNumber: 5, + lineContent: "- [ ] Call client", + }); + + const view = new SphereView(leaf, "work", settings, mockSaveSettings); + view.app = app; + + await view.onOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const container = view.containerEl.children[1] as HTMLElement; + + // Find the workspace.on call for flow:action-waiting + const workspaceOnCalls = (app.workspace.on as jest.Mock).mock.calls; + const waitingHandler = workspaceOnCalls.find( + (call: any[]) => call[0] === "flow:action-waiting" + ); + expect(waitingHandler).toBeTruthy(); + + // Simulate waiting-for event for "Call client" + waitingHandler[1]({ file: "Projects/Test.md", action: "Call client" }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // The action should still exist but now have the handshake emoji + const items = container.querySelectorAll("li[data-focus-file]"); + expect(items.length).toBe(2); + const callClientItem = Array.from(items).find( + (item) => item.getAttribute("data-focus-action") === "Call client" + ); + expect(callClientItem).toBeTruthy(); + expect(callClientItem!.textContent).toContain("🤝"); + }); + it("should clean up workspace event listeners on close", async () => { const view = new SphereView(leaf, "work", settings, mockSaveSettings); view.app = app; From 328f967ea436dcc592542cb8a7d7f7e9f1d3cd65 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Tue, 17 Feb 2026 08:52:20 +0000 Subject: [PATCH 18/25] Clean up workspace event refs on re-registration Unregister existing workspace event handlers before registering new ones in registerWorkspaceEvents, matching the pattern used by registerMetadataCacheListener. Also strengthen the cleanup test assertion from toBeDefined to toHaveBeenCalled. --- src/sphere-view.ts | 5 +++++ tests/sphere-view.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/sphere-view.ts b/src/sphere-view.ts index 52936aee..4f8fdcfd 100644 --- a/src/sphere-view.ts +++ b/src/sphere-view.ts @@ -182,6 +182,11 @@ export class SphereView extends ItemView { } private registerWorkspaceEvents(): void { + for (const ref of this.workspaceEventRefs) { + this.app.workspace.offref(ref); + } + this.workspaceEventRefs = []; + const completedRef = (this.app.workspace as any).on( "flow:action-completed", (detail: { file: string; action: string }) => { diff --git a/tests/sphere-view.test.ts b/tests/sphere-view.test.ts index 829e0a3e..ded4197d 100644 --- a/tests/sphere-view.test.ts +++ b/tests/sphere-view.test.ts @@ -1004,8 +1004,8 @@ describe("SphereView", () => { await view.onClose(); - // offref should have been called for workspace event refs - expect(app.workspace.offref).toBeDefined(); + // offref should have been called for each workspace event ref + expect(app.workspace.offref).toHaveBeenCalled(); }); }); From 1d04ed8c914d643634f617938ea22264f6de1a26 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Tue, 17 Feb 2026 12:40:12 +0000 Subject: [PATCH 19/25] Release v1.2.3 --- manifest.json | 2 +- package.json | 2 +- versions.json | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index f477ae99..e42d7d11 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "flow", "name": "Flow", - "version": "1.2.2", + "version": "1.2.3", "minAppVersion": "0.15.0", "description": "Implements key processes in David Allen's Getting Things Done (GTD) methodology", "author": "Ben Phillips", diff --git a/package.json b/package.json index dea99a60..ba2289dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flow", - "version": "1.2.2", + "version": "1.2.3", "description": "Implements key processes in David Allen's Getting Things Done (GTD) methodology", "main": "main.js", "scripts": { diff --git a/versions.json b/versions.json index 360b4a17..bf38db24 100644 --- a/versions.json +++ b/versions.json @@ -9,5 +9,6 @@ "1.1.3": "0.15.0", "1.2.0": "0.15.0", "1.2.1": "0.15.0", - "1.2.2": "0.15.0" + "1.2.2": "0.15.0", + "1.2.3": "0.15.0" } From 24b499461e654628e12e4a13b48e0412e1ece59a Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Tue, 17 Feb 2026 15:51:12 +0000 Subject: [PATCH 20/25] Update AGENTS.md to reflect current architecture and remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md was significantly out of date — it described AI-powered inbox processing that no longer exists, referenced deleted files (gtd-processor, flow-coach-view), and was missing many current components. Also removes project-title-prompt.ts and its test, which were unused since AI inbox processing was removed. --- AGENTS.md | 51 +++++++++++++++++++++--------- src/project-title-prompt.ts | 17 ---------- tests/project-title-prompt.test.ts | 11 ------- 3 files changed, 36 insertions(+), 43 deletions(-) delete mode 100644 src/project-title-prompt.ts delete mode 100644 tests/project-title-prompt.test.ts diff --git a/AGENTS.md b/AGENTS.md index 87d4fc21..b4aabcd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ This file provides guidance to Claude Code and other agents when working with co ## Project Overview -Flow is an Obsidian plugin implementing GTD (Getting Things Done) with AI-powered inbox processing. It uses Claude or OpenAI-compatible models to categorise inbox items into projects, next actions, reference material, someday/maybe items, and person notes. +Flow is an Obsidian plugin implementing GTD (Getting Things Done). It provides manual inbox processing, project management with hierarchical spheres, focus lists, waiting-for tracking, and someday/maybe items. LLM integration (via OpenRouter) is used for cover image generation. ## Commands @@ -15,7 +15,6 @@ npm run test:watch # Tests in watch mode npm run test:coverage # Coverage report (80% threshold) npm run format # Format with Prettier npm run format:check # Check formatting without modifying -npm run evaluate # Run AI evaluations (requires Anthropic credentials) npm run release # Interactive production release workflow npm run release:beta # Interactive beta release workflow ``` @@ -30,14 +29,32 @@ Before declaring any task complete, always run: ## Architecture -### Core Processing Flow +### Scanners -1. **Flow Scanner** (`src/flow-scanner.ts`) - Scans vault for projects (files with `project/*` tags in frontmatter) -2. **Person Scanner** (`src/person-scanner.ts`) - Scans for person notes (files with `person` tag) -3. **Inbox Scanner** (`src/inbox-scanner.ts`) - Scans inbox folders for items to process -4. **GTD Processor** (`src/gtd-processor.ts`) - AI analysis with context from existing projects/people -5. **LLM Factory** (`src/llm-factory.ts`) - Factory for Anthropic/OpenAI-compatible clients -6. **File Writer** (`src/file-writer.ts`) - Creates/updates project files with Flow frontmatter +- **Flow Scanner** (`src/flow-scanner.ts`) - Scans vault for projects (files with `project/*` tags in frontmatter) +- **Person Scanner** (`src/person-scanner.ts`) - Scans for person notes (files with `person` tag) +- **Inbox Scanner** (`src/inbox-scanner.ts`) - Scans inbox folders for items to process +- **Waiting For Scanner** (`src/waiting-for-scanner.ts`) - Scans for `[w]` items across vault +- **Someday Scanner** (`src/someday-scanner.ts`) - Scans for someday/maybe items +- **GTD Context Scanner** (`src/gtd-context-scanner.ts`) - Scans vault for comprehensive GTD system state + +### Inbox Processing + +Inbox processing is manual and UI-driven (no AI involvement): + +- **InboxProcessingView** (`src/inbox-processing-view.ts`) - Full Obsidian tab view for processing +- **InboxProcessingController** (`src/inbox-processing-controller.ts`) - Coordinates the processing workflow +- **InboxItemPersistence** (`src/inbox-item-persistence.ts`) - Saves processed items to vault +- **File Writer** (`src/file-writer.ts`) - Creates/updates project files with Flow frontmatter + +Supporting UI: `src/inbox-modal-state.ts`, `src/inbox-modal-utils.ts`, `src/inbox-modal-views.ts`, `src/inbox-types.ts` + +### Project Management + +- **Project Hierarchy** (`src/project-hierarchy.ts`) - Builds/manages hierarchical project relationships +- **Project Filters** (`src/project-filters.ts`) - Filtering utilities (live projects, templates) +- **Sphere Data Loader** (`src/sphere-data-loader.ts`) - Loads and filters sphere data +- **System Analyzer** (`src/system-analyzer.ts`) - Detects GTD system issues (stalled projects, large inboxes) ### Key Domain Concepts @@ -90,14 +107,17 @@ Project description and context. - **FocusView** (`src/focus-view.ts`) - Curated action list with pinning and reordering - **WaitingForView** (`src/waiting-for-view.ts`) - Aggregated `[w]` items across vault - **SomedayView** (`src/someday-view.ts`) - Someday/maybe items -- **FlowCoachView** (`src/flow-coach-view.ts`) - Chat interface for GTD coaching (incomplete, not currently enabled) +- **InboxProcessingView** (`src/inbox-processing-view.ts`) - Inbox processing interface +- **RefreshingView** (`src/refreshing-view.ts`) - Base class for auto-refreshing views ### Focus System -- Items stored in `flow-focus-data/focus.md` as JSON with: file, lineNumber, lineContent, text, sphere, isPinned, completedAt +- Items stored in `flow-focus-data/focus.md` as JSONL (one JSON object per line, sync-friendly) +- **FocusPersistence** (`src/focus-persistence.ts`) - Reads/writes focus items in JSONL format - **ActionLineFinder** (`src/action-line-finder.ts`) - Finds exact line numbers for actions - **FocusValidator** (`src/focus-validator.ts`) - Validates items when source files change - **FocusAutoClear** (`src/focus-auto-clear.ts`) - Automatic daily clearing at configured time +- **WaitingForValidator** (`src/waiting-for-validator.ts`) - Validates/resolves waiting-for items ### Task Status Cycling @@ -150,7 +170,8 @@ All source files start with two ABOUTME lines: ### LLM Integration -- Default provider: OpenAI-compatible (OpenRouter) -- Fallback: Anthropic Claude -- British English for all AI responses -- Structured JSON output from Claude +LLM is used only for cover image generation, not for inbox processing: + +- **LLM Factory** (`src/llm-factory.ts`) - Creates LLM clients +- **Cover Image Generator** (`src/cover-image-generator.ts`) - Generates project cover images via OpenRouter +- Provider: OpenAI-compatible (OpenRouter) diff --git a/src/project-title-prompt.ts b/src/project-title-prompt.ts deleted file mode 100644 index 25d02dee..00000000 --- a/src/project-title-prompt.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const buildProjectTitlePrompt = ( - originalItem: string -): string => `Given this inbox item: "${originalItem}" - -The user wants to create a project for this. Suggest a clear, concise project title that: -- States the desired outcome (not just the topic) -- Is specific and measurable -- Defines what "done" looks like -- Uses past tense or completion-oriented language when appropriate - -Examples: -- Good: "Website redesign complete and deployed" -- Bad: "Website project" -- Good: "Kitchen renovation finished" -- Bad: "Kitchen stuff" - -Respond with ONLY the project title, nothing else.`; diff --git a/tests/project-title-prompt.test.ts b/tests/project-title-prompt.test.ts deleted file mode 100644 index d5a11464..00000000 --- a/tests/project-title-prompt.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { buildProjectTitlePrompt } from "../src/project-title-prompt"; - -describe("buildProjectTitlePrompt", () => { - it("includes the original inbox item and clear guidance", () => { - const prompt = buildProjectTitlePrompt("Organize files"); - - expect(prompt).toContain("Organize files"); - expect(prompt).toContain("Respond with ONLY the project title"); - expect(prompt.startsWith("Given this inbox item")).toBe(true); - }); -}); From b9c8ca554dbed0ee3a5c230aa95dce107da8e64a Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Tue, 17 Feb 2026 19:47:57 +0000 Subject: [PATCH 21/25] Add design doc for GTD context tag filtering --- docs/plans/2026-02-17-context-tags-design.md | 79 ++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/plans/2026-02-17-context-tags-design.md diff --git a/docs/plans/2026-02-17-context-tags-design.md b/docs/plans/2026-02-17-context-tags-design.md new file mode 100644 index 00000000..40878278 --- /dev/null +++ b/docs/plans/2026-02-17-context-tags-design.md @@ -0,0 +1,79 @@ +# GTD Context Tags + +## Problem + +Flow lacks support for GTD contexts (@home, @phone, @errands) — a core GTD +concept that answers "what can I do right here, right now?" Users can add +Obsidian tags manually but Flow doesn't extract, display, or filter by them. + +## Design + +### Tag Format + +`#context/X` inline on action checkbox lines, matching the existing `#sphere/X` +pattern: + +```markdown +- [ ] Call dentist about appointment #context/phone +- [ ] Buy milk and eggs #context/errands +- [ ] Review pull request #context/computer +``` + +### Scope + +- **Action lines only.** No project-level defaults, no tags on projects or + someday items. +- **Not stripped from display text.** Unlike `#sphere/X`, context tags remain + visible — they convey useful information at a glance. +- **No predefined list.** Contexts are discovered dynamically from whatever + `#context/X` tags exist in the vault. + +### Data Model + +Add `contexts: string[]` to action data structures: + +- Actions in `SphereDataLoader` output +- `FocusItem` in `types/domain.ts` (persisted in JSONL) +- Waiting-for items from `WaitingForScanner` +- Someday items from `SomedayScanner` + +Default `[]` for actions without context tags. Existing FocusItems without a +`contexts` field default to `[]` on read. + +### Extraction + +Extend existing scanners to extract `#context/X` alongside `#sphere/X`: + +- **`SphereDataLoader`** — extract context tags during action line parsing +- **`WaitingForScanner`** — extract context tags alongside sphere tags +- **`SomedayScanner`** — same pattern +- **`FocusPersistence`** — persist `contexts` array in JSONL, default `[]` on + read for backwards compatibility + +Regex: `/#context\/([^\s]+)/gi` + +### Filter UI + +Multi-select toggle buttons in all four views: + +- **SphereView** — context filter alongside existing text search +- **FocusView** — context filter (currently has no filter UI) +- **WaitingForView** — context filter below existing sphere filter +- **SomedayView** — context filter below existing sphere filter + +Behaviour: + +- Buttons show all context tags found in the current view's data +- No contexts selected = show everything (no filtering) +- One or more selected = show only actions with at least one matching tag +- Actions with no context tags are hidden when any filter is active +- Available buttons update dynamically as data changes +- Selected contexts persisted via `getState()`/`setState()` + +Follows the existing sphere filter pattern from WaitingForView/SomedayView. + +### Out of Scope + +- Autocomplete for context tags in inbox modal (follow-up) +- AI-suggested context tags +- Context tags on non-action items From 6eeb4d09cb73201c222d9f9e2e3646efb9050d04 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Tue, 17 Feb 2026 19:51:12 +0000 Subject: [PATCH 22/25] Add implementation plan for context tag filtering --- docs/plans/2026-02-17-context-tags.md | 794 ++++++++++++++++++++++++++ 1 file changed, 794 insertions(+) create mode 100644 docs/plans/2026-02-17-context-tags.md diff --git a/docs/plans/2026-02-17-context-tags.md b/docs/plans/2026-02-17-context-tags.md new file mode 100644 index 00000000..7585bd3b --- /dev/null +++ b/docs/plans/2026-02-17-context-tags.md @@ -0,0 +1,794 @@ +# Context Tags Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add GTD context tag filtering (`#context/X`) to all four views (Sphere, Focus, WaitingFor, Someday). + +**Architecture:** Extract `#context/X` tags from action lines at scan time, store on item data structures, and add multi-select toggle filter UI to each view following the existing sphere filter pattern. + +**Tech Stack:** TypeScript, Obsidian API, Jest + +--- + +### Task 1: Context tag extraction utility + +**Files:** +- Create: `src/context-tags.ts` +- Test: `tests/context-tags.test.ts` + +**Step 1: Write the failing tests** + +```typescript +// tests/context-tags.test.ts +// ABOUTME: Tests for context tag extraction from action line text. +// ABOUTME: Validates parsing of #context/X tags from various line formats. + +import { extractContexts } from "../src/context-tags"; + +describe("extractContexts", () => { + it("extracts a single context tag", () => { + expect(extractContexts("Call dentist #context/phone")).toEqual(["phone"]); + }); + + it("extracts multiple context tags", () => { + expect(extractContexts("Check email #context/computer #context/office")).toEqual([ + "computer", + "office", + ]); + }); + + it("returns empty array when no context tags", () => { + expect(extractContexts("Buy milk and eggs")).toEqual([]); + }); + + it("is case-insensitive", () => { + expect(extractContexts("Task #Context/Phone")).toEqual(["phone"]); + }); + + it("ignores sphere tags", () => { + expect(extractContexts("Task #sphere/work #context/phone")).toEqual(["phone"]); + }); + + it("handles context tag at start of text", () => { + expect(extractContexts("#context/home clean kitchen")).toEqual(["home"]); + }); + + it("handles hyphenated context names", () => { + expect(extractContexts("Task #context/at-computer")).toEqual(["at-computer"]); + }); + + it("deduplicates repeated contexts", () => { + expect(extractContexts("Task #context/phone #context/phone")).toEqual(["phone"]); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm test -- context-tags` +Expected: FAIL — module not found + +**Step 3: Write minimal implementation** + +```typescript +// src/context-tags.ts +// ABOUTME: Extracts GTD context tags (#context/X) from action line text. +// ABOUTME: Used by scanners and views for context-based filtering. + +const CONTEXT_TAG_PATTERN = /#context\/([^\s]+)/gi; + +export function extractContexts(text: string): string[] { + const contexts: string[] = []; + let match; + + while ((match = CONTEXT_TAG_PATTERN.exec(text)) !== null) { + const context = match[1].toLowerCase(); + if (!contexts.includes(context)) { + contexts.push(context); + } + } + + // Reset lastIndex since we're using a global regex + CONTEXT_TAG_PATTERN.lastIndex = 0; + + return contexts; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm test -- context-tags` +Expected: PASS + +**Step 5: Commit** + +``` +git add src/context-tags.ts tests/context-tags.test.ts +git commit -m "Add context tag extraction utility" +``` + +--- + +### Task 2: Add contexts to WaitingForItem + +**Files:** +- Modify: `src/waiting-for-scanner.ts` (WaitingForItem interface + extractContexts calls) +- Modify: `tests/waiting-for-scanner.test.ts` + +**Step 1: Write failing tests** + +Add to `tests/waiting-for-scanner.test.ts`: + +```typescript +test("should extract context tags from waiting-for items", async () => { + const mockFile = Object.create(TFile.prototype); + mockFile.path = "Projects/Project A.md"; + mockFile.basename = "Project A"; + + mockVault.getMarkdownFiles.mockReturnValue([mockFile]); + mockVault.getAbstractFileByPath.mockImplementation((path) => { + if (path === "Projects/Project A.md") return mockFile; + return null; + }); + mockVault.read.mockResolvedValue( + "---\ntags: project/work\n---\n\n## Next actions\n\n- [w] Chase invoice from supplier #context/phone\n" + ); + + mockMetadataCache.getFileCache.mockReturnValue({ + frontmatter: { tags: ["project/work"] }, + listItems: [{ position: { start: { line: 6 } } }], + } as any); + + const items = await scanner.scanWaitingForItems(); + + expect(items).toHaveLength(1); + expect(items[0].contexts).toEqual(["phone"]); +}); + +test("should return empty contexts array when no context tags", async () => { + const mockFile = Object.create(TFile.prototype); + mockFile.path = "Projects/Project A.md"; + mockFile.basename = "Project A"; + + mockVault.getMarkdownFiles.mockReturnValue([mockFile]); + mockVault.getAbstractFileByPath.mockImplementation((path) => { + if (path === "Projects/Project A.md") return mockFile; + return null; + }); + mockVault.read.mockResolvedValue( + "---\ntags: project/work\n---\n\n## Next actions\n\n- [w] Plain waiting item\n" + ); + + mockMetadataCache.getFileCache.mockReturnValue({ + frontmatter: { tags: ["project/work"] }, + listItems: [{ position: { start: { line: 6 } } }], + } as any); + + const items = await scanner.scanWaitingForItems(); + + expect(items).toHaveLength(1); + expect(items[0].contexts).toEqual([]); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm test -- waiting-for-scanner` +Expected: FAIL — `contexts` property missing from items + +**Step 3: Implement** + +In `src/waiting-for-scanner.ts`: + +1. Add import: `import { extractContexts } from "./context-tags";` +2. Add `contexts: string[]` to `WaitingForItem` interface +3. In `scanWithDataview` method, add `contexts: extractContexts(lineContent)` to the pushed item (around line 100-107) +4. In `scanFile` method, add `contexts: extractContexts(line)` to the pushed item (around line 144-151) + +**Step 4: Run tests to verify they pass** + +Run: `npm test -- waiting-for-scanner` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `npm test` +Expected: Some tests may fail if they assert on WaitingForItem shape without `contexts`. Fix any failing tests by adding `contexts: []` to expected items. + +**Step 6: Commit** + +``` +git add src/waiting-for-scanner.ts tests/waiting-for-scanner.test.ts +git commit -m "Add context tag extraction to WaitingForScanner" +``` + +--- + +### Task 3: Add contexts to SomedayItem + +**Files:** +- Modify: `src/someday-scanner.ts` (SomedayItem interface + extractContexts calls) +- Create: `tests/someday-scanner.test.ts` (there is no existing test file) + +**Step 1: Write failing tests** + +Create `tests/someday-scanner.test.ts` with tests for context extraction from someday items. Follow the pattern from `tests/waiting-for-scanner.test.ts` — mock App, Vault, MetadataCache, create a SomedayScanner, verify `contexts` field is populated. + +Key test cases: +- Item with context tag → `contexts: ["phone"]` +- Item without context tag → `contexts: []` +- Item with multiple context tags → `contexts: ["phone", "computer"]` + +**Step 2: Run tests to verify they fail** + +Run: `npm test -- someday-scanner` +Expected: FAIL + +**Step 3: Implement** + +In `src/someday-scanner.ts`: + +1. Add import: `import { extractContexts } from "./context-tags";` +2. Add `contexts: string[]` to `SomedayItem` interface +3. In `scanSomedayFile` method, add `contexts: extractContexts(line)` to the pushed item (around line 90-97) + +**Step 4: Run tests to verify they pass** + +Run: `npm test -- someday-scanner` +Expected: PASS + +**Step 5: Run full test suite to check for breakage** + +Run: `npm test` + +**Step 6: Commit** + +``` +git add src/someday-scanner.ts tests/someday-scanner.test.ts +git commit -m "Add context tag extraction to SomedayScanner" +``` + +--- + +### Task 4: Add contexts to FocusItem and persistence + +**Files:** +- Modify: `src/types/domain.ts` (FocusItem interface) +- Modify: `src/focus-persistence.ts` (default `contexts` to `[]` on load) +- Modify: `tests/focus-persistence.test.ts` + +**Step 1: Write failing tests** + +Add to `tests/focus-persistence.test.ts`: + +```typescript +it("defaults contexts to empty array when loading items without contexts field", async () => { + const itemWithoutContexts = { + file: "Projects/Test.md", + lineNumber: 10, + lineContent: "- [ ] Task 1", + text: "Task 1", + sphere: "work", + isGeneral: false, + addedAt: 1700000000000, + }; + + const mockFile = new TFile(); + mockVault.getAbstractFileByPath.mockReturnValue(mockFile); + mockVault.read.mockResolvedValue(JSON.stringify(itemWithoutContexts)); + + const result = await loadFocusItems(mockVault); + + expect(result).toHaveLength(1); + expect(result[0].contexts).toEqual([]); +}); + +it("preserves contexts when loading items with contexts field", async () => { + const itemWithContexts = { + file: "Projects/Test.md", + lineNumber: 10, + lineContent: "- [ ] Call dentist #context/phone", + text: "Call dentist #context/phone", + sphere: "work", + isGeneral: false, + addedAt: 1700000000000, + contexts: ["phone"], + }; + + const mockFile = new TFile(); + mockVault.getAbstractFileByPath.mockReturnValue(mockFile); + mockVault.read.mockResolvedValue(JSON.stringify(itemWithContexts)); + + const result = await loadFocusItems(mockVault); + + expect(result).toHaveLength(1); + expect(result[0].contexts).toEqual(["phone"]); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm test -- focus-persistence` +Expected: FAIL — `contexts` property missing + +**Step 3: Implement** + +In `src/types/domain.ts`, add to FocusItem interface: +```typescript +contexts?: string[]; // GTD context tags (#context/X) from the action line +``` + +In `src/focus-persistence.ts`, in the `parseJsonlFormat` function, after parsing each item add a default: +```typescript +// After JSON.parse(trimmed) in parseJsonlFormat: +const item: FocusItem = JSON.parse(trimmed); +if (!item.contexts) { + item.contexts = []; +} +items.push(item); +``` + +Do the same in `parseLegacyFormat` — iterate items and default `contexts` to `[]`. + +**Step 4: Run tests to verify they pass** + +Run: `npm test -- focus-persistence` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `npm test` +Expected: PASS (FocusItem has optional `contexts` so existing code won't break) + +**Step 6: Commit** + +``` +git add src/types/domain.ts src/focus-persistence.ts tests/focus-persistence.test.ts +git commit -m "Add contexts field to FocusItem with backwards-compatible persistence" +``` + +--- + +### Task 5: Populate contexts when adding to focus from SphereView + +**Files:** +- Modify: `src/sphere-view.ts` (addToFocus method) + +**Step 1: Write the change** + +In `src/sphere-view.ts`, in the `addToFocus` method (around line 817-847): + +1. Add import: `import { extractContexts } from "./context-tags";` +2. When constructing the FocusItem, add: `contexts: extractContexts(lineContent)` + +The `addToFocus` method already has `lineContent` as a parameter, so extraction is straightforward. + +**Step 2: Run full test suite** + +Run: `npm test` +Expected: PASS + +**Step 3: Commit** + +``` +git add src/sphere-view.ts +git commit -m "Populate context tags when adding actions to focus" +``` + +--- + +### Task 6: Context filter UI for WaitingForView + +**Files:** +- Modify: `src/waiting-for-view.ts` + +This task follows the existing sphere filter pattern in the same file. + +**Step 1: Add state** + +Add to the class: +```typescript +private selectedContexts: string[] = []; +``` + +**Step 2: Persist state** + +Update `getState()` to include `selectedContexts`. +Update `setState()` to restore `selectedContexts`. + +**Step 3: Add context discovery** + +Add method to discover unique contexts from items: +```typescript +private discoverContexts(items: WaitingForItem[]): string[] { + const contexts = new Set(); + for (const item of items) { + for (const context of item.contexts) { + contexts.add(context); + } + } + return Array.from(contexts).sort(); +} +``` + +**Step 4: Add context filter rendering** + +Add `renderContextFilter(container, items)` method — follows same pattern as `renderSphereFilter` but uses discovered contexts from items rather than `settings.spheres`. CSS class: `flow-gtd-context-buttons` for the container, `flow-gtd-context-button` for each button. + +**Step 5: Add context filtering logic** + +Add `filterItemsByContext(items)` method: +- If no contexts selected (`selectedContexts.length === 0`), return all items +- Otherwise, return items where at least one context matches selectedContexts +- Items with no contexts are hidden when any context filter is active + +**Step 6: Wire into renderContent** + +In `renderContent`, after `renderSphereFilter(container)`: +1. Call `renderContextFilter(container, items)` (pass unfiltered items so all contexts are discoverable) +2. Chain filtering: `const filteredItems = this.filterItemsByContext(this.filterItemsBySphere(items))` + +**Step 7: Run full test suite** + +Run: `npm test` +Expected: PASS + +**Step 8: Commit** + +``` +git add src/waiting-for-view.ts +git commit -m "Add context tag filter to WaitingForView" +``` + +--- + +### Task 7: Context filter UI for SomedayView + +**Files:** +- Modify: `src/someday-view.ts` + +Same pattern as Task 6 but for SomedayView. Note: SomedayView has both items and projects. Context tags only exist on items (per design), so the context filter only applies to the items section. Projects section remains filtered by sphere only. + +**Step 1-6: Follow same pattern as Task 6** + +Key difference: `filterItemsByContext` applies to `SomedayItem[]` only, not to `SomedayProject[]`. + +In `renderContent`: +```typescript +const filteredItems = this.filterItemsByContext(this.filterItemsBySphere(data.items)); +const filteredProjects = this.filterProjectsBySphere(data.projects); +``` + +**Step 7: Run full test suite** + +Run: `npm test` +Expected: PASS + +**Step 8: Commit** + +``` +git add src/someday-view.ts +git commit -m "Add context tag filter to SomedayView" +``` + +--- + +### Task 8: Context filter UI for FocusView + +**Files:** +- Modify: `src/focus-view.ts` + +FocusView currently has no filter UI. Add context filter buttons. + +**Step 1: Add state** + +```typescript +private selectedContexts: string[] = []; +``` + +**Step 2: Persist state** + +Add `getState()` / `setState()` — FocusView currently doesn't have these methods. Add them: +```typescript +getState() { + return { selectedContexts: this.selectedContexts }; +} + +async setState(state: { selectedContexts?: string[] }, result: any) { + if (state?.selectedContexts !== undefined) { + this.selectedContexts = state.selectedContexts; + } + await super.setState(state, result); +} +``` + +**Step 3: Add context discovery** + +```typescript +private discoverContexts(items: FocusItem[]): string[] { + const contexts = new Set(); + for (const item of items) { + for (const context of item.contexts || []) { + contexts.add(context); + } + } + return Array.from(contexts).sort(); +} +``` + +**Step 4: Add context filter rendering and filtering** + +Same pattern as WaitingForView. Render after the title element. Filter active (non-completed) items before rendering. + +**Step 5: Wire into onOpen and performRefresh** + +In `onOpen`, after rendering the title, render context filter then filter items before `renderGroupedItems`. + +In `performRefresh`, same — render filter, apply filtering. + +**Step 6: Run full test suite** + +Run: `npm test` +Expected: PASS + +**Step 7: Commit** + +``` +git add src/focus-view.ts +git commit -m "Add context tag filter to FocusView" +``` + +--- + +### Task 9: Context filter for SphereView + +**Files:** +- Modify: `src/sphere-data-loader.ts` (add context filtering to `filterData`) +- Modify: `src/sphere-view.ts` (add context filter UI and state) +- Modify: `tests/sphere-data-loader.test.ts` + +This is the most complex view because actions are plain strings, not typed objects. Context tags are embedded in the action text. + +**Step 1: Write failing test for context filtering in SphereDataLoader** + +Add to `tests/sphere-data-loader.test.ts`: + +```typescript +describe("filterData with contexts", () => { + it("should filter actions by context tag", () => { + const loader = new SphereDataLoader(mockApp, "work", { + nextActionsFilePath: "Next actions.md", + projectTemplateFilePath: "Templates/Project.md", + } as any); + + const data: SphereViewData = { + projects: [ + { + project: { + title: "Project A", + file: "a.md", + tags: ["project/work"], + nextActions: [ + "Call dentist #context/phone", + "Buy supplies #context/errands", + "Write report", + ], + }, + priority: 1, + depth: 0, + }, + ], + projectsNeedingNextActions: [], + generalNextActions: ["Check voicemail #context/phone", "Clean desk"], + }; + + const result = loader.filterDataByContexts(data, ["phone"]); + + expect(result.projects).toHaveLength(1); + expect(result.projects[0].project.nextActions).toEqual(["Call dentist #context/phone"]); + expect(result.generalNextActions).toEqual(["Check voicemail #context/phone"]); + }); + + it("should exclude projects with no matching actions", () => { + const loader = new SphereDataLoader(mockApp, "work", { + nextActionsFilePath: "Next actions.md", + projectTemplateFilePath: "Templates/Project.md", + } as any); + + const data: SphereViewData = { + projects: [ + { + project: { + title: "Project A", + file: "a.md", + tags: ["project/work"], + nextActions: ["Write report", "Review doc"], + }, + priority: 1, + depth: 0, + }, + ], + projectsNeedingNextActions: [], + generalNextActions: [], + }; + + const result = loader.filterDataByContexts(data, ["phone"]); + + expect(result.projects).toHaveLength(0); + }); + + it("should return all data when no contexts selected", () => { + const loader = new SphereDataLoader(mockApp, "work", { + nextActionsFilePath: "Next actions.md", + projectTemplateFilePath: "Templates/Project.md", + } as any); + + const data: SphereViewData = { + projects: [ + { + project: { + title: "Project A", + file: "a.md", + tags: ["project/work"], + nextActions: ["Task 1", "Task 2"], + }, + priority: 1, + depth: 0, + }, + ], + projectsNeedingNextActions: [], + generalNextActions: ["General task"], + }; + + const result = loader.filterDataByContexts(data, []); + + expect(result.projects).toHaveLength(1); + expect(result.projects[0].project.nextActions).toHaveLength(2); + expect(result.generalNextActions).toHaveLength(1); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm test -- sphere-data-loader` +Expected: FAIL — `filterDataByContexts` not found + +**Step 3: Implement filterDataByContexts in SphereDataLoader** + +Add to `src/sphere-data-loader.ts`: + +```typescript +import { extractContexts } from "./context-tags"; + +// Add new method: +filterDataByContexts(data: SphereViewData, selectedContexts: string[]): SphereViewData { + if (selectedContexts.length === 0) { + return data; + } + + const matchesContext = (action: string) => { + const contexts = extractContexts(action); + return contexts.some((c) => selectedContexts.includes(c)); + }; + + const filteredProjects = data.projects + .map((summary) => { + const filteredActions = summary.project.nextActions?.filter(matchesContext) || []; + if (filteredActions.length === 0) return null; + + return { + ...summary, + project: { ...summary.project, nextActions: filteredActions }, + }; + }) + .filter((p): p is SphereProjectSummary => p !== null); + + const filteredGeneralActions = data.generalNextActions.filter(matchesContext); + + return { + projects: filteredProjects, + projectsNeedingNextActions: data.projectsNeedingNextActions, + generalNextActions: filteredGeneralActions, + generalNextActionsNotice: data.generalNextActionsNotice, + }; +} +``` + +Also add a method to discover contexts from the data: + +```typescript +discoverContexts(data: SphereViewData): string[] { + const contexts = new Set(); + + for (const summary of data.projects) { + for (const action of summary.project.nextActions || []) { + for (const context of extractContexts(action)) { + contexts.add(context); + } + } + } + + for (const action of data.generalNextActions) { + for (const context of extractContexts(action)) { + contexts.add(context); + } + } + + return Array.from(contexts).sort(); +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm test -- sphere-data-loader` +Expected: PASS + +**Step 5: Add context filter UI to SphereView** + +In `src/sphere-view.ts`: + +1. Add `private selectedContexts: string[] = [];` +2. Update `getState()` to include `selectedContexts` +3. Update `setState()` to restore `selectedContexts` +4. Add `renderContextFilter(container, data)` — render toggle buttons for discovered contexts, placed inside the sticky header controls row +5. Add `toggleContextFilter(context)` method +6. In `renderContent` and `refreshContent`, chain context filtering after text filtering: + ```typescript + const textFiltered = this.filterData(data, this.searchQuery); + const filteredData = this.getDataLoader().filterDataByContexts(textFiltered, this.selectedContexts); + ``` + +**Step 6: Run full test suite** + +Run: `npm test` +Expected: PASS + +**Step 7: Commit** + +``` +git add src/sphere-data-loader.ts src/sphere-view.ts tests/sphere-data-loader.test.ts +git commit -m "Add context tag filter to SphereView" +``` + +--- + +### Task 10: CSS for context filter buttons + +**Files:** +- Modify: `styles.css` (add styles for context filter buttons) + +Add CSS for `.flow-gtd-context-buttons` and `.flow-gtd-context-button` classes. Base these on the existing `.flow-gtd-sphere-buttons` and `.flow-gtd-sphere-button` styles but with a distinct visual treatment (e.g., different accent colour or a tag icon prefix) so users can distinguish context filters from sphere filters. + +**Commit** + +``` +git add styles.css +git commit -m "Add CSS for context filter buttons" +``` + +--- + +### Task 11: Final verification + +**Step 1: Run all checks** + +``` +npm run format +npm run build +npm test +``` + +**Step 2: Manual testing checklist** + +- [ ] Add `#context/phone` to an action line in a project file +- [ ] Verify it appears in SphereView with tag visible +- [ ] Verify context filter button appears in SphereView +- [ ] Click filter button, verify filtering works +- [ ] Add action to focus, verify contexts persist +- [ ] Check FocusView shows context filter +- [ ] Check WaitingForView shows context filter for `[w]` items with context tags +- [ ] Check SomedayView shows context filter +- [ ] Verify filter state persists across view reloads + +**Step 3: Commit any final fixes and push** From bb8e19ddc0e2eb04aa649eb63b208aaf7b6b5d00 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Thu, 19 Feb 2026 08:27:35 +0000 Subject: [PATCH 23/25] Render focus action text as markdown for clickable wikilinks and tags Use MarkdownRenderer.renderMarkdown() instead of setText() for action text in FocusView. This makes wikilinks like [[Note]] and tags like #context/home clickable in the focus pane. Click handlers check the event target so link clicks are handled by Obsidian rather than navigating to the action's source file. Fixes tavva/flow#37 (item 3) --- src/focus-view.ts | 42 ++++++++----- tests/focus-view.test.ts | 127 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 18 deletions(-) diff --git a/src/focus-view.ts b/src/focus-view.ts index 2cd15600..0ad98014 100644 --- a/src/focus-view.ts +++ b/src/focus-view.ts @@ -1,7 +1,7 @@ // ABOUTME: Leaf view displaying curated focus of next actions from across the vault. // ABOUTME: Allows marking items complete, converting to waiting-for, or removing from list. -import { WorkspaceLeaf, TFile, setIcon } from "obsidian"; +import { WorkspaceLeaf, TFile, setIcon, MarkdownRenderer } from "obsidian"; import { getAPI } from "obsidian-dataview"; import { FocusItem, PluginSettings, FlowProject } from "./types"; import { FocusValidator, ValidationResult } from "./focus-validator"; @@ -367,7 +367,7 @@ export class FocusView extends RefreshingView { cls: "flow-gtd-focus-items flow-gtd-focus-pinned-items", }); pinnedItems.forEach((item) => { - this.renderPinnedItem(pinnedList, item); + void this.renderPinnedItem(pinnedList, item); }); } @@ -488,7 +488,7 @@ export class FocusView extends RefreshingView { const itemsList = fileSection.createEl("ul", { cls: "flow-gtd-focus-items" }); items.forEach((item) => { - this.renderCompletedItem(itemsList, item); + void this.renderCompletedItem(itemsList, item); }); } @@ -502,7 +502,7 @@ export class FocusView extends RefreshingView { const itemsList = sphereSection.createEl("ul", { cls: "flow-gtd-focus-items" }); items.forEach((item) => { - this.renderCompletedItem(itemsList, item); + void this.renderCompletedItem(itemsList, item); }); } @@ -537,7 +537,7 @@ export class FocusView extends RefreshingView { const itemsList = fileSection.createEl("ul", { cls: "flow-gtd-focus-items" }); items.forEach((item) => { - this.renderItem(itemsList, item); + void this.renderItem(itemsList, item); }); } @@ -551,11 +551,11 @@ export class FocusView extends RefreshingView { const itemsList = sphereSection.createEl("ul", { cls: "flow-gtd-focus-items" }); items.forEach((item) => { - this.renderItem(itemsList, item); + void this.renderItem(itemsList, item); }); } - private renderItem(container: HTMLElement, item: FocusItem) { + private async renderItem(container: HTMLElement, item: FocusItem) { const itemEl = container.createEl("li", { cls: "flow-gtd-focus-item" }); // Check if this is a waiting-for item @@ -572,7 +572,7 @@ export class FocusView extends RefreshingView { } const textSpan = itemEl.createSpan({ cls: "flow-gtd-focus-item-text" }); - textSpan.setText(item.text); + await MarkdownRenderer.renderMarkdown(item.text, textSpan, item.file, this); textSpan.style.cursor = "pointer"; // Gray out waiting-for items @@ -581,7 +581,11 @@ export class FocusView extends RefreshingView { textSpan.style.fontStyle = "italic"; } - textSpan.addEventListener("click", () => { + textSpan.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + if (target.closest("a")) { + return; + } this.openFile(item.file, item.lineNumber); }); @@ -644,7 +648,7 @@ export class FocusView extends RefreshingView { }); } - private renderPinnedItem(container: HTMLElement, item: FocusItem) { + private async renderPinnedItem(container: HTMLElement, item: FocusItem) { const itemEl = container.createEl("li", { cls: "flow-gtd-focus-item flow-gtd-focus-pinned-item", attr: { draggable: "true" }, @@ -688,7 +692,7 @@ export class FocusView extends RefreshingView { } const textSpan = actionRow.createSpan({ cls: "flow-gtd-focus-item-text" }); - textSpan.setText(item.text); + await MarkdownRenderer.renderMarkdown(item.text, textSpan, item.file, this); textSpan.style.cursor = "pointer"; // Gray out waiting-for items @@ -697,7 +701,11 @@ export class FocusView extends RefreshingView { textSpan.style.fontStyle = "italic"; } - textSpan.addEventListener("click", () => { + textSpan.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + if (target.closest("a")) { + return; + } this.openFile(item.file, item.lineNumber); }); @@ -758,7 +766,7 @@ export class FocusView extends RefreshingView { }); } - private renderCompletedItem(container: HTMLElement, item: FocusItem) { + private async renderCompletedItem(container: HTMLElement, item: FocusItem) { const itemEl = container.createEl("li", { cls: "flow-gtd-focus-item flow-gtd-focus-completed", }); @@ -770,12 +778,16 @@ export class FocusView extends RefreshingView { }); const textSpan = itemEl.createSpan({ cls: "flow-gtd-focus-item-text" }); - textSpan.setText(item.text); + await MarkdownRenderer.renderMarkdown(item.text, textSpan, item.file, this); textSpan.style.cursor = "pointer"; textSpan.style.textDecoration = "line-through"; textSpan.style.opacity = "0.6"; - textSpan.addEventListener("click", () => { + textSpan.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + if (target.closest("a")) { + return; + } this.openFile(item.file, item.lineNumber); }); diff --git a/tests/focus-view.test.ts b/tests/focus-view.test.ts index fd36251c..e9258bcd 100644 --- a/tests/focus-view.test.ts +++ b/tests/focus-view.test.ts @@ -1,7 +1,7 @@ // tests/focus-view.test.ts import { FocusView, FOCUS_VIEW_TYPE } from "../src/focus-view"; import { FocusItem } from "../src/types"; -import { WorkspaceLeaf } from "obsidian"; +import { WorkspaceLeaf, MarkdownRenderer } from "obsidian"; jest.mock("obsidian"); @@ -1456,6 +1456,127 @@ describe("FocusView", () => { }); }); + describe("Clickable links in action text", () => { + let renderSpy: jest.SpyInstance; + + beforeEach(() => { + renderSpy = jest.spyOn(MarkdownRenderer, "renderMarkdown"); + }); + + afterEach(() => { + renderSpy.mockRestore(); + }); + + it("should render action text using MarkdownRenderer for unpinned items", async () => { + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Call [[John]] about #project/alpha", + text: "Call [[John]] about #project/alpha", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }, + ]; + + await view.onOpen(); + + expect(renderSpy).toHaveBeenCalledWith( + "Call [[John]] about #project/alpha", + expect.any(HTMLElement), + "Projects/Test.md", + expect.anything() + ); + }); + + it("should render action text using MarkdownRenderer for pinned items", async () => { + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Review [[PR-42]] notes", + text: "Review [[PR-42]] notes", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + isPinned: true, + }, + ]; + + await view.onOpen(); + + expect(renderSpy).toHaveBeenCalledWith( + "Review [[PR-42]] notes", + expect.any(HTMLElement), + "Projects/Test.md", + expect.anything() + ); + }); + + it("should render action text using MarkdownRenderer for completed items", async () => { + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [x] Email [[Sarah]] ✅ 2026-02-19", + text: "Email [[Sarah]]", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + completedAt: Date.now(), + }, + ]; + + await view.onOpen(); + + expect(renderSpy).toHaveBeenCalledWith( + "Email [[Sarah]]", + expect.any(HTMLElement), + "Projects/Test.md", + expect.anything() + ); + }); + + it("should not navigate to source file when clicking a link inside action text", async () => { + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Call [[John]]", + text: "Call [[John]]", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }, + ]; + + await view.onOpen(); + + // Find the text span in the rendered DOM + const container = (view as any).contentEl as HTMLElement; + const textSpan = container.querySelector(".flow-gtd-focus-item-text") as HTMLElement; + expect(textSpan).toBeTruthy(); + + // Create a fake anchor element inside the text span (simulating a rendered wikilink) + const fakeLink = document.createElement("a"); + fakeLink.className = "internal-link"; + fakeLink.setAttribute("data-href", "John"); + textSpan.appendChild(fakeLink); + + // Spy on openFile + const openFileSpy = jest.fn(); + (view as any).openFile = openFileSpy; + + // Simulate clicking the link element + const clickEvent = new (window as any).MouseEvent("click", { bubbles: true }); + Object.defineProperty(clickEvent, "target", { value: fakeLink }); + textSpan.dispatchEvent(clickEvent); + + expect(openFileSpy).not.toHaveBeenCalled(); + }); + }); + // Helper function for creating mock focus items const createMockFocusItem = (): FocusItem => ({ file: "test.md", @@ -1468,7 +1589,7 @@ describe("FocusView", () => { }); describe("renderCompletedItem", () => { - it("should render completed item with strikethrough and no actions", () => { + it("should render completed item with strikethrough and no actions", async () => { const mockItem: FocusItem = { file: "test.md", lineNumber: 5, @@ -1516,7 +1637,7 @@ describe("FocusView", () => { } }); - (view as any).renderCompletedItem(container, mockItem); + await (view as any).renderCompletedItem(container, mockItem); const itemEl = container.querySelector(".flow-gtd-focus-completed"); expect(itemEl).toBeTruthy(); From c10787c6cf4bbea8ad7f06aff6662e1b73443e60 Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Thu, 19 Feb 2026 09:31:28 +0000 Subject: [PATCH 24/25] Handle clicks on rendered links and tags in focus view MarkdownRenderer creates the HTML elements but doesn't set up click handlers in custom ItemView contexts. Add explicit handling: - Internal links (wikilinks): open linked note via workspace.openLinkText - Tags: open Obsidian's global search with the tag query - Other links (external URLs): let browser handle - Non-link text: navigate to the action's source file Extract shared handleRenderedTextClick method to avoid duplication across renderItem, renderPinnedItem, and renderCompletedItem. --- src/focus-view.ts | 53 +++++++++++++++++++++++++----------- tests/focus-view.test.ts | 58 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 17 deletions(-) diff --git a/src/focus-view.ts b/src/focus-view.ts index 0ad98014..210e909f 100644 --- a/src/focus-view.ts +++ b/src/focus-view.ts @@ -582,11 +582,7 @@ export class FocusView extends RefreshingView { } textSpan.addEventListener("click", (e) => { - const target = e.target as HTMLElement; - if (target.closest("a")) { - return; - } - this.openFile(item.file, item.lineNumber); + this.handleRenderedTextClick(e, item); }); const actionsSpan = itemEl.createSpan({ cls: "flow-gtd-focus-item-actions" }); @@ -702,11 +698,7 @@ export class FocusView extends RefreshingView { } textSpan.addEventListener("click", (e) => { - const target = e.target as HTMLElement; - if (target.closest("a")) { - return; - } - this.openFile(item.file, item.lineNumber); + this.handleRenderedTextClick(e, item); }); const actionsSpan = itemEl.createSpan({ cls: "flow-gtd-focus-item-actions" }); @@ -784,11 +776,7 @@ export class FocusView extends RefreshingView { textSpan.style.opacity = "0.6"; textSpan.addEventListener("click", (e) => { - const target = e.target as HTMLElement; - if (target.closest("a")) { - return; - } - this.openFile(item.file, item.lineNumber); + this.handleRenderedTextClick(e, item); }); // No action buttons for completed items @@ -919,6 +907,41 @@ export class FocusView extends RefreshingView { } } + private handleRenderedTextClick(e: MouseEvent, item: FocusItem): void { + const target = e.target as HTMLElement; + + // Handle clicks on internal links (wikilinks) + const internalLink = target.closest("a.internal-link") as HTMLElement | null; + if (internalLink) { + e.preventDefault(); + const href = internalLink.getAttribute("data-href"); + if (href) { + this.app.workspace.openLinkText(href, item.file); + } + return; + } + + // Handle clicks on tags + const tagLink = target.closest("a.tag") as HTMLElement | null; + if (tagLink) { + e.preventDefault(); + const tag = tagLink.textContent; + if (tag) { + const searchPlugin = (this.app as any).internalPlugins?.getPluginById?.("global-search"); + searchPlugin?.instance?.openGlobalSearch?.(`tag:${tag}`); + } + return; + } + + // Handle clicks on any other link (e.g. external URLs) + if (target.closest("a")) { + return; + } + + // Default: navigate to source file + this.openFile(item.file, item.lineNumber); + } + private async openFile(filePath: string, lineNumber?: number): Promise { const file = this.app.vault.getAbstractFileByPath(filePath); diff --git a/tests/focus-view.test.ts b/tests/focus-view.test.ts index e9258bcd..321587cb 100644 --- a/tests/focus-view.test.ts +++ b/tests/focus-view.test.ts @@ -1538,7 +1538,7 @@ describe("FocusView", () => { ); }); - it("should not navigate to source file when clicking a link inside action text", async () => { + it("should open linked note when clicking an internal link in action text", async () => { mockFocusItems = [ { file: "Projects/Test.md", @@ -1551,6 +1551,9 @@ describe("FocusView", () => { }, ]; + // Mock workspace.openLinkText + mockApp.workspace.openLinkText = jest.fn(); + await view.onOpen(); // Find the text span in the rendered DOM @@ -1564,7 +1567,7 @@ describe("FocusView", () => { fakeLink.setAttribute("data-href", "John"); textSpan.appendChild(fakeLink); - // Spy on openFile + // Spy on openFile to ensure it's NOT called const openFileSpy = jest.fn(); (view as any).openFile = openFileSpy; @@ -1573,7 +1576,58 @@ describe("FocusView", () => { Object.defineProperty(clickEvent, "target", { value: fakeLink }); textSpan.dispatchEvent(clickEvent); + // Should open the linked note, not the source file + expect(openFileSpy).not.toHaveBeenCalled(); + expect(mockApp.workspace.openLinkText).toHaveBeenCalledWith("John", "Projects/Test.md"); + }); + + it("should open tag search when clicking a tag in action text", async () => { + mockFocusItems = [ + { + file: "Projects/Test.md", + lineNumber: 5, + lineContent: "- [ ] Buy groceries #context/errands", + text: "Buy groceries #context/errands", + sphere: "work", + isGeneral: false, + addedAt: Date.now(), + }, + ]; + + // Mock the global search plugin + const mockOpenGlobalSearch = jest.fn(); + (mockApp as any).internalPlugins = { + getPluginById: jest.fn().mockReturnValue({ + instance: { openGlobalSearch: mockOpenGlobalSearch }, + }), + }; + + await view.onOpen(); + + // Find the text span + const container = (view as any).contentEl as HTMLElement; + const textSpan = container.querySelector(".flow-gtd-focus-item-text") as HTMLElement; + expect(textSpan).toBeTruthy(); + + // Create a fake tag element inside the text span + const fakeTag = document.createElement("a"); + fakeTag.className = "tag"; + fakeTag.setAttribute("href", "#context/errands"); + fakeTag.textContent = "#context/errands"; + textSpan.appendChild(fakeTag); + + // Spy on openFile to ensure it's NOT called + const openFileSpy = jest.fn(); + (view as any).openFile = openFileSpy; + + // Simulate clicking the tag element + const clickEvent = new (window as any).MouseEvent("click", { bubbles: true }); + Object.defineProperty(clickEvent, "target", { value: fakeTag }); + textSpan.dispatchEvent(clickEvent); + + // Should open search for the tag, not navigate to source file expect(openFileSpy).not.toHaveBeenCalled(); + expect(mockOpenGlobalSearch).toHaveBeenCalledWith("tag:#context/errands"); }); }); From 1cb4d7e26248f9a06c1b06085f0fb775e8172f3f Mon Sep 17 00:00:00 2001 From: Ben Phillips Date: Thu, 19 Feb 2026 09:48:04 +0000 Subject: [PATCH 25/25] Guard against non-HTMLElement click targets in handleRenderedTextClick e.target can be a Text node when clicking plain text inside rendered markdown. Text nodes don't have closest(), which would throw. Add an instanceof check and fall back to source file navigation. --- src/focus-view.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/focus-view.ts b/src/focus-view.ts index 210e909f..cad86c2d 100644 --- a/src/focus-view.ts +++ b/src/focus-view.ts @@ -908,7 +908,11 @@ export class FocusView extends RefreshingView { } private handleRenderedTextClick(e: MouseEvent, item: FocusItem): void { - const target = e.target as HTMLElement; + const target = e.target; + if (!(target instanceof HTMLElement)) { + this.openFile(item.file, item.lineNumber); + return; + } // Handle clicks on internal links (wikilinks) const internalLink = target.closest("a.internal-link") as HTMLElement | null;