mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
+13
-4
@@ -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 >
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user