From cc941bbb6ecc73cd5d21a8af14707ceac85603a4 Mon Sep 17 00:00:00 2001 From: Joah Gerstenberg Date: Tue, 28 Jul 2026 22:34:48 -0500 Subject: [PATCH 01/69] feat(desktop): add developer mode display style (AGNTOPS-354) Prompt-first, terminal-style UI toggled in Settings > Appearance or Cmd/Ctrl+Shift+D. Tab cycles the composer between chat and agent modes; Enter on a fresh prompt spawns a stream channel named from the prompt, attaches the selected agent, and mentions it so it starts working there. Arrow keys navigate recent session channels. Amp-Thread-ID: https://ampcode.com/threads/T-019fabca-7cdc-777d-9ccd-ed829c1e60f0 Co-authored-by: Amp Signed-off-by: Joah Gerstenberg --- desktop/src/app/AppShell.tsx | 85 ++--- desktop/src/app/useGlobalActionShortcuts.ts | 95 ++++++ .../dev-mode/lib/displayStylePreference.ts | 78 +++++ .../dev-mode/lib/sessionNaming.test.mjs | 44 +++ .../features/dev-mode/lib/sessionNaming.ts | 25 ++ .../dev-mode/lib/useDevComposerModes.ts | 91 ++++++ .../dev-mode/lib/useDevSessionActions.ts | 102 ++++++ .../src/features/dev-mode/ui/DevModeShell.tsx | 206 ++++++++++++ .../dev-mode/ui/DevPromptComposer.tsx | 109 +++++++ .../features/dev-mode/ui/DevSessionList.tsx | 76 +++++ .../features/dev-mode/ui/DevTranscript.tsx | 302 ++++++++++++++++++ .../features/settings/ui/SettingsPanels.tsx | 41 +++ 12 files changed, 1191 insertions(+), 63 deletions(-) create mode 100644 desktop/src/app/useGlobalActionShortcuts.ts create mode 100644 desktop/src/features/dev-mode/lib/displayStylePreference.ts create mode 100644 desktop/src/features/dev-mode/lib/sessionNaming.test.mjs create mode 100644 desktop/src/features/dev-mode/lib/sessionNaming.ts create mode 100644 desktop/src/features/dev-mode/lib/useDevComposerModes.ts create mode 100644 desktop/src/features/dev-mode/lib/useDevSessionActions.ts create mode 100644 desktop/src/features/dev-mode/ui/DevModeShell.tsx create mode 100644 desktop/src/features/dev-mode/ui/DevPromptComposer.tsx create mode 100644 desktop/src/features/dev-mode/ui/DevSessionList.tsx create mode 100644 desktop/src/features/dev-mode/ui/DevTranscript.tsx diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 75f57257cc..f69cbcf86e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -13,6 +13,7 @@ import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions"; import { useChannelBrowserDialog } from "@/app/useChannelBrowserDialog"; import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts"; import { useSettingsShortcuts } from "@/app/useSettingsShortcuts"; +import { useGlobalActionShortcuts } from "@/app/useGlobalActionShortcuts"; import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications"; import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects"; import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems"; @@ -80,6 +81,7 @@ import { } from "@/features/communities/communityNavigationStorage"; import { useAddCommunityDialogState } from "@/features/communities/addCommunityPrefill"; import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"; +import { useDisplayStyle } from "@/features/dev-mode/lib/displayStylePreference"; import { relayClient } from "@/shared/api/relayClient"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal"; @@ -91,7 +93,6 @@ import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationCon import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; -import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; @@ -102,10 +103,16 @@ const LazySettingsScreen = React.lazy(async () => { return { default: module.SettingsScreen }; }); +const LazyDevModeShell = React.lazy(async () => { + const module = await import("@/features/dev-mode/ui/DevModeShell"); + return { default: module.DevModeShell }; +}); + export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); + const displayStyle = useDisplayStyle(); const communitiesHook = useCommunities(); const hasCommunityRail = communitiesHook.communities.length > 1; const addCommunityDialog = useAddCommunityDialogState(); @@ -631,69 +638,15 @@ export function AppShell() { () => setIsCreateChannelOpen(true), [], ); - React.useLayoutEffect(() => { - if (settingsOpen) { - return; - } - - function handleKeyDown(event: KeyboardEvent) { - if (!hasPrimaryShortcutModifier(event) || event.altKey || event.repeat) { - return; - } - - // A focused surface may claim the shortcut first — e.g. the composer - // consumes ⌘K to open the link editor when text is selected. Its - // element-level handler runs before this window-level bubble listener - // and calls `preventDefault()`; respect that instead of also opening - // the global dialog. - if (event.defaultPrevented) { - return; - } - - const key = event.key.toLowerCase(); - if (key === "k" && !event.shiftKey) { - event.preventDefault(); - handleOpenSearch(); - return; - } - - if (key === "k" && event.shiftKey) { - event.preventDefault(); - handleOpenNewDm(); - return; - } - - if (key === "n" && event.shiftKey) { - event.preventDefault(); - handleOpenCreateChannel(); - return; - } - - if (key === "o" && event.shiftKey) { - event.preventDefault(); - handleOpenBrowseChannels(); - return; - } - - if (key === "a" && event.shiftKey) { - event.preventDefault(); - void goHome(); - return; - } - } - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [ - handleOpenBrowseChannels, - handleOpenNewDm, - handleOpenCreateChannel, - handleOpenSearch, - goHome, + const handleGoHomeShortcut = React.useCallback(() => void goHome(), [goHome]); + useGlobalActionShortcuts({ settingsOpen, - ]); + onOpenSearch: handleOpenSearch, + onOpenNewDm: handleOpenNewDm, + onOpenCreateChannel: handleOpenCreateChannel, + onOpenBrowseChannels: handleOpenBrowseChannels, + onGoHome: handleGoHomeShortcut, + }); useSettingsShortcuts({ onClose: handleCloseSettings, onOpenSettings: handleOpenSettings, @@ -819,6 +772,12 @@ export function AppShell() { /> + ) : displayStyle === "developer" ? ( +
+ + + +
) : (
void; + onOpenNewDm: () => void; + onOpenCreateChannel: () => void; + onOpenBrowseChannels: () => void; + onGoHome: () => void; +}) { + React.useLayoutEffect(() => { + if (settingsOpen) { + return; + } + + function handleKeyDown(event: KeyboardEvent) { + if (!hasPrimaryShortcutModifier(event) || event.altKey || event.repeat) { + return; + } + + // A focused surface may claim the shortcut first — e.g. the composer + // consumes ⌘K to open the link editor when text is selected. Its + // element-level handler runs before this window-level bubble listener + // and calls `preventDefault()`; respect that instead of also opening + // the global dialog. + if (event.defaultPrevented) { + return; + } + + const key = event.key.toLowerCase(); + if (key === "k" && !event.shiftKey) { + event.preventDefault(); + onOpenSearch(); + return; + } + + if (key === "k" && event.shiftKey) { + event.preventDefault(); + onOpenNewDm(); + return; + } + + if (key === "n" && event.shiftKey) { + event.preventDefault(); + onOpenCreateChannel(); + return; + } + + if (key === "o" && event.shiftKey) { + event.preventDefault(); + onOpenBrowseChannels(); + return; + } + + if (key === "a" && event.shiftKey) { + event.preventDefault(); + onGoHome(); + return; + } + + if (key === "d" && event.shiftKey) { + event.preventDefault(); + toggleDisplayStyle(); + return; + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ + settingsOpen, + onOpenSearch, + onOpenNewDm, + onOpenCreateChannel, + onOpenBrowseChannels, + onGoHome, + ]); +} diff --git a/desktop/src/features/dev-mode/lib/displayStylePreference.ts b/desktop/src/features/dev-mode/lib/displayStylePreference.ts new file mode 100644 index 0000000000..23bf303d84 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/displayStylePreference.ts @@ -0,0 +1,78 @@ +import * as React from "react"; + +/** + * App-wide display style. + * + * - `standard` — the default sidebar + channel pane layout. + * - `developer` — a prompt-first, terminal-style surface: one composer that + * spawns a channel per prompt with an agent tagged (Tab cycles the target + * agent), plus keyboard-driven session navigation. + * + * Persisted in localStorage. Device-level UI preference, not community-scoped. + */ +export type DisplayStyle = "standard" | "developer"; + +const STORAGE_KEY = "buzz.displayStyle"; + +const DEFAULT_DISPLAY_STYLE: DisplayStyle = "standard"; + +const listeners = new Set<() => void>(); + +let displayStyle = readStoredDisplayStyle(); + +function parseDisplayStyle(value: string | null | undefined): DisplayStyle { + return value === "standard" || value === "developer" + ? value + : DEFAULT_DISPLAY_STYLE; +} + +function readStoredDisplayStyle(): DisplayStyle { + try { + return parseDisplayStyle(globalThis.localStorage?.getItem(STORAGE_KEY)); + } catch { + return DEFAULT_DISPLAY_STYLE; + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): DisplayStyle { + return displayStyle; +} + +function getServerSnapshot(): DisplayStyle { + return DEFAULT_DISPLAY_STYLE; +} + +/** Read the persisted display style outside of React. */ +export function getDisplayStyle(): DisplayStyle { + return displayStyle; +} + +/** Update the display style and notify all subscribed components. */ +export function setDisplayStyle(style: DisplayStyle): void { + displayStyle = style; + + try { + globalThis.localStorage?.setItem(STORAGE_KEY, style); + } catch { + // Persistence is best-effort; the in-memory value still applies. + } + + for (const listener of listeners) { + listener(); + } +} + +export function toggleDisplayStyle(): void { + setDisplayStyle(displayStyle === "developer" ? "standard" : "developer"); +} + +export function useDisplayStyle(): DisplayStyle { + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/desktop/src/features/dev-mode/lib/sessionNaming.test.mjs b/desktop/src/features/dev-mode/lib/sessionNaming.test.mjs new file mode 100644 index 0000000000..b4d14c251f --- /dev/null +++ b/desktop/src/features/dev-mode/lib/sessionNaming.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { slugifyPrompt } from "./sessionNaming.ts"; + +const none = new Set(); + +test("slugifyPrompt_buildsHyphenatedNameFromPromptWords", () => { + assert.equal( + slugifyPrompt("Fix the login race condition", none), + "fix-the-login-race-condition", + ); +}); + +test("slugifyPrompt_stripsPunctuationAndNormalizesCase", () => { + assert.equal( + slugifyPrompt("Why is CI broken?! (again)", none), + "why-is-ci-broken-again", + ); +}); + +test("slugifyPrompt_capsLengthAtWordBoundary", () => { + const slug = slugifyPrompt( + "one two three four five six seven eight nine ten eleven twelve", + none, + ); + assert.ok(slug.length <= 40, `expected <= 40 chars, got ${slug.length}`); + assert.ok(!slug.endsWith("-"), "must not end mid-separator"); +}); + +test("slugifyPrompt_truncatesSingleOversizedWord", () => { + const slug = slugifyPrompt("a".repeat(100), none); + assert.equal(slug, "a".repeat(40)); +}); + +test("slugifyPrompt_fallsBackForEmptyOrSymbolOnlyPrompts", () => { + assert.equal(slugifyPrompt("", none), "session"); + assert.equal(slugifyPrompt("!!! ???", none), "session"); +}); + +test("slugifyPrompt_suffixesUntilNameIsUnique", () => { + const existing = new Set(["fix-login", "fix-login-2"]); + assert.equal(slugifyPrompt("Fix login", existing), "fix-login-3"); +}); diff --git a/desktop/src/features/dev-mode/lib/sessionNaming.ts b/desktop/src/features/dev-mode/lib/sessionNaming.ts new file mode 100644 index 0000000000..fd5f4c24b8 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/sessionNaming.ts @@ -0,0 +1,25 @@ +const MAX_SLUG_LENGTH = 40; + +/** Derive a channel name from the first words of a prompt. */ +export function slugifyPrompt( + prompt: string, + existingNames: ReadonlySet, +): string { + const base = + prompt + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .trim() + .split(/\s+/) + .reduce((slug, word) => { + if (slug.length === 0) return word.slice(0, MAX_SLUG_LENGTH); + const next = `${slug}-${word}`; + return next.length > MAX_SLUG_LENGTH ? slug : next; + }, "") || "session"; + + if (!existingNames.has(base)) return base; + for (let suffix = 2; ; suffix += 1) { + const candidate = `${base}-${suffix}`; + if (!existingNames.has(candidate)) return candidate; + } +} diff --git a/desktop/src/features/dev-mode/lib/useDevComposerModes.ts b/desktop/src/features/dev-mode/lib/useDevComposerModes.ts new file mode 100644 index 0000000000..ad3b5ce505 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/useDevComposerModes.ts @@ -0,0 +1,91 @@ +import * as React from "react"; + +import { + getSharedChannelIds, + relayAgentIsSharedWithUser, +} from "@/features/agents/lib/agentAutocompleteEligibility"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type DevAgentTarget = { + pubkey: string; + name: string; + source: "managed" | "relay"; + /** Present for managed agents; needed to attach + start them. */ + managedAgent?: ManagedAgent; +}; + +/** + * The composer target the user cycles through with Tab: plain human chat, or + * one of the agents that can be tagged into a new session channel. + */ +export type DevComposerMode = + | { kind: "chat" } + | { kind: "agent"; target: DevAgentTarget }; + +export function devComposerModeLabel(mode: DevComposerMode): string { + return mode.kind === "chat" ? "chat" : `@${mode.target.name}`; +} + +/** + * Global (channel-independent) list of composer modes. Managed agents are + * always available — attaching one to a fresh channel is part of the send + * flow. Relay agents qualify under the same rules as mention autocomplete, + * minus the channel-membership requirement. + */ +export function useDevComposerModes(): DevComposerMode[] { + const identityQuery = useIdentityQuery(); + const channelsQuery = useChannelsQuery(); + const managedAgentsQuery = useManagedAgentsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); + + const currentPubkey = identityQuery.data?.pubkey ?? null; + const channels = channelsQuery.data; + const managedAgents = managedAgentsQuery.data; + const relayAgents = relayAgentsQuery.data; + + return React.useMemo(() => { + const targets = new Map(); + + for (const agent of managedAgents ?? []) { + const pubkey = normalizePubkey(agent.pubkey); + targets.set(pubkey, { + pubkey: agent.pubkey, + name: agent.name, + source: "managed", + managedAgent: agent, + }); + } + + const sharedChannelIds = getSharedChannelIds(channels); + for (const agent of relayAgents ?? []) { + const pubkey = normalizePubkey(agent.pubkey); + if (targets.has(pubkey)) continue; + if (!relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) { + continue; + } + targets.set(pubkey, { + pubkey: agent.pubkey, + name: agent.name, + source: "relay", + }); + } + + const agentModes = [...targets.values()] + .sort((left, right) => left.name.localeCompare(right.name)) + .map( + (target): DevComposerMode => ({ + kind: "agent", + target, + }), + ); + + return [{ kind: "chat" } satisfies DevComposerMode, ...agentModes]; + }, [channels, currentPubkey, managedAgents, relayAgents]); +} diff --git a/desktop/src/features/dev-mode/lib/useDevSessionActions.ts b/desktop/src/features/dev-mode/lib/useDevSessionActions.ts new file mode 100644 index 0000000000..943695b8fb --- /dev/null +++ b/desktop/src/features/dev-mode/lib/useDevSessionActions.ts @@ -0,0 +1,102 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { attachManagedAgentToChannel } from "@/features/agents/channelAgents"; +import { + channelsQueryKey, + useCreateChannelMutation, +} from "@/features/channels/hooks"; +import { useSendMessageMutation } from "@/features/messages/hooks"; +import { addChannelMembers } from "@/shared/api/tauri"; +import type { Channel, Identity } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { slugifyPrompt } from "@/features/dev-mode/lib/sessionNaming"; +import type { + DevAgentTarget, + DevComposerMode, +} from "@/features/dev-mode/lib/useDevComposerModes"; + +async function ensureAgentInChannel(channelId: string, target: DevAgentTarget) { + if (target.source === "managed" && target.managedAgent) { + await attachManagedAgentToChannel(channelId, { + agent: target.managedAgent, + }); + return; + } + + const result = await addChannelMembers({ + channelId, + pubkeys: [target.pubkey], + role: "bot", + }); + const failure = result.errors.find( + (error) => normalizePubkey(error.pubkey) === normalizePubkey(target.pubkey), + ); + if (failure) { + throw new Error(failure.error); + } +} + +export function useDevSessionActions(identity: Identity | undefined) { + const queryClient = useQueryClient(); + const createChannelMutation = useCreateChannelMutation(); + const sendMessageMutation = useSendMessageMutation(null, identity); + + /** + * Create the channel for a new session, named and described from the + * prompt. Creation is separate from the first send so a failure after this + * point leaves an open, recoverable session instead of a duplicate channel + * on retry. + */ + const createSessionChannel = React.useCallback( + async (prompt: string): Promise => { + const existingNames = new Set( + (queryClient.getQueryData(channelsQueryKey) ?? []).map( + (channel) => channel.name, + ), + ); + + return createChannelMutation.mutateAsync({ + name: slugifyPrompt(prompt, existingNames), + channelType: "stream", + visibility: "open", + description: prompt.length > 140 ? `${prompt.slice(0, 139)}…` : prompt, + }); + }, + [createChannelMutation, queryClient], + ); + + /** + * Send a prompt into a session. In an agent mode, the agent is attached + * first when it is not yet a member (membership must land before the + * mention or the harness filter drops it) — agents are not limited to a + * single channel. + */ + const sendToSession = React.useCallback( + async (channel: Channel, prompt: string, mode: DevComposerMode) => { + if (mode.kind === "agent") { + const isMember = channel.memberPubkeys.some( + (pubkey) => + normalizePubkey(pubkey) === normalizePubkey(mode.target.pubkey), + ); + if (!isMember) { + await ensureAgentInChannel(channel.id, mode.target); + } + } + + await sendMessageMutation.mutateAsync({ + targetChannel: channel, + content: prompt, + mentionPubkeys: + mode.kind === "agent" ? [mode.target.pubkey] : undefined, + }); + }, + [sendMessageMutation], + ); + + return { + createSessionChannel, + sendToSession, + isCreatingChannel: createChannelMutation.isPending, + }; +} diff --git a/desktop/src/features/dev-mode/ui/DevModeShell.tsx b/desktop/src/features/dev-mode/ui/DevModeShell.tsx new file mode 100644 index 0000000000..7050a74a63 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevModeShell.tsx @@ -0,0 +1,206 @@ +import * as React from "react"; + +import { useChannelsQuery } from "@/features/channels/hooks"; +import { setDisplayStyle } from "@/features/dev-mode/lib/displayStylePreference"; +import { + devComposerModeLabel, + useDevComposerModes, + type DevComposerMode, +} from "@/features/dev-mode/lib/useDevComposerModes"; +import { useDevSessionActions } from "@/features/dev-mode/lib/useDevSessionActions"; +import { DevPromptComposer } from "@/features/dev-mode/ui/DevPromptComposer"; +import { DevSessionList } from "@/features/dev-mode/ui/DevSessionList"; +import { DevTranscript } from "@/features/dev-mode/ui/DevTranscript"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +const SESSION_LIST_LIMIT = 20; + +/** + * Stable identity for the cycled mode. Selection is keyed rather than + * indexed so agent list refreshes cannot silently retarget the next prompt + * at a different agent; a vanished agent falls back to chat. + */ +function devComposerModeKey(mode: DevComposerMode): string { + return mode.kind === "chat" ? "chat" : normalizePubkey(mode.target.pubkey); +} + +export function DevModeShell() { + const identityQuery = useIdentityQuery(); + const channelsQuery = useChannelsQuery(); + const modes = useDevComposerModes(); + const { createSessionChannel, sendToSession } = useDevSessionActions( + identityQuery.data, + ); + + const [input, setInput] = React.useState(""); + const [modeKey, setModeKey] = React.useState("chat"); + const [activeSessionId, setActiveSessionId] = React.useState( + null, + ); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + + const foundModeIndex = modes.findIndex( + (candidate) => devComposerModeKey(candidate) === modeKey, + ); + const modeIndex = foundModeIndex === -1 ? 0 : foundModeIndex; + const mode = modes[modeIndex]; + + const sessions = React.useMemo(() => { + const streams = (channelsQuery.data ?? []).filter( + (channel) => + channel.channelType === "stream" && + channel.isMember && + channel.archivedAt === null, + ); + streams.sort((left, right) => + (left.lastMessageAt ?? "").localeCompare(right.lastMessageAt ?? ""), + ); + return streams.slice(-SESSION_LIST_LIMIT); + }, [channelsQuery.data]); + + const activeChannel = + (channelsQuery.data ?? []).find( + (channel) => channel.id === activeSessionId, + ) ?? null; + // A stored id whose channel vanished (or is still propagating) renders as + // the fresh-session state; navigation starts from what is actually shown. + const effectiveSessionId = activeChannel?.id ?? null; + + const handleCycleMode = React.useCallback( + (direction: 1 | -1) => { + if (modes.length === 0) return; + const currentIndex = modes.findIndex( + (candidate) => devComposerModeKey(candidate) === modeKey, + ); + const baseIndex = currentIndex === -1 ? 0 : currentIndex; + const nextIndex = (baseIndex + direction + modes.length) % modes.length; + setModeKey(devComposerModeKey(modes[nextIndex])); + }, + [modeKey, modes], + ); + + const handleNavigateSessions = React.useCallback( + (direction: 1 | -1) => { + if (sessions.length === 0) return; + const currentIndex = sessions.findIndex( + (session) => session.id === effectiveSessionId, + ); + if (currentIndex === -1) { + // From the fresh-prompt state, ArrowUp enters the list at the newest + // session; ArrowDown stays on the fresh prompt. + if (direction === -1) { + setActiveSessionId(sessions[sessions.length - 1].id); + } + return; + } + const nextIndex = currentIndex + direction; + if (nextIndex >= sessions.length) { + setActiveSessionId(null); + return; + } + setActiveSessionId(sessions[Math.max(0, nextIndex)].id); + }, + [effectiveSessionId, sessions], + ); + + const handleSubmit = React.useCallback(() => { + const prompt = input.trim(); + if (!prompt || busy || !mode) return; + + setBusy(true); + setError(null); + setInput(""); + void (async () => { + try { + let channel = activeChannel; + if (!channel) { + channel = await createSessionChannel(prompt); + setActiveSessionId(channel.id); + } + await sendToSession(channel, prompt, mode); + } catch (submitError) { + setError( + submitError instanceof Error + ? submitError.message + : "Failed to send prompt.", + ); + // Restore the failed prompt unless the user already typed on. + setInput((current) => (current === "" ? prompt : current)); + } finally { + setBusy(false); + } + })(); + }, [activeChannel, busy, createSessionChannel, input, mode, sendToSession]); + + const placeholder = activeChannel + ? mode?.kind === "agent" + ? `Message # ${activeChannel.name} and put ${devComposerModeLabel(mode)} to work…` + : `Message # ${activeChannel.name}…` + : mode?.kind === "agent" + ? `Prompt ${devComposerModeLabel(mode)} — spawns a new channel where it works…` + : "Start a discussion — spawns a new channel for humans…"; + + return ( +
+
+ buzz · developer mode + +
+ + + + {activeChannel ? ( + + ) : ( +
+
+
new session
+
+ Type a prompt and hit enter — it spawns a channel and puts the + selected target to work. Tab cycles between chat and{" "} + {modes.length - 1} agent{modes.length === 2 ? "" : "s"}. +
+
+
+ )} + + {error ? ( +
+ {error} +
+ ) : null} + + {mode ? ( + setActiveSessionId(null)} + onNavigateSessions={handleNavigateSessions} + onSubmit={handleSubmit} + placeholder={placeholder} + value={input} + /> + ) : null} +
+ ); +} diff --git a/desktop/src/features/dev-mode/ui/DevPromptComposer.tsx b/desktop/src/features/dev-mode/ui/DevPromptComposer.tsx new file mode 100644 index 0000000000..90fdc447c3 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevPromptComposer.tsx @@ -0,0 +1,109 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { + devComposerModeLabel, + type DevComposerMode, +} from "@/features/dev-mode/lib/useDevComposerModes"; + +type DevPromptComposerProps = { + value: string; + mode: DevComposerMode; + placeholder: string; + busy: boolean; + onChange: (value: string) => void; + onSubmit: () => void; + /** Tab / Shift+Tab. */ + onCycleMode: (direction: 1 | -1) => void; + /** ArrowUp / ArrowDown while the input is empty. */ + onNavigateSessions: (direction: 1 | -1) => void; + onEscape: () => void; +}; + +export function DevPromptComposer({ + value, + mode, + placeholder, + busy, + onChange, + onSubmit, + onCycleMode, + onNavigateSessions, + onEscape, +}: DevPromptComposerProps) { + const textareaRef = React.useRef(null); + + React.useEffect(() => { + textareaRef.current?.focus(); + }, []); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Tab") { + event.preventDefault(); + onCycleMode(event.shiftKey ? -1 : 1); + return; + } + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + if (!busy) onSubmit(); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + onEscape(); + return; + } + + if ((event.key === "ArrowUp" || event.key === "ArrowDown") && !value) { + event.preventDefault(); + onNavigateSessions(event.key === "ArrowUp" ? -1 : 1); + } + }; + + const rowCount = Math.min(value.split("\n").length, 8); + + return ( +
+
+ + ⏵ + +