fix: bug sweep — profile, mentions, deep links, channel links, doctor page (#480)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-05 12:08:22 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent ea62664759
commit 89f4b5de07
7 changed files with 126 additions and 284 deletions
+13 -4
View File
@@ -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
@@ -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 (
<div
className="flex items-start gap-3 rounded-xl border border-border/70 bg-background/80 px-4 py-3"
data-testid={`doctor-check-${id}`}
>
<div className="mt-0.5 shrink-0">
<StatusIcon available={isAvailable} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold tracking-tight">{label}</p>
<code className="rounded bg-muted px-1.5 py-0.5 text-[11px]">
{command}
</code>
</div>
<p className="mt-1 text-sm text-muted-foreground">{purpose}</p>
<p
className={cn(
"mt-2 text-xs",
isAvailable ? "text-muted-foreground" : "text-warning",
)}
>
{availability?.resolvedPath
? `Available via ${describeResolvedCommand(command, availability.resolvedPath)}`
: isLoading
? "Checking for a matching binary..."
: "Not currently available."}
</p>
{availability?.resolvedPath ? (
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/80">
{availability.resolvedPath}
</p>
) : null}
</div>
</div>
);
}
function ProviderRow({
command,
defaultArgs,
@@ -127,65 +64,10 @@ function ProviderRow({
);
}
function SetupHelpCard() {
return (
<div className="rounded-xl border border-border/70 bg-muted/20 p-4">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold tracking-tight">Setup help</h3>
</div>
<div className="mt-3 space-y-3 text-sm text-muted-foreground">
<p>
Build the local Sprout tools with{" "}
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-[12px]">
cargo build --release --workspace
</code>{" "}
when you want the desktop app to spawn ACP harnesses from this
checkout.
</p>
<p>
If you keep binaries outside your PATH, use the custom ACP and MCP
commands below and then copy those same values into Create agent &gt;
Advanced setup.
</p>
<p>
ACP runtimes like Goose or Codex are optional. They appear
automatically once their commands are installed on your PATH.
</p>
</div>
</div>
);
}
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 (
<section className="space-y-5" data-testid="settings-doctor">
@@ -196,8 +78,7 @@ export function DoctorSettingsPanel() {
<h2 className="text-sm font-semibold tracking-tight">Doctor</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Verify the local Sprout tools and ACP runtime commands used by the
desktop app.
Verify the ACP runtime commands available to the desktop app.
</p>
</div>
@@ -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() {
</Button>
</div>
<div className="mt-5 grid gap-4">
<div className="space-y-4">
<div className="rounded-xl border border-border/70 bg-muted/20 p-4">
<div className="flex items-center gap-2">
<TerminalSquare className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold tracking-tight">
Local Sprout binaries
</h3>
</div>
<p className="mt-1 text-sm text-muted-foreground">
These checks replace the old binary status card from Create agent.
</p>
<div className="mt-5 space-y-4">
<div className="rounded-xl border border-border/70 bg-muted/20 p-4">
<h3 className="text-sm font-semibold tracking-tight">ACP runtimes</h3>
<p className="mt-1 text-sm text-muted-foreground">
Installed runtimes that the desktop app can offer in Create agent.
</p>
<div className="mt-4 space-y-2">
{toolChecks.map((check) => (
<CommandCheckRow
availability={check.availability}
id={check.id}
isLoading={prereqsQuery.isLoading}
key={check.id}
label={check.label}
purpose={check.purpose}
/>
))}
</div>
{hasMissingSproutTools ? (
<p className="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-warning">
Build the workspace binaries with{" "}
<code className="font-mono">
cargo build --release --workspace
</code>{" "}
or point agent creation at custom ACP and MCP commands.
<div className="mt-4 space-y-2">
{providersQuery.isLoading ? (
<p className="text-sm text-muted-foreground">
Looking for installed ACP runtimes...
</p>
) : null}
{prereqsQuery.error instanceof Error ? (
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{prereqsQuery.error.message}
</p>
) : null}
</div>
<div className="rounded-xl border border-border/70 bg-muted/20 p-4">
<h3 className="text-sm font-semibold tracking-tight">
Custom harness commands
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Verify non-default ACP or MCP binaries before using them in agent
creation.
</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="doctor-acp">
ACP command
</label>
<Input
data-testid="doctor-acp-command"
id="doctor-acp"
onChange={(event) => setAcpCommand(event.target.value)}
value={acpCommand}
) : providers.length > 0 ? (
providers.map((provider) => (
<ProviderRow
command={provider.command}
defaultArgs={provider.defaultArgs}
key={provider.id}
label={provider.label}
providerId={provider.id}
resolvedPath={provider.binaryPath}
/>
))
) : (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-warning">
No known ACP runtime was detected on your PATH yet. You can
still use a custom command in Create agent.
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="doctor-mcp">
MCP command
</label>
<Input
data-testid="doctor-mcp-command"
id="doctor-mcp"
onChange={(event) => setMcpCommand(event.target.value)}
value={mcpCommand}
/>
</div>
</div>
)}
</div>
</div>
<div className="space-y-4">
<div className="rounded-xl border border-border/70 bg-muted/20 p-4">
<h3 className="text-sm font-semibold tracking-tight">
ACP runtimes
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Installed runtimes that the desktop app can offer in Create agent.
{providersQuery.error instanceof Error ? (
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{providersQuery.error.message}
</p>
<div className="mt-4 space-y-2">
{providersQuery.isLoading ? (
<p className="text-sm text-muted-foreground">
Looking for installed ACP runtimes...
</p>
) : providers.length > 0 ? (
providers.map((provider) => (
<ProviderRow
command={provider.command}
defaultArgs={provider.defaultArgs}
key={provider.id}
label={provider.label}
providerId={provider.id}
resolvedPath={provider.binaryPath}
/>
))
) : (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-warning">
No known ACP runtime was detected on your PATH yet. You can
still use a custom command in Create agent.
</div>
)}
</div>
{providersQuery.error instanceof Error ? (
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{providersQuery.error.message}
</p>
) : null}
</div>
<SetupHelpCard />
) : null}
</div>
</div>
</section>
@@ -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<Pick<Workspace, "name" | "relayUrl" | "token" | "pubkey">>,
@@ -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,
};
}
+4
View File
@@ -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<UnlistenFn> {
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}`);
});
}
-6
View File
@@ -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",
);
@@ -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();
@@ -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<void>(
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<void>(
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),
],
],
),
),