From 89f4b5de07dc86d91135bcde6901373bd56c813a Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 5 May 2026 13:08:22 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20bug=20sweep=20=E2=80=94=20profile,=20men?= =?UTF-8?q?tions,=20deep=20links,=20channel=20links,=20doctor=20page=20(#4?= =?UTF-8?q?80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.6 --- desktop/src/app/App.tsx | 17 +- .../settings/ui/DoctorSettingsPanel.tsx | 269 +++--------------- .../src/features/workspaces/useWorkspaces.tsx | 11 +- desktop/src/shared/deep-link.ts | 4 + desktop/tests/e2e/profile.spec.ts | 6 - .../features/channels/message_content.dart | 38 ++- .../features/profile/user_profile_sheet.dart | 65 +++-- 7 files changed, 126 insertions(+), 284 deletions(-) diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index a5b4dce12..7316b010b 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -57,15 +57,24 @@ export function App() { }, []); const queryClient = useQueryClient(); - const { activeWorkspace, reinitKey, addWorkspace, switchWorkspace } = - useWorkspaces(); + const { + activeWorkspace, + reinitKey, + addWorkspace, + switchWorkspace, + reconnectWorkspace, + } = useWorkspaces(); useEffect(() => { - const unlisten = listenForDeepLinks({ addWorkspace, switchWorkspace }); + const unlisten = listenForDeepLinks({ + addWorkspace, + switchWorkspace, + reconnectWorkspace, + }); return () => { void unlisten.then((fn) => fn()); }; - }, [addWorkspace, switchWorkspace]); + }, [addWorkspace, switchWorkspace, reconnectWorkspace]); const workspace = useWorkspaceInit(activeWorkspace); // Composite key: changes when workspace ID changes OR when diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 39ab7f3e0..b3e99d363 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -2,21 +2,13 @@ import { AlertTriangle, CheckCircle2, RefreshCw, - Sparkles, Stethoscope, - TerminalSquare, } from "lucide-react"; -import * as React from "react"; -import { - useAcpProvidersQuery, - useManagedAgentPrereqsQuery, -} from "@/features/agents/hooks"; +import { useAcpProvidersQuery } from "@/features/agents/hooks"; import { describeResolvedCommand } from "@/features/agents/ui/agentUi"; -import type { CommandAvailability } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; function StatusIcon({ available }: { available: boolean }) { return available ? ( @@ -26,61 +18,6 @@ function StatusIcon({ available }: { available: boolean }) { ); } -function CommandCheckRow({ - availability, - id, - isLoading, - label, - purpose, -}: { - availability: CommandAvailability | null; - id: string; - isLoading: boolean; - label: string; - purpose: string; -}) { - const command = availability?.command ?? "Unavailable"; - const isAvailable = availability?.available ?? false; - - return ( -
-
- -
- -
-
-

{label}

- - {command} - -
-

{purpose}

-

- {availability?.resolvedPath - ? `Available via ${describeResolvedCommand(command, availability.resolvedPath)}` - : isLoading - ? "Checking for a matching binary..." - : "Not currently available."} -

- {availability?.resolvedPath ? ( -

- {availability.resolvedPath} -

- ) : null} -
-
- ); -} - function ProviderRow({ command, defaultArgs, @@ -127,65 +64,10 @@ function ProviderRow({ ); } -function SetupHelpCard() { - return ( -
-
- -

Setup help

-
- -
-

- Build the local Sprout tools with{" "} - - cargo build --release --workspace - {" "} - when you want the desktop app to spawn ACP harnesses from this - checkout. -

-

- If you keep binaries outside your PATH, use the custom ACP and MCP - commands below and then copy those same values into Create agent > - Advanced setup. -

-

- ACP runtimes like Goose or Codex are optional. They appear - automatically once their commands are installed on your PATH. -

-
-
- ); -} - export function DoctorSettingsPanel() { - const [acpCommand, setAcpCommand] = React.useState("sprout-acp"); - const [mcpCommand, setMcpCommand] = React.useState("sprout-mcp-server"); const providersQuery = useAcpProvidersQuery(); - const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand); - const prereqs = prereqsQuery.data ?? null; const providers = providersQuery.data ?? []; - const isRefreshing = providersQuery.isFetching || prereqsQuery.isFetching; - - const toolChecks = [ - { - id: "acp", - label: "ACP harness", - purpose: - "Desktop launches this command to bridge a local runtime into ACP.", - availability: prereqs?.acp ?? null, - }, - { - id: "mcp", - label: "MCP server", - purpose: - "Desktop uses this server when the ACP harness requests Sprout tools.", - availability: prereqs?.mcp ?? null, - }, - ]; - - const hasMissingSproutTools = - prereqs !== null && (!prereqs.acp.available || !prereqs.mcp.available); + const isRefreshing = providersQuery.isFetching; return (
@@ -196,8 +78,7 @@ export function DoctorSettingsPanel() {

Doctor

- Verify the local Sprout tools and ACP runtime commands used by the - desktop app. + Verify the ACP runtime commands available to the desktop app.

@@ -206,7 +87,6 @@ export function DoctorSettingsPanel() { disabled={isRefreshing} onClick={() => { void providersQuery.refetch(); - void prereqsQuery.refetch(); }} size="sm" type="button" @@ -219,127 +99,42 @@ export function DoctorSettingsPanel() { -
-
-
-
- -

- Local Sprout binaries -

-
-

- These checks replace the old binary status card from Create agent. -

+
+
+

ACP runtimes

+

+ Installed runtimes that the desktop app can offer in Create agent. +

-
- {toolChecks.map((check) => ( - - ))} -
- - {hasMissingSproutTools ? ( -

- Build the workspace binaries with{" "} - - cargo build --release --workspace - {" "} - or point agent creation at custom ACP and MCP commands. +

+ {providersQuery.isLoading ? ( +

+ Looking for installed ACP runtimes...

- ) : null} - - {prereqsQuery.error instanceof Error ? ( -

- {prereqsQuery.error.message} -

- ) : null} -
- -
-

- Custom harness commands -

-

- Verify non-default ACP or MCP binaries before using them in agent - creation. -

- -
-
- - setAcpCommand(event.target.value)} - value={acpCommand} + ) : providers.length > 0 ? ( + providers.map((provider) => ( + + )) + ) : ( +
+ No known ACP runtime was detected on your PATH yet. You can + still use a custom command in Create agent.
- -
- - setMcpCommand(event.target.value)} - value={mcpCommand} - /> -
-
+ )}
-
-
-
-

- ACP runtimes -

-

- Installed runtimes that the desktop app can offer in Create agent. + {providersQuery.error instanceof Error ? ( +

+ {providersQuery.error.message}

- -
- {providersQuery.isLoading ? ( -

- Looking for installed ACP runtimes... -

- ) : providers.length > 0 ? ( - providers.map((provider) => ( - - )) - ) : ( -
- No known ACP runtime was detected on your PATH yet. You can - still use a custom command in Create agent. -
- )} -
- - {providersQuery.error instanceof Error ? ( -

- {providersQuery.error.message} -

- ) : null} -
- - + ) : null}
diff --git a/desktop/src/features/workspaces/useWorkspaces.tsx b/desktop/src/features/workspaces/useWorkspaces.tsx index a7023cf6a..eae296877 100644 --- a/desktop/src/features/workspaces/useWorkspaces.tsx +++ b/desktop/src/features/workspaces/useWorkspaces.tsx @@ -25,6 +25,8 @@ export type UseWorkspacesReturn = { addWorkspace: (workspace: Workspace) => string; removeWorkspace: (id: string) => void; switchWorkspace: (id: string) => void; + /** Force the active workspace to re-init (e.g. after a deep-link reconnect). */ + reconnectWorkspace: () => void; updateWorkspace: ( id: string, updates: Partial>, @@ -117,15 +119,17 @@ function useWorkspacesInternal(): UseWorkspacesReturn { const switchWorkspace = useCallback( (id: string) => { - if (id === activeId) { - return; - } + if (id === activeId) return; saveActiveWorkspaceId(id); setActiveId(id); }, [activeId], ); + const reconnectWorkspace = useCallback(() => { + setReinitKey((k) => k + 1); + }, []); + const updateWorkspace = useCallback( ( id: string, @@ -164,6 +168,7 @@ function useWorkspacesInternal(): UseWorkspacesReturn { addWorkspace, removeWorkspace, switchWorkspace, + reconnectWorkspace, updateWorkspace, }; } diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index c02cde5a3..97fc24248 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -10,6 +10,7 @@ import { export interface DeepLinkDeps { addWorkspace: (workspace: Workspace) => string; switchWorkspace: (id: string) => void; + reconnectWorkspace: () => void; } /** @@ -30,6 +31,9 @@ export function listenForDeepLinks(deps: DeepLinkDeps): Promise { addedAt: new Date().toISOString(), }); deps.switchWorkspace(id); + // If addWorkspace returned the already-active workspace (same relay URL), + // switchWorkspace is a no-op — force re-init so the connection refreshes. + deps.reconnectWorkspace(); toast.success(`Connected to ${name}`); }); } diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 921e712fe..23b1127f9 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -393,12 +393,6 @@ test("shows doctor checks for local sprout tooling", async ({ page }) => { await openSettings(page, "doctor"); await expect(page.getByTestId("settings-doctor")).toBeVisible(); - await expect(page.getByTestId("doctor-check-acp")).toContainText( - "sprout-acp", - ); - await expect(page.getByTestId("doctor-check-mcp")).toContainText( - "sprout-mcp-server", - ); await expect(page.getByTestId("doctor-provider-goose")).toContainText( "Goose", ); diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 9801dd114..57d1cf2ac 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -84,8 +84,40 @@ class MessageContent extends StatelessWidget { } final processed = buffer.toString(); + // Replace spaces with non-breaking spaces inside known mention names + // so the gpt_markdown combined regex can match multi-word names + // even when caseSensitive is not preserved. + // Skip content inside backticks to avoid altering inline code. + final mentionParts = processed.split('`'); + final mentionBuf = StringBuffer(); + for (var i = 0; i < mentionParts.length; i++) { + if (i.isOdd) { + mentionBuf.write('`${mentionParts[i]}`'); + } else { + var segment = mentionParts[i]; + for (final name in mentionNames.values) { + if (name.contains(' ')) { + final nbspName = name.replaceAll(' ', '\u00A0'); + segment = segment.replaceAllMapped( + RegExp('@${RegExp.escape(name)}', caseSensitive: false), + (m) => '@$nbspName', + ); + } + } + mentionBuf.write(segment); + } + } + final mentionProcessed = mentionBuf.toString(); + + // Ensure channel links at the very start of content don't get + // swallowed by markdown processing. + var finalContent = mentionProcessed; + if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(finalContent)) { + finalContent = '\u200B$finalContent'; + } + return GptMarkdown( - processed, + finalContent, style: style, followLinkColor: false, linkBuilder: (context, linkText, url, linkStyle) => @@ -416,7 +448,7 @@ class _MentionMd extends InlineMd { late final RegExp _exp = _buildPrefixPattern( prefix: '@', knownNames: _mentionAliases(mentionNames.values), - genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*', + genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_\u00A0-]*', ); _MentionMd({required this.mentionNames}); @@ -435,7 +467,7 @@ class _MentionMd extends InlineMd { return TextSpan(text: text, style: config.style); } - final name = raw.substring(1).toLowerCase(); + final name = raw.substring(1).replaceAll('\u00A0', ' ').toLowerCase(); String? displayName; for (final entry in mentionNames.entries) { final entryName = entry.value.toLowerCase(); diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 6807d485b..27fe5cc27 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -31,6 +31,7 @@ class UserProfileSheet extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final pk = pubkey.toLowerCase(); + final currentPubkey = ref.watch(currentPubkeyProvider); // Watch cached profile, presence, and user status. final profile = @@ -208,40 +209,42 @@ class UserProfileSheet extends HookConsumerWidget { const SizedBox(height: Grid.xs), - // Action button — Message - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () async { - Navigator.of(context).pop(); - try { - final channel = await ref - .read(channelActionsProvider) - .openDm(pubkeys: [pk]); - if (!context.mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), - ), - ); - } catch (_) { - // Silently fail — user tapped but DM open failed. - } - }, - icon: const Icon(LucideIcons.messageSquare, size: 18), - label: const Text('Message'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - vertical: Grid.twelve, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.lg), + // Action button — Message (hidden on own profile) + if (pk != currentPubkey) ...[ + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () async { + Navigator.of(context).pop(); + try { + final channel = await ref + .read(channelActionsProvider) + .openDm(pubkeys: [pk]); + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ChannelDetailPage(channel: channel), + ), + ); + } catch (_) { + // Silently fail — user tapped but DM open failed. + } + }, + icon: const Icon(LucideIcons.messageSquare, size: 18), + label: const Text('Message'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: Grid.twelve, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.lg), + ), ), ), ), - ), - - const SizedBox(height: Grid.xxs), + const SizedBox(height: Grid.xxs), + ], ], ), ),