Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

feat(canvas): file tasks to channels via desktop_file_system - #2535

Merged
adamleithp merged 8 commits into
mainfrom
feat/canvas-file-tasks-fs-entries
Jun 11, 2026
Merged

feat(canvas): file tasks to channels via desktop_file_system#2535
adamleithp merged 8 commits into
mainfrom
feat/canvas-file-tasks-fs-entries

Conversation

@raquelmsmith

@raquelmsmith raquelmsmith commented Jun 8, 2026

Copy link
Copy Markdown
Member

video

https://www.loom.com/share/4933535612a74c67bac55e88c7e4fcd7

Requires PostHog/posthog#62791 to be functional

Summary

  • Persists task↔channel filing as channel-task rows on the project's desktop_file_system surface (mirrors how dashboards are stored), replacing the local Zustand mapping.
  • New ChannelTasksService + tRPC router (list/file/unfile).
  • "File to…" submenu wired in two places: right-click on a task row in the channels sidebar, and the native task context menu in the main tasks panel.
  • Channels sidebar: drops the Backlog/Todo/etc. task-type rows and the inner "Tasks" Collapsible; filed tasks render flat with a code icon; Dashboards row gets a file icon; hover-revealed "+" button on the channel header opens the New task panel.

Test plan

  • Restart Electron so the new tRPC router is picked up.
  • Right-click a task in the channels sidebar → File to… → pick a channel → task appears under the target channel.
  • Right-click a task in the main tasks panel → File to… → pick a channel → task appears under that channel in the canvas sidebar.
  • Create a new task from a channel's "+" button → task is filed to that channel.
  • Delete a channel → its filed channel-task rows are cleaned up.
  • Reload the app → filed tasks survive (server-backed).

Note: branched off feat/canvas-file-tasks-to-channels, which isn't yet on remote, so the diff vs. main includes the prior canvas work as well.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/code/src/main/services/channel-tasks/service.ts:75-84
**`file()` triggers two independent full-list scans**

`channelPath()` already performs a point-GET for the channel entry. The subsequent `this.list(input.channelId)` then calls `listAll()` — a full paginated scan of every FS entry — purely to check for duplicates. You can recover the path from that same scan, eliminating the `getEntry` round-trip, or alternatively do the idempotency check first so the path fetch only happens when a POST is actually needed.

### Issue 2 of 3
apps/code/src/renderer/features/canvas/components/ChannelsList.tsx:187-195
**"File to…" copies rather than moves**

`fileTask(targetChannelId, taskId)` files the task to the new channel but does nothing to remove it from `channelId` (the channel the task row lives under). The task will appear under both channels after the operation. If the intent is a move, an `unfileTask(existingRecord.id)` call for the source entry is needed alongside the new `file()` call.

### Issue 3 of 3
apps/code/src/main/services/channel-tasks/service.ts:32-63
**`fsFetch` / `listAll` / `getEntry` duplicated from `DashboardsService`**

These three private helpers are character-for-character copies of the identical methods in `apps/code/src/main/services/dashboards/service.ts` (same constant names, same pagination logic, same error messages). Extracting a shared `DesktopFileSystemClient` base class or utility would express this idea OnceAndOnlyOnce and prevent the two copies drifting apart.

Reviews (1): Last reviewed commit: "feat(canvas): file tasks to channels via..." | Re-trigger Greptile

