Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/mobile/src/components/ComposerAttachmentButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ export function ComposerAttachmentButton(props: {
readonly onPickLinearIssue?: () => void;
/** Omit to hide the GitHub issue action. See `useGitHubIssuePicker`. */
readonly onPickGitHubIssue?: () => void;
/** Omit to hide the Slack action. See `useSlackMessagePicker`. */
/** Omit to hide the Notion action. See `useNotionPagePicker`. */
readonly onPickNotionPage?: () => void;
/** Omit to hide the Slack action. See `useSlackMessagePicker`. */
readonly onPickSlackMessage?: () => void;
/** Omit to hide the repository action. See `useRepositoryPicker`. */
readonly onPickRepository?: () => void;
Expand Down
20 changes: 13 additions & 7 deletions apps/mobile/src/components/NotionPagePickerSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ActivityIndicator, Alert, FlatList, Pressable, View } from "react-nativ
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { relativeTime } from "../lib/time";
import { useEnvironmentServerConfig } from "../state/entities";
import { notionEnvironment } from "../state/notion";
import { useDebouncedValue } from "../state/queries";
import { useEnvironmentQuery } from "../state/query";
Expand Down Expand Up @@ -52,17 +53,22 @@ type OpenedNotionPagePicker = NotionPagePickerTarget & {
* opening the sheet blurs the editor, which can unmount a focus-dependent toolbar.
*/
export function useNotionPagePicker(target: NotionPagePickerTarget | null): {
readonly open: () => void;
readonly open: (() => void) | undefined;
readonly sheet: ReactNode;
} {
const [opened, setOpened] = useState<OpenedNotionPagePicker | null>(null);
const serverConfig = useEnvironmentServerConfig(target?.environmentId ?? null);
const enabled = serverConfig?.settings.enableNotionIntegration === true;
const openedServerConfig = useEnvironmentServerConfig(opened?.environmentId ?? null);
return {
open: () => {
if (target) {
setOpened({ ...target, insertion: captureComposerDraftInsertion(target.draftKey) });
}
},
sheet: opened ? <NotionPagePickerSheet {...opened} onClose={() => setOpened(null)} /> : null,
open:
target && enabled
? () => setOpened({ ...target, insertion: captureComposerDraftInsertion(target.draftKey) })
: undefined,
sheet:
opened && openedServerConfig?.settings.enableNotionIntegration ? (
<NotionPagePickerSheet {...opened} onClose={() => setOpened(null)} />
) : null,
};
}

Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,31 @@ it.layer(NodeServices.layer)("server settings", (it) => {
).pipe(Effect.provide(layerServerSettings())),
);

it.effect("persists and broadcasts disabling and re-enabling Notion", () =>
Effect.scoped(
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;

for (const enabled of [false, true]) {
const changes = yield* serverSettings.subscribeChanges;
const next = yield* serverSettings.updateSettings({ enableNotionIntegration: enabled });
const change = Option.getOrUndefined(yield* Stream.runHead(changes));
const persisted = yield* fileSystem
.readFileString(serverConfig.settingsPath)
.pipe(
Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))),
);

assert.strictEqual(next.enableNotionIntegration, enabled);
assert.strictEqual(change?.enableNotionIntegration, enabled);
assert.strictEqual(persisted.enableNotionIntegration, enabled);
}
}),
).pipe(Effect.provide(layerServerSettings())),
);

