feat(canvas): file tasks to channels via desktop_file_system - #2535
Conversation
Prompt To Fix All With AIFix 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 |
| 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; |
There was a problem hiding this 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.
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.| 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), | ||
| }); | ||
| } | ||
| }; |
There was a problem hiding this 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)?
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.| 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; | ||
| } |
There was a problem hiding this 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.
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>
8ce6a89 to
66d68b9
Compare
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>
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>
Prompt To Fix All With AIFix 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 |
| 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; | ||
| } |
There was a problem hiding this 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.
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.
video
https://www.loom.com/share/4933535612a74c67bac55e88c7e4fcd7
Requires PostHog/posthog#62791 to be functional
Summary
channel-taskrows on the project'sdesktop_file_systemsurface (mirrors how dashboards are stored), replacing the local Zustand mapping.ChannelTasksService+ tRPC router (list/file/unfile).Test plan
channel-taskrows are cleaned up.🤖 Generated with Claude Code