From 3a78d862488824e0a492b7212dfcd956fa96388a Mon Sep 17 00:00:00 2001 From: ChangeHow <23733347+ChangeHow@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:53:21 +0800 Subject: [PATCH] fix(ui): open app deep links from chat after confirmation (#2932) * fix(ui): open app deep links from chat after confirmation DOMPurify's default URI policy stripped href from anchors with custom application schemes (obsidian://, vscode://, ...), so every app link rendered in chat was dead across web, desktop, VS Code, and mobile. - Classify safe app-link schemes in lib/url.ts (browser-handled, scriptable, webview-internal, network, and self-deep-link schemes stay excluded) and let openExternalUrl accept them - Keep app-link hrefs through the markdown sanitize hook - Intercept app-link clicks in the markdown renderer and route them through a confirmation dialog (Trust and open / Open once, dismiss to cancel) mounted in the desktop/web app root and the mobile shell - Persist per-device trusted schemes in a zustand store; trusted schemes open without asking again * feat(settings): manage trusted app link schemes in General Add an App links section to Settings > General listing the application schemes trusted on this device with a delete action; removing a scheme restores the confirmation dialog for it. Register the section in settings search. * fix(ui): enforce app link confirmation * fix(ui): handle app links by runtime * fix(vscode): keep app links unsupported * fix(settings): clarify trusted app links --------- Co-authored-by: Bohdan Triapitsyn --- CHANGELOG.md | 1 + packages/ui/src/App.tsx | 3 + packages/ui/src/apps/ElectronMiniChatApp.tsx | 2 + packages/ui/src/apps/MobileApp.tsx | 2 + packages/ui/src/apps/VSCodeApp.tsx | 3 + .../chat/AppLinkConfirmDialog.test.tsx | 46 +++++++++ .../components/chat/AppLinkConfirmDialog.tsx | 77 +++++++++++++++ .../components/chat/MarkdownRendererImpl.tsx | 53 +++-------- .../chat/appLinkConfirmation.test.ts | 67 +++++++++++++ .../components/chat/appLinkConfirmation.ts | 71 ++++++++++++++ .../chat/appLinkInteractions.test.ts | 93 +++++++++++++++++++ .../components/chat/appLinkInteractions.ts | 75 +++++++++++++++ .../chat/markdown/markdownCore.test.ts | 52 ++++++++++- .../components/chat/markdown/markdownCore.ts | 16 +++- .../chat/message/parts/DOCUMENTATION.md | 3 +- .../openchamber/AppLinkSecuritySettings.tsx | 48 ++++++++++ .../sections/openchamber/OpenChamberPage.tsx | 3 + .../ui/src/lib/i18n/messages/de.settings.ts | 4 + packages/ui/src/lib/i18n/messages/de.ts | 6 ++ .../ui/src/lib/i18n/messages/en.settings.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 6 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 6 ++ .../ui/src/lib/i18n/messages/fr.settings.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 6 ++ .../ui/src/lib/i18n/messages/ja.settings.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 6 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 6 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 6 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 6 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 6 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 6 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 6 ++ packages/ui/src/lib/settings/search.ts | 8 +- packages/ui/src/lib/url.test.ts | 62 +++++++++++++ packages/ui/src/lib/url.ts | 68 +++++++++++++- .../ui/src/stores/appLinkTrustStore.test.ts | 50 ++++++++++ packages/ui/src/stores/appLinkTrustStore.ts | 48 ++++++++++ packages/vscode/CHANGELOG.md | 1 + 45 files changed, 909 insertions(+), 53 deletions(-) create mode 100644 packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx create mode 100644 packages/ui/src/components/chat/AppLinkConfirmDialog.tsx create mode 100644 packages/ui/src/components/chat/appLinkConfirmation.test.ts create mode 100644 packages/ui/src/components/chat/appLinkConfirmation.ts create mode 100644 packages/ui/src/components/chat/appLinkInteractions.test.ts create mode 100644 packages/ui/src/components/chat/appLinkInteractions.ts create mode 100644 packages/ui/src/components/sections/openchamber/AppLinkSecuritySettings.tsx create mode 100644 packages/ui/src/lib/url.test.ts create mode 100644 packages/ui/src/stores/appLinkTrustStore.test.ts create mode 100644 packages/ui/src/stores/appLinkTrustStore.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 96723c6f..a0923699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file. - Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them. - Settings/Integrations: the experimental page now only lists integrations that can be installed; unavailable and Coming soon entries were removed. - Chat: file paths in messages now open from the session's project, even if you last browsed files in another project (thanks to @tomzx). +- Chat: app links such as `spotify://` now ask for confirmation before opening another app. You can trust an app link type on one device and manage trusted links in Settings. - Files/Desktop: files opened from outside the workspace remain readable after their temporary access expires instead of failing until you reopen them (thanks to @pascalandr). - Diff: creating an inline comment now opens the chat and focuses the composer for your follow-up. - Chat: in the expanded composer, Enter now starts a new line and Cmd/Ctrl+Enter sends, so a long prompt is harder to send by accident. diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 3470f692..5c9e8806 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { MainLayout } from '@/components/layout/MainLayout'; import { ChatView } from '@/components/views/ChatView'; +import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { FireworksProvider } from '@/contexts/FireworksContext'; import { Toaster } from '@/components/ui/sonner'; import { Button } from '@/components/ui/button'; @@ -908,6 +909,7 @@ function App({ apis }: AppProps) { isVSCodeRuntime={isVSCodeRuntime} embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} /> + @@ -951,6 +953,7 @@ function App({ apis }: AppProps) { + {!isBootShell && ( <> diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index d1d53b50..7aed5ba0 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -5,6 +5,7 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/sonner'; import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout'; +import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { opencodeClient } from '@/lib/opencode/client'; @@ -325,6 +326,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
+
diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index b790270c..4d30dc00 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -9,6 +9,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { ChatView } from '@/components/views/ChatView'; import { PlanView } from '@/components/views/PlanView'; import { SettingsView } from '@/components/views/SettingsView'; +import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; @@ -1258,6 +1259,7 @@ export function MobileApp({ apis }: MobileAppProps) { switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); setConnectionEpoch((value) => value + 1); }} /> + {isInitialized ? : null} diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx index 9090cd1d..737a0239 100644 --- a/packages/ui/src/apps/VSCodeApp.tsx +++ b/packages/ui/src/apps/VSCodeApp.tsx @@ -8,6 +8,7 @@ import { Toaster } from '@/components/ui/sonner'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; +import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { VSCodeLayout } from '@/components/layout/VSCodeLayout'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling'; @@ -110,6 +111,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
+
@@ -129,6 +131,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
+ diff --git a/packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx b/packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx new file mode 100644 index 00000000..ceb2624e --- /dev/null +++ b/packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { I18nProvider } from '@/lib/i18n'; + +mock.module('@/components/ui/dialog', () => ({ + Dialog: ({ children }: React.PropsWithChildren) => <>{children}, + DialogContent: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogDescription: ({ children }: React.PropsWithChildren) =>

{children}

, + DialogFooter: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogHeader: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogTitle: ({ children }: React.PropsWithChildren) =>

{children}

, +})); + +const { AppLinkConfirmDialog } = await import('./AppLinkConfirmDialog'); +const { + getAppLinkConfirmationSnapshot, + openAppLinkWithConfirmation, + settleAppLinkConfirmation, +} = await import('./appLinkConfirmation'); + +describe('AppLinkConfirmDialog', () => { + beforeEach(() => { + if (getAppLinkConfirmationSnapshot()) { + settleAppLinkConfirmation('cancel'); + } + }); + + test('keeps cancel visible and focused beside both open choices', () => { + void openAppLinkWithConfirmation('obsidian://open?vault=Notebook'); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('>Cancel'); + expect(markup).toContain('autofocus=""'); + expect(markup).toContain('>Open once'); + expect(markup).toContain('>Trust and open'); + + settleAppLinkConfirmation('cancel'); + }); +}); diff --git a/packages/ui/src/components/chat/AppLinkConfirmDialog.tsx b/packages/ui/src/components/chat/AppLinkConfirmDialog.tsx new file mode 100644 index 00000000..73960a9e --- /dev/null +++ b/packages/ui/src/components/chat/AppLinkConfirmDialog.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useI18n } from '@/lib/i18n'; +import { getUrlScheme } from '@/lib/url'; + +import { + getAppLinkConfirmationSnapshot, + settleAppLinkConfirmation, + subscribeAppLinkConfirmation, + type AppLinkConfirmationChoice, +} from './appLinkConfirmation'; + +/** + * App-level dialog confirming application deep links (obsidian://, vscode://, + * ...) rendered in chat markdown before the OS is asked to open them. + * Dismissing via the close button, Escape, or the backdrop cancels the open. + */ +export const AppLinkConfirmDialog = () => { + const { t } = useI18n(); + const request = React.useSyncExternalStore( + subscribeAppLinkConfirmation, + getAppLinkConfirmationSnapshot, + getAppLinkConfirmationSnapshot, + ); + + const url = request?.url ?? ''; + const scheme = getUrlScheme(url) ?? ''; + + const settle = React.useCallback((choice: AppLinkConfirmationChoice) => { + settleAppLinkConfirmation(choice); + }, []); + + return ( + { + if (!open) { + settle('cancel'); + } + }} + > + + + {t('chat.appLink.confirm.title')} + + {scheme + ? t('chat.appLink.confirm.description', { scheme: `${scheme}://` }) + : t('chat.appLink.confirm.descriptionPlain')} + + +
+ {url} +
+ + + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 29d5416d..b8af3e03 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -4,10 +4,12 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid'; import type { Part } from '@opencode-ai/sdk/v2'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; -import { isExternalHttpUrl, openExternalUrl } from '@/lib/url'; +import { openExternalUrl } from '@/lib/url'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { getDefaultTheme } from '@/lib/theme/themes'; import type { Theme } from '@/types/theme'; +import { openAppLinkWithConfirmation } from './appLinkConfirmation'; +import { attachAppLinkInteractions } from './appLinkInteractions'; import type { ToolPopupContent } from './message/types'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import { useUIStore } from '@/stores/useUIStore'; @@ -55,7 +57,7 @@ const useCurrentMermaidTheme = () => { : fallbackLight); }; -const useExternalLinkInteractions = ({ +const useLinkInteractions = ({ containerRef, enabled, }: { @@ -63,48 +65,16 @@ const useExternalLinkInteractions = ({ enabled?: boolean; }) => { React.useEffect(() => { - if (enabled === false) { - return; - } - const container = containerRef.current; if (!container) { return; } - const handleClick = (event: MouseEvent) => { - if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) { - return; - } - - const target = event.target; - if (!(target instanceof Element)) { - return; - } - - const anchor = target.closest('a[href]'); - if (!(anchor instanceof HTMLAnchorElement)) { - return; - } - - if (anchor.getAttribute('data-openchamber-file-link') === 'true') { - return; - } - - const href = anchor.getAttribute('href') ?? ''; - if (!isExternalHttpUrl(href)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - void openExternalUrl(href); - }; - - container.addEventListener('click', handleClick); - return () => { - container.removeEventListener('click', handleClick); - }; + return attachAppLinkInteractions(container, { + allowExternalHttp: enabled !== false, + openAppLink: (href) => void openAppLinkWithConfirmation(href), + openExternalHttp: (href) => void openExternalUrl(href), + }); }, [containerRef, enabled]); }; @@ -969,7 +939,7 @@ const MarkdownRendererImpl: React.FC = ({ preferRuntimeEditor: runtime.isVSCode, enabled: enableFileReferences && !isStreaming, }); - useExternalLinkInteractions({ containerRef }); + useLinkInteractions({ containerRef }); const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS); @@ -1020,6 +990,7 @@ const SimpleMarkdownRendererImpl: React.FC<{ content: string; className?: string; variant?: MarkdownVariant; + // App links remain confirmed even where ordinary HTTP link handling is off. disableLinkSafety?: boolean; stripFrontmatter?: boolean; onShowPopup?: (content: ToolPopupContent) => void; @@ -1061,7 +1032,7 @@ const SimpleMarkdownRendererImpl: React.FC<{ preferRuntimeEditor: runtime.isVSCode, enabled: enableFileReferences, }); - useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety }); + useLinkInteractions({ containerRef, enabled: !disableLinkSafety }); const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls); diff --git a/packages/ui/src/components/chat/appLinkConfirmation.test.ts b/packages/ui/src/components/chat/appLinkConfirmation.test.ts new file mode 100644 index 00000000..1fb41557 --- /dev/null +++ b/packages/ui/src/components/chat/appLinkConfirmation.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore'; + +import { + getAppLinkConfirmationSnapshot, + openAppLinkWithConfirmation, + settleAppLinkConfirmation, +} from './appLinkConfirmation'; + +describe('app link confirmation', () => { + beforeEach(() => { + useAppLinkTrustStore.setState({ trustedSchemes: [] }); + const pending = getAppLinkConfirmationSnapshot(); + if (pending) { + settleAppLinkConfirmation('cancel'); + } + }); + + test('opens trusted schemes without asking', async () => { + useAppLinkTrustStore.getState().trustScheme('obsidian'); + + await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes'); + + expect(getAppLinkConfirmationSnapshot()).toBeNull(); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true); + }); + + test('asks once and trusts the scheme when the user chooses trust', async () => { + const pending = openAppLinkWithConfirmation('linear://issue/ABC-1'); + + expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1'); + + settleAppLinkConfirmation('trust'); + await pending; + + expect(getAppLinkConfirmationSnapshot()).toBeNull(); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true); + }); + + test('cancel opens nothing and keeps the scheme untrusted', async () => { + const pending = openAppLinkWithConfirmation('notion://note/xyz'); + + settleAppLinkConfirmation('cancel'); + await pending; + + expect(getAppLinkConfirmationSnapshot()).toBeNull(); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false); + }); + + test('a newer request cancels the pending one', async () => { + const first = openAppLinkWithConfirmation('obsidian://open?vault=a'); + const firstChoice = first.then( + () => 'settled', + () => 'settled', + ); + const second = openAppLinkWithConfirmation('linear://open/1'); + + expect(await firstChoice).toBe('settled'); + expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1'); + + settleAppLinkConfirmation('open'); + await second; + + expect(getAppLinkConfirmationSnapshot()).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/chat/appLinkConfirmation.ts b/packages/ui/src/components/chat/appLinkConfirmation.ts new file mode 100644 index 00000000..d9eea31e --- /dev/null +++ b/packages/ui/src/components/chat/appLinkConfirmation.ts @@ -0,0 +1,71 @@ +import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore'; +import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url'; + +export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel'; + +type PendingAppLinkRequest = { + url: string; + resolve: (choice: AppLinkConfirmationChoice) => void; +}; + +let pendingRequest: PendingAppLinkRequest | null = null; +const listeners = new Set<() => void>(); + +const emitChange = (): void => { + for (const listener of listeners) { + listener(); + } +}; + +const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest; + +const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +/** + * Ask the user (via the app-level confirmation dialog) whether an application + * deep link may be opened. Resolves immediately when the scheme was trusted + * earlier. Only one request is active at a time; a new request cancels the + * pending one. + */ +export const openAppLinkWithConfirmation = (url: string): Promise => { + const scheme = getUrlScheme(url); + if (!scheme) { + return Promise.resolve(); + } + + const trustStore = useAppLinkTrustStore.getState(); + if (trustStore.isSchemeTrusted(scheme)) { + return openConfirmedAppLinkUrl(url).then(() => undefined); + } + + if (pendingRequest) { + pendingRequest.resolve('cancel'); + } + + return new Promise((resolve) => { + pendingRequest = { url, resolve }; + emitChange(); + }).then((choice) => { + if (choice === 'trust') { + useAppLinkTrustStore.getState().trustScheme(scheme); + } + if (choice === 'open' || choice === 'trust') { + return openConfirmedAppLinkUrl(url).then(() => undefined); + } + }); +}; + +export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => { + const request = pendingRequest; + pendingRequest = null; + emitChange(); + request?.resolve(choice); +}; + +export const subscribeAppLinkConfirmation = subscribe; +export const getAppLinkConfirmationSnapshot = getSnapshot; diff --git a/packages/ui/src/components/chat/appLinkInteractions.test.ts b/packages/ui/src/components/chat/appLinkInteractions.test.ts new file mode 100644 index 00000000..e32acbd9 --- /dev/null +++ b/packages/ui/src/components/chat/appLinkInteractions.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from 'bun:test'; + +import { attachAppLinkInteractions } from './appLinkInteractions'; + +const TestElement = class Element {}; +const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {}; +Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement }); + +class TestAnchor extends HTMLAnchorElement { + constructor(private readonly rawHref: string) { + super(); + } + + getAttribute(name: string): string | null { + return name === 'href' ? this.rawHref : null; + } + + closest(): TestAnchor { + return this; + } +} + +class TestContainer { + listeners = new Map(); + + addEventListener(name: string, listener: (event: MouseEvent) => void): void { + // SAFETY: dispatch constructs every mouse field read by the production listener. + this.listeners.set(name, (event) => listener(event as MouseEvent)); + } + + removeEventListener(name: string, listener: (event: MouseEvent) => void): void { + void listener; + this.listeners.delete(name); + } + + dispatch(name: string, href: string, init: Partial = {}): Event { + const event = new Event(name, { cancelable: true }); + Object.defineProperties(event, { + target: { value: new TestAnchor(href) }, + button: { value: init.button ?? 0 }, + metaKey: { value: init.metaKey ?? false }, + ctrlKey: { value: init.ctrlKey ?? false }, + altKey: { value: init.altKey ?? false }, + shiftKey: { value: init.shiftKey ?? false }, + }); + this.listeners.get(name)?.(event); + return event; + } +} + +const setup = (allowExternalHttp = true) => { + const container = new TestContainer(); + const appLinks: string[] = []; + const httpLinks: string[] = []; + const cleanup = attachAppLinkInteractions(container, { + allowExternalHttp, + openAppLink: (url) => appLinks.push(url), + openExternalHttp: (url) => httpLinks.push(url), + }); + return { container, appLinks, httpLinks, cleanup }; +}; + +describe('app link interactions', () => { + test('confirms plain, modifier, and middle-click activations', () => { + const { container, appLinks } = setup(); + const href = 'obsidian://open?vault=Notes'; + + expect(container.dispatch('click', href).defaultPrevented).toBe(true); + expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true); + expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true); + expect(appLinks).toEqual([href, href, href]); + }); + + test('blocks drag activation without opening immediately', () => { + const { container, appLinks } = setup(); + const href = 'obsidian://open?vault=Notes'; + + expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true); + expect(appLinks).toEqual([]); + }); + + test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => { + const enabled = setup(); + const disabled = setup(false); + const href = 'https://example.com'; + + expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false); + expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true); + expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false); + expect(enabled.httpLinks).toEqual([href]); + expect(disabled.httpLinks).toEqual([]); + }); +}); diff --git a/packages/ui/src/components/chat/appLinkInteractions.ts b/packages/ui/src/components/chat/appLinkInteractions.ts new file mode 100644 index 00000000..595e671a --- /dev/null +++ b/packages/ui/src/components/chat/appLinkInteractions.ts @@ -0,0 +1,75 @@ +import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url'; + +type AppLinkInteractionOptions = { + allowExternalHttp: boolean; + openAppLink: (url: string) => void; + openExternalHttp: (url: string) => void; +}; + +type LinkInteractionContainer = { + addEventListener: (type: string, listener: (event: MouseEvent) => void) => void; + removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void; +}; + +const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => { + const target = event.target; + if (!(target instanceof Element)) return null; + const anchor = target.closest('a[href]'); + if (!(anchor instanceof HTMLAnchorElement)) return null; + if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null; + return anchor; +}; + +const interceptAppLink = ( + event: MouseEvent | DragEvent, + openAppLink?: (url: string) => void, +): boolean => { + if (event.defaultPrevented) return false; + const anchor = findLink(event); + const href = anchor?.getAttribute('href') ?? ''; + if (!isAppLinkUrl(href)) return false; + + event.preventDefault(); + event.stopPropagation(); + openAppLink?.(href); + return true; +}; + +const isPlainPrimaryClick = (event: MouseEvent): boolean => ( + event.button === 0 + && !event.metaKey + && !event.ctrlKey + && !event.altKey + && !event.shiftKey +); + +export const attachAppLinkInteractions = ( + container: LinkInteractionContainer, + options: AppLinkInteractionOptions, +): (() => void) => { + const handleClick = (event: MouseEvent) => { + if (interceptAppLink(event, options.openAppLink)) return; + if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return; + + const href = findLink(event)?.getAttribute('href') ?? ''; + if (!isExternalHttpUrl(href)) return; + event.preventDefault(); + event.stopPropagation(); + options.openExternalHttp(href); + }; + const handleAuxClick = (event: MouseEvent) => { + if (event.button === 1) interceptAppLink(event, options.openAppLink); + }; + const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => { + interceptAppLink(event); + }; + + container.addEventListener('click', handleClick); + container.addEventListener('auxclick', handleAuxClick); + container.addEventListener('dragstart', blockAlternateAppLinkActivation); + return () => { + container.removeEventListener('click', handleClick); + container.removeEventListener('auxclick', handleAuxClick); + container.removeEventListener('dragstart', blockAlternateAppLinkActivation); + }; +}; diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts index 968363d3..9153250d 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.test.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts @@ -1,10 +1,43 @@ import { describe, expect, mock, test } from 'bun:test'; +type SanitizeAttribute = { + attrName: string; + attrValue: string; + forceKeepAttr?: boolean; +}; + +class TestAnchorElement { + target = ''; + + setAttribute(name: string, value: string): void { + if (name === 'target') this.target = value; + } +} + +const sanitizeHooks: { + uponSanitizeAttribute?: (node: unknown, data: SanitizeAttribute) => void; + afterSanitizeAttributes?: (node: unknown) => void; +} = {}; + +Object.assign(globalThis, { + window: {}, + HTMLAnchorElement: TestAnchorElement, +}); + mock.module('dompurify', () => ({ default: { isSupported: true, - addHook: () => undefined, - sanitize: (html: string) => html, + addHook: (name: keyof typeof sanitizeHooks, hook: never) => { + sanitizeHooks[name] = hook; + }, + sanitize: (html: string) => html.replace(/ href="([^"]*)"/g, (attribute, href: string) => { + const anchor = new TestAnchorElement(); + const data: SanitizeAttribute = { attrName: 'href', attrValue: href }; + sanitizeHooks.uponSanitizeAttribute?.(anchor, data); + sanitizeHooks.afterSanitizeAttributes?.(anchor); + + return data.forceKeepAttr || /^(?:https?|mailto|tel):/i.test(href) ? attribute : ''; + }), }, })); mock.module('./markdown-worker', () => ({ @@ -40,6 +73,21 @@ describe('markdown sanitization', () => { expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false); expect(isLocalFileUrl('javascript:alert(1)')).toBe(false); }); + + test('keeps app and local file links while stripping blocked schemes', () => { + const html = renderMarkdownSync([ + '[app](obsidian://open?vault=Notebook)', + '[file](file:///workspace/notes.md)', + '[script](javascript:alert(1))', + '[diagnostic](ms-msdt:/id%20PCWDiagnostic)', + ].join('\n\n'), 'inline'); + + expect(html).toContain('href="obsidian://open?vault=Notebook"'); + expect(html).toContain('href="file:///workspace/notes.md"'); + expect(html).not.toContain('href="javascript:alert(1)"'); + expect(html).not.toContain('href="ms-msdt:/id%20PCWDiagnostic"'); + }); + }); describe('Markdown images', () => { diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 2d3cb7a9..822a3168 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -3,6 +3,7 @@ import remend from 'remend'; import katex from 'katex'; import DOMPurify from 'dompurify'; import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks'; +import { isAppLinkUrl } from '@/lib/url'; import { isVSCodeRuntime } from '@/lib/desktop'; import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache'; import { highlightCodeInWorker } from './markdown-worker'; @@ -472,7 +473,10 @@ const ensureSanitizeHook = (): void => { sanitizeHookInstalled = true; DOMPurify.addHook('uponSanitizeAttribute', (node, data) => { if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return; - if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true; + // DOMPurify's default URI policy strips custom application schemes + // (obsidian://, vscode://, ...). Keep them for anchors; dangerous schemes + // stay excluded via isAppLinkUrl and clicks go through confirmation. + if (isLocalFileUrl(data.attrValue) || isAppLinkUrl(data.attrValue)) data.forceKeepAttr = true; }); DOMPurify.addHook('afterSanitizeAttributes', (node) => { if (!(node instanceof HTMLAnchorElement)) return; @@ -544,7 +548,10 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe live: liveBlockCache.size, }); -const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise => { +const parseBlock = async ( + block: MarkdownBlock, + imageMode: MarkdownImageMode, +): Promise => { const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser; const parsed = await Promise.resolve(parser.parse(block.src)); const withMath = renderMathExpressions(parsed); @@ -561,7 +568,10 @@ const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): P * is synchronous (marked is not configured `async`), so this never blocks on a * worker round-trip. */ -export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => { +export const renderMarkdownSync = ( + text: string, + imageMode: MarkdownImageMode = 'inline', +): string => { if (!text) return ''; const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser; const parsed = parser.parse(text) as string; diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 730b78e2..9cd7bf7a 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -54,7 +54,8 @@ Use this doc when you ask an agent to change tool/header/description behavior. - Assistant markdown treats raw HTML as inert visible text. The final generated HTML is sanitized as defense in depth, with script and style elements forbidden, so message content cannot inject active DOM or application-wide - CSS into any runtime surface. + CSS into any runtime surface. Safe custom application links go through the + app-link confirmation flow in every supported renderer, including VS Code. - Final assistant Markdown rendering is independent from image gallery extraction: gallery presence never changes the chat body. Assistant image syntax consistently renders as a shared image icon followed by its filename, diff --git a/packages/ui/src/components/sections/openchamber/AppLinkSecuritySettings.tsx b/packages/ui/src/components/sections/openchamber/AppLinkSecuritySettings.tsx new file mode 100644 index 00000000..55eafdda --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/AppLinkSecuritySettings.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import { Button } from '@/components/ui/button'; +import { SettingsSection } from '@/components/sections/shared/SettingsSection'; +import { useI18n } from '@/lib/i18n'; +import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore'; + +/** + * Security section for application deep links (obsidian://, notion://, ...) + * that the user chose to always allow from chat. Removing a scheme restores + * the confirmation dialog for it. + */ +export const AppLinkSecuritySettings: React.FC = () => { + const { t } = useI18n(); + const trustedSchemes = useAppLinkTrustStore((state) => state.trustedSchemes); + const removeTrustedScheme = useAppLinkTrustStore((state) => state.removeTrustedScheme); + + return ( + +
+ {trustedSchemes.length === 0 ? ( +

+ {t('settings.openchamber.appLinks.empty')} +

+ ) : ( + trustedSchemes.map((scheme) => ( +
+ {`${scheme}://`} + +
+ )) + )} +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 08614a31..43fd56c0 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -3,6 +3,7 @@ import { OpenChamberVisualSettings } from './OpenChamberVisualSettings'; import { AboutSettings } from './AboutSettings'; import { SessionRetentionSettings } from './SessionRetentionSettings'; import { PasskeySettings } from './PasskeySettings'; +import { AppLinkSecuritySettings } from './AppLinkSecuritySettings'; import { DefaultsSettings } from './DefaultsSettings'; import { GitSettings } from './GitSettings'; import { NotificationSettings } from './NotificationSettings'; @@ -55,6 +56,7 @@ export const OpenChamberPage: React.FC = ({ section }) => {!isVSCode && } {!isVSCode && } + {isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp() && } {showAbout && } @@ -145,6 +147,7 @@ const GeneralSectionContent: React.FC = () => { <> {showDesktopNetworkSettings && } {showPasskeySettings && } + {!isVSCode && } {!isVSCode && } = { 'chat.dictation.retry': 'Reintentar transcripción', 'chat.dictation.discard': 'Descartar grabación', 'chat.history.loadOlder': 'Cargar mensajes anteriores', + "chat.appLink.confirm.title": "¿Abrir este enlace en otra aplicación?", + "chat.appLink.confirm.description": "Este enlace del chat usa el protocolo {scheme} y se abrirá en otra aplicación.", + "chat.appLink.confirm.descriptionPlain": "Este enlace del chat se abrirá en otra aplicación.", + "chat.appLink.confirm.cancel": "Cancelar", + "chat.appLink.confirm.open": "Abrir una vez", + "chat.appLink.confirm.trustAndOpen": "Confiar y abrir", 'chat.autoReview.title': 'El ciclo de revisión de código está en curso', 'chat.autoReview.status.waitingForReviewer': 'Esperando al revisor', 'chat.autoReview.status.waitingForImplementer': 'Esperando al implementador', diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index cdaffae8..88d46f33 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -324,6 +324,10 @@ export const settingsDict = { 'settings.common.actions.cancel': 'Annuler', 'settings.common.actions.create': 'Créer', 'settings.common.actions.delete': 'Supprimer', + 'settings.openchamber.appLinks.title': 'Liens d’application approuvés', + 'settings.openchamber.appLinks.info': 'Les liens de cette liste s’ouvrent sans nouvelle demande sur cet appareil. Les autres liens d’application demandent toujours une confirmation.', + 'settings.openchamber.appLinks.empty': 'Aucun lien d’application approuvé sur cet appareil. Choisissez « Approuver et ouvrir » lors de l’ouverture d’un lien pour l’ajouter ici.', + 'settings.openchamber.appLinks.removeAria': 'Supprimer les liens {scheme} approuvés', 'settings.common.actions.reset': 'Réinitialiser', 'settings.common.actions.rename': 'Rebaptiser', 'settings.common.actions.duplicate': 'Dupliquer', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index fd989b8f..af006030 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1314,6 +1314,12 @@ export const dict = { 'diffView.reviewDialog.toast.noSessionDirectory': 'Le dossier de session est indisponible', 'diffView.reviewDialog.toast.startFailed': 'Impossible de démarrer le flux de revue', 'chat.history.loadOlder': 'Charger les messages précédents', + 'chat.appLink.confirm.title': 'Ouvrir ce lien dans une autre application ?', + 'chat.appLink.confirm.description': "Ce lien de discussion utilise le protocole {scheme} et s'ouvrira dans une autre application.", + 'chat.appLink.confirm.descriptionPlain': "Ce lien de discussion s'ouvrira dans une autre application.", + 'chat.appLink.confirm.cancel': 'Annuler', + 'chat.appLink.confirm.open': 'Ouvrir une fois', + 'chat.appLink.confirm.trustAndOpen': 'Approuver et ouvrir', 'chat.autoReview.title': 'La boucle de revue de code est en cours', 'chat.autoReview.status.waitingForReviewer': 'En attente du reviewer', 'chat.autoReview.status.waitingForImplementer': 'En attente de l’implémenteur', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index b83da80c..f4070831 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -434,6 +434,10 @@ export const settingsDict = { 'settings.common.actions.cancel': 'キャンセル', 'settings.common.actions.create': '作成', 'settings.common.actions.delete': '削除', + 'settings.openchamber.appLinks.title': '信頼済みのアプリリンク', + 'settings.openchamber.appLinks.info': 'ここに表示されたリンクは、このデバイスでは次回から確認せずに開きます。その他のアプリリンクは開く前に必ず確認します。', + 'settings.openchamber.appLinks.empty': 'このデバイスには信頼済みのアプリリンクがありません。リンクを開く際に「信頼して開く」を選ぶとここに追加されます。', + 'settings.openchamber.appLinks.removeAria': '信頼済みの {scheme} リンクを削除', 'settings.common.actions.reset': 'リセット', 'settings.common.actions.rename': '名前変更', 'settings.common.actions.duplicate': '複製', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index f233371b..ebe9b889 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1554,6 +1554,12 @@ export const dict: Record = { 'diffView.hunk.unavailable': 'このハンクはもう利用できません。差分を更新してからもう一度お試しください。', 'diffView.hunk.unsupported': '個別のハンクのステージングはこのランタイムではサポートされていません。', 'chat.history.loadOlder': '以前のメッセージを読み込む', + 'chat.appLink.confirm.title': 'このリンクを別のアプリで開きますか?', + 'chat.appLink.confirm.description': 'このチャットのリンクは {scheme} プロトコルを使用し、別のアプリで開かれます。', + 'chat.appLink.confirm.descriptionPlain': 'このチャットのリンクは別のアプリで開かれます。', + 'chat.appLink.confirm.cancel': 'キャンセル', + 'chat.appLink.confirm.open': '一度だけ開く', + 'chat.appLink.confirm.trustAndOpen': '信頼して開く', 'chat.autoReview.title': 'コードレビューループが実行中です', 'chat.autoReview.status.waitingForReviewer': 'レビュアーを待機中', 'chat.autoReview.status.waitingForImplementer': '実装者を待機中', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index a8e05bba..e4e2ef9e 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -401,6 +401,10 @@ export const settingsDict = { 'settings.common.actions.cancel': '취소', 'settings.common.actions.create': '생성', 'settings.common.actions.delete': '삭제', + 'settings.openchamber.appLinks.title': '신뢰한 앱 링크', + 'settings.openchamber.appLinks.info': '여기에 표시된 링크는 이 기기에서 다시 묻지 않고 열립니다. 그 밖의 앱 링크는 열기 전에 항상 확인합니다.', + 'settings.openchamber.appLinks.empty': '이 기기에 신뢰한 앱 링크가 없습니다. 링크를 열 때 "신뢰하고 열기"를 선택하면 여기에 추가됩니다.', + 'settings.openchamber.appLinks.removeAria': '신뢰된 {scheme} 링크 제거', 'settings.common.actions.reset': '초기화', 'settings.common.actions.rename': '이름 변경', 'settings.common.actions.duplicate': '복제', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index bfe88f96..6300cd13 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1551,6 +1551,12 @@ export const dict: Record = { 'diffView.reviewDialog.toast.noSessionDirectory': '세션 디렉터리를 사용할 수 없습니다', 'diffView.reviewDialog.toast.startFailed': '리뷰 흐름을 시작하지 못했습니다', 'chat.history.loadOlder': '이전 메시지 불러오기', + 'chat.appLink.confirm.title': '이 링크를 다른 앱에서 열까요?', + 'chat.appLink.confirm.description': '이 채팅 링크는 {scheme} 프로토콜을 사용하며 다른 앱에서 열립니다.', + 'chat.appLink.confirm.descriptionPlain': '이 채팅 링크는 다른 앱에서 열립니다.', + 'chat.appLink.confirm.cancel': '취소', + 'chat.appLink.confirm.open': '한 번만 열기', + 'chat.appLink.confirm.trustAndOpen': '신뢰하고 열기', 'chat.autoReview.title': '코드 리뷰 루프 실행 중', 'chat.autoReview.status.waitingForReviewer': '리뷰어를 기다리는 중', 'chat.autoReview.status.waitingForImplementer': '구현 에이전트를 기다리는 중', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 3555958f..7bebca44 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -216,6 +216,10 @@ export const settingsDict = { 'settings.common.actions.copyAll': 'Kopiuj wszystko', 'settings.common.actions.create': 'Utwórz', 'settings.common.actions.delete': 'Usuń', + 'settings.openchamber.appLinks.title': 'Zaufane linki aplikacji', + 'settings.openchamber.appLinks.info': 'Linki z tej listy otwierają się na tym urządzeniu bez ponownego pytania. Inne linki aplikacji zawsze wymagają potwierdzenia.', + 'settings.openchamber.appLinks.empty': 'Brak zaufanych linków aplikacji na tym urządzeniu. Wybierz „Zaufaj i otwórz” podczas otwierania linku, aby dodać go tutaj.', + 'settings.openchamber.appLinks.removeAria': 'Usuń zaufane linki {scheme}', 'settings.common.actions.duplicate': 'Duplikuj', 'settings.common.actions.import': 'Importuj', 'settings.common.actions.rename': 'Zmień nazwę', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index c8ded4ba..666c1d73 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1763,6 +1763,12 @@ export const dict: Record = { 'diffView.reviewDialog.toast.noSessionDirectory': 'Katalog sesji jest niedostępny', 'diffView.reviewDialog.toast.startFailed': 'Nie udało się uruchomić flow review', 'chat.history.loadOlder': 'Wczytaj starsze wiadomości', + 'chat.appLink.confirm.title': 'Otworzyć ten link w innej aplikacji?', + 'chat.appLink.confirm.description': 'Ten link z czatu używa protokołu {scheme} i zostanie otwarty w innej aplikacji.', + 'chat.appLink.confirm.descriptionPlain': 'Ten link z czatu zostanie otwarty w innej aplikacji.', + 'chat.appLink.confirm.cancel': 'Anuluj', + 'chat.appLink.confirm.open': 'Otwórz raz', + 'chat.appLink.confirm.trustAndOpen': 'Zaufaj i otwórz', 'chat.autoReview.title': 'Pętla code review trwa', 'chat.autoReview.status.waitingForReviewer': 'Oczekiwanie na reviewera', 'chat.autoReview.status.waitingForImplementer': 'Oczekiwanie na implementatora', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 963fbf91..ce24a8d0 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -401,6 +401,10 @@ export const settingsDict = { "settings.common.actions.cancel": "Cancelar", "settings.common.actions.create": "Criar", "settings.common.actions.delete": "Excluir", + "settings.openchamber.appLinks.title": "Links de aplicativos confiáveis", + "settings.openchamber.appLinks.info": "Os links desta lista abrem sem perguntar novamente neste dispositivo. Outros links de aplicativos sempre pedem confirmação antes de abrir.", + "settings.openchamber.appLinks.empty": "Não há links de aplicativos confiáveis neste dispositivo. Escolha \"Confiar e abrir\" ao abrir um link para adicioná-lo aqui.", + "settings.openchamber.appLinks.removeAria": "Remover links {scheme} confiáveis", "settings.common.actions.reset": "Reiniciar", "settings.common.actions.rename": "Renomear", "settings.common.actions.duplicate": "Duplicar", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 48235bf6..59c2ff9e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1527,6 +1527,12 @@ export const dict: Record = { 'chat.dictation.retry': 'Tentar transcrever novamente', 'chat.dictation.discard': 'Descartar gravação', 'chat.history.loadOlder': 'Carregar mensagens anteriores', + "chat.appLink.confirm.title": "Abrir este link em outro aplicativo?", + "chat.appLink.confirm.description": "Este link do chat usa o protocolo {scheme} e será aberto em outro aplicativo.", + "chat.appLink.confirm.descriptionPlain": "Este link do chat será aberto em outro aplicativo.", + "chat.appLink.confirm.cancel": "Cancelar", + "chat.appLink.confirm.open": "Abrir uma vez", + "chat.appLink.confirm.trustAndOpen": "Confiar e abrir", 'chat.autoReview.title': 'O ciclo de revisão de código está em andamento', 'chat.autoReview.status.waitingForReviewer': 'Aguardando o revisor', 'chat.autoReview.status.waitingForImplementer': 'Aguardando o implementador', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 00035bca..2d7dcb16 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -401,6 +401,10 @@ export const settingsDict = { "settings.common.actions.cancel": "Скасувати", "settings.common.actions.create": "Створити", "settings.common.actions.delete": "Видалити", + "settings.openchamber.appLinks.title": "Довірені посилання програм", + "settings.openchamber.appLinks.info": "Посилання в цьому списку відкриваються без повторного запиту на цьому пристрої. Для інших посилань програм ми завжди просимо підтвердження.", + "settings.openchamber.appLinks.empty": "На цьому пристрої ще немає довірених посилань програм. Виберіть «Довірити і відкрити» під час відкриття посилання, щоб додати його сюди.", + "settings.openchamber.appLinks.removeAria": "Видалити довірені посилання {scheme}", "settings.common.actions.reset": "Скинути", "settings.common.actions.rename": "Перейменувати", "settings.common.actions.duplicate": "Дублювати", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 618fafa4..a90cab4c 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1527,6 +1527,12 @@ export const dict: Record = { 'chat.dictation.retry': 'Повторити розшифровку', 'chat.dictation.discard': 'Відхилити запис', 'chat.history.loadOlder': 'Завантажити ще', + "chat.appLink.confirm.title": "Відкрити це посилання в іншій програмі?", + "chat.appLink.confirm.description": "Це посилання з чату використовує протокол {scheme} і буде відкрито в іншій програмі.", + "chat.appLink.confirm.descriptionPlain": "Це посилання з чату буде відкрито в іншій програмі.", + "chat.appLink.confirm.cancel": "Скасувати", + "chat.appLink.confirm.open": "Відкрити один раз", + "chat.appLink.confirm.trustAndOpen": "Довірити і відкрити", 'chat.autoReview.title': 'Цикл код-ревʼю триває', 'chat.autoReview.status.waitingForReviewer': 'Очікуємо ревʼювера', 'chat.autoReview.status.waitingForImplementer': 'Очікуємо імплементатора', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index eddb6b73..d648d30e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -401,6 +401,10 @@ export const settingsDict = { 'settings.common.actions.cancel': '取消', 'settings.common.actions.create': '创建', 'settings.common.actions.delete': '删除', + 'settings.openchamber.appLinks.title': '受信任的应用链接', + 'settings.openchamber.appLinks.info': '此列表中的链接在本设备上打开时不再询问。其他应用链接在打开前始终需要确认。', + 'settings.openchamber.appLinks.empty': '本设备上暂无受信任的应用链接。打开链接时选择“信任并打开”即可添加到这里。', + 'settings.openchamber.appLinks.removeAria': '移除受信任的 {scheme} 链接', 'settings.common.actions.reset': '重置', 'settings.common.actions.rename': '重命名', 'settings.common.actions.duplicate': '复制', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index df7f48c5..175b755b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1515,6 +1515,12 @@ export const dict: Record = { 'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable', 'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow', 'chat.history.loadOlder': '加载更早的消息', + 'chat.appLink.confirm.title': '要在其他应用中打开此链接吗?', + 'chat.appLink.confirm.description': '此聊天链接使用 {scheme} 协议,将在其他应用中打开。', + 'chat.appLink.confirm.descriptionPlain': '此聊天链接将在其他应用中打开。', + 'chat.appLink.confirm.cancel': '取消', + 'chat.appLink.confirm.open': '打开一次', + 'chat.appLink.confirm.trustAndOpen': '信任并打开', 'chat.autoReview.title': '代码审查循环正在运行', 'chat.autoReview.status.waitingForReviewer': '等待审查者', 'chat.autoReview.status.waitingForImplementer': '等待实现者', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index b1fdb079..28e71162 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -398,6 +398,10 @@ export const settingsDict = { 'settings.common.actions.cancel': '取消', 'settings.common.actions.create': '建立', 'settings.common.actions.delete': '刪除', + 'settings.openchamber.appLinks.title': '受信任的應用程式連結', + 'settings.openchamber.appLinks.info': '此清單中的連結在這台裝置上開啟時不再詢問。其他應用程式連結在開啟前一律需要確認。', + 'settings.openchamber.appLinks.empty': '這台裝置上目前沒有受信任的應用程式連結。開啟連結時選擇「信任並開啟」即可加入這裡。', + 'settings.openchamber.appLinks.removeAria': '移除受信任的 {scheme} 連結', 'settings.common.actions.reset': '重設', 'settings.common.actions.rename': '重新命名', 'settings.common.actions.duplicate': '複製', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 673ae58e..ae1d8a49 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1525,6 +1525,12 @@ export const dict: Record = { 'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable', 'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow', 'chat.history.loadOlder': '載入更早的訊息', + 'chat.appLink.confirm.title': '要在其他應用程式中開啟此連結嗎?', + 'chat.appLink.confirm.description': '此聊天連結使用 {scheme} 通訊協定,將在其他應用程式中開啟。', + 'chat.appLink.confirm.descriptionPlain': '此聊天連結將在其他應用程式中開啟。', + 'chat.appLink.confirm.cancel': '取消', + 'chat.appLink.confirm.open': '開啟一次', + 'chat.appLink.confirm.trustAndOpen': '信任並開啟', 'chat.autoReview.title': '程式碼審查循環執行中', 'chat.autoReview.status.waitingForReviewer': '等待審查者', 'chat.autoReview.status.waitingForImplementer': '等待實作者', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index f247f679..9945f92c 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -50,7 +50,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ page: 'appearance', titleKey: 'settings.openchamber.visual.field.weekStartsOn', keywords: ['calendar', 'monday', 'sunday'], - isAvailable: (ctx) => !ctx.isVSCode, }, { id: 'appearance.light-theme', @@ -177,6 +176,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ descriptionKey: 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint', keywords: ['telemetry', 'analytics'], }, + { + id: 'general.app-links', + page: 'general', + titleKey: 'settings.openchamber.appLinks.title', + descriptionKey: 'settings.openchamber.appLinks.info', + keywords: ['security', 'app link', 'deep link', 'scheme', 'protocol', 'obsidian', 'notion'], + }, { id: 'chat.render-mode', page: 'chat', diff --git a/packages/ui/src/lib/url.test.ts b/packages/ui/src/lib/url.test.ts new file mode 100644 index 00000000..4a3c5278 --- /dev/null +++ b/packages/ui/src/lib/url.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; + +import { getUrlScheme, isAppLinkUrl } from '@/lib/url'; + +describe('getUrlScheme', () => { + test('extracts the lowercased scheme', () => { + expect(getUrlScheme('Obsidian://open?vault=X')).toBe('obsidian'); + expect(getUrlScheme('https://example.test')).toBe('https'); + }); + + test('returns null for unparseable values', () => { + expect(getUrlScheme('')).toBeNull(); + expect(getUrlScheme('not a url')).toBeNull(); + }); +}); + +describe('isAppLinkUrl', () => { + test('accepts custom application schemes', () => { + expect(isAppLinkUrl('obsidian://open?vault=Notebook&file=a%20b')).toBe(true); + expect(isAppLinkUrl('vscode://file/path/to/file.ts')).toBe(true); + expect(isAppLinkUrl('linear://issue/ABC-1')).toBe(true); + expect(isAppLinkUrl('notion://note/xyz')).toBe(true); + expect(isAppLinkUrl('slack://channel?id=C123')).toBe(true); + }); + + test('rejects browser and communication schemes', () => { + expect(isAppLinkUrl('https://example.test')).toBe(false); + expect(isAppLinkUrl('http://example.test')).toBe(false); + expect(isAppLinkUrl('mailto:user@example.test')).toBe(false); + expect(isAppLinkUrl('tel:+1234567890')).toBe(false); + expect(isAppLinkUrl('sms:+1234567890')).toBe(false); + expect(isAppLinkUrl('webcal://example.test/cal.ics')).toBe(false); + }); + + test('rejects dangerous and internal schemes', () => { + expect(isAppLinkUrl('javascript:alert(1)')).toBe(false); + expect(isAppLinkUrl('data:text/html;base64,PHNjcmlwdD4=')).toBe(false); + expect(isAppLinkUrl('vbscript:msgbox(1)')).toBe(false); + expect(isAppLinkUrl('blob:https://example.test/uuid')).toBe(false); + expect(isAppLinkUrl('about:blank')).toBe(false); + expect(isAppLinkUrl('file:///etc/passwd')).toBe(false); + expect(isAppLinkUrl('ws://localhost:8080')).toBe(false); + expect(isAppLinkUrl('ftp://files.example.test')).toBe(false); + expect(isAppLinkUrl('intent://scan/#Intent;scheme=zxing;end')).toBe(false); + expect(isAppLinkUrl('chrome://settings')).toBe(false); + expect(isAppLinkUrl('devtools://devtools/bundled/inspector.html')).toBe(false); + expect(isAppLinkUrl('ms-msdt:/id%20PCWDiagnostic')).toBe(false); + expect(isAppLinkUrl('search-ms:query=report')).toBe(false); + expect(isAppLinkUrl('shell:AppsFolder')).toBe(false); + }); + + test('rejects OpenChamber and Capacitor self-deep-links', () => { + expect(isAppLinkUrl('openchamber://connect?host=x')).toBe(false); + expect(isAppLinkUrl('openchamber-ui://app/index.html')).toBe(false); + expect(isAppLinkUrl('capacitor://localhost/index.html')).toBe(false); + }); + + test('rejects malformed input', () => { + expect(isAppLinkUrl('')).toBe(false); + expect(isAppLinkUrl('random text')).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/url.ts b/packages/ui/src/lib/url.ts index 0e688c97..9696082b 100644 --- a/packages/ui/src/lib/url.ts +++ b/packages/ui/src/lib/url.ts @@ -20,6 +20,61 @@ export const isExternalHttpUrl = (url: string): boolean => { return parsed.protocol === 'http:' || parsed.protocol === 'https:'; }; +/** Lowercased URL scheme without the trailing colon, or null when unparseable. */ +export const getUrlScheme = (url: string): string | null => { + const parsed = parseUrlSafely(url.trim()); + if (!parsed) { + return null; + } + return parsed.protocol.replace(/:$/, '').toLowerCase(); +}; + +/** + * Schemes the browser or OS communication apps already handle natively + * (mailto:, tel:, sms:, ...). They are not application deep links. + */ +const BROWSER_HANDLED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel', 'sms', 'callto', 'cid', 'xmpp', 'irc', 'news', 'nntp', 'feed', 'webcal']); + +/** + * Schemes that must never be preserved or opened from rendered chat content. + */ +const BLOCKED_APP_LINK_SCHEMES = new Set([ + // Scriptable or web-content schemes + 'javascript', 'data', 'vbscript', 'blob', 'filesystem', 'about', + // WebView/Electron internal schemes + 'chrome', 'chrome-extension', 'devtools', 'moz-extension', 'ms-browser-extension', + // Local files flow through the dedicated file-link handling + 'file', + // Network protocols that are not application links + 'ws', 'wss', 'ftp', 'ftps', + // Android intent URIs can launch arbitrary components with extras + 'intent', + // Historically abused Windows handlers can invoke diagnostic, shell, or + // file-search flows that must not be offered from untrusted chat content. + 'ms-msdt', 'search-ms', 'shell', + // OpenChamber's own schemes must not be re-launched from chat content + 'openchamber', 'openchamber-ui', 'capacitor', +]); + +const APP_LINK_SCHEME_RE = /^[a-z][a-z0-9+.-]{1,31}$/; + +/** + * True for custom application deep links such as `obsidian://`, `linear://`, + * or `vscode://`. Browser-handled and dangerous/internal schemes are excluded, + * so a true result means the link may be offered to the user behind a + * confirmation the first time its scheme appears. + */ +export const isAppLinkUrl = (url: string): boolean => { + const scheme = getUrlScheme(url); + if (!scheme) { + return false; + } + if (BROWSER_HANDLED_SCHEMES.has(scheme) || BLOCKED_APP_LINK_SCHEMES.has(scheme)) { + return false; + } + return APP_LINK_SCHEME_RE.test(scheme); +}; + export const getExternalFaviconUrl = (url: string): string | null => { const parsed = parseUrlSafely(url.trim()); if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) { @@ -88,7 +143,7 @@ export const extractLoopbackUrls = (text: string): string[] => { * @param url - The URL to open * @returns Promise - true if the URL was opened successfully */ -export const openExternalUrl = async (url: string): Promise => { +const openValidatedExternalUrl = async (url: string): Promise => { if (typeof window === 'undefined') { return false; } @@ -103,10 +158,6 @@ export const openExternalUrl = async (url: string): Promise => { return false; } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return false; - } - const normalizedTarget = parsed.toString(); const runtimeApis = getRegisteredRuntimeAPIs(); @@ -136,3 +187,10 @@ export const openExternalUrl = async (url: string): Promise => { return false; } }; + +export const openExternalUrl = (url: string): Promise => + isExternalHttpUrl(url) ? openValidatedExternalUrl(url) : Promise.resolve(false); + +/** Opens a classified app link after the caller has completed confirmation. */ +export const openConfirmedAppLinkUrl = (url: string): Promise => + isAppLinkUrl(url) ? openValidatedExternalUrl(url) : Promise.resolve(false); diff --git a/packages/ui/src/stores/appLinkTrustStore.test.ts b/packages/ui/src/stores/appLinkTrustStore.test.ts new file mode 100644 index 00000000..91616e14 --- /dev/null +++ b/packages/ui/src/stores/appLinkTrustStore.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { useAppLinkTrustStore, MAX_TRUSTED_SCHEMES } from './appLinkTrustStore'; + +describe('app link trust store', () => { + beforeEach(() => { + useAppLinkTrustStore.setState({ trustedSchemes: [] }); + }); + + test('trusts a scheme with case and whitespace normalization', () => { + const store = useAppLinkTrustStore.getState(); + + store.trustScheme(' Obsidian '); + + expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['obsidian']); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('OBSIDIAN')).toBe(true); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(false); + }); + + test('re-trusting moves the scheme to the front without duplicates', () => { + const store = useAppLinkTrustStore.getState(); + store.trustScheme('obsidian'); + store.trustScheme('linear'); + store.trustScheme('obsidian'); + + expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['obsidian', 'linear']); + }); + + test('removes a trusted scheme', () => { + const store = useAppLinkTrustStore.getState(); + store.trustScheme('obsidian'); + store.trustScheme('linear'); + + useAppLinkTrustStore.getState().removeTrustedScheme('obsidian'); + + expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['linear']); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(false); + }); + + test('caps the stored scheme list', () => { + const store = useAppLinkTrustStore.getState(); + for (let index = 0; index < MAX_TRUSTED_SCHEMES + 5; index += 1) { + store.trustScheme(`scheme${index}`); + } + + const schemes = useAppLinkTrustStore.getState().trustedSchemes; + expect(schemes).toHaveLength(MAX_TRUSTED_SCHEMES); + expect(schemes[0]).toBe(`scheme${MAX_TRUSTED_SCHEMES + 4}`); + }); +}); diff --git a/packages/ui/src/stores/appLinkTrustStore.ts b/packages/ui/src/stores/appLinkTrustStore.ts new file mode 100644 index 00000000..b20de2dc --- /dev/null +++ b/packages/ui/src/stores/appLinkTrustStore.ts @@ -0,0 +1,48 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage'; + +export const MAX_TRUSTED_SCHEMES = 64; + +interface AppLinkTrustState { + /** Application deep-link schemes (obsidian, vscode, ...) the user chose to always allow. */ + trustedSchemes: string[]; + trustScheme: (scheme: string) => void; + removeTrustedScheme: (scheme: string) => void; + isSchemeTrusted: (scheme: string) => boolean; +} + +const normalizeScheme = (scheme: string): string => scheme.trim().toLowerCase(); + +/** + * Per-device trust for application deep links rendered in chat. Security + * decisions do not roam, so this persists locally through the shared safe + * storage rather than server-synced settings. + */ +export const useAppLinkTrustStore = create()( + persist( + (set, get) => ({ + trustedSchemes: [], + trustScheme: (scheme) => { + const normalized = normalizeScheme(scheme); + if (!normalized) return; + set((state) => { + const next = [normalized, ...state.trustedSchemes.filter((entry) => entry !== normalized)]; + return { trustedSchemes: next.slice(0, MAX_TRUSTED_SCHEMES) }; + }); + }, + removeTrustedScheme: (scheme) => { + const normalized = normalizeScheme(scheme); + set((state) => ({ trustedSchemes: state.trustedSchemes.filter((entry) => entry !== normalized) })); + }, + isSchemeTrusted: (scheme) => get().trustedSchemes.includes(normalizeScheme(scheme)), + }), + { + name: 'app-link-trust-store', + storage: createDeferredSafeJSONStorage(), + version: 1, + partialize: (state) => ({ trustedSchemes: state.trustedSchemes }), + }, + ), +); diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 47e08755..26c4bac6 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -12,6 +12,7 @@ - If OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). - Usage: Z.ai credit limits now appear alongside its other quota windows. - Chat: file paths in messages now open from the session's workspace, even if you last browsed files in another workspace (thanks to @tomzx). +- Chat: app links such as `spotify://` now ask for confirmation before opening another app. You can trust an app link type on one device and manage trusted links in Settings. - While a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes. - Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off. - Chat: long user messages can be expanded even when their final layout finishes after they first appear.