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.
-
-
-
-
-
- ACP command
-
-
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.
-
-
-
- MCP command
-
- 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),
+ ],
],
),
),