Comment on lines +75 to +84
async file(input: {
channelId: string;
taskId: string;
}): Promise<ChannelTaskRecord> {
const channelPath = await this.channelPath(input.channelId);
// Idempotent: if already filed, return the existing row.
const existing = (await this.list(input.channelId)).find(
(r) => r.taskId === input.taskId,
);
if (existing) return existing;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 file() triggers two independent full-list scans

channelPath() already performs a point-GET for the channel entry. The subsequent this.list(input.channelId) then calls listAll() — a full paginated scan of every FS entry — purely to check for duplicates. You can recover the path from that same scan, eliminating the getEntry round-trip, or alternatively do the idempotency check first so the path fetch only happens when a POST is actually needed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/main/services/channel-tasks/service.ts
Line: 75-84

Comment:
**`file()` triggers two independent full-list scans**

`channelPath()` already performs a point-GET for the channel entry. The subsequent `this.list(input.channelId)` then calls `listAll()` — a full paginated scan of every FS entry — purely to check for duplicates. You can recover the path from that same scan, eliminating the `getEntry` round-trip, or alternatively do the idempotency check first so the path fetch only happens when a POST is actually needed.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +187 to +195
const onFileTo = async (targetChannelId: string) => {
try {
await fileTask(targetChannelId, taskId);
} catch (error) {
toast.error("Couldn't file task", {
description: error instanceof Error ? error.message : String(error),
});
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 "File to…" copies rather than moves

fileTask(targetChannelId, taskId) files the task to the new channel but does nothing to remove it from channelId (the channel the task row lives under). The task will appear under both channels after the operation. If the intent is a move, an unfileTask(existingRecord.id) call for the source entry is needed alongside the new file() call. Is "File to…" intended as copy-to-another-channel (task lives under both) or move (unfile from current, file to target)?

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/canvas/components/ChannelsList.tsx
Line: 187-195

Comment:
**"File to…" copies rather than moves**

`fileTask(targetChannelId, taskId)` files the task to the new channel but does nothing to remove it from `channelId` (the channel the task row lives under). The task will appear under both channels after the operation. If the intent is a move, an `unfileTask(existingRecord.id)` call for the source entry is needed alongside the new `file()` call. Is "File to…" intended as copy-to-another-channel (task lives under both) or move (unfile from current, file to target)?

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +32 to +63
private async fsFetch(suffix: string, init?: RequestInit): Promise<Response> {
const { apiHost } = await this.authService.getValidAccessToken();
const projectId = this.authService.getState().currentProjectId;
if (projectId == null) throw new Error("No PostHog project selected");
const url = `${apiHost}/api/projects/${projectId}/desktop_file_system/${suffix}`;
return this.authService.authenticatedFetch(fetch, url, init);
}

private async listAll(): Promise<FsEntry[]> {
const all: FsEntry[] = [];
let suffix = "";
for (let i = 0; i < MAX_PAGES; i++) {
const res = await this.fsFetch(suffix);
if (!res.ok)
throw new Error(`Failed to list channel tasks (${res.status})`);
const page = (await res.json()) as {
next: string | null;
results: FsEntry[];
};
all.push(...page.results);
if (!page.next) return all;
suffix = new URL(page.next).search;
}
return all;
}

private async getEntry(id: string): Promise<FsEntry | null> {
const res = await this.fsFetch(`${encodeURIComponent(id)}/`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to load channel task (${res.status})`);
return (await res.json()) as FsEntry;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 fsFetch / listAll / getEntry duplicated from DashboardsService

These three private helpers are character-for-character copies of the identical methods in apps/code/src/main/services/dashboards/service.ts (same constant names, same pagination logic, same error messages). Extracting a shared DesktopFileSystemClient base class or utility would express this idea OnceAndOnlyOnce and prevent the two copies drifting apart.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/main/services/channel-tasks/service.ts
Line: 32-63

Comment:
**`fsFetch` / `listAll` / `getEntry` duplicated from `DashboardsService`**

These three private helpers are character-for-character copies of the identical methods in `apps/code/src/main/services/dashboards/service.ts` (same constant names, same pagination logic, same error messages). Extracting a shared `DesktopFileSystemClient` base class or utility would express this idea OnceAndOnlyOnce and prevent the two copies drifting apart.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Persists task→channel filing as `channel-task` rows on the project's
desktop_file_system surface (mirrors how dashboards are stored), replacing
the local Zustand mapping.

- New `ChannelTasksService` in `@posthog/core/canvas` (host-agnostic, bound
  via `canvasCoreModule`).
- New `channelTasks` host router (`list`/`file`/`unfile`).
- "File to…" submenu wired in two places: right-click on a task row in the
  channels sidebar, and the native task context menu in the main tasks
  panel (new `file-to-channel` intent).
- Channels sidebar: drops the Backlog/Todo task-type rows and the inner
  "Tasks" Collapsible; filed tasks render flat with a code icon; Dashboards
  row gets a file icon; hover-revealed "+" button on the channel header
  opens the New task panel.
- Deleting a channel cascades its dashboard *and* filed-task rows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@raquelmsmith
raquelmsmith force-pushed the feat/canvas-file-tasks-fs-entries branch from 8ce6a89 to 66d68b9 Compare June 10, 2026 19:43
raquelmsmith and others added 6 commits June 10, 2026 13:38
Switches channel task filing from the custom `channel-task` FS type to
plain `type=task` rows nested under the channel folder. This relies on
PostHog/posthog#62791, which makes Task inherit FileSystemSyncMixin so
every task has a home row in Unfiled/Tasks: deleting a channel row then
leaves remaining > 0 and posthog preserves the task instead of returning
400 (unregistered-type guard) or cascading into a soft-delete.

list() now hits the FS endpoint with `?parent=<channelPath>&type=task`
instead of paging through the entire project's file system. file()
takes the task title so the row's last segment matches what the FS
tree displays — the mixin keeps it in sync on subsequent task renames.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tasks created before posthog's FileSystemSyncMixin landed have no
Unfiled/Tasks/<title> row. Without one, the channel row created by
file() is the only row, and removing it from the channel cascades into
a soft-delete of the underlying task. Before creating the channel row,
check `?type=task&ref=<id>` and POST a home row if none exist.
Idempotent for tasks already homed by the mixin.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ating

"File to ..." on a task already filed under one channel previously
created an additional row under the new channel — the task showed up in
both. Now file() finds the existing channel row and moves it (via the
file_system /move/ action) to the target. If multiple channel rows
exist defensively drops the extras. The Unfiled/Tasks home row is left
untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The fsFetch / listAll / getEntry helpers were duplicated between
DashboardsService and ChannelTasksService. Move them into an injectable
DesktopFsClient so both services compose the same client instead of
their own copies. Service-specific error labels are passed in; per-type
FS row shapes ride a generic FsEntryBase.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Temporary: logs the listByRef result, identified home row, and channel
row count when filing so we can see which branch in file() runs when a
duplicate appears. Strip once the duplication report is closed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@raquelmsmith
raquelmsmith marked this pull request as ready for review June 11, 2026 02:57
Hook existed locally but was never staged, so CI build/typecheck/integration
all failed with a missing module error from ChannelsList.tsx.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/core/src/canvas/channelTasksService.ts:89-97
**`listUnderParent` drops tasks beyond the first page**

The method reads `page.results` once and returns without following `page.next`, so any filed tasks beyond the API's default page size will silently disappear from the channel sidebar. `DesktopFsClient.listAll()` already contains the pagination loop (up to `MAX_PAGES = 50`) for exactly this reason, but `listUnderParent` bypasses it.

The same single-page read is present in `listByRef` (line 99–106). That one carries less immediate risk because a single task should never accumulate many rows, but for `listUnderParent` the truncation is a live data-loss path once a channel grows past the default page size.

Consider exposing a filtered variant from `DesktopFsClient` that accepts query params but still follows pagination, or replicate the `next`-following loop inline here.

### Issue 2 of 2
packages/core/src/canvas/channelTasksService.ts:43
**Path collision when two tasks share a title within the same channel**

`targetPath` is derived purely from `sanitizeSegment(input.taskTitle)`. If two distinct tasks both have the title `"Fix bug"` and both are filed to the same channel, the second `createRow` (or `moveRow`) will target the identical path `<channelPath>/Fix bug`. Depending on whether the `desktop_file_system` API enforces path uniqueness, this either silently clobbers the first task's FS row or returns an error that surfaces only as a generic "Failed to file task" toast with no actionable detail for the user.

A stable per-task suffix (e.g. the last few chars of `taskId`) appended to `sanitizeSegment(taskTitle)` would make the path unique while keeping it human-readable.

Reviews (2): Last reviewed commit: "fix(canvas): commit useChannelTaskData h..." | Re-trigger Greptile

Comment on lines +89 to +97
private async listUnderParent(parentPath: string): Promise<FsEntry[]> {
const res = await this.fs.fetch(
`?parent=${encodeURIComponent(parentPath)}&type=${TASK_TYPE}`,
);
if (!res.ok)
throw new Error(`Failed to list channel tasks (${res.status})`);
const page = (await res.json()) as { results: FsEntry[] };
return page.results;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 listUnderParent drops tasks beyond the first page

The method reads page.results once and returns without following page.next, so any filed tasks beyond the API's default page size will silently disappear from the channel sidebar. DesktopFsClient.listAll() already contains the pagination loop (up to MAX_PAGES = 50) for exactly this reason, but listUnderParent bypasses it.

The same single-page read is present in listByRef (line 99–106). That one carries less immediate risk because a single task should never accumulate many rows, but for listUnderParent the truncation is a live data-loss path once a channel grows past the default page size.

Consider exposing a filtered variant from DesktopFsClient that accepts query params but still follows pagination, or replicate the next-following loop inline here.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/canvas/channelTasksService.ts
Line: 89-97

Comment:
**`listUnderParent` drops tasks beyond the first page**

The method reads `page.results` once and returns without following `page.next`, so any filed tasks beyond the API's default page size will silently disappear from the channel sidebar. `DesktopFsClient.listAll()` already contains the pagination loop (up to `MAX_PAGES = 50`) for exactly this reason, but `listUnderParent` bypasses it.

The same single-page read is present in `listByRef` (line 99–106). That one carries less immediate risk because a single task should never accumulate many rows, but for `listUnderParent` the truncation is a live data-loss path once a channel grows past the default page size.

Consider exposing a filtered variant from `DesktopFsClient` that accepts query params but still follows pagination, or replicate the `next`-following loop inline here.

How can I resolve this? If you propose a fix, please make it concise.

@adamleithp
adamleithp merged commit 2f4daef into main Jun 11, 2026
18 of 19 checks passed
@adamleithp
adamleithp deleted the feat/canvas-file-tasks-fs-entries branch June 11, 2026 09:49
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants