From c50fee45b29fb35306f429e4eae7afe111c82379 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:05:54 +0200 Subject: [PATCH 01/26] feat(issues): add notion provider --- .../src/main/core/issues/registry.ts | 2 + .../src/main/core/notion/controller.ts | 19 ++ .../notion/notion-connection-service.test.ts | 153 ++++++++++ .../core/notion/notion-connection-service.ts | 261 +++++++++++++++++ .../core/notion/notion-issue-provider.test.ts | 165 +++++++++++ .../main/core/notion/notion-issue-provider.ts | 265 ++++++++++++++++++ apps/emdash-desktop/src/main/rpc.ts | 2 + .../tasks/conversations/context-actions.ts | 1 + .../src/shared/core/linked-issue.ts | 1 + .../src/shared/issue-providers.ts | 5 + apps/emdash-desktop/src/shared/telemetry.ts | 38 ++- 11 files changed, 909 insertions(+), 3 deletions(-) create mode 100644 apps/emdash-desktop/src/main/core/notion/controller.ts create mode 100644 apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts create mode 100644 apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts create mode 100644 apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts create mode 100644 apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts diff --git a/apps/emdash-desktop/src/main/core/issues/registry.ts b/apps/emdash-desktop/src/main/core/issues/registry.ts index 4de7259cf4..bfc53cb9d2 100644 --- a/apps/emdash-desktop/src/main/core/issues/registry.ts +++ b/apps/emdash-desktop/src/main/core/issues/registry.ts @@ -6,6 +6,7 @@ import { gitlabIssueProvider } from '@main/core/gitlab/gitlab-issue-provider'; import { jiraIssueProvider } from '@main/core/jira/jira-issue-provider'; import { linearIssueProvider } from '@main/core/linear/linear-issue-provider'; import { mondayIssueProvider } from '@main/core/monday/monday-issue-provider'; +import { notionIssueProvider } from '@main/core/notion/notion-issue-provider'; import { plainIssueProvider } from '@main/core/plain/plain-issue-provider'; import { planeIssueProvider } from '@main/core/plane/plane-issue-provider'; import { trelloIssueProvider } from '@main/core/trello/trello-issue-provider'; @@ -29,6 +30,7 @@ register(plainIssueProvider); register(asanaIssueProvider); register(mondayIssueProvider); register(trelloIssueProvider); +register(notionIssueProvider); export function getIssueProvider(type: IssueProviderType): IssueProvider | undefined { return providers.get(type); diff --git a/apps/emdash-desktop/src/main/core/notion/controller.ts b/apps/emdash-desktop/src/main/core/notion/controller.ts new file mode 100644 index 0000000000..683103f22c --- /dev/null +++ b/apps/emdash-desktop/src/main/core/notion/controller.ts @@ -0,0 +1,19 @@ +import { createRPCController } from '@shared/lib/ipc/rpc'; +import { notionConnectionService } from './notion-connection-service'; + +export const notionController = createRPCController({ + saveCredentials: async (input: { token: string; databaseUrls: string }) => { + if ( + !input?.token || + typeof input.token !== 'string' || + typeof input.databaseUrls !== 'string' + ) { + return { success: false, error: 'A Notion token and database URLs are required.' }; + } + return notionConnectionService.saveCredentials(input); + }, + + checkConnection: async () => notionConnectionService.checkConnection(), + + clearCredentials: async () => notionConnectionService.clearCredentials(), +}); diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts new file mode 100644 index 0000000000..1d41afa084 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSetSecret = vi.fn(); +const mockGetSecret = vi.fn(); +const mockDeleteSecret = vi.fn(); + +vi.mock('@main/core/secrets/encrypted-app-secrets-store', () => ({ + encryptedAppSecretsStore: { + setSecret: (...args: unknown[]) => mockSetSecret(...args), + getSecret: (...args: unknown[]) => mockGetSecret(...args), + deleteSecret: (...args: unknown[]) => mockDeleteSecret(...args), + }, +})); + +vi.mock('@main/lib/logger', () => ({ + log: { error: vi.fn() }, +})); + +vi.mock('@main/lib/telemetry', () => ({ + telemetryService: { capture: vi.fn() }, +})); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +import { NOTION_API_ERROR_MESSAGES, NotionConnectionService } from './notion-connection-service'; + +function jsonResponse(body: unknown): { ok: boolean; json: () => Promise } { + return { ok: true, json: async () => body }; +} + +describe('NotionConnectionService', () => { + let service: NotionConnectionService; + + beforeEach(() => { + vi.resetAllMocks(); + service = new NotionConnectionService(); + }); + + describe('saveCredentials', () => { + it('validates token against Notion API and stores credentials', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ id: 'bot-1', name: 'Emdash', bot: { workspace_name: 'Acme' } }) + ); + + const input = { token: 'secret_token', databaseUrls: '' }; + const result = await service.saveCredentials(input); + + expect(result).toEqual({ success: true, displayName: 'Acme' }); + expect(mockSetSecret).toHaveBeenCalledWith( + 'emdash-notion-credentials', + JSON.stringify({ token: input.token, databaseIds: [], databaseUrls: [] }) + ); + }); + + it('sends Notion auth and version headers', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ id: 'bot-1', name: 'Emdash' })); + + await service.saveCredentials({ token: 'secret_token', databaseUrls: '' }); + + const init = mockFetch.mock.calls[0][1] as RequestInit; + const headers = init.headers as Headers; + expect(mockFetch.mock.calls[0][0]).toBe('https://api.notion.com/v1/users/me'); + expect(headers.get('Authorization')).toBe('Bearer secret_token'); + expect(headers.get('Notion-Version')).toEqual(expect.any(String)); + }); + + it('parses and deduplicates database IDs from URLs and raw IDs', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ id: 'bot-1', name: 'Emdash' })); + + const databaseId = 'abcdefabcdefabcdefabcdefabcdefab'; + const result = await service.saveCredentials({ + token: 'secret_token', + databaseUrls: `https://www.notion.so/acme/Roadmap-${databaseId}?v=123\n${databaseId}`, + }); + + expect(result.success).toBe(true); + expect(mockSetSecret).toHaveBeenCalledWith( + 'emdash-notion-credentials', + JSON.stringify({ + token: 'secret_token', + databaseIds: [databaseId], + databaseUrls: [`https://www.notion.so/acme/Roadmap-${databaseId}?v=123`, databaseId], + }) + ); + }); + + it('returns error for invalid database URL format', async () => { + const result = await service.saveCredentials({ + token: 'secret_token', + databaseUrls: 'https://example.com/not-notion', + }); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('Could not parse database ID'), + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns error for empty token', async () => { + const result = await service.saveCredentials({ token: ' ', databaseUrls: '' }); + + expect(result).toEqual({ + success: false, + error: 'Notion integration token cannot be empty.', + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns a helpful authentication error when Notion rejects the token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({}), + }); + + const result = await service.saveCredentials({ token: 'bad-token', databaseUrls: '' }); + + expect(result).toEqual({ + success: false, + error: NOTION_API_ERROR_MESSAGES.AUTH_FAILED, + }); + }); + }); + + describe('getStoredCredentials', () => { + it('defaults missing database scope to empty lists', async () => { + mockGetSecret.mockResolvedValueOnce(JSON.stringify({ token: 'stored-token' })); + + const result = await service.getStoredCredentials(); + + expect(result).toEqual({ token: 'stored-token', databaseIds: [], databaseUrls: [] }); + }); + + it('returns null for invalid stored credential shapes', async () => { + mockGetSecret.mockResolvedValueOnce(JSON.stringify({ token: 123 })); + + const result = await service.getStoredCredentials(); + + expect(result).toBeNull(); + }); + }); + + describe('clearCredentials', () => { + it('deletes stored credentials', async () => { + const result = await service.clearCredentials(); + + expect(result).toEqual({ success: true }); + expect(mockDeleteSecret).toHaveBeenCalledWith('emdash-notion-credentials'); + }); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts new file mode 100644 index 0000000000..6e759f6764 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts @@ -0,0 +1,261 @@ +import { encryptedAppSecretsStore } from '@main/core/secrets/encrypted-app-secrets-store'; +import { log } from '@main/lib/logger'; +import { telemetryService } from '@main/lib/telemetry'; +import { ISSUE_PROVIDER_CAPABILITIES, type ConnectionStatus } from '@shared/issue-providers'; + +const NOTION_API_BASE_URL = 'https://api.notion.com/v1'; +const NOTION_VERSION = '2022-06-28'; +const CREDENTIALS_KEY = 'emdash-notion-credentials'; +const MAX_SELECTED_DATABASES = 50; + +export const NOTION_API_ERROR_MESSAGES = { + AUTH_FAILED: 'Notion authentication failed. Check your internal integration token.', + MISSING_PERMISSIONS: + 'Notion token was accepted but is missing access to the selected pages or databases.', + RATE_LIMITED: 'Notion API rate limit exceeded. Please try again shortly.', + UNAVAILABLE: 'Notion API is temporarily unavailable. Please try again.', +} as const; + +export type NotionCredentials = { + token: string; + databaseIds: string[]; + databaseUrls: string[]; +}; + +type SaveCredentialsInput = { + token: string; + databaseUrls: string; +}; + +type NotionUser = { + id: string; + name?: string | null; + bot?: { owner?: { type?: string; workspace?: boolean }; workspace_name?: string | null }; +}; + +function normalizeStoredCredentials(raw: unknown): NotionCredentials | null { + if (!raw || typeof raw !== 'object') return null; + + const candidate = raw as Partial; + if (typeof candidate.token !== 'string' || !candidate.token.trim()) { + return null; + } + + const databaseIds = candidate.databaseIds ?? []; + const databaseUrls = candidate.databaseUrls ?? []; + if (!Array.isArray(databaseIds) || databaseIds.some((id) => typeof id !== 'string')) { + return null; + } + if (!Array.isArray(databaseUrls) || databaseUrls.some((url) => typeof url !== 'string')) { + return null; + } + + return { + token: candidate.token, + databaseIds: [...new Set(databaseIds)], + databaseUrls: [...new Set(databaseUrls)], + }; +} + +function toNotionApiErrorMessage(status: number, apiMessage?: string): string { + if (apiMessage) return apiMessage; + + if (status === 401) return NOTION_API_ERROR_MESSAGES.AUTH_FAILED; + if (status === 403) return NOTION_API_ERROR_MESSAGES.MISSING_PERMISSIONS; + if (status === 429) return NOTION_API_ERROR_MESSAGES.RATE_LIMITED; + if (status >= 500) return NOTION_API_ERROR_MESSAGES.UNAVAILABLE; + + return `Notion API error (${status})`; +} + +function normalizeNotionId(value: string): string | null { + const compact = value.replace(/-/g, '').trim(); + if (!/^[a-fA-F0-9]{32}$/.test(compact)) return null; + return compact.toLowerCase(); +} + +function parseDatabaseIdFromUrl(rawUrl: string): string | null { + try { + const url = new URL(rawUrl); + if (!url.hostname.endsWith('notion.so') && !url.hostname.endsWith('notion.site')) { + return null; + } + + const candidates = [ + ...url.pathname.split('/'), + ...url.searchParams.values(), + url.hash.replace(/^#/, ''), + ]; + + for (const candidate of candidates) { + const match = candidate.match(/[a-fA-F0-9]{32}/); + if (!match) continue; + const id = normalizeNotionId(match[0]); + if (id) return id; + } + + return null; + } catch { + return normalizeNotionId(rawUrl); + } +} + +export class NotionConnectionService { + private cachedCredentials: NotionCredentials | null | undefined = undefined; + + async saveCredentials( + input: SaveCredentialsInput + ): Promise<{ success: boolean; displayName?: string; error?: string }> { + const token = input.token.trim(); + if (!token) { + return { success: false, error: 'Notion integration token cannot be empty.' }; + } + + const databaseScope = this.parseDatabaseUrls(input.databaseUrls); + if (databaseScope === null) { + return { + success: false, + error: + 'Could not parse database ID from one or more URLs. Paste Notion database URLs or 32-character IDs.', + }; + } + if (databaseScope.databaseIds.length > MAX_SELECTED_DATABASES) { + return { + success: false, + error: `Notion database scope is limited to ${MAX_SELECTED_DATABASES} databases. Remove some URLs and try again.`, + }; + } + + try { + const user = await this.fetchMe(token); + const credentials: NotionCredentials = { token, ...databaseScope }; + await this.storeCredentials(credentials); + telemetryService.capture('integration_connected', { provider: 'notion' }); + return { success: true, displayName: user.bot?.workspace_name ?? user.name ?? 'Notion' }; + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Failed to validate Notion token. Please try again.'; + return { success: false, error: message }; + } + } + + async clearCredentials(): Promise<{ success: boolean; error?: string }> { + try { + await encryptedAppSecretsStore.deleteSecret(CREDENTIALS_KEY); + this.cachedCredentials = null; + telemetryService.capture('integration_disconnected', { provider: 'notion' }); + return { success: true }; + } catch (error) { + log.error('Failed to clear Notion credentials:', error); + return { + success: false, + error: 'Unable to remove Notion credentials from secure storage.', + }; + } + } + + async checkConnection(): Promise { + try { + const credentials = await this.getStoredCredentials(); + if (!credentials) { + return { connected: false, capabilities: ISSUE_PROVIDER_CAPABILITIES.notion }; + } + + const user = await this.fetchMe(credentials.token); + return { + connected: true, + displayName: user.bot?.workspace_name ?? user.name ?? 'Notion', + displayDetail: user.bot?.workspace_name && user.name ? user.name : undefined, + capabilities: ISSUE_PROVIDER_CAPABILITIES.notion, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to verify Notion connection.'; + return { connected: false, error: message, capabilities: ISSUE_PROVIDER_CAPABILITIES.notion }; + } + } + + async isConfigured(): Promise { + return !!(await this.getStoredCredentials()); + } + + async getStoredCredentials(): Promise { + if (this.cachedCredentials !== undefined) { + return this.cachedCredentials; + } + + try { + const raw = await encryptedAppSecretsStore.getSecret(CREDENTIALS_KEY); + if (!raw) { + this.cachedCredentials = null; + return null; + } + this.cachedCredentials = normalizeStoredCredentials(JSON.parse(raw)); + return this.cachedCredentials; + } catch (error) { + log.error('Failed to read Notion credentials from secure storage:', error); + return null; + } + } + + async request(token: string, path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set('Accept', 'application/json'); + headers.set('Content-Type', 'application/json'); + headers.set('Notion-Version', NOTION_VERSION); + headers.set('Authorization', `Bearer ${token}`); + + const response = await fetch(`${NOTION_API_BASE_URL}${path}`, { + ...init, + headers, + }); + + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error(toNotionApiErrorMessage(response.status, body?.message)); + } + + return (await response.json()) as T; + } + + private parseDatabaseUrls( + databaseUrls: string + ): Pick | null { + const raw = databaseUrls.trim(); + if (!raw) return { databaseIds: [], databaseUrls: [] }; + + const values = raw + .split(/[,\n]+/) + .map((s) => s.trim()) + .filter(Boolean); + const databaseIds = new Set(); + const normalizedUrls = new Set(); + + for (const value of values) { + const id = parseDatabaseIdFromUrl(value); + if (!id) return null; + databaseIds.add(id); + normalizedUrls.add(value); + } + + return { databaseIds: [...databaseIds], databaseUrls: [...normalizedUrls] }; + } + + private async fetchMe(token: string): Promise { + return this.request(token, '/users/me'); + } + + private async storeCredentials(credentials: NotionCredentials): Promise { + try { + await encryptedAppSecretsStore.setSecret(CREDENTIALS_KEY, JSON.stringify(credentials)); + this.cachedCredentials = credentials; + } catch (error) { + log.error('Failed to store Notion credentials:', error); + throw new Error('Unable to save Notion credentials securely.'); + } + } +} + +export const notionConnectionService = new NotionConnectionService(); diff --git a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts new file mode 100644 index 0000000000..885de77f70 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { notionConnectionService } from './notion-connection-service'; +import { notionIssueProvider } from './notion-issue-provider'; + +vi.mock('./notion-connection-service', () => ({ + notionConnectionService: { + getStoredCredentials: vi.fn(), + isConfigured: vi.fn(), + checkConnection: vi.fn(), + request: vi.fn(), + }, +})); + +const mockGetStoredCredentials = vi.mocked(notionConnectionService.getStoredCredentials); +const mockRequest = vi.mocked(notionConnectionService.request); + +const DATABASE_ID = 'abcdefabcdefabcdefabcdefabcdefab'; +const PAGE = { + object: 'page', + id: 'page-1', + url: 'https://www.notion.so/acme/Fix-login-bug-page-1', + parent: { type: 'database_id', database_id: DATABASE_ID }, + last_edited_time: '2026-05-20T10:00:00.000Z', + properties: { + Name: { type: 'title', title: [{ plain_text: 'Fix login bug' }] }, + Description: { type: 'rich_text', rich_text: [{ plain_text: 'Steps to reproduce...' }] }, + Status: { type: 'status', status: { name: 'In progress' } }, + Assignee: { type: 'people', people: [{ name: 'Jan' }] }, + }, +} as const; + +describe('notionIssueProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('listIssues', () => { + it('returns pages from configured databases', async () => { + const credentials = { token: 'tok', databaseIds: [DATABASE_ID], databaseUrls: [] }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest.mockResolvedValue({ + results: [PAGE], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.listIssues({ limit: 50 }); + + expect(result).toEqual({ + success: true, + issues: [ + expect.objectContaining({ + provider: 'notion', + identifier: PAGE.id, + title: 'Fix login bug', + url: PAGE.url, + description: 'Steps to reproduce...', + status: 'In progress', + assignees: ['Jan'], + }), + ], + }); + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + '/search', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"value":"page"'), + }) + ); + }); + + it('filters out pages outside configured database scope', async () => { + const credentials = { + token: 'tok', + databaseIds: ['11111111111111111111111111111111'], + databaseUrls: [], + }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest.mockResolvedValue({ + results: [PAGE], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.listIssues({ limit: 50 }); + + expect(result).toEqual({ success: true, issues: [] }); + }); + + it('returns error when no credentials stored', async () => { + mockGetStoredCredentials.mockResolvedValue(null); + + const result = await notionIssueProvider.listIssues({ limit: 50 }); + + expect(result).toEqual({ success: false, error: 'Notion is not connected.' }); + }); + }); + + describe('searchIssues', () => { + it('returns no results for an empty search term without querying Notion', async () => { + const result = await notionIssueProvider.searchIssues({ searchTerm: ' ', limit: 20 }); + + expect(result).toEqual({ success: true, issues: [] }); + expect(mockGetStoredCredentials).not.toHaveBeenCalled(); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('passes the search query to Notion', async () => { + const credentials = { token: 'tok', databaseIds: [], databaseUrls: [] }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest.mockResolvedValue({ + results: [PAGE], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.searchIssues({ searchTerm: 'login', limit: 20 }); + + expect(result).toEqual({ success: true, issues: [expect.any(Object)] }); + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + '/search', + expect.objectContaining({ body: expect.stringContaining('"query":"login"') }) + ); + }); + }); + + describe('getIssueContext', () => { + it('returns page metadata with block children as context', async () => { + const credentials = { token: 'tok', databaseIds: [], databaseUrls: [] }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest.mockResolvedValueOnce(PAGE).mockResolvedValueOnce({ + results: [ + { id: 'block-1', type: 'paragraph', paragraph: { rich_text: [{ plain_text: 'Body' }] } }, + { + id: 'block-2', + type: 'to_do', + to_do: { checked: true, rich_text: [{ plain_text: 'Verified locally' }] }, + }, + ], + }); + + const result = await notionIssueProvider.getIssueContext!({ identifier: PAGE.id }); + + expect(result).toEqual({ + success: true, + issue: expect.objectContaining({ + provider: 'notion', + identifier: PAGE.id, + title: 'Fix login bug', + context: expect.stringContaining('Body'), + }), + }); + if (result.success) { + expect(result.issue.context).toContain('- [x] Verified locally'); + } + expect(mockRequest).toHaveBeenCalledWith(credentials.token, `/pages/${PAGE.id}`); + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + `/blocks/${PAGE.id}/children?page_size=50` + ); + }); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts new file mode 100644 index 0000000000..48d4bfdcee --- /dev/null +++ b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts @@ -0,0 +1,265 @@ +import { clampIssueLimit, normalizeSearchTerm } from '@main/core/issues/helpers/provider-inputs'; +import type { + IssueContextOpts, + IssueProvider, + IssueQueryOpts, + IssueSearchOpts, +} from '@main/core/issues/issue-provider'; +import type { LinkedIssue } from '@shared/core/linked-issue'; +import { + ISSUE_PROVIDER_CAPABILITIES, + type IssueContextResult, + type IssueListResult, +} from '@shared/issue-providers'; +import { notionConnectionService, type NotionCredentials } from './notion-connection-service'; + +type NotionRichText = { plain_text?: string }; +type NotionProperty = { + type: string; + title?: NotionRichText[]; + rich_text?: NotionRichText[]; + status?: { name?: string | null } | null; + select?: { name?: string | null } | null; + people?: { name?: string | null }[]; + last_edited_time?: string; +}; + +type NotionPage = { + object: 'page'; + id: string; + url: string; + parent?: { type?: string; database_id?: string; data_source_id?: string }; + last_edited_time?: string; + properties: Record; +}; + +type NotionSearchResponse = { + results: NotionPage[]; + has_more: boolean; + next_cursor?: string | null; +}; + +type NotionBlock = { + id: string; + type: string; + has_children?: boolean; + [key: string]: unknown; +}; + +function plainText(value: NotionRichText[] | undefined): string | undefined { + const text = value + ?.map((part) => part.plain_text ?? '') + .join('') + .trim(); + return text || undefined; +} + +function findProperty( + page: NotionPage, + types: string[], + preferredNames: string[] = [] +): NotionProperty | undefined { + const entries = Object.entries(page.properties); + for (const name of preferredNames) { + const property = page.properties[name]; + if (property && types.includes(property.type)) return property; + } + + return entries.find(([, property]) => types.includes(property.type))?.[1]; +} + +function getTitle(page: NotionPage): string { + const property = findProperty(page, ['title']); + if (property?.type !== 'title') return 'Untitled Notion page'; + return plainText(property.title) ?? 'Untitled Notion page'; +} + +function getDescription(page: NotionPage): string | undefined { + const property = findProperty( + page, + ['rich_text'], + ['Description', 'Summary', 'Details', 'Context'] + ); + return property?.type === 'rich_text' ? plainText(property.rich_text) : undefined; +} + +function getStatus(page: NotionPage): string | undefined { + const property = findProperty(page, ['status', 'select'], ['Status', 'State']); + if (property?.type === 'status') return property.status?.name ?? undefined; + if (property?.type === 'select') return property.select?.name ?? undefined; + return undefined; +} + +function getAssignees(page: NotionPage): string[] | undefined { + const property = findProperty(page, ['people'], ['Assignee', 'Assignees', 'Owner']); + if (property?.type !== 'people') return undefined; + + const assignees = property.people?.map((person) => person.name).filter(Boolean) ?? []; + return assignees.length ? (assignees as string[]) : undefined; +} + +function getParentDatabaseId(page: NotionPage): string | undefined { + return page.parent?.database_id ?? page.parent?.data_source_id; +} + +function toIssue(page: NotionPage, context?: string): LinkedIssue { + return { + provider: 'notion', + identifier: page.id, + title: getTitle(page), + url: page.url, + description: getDescription(page), + status: getStatus(page), + assignees: getAssignees(page), + updatedAt: page.last_edited_time, + fetchedAt: new Date().toISOString(), + context, + }; +} + +function isInConfiguredDatabase(page: NotionPage, databaseIds: string[]): boolean { + if (!databaseIds.length) return true; + const parentId = getParentDatabaseId(page)?.replace(/-/g, '').toLowerCase(); + return !!parentId && databaseIds.includes(parentId); +} + +function sortByUpdatedAtDesc(issues: LinkedIssue[]): LinkedIssue[] { + return [...issues].sort( + (a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime() + ); +} + +async function searchPages( + credentials: NotionCredentials, + searchTerm: string | undefined, + limit: number +): Promise { + const pages: NotionPage[] = []; + let startCursor: string | undefined; + + do { + const body: Record = { + page_size: Math.min(100, limit), + filter: { property: 'object', value: 'page' }, + sort: { direction: 'descending', timestamp: 'last_edited_time' }, + }; + if (searchTerm) body.query = searchTerm; + if (startCursor) body.start_cursor = startCursor; + + const data = await notionConnectionService.request( + credentials.token, + '/search', + { method: 'POST', body: JSON.stringify(body) } + ); + + pages.push( + ...data.results.filter((page) => isInConfiguredDatabase(page, credentials.databaseIds)) + ); + startCursor = data.next_cursor ?? undefined; + } while (startCursor && pages.length < limit); + + return pages.slice(0, limit); +} + +async function listIssues(opts: IssueQueryOpts): Promise { + const credentials = await notionConnectionService.getStoredCredentials(); + if (!credentials) { + return { success: false, error: 'Notion is not connected.' }; + } + + const sanitizedLimit = clampIssueLimit(opts.limit, 50, 200); + + try { + const pages = await searchPages(credentials, undefined, sanitizedLimit); + return { success: true, issues: sortByUpdatedAtDesc(pages.map((page) => toIssue(page))) }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch Notion pages.', + }; + } +} + +async function searchIssues(opts: IssueSearchOpts): Promise { + const term = normalizeSearchTerm(opts.searchTerm); + if (!term) { + return { success: true, issues: [] }; + } + + const credentials = await notionConnectionService.getStoredCredentials(); + if (!credentials) { + return { success: false, error: 'Notion is not connected.' }; + } + + const sanitizedLimit = clampIssueLimit(opts.limit, 20, 200); + + try { + const pages = await searchPages(credentials, term, sanitizedLimit); + return { success: true, issues: sortByUpdatedAtDesc(pages.map((page) => toIssue(page))) }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to search Notion pages.', + }; + } +} + +function getBlockText(block: NotionBlock): string | undefined { + const value = block[block.type]; + if (!value || typeof value !== 'object') return undefined; + + const richText = (value as { rich_text?: NotionRichText[] }).rich_text; + const text = plainText(richText); + if (!text) return undefined; + + if (block.type === 'to_do') { + const checked = (value as { checked?: boolean }).checked; + return `- [${checked ? 'x' : ' '}] ${text}`; + } + if (block.type === 'bulleted_list_item') return `- ${text}`; + if (block.type === 'numbered_list_item') return `1. ${text}`; + if (block.type === 'heading_1') return `# ${text}`; + if (block.type === 'heading_2') return `## ${text}`; + if (block.type === 'heading_3') return `### ${text}`; + return text; +} + +async function fetchBlockContext(token: string, pageId: string): Promise { + const data = await notionConnectionService.request<{ results: NotionBlock[] }>( + token, + `/blocks/${encodeURIComponent(pageId)}/children?page_size=50` + ); + const lines = data.results.map(getBlockText).filter(Boolean) as string[]; + return lines.length ? lines.join('\n\n') : undefined; +} + +async function getIssueContext(opts: IssueContextOpts): Promise { + const credentials = await notionConnectionService.getStoredCredentials(); + if (!credentials) { + return { success: false, error: 'Notion is not connected.' }; + } + + try { + const page = await notionConnectionService.request( + credentials.token, + `/pages/${encodeURIComponent(opts.identifier)}` + ); + const context = await fetchBlockContext(credentials.token, page.id).catch(() => undefined); + return { success: true, issue: toIssue(page, context) }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch Notion page context.', + }; + } +} + +export const notionIssueProvider: IssueProvider = { + type: 'notion', + capabilities: ISSUE_PROVIDER_CAPABILITIES.notion, + isConfigured: () => notionConnectionService.isConfigured(), + checkConnection: () => notionConnectionService.checkConnection(), + listIssues, + searchIssues, + getIssueContext, +}; diff --git a/apps/emdash-desktop/src/main/rpc.ts b/apps/emdash-desktop/src/main/rpc.ts index 5f5df792ff..392b0fb927 100644 --- a/apps/emdash-desktop/src/main/rpc.ts +++ b/apps/emdash-desktop/src/main/rpc.ts @@ -19,6 +19,7 @@ import { jiraController } from './core/jira/controller'; import { linearController } from './core/linear/controller'; import { mcpController } from './core/mcp/controller'; import { mondayController } from './core/monday/controller'; +import { notionController } from './core/notion/controller'; import { plainController } from './core/plain/controller'; import { planeController } from './core/plane/controller'; import { previewServersController } from './core/preview-servers/controller'; @@ -64,6 +65,7 @@ export const rpcRouter = createRPCRouter({ jira: jiraController, linear: linearController, monday: mondayController, + notion: notionController, plane: planeController, plain: plainController, trello: trelloController, diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/context-actions.ts b/apps/emdash-desktop/src/renderer/features/tasks/conversations/context-actions.ts index ca754b7208..9cfea641e0 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/conversations/context-actions.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/context-actions.ts @@ -44,6 +44,7 @@ const PROVIDER_LABELS: Record = { asana: 'Asana', monday: 'Monday.com', trello: 'Trello', + notion: 'Notion', }; export function buildIssueContextText(issue: LinkedIssue): string { diff --git a/apps/emdash-desktop/src/shared/core/linked-issue.ts b/apps/emdash-desktop/src/shared/core/linked-issue.ts index c7b8f387dc..c89aa0e1f7 100644 --- a/apps/emdash-desktop/src/shared/core/linked-issue.ts +++ b/apps/emdash-desktop/src/shared/core/linked-issue.ts @@ -18,6 +18,7 @@ const v0Schema = z.object({ 'asana', 'monday', 'trello', + 'notion', ]), url: z.string(), title: z.string(), diff --git a/apps/emdash-desktop/src/shared/issue-providers.ts b/apps/emdash-desktop/src/shared/issue-providers.ts index 7091244996..e41d5108c1 100644 --- a/apps/emdash-desktop/src/shared/issue-providers.ts +++ b/apps/emdash-desktop/src/shared/issue-providers.ts @@ -64,6 +64,11 @@ export const ISSUE_PROVIDER_CAPABILITIES: Record Date: Wed, 24 Jun 2026 07:06:12 +0200 Subject: [PATCH 02/26] feat(integrations): add notion setup UI --- .../features/integrations/NotionSetupForm.tsx | 46 +++++++++++++++++++ .../integrations/integration-setup-modal.tsx | 2 + .../integrations-provider.test.ts | 1 + .../integrations/integrations-provider.tsx | 17 +++++++ .../integrations/issue-provider-meta.ts | 11 +++++ .../features/integrations/provider-icons.tsx | 16 +++++++ .../renderer/features/integrations/types.ts | 1 + 7 files changed, 94 insertions(+) create mode 100644 apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx diff --git a/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx b/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx new file mode 100644 index 0000000000..b8cc2ff8bb --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import { Input } from '@renderer/lib/ui/input'; +import { SetupFormShell, type SetupFormProps } from './SetupFormShell'; + +function NotionSetupForm({ onSuccess, onClose }: SetupFormProps) { + const [token, setToken] = useState(''); + const [databaseUrls, setDatabaseUrls] = useState(''); + + return ( + ({ + token: token.trim(), + databaseUrls: databaseUrls.trim(), + })} + canSubmit={!!token.trim()} + onSuccess={onSuccess} + onClose={onClose} + > +
+ setToken(e.target.value)} + className="h-9 w-full" + autoFocus + /> + setDatabaseUrls(e.target.value)} + className="h-9 w-full" + /> +

