diff --git a/apps/desktop/src/main/tasks/project-item-links.test.ts b/apps/desktop/src/main/tasks/project-item-links.test.ts
index 6c0da7e05..ad9cb0489 100644
--- a/apps/desktop/src/main/tasks/project-item-links.test.ts
+++ b/apps/desktop/src/main/tasks/project-item-links.test.ts
@@ -81,6 +81,37 @@ describe('project item link reroute', () => {
expect(setEntityProperties).toHaveBeenCalledWith('n1', { project: ['Beta'] })
})
+ // The chips on the file page are the only unassign surface a binary file
+ // has. It owns no frontmatter, so the row in `project_links` is the whole of
+ // its membership and this branch is the entire write.
+ it('deletes the link row for a file on unlink, writing no frontmatter', async () => {
+ isMarkdownNote.mockReturnValue(false)
+ domainUnlink.mockResolvedValue({ success: true })
+
+ const result = await unlinkProjectItem({} as never, domain as never, {
+ projectId: 'p1',
+ itemType: 'file',
+ itemId: 'f1'
+ })
+
+ expect(result).toEqual({ success: true })
+ expect(domainUnlink).toHaveBeenCalledWith({ projectId: 'p1', itemType: 'file', itemId: 'f1' })
+ expect(setEntityProperties).not.toHaveBeenCalled()
+ })
+
+ it('reports a failed file unlink instead of claiming success', async () => {
+ isMarkdownNote.mockReturnValue(false)
+ domainUnlink.mockResolvedValue({ success: false })
+
+ expect(
+ await unlinkProjectItem({} as never, domain as never, {
+ projectId: 'p1',
+ itemType: 'file',
+ itemId: 'f1'
+ })
+ ).toEqual({ success: false, error: 'Failed to unlink item' })
+ })
+
it('errors when the project does not exist', async () => {
isMarkdownNote.mockReturnValue(true)
getProjectById.mockReturnValue(undefined)
diff --git a/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx b/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx
index 79c683835..09a7444dc 100644
--- a/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx
+++ b/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx
@@ -31,10 +31,28 @@ const SOURCE_TYPE_BY_VISUAL_TYPE = {
note: 'note'
} as const
+/**
+ * The instant these tests pretend it is, on today's real local date.
+ *
+ * `use-today` snapshots the local date into module scope at import and re-reads the wall clock
+ * for its first subscriber. A clock faked onto any other date therefore arrives as a midnight
+ * rollover, which moves `todayCalendarRange`, moves the query key with it, and makes the widget
+ * fetch a second day on mount. Only the time of day is pinned, and from local fields rather than
+ * a UTC instant, which far enough from UTC would name a different day.
+ */
+const NOW = new Date()
+NOW.setHours(9, 30, 0, 0)
+
+function todayAtHour(hour: number): string {
+ const at = new Date(NOW)
+ at.setHours(hour, 0, 0, 0)
+ return at.toISOString()
+}
+
function projectionItem(
id: string,
title: string,
- hourUtc: number,
+ hour: number,
visualType: keyof typeof SOURCE_TYPE_BY_VISUAL_TYPE
): CalendarProjectionItem {
return {
@@ -43,8 +61,8 @@ function projectionItem(
sourceId: id,
title,
descriptionPreview: null,
- startAt: `2026-08-31T${String(hourUtc).padStart(2, '0')}:00:00.000Z`,
- endAt: `2026-08-31T${String(hourUtc + 1).padStart(2, '0')}:00:00.000Z`,
+ startAt: todayAtHour(hour),
+ endAt: todayAtHour(hour + 1),
isAllDay: false,
timezone: 'UTC',
visualType,
@@ -101,7 +119,7 @@ function renderApp(): { showBoard: (visible: boolean) => void } {
describe('home calendar widget stays current', () => {
beforeEach(() => {
- vi.setSystemTime(new Date('2026-08-31T09:30:00.000Z'))
+ vi.setSystemTime(NOW)
listeners.clear()
server.items = [projectionItem('e1', 'Standup', 10, 'event')]
mockGetRange.mockReset()
diff --git a/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.test.tsx b/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.test.tsx
index 5c3f3462d..5acaf372c 100644
--- a/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.test.tsx
+++ b/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.test.tsx
@@ -2,22 +2,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ItemProjectChips } from './item-project-chips'
-const { mockListForItem, mockOnProjectUpdated } = vi.hoisted(() => ({
- mockListForItem: vi.fn(),
- mockOnProjectUpdated: vi.fn(() => () => {})
-}))
+const { mockListForItem, mockOnProjectUpdated, mockUnlinkProjectItem, mockToastError } = vi.hoisted(
+ () => ({
+ mockListForItem: vi.fn(),
+ // Declares the subscriber parameter the component actually passes, so
+ // the mockImplementation below that captures it still typechecks.
+ mockOnProjectUpdated: vi.fn((_callback: () => void) => () => {}),
+ mockUnlinkProjectItem: vi.fn(),
+ mockToastError: vi.fn()
+ })
+)
vi.mock('@/services/tasks-service', () => ({
tasksService: {
- listForItem: mockListForItem
+ listForItem: mockListForItem,
+ unlinkProjectItem: mockUnlinkProjectItem
},
onProjectUpdated: mockOnProjectUpdated
}))
+vi.mock('sonner', () => ({ toast: { error: mockToastError } }))
+
describe('ItemProjectChips', () => {
beforeEach(() => {
vi.clearAllMocks()
mockOnProjectUpdated.mockReturnValue(() => {})
+ mockUnlinkProjectItem.mockResolvedValue({ success: true })
})
it('renders a chip per linked project', async () => {
@@ -79,4 +89,69 @@ describe('ItemProjectChips', () => {
expect(await screen.findByText('Launch')).toBeInTheDocument()
expect(mockListForItem).toHaveBeenCalledTimes(2)
})
+
+ // A binary file has no frontmatter, so these chips are the whole of its
+ // project membership UI. Without a remove control on them the only exits
+ // from a project are destructive (issue #1941).
+ it('unlinks the item when a chip remove control is used', async () => {
+ mockListForItem
+ .mockResolvedValueOnce([{ id: 'p1', name: 'Launch', color: '#f00', icon: null }])
+ .mockResolvedValue([])
+
+ render()
+
+ fireEvent.click(await screen.findByRole('button', { name: /remove from launch/i }))
+
+ await waitFor(() =>
+ expect(mockUnlinkProjectItem).toHaveBeenCalledWith({
+ projectId: 'p1',
+ itemType: 'file',
+ itemId: 'f1'
+ })
+ )
+ await waitFor(() => expect(screen.queryByText('Launch')).not.toBeInTheDocument())
+ })
+
+ it('gives each chip its own remove control, naming the project it drops', async () => {
+ mockListForItem.mockResolvedValue([
+ { id: 'p1', name: 'Launch', color: '#f00', icon: null },
+ { id: 'p2', name: 'Finance', color: '#0f0', icon: null }
+ ])
+
+ render()
+
+ fireEvent.click(await screen.findByRole('button', { name: /remove from finance/i }))
+
+ await waitFor(() =>
+ expect(mockUnlinkProjectItem).toHaveBeenCalledWith({
+ projectId: 'p2',
+ itemType: 'file',
+ itemId: 'f1'
+ })
+ )
+ })
+
+ it('renders the name as plain text where there is nowhere to navigate', async () => {
+ mockListForItem.mockResolvedValue([{ id: 'p1', name: 'Launch', color: '#f00', icon: null }])
+
+ render()
+
+ expect(await screen.findByText('Launch')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /open project launch/i })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /remove from launch/i })).toBeInTheDocument()
+ })
+
+ // The main side answers a failed unlink with an envelope rather than a
+ // rejection, so a bare await would read it as success and blank the chip.
+ it('keeps the chip and reports a rejected unlink', async () => {
+ mockListForItem.mockResolvedValue([{ id: 'p1', name: 'Launch', color: '#f00', icon: null }])
+ mockUnlinkProjectItem.mockResolvedValue({ success: false, error: 'link is gone' })
+
+ render()
+
+ fireEvent.click(await screen.findByRole('button', { name: /remove from launch/i }))
+
+ await waitFor(() => expect(mockToastError).toHaveBeenCalledWith('link is gone'))
+ expect(screen.getByText('Launch')).toBeInTheDocument()
+ })
})
diff --git a/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.tsx b/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.tsx
index 80f00d98e..ea4704ff0 100644
--- a/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.tsx
+++ b/apps/desktop/src/renderer/src/components/tasks/projects/item-project-chips.tsx
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
+import { toast } from 'sonner'
+import { X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
tasksService,
@@ -12,6 +14,19 @@ import { useT } from '@memry/i18n/renderer'
const log = createLogger('ItemProjectChips')
+function ProjectChipLabel({ project }: { project: ProjectRef }): React.JSX.Element {
+ return (
+ <>
+
+ {project.name}
+ >
+ )
+}
+
interface ItemProjectChipsProps {
itemType: ProjectItemType
itemId: string
@@ -26,9 +41,16 @@ interface ItemProjectChipsProps {
}
/**
- * Small pill row showing the projects an item (note/calendar event) belongs
- * to, via `project_links`. Shared between the note view and the calendar
- * event popover.
+ * Small pill row showing the projects an item belongs to, via `project_links`,
+ * with a remove control per chip.
+ *
+ * The remove control is not optional. This row is the only place a binary
+ * file's project membership is ever shown — a file has no frontmatter, so it
+ * has no `project` property row to edit the way a markdown note does — and
+ * three separate surfaces can add one (sidebar drag, the file page's "Add to
+ * project", the project hub's paperclip import). Without it the membership is
+ * a one-way door out of which the only exits are deleting the project or the
+ * file.
*/
export const ItemProjectChips = ({
itemType,
@@ -63,6 +85,20 @@ export const ItemProjectChips = ({
useEffect(() => onProjectUpdated(() => void load()), [load])
+ // The main side answers a failed unlink with an error envelope rather than a
+ // rejection, so the envelope has to be checked before the reload; and the
+ // reload runs either way so the row shows what the DB actually holds.
+ const handleRemove = async (projectId: string): Promise => {
+ try {
+ const result = await tasksService.unlinkProjectItem({ projectId, itemType, itemId })
+ if (!result.success) throw new Error(result.error)
+ } catch (error) {
+ log.error('Failed to remove item from project', extractErrorMessage(error))
+ toast.error(extractErrorMessage(error, t('itemProjects.removeFailed')))
+ }
+ await load()
+ }
+
if (!isLoading && projects.length === 0) return null
const visible = maxVisible === undefined ? projects : projects.slice(0, maxVisible)
@@ -71,23 +107,37 @@ export const ItemProjectChips = ({
return (
{visible.map((project) => (
-
+ {/* The name is a control only where it leads somewhere. The file page
+ mounts this row without `onProjectClick`, and a second tab stop
+ that does nothing beside the real remove control is worse than
+ plain text. */}
+ {onProjectClick ? (
+
+ ) : (
+
+
+
+ )}
+
+
))}
{hiddenCount > 0 && (
{
+ return page.evaluate(async (name) => {
+ const created = await window.api.tasks.createProject({ name, color: '#6366f1' })
+ if (!created.success || !created.project) {
+ throw new Error(created.error ?? 'project create failed')
+ }
+
+ const { projects } = await window.api.tasks.listProjects()
+ const inbox = projects.find((project) => project.isInbox)
+ if (!inbox) throw new Error('inbox project missing')
+
+ return { inboxId: inbox.id, inboxName: inbox.name, projectId: created.project.id }
+ }, projectName)
+}
+
+async function linkedItemIds(page: Page, projectId: string): Promise {
+ return page.evaluate(async (id) => {
+ const links = await window.api.tasks.listProjectLinks(id)
+ return Array.isArray(links) ? links.map((link) => link.itemId) : []
+ }, projectId)
+}
+
+test.describe('Unassigning from a project', () => {
+ test('a note leaves both a user project and Inbox from its project property', async ({
+ page,
+ testVaultPath
+ }) => {
+ await ready(page)
+
+ const projectName = uniqueLabel('Unassign Project')
+ const { inboxId, inboxName, projectId } = await seedProjects(page, projectName)
+ const noteId = await seedNote(page, uniqueLabel('Unassign Note'), 'Body')
+
+ await page.evaluate(
+ async ({ noteId, projectId, inboxId }) => {
+ for (const id of [projectId, inboxId]) {
+ const linked = await window.api.tasks.linkProjectItem({
+ projectId: id,
+ itemType: 'note',
+ itemId: noteId
+ })
+ if (!linked.success) throw new Error(linked.error ?? 'link failed')
+ }
+ },
+ { noteId, projectId, inboxId }
+ )
+
+ await expect.poll(() => linkedItemIds(page, projectId)).toContain(noteId)
+ await expect.poll(() => linkedItemIds(page, inboxId)).toContain(noteId)
+
+ const notePath = await page.evaluate(async (id) => {
+ const note = await window.api.notes.get(id)
+ return note?.path ?? null
+ }, noteId)
+ expect(notePath).toBeTruthy()
+ expect(fs.readFileSync(path.join(testVaultPath, notePath!), 'utf8')).toContain(projectName)
+
+ await openNoteTab(page, noteId)
+
+ const properties = page.getByRole('list', { name: 'Properties list' }).first()
+ await expect(properties).toBeVisible()
+
+ for (const name of [projectName, inboxName]) {
+ // Exact: the property row's own wrapper is a role="button" whose
+ // accessible name concatenates the chip label with this one, so a
+ // substring match would resolve to two elements.
+ const remove = properties.getByRole('button', { name: `Remove from ${name}`, exact: true })
+ await expect(remove).toBeVisible()
+ await remove.click()
+ await expect(remove).toHaveCount(0)
+ }
+
+ await expect.poll(() => linkedItemIds(page, projectId)).not.toContain(noteId)
+ await expect.poll(() => linkedItemIds(page, inboxId)).not.toContain(noteId)
+
+ await page.reload()
+ await page.waitForLoadState('domcontentloaded')
+
+ await expect.poll(() => linkedItemIds(page, projectId)).not.toContain(noteId)
+ await expect.poll(() => linkedItemIds(page, inboxId)).not.toContain(noteId)
+
+ // The file is the sync payload and the reindex source, so the link has to
+ // be gone from the bytes, not only from the index. `Note.content` is the
+ // body with the frontmatter stripped, so the property is read off
+ // `frontmatter` and confirmed against the file on disk.
+ const stored = await page.evaluate(async (id) => {
+ const note = await window.api.notes.get(id)
+ return note ? JSON.stringify(note.frontmatter) : null
+ }, noteId)
+ expect(stored).not.toContain(projectName)
+ expect(fs.readFileSync(path.join(testVaultPath, notePath!), 'utf8')).not.toContain(projectName)
+ })
+
+ test('a file leaves both a user project and Inbox from its project chips', async ({ page }) => {
+ await ready(page)
+
+ const projectName = uniqueLabel('Unassign File Project')
+ const { inboxId, inboxName, projectId } = await seedProjects(page, projectName)
+
+ const importDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memry-e2e-unassign-'))
+ const fileName = `unassign-${Date.now()}.png`
+ fs.writeFileSync(path.join(importDir, fileName), Buffer.from(PNG_BYTES))
+ const fileTitle = path.basename(fileName, path.extname(fileName))
+
+ try {
+ const imported = await page.evaluate(
+ async (sourcePath) => window.api.notes.importFiles([sourcePath], ''),
+ path.join(importDir, fileName)
+ )
+ expect(imported.success).toBe(true)
+
+ const findFileId = async (): Promise =>
+ page.evaluate(async (title) => {
+ const list = await window.api.notes.list({ limit: 200 })
+ return list.notes.find((note) => note.title === title)?.id ?? null
+ }, fileTitle)
+
+ // The import writes the file; the indexer gives it an id a moment later.
+ await expect.poll(findFileId, { timeout: 20_000 }).not.toBeNull()
+ const fileId = await findFileId()
+ expect(fileId).toBeTruthy()
+
+ await page.evaluate(
+ async ({ fileId, projectId, inboxId }) => {
+ for (const id of [projectId, inboxId]) {
+ const linked = await window.api.tasks.linkProjectItem({
+ projectId: id,
+ itemType: 'file',
+ itemId: fileId
+ })
+ if (!linked.success) throw new Error(linked.error ?? 'link failed')
+ }
+ },
+ { fileId: fileId!, projectId, inboxId }
+ )
+
+ await expect.poll(() => linkedItemIds(page, projectId)).toContain(fileId)
+ await expect.poll(() => linkedItemIds(page, inboxId)).toContain(fileId)
+
+ await openFileTab(page, fileId!, fileTitle)
+
+ for (const name of [projectName, inboxName]) {
+ const remove = page.getByRole('button', { name: `Remove from ${name}`, exact: true })
+ await expect(remove).toBeVisible()
+ await remove.click()
+ await expect(remove).toHaveCount(0)
+ }
+
+ await expect.poll(() => linkedItemIds(page, projectId)).not.toContain(fileId)
+ await expect.poll(() => linkedItemIds(page, inboxId)).not.toContain(fileId)
+
+ await page.reload()
+ await page.waitForLoadState('domcontentloaded')
+
+ await expect.poll(() => linkedItemIds(page, projectId)).not.toContain(fileId)
+ await expect.poll(() => linkedItemIds(page, inboxId)).not.toContain(fileId)
+ } finally {
+ fs.rmSync(importDir, { recursive: true, force: true })
+ }
+ })
+})
+
+async function openNoteTab(page: Page, noteId: string): Promise {
+ await seedTab(page, { type: 'note', id: noteId, title: 'Unassign Note', path: `/note/${noteId}` })
+ await expect(page.getByRole('list', { name: 'Properties list' }).first()).toBeVisible()
+}
+
+async function openFileTab(page: Page, fileId: string, title: string): Promise {
+ await seedTab(page, { type: 'file', id: fileId, title, path: `/file/${fileId}` })
+ await expect(page.getByRole('heading', { name: title })).toBeVisible()
+}
+
+async function seedTab(
+ page: Page,
+ tab: { type: string; id: string; title: string; path: string }
+): Promise {
+ const storageKey = await tabSessionStorageKey(page)
+ await page.addInitScript(
+ ({ tab, storageKey }) => {
+ localStorage.setItem(
+ storageKey,
+ JSON.stringify({
+ version: 2,
+ tabGroups: {
+ g1: {
+ id: 'g1',
+ activeTabId: 'seeded-tab',
+ tabs: [
+ {
+ id: 'seeded-tab',
+ type: tab.type,
+ title: tab.title,
+ icon: tab.type,
+ path: tab.path,
+ entityId: tab.id,
+ isPinned: false
+ }
+ ]
+ }
+ },
+ layout: { type: 'leaf', tabGroupId: 'g1' },
+ activeGroupId: 'g1',
+ settings: { restoreSessionOnStart: true, tabCloseButton: 'hover' },
+ savedAt: Date.now()
+ })
+ )
+ },
+ { tab, storageKey }
+ )
+ await page.reload()
+ await page.waitForLoadState('domcontentloaded')
+}
diff --git a/apps/desktop/tsconfig.test.web.json b/apps/desktop/tsconfig.test.web.json
index 1a40f0e5c..cb3ca288f 100644
--- a/apps/desktop/tsconfig.test.web.json
+++ b/apps/desktop/tsconfig.test.web.json
@@ -83,7 +83,6 @@
"src/renderer/src/components/tasks/kanban/kanban-column-extra.test.tsx",
"src/renderer/src/components/tasks/kanban/kanban-columns.test.ts",
"src/renderer/src/components/tasks/project/virtualized-project-task-list.test.tsx",
- "src/renderer/src/components/tasks/projects/item-project-chips.test.tsx",
"src/renderer/src/components/tasks/task-detail-drawer.test.tsx",
"src/renderer/src/components/tasks/task-row-extra.test.tsx",
"src/renderer/src/components/tasks/task-section.test.tsx",
diff --git a/apps/docs/src/user-guide/projects.md b/apps/docs/src/user-guide/projects.md
index e9b5f9609..7f9a7f5f2 100644
--- a/apps/docs/src/user-guide/projects.md
+++ b/apps/docs/src/user-guide/projects.md
@@ -102,7 +102,13 @@ Notes, events, and files join a project as **links** (many-to-many): the same no
- **Add an event** — right-click a calendar event and choose **Add to project**
- Dragging any note or file from the sidebar onto a project links it in one step — memrynote tells notes and files apart automatically, so the same drag works for either
-Files and calendar events, which have no frontmatter, show small **project chips** under their title — click a chip to jump to that project's hub. A note or journal entry shows its projects in the `project` property row instead.
+Files and calendar events, which have no frontmatter, show small **project chips** under their title. A note or journal entry shows its projects in the `project` property row instead.
+
+### Removing from a project
+
+Nothing has to be deleted to undo a link. A note or journal entry leaves a project from the same `project` property row that put it there: every project is a chip with an **×**. A file leaves the same way, from the project chips under its title, which carry that **×** too. Removing the last one leaves the item in your vault with no project at all, exactly as it was before you linked it. A note's membership lives in its frontmatter, so the removal travels to your other devices with the file itself.
+
+A calendar event leaves from the **Project** field in its own form, either by choosing **No project** or with the **×** on any extra project chip beside it.
## Deleting a Project
diff --git a/packages/i18n/src/locales/en/tasks.json b/packages/i18n/src/locales/en/tasks.json
index ec696813a..a28bea3c3 100644
--- a/packages/i18n/src/locales/en/tasks.json
+++ b/packages/i18n/src/locales/en/tasks.json
@@ -826,7 +826,9 @@
}
},
"itemProjects": {
- "openProject": "Open project {name}"
+ "openProject": "Open project {name}",
+ "removeFromProject": "Remove from {name}",
+ "removeFailed": "Could not remove this item from the project"
},
"projectHub": {
"header": {