it.effect("persists and broadcasts thread settlement settings", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
24 changes: 14 additions & 10 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2129,16 +2129,20 @@ function OpenCommandPaletteDialog(props: {
openLinearIssuePicker(composerThreadRef);
},
});
actionItems.push({
kind: "action",
value: "action:attach-notion-page",
searchTerms: ["notion", "page", "document", "attach", "context"],
title: "Attach Notion page",
icon: <NotionIcon className={ITEM_ICON_CLASS} />,
run: async () => {
openNotionPagePicker(composerThreadRef);
},
});
if (
serverConfigs.get(composerThreadRef.environmentId)?.settings.enableNotionIntegration !== false
) {
actionItems.push({
kind: "action",
value: "action:attach-notion-page",
searchTerms: ["notion", "page", "document", "attach", "context"],
title: "Attach Notion page",
icon: <NotionIcon className={ITEM_ICON_CLASS} />,
run: async () => {
openNotionPagePicker(composerThreadRef);
},
});
}
if (
serverConfigs.get(composerThreadRef.environmentId)?.settings.enableSlackIntegration !== false
) {
Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/components/chat/ComposerAttachMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export const ComposerAttachMenu = memo(function ComposerAttachMenu(props: {
props.threadRef.environmentId,
(s) => s.enableSlackIntegration,
);
const notionEnabled = useEnvironmentSettings(
props.threadRef.environmentId,
(s) => s.enableNotionIntegration,
);
return (
<Menu>
<MenuTrigger
Expand All @@ -53,10 +57,12 @@ export const ComposerAttachMenu = memo(function ComposerAttachMenu(props: {
<LinearIcon />
Linear issue
</MenuItem>
<MenuItem onClick={() => openNotionPagePicker(props.threadRef)}>
<NotionIcon />
Notion page
</MenuItem>
{notionEnabled ? (
<MenuItem onClick={() => openNotionPagePicker(props.threadRef)}>
<NotionIcon />
Notion page
</MenuItem>
) : null}
{slackEnabled ? (
<MenuItem onClick={() => openSlackMessagePicker(props.threadRef)}>
<SlackIcon />
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/components/chat/NotionPagePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useNavigate } from "@tanstack/react-router";
import { Atom } from "effect/reactivity";
import { useCallback, useState } from "react";
import { useComposerDraftStore } from "~/composerDraftStore";
import { useEnvironmentSettings } from "~/hooks/useSettings";
import { useIssueContextStore } from "~/issueContextStore";
import { appAtomRegistry } from "~/rpc/atomRegistry";
import { useDebouncedValue } from "~/state/queries";
Expand Down Expand Up @@ -53,13 +54,19 @@ export function NotionPagePickerHost() {
return threadRef === null ? null : <NotionPagePickerDialog threadRef={threadRef} />;
}
function NotionPagePickerDialog({ threadRef }: { threadRef: ScopedThreadRef }) {
const notionEnabled = useEnvironmentSettings(
threadRef.environmentId,
(s) => s.enableNotionIntegration,
);
const navigate = useNavigate();
const attach = useAttachNotionPage();
const [query, setQuery] = useState("");
const [attaching, setAttaching] = useState(false);
const settled = useDebouncedValue(query.trim(), 400);
const connection = useEnvironmentQuery(
notionEnvironment.connection({ environmentId: threadRef.environmentId, input: {} }),
notionEnabled
? notionEnvironment.connection({ environmentId: threadRef.environmentId, input: {} })
: null,
);
const connected = connection.data?.phase === "connected";
const result = useEnvironmentQuery(
Expand All @@ -81,6 +88,7 @@ function NotionPagePickerDialog({ threadRef }: { threadRef: ScopedThreadRef }) {
: pages.length === 0
? "No pages found. Share pages with the Notion connection to see them here."
: null);
if (!notionEnabled) return null;
return (
<CommandDialog
open
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/chat/useComposerNotionItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useMemo } from "react";
import type { ComposerTrigger } from "~/composer-logic";
import { useDebouncedValue } from "~/state/queries";
import { useEnvironmentQuery } from "~/state/query";
import { useEnvironmentSettings } from "~/hooks/useSettings";
import { notionEnvironment } from "~/state/notion";
import type { ComposerCommandItem } from "./ComposerCommandMenu";

Expand All @@ -20,12 +21,14 @@ export function useComposerNotionItems(
trigger: ComposerTrigger | null,
active: boolean,
) {
const notionEnabled = useEnvironmentSettings(environmentId, (s) => s.enableNotionIntegration);
const connection = useEnvironmentQuery(
trigger?.kind === "pull-request"
notionEnabled && trigger?.kind === "pull-request"
? notionEnvironment.connection({ environmentId, input: {} })
: null,
);
const enabled = trigger?.kind === "pull-request" && connection.data?.phase === "connected";
const enabled =
notionEnabled && trigger?.kind === "pull-request" && connection.data?.phase === "connected";
const query = enabled && active ? trigger.query : null;
const debouncedQuery = useDebouncedValue(query, NOTION_SEARCH_DEBOUNCE_MS);
const settledQuery = query === debouncedQuery ? query : null;
Expand Down
61 changes: 58 additions & 3 deletions apps/web/src/components/chat/useResolveComposerObjectLink.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
import { parseComposerObjectLink } from "@t3tools/client-runtime/composer-object-links";
import { EnvironmentId, ThreadId, type SlackThreadContext } from "@t3tools/contracts";
import {
EnvironmentId,
ThreadId,
type NotionPageContext,
type SlackThreadContext,
} from "@t3tools/contracts";
import { act, useLayoutEffect } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

const mocks = vi.hoisted(() => ({
slackEnabled: true,
notionEnabled: true,
getSlackThread: vi.fn(),
getNotionPage: vi.fn(),
upsert: vi.fn(),
toast: vi.fn(),
}));

vi.mock("~/hooks/useSettings", () => ({
useEnvironmentSettings: () => mocks.slackEnabled,
useEnvironmentSettings: (
_environmentId: unknown,
select: (settings: {
enableSlackIntegration: boolean;
enableNotionIntegration: boolean;
}) => boolean,
) =>
select({
enableSlackIntegration: mocks.slackEnabled,
enableNotionIntegration: mocks.notionEnabled,
}),
}));
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
vi.mock("~/state/entities", () => ({ useProjects: () => [] }));
Expand All @@ -25,7 +42,12 @@ vi.mock("~/state/pullRequests", () => ({
pullRequestEnvironment: { detail: "detail", activity: "activity" },
}));
vi.mock("~/state/use-atom-command", () => ({
useAtomCommand: (command: string) => (command === "slack" ? mocks.getSlackThread : vi.fn()),
useAtomCommand: (command: string) =>
command === "slack"
? mocks.getSlackThread
: command === "notion"
? mocks.getNotionPage
: vi.fn(),
}));
vi.mock("~/state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
vi.mock("~/issueContextStore", () => ({
Expand Down Expand Up @@ -60,6 +82,17 @@ const thread: SlackThreadContext = {
markdown: "Alice: A message",
};

const notionUrl =
"https://app.notion.com/p/acme/Methodology-28599cd1efb78275839b01b59665ef86?source=copy_link";
const notionLink = parseComposerObjectLink(notionUrl)!;
const notionPage: NotionPageContext = {
id: "28599cd1-efb7-8275-839b-01b59665ef86",
title: "Methodology",
url: notionUrl,
updatedAt: "2026-10-06T00:00:00.000Z",
markdown: "# Methodology",
};

let renderer: ReactTestRenderer;
let resolveLink: ReturnType<typeof useResolveComposerObjectLink>;

Expand All @@ -75,6 +108,8 @@ beforeEach(async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.clearAllMocks();
mocks.slackEnabled = true;
mocks.notionEnabled = true;
mocks.getNotionPage.mockResolvedValue({ _tag: "Success", value: notionPage });
mocks.getSlackThread.mockResolvedValue({ _tag: "Success", value: thread });
await act(() => {
renderer = create(<Probe />);
Expand Down Expand Up @@ -127,3 +162,23 @@ describe("Slack link attachments", () => {
expect(mocks.getSlackThread).not.toHaveBeenCalled();
});
});

describe("Notion link attachments", () => {
it("attaches a pasted app.notion.com page link", async () => {
const attached = await resolveLink(notionLink);
expect(attached?.reference.kind).toBe("notion-page");
expect(mocks.getNotionPage).toHaveBeenCalledWith({
environmentId: threadRef.environmentId,
input: { id: notionPage.id },
});
});

it("keeps links as text without requesting Notion or showing setup prompts when disabled", async () => {
mocks.notionEnabled = false;
await act(() => renderer.update(<Probe />));

expect(await resolveLink(notionLink)).toBeNull();
expect(mocks.getNotionPage).not.toHaveBeenCalled();
expect(mocks.toast).not.toHaveBeenCalled();
});
});
7 changes: 5 additions & 2 deletions apps/web/src/components/chat/useResolveComposerObjectLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ export interface ResolvedComposerObjectLink {

/**
* Turns a link into the chip its attach picker would have made: the issue or pull request is
* fetched and snapshotted the same way. Null when it cannot be read (Linear or Slack not
* connected, `gh` signed out, a repository no project here is checked out from), so the caller
* fetched and snapshotted the same way. Null when it cannot be read (Linear, Slack, or Notion
* not connected or turned off, `gh` signed out, a repository no project here is checked out from), so the caller
* keeps the link.
*/
export function useResolveComposerObjectLink(input: {
Expand All @@ -81,6 +81,7 @@ export function useResolveComposerObjectLink(input: {
const { threadRef, draftTarget } = input;
const environmentId = threadRef.environmentId;
const slackEnabled = useEnvironmentSettings(environmentId, (s) => s.enableSlackIntegration);
const notionEnabled = useEnvironmentSettings(environmentId, (s) => s.enableNotionIntegration);
const getLinearIssue = useAtomCommand(linearEnvironment.getIssue, { reportFailure: false });
const getGitHubIssue = useAtomCommand(githubIssueEnvironment.getIssue, { reportFailure: false });
const getNotionPage = useAtomCommand(notionEnvironment.getPage, { reportFailure: false });
Expand All @@ -102,6 +103,7 @@ export function useResolveComposerObjectLink(input: {
async (link: ComposerObjectLink): Promise<ResolvedComposerObjectLink | null> => {
switch (link.kind) {
case "notion-page": {
if (!notionEnabled) return null;
const result = await getNotionPage({ environmentId, input: { id: link.pageId } });
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result))
Expand Down Expand Up @@ -235,6 +237,7 @@ export function useResolveComposerObjectLink(input: {
getPullRequestDiff,
getNotionPage,
getSlackThread,
notionEnabled,
projects,
pullRequestsEnabled,
slackEnabled,
Expand Down
28 changes: 27 additions & 1 deletion apps/web/src/components/settings/NotionSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useRef, useState } from "react";

import { isElectron } from "../../env";
import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";
import { ensureLocalApi } from "../../localApi";
import { usePrimaryEnvironment } from "../../state/environments";
import { notionEnvironment } from "../../state/notion";
Expand All @@ -21,6 +22,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { Button, InlineButton } from "../ui/button";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { SettingsRow, SettingsSection } from "./settingsLayout";
import { searchableSetting } from "./settingsSearch";
import { useSettingsScope } from "./SettingsScopeContext";
Expand All @@ -46,7 +48,7 @@ export function NotionSettingsSection() {
description="Connect to an environment to set up Notion."
/>
) : (
<NotionConnectionRows
<NotionIntegrationRows
key={environmentId}
environmentId={environmentId}
environmentLabel={environment?.label ?? "this environment"}
Expand All @@ -56,6 +58,30 @@ export function NotionSettingsSection() {
);
}

function NotionIntegrationRows(props: {
readonly environmentId: EnvironmentId;
readonly environmentLabel: string;
}) {
const enabled = useEnvironmentSettings(props.environmentId, (s) => s.enableNotionIntegration);
const updateSettings = useUpdateEnvironmentSettings(props.environmentId);
return (
<>
<SettingsRow
title="Enable Notion integration"
description="Attach Notion pages. Turn off to keep pasted Notion links as links without setup prompts. Your connected account is kept."
control={
<Switch
aria-label="Enable Notion integration"
checked={enabled}
onCheckedChange={(checked) => updateSettings({ enableNotionIntegration: checked })}
/>
}
/>
{enabled ? <NotionConnectionRows {...props} /> : null}
</>
);
}

function describeConnection(state: NotionConnectionState | null): string {
if (state === null) return "Reading Notion status.";
switch (state.phase) {
Expand Down
Loading