+ Create an internal integration at{' '} + notion.so/my-integrations, then share the target + databases with that integration. Add database URLs to choose exactly which databases + Emdash searches; otherwise it searches all shared pages. +

+
+
+ ); +} + +export default NotionSetupForm; diff --git a/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx b/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx index a5da5753ee..064243989d 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx @@ -9,6 +9,7 @@ import { SETUP_PROVIDER_META } from './issue-provider-meta'; import JiraSetupForm from './JiraSetupForm'; import LinearSetupForm from './LinearSetupForm'; import MondaySetupForm from './MondaySetupForm'; +import NotionSetupForm from './NotionSetupForm'; import PlainSetupForm from './PlainSetupForm'; import PlaneSetupForm from './PlaneSetupForm'; import { type SetupFormProps } from './SetupFormShell'; @@ -32,6 +33,7 @@ const SETUP_FORMS: Record> = asana: AsanaSetupForm, monday: MondaySetupForm, trello: TrelloSetupForm, + notion: NotionSetupForm, }; export function IntegrationSetupModal({ integration, onSuccess, onClose }: Props) { diff --git a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.test.ts b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.test.ts index f0eb83a196..51ca10e905 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.test.ts +++ b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.test.ts @@ -29,6 +29,7 @@ vi.mock('@renderer/lib/ipc', () => ({ asana: { saveToken: vi.fn(), clearToken: vi.fn() }, monday: { saveCredentials: vi.fn(), clearCredentials: vi.fn() }, trello: { saveCredentials: vi.fn(), clearCredentials: vi.fn() }, + notion: { saveCredentials: vi.fn(), clearCredentials: vi.fn() }, }, })); diff --git a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx index 9a23c10c44..dad4bb1e13 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx @@ -77,6 +77,13 @@ function validateTrelloCredentials(input: { return null; } +function validateNotionCredentials(input: { token: string; databaseUrls: string }): string | null { + if (!input.token?.trim()) { + return 'Integration token is required.'; + } + return null; +} + const PROVIDER_CONNECTION_CONFIG: { [P in SetupIntegrationType]: ProviderConnectionConfig

; } = { @@ -130,6 +137,11 @@ const PROVIDER_CONNECTION_CONFIG: { disconnectMutationFn: () => rpc.trello.clearCredentials(), validateInput: validateTrelloCredentials, }, + notion: { + connectMutationFn: (credentials) => rpc.notion.saveCredentials(credentials), + disconnectMutationFn: () => rpc.notion.clearCredentials(), + validateInput: validateNotionCredentials, + }, }; type ProviderConnectionEntry

= { @@ -214,6 +226,10 @@ export function IntegrationsProvider({ children }: { children: React.ReactNode } ...PROVIDER_CONNECTION_CONFIG.trello, invalidate: invalidateStatuses, }); + const notionConnection = useProviderConnection({ + ...PROVIDER_CONNECTION_CONFIG.notion, + invalidate: invalidateStatuses, + }); const connectionStatus = statusData ? { ...DEFAULT_CONNECTION_STATUS, ...statusData } @@ -229,6 +245,7 @@ export function IntegrationsProvider({ children }: { children: React.ReactNode } asana: asanaConnection, monday: mondayConnection, trello: trelloConnection, + notion: notionConnection, }; return ( diff --git a/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts b/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts index 9f85048923..7c94753209 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts +++ b/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts @@ -11,6 +11,7 @@ export const ISSUE_PROVIDER_ORDER: IssueProviderType[] = [ 'trello', 'asana', 'monday', + 'notion', 'featurebase', 'plain', ]; @@ -71,6 +72,12 @@ export const ISSUE_PROVIDER_META: Record< features: ['issues'], disconnectCredentialLabel: 'API token', }, + notion: { + displayName: 'Notion', + description: 'Work on Notion pages', + features: ['issues'], + disconnectCredentialLabel: 'integration token', + }, trello: { displayName: 'Trello', description: 'Work on Trello cards', @@ -140,6 +147,10 @@ export const SETUP_PROVIDER_META: Record< title: 'Connect Monday.com', subtitle: 'Enter your Monday.com API token and optionally specify board URLs.', }, + notion: { + title: 'Connect Notion', + subtitle: 'Enter your Notion integration token and optionally specify database URLs.', + }, trello: { title: 'Connect Trello', subtitle: 'Enter your Trello API key and token, and optionally specify board URLs.', diff --git a/apps/emdash-desktop/src/renderer/features/integrations/provider-icons.tsx b/apps/emdash-desktop/src/renderer/features/integrations/provider-icons.tsx index 74cb6b265d..2b9d06d8df 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/provider-icons.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/provider-icons.tsx @@ -198,6 +198,21 @@ function PlainIcon(props: ProviderIconProps) { ); } +function NotionIcon(props: ProviderIconProps) { + return ( + + + + ); +} + export const PROVIDER_ICON_COMPONENTS = { linear: LinearIcon, github: GitHubIcon, @@ -207,6 +222,7 @@ export const PROVIDER_ICON_COMPONENTS = { asana: AsanaIcon, monday: MondayIcon, trello: TrelloIcon, + notion: NotionIcon, forgejo: ForgejoIcon, featurebase: FeaturebaseIcon, plain: PlainIcon, diff --git a/apps/emdash-desktop/src/renderer/features/integrations/types.ts b/apps/emdash-desktop/src/renderer/features/integrations/types.ts index 62724c9a80..793914cd8d 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/types.ts +++ b/apps/emdash-desktop/src/renderer/features/integrations/types.ts @@ -13,4 +13,5 @@ export type ProviderInput = { asana: string; monday: { token: string; boardUrls: string }; trello: { apiKey: string; token: string; boardUrls: string }; + notion: { token: string; databaseUrls: string }; }; From 3a3579af65da4fba426fd69c53551c372fa24e54 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:51:56 +0200 Subject: [PATCH 03/26] fix(notion): fetch full page context --- .../core/notion/notion-issue-provider.test.ts | 93 ++++++++++++++++++- .../main/core/notion/notion-issue-provider.ts | 55 ++++++++++- 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts index 885de77f70..a8dfce3707 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts @@ -158,7 +158,98 @@ describe('notionIssueProvider', () => { expect(mockRequest).toHaveBeenCalledWith(credentials.token, `/pages/${PAGE.id}`); expect(mockRequest).toHaveBeenCalledWith( credentials.token, - `/blocks/${PAGE.id}/children?page_size=50` + `/blocks/${PAGE.id}/children?page_size=100` + ); + }); + + it('follows block children pagination when building context', async () => { + const credentials = { token: 'tok', databaseIds: [], databaseUrls: [] }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest + .mockResolvedValueOnce(PAGE) + .mockResolvedValueOnce({ + results: [ + { + id: 'block-1', + type: 'paragraph', + paragraph: { rich_text: [{ plain_text: 'First page body' }] }, + }, + ], + has_more: true, + next_cursor: 'cursor-2', + }) + .mockResolvedValueOnce({ + results: [ + { + id: 'block-2', + type: 'paragraph', + paragraph: { rich_text: [{ plain_text: 'Second page body' }] }, + }, + ], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.getIssueContext!({ identifier: PAGE.id }); + + expect(result).toEqual({ + success: true, + issue: expect.objectContaining({ + context: expect.stringContaining('First page body'), + }), + }); + if (result.success) { + expect(result.issue.context).toContain('Second page body'); + } + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + `/blocks/${PAGE.id}/children?page_size=100&start_cursor=cursor-2` + ); + }); + + it('includes nested child blocks when building context', async () => { + const credentials = { token: 'tok', databaseIds: [], databaseUrls: [] }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest + .mockResolvedValueOnce(PAGE) + .mockResolvedValueOnce({ + results: [ + { + id: 'block-parent', + type: 'toggle', + has_children: true, + toggle: { rich_text: [{ plain_text: 'Investigation notes' }] }, + }, + ], + has_more: false, + next_cursor: null, + }) + .mockResolvedValueOnce({ + results: [ + { + id: 'block-child', + type: 'paragraph', + paragraph: { rich_text: [{ plain_text: 'Nested reproduction detail' }] }, + }, + ], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.getIssueContext!({ identifier: PAGE.id }); + + expect(result).toEqual({ + success: true, + issue: expect.objectContaining({ + context: expect.stringContaining('Investigation notes'), + }), + }); + if (result.success) { + expect(result.issue.context).toContain('Nested reproduction detail'); + } + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + '/blocks/block-parent/children?page_size=100' ); }); }); diff --git a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts index 48d4bfdcee..e936544266 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts @@ -39,6 +39,12 @@ type NotionSearchResponse = { next_cursor?: string | null; }; +type NotionBlockChildrenResponse = { + results: NotionBlock[]; + has_more: boolean; + next_cursor?: string | null; +}; + type NotionBlock = { id: string; type: string; @@ -46,6 +52,10 @@ type NotionBlock = { [key: string]: unknown; }; +const BLOCK_CONTEXT_PAGE_SIZE = 100; +const MAX_CONTEXT_BLOCKS = 300; +const MAX_CONTEXT_DEPTH = 3; + function plainText(value: NotionRichText[] | undefined): string | undefined { const text = value ?.map((part) => part.plain_text ?? '') @@ -224,12 +234,47 @@ function getBlockText(block: NotionBlock): string | undefined { return text; } +function blockChildrenPath(blockId: string, startCursor?: string): string { + const params = new URLSearchParams({ page_size: String(BLOCK_CONTEXT_PAGE_SIZE) }); + if (startCursor) params.set('start_cursor', startCursor); + return `/blocks/${encodeURIComponent(blockId)}/children?${params.toString()}`; +} + +async function collectBlockLines( + token: string, + blockId: string, + depth: number, + remainingBlocks: { count: number } +): Promise { + const lines: string[] = []; + let startCursor: string | undefined; + + do { + const data = await notionConnectionService.request( + token, + blockChildrenPath(blockId, startCursor) + ); + + for (const block of data.results) { + if (remainingBlocks.count <= 0) return lines; + remainingBlocks.count -= 1; + + const text = getBlockText(block); + if (text) lines.push(text); + + if (block.has_children && depth < MAX_CONTEXT_DEPTH && remainingBlocks.count > 0) { + lines.push(...(await collectBlockLines(token, block.id, depth + 1, remainingBlocks))); + } + } + + startCursor = data.next_cursor ?? undefined; + } while (startCursor && remainingBlocks.count > 0); + + return lines; +} + async function fetchBlockContext(token: string, pageId: string): Promise { - const data = await notionConnectionService.request<{ results: NotionBlock[] }>( - token, - `/blocks/${encodeURIComponent(pageId)}/children?page_size=50` - ); - const lines = data.results.map(getBlockText).filter(Boolean) as string[]; + const lines = await collectBlockLines(token, pageId, 0, { count: MAX_CONTEXT_BLOCKS }); return lines.length ? lines.join('\n\n') : undefined; } From a9fd597ba5d0760ef0f7d3f459ac6e8025e43729 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:52:31 +0200 Subject: [PATCH 04/26] fix(telemetry): use issue provider type --- apps/emdash-desktop/src/shared/telemetry.ts | 57 +++------------------ 1 file changed, 6 insertions(+), 51 deletions(-) diff --git a/apps/emdash-desktop/src/shared/telemetry.ts b/apps/emdash-desktop/src/shared/telemetry.ts index 367b71dbf7..4c1a704fe1 100644 --- a/apps/emdash-desktop/src/shared/telemetry.ts +++ b/apps/emdash-desktop/src/shared/telemetry.ts @@ -5,6 +5,7 @@ import type { } from '@shared/core/automations/automation-run'; import type { PullRequestMergeStrategy } from '@shared/core/pull-requests/pull-requests'; import type { TaskLifecycleStatus } from '@shared/core/tasks/tasks'; +import type { IssueProviderType } from '@shared/issue-providers'; import type { OpenInAppId } from '@shared/openInApps'; type EmptyProps = Record; @@ -86,18 +87,7 @@ export type TelemetryEventProperties = { task_created: { strategy: 'blank' | 'branch' | 'issue' | 'pr'; has_initial_prompt: boolean; - has_issue: - | 'github' - | 'linear' - | 'jira' - | 'gitlab' - | 'plane' - | 'plain' - | 'forgejo' - | 'featurebase' - | 'notion' - | 'asana' - | 'none'; + has_issue: IssueProviderType | 'none'; provider: AgentProviderId | null; }; task_provisioned: EmptyProps; @@ -133,49 +123,14 @@ export type TelemetryEventProperties = { user_signed_out: EmptyProps; integration_connected: { - provider: - | 'github' - | 'linear' - | 'jira' - | 'gitlab' - | 'plane' - | 'plain' - | 'forgejo' - | 'featurebase' - | 'notion' - | 'asana' - | 'monday' - | 'trello'; + provider: IssueProviderType; + source?: 'cli'; }; integration_disconnected: { - provider: - | 'github' - | 'linear' - | 'jira' - | 'gitlab' - | 'plane' - | 'plain' - | 'forgejo' - | 'featurebase' - | 'notion' - | 'asana' - | 'monday' - | 'trello'; + provider: IssueProviderType; }; issue_linked_to_task: { - provider: - | 'github' - | 'linear' - | 'jira' - | 'gitlab' - | 'plane' - | 'plain' - | 'forgejo' - | 'featurebase' - | 'notion' - | 'asana' - | 'monday' - | 'trello'; + provider: IssueProviderType; }; open_in_external: { app: OpenInAppId | 'browser' }; From df3b9fbb7056288b59ed23fea459c5981c56e76e Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:56:40 +0200 Subject: [PATCH 05/26] fix(notion): model page scope explicitly --- .../notion/notion-connection-service.test.ts | 41 +++++++-- .../core/notion/notion-connection-service.ts | 91 ++++++++++++++----- .../core/notion/notion-issue-provider.test.ts | 77 ++++++++++++---- .../main/core/notion/notion-issue-provider.ts | 90 ++++++++++++++---- 4 files changed, 232 insertions(+), 67 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts index 1d41afa084..7d96530921 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts @@ -49,7 +49,7 @@ describe('NotionConnectionService', () => { expect(result).toEqual({ success: true, displayName: 'Acme' }); expect(mockSetSecret).toHaveBeenCalledWith( 'emdash-notion-credentials', - JSON.stringify({ token: input.token, databaseIds: [], databaseUrls: [] }) + JSON.stringify({ token: input.token, scope: { type: 'all-shared' } }) ); }); @@ -65,13 +65,13 @@ describe('NotionConnectionService', () => { expect(headers.get('Notion-Version')).toEqual(expect.any(String)); }); - it('parses and deduplicates database IDs from URLs and raw IDs', async () => { + it('parses and deduplicates data source IDs from URLs and raw IDs', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ id: 'bot-1', name: 'Emdash' })); - const databaseId = 'abcdefabcdefabcdefabcdefabcdefab'; + const dataSourceId = 'abcdefabcdefabcdefabcdefabcdefab'; const result = await service.saveCredentials({ token: 'secret_token', - databaseUrls: `https://www.notion.so/acme/Roadmap-${databaseId}?v=123\n${databaseId}`, + databaseUrls: `https://www.notion.so/acme/Roadmap-${dataSourceId}?v=123\n${dataSourceId}`, }); expect(result.success).toBe(true); @@ -79,8 +79,11 @@ describe('NotionConnectionService', () => { 'emdash-notion-credentials', JSON.stringify({ token: 'secret_token', - databaseIds: [databaseId], - databaseUrls: [`https://www.notion.so/acme/Roadmap-${databaseId}?v=123`, databaseId], + scope: { + type: 'data-sources', + dataSourceIds: [dataSourceId], + sourceUrls: [`https://www.notion.so/acme/Roadmap-${dataSourceId}?v=123`, dataSourceId], + }, }) ); }); @@ -125,12 +128,34 @@ describe('NotionConnectionService', () => { }); describe('getStoredCredentials', () => { - it('defaults missing database scope to empty lists', async () => { + it('defaults missing database scope to all shared pages', async () => { mockGetSecret.mockResolvedValueOnce(JSON.stringify({ token: 'stored-token' })); const result = await service.getStoredCredentials(); - expect(result).toEqual({ token: 'stored-token', databaseIds: [], databaseUrls: [] }); + expect(result).toEqual({ token: 'stored-token', scope: { type: 'all-shared' } }); + }); + + it('migrates legacy database scope to data source scope', async () => { + const dataSourceId = 'abcdefabcdefabcdefabcdefabcdefab'; + mockGetSecret.mockResolvedValueOnce( + JSON.stringify({ + token: 'stored-token', + databaseIds: [dataSourceId, dataSourceId], + databaseUrls: ['https://notion.so/example'], + }) + ); + + const result = await service.getStoredCredentials(); + + expect(result).toEqual({ + token: 'stored-token', + scope: { + type: 'data-sources', + dataSourceIds: [dataSourceId], + sourceUrls: ['https://notion.so/example'], + }, + }); }); it('returns null for invalid stored credential shapes', async () => { diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts index 6e759f6764..58d05956fc 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts @@ -16,10 +16,13 @@ export const NOTION_API_ERROR_MESSAGES = { UNAVAILABLE: 'Notion API is temporarily unavailable. Please try again.', } as const; +export type NotionPageScope = + | { type: 'all-shared' } + | { type: 'data-sources'; dataSourceIds: string[]; sourceUrls: string[] }; + export type NotionCredentials = { token: string; - databaseIds: string[]; - databaseUrls: string[]; + scope: NotionPageScope; }; type SaveCredentialsInput = { @@ -33,27 +36,63 @@ type NotionUser = { bot?: { owner?: { type?: string; workspace?: boolean }; workspace_name?: string | null }; }; -function normalizeStoredCredentials(raw: unknown): NotionCredentials | null { - if (!raw || typeof raw !== 'object') return null; +type StoredNotionCredentials = { + token?: unknown; + scope?: unknown; + databaseIds?: unknown; + databaseUrls?: unknown; +}; - const candidate = raw as Partial; - if (typeof candidate.token !== 'string' || !candidate.token.trim()) { +function uniqueStrings(values: unknown): string[] | null { + if (!Array.isArray(values) || values.some((value) => typeof value !== 'string')) { return null; } + return [...new Set(values)]; +} - const databaseIds = candidate.databaseIds ?? []; - const databaseUrls = candidate.databaseUrls ?? []; - if (!Array.isArray(databaseIds) || databaseIds.some((id) => typeof id !== 'string')) { +function normalizeStoredScope(candidate: StoredNotionCredentials): NotionPageScope | null { + if (candidate.scope && typeof candidate.scope === 'object') { + const scope = candidate.scope as Partial; + if (scope.type === 'all-shared') { + return { type: 'all-shared' }; + } + if (scope.type === 'data-sources') { + const dataSourceIds = uniqueStrings(scope.dataSourceIds); + const sourceUrls = uniqueStrings(scope.sourceUrls); + if (!dataSourceIds || !sourceUrls) return null; + return dataSourceIds.length + ? { type: 'data-sources', dataSourceIds, sourceUrls } + : { type: 'all-shared' }; + } return null; } - if (!Array.isArray(databaseUrls) || databaseUrls.some((url) => typeof url !== 'string')) { + + const legacyDatabaseIds = uniqueStrings(candidate.databaseIds ?? []); + const legacyDatabaseUrls = uniqueStrings(candidate.databaseUrls ?? []); + if (!legacyDatabaseIds || !legacyDatabaseUrls) return null; + return legacyDatabaseIds.length + ? { + type: 'data-sources', + dataSourceIds: legacyDatabaseIds, + sourceUrls: legacyDatabaseUrls, + } + : { type: 'all-shared' }; +} + +function normalizeStoredCredentials(raw: unknown): NotionCredentials | null { + if (!raw || typeof raw !== 'object') return null; + + const candidate = raw as StoredNotionCredentials; + if (typeof candidate.token !== 'string' || !candidate.token.trim()) { return null; } + const scope = normalizeStoredScope(candidate); + if (!scope) return null; + return { token: candidate.token, - databaseIds: [...new Set(databaseIds)], - databaseUrls: [...new Set(databaseUrls)], + scope, }; } @@ -111,15 +150,15 @@ export class NotionConnectionService { return { success: false, error: 'Notion integration token cannot be empty.' }; } - const databaseScope = this.parseDatabaseUrls(input.databaseUrls); - if (databaseScope === null) { + const scope = this.parseDatabaseUrls(input.databaseUrls); + if (scope === null) { return { success: false, error: 'Could not parse database ID from one or more URLs. Paste Notion database URLs or 32-character IDs.', }; } - if (databaseScope.databaseIds.length > MAX_SELECTED_DATABASES) { + if (scope.type === 'data-sources' && scope.dataSourceIds.length > MAX_SELECTED_DATABASES) { return { success: false, error: `Notion database scope is limited to ${MAX_SELECTED_DATABASES} databases. Remove some URLs and try again.`, @@ -128,7 +167,7 @@ export class NotionConnectionService { try { const user = await this.fetchMe(token); - const credentials: NotionCredentials = { token, ...databaseScope }; + const credentials: NotionCredentials = { token, scope }; await this.storeCredentials(credentials); telemetryService.capture('integration_connected', { provider: 'notion' }); return { success: true, displayName: user.bot?.workspace_name ?? user.name ?? 'Notion' }; @@ -220,27 +259,29 @@ export class NotionConnectionService { return (await response.json()) as T; } - private parseDatabaseUrls( - databaseUrls: string - ): Pick | null { + private parseDatabaseUrls(databaseUrls: string): NotionPageScope | null { const raw = databaseUrls.trim(); - if (!raw) return { databaseIds: [], databaseUrls: [] }; + if (!raw) return { type: 'all-shared' }; const values = raw .split(/[,\n]+/) .map((s) => s.trim()) .filter(Boolean); - const databaseIds = new Set(); - const normalizedUrls = new Set(); + const dataSourceIds = new Set(); + const sourceUrls = new Set(); for (const value of values) { const id = parseDatabaseIdFromUrl(value); if (!id) return null; - databaseIds.add(id); - normalizedUrls.add(value); + dataSourceIds.add(id); + sourceUrls.add(value); } - return { databaseIds: [...databaseIds], databaseUrls: [...normalizedUrls] }; + return { + type: 'data-sources', + dataSourceIds: [...dataSourceIds], + sourceUrls: [...sourceUrls], + }; } private async fetchMe(token: string): Promise { diff --git a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts index a8dfce3707..8d51de5d7b 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts @@ -14,12 +14,12 @@ vi.mock('./notion-connection-service', () => ({ const mockGetStoredCredentials = vi.mocked(notionConnectionService.getStoredCredentials); const mockRequest = vi.mocked(notionConnectionService.request); -const DATABASE_ID = 'abcdefabcdefabcdefabcdefabcdefab'; +const DATA_SOURCE_ID = 'abcdefabcdefabcdefabcdefabcdefab'; const PAGE = { object: 'page', id: 'page-1', url: 'https://www.notion.so/acme/Fix-login-bug-page-1', - parent: { type: 'database_id', database_id: DATABASE_ID }, + parent: { type: 'data_source_id', data_source_id: DATA_SOURCE_ID }, last_edited_time: '2026-05-20T10:00:00.000Z', properties: { Name: { type: 'title', title: [{ plain_text: 'Fix login bug' }] }, @@ -35,8 +35,11 @@ describe('notionIssueProvider', () => { }); describe('listIssues', () => { - it('returns pages from configured databases', async () => { - const credentials = { token: 'tok', databaseIds: [DATABASE_ID], databaseUrls: [] }; + it('queries configured data sources directly', async () => { + const credentials = { + token: 'tok', + scope: { type: 'data-sources' as const, dataSourceIds: [DATA_SOURCE_ID], sourceUrls: [] }, + }; mockGetStoredCredentials.mockResolvedValue(credentials); mockRequest.mockResolvedValue({ results: [PAGE], @@ -62,20 +65,16 @@ describe('notionIssueProvider', () => { }); expect(mockRequest).toHaveBeenCalledWith( credentials.token, - '/search', + `/data_sources/${DATA_SOURCE_ID}/query`, expect.objectContaining({ method: 'POST', - body: expect.stringContaining('"value":"page"'), + body: expect.stringContaining('"result_type":"page"'), }) ); }); - it('filters out pages outside configured database scope', async () => { - const credentials = { - token: 'tok', - databaseIds: ['11111111111111111111111111111111'], - databaseUrls: [], - }; + it('uses shared-page search when no data source scope is configured', async () => { + const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; mockGetStoredCredentials.mockResolvedValue(credentials); mockRequest.mockResolvedValue({ results: [PAGE], @@ -85,7 +84,15 @@ describe('notionIssueProvider', () => { const result = await notionIssueProvider.listIssues({ limit: 50 }); - expect(result).toEqual({ success: true, issues: [] }); + expect(result).toEqual({ success: true, issues: [expect.any(Object)] }); + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + '/search', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"value":"page"'), + }) + ); }); it('returns error when no credentials stored', async () => { @@ -107,7 +114,7 @@ describe('notionIssueProvider', () => { }); it('passes the search query to Notion', async () => { - const credentials = { token: 'tok', databaseIds: [], databaseUrls: [] }; + const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; mockGetStoredCredentials.mockResolvedValue(credentials); mockRequest.mockResolvedValue({ results: [PAGE], @@ -124,11 +131,47 @@ describe('notionIssueProvider', () => { expect.objectContaining({ body: expect.stringContaining('"query":"login"') }) ); }); + + it('searches configured data sources without global parent filtering', async () => { + const credentials = { + token: 'tok', + scope: { type: 'data-sources' as const, dataSourceIds: [DATA_SOURCE_ID], sourceUrls: [] }, + }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest.mockResolvedValue({ + results: [ + PAGE, + { + ...PAGE, + id: 'page-2', + properties: { + ...PAGE.properties, + Name: { type: 'title', title: [{ plain_text: 'Write release notes' }] }, + }, + }, + ], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.searchIssues({ searchTerm: 'login', limit: 20 }); + + expect(result).toEqual({ + success: true, + issues: [expect.objectContaining({ identifier: PAGE.id })], + }); + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + `/data_sources/${DATA_SOURCE_ID}/query`, + expect.objectContaining({ method: 'POST' }) + ); + expect(mockRequest).not.toHaveBeenCalledWith(credentials.token, '/search', expect.anything()); + }); }); describe('getIssueContext', () => { it('returns page metadata with block children as context', async () => { - const credentials = { token: 'tok', databaseIds: [], databaseUrls: [] }; + const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; mockGetStoredCredentials.mockResolvedValue(credentials); mockRequest.mockResolvedValueOnce(PAGE).mockResolvedValueOnce({ results: [ @@ -163,7 +206,7 @@ describe('notionIssueProvider', () => { }); it('follows block children pagination when building context', async () => { - const credentials = { token: 'tok', databaseIds: [], databaseUrls: [] }; + const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; mockGetStoredCredentials.mockResolvedValue(credentials); mockRequest .mockResolvedValueOnce(PAGE) @@ -208,7 +251,7 @@ describe('notionIssueProvider', () => { }); it('includes nested child blocks when building context', async () => { - const credentials = { token: 'tok', databaseIds: [], databaseUrls: [] }; + const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; mockGetStoredCredentials.mockResolvedValue(credentials); mockRequest .mockResolvedValueOnce(PAGE) diff --git a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts index e936544266..e1d33860bf 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts @@ -39,6 +39,12 @@ type NotionSearchResponse = { next_cursor?: string | null; }; +type NotionDataSourceQueryResponse = { + results: NotionPage[]; + has_more: boolean; + next_cursor?: string | null; +}; + type NotionBlockChildrenResponse = { results: NotionBlock[]; has_more: boolean; @@ -108,10 +114,6 @@ function getAssignees(page: NotionPage): string[] | undefined { return assignees.length ? (assignees as string[]) : undefined; } -function getParentDatabaseId(page: NotionPage): string | undefined { - return page.parent?.database_id ?? page.parent?.data_source_id; -} - function toIssue(page: NotionPage, context?: string): LinkedIssue { return { provider: 'notion', @@ -127,19 +129,24 @@ function toIssue(page: NotionPage, context?: string): LinkedIssue { }; } -function isInConfiguredDatabase(page: NotionPage, databaseIds: string[]): boolean { - if (!databaseIds.length) return true; - const parentId = getParentDatabaseId(page)?.replace(/-/g, '').toLowerCase(); - return !!parentId && databaseIds.includes(parentId); -} - function sortByUpdatedAtDesc(issues: LinkedIssue[]): LinkedIssue[] { return [...issues].sort( (a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime() ); } -async function searchPages( +function issueMatchesTerm(issue: LinkedIssue, term: string): boolean { + const normalizedTerm = term.toLowerCase(); + return [ + issue.title, + issue.description, + issue.status, + issue.project, + ...(issue.assignees ?? []), + ].some((value) => value?.toLowerCase().includes(normalizedTerm)); +} + +async function searchSharedPages( credentials: NotionCredentials, searchTerm: string | undefined, limit: number @@ -162,15 +169,64 @@ async function searchPages( { method: 'POST', body: JSON.stringify(body) } ); - pages.push( - ...data.results.filter((page) => isInConfiguredDatabase(page, credentials.databaseIds)) + pages.push(...data.results); + startCursor = data.next_cursor ?? undefined; + } while (startCursor && pages.length < limit); + + return pages.slice(0, limit); +} + +async function queryDataSourcePages( + token: string, + dataSourceId: string, + limit: number +): Promise { + const pages: NotionPage[] = []; + let startCursor: string | undefined; + + do { + const body: Record = { + page_size: Math.min(100, limit), + result_type: 'page', + sorts: [{ direction: 'descending', timestamp: 'last_edited_time' }], + }; + if (startCursor) body.start_cursor = startCursor; + + const data = await notionConnectionService.request( + token, + `/data_sources/${encodeURIComponent(dataSourceId)}/query`, + { method: 'POST', body: JSON.stringify(body) } ); + + pages.push(...data.results); startCursor = data.next_cursor ?? undefined; } while (startCursor && pages.length < limit); return pages.slice(0, limit); } +async function listScopedIssues( + credentials: NotionCredentials, + searchTerm: string | undefined, + limit: number +): Promise { + if (credentials.scope.type === 'all-shared') { + const pages = await searchSharedPages(credentials, searchTerm, limit); + return pages.map((page) => toIssue(page)); + } + + const pagesBySource = await Promise.all( + credentials.scope.dataSourceIds.map((dataSourceId) => + queryDataSourcePages(credentials.token, dataSourceId, limit) + ) + ); + const issues = pagesBySource.flat().map((page) => toIssue(page)); + const filteredIssues = searchTerm + ? issues.filter((issue) => issueMatchesTerm(issue, searchTerm)) + : issues; + return sortByUpdatedAtDesc(filteredIssues).slice(0, limit); +} + async function listIssues(opts: IssueQueryOpts): Promise { const credentials = await notionConnectionService.getStoredCredentials(); if (!credentials) { @@ -180,8 +236,8 @@ async function listIssues(opts: IssueQueryOpts): Promise { const sanitizedLimit = clampIssueLimit(opts.limit, 50, 200); try { - const pages = await searchPages(credentials, undefined, sanitizedLimit); - return { success: true, issues: sortByUpdatedAtDesc(pages.map((page) => toIssue(page))) }; + const issues = await listScopedIssues(credentials, undefined, sanitizedLimit); + return { success: true, issues: sortByUpdatedAtDesc(issues) }; } catch (error) { return { success: false, @@ -204,8 +260,8 @@ async function searchIssues(opts: IssueSearchOpts): Promise { const sanitizedLimit = clampIssueLimit(opts.limit, 20, 200); try { - const pages = await searchPages(credentials, term, sanitizedLimit); - return { success: true, issues: sortByUpdatedAtDesc(pages.map((page) => toIssue(page))) }; + const issues = await listScopedIssues(credentials, term, sanitizedLimit); + return { success: true, issues: sortByUpdatedAtDesc(issues) }; } catch (error) { return { success: false, From 028bcc100e2ac433a34edd8e59598497f3997e00 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:48:07 +0200 Subject: [PATCH 06/26] fix(notion): align connection copy with docs --- .../src/main/core/notion/controller.ts | 2 +- .../notion/notion-connection-service.test.ts | 29 +++++++++++++++++-- .../core/notion/notion-connection-service.ts | 24 +++++++++++---- .../features/integrations/NotionSetupForm.tsx | 12 ++++---- .../integrations/integrations-provider.tsx | 2 +- .../integrations/issue-provider-meta.ts | 4 +-- 6 files changed, 55 insertions(+), 18 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/notion/controller.ts b/apps/emdash-desktop/src/main/core/notion/controller.ts index 683103f22c..61a5d1e95c 100644 --- a/apps/emdash-desktop/src/main/core/notion/controller.ts +++ b/apps/emdash-desktop/src/main/core/notion/controller.ts @@ -8,7 +8,7 @@ export const notionController = createRPCController({ typeof input.token !== 'string' || typeof input.databaseUrls !== 'string' ) { - return { success: false, error: 'A Notion token and database URLs are required.' }; + return { success: false, error: 'A Notion access token and scope URLs are required.' }; } return notionConnectionService.saveCredentials(input); }, diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts index 7d96530921..e966f32389 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts @@ -88,6 +88,31 @@ describe('NotionConnectionService', () => { ); }); + it('parses data source IDs from notion.com and app.notion.com URLs', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ id: 'bot-1', name: 'Emdash' })); + + const dataSourceId = 'abcdefabcdefabcdefabcdefabcdefab'; + const notionComUrl = `https://www.notion.com/acme/Roadmap-${dataSourceId}?v=123`; + const appNotionUrl = `https://app.notion.com/p/Roadmap-${dataSourceId}`; + const result = await service.saveCredentials({ + token: 'secret_token', + databaseUrls: `${notionComUrl}, ${appNotionUrl}`, + }); + + expect(result.success).toBe(true); + expect(mockSetSecret).toHaveBeenCalledWith( + 'emdash-notion-credentials', + JSON.stringify({ + token: 'secret_token', + scope: { + type: 'data-sources', + dataSourceIds: [dataSourceId], + sourceUrls: [notionComUrl, appNotionUrl], + }, + }) + ); + }); + it('returns error for invalid database URL format', async () => { const result = await service.saveCredentials({ token: 'secret_token', @@ -96,7 +121,7 @@ describe('NotionConnectionService', () => { expect(result).toEqual({ success: false, - error: expect.stringContaining('Could not parse database ID'), + error: expect.stringContaining('Could not parse Notion ID'), }); expect(mockFetch).not.toHaveBeenCalled(); }); @@ -106,7 +131,7 @@ describe('NotionConnectionService', () => { expect(result).toEqual({ success: false, - error: 'Notion integration token cannot be empty.', + error: 'Notion access token cannot be empty.', }); expect(mockFetch).not.toHaveBeenCalled(); }); diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts index 58d05956fc..54c2463f29 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts @@ -4,12 +4,12 @@ import { telemetryService } from '@main/lib/telemetry'; import { ISSUE_PROVIDER_CAPABILITIES, type ConnectionStatus } from '@shared/issue-providers'; const NOTION_API_BASE_URL = 'https://api.notion.com/v1'; -const NOTION_VERSION = '2022-06-28'; +const NOTION_VERSION = '2026-03-11'; const CREDENTIALS_KEY = 'emdash-notion-credentials'; const MAX_SELECTED_DATABASES = 50; export const NOTION_API_ERROR_MESSAGES = { - AUTH_FAILED: 'Notion authentication failed. Check your internal integration token.', + AUTH_FAILED: 'Notion authentication failed. Check your connection access token.', MISSING_PERMISSIONS: 'Notion token was accepted but is missing access to the selected pages or databases.', RATE_LIMITED: 'Notion API rate limit exceeded. Please try again shortly.', @@ -113,10 +113,22 @@ function normalizeNotionId(value: string): string | null { return compact.toLowerCase(); } +function isNotionUrlHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return ( + normalized === 'notion.so' || + normalized.endsWith('.notion.so') || + normalized === 'notion.com' || + normalized.endsWith('.notion.com') || + normalized === 'notion.site' || + normalized.endsWith('.notion.site') + ); +} + function parseDatabaseIdFromUrl(rawUrl: string): string | null { try { const url = new URL(rawUrl); - if (!url.hostname.endsWith('notion.so') && !url.hostname.endsWith('notion.site')) { + if (!isNotionUrlHostname(url.hostname)) { return null; } @@ -147,7 +159,7 @@ export class NotionConnectionService { ): Promise<{ success: boolean; displayName?: string; error?: string }> { const token = input.token.trim(); if (!token) { - return { success: false, error: 'Notion integration token cannot be empty.' }; + return { success: false, error: 'Notion access token cannot be empty.' }; } const scope = this.parseDatabaseUrls(input.databaseUrls); @@ -155,13 +167,13 @@ export class NotionConnectionService { return { success: false, error: - 'Could not parse database ID from one or more URLs. Paste Notion database URLs or 32-character IDs.', + 'Could not parse Notion ID from one or more URLs. Paste Notion page, database, or data source URLs or 32-character IDs.', }; } if (scope.type === 'data-sources' && scope.dataSourceIds.length > MAX_SELECTED_DATABASES) { return { success: false, - error: `Notion database scope is limited to ${MAX_SELECTED_DATABASES} databases. Remove some URLs and try again.`, + error: `Notion scope is limited to ${MAX_SELECTED_DATABASES} data sources. Remove some URLs and try again.`, }; } diff --git a/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx b/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx index b8cc2ff8bb..a9efc83423 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx @@ -20,23 +20,23 @@ function NotionSetupForm({ onSuccess, onClose }: SetupFormProps) {

setToken(e.target.value)} className="h-9 w-full" autoFocus /> setDatabaseUrls(e.target.value)} className="h-9 w-full" />

- Create an internal integration at{' '} - notion.so/my-integrations, then share the target - databases with that integration. Add database URLs to choose exactly which databases - Emdash searches; otherwise it searches all shared pages. + Create a connection at{' '} + notion.com/my-integrations, copy its access token, + then share the target pages or databases with that connection. Add URLs to choose exactly + which data sources Emdash searches; otherwise it searches all shared pages.

diff --git a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx index dad4bb1e13..ba303202ea 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx @@ -79,7 +79,7 @@ function validateTrelloCredentials(input: { function validateNotionCredentials(input: { token: string; databaseUrls: string }): string | null { if (!input.token?.trim()) { - return 'Integration token is required.'; + return 'Access token is required.'; } return null; } diff --git a/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts b/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts index 7c94753209..b685666247 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts +++ b/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts @@ -76,7 +76,7 @@ export const ISSUE_PROVIDER_META: Record< displayName: 'Notion', description: 'Work on Notion pages', features: ['issues'], - disconnectCredentialLabel: 'integration token', + disconnectCredentialLabel: 'access token', }, trello: { displayName: 'Trello', @@ -149,7 +149,7 @@ export const SETUP_PROVIDER_META: Record< }, notion: { title: 'Connect Notion', - subtitle: 'Enter your Notion integration token and optionally specify database URLs.', + subtitle: 'Enter your Notion connection access token and optionally specify scope URLs.', }, trello: { title: 'Connect Trello', From c6c8d3062e34b8a661d917ce48d19ca59c759181 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:57:18 +0200 Subject: [PATCH 07/26] fix(integrations): update Notion icon --- .../src/assets/images/mcp/notion.svg | 12 +++++++++--- .../features/integrations/provider-icons.tsx | 14 +++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/emdash-desktop/src/assets/images/mcp/notion.svg b/apps/emdash-desktop/src/assets/images/mcp/notion.svg index 34953987b4..55e590dd45 100644 --- a/apps/emdash-desktop/src/assets/images/mcp/notion.svg +++ b/apps/emdash-desktop/src/assets/images/mcp/notion.svg @@ -1,4 +1,10 @@ - -Notion - + + + diff --git a/apps/emdash-desktop/src/renderer/features/integrations/provider-icons.tsx b/apps/emdash-desktop/src/renderer/features/integrations/provider-icons.tsx index 2b9d06d8df..e1be9ff8c3 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/provider-icons.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/provider-icons.tsx @@ -200,14 +200,14 @@ function PlainIcon(props: ProviderIconProps) { function NotionIcon(props: ProviderIconProps) { return ( - + + ); From 05cc91e584104c17bade62a812319281403a3701 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:00:03 +0200 Subject: [PATCH 08/26] fix(notion): simplify access token copy --- .../main/core/notion/notion-connection-service.test.ts | 2 +- .../src/main/core/notion/notion-connection-service.ts | 4 ++-- .../renderer/features/integrations/NotionSetupForm.tsx | 10 +++++----- .../features/integrations/issue-provider-meta.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts index e966f32389..8090d60464 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts @@ -131,7 +131,7 @@ describe('NotionConnectionService', () => { expect(result).toEqual({ success: false, - error: 'Notion access token cannot be empty.', + error: 'Access token cannot be empty.', }); expect(mockFetch).not.toHaveBeenCalled(); }); diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts index 54c2463f29..0622b74e4c 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts @@ -9,7 +9,7 @@ const CREDENTIALS_KEY = 'emdash-notion-credentials'; const MAX_SELECTED_DATABASES = 50; export const NOTION_API_ERROR_MESSAGES = { - AUTH_FAILED: 'Notion authentication failed. Check your connection access token.', + AUTH_FAILED: 'Notion authentication failed. Check your access token.', MISSING_PERMISSIONS: 'Notion token was accepted but is missing access to the selected pages or databases.', RATE_LIMITED: 'Notion API rate limit exceeded. Please try again shortly.', @@ -159,7 +159,7 @@ export class NotionConnectionService { ): Promise<{ success: boolean; displayName?: string; error?: string }> { const token = input.token.trim(); if (!token) { - return { success: false, error: 'Notion access token cannot be empty.' }; + return { success: false, error: 'Access token cannot be empty.' }; } const scope = this.parseDatabaseUrls(input.databaseUrls); diff --git a/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx b/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx index a9efc83423..46e13e2131 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx @@ -20,7 +20,7 @@ function NotionSetupForm({ onSuccess, onClose }: SetupFormProps) {
setToken(e.target.value)} className="h-9 w-full" @@ -33,10 +33,10 @@ function NotionSetupForm({ onSuccess, onClose }: SetupFormProps) { className="h-9 w-full" />

- Create a connection at{' '} - notion.com/my-integrations, copy its access token, - then share the target pages or databases with that connection. Add URLs to choose exactly - which data sources Emdash searches; otherwise it searches all shared pages. + Create a connection at notion.com/my-integrations, + copy the access token, then share the target pages or databases with that connection. Add + URLs to choose exactly which data sources Emdash searches; otherwise it searches all + shared pages.

diff --git a/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts b/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts index b685666247..6ad3c0f6d0 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts +++ b/apps/emdash-desktop/src/renderer/features/integrations/issue-provider-meta.ts @@ -149,7 +149,7 @@ export const SETUP_PROVIDER_META: Record< }, notion: { title: 'Connect Notion', - subtitle: 'Enter your Notion connection access token and optionally specify scope URLs.', + subtitle: 'Enter your Notion access token and optionally specify scope URLs.', }, trello: { title: 'Connect Trello', From 7ff6f989f431a5cdbacca44cd161cb746b6b6114 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:15:33 +0200 Subject: [PATCH 09/26] fix(issues): clarify provider access errors --- .../notion/notion-connection-service.test.ts | 18 +++++ .../core/notion/notion-connection-service.ts | 18 ++++- .../issue-search-empty-state.tsx | 81 +++++++++++++++++++ .../issue-selector/issue-selector.test.ts | 4 +- .../issue-selector/issue-selector.tsx | 7 +- .../parse-issue-search-error.test.ts | 48 +++++++++++ .../parse-issue-search-error.ts | 65 +++++++++++++++ 7 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-search-empty-state.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.test.ts create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.ts diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts index 8090d60464..e7fc006fca 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.test.ts @@ -200,4 +200,22 @@ describe('NotionConnectionService', () => { expect(mockDeleteSecret).toHaveBeenCalledWith('emdash-notion-credentials'); }); }); + + describe('request', () => { + it('normalizes Notion database access errors into actionable messages', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ + message: + 'Could not find database with ID: 94143f25-f30a-47ac-ac47-4a2d0692d2b0. Make sure the relevant pages and databases are shared with your integration "emdash".', + }), + }); + + await expect(service.request('token', '/data_sources/abc/query', { method: 'POST' })).rejects + .toThrow( + 'Notion cannot access the configured data source. Share the page or database with emdash, or update the scope URLs in Emdash settings.' + ); + }); + }); }); diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts index 0622b74e4c..ab3d92c2fa 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts @@ -96,8 +96,24 @@ function normalizeStoredCredentials(raw: unknown): NotionCredentials | null { }; } +function normalizeNotionApiMessage(message: string): string { + if (/Could not find (?:database|page|data source) with ID:/i.test(message)) { + const integrationMatch = message.match(/integration "([^"]+)"/i); + const integrationName = integrationMatch?.[1] ?? 'your Notion integration'; + return `Notion cannot access the configured data source. Share the page or database with ${integrationName}, or update the scope URLs in Emdash settings.`; + } + + if (/Make sure the relevant pages and databases are shared/i.test(message)) { + const integrationMatch = message.match(/integration "([^"]+)"/i); + const integrationName = integrationMatch?.[1] ?? 'your Notion integration'; + return `Share the relevant pages and databases with ${integrationName}, or update the scope URLs in Emdash settings.`; + } + + return message; +} + function toNotionApiErrorMessage(status: number, apiMessage?: string): string { - if (apiMessage) return apiMessage; + if (apiMessage) return normalizeNotionApiMessage(apiMessage); if (status === 401) return NOTION_API_ERROR_MESSAGES.AUTH_FAILED; if (status === 403) return NOTION_API_ERROR_MESSAGES.MISSING_PERMISSIONS; diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-search-empty-state.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-search-empty-state.tsx new file mode 100644 index 0000000000..48ef6bfb08 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-search-empty-state.tsx @@ -0,0 +1,81 @@ +import { AlertCircle } from 'lucide-react'; +import { ISSUE_PROVIDER_META } from '@renderer/features/integrations/issue-provider-meta'; +import { PROVIDER_ICON_COMPONENTS } from '@renderer/features/integrations/provider-icons'; +import { useNavigate } from '@renderer/lib/layout/navigation-provider'; +import { Button } from '@renderer/lib/ui/button'; +import { cn } from '@renderer/utils/utils'; +import type { LinkedIssue } from '@shared/core/linked-issue'; +import { parseIssueSearchError } from './parse-issue-search-error'; + +function ProviderIcon({ + provider, + className, +}: { + provider: LinkedIssue['provider']; + className?: string; +}) { + const Icon = PROVIDER_ICON_COMPONENTS[provider]; + + return ( + + + + ); +} + +export function IssueSearchEmptyState({ + provider, + error, +}: { + provider: LinkedIssue['provider'] | null; + error: string | null; +}) { + const { navigate } = useNavigate(); + const parsed = parseIssueSearchError(provider, error); + + if (!parsed) { + return No issues found; + } + + const openIntegrations = () => navigate('settings', { tab: 'integrations' }); + + return ( +
+ + {provider && parsed.kind !== 'generic' ? ( + + ) : ( + + )} + +
+

{parsed.title}

+

+ {parsed.description} +

+
+ {parsed.actionLabel ? ( + + ) : null} +
+ ); +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts index 3b29fc7454..39dc14a60d 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.test.ts @@ -85,7 +85,7 @@ vi.mock('@renderer/lib/components/inline-markdown', async () => { }); vi.mock('@renderer/lib/layout/navigation-provider', () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => ({ navigate: vi.fn() }), })); describe('IssueSelector', () => { @@ -139,9 +139,11 @@ describe('IssueSelector', () => { ); }); + expect(container.textContent).toContain('GitHub access required'); expect(container.textContent).toContain( 'acme/repo on github.com was not found, or the selected GitHub account does not have access.' ); + expect(container.textContent).toContain('Open integrations'); expect(container.textContent).not.toContain('No issues found'); }); }); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx index 719d1a0468..36206f84c8 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx @@ -28,6 +28,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from '@renderer/lib/ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip'; import { cn } from '@renderer/utils/utils'; import type { LinkedIssue } from '@shared/core/linked-issue'; +import { IssueSearchEmptyState } from './issue-search-empty-state'; import { getLinkedIssueMap, type LinkedIssueInfo } from './use-linked-issue-urls'; import { useIssueSearch } from './useIssueSearch'; @@ -281,10 +282,8 @@ export const IssueSelector = observer(function IssueSelector({ placeholder={`Search ${issueProvider ?? 'issues'}…`} disabled={!hasAnyIntegration} /> - - - {error ?? 'No issues found'} - + + {(issue: LinkedIssue) => { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.test.ts new file mode 100644 index 0000000000..5108bb3c86 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { parseIssueSearchError } from './parse-issue-search-error'; + +describe('parseIssueSearchError', () => { + it('returns a Notion access error with an integrations action', () => { + const result = parseIssueSearchError( + 'notion', + 'Notion cannot access the configured data source. Share the page or database with emdash, or update the scope URLs in Emdash settings.' + ); + + expect(result).toEqual({ + kind: 'access', + title: 'Notion access required', + description: + 'Notion cannot access the configured data source. Share the page or database with emdash, or update the scope URLs in Emdash settings.', + actionLabel: 'Open integrations', + }); + }); + + it('returns a GitHub access error with an integrations action', () => { + const result = parseIssueSearchError( + 'github', + 'acme/repo on github.com was not found, or the selected GitHub account does not have access.' + ); + + expect(result).toEqual({ + kind: 'access', + title: 'GitHub access required', + description: + 'acme/repo on github.com was not found, or the selected GitHub account does not have access.', + actionLabel: 'Open integrations', + }); + }); + + it('returns a generic error when no provider-specific pattern matches', () => { + const result = parseIssueSearchError('linear', 'Linear API rate limit exceeded.'); + + expect(result).toEqual({ + kind: 'generic', + title: 'Could not load issues', + description: 'Linear API rate limit exceeded.', + }); + }); + + it('returns null when there is no error', () => { + expect(parseIssueSearchError('notion', null)).toBeNull(); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.ts new file mode 100644 index 0000000000..fba615eccb --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/parse-issue-search-error.ts @@ -0,0 +1,65 @@ +import type { LinkedIssue } from '@shared/core/linked-issue'; + +export type IssueSearchErrorDisplay = { + kind: 'access' | 'auth' | 'generic'; + title: string; + description: string; + actionLabel?: string; +}; + +function notionAccessErrorDescription(error: string): string { + const integrationMatch = error.match(/integration "([^"]+)"/i); + const integrationName = integrationMatch?.[1] ?? 'your Notion integration'; + + if (/scope URLs|Emdash settings/i.test(error)) { + return error; + } + + return `Share the page or database with ${integrationName}, or update the scope URLs in Emdash settings.`; +} + +export function parseIssueSearchError( + provider: LinkedIssue['provider'] | null, + error: string | null +): IssueSearchErrorDisplay | null { + if (!error) return null; + + if (provider === 'notion') { + if ( + /Could not find (database|page|data source)|cannot access the configured data source|not shared with|Missing permissions|missing access/i.test( + error + ) + ) { + return { + kind: 'access', + title: 'Notion access required', + description: notionAccessErrorDescription(error), + actionLabel: 'Open integrations', + }; + } + + if (/authentication failed|not connected|Failed to verify Notion/i.test(error)) { + return { + kind: 'auth', + title: 'Notion connection issue', + description: error, + actionLabel: 'Open integrations', + }; + } + } + + if (provider === 'github' && /does not have access|not found|Connect GitHub/i.test(error)) { + return { + kind: 'access', + title: 'GitHub access required', + description: error, + actionLabel: 'Open integrations', + }; + } + + return { + kind: 'generic', + title: 'Could not load issues', + description: error, + }; +} From 1ed7e264e978f4c7a272807f2be076b857407e50 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:21:40 +0200 Subject: [PATCH 10/26] feat(notion): allow editing configuration --- .../src/main/core/notion/controller.ts | 8 ++- .../core/notion/notion-connection-service.ts | 21 +++++++- .../features/integrations/NotionSetupForm.tsx | 41 +++++++++++++-- .../features/integrations/SetupFormShell.tsx | 12 +++-- .../integrations/integrations-provider.tsx | 1 + .../components/IntegrationDetailSidebar.tsx | 52 +++++++++++++------ .../settings/components/IntegrationsCard.tsx | 3 ++ 7 files changed, 107 insertions(+), 31 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/notion/controller.ts b/apps/emdash-desktop/src/main/core/notion/controller.ts index 61a5d1e95c..24717c5c46 100644 --- a/apps/emdash-desktop/src/main/core/notion/controller.ts +++ b/apps/emdash-desktop/src/main/core/notion/controller.ts @@ -3,16 +3,14 @@ import { notionConnectionService } from './notion-connection-service'; export const notionController = createRPCController({ saveCredentials: async (input: { token: string; databaseUrls: string }) => { - if ( - !input?.token || - typeof input.token !== 'string' || - typeof input.databaseUrls !== 'string' - ) { + if (!input || typeof input.token !== 'string' || typeof input.databaseUrls !== 'string') { return { success: false, error: 'A Notion access token and scope URLs are required.' }; } return notionConnectionService.saveCredentials(input); }, + getConfiguration: async () => notionConnectionService.getConfiguration(), + checkConnection: async () => notionConnectionService.checkConnection(), clearCredentials: async () => notionConnectionService.clearCredentials(), diff --git a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts index ab3d92c2fa..df5b7fe147 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-connection-service.ts @@ -30,6 +30,11 @@ type SaveCredentialsInput = { databaseUrls: string; }; +export type NotionConfiguration = { + hasCredentials: boolean; + databaseUrls: string; +}; + type NotionUser = { id: string; name?: string | null; @@ -173,7 +178,8 @@ export class NotionConnectionService { async saveCredentials( input: SaveCredentialsInput ): Promise<{ success: boolean; displayName?: string; error?: string }> { - const token = input.token.trim(); + const existingCredentials = await this.getStoredCredentials(); + const token = input.token.trim() || existingCredentials?.token || ''; if (!token) { return { success: false, error: 'Access token cannot be empty.' }; } @@ -208,6 +214,19 @@ export class NotionConnectionService { } } + async getConfiguration(): Promise { + const credentials = await this.getStoredCredentials(); + if (!credentials) { + return { hasCredentials: false, databaseUrls: '' }; + } + + return { + hasCredentials: true, + databaseUrls: + credentials.scope.type === 'data-sources' ? credentials.scope.sourceUrls.join('\n') : '', + }; + } + async clearCredentials(): Promise<{ success: boolean; error?: string }> { try { await encryptedAppSecretsStore.deleteSecret(CREDENTIALS_KEY); diff --git a/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx b/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx index 46e13e2131..9beaafd2f1 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx @@ -1,26 +1,57 @@ +import { useQuery } from '@tanstack/react-query'; import { useState } from 'react'; +import { rpc } from '@renderer/lib/ipc'; import { Input } from '@renderer/lib/ui/input'; import { SetupFormShell, type SetupFormProps } from './SetupFormShell'; -function NotionSetupForm({ onSuccess, onClose }: SetupFormProps) { +function NotionSetupForm(props: SetupFormProps) { + const { data: configuration } = useQuery({ + queryKey: ['notion:configuration'], + queryFn: () => rpc.notion.getConfiguration(), + staleTime: 0, + }); + + return ( + + ); +} + +function NotionSetupFormFields({ + onSuccess, + onClose, + hasCredentials, + initialDatabaseUrls, +}: SetupFormProps & { hasCredentials: boolean; initialDatabaseUrls: string }) { const [token, setToken] = useState(''); - const [databaseUrls, setDatabaseUrls] = useState(''); + const [databaseUrls, setDatabaseUrls] = useState(initialDatabaseUrls); + const trimmedToken = token.trim(); + const isEditing = hasCredentials; return ( ({ - token: token.trim(), + token: trimmedToken, databaseUrls: databaseUrls.trim(), })} - canSubmit={!!token.trim()} + canSubmit={isEditing || !!trimmedToken} + submitLabel={isEditing ? 'Save changes' : 'Connect'} + successTitle={isEditing ? 'Integration updated' : 'Integration connected'} + successDescription={ + isEditing ? 'Notion settings updated successfully.' : 'Integration set up successfully.' + } onSuccess={onSuccess} onClose={onClose} >
setToken(e.target.value)} className="h-9 w-full" diff --git a/apps/emdash-desktop/src/renderer/features/integrations/SetupFormShell.tsx b/apps/emdash-desktop/src/renderer/features/integrations/SetupFormShell.tsx index b772b18457..01538d3352 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/SetupFormShell.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/SetupFormShell.tsx @@ -16,6 +16,9 @@ type SetupFormShellProps

= { providerId: P; getInput: () => ProviderInput[P]; canSubmit: boolean; + submitLabel?: string; + successTitle?: string; + successDescription?: string; onSuccess: () => void; onClose: () => void; children: ReactNode; @@ -25,6 +28,9 @@ export function SetupFormShell

({ providerId, getInput, canSubmit, + submitLabel = 'Connect', + successTitle = 'Integration connected', + successDescription = 'Integration set up successfully.', onSuccess, onClose, children, @@ -40,8 +46,8 @@ export function SetupFormShell

({ try { await providers[providerId].connect(getInput()); toast({ - title: 'Integration connected', - description: 'Integration set up successfully.', + title: successTitle, + description: successDescription, }); onSuccess(); } catch (e) { @@ -65,7 +71,7 @@ export function SetupFormShell

({ void handleSubmit()} disabled={!canSubmit || isMutating}> {isMutating && } - Connect + {submitLabel} diff --git a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx index ba303202ea..ef2b41b79d 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/integrations-provider.tsx @@ -184,6 +184,7 @@ export function IntegrationsProvider({ children }: { children: React.ReactNode } const invalidateStatuses = useCallback(() => { void queryClient.invalidateQueries({ queryKey: ISSUE_CONNECTION_STATUS_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: ['notion:configuration'] }); }, [queryClient]); const linearConnection = useProviderConnection({ diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationDetailSidebar.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationDetailSidebar.tsx index c3568a1244..ca79605fa4 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationDetailSidebar.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationDetailSidebar.tsx @@ -1,4 +1,4 @@ -import { Loader2, Plus, Trash2, X } from 'lucide-react'; +import { Loader2, Pencil, Plus, Trash2, X } from 'lucide-react'; import type { ReactNode } from 'react'; import { ISSUE_FEATURE_LABELS } from '@renderer/features/integrations/issue-provider-meta'; import { PROVIDER_ICON_COMPONENTS } from '@renderer/features/integrations/provider-icons'; @@ -107,22 +107,40 @@ function SingleIntegrationAccount({ integration }: { integration: IntegrationIte {integration.displayDetail ?? 'Connected'}

- {integration.onDisconnect && ( - - - - - Disconnect - - )} +
+ {integration.onEdit && ( + + + + + Edit + + )} + {integration.onDisconnect && ( + + + + + Disconnect + + )} +
); } diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx index 37d69b90e8..05fdccb544 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx @@ -25,6 +25,7 @@ export type IntegrationItem = { displayName?: string; displayDetail?: string; onConnect: () => void; + onEdit?: () => void; onDisconnect?: () => void | Promise; }; @@ -97,6 +98,8 @@ const IntegrationsCard: React.FC = () => { displayName: status.displayName, displayDetail: status.displayDetail, onConnect: () => showIntegrationSetup({ integration: provider }), + onEdit: + provider === 'notion' ? () => showIntegrationSetup({ integration: provider }) : undefined, onDisconnect: () => confirmDisconnect({ name: meta.displayName, From cd3bb4dd3682a4e5e5fd8769b18cd737fb44889a Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:37:29 +0200 Subject: [PATCH 11/26] fix(notion): query shared data source pages --- .../core/notion/notion-issue-provider.test.ts | 71 ++++++++++++++++--- .../main/core/notion/notion-issue-provider.ts | 57 ++++++++++++++- 2 files changed, 116 insertions(+), 12 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts index 8d51de5d7b..1b6247b9b7 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.test.ts @@ -68,31 +68,84 @@ describe('notionIssueProvider', () => { `/data_sources/${DATA_SOURCE_ID}/query`, expect.objectContaining({ method: 'POST', - body: expect.stringContaining('"result_type":"page"'), + body: expect.not.stringContaining('result_type'), }) ); }); - it('uses shared-page search when no data source scope is configured', async () => { + it('discovers shared data sources when no data source scope is configured', async () => { const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; mockGetStoredCredentials.mockResolvedValue(credentials); - mockRequest.mockResolvedValue({ - results: [PAGE], - has_more: false, - next_cursor: null, - }); + mockRequest + .mockResolvedValueOnce({ + results: [ + { + object: 'data_source', + id: DATA_SOURCE_ID, + url: 'https://app.notion.com/p/source', + title: [{ plain_text: 'Goals' }], + }, + ], + has_more: false, + next_cursor: null, + }) + .mockResolvedValueOnce({ + results: [PAGE], + has_more: false, + next_cursor: null, + }); const result = await notionIssueProvider.listIssues({ limit: 50 }); - expect(result).toEqual({ success: true, issues: [expect.any(Object)] }); + expect(result).toEqual({ + success: true, + issues: [expect.objectContaining({ title: 'Fix login bug' })], + }); expect(mockRequest).toHaveBeenCalledWith( credentials.token, '/search', expect.objectContaining({ method: 'POST', - body: expect.stringContaining('"value":"page"'), + body: expect.stringContaining('"value":"data_source"'), }) ); + expect(mockRequest).toHaveBeenCalledWith( + credentials.token, + `/data_sources/${DATA_SOURCE_ID}/query`, + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('falls back to shared-page search when discovered data sources have no pages', async () => { + const credentials = { token: 'tok', scope: { type: 'all-shared' as const } }; + mockGetStoredCredentials.mockResolvedValue(credentials); + mockRequest + .mockResolvedValueOnce({ + results: [ + { object: 'data_source', id: DATA_SOURCE_ID, url: 'https://app.notion.com/p/source' }, + ], + has_more: false, + next_cursor: null, + }) + .mockResolvedValueOnce({ + results: [], + has_more: false, + next_cursor: null, + }) + .mockResolvedValueOnce({ + results: [PAGE], + has_more: false, + next_cursor: null, + }); + + const result = await notionIssueProvider.listIssues({ limit: 50 }); + + expect(result).toEqual({ success: true, issues: [expect.any(Object)] }); + expect(mockRequest).toHaveBeenLastCalledWith( + credentials.token, + '/search', + expect.objectContaining({ body: expect.stringContaining('"value":"page"') }) + ); }); it('returns error when no credentials stored', async () => { diff --git a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts index e1d33860bf..cba6bf319e 100644 --- a/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts +++ b/apps/emdash-desktop/src/main/core/notion/notion-issue-provider.ts @@ -1,3 +1,4 @@ +import { mapWithConcurrency } from '@main/core/issues/helpers/map-with-concurrency'; import { clampIssueLimit, normalizeSearchTerm } from '@main/core/issues/helpers/provider-inputs'; import type { IssueContextOpts, @@ -34,11 +35,19 @@ type NotionPage = { }; type NotionSearchResponse = { - results: NotionPage[]; + results: (NotionPage | NotionDataSource)[]; has_more: boolean; next_cursor?: string | null; }; +type NotionDataSource = { + object: 'data_source'; + id: string; + url: string; + title?: NotionRichText[]; + parent?: { type?: string; database_id?: string }; +}; + type NotionDataSourceQueryResponse = { results: NotionPage[]; has_more: boolean; @@ -61,6 +70,8 @@ type NotionBlock = { const BLOCK_CONTEXT_PAGE_SIZE = 100; const MAX_CONTEXT_BLOCKS = 300; const MAX_CONTEXT_DEPTH = 3; +const NOTION_DATA_SOURCE_CONCURRENCY = 4; +const MAX_DISCOVERED_DATA_SOURCES = 25; function plainText(value: NotionRichText[] | undefined): string | undefined { const text = value @@ -169,13 +180,41 @@ async function searchSharedPages( { method: 'POST', body: JSON.stringify(body) } ); - pages.push(...data.results); + pages.push(...data.results.filter((result) => result.object === 'page')); startCursor = data.next_cursor ?? undefined; } while (startCursor && pages.length < limit); return pages.slice(0, limit); } +async function searchSharedDataSources( + credentials: NotionCredentials, + limit: number +): Promise { + const dataSources: NotionDataSource[] = []; + let startCursor: string | undefined; + + do { + const body: Record = { + page_size: Math.min(100, limit), + filter: { property: 'object', value: 'data_source' }, + sort: { direction: 'descending', timestamp: 'last_edited_time' }, + }; + if (startCursor) body.start_cursor = startCursor; + + const data = await notionConnectionService.request( + credentials.token, + '/search', + { method: 'POST', body: JSON.stringify(body) } + ); + + dataSources.push(...data.results.filter((result) => result.object === 'data_source')); + startCursor = data.next_cursor ?? undefined; + } while (startCursor && dataSources.length < limit); + + return dataSources.slice(0, limit); +} + async function queryDataSourcePages( token: string, dataSourceId: string, @@ -187,7 +226,6 @@ async function queryDataSourcePages( do { const body: Record = { page_size: Math.min(100, limit), - result_type: 'page', sorts: [{ direction: 'descending', timestamp: 'last_edited_time' }], }; if (startCursor) body.start_cursor = startCursor; @@ -211,6 +249,19 @@ async function listScopedIssues( limit: number ): Promise { if (credentials.scope.type === 'all-shared') { + if (!searchTerm) { + const dataSources = await searchSharedDataSources(credentials, MAX_DISCOVERED_DATA_SOURCES); + const pagesBySource = await mapWithConcurrency( + dataSources, + NOTION_DATA_SOURCE_CONCURRENCY, + (dataSource) => queryDataSourcePages(credentials.token, dataSource.id, limit) + ); + const dataSourceIssues = pagesBySource.flat().map((page) => toIssue(page)); + if (dataSourceIssues.length) { + return sortByUpdatedAtDesc(dataSourceIssues).slice(0, limit); + } + } + const pages = await searchSharedPages(credentials, searchTerm, limit); return pages.map((page) => toIssue(page)); } From 1033ad6fea02301b237acfd1e308999c745475be Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:42:18 +0200 Subject: [PATCH 12/26] fix(notion): distinguish edit setup flow --- .../features/integrations/NotionSetupForm.tsx | 8 +++++++- .../integrations/integration-setup-modal.tsx | 17 ++++++++++++++--- .../settings/components/IntegrationsCard.tsx | 4 +++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx b/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx index 9beaafd2f1..1ac5634769 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/NotionSetupForm.tsx @@ -49,9 +49,15 @@ function NotionSetupFormFields({ onClose={onClose} >
+ {isEditing ? ( +

+ A Notion access token is already saved. Enter a new token only if you want to replace + it. +

+ ) : null} setToken(e.target.value)} className="h-9 w-full" diff --git a/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx b/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx index 064243989d..4ce084dd11 100644 --- a/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx +++ b/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx @@ -18,6 +18,7 @@ import type { SetupIntegrationType } from './types'; type IntegrationSetupModalArgs = { integration: SetupIntegrationType; + mode?: 'connect' | 'edit'; }; type Props = BaseModalProps & IntegrationSetupModalArgs; @@ -36,15 +37,25 @@ const SETUP_FORMS: Record> = notion: NotionSetupForm, }; -export function IntegrationSetupModal({ integration, onSuccess, onClose }: Props) { +export function IntegrationSetupModal({ + integration, + mode = 'connect', + onSuccess, + onClose, +}: Props) { const { title, subtitle } = SETUP_PROVIDER_META[integration]; const Form = SETUP_FORMS[integration]; + const isEditing = mode === 'edit'; + const modalTitle = isEditing ? `Edit ${title.replace(/^Connect\s+/, '')}` : title; + const modalSubtitle = isEditing + ? 'Update the saved integration settings. Leave the access token blank to keep the current one.' + : subtitle; return ( <> - {title} - {subtitle} + {modalTitle} + {modalSubtitle}
diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx index 05fdccb544..d70b913367 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/IntegrationsCard.tsx @@ -99,7 +99,9 @@ const IntegrationsCard: React.FC = () => { displayDetail: status.displayDetail, onConnect: () => showIntegrationSetup({ integration: provider }), onEdit: - provider === 'notion' ? () => showIntegrationSetup({ integration: provider }) : undefined, + provider === 'notion' + ? () => showIntegrationSetup({ integration: provider, mode: 'edit' }) + : undefined, onDisconnect: () => confirmDisconnect({ name: meta.displayName, From c2ed3fdd0cabded0629bcf261f0900976e41ca01 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:46:19 +0200 Subject: [PATCH 13/26] fix(notion): compact long issue ids --- .../issue-selector/issue-selector.tsx | 24 +++++++++++------ .../create-task-modal/modal-context-bar.tsx | 27 +++++++++++++++---- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx index 36206f84c8..79c10a9b16 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx @@ -42,14 +42,18 @@ export function IssueIdentifier({ className?: string; }) { if (provider === 'asana') return null; + const displayIdentifier = + provider === 'notion' && identifier.length > 12 ? `${identifier.slice(0, 8)}…` : identifier; + return ( - {identifier} + {displayIdentifier} ); } @@ -320,20 +324,24 @@ export function SelectedIssueValue({ issue }: { issue: LinkedIssue }) { ) : null}
- - -
{issue.title}
+ + +
{issue.title}
- + - + {issue.description ? ( diff --git a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/modal-context-bar.tsx b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/modal-context-bar.tsx index 136b020610..96f769b4c0 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/modal-context-bar.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/modal-context-bar.tsx @@ -6,8 +6,15 @@ import { import { PromptActionsMenu } from '@renderer/features/tasks/conversations/prompt-actions-menu'; import { Button } from '@renderer/lib/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@renderer/lib/ui/tooltip'; +import type { LinkedIssue } from '@shared/core/linked-issue'; import { ProviderLogo } from '../components/issue-selector/issue-selector'; +function compactIssueIdentifier(provider: LinkedIssue['provider'], identifier: string): string { + return provider === 'notion' && identifier.length > 12 + ? `${identifier.slice(0, 8)}…` + : identifier; +} + interface ModalContextBarProps { actions: ContextAction[]; onActionClick: (action: ContextAction) => void; @@ -47,11 +54,21 @@ export function ModalContextBar({ {issueAction.provider ? ( ) : null} - - {issueActionPending - ? 'Adding issue context...' - : `${issueAction.issue.identifier} ${issueAction.issue.title}`} - + {issueActionPending ? ( + Adding issue context... + ) : ( + + + {issueAction.issue.title} + + + {compactIssueIdentifier(issueAction.provider, issueAction.issue.identifier)} + + + )} {issueActionPending ? ( ) : ( From 171e7f1669ba3cf7cf9a4b5a438e4ff78e8a46ca Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:48:51 +0200 Subject: [PATCH 14/26] fix(notion): compact composer issue id --- .../initial-conversation-section.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/initial-conversation-section.tsx b/apps/emdash-desktop/src/renderer/features/tasks/conversations/initial-conversation-section.tsx index d7c16186e5..5ca3f9def6 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/conversations/initial-conversation-section.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/initial-conversation-section.tsx @@ -27,6 +27,12 @@ import { AddContextPopover } from './add-context-popover'; import { buildIssueContextText, buildTaskContextActions } from './context-actions'; import { useEffectiveProvider } from './use-effective-provider'; +function compactIssueIdentifier(issue: LinkedIssue): string { + return issue.provider === 'notion' && issue.identifier.length > 12 + ? `${issue.identifier.slice(0, 8)}…` + : issue.identifier; +} + export type InitialConversationState = { provider: AgentProviderId | null; setProvider: (provider: AgentProviderId | null) => void; @@ -243,12 +249,17 @@ export function InitialConversationField({ )} > - {linkedIssue.identifier} {linkedIssue.title && ( - + {linkedIssue.title} )} + + {compactIssueIdentifier(linkedIssue)} +