Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"test": "node scripts/generate-pwa-icons.mjs --check && vitest run --config vitest.config.ts"
},
"dependencies": {
"@bb/client-core": "workspace:*",
"@bb/config": "workspace:*",
"@bb/core-ui": "workspace:*",
"@bb/desktop-contract": "workspace:*",
Expand Down
31 changes: 7 additions & 24 deletions apps/app/src/components/promptbox/FollowUpPromptBox.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { FollowUpSubmitMode } from "@bb/client-core";
import {
memo,
useCallback,
Expand Down Expand Up @@ -135,30 +136,12 @@ function isKeyboardFocusTarget(target: EventTarget | null): boolean {
target instanceof HTMLSelectElement)
);
}
/**
* Discriminated state for the composer's submit affordances. Replaces the
* previous canSendFollowUp / canQueueFollowUp / canStopRuntime / onStop
* boolean soup. The caller computes one of these from runtimeDisplayStatus +
* pending-interaction state and passes it down; the composer reads .kind to
* render submit/queue/stop affordances.
*/
export type FollowUpBlockedReason =
| "loading-execution-options"
| "loading-pending-interactions"
| "pending-interaction"
| "provisioning"
| "stopping"
| "unavailable";

export type FollowUpSubmitMode =
/** Idle thread — submit creates a new turn; no stop affordance. */
| { kind: "ready" }
/** Runtime is active or host-reconnecting — submit queues the message; stop the runtime. */
| { kind: "queue"; onStop: () => void }
/** Runtime is pre-start or waiting on the host — can't send/queue, but can stop. */
| { kind: "stop-only"; onStop: () => void }
/** Can't submit and can't stop — show why. */
| { kind: "blocked"; reason: FollowUpBlockedReason };
// The submit-mode discriminated union lives in @bb/client-core so the shared
// submission policy and the native composer read the same shape.
export type {
FollowUpBlockedReason,
FollowUpSubmitMode,
} from "@bb/client-core";

export interface FollowUpComposerProps {
history: HistoryConfig;
Expand Down
83 changes: 12 additions & 71 deletions apps/app/src/components/promptbox/effective-prompt-mode.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,12 @@
import {
promptInputHasCommandMention,
type ThreadTimelineActivePromptMode,
type PromptTextMention,
} from "@bb/domain";

export interface PromptModeInput {
mentionRanges: readonly PromptTextMention[];
providerId: string | undefined;
value: string;
}

export interface PermissionDisplayOverride {
label: string;
compactLabel?: string;
description?: string;
title?: string;
}

const CLAUDE_PLAN_PERMISSION_DISPLAY: PermissionDisplayOverride = {
label: "Plan Mode",
compactLabel: "Plan",
description: "Claude Code will plan without normal full-access execution.",
};

export function isClaudePlanModePrompt({
mentionRanges,
providerId,
value,
}: PromptModeInput): boolean {
return (
providerId === "claude-code" &&
promptInputHasCommandMention(
[{ type: "text", text: value, mentions: [...mentionRanges] }],
{ trigger: "/", name: "plan" },
)
);
}

export function permissionDisplayForPromptMode(
args: PromptModeInput,
): PermissionDisplayOverride | undefined {
if (!isClaudePlanModePrompt(args)) {
return undefined;
}
return CLAUDE_PLAN_PERMISSION_DISPLAY;
}

export function permissionDisplayForActivePromptMode(
activePromptMode: ThreadTimelineActivePromptMode | null | undefined,
): PermissionDisplayOverride | undefined {
if (
activePromptMode?.mode === "plan" &&
activePromptMode.providerId === "claude-code"
) {
return CLAUDE_PLAN_PERMISSION_DISPLAY;
}
return undefined;
}

export function shouldDisablePermissionPickerForPromptMode(
args: PromptModeInput,
): boolean {
return isClaudePlanModePrompt(args);
}

export function shouldDisablePermissionPickerForActivePromptMode(
activePromptMode: ThreadTimelineActivePromptMode | null | undefined,
): boolean {
return activePromptMode?.mode === "plan";
}
// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving.
export {
isClaudePlanModePrompt,
permissionDisplayForPromptMode,
permissionDisplayForActivePromptMode,
shouldDisablePermissionPickerForPromptMode,
shouldDisablePermissionPickerForActivePromptMode,
} from "@bb/client-core";
export type {
PromptModeInput,
PermissionDisplayOverride,
} from "@bb/client-core";
83 changes: 11 additions & 72 deletions apps/app/src/components/promptbox/mentions/command-trigger.ts
Original file line number Diff line number Diff line change
@@ -1,72 +1,11 @@
import type {
ProviderComposerCommand,
PromptMentionCommandTrigger,
ProviderComposerAction,
} from "@bb/domain";

export type ProviderPromptActionCommand = ProviderComposerCommand;

export interface ProviderPromptAction {
kind: "goal" | "plan" | "skills";
text: string;
command?: ProviderPromptActionCommand;
}

export interface ProviderPromptActionProps {
skillsTrigger: PromptMentionCommandTrigger | null;
promptActions: readonly ProviderPromptAction[];
}

/**
* Maps provider-owned composer metadata into the prompt action shape consumed
* by app hosts.
*/
export function buildProviderPromptActionProps(
composerActions: readonly ProviderComposerAction[],
): ProviderPromptActionProps {
const promptActions: ProviderPromptAction[] = [];
let skillsTrigger: PromptMentionCommandTrigger | null = null;

for (const action of composerActions) {
switch (action.kind) {
case "skills":
skillsTrigger = action.trigger;
promptActions.push({
kind: action.kind,
text: action.trigger,
});
break;
case "goal":
case "plan":
promptActions.push({
kind: action.kind,
command: action.command,
text: serializedProviderCommand(action.command),
});
break;
}
}

return { skillsTrigger, promptActions };
}

export function serializedProviderCommand(
command: ProviderComposerCommand,
): string {
return `${command.trigger}${command.name}${command.trailingText}`;
}

/**
* A selected command is a one-position mention atom in the editor doc. The
* dismissed range is based on that rendered node width plus any space inserted
* after it, not on the serialized provider token length (`/review`, etc.).
*/
export function commandPillDismissedRangeEnd({
triggerPosition,
trailingText,
}: {
triggerPosition: number;
trailingText: string;
}): number {
return triggerPosition + 1 + trailingText.length;
}
// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving.
export {
buildProviderPromptActionProps,
serializedProviderCommand,
commandPillDismissedRangeEnd,
} from "@bb/client-core";
export type {
ProviderPromptActionCommand,
ProviderPromptAction,
ProviderPromptActionProps,
} from "@bb/client-core";
122 changes: 3 additions & 119 deletions apps/app/src/components/promptbox/mentions/find-active-trigger.ts
Original file line number Diff line number Diff line change
@@ -1,119 +1,3 @@
import type { Editor } from "@tiptap/react";
import type {
ActiveTrigger,
TypeaheadTrigger,
} from "@/components/promptbox/mentions/types";

interface ActiveTriggerEditor {
state: {
selection: {
empty: boolean;
from: number;
};
doc: {
textBetween(
from: number,
to: number,
blockSeparator?: string,
leafText?: string,
): string;
};
};
}

/**
* Builds the word-boundary detection regex for a trigger char. A trigger only
* fires at the start of input or after whitespace / an opening bracket, so a
* mid-word `a/b` or `foo@bar` never opens a menu.
*
* - mention triggers keep a per-char self-exclusion query class, so a second
* trigger char ends the current query rather than extending it (`##` stays a
* markdown heading, not a `#` mention query).
* - command triggers (`/`) capture the whole token up to whitespace
* (`\S*`), so a namespaced name like `frontend:component` is captured whole.
*/
function escapeRegexLiteral(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}

function triggerPattern(
trigger: TypeaheadTrigger,
options: { windowed: boolean },
): RegExp {
const escapedChar = escapeRegexLiteral(trigger.char);
const queryClass =
trigger.kind === "mention" ? `[^\\s${escapedChar}]*` : "\\S*";
// In a windowed scan the window start is not the start of input, so the
// `^` alternative must not fire there; a real trigger inside the window
// always carries its boundary char (the window includes one extra char
// beyond the longest recognizable query).
const boundary = options.windowed ? "([\\s([{])" : "(^|[\\s([{])";
return new RegExp(`${boundary}${escapedChar}(${queryClass})$`, "u");
}

/**
* How many characters before the caret are scanned for a trigger. Trigger
* queries are short human-typed tokens (skill/command names, mention
* queries); scanning the full document instead would rebuild and regex-scan
* the entire text on every keystroke and selection change, which costs
* several ms once a large paste (e.g. a minified JS bundle) is in the box. A
* trigger whose query exceeds the window no longer opens the menu — at that
* length no menu has useful matches anyway.
*/
const TRIGGER_SCAN_WINDOW = 256;

/**
* Resolves the typeahead trigger currently under the caret, if any. Replaces the
* single-`@` `findActiveEditorMention`: it scans the configured `triggers` in
* order and returns the first whose pattern matches the text before the caret.
* Because a thread is bound to one provider, the active set is at most `@` plus
* one command trigger, so order only matters when both could match (they can't —
* the leading char differs).
*
* Returns `null` when the selection is non-empty (a range, not a caret) or no
* trigger matches.
*/
export function findActiveTrigger(
editor: ActiveTriggerEditor | Editor,
triggers: readonly TypeaheadTrigger[],
): ActiveTrigger | null {
const selection = editor.state.selection;
if (!selection.empty) return null;

const scanStart = Math.max(0, selection.from - TRIGGER_SCAN_WINDOW);
const windowed = scanStart > 0;
const textBeforeCursor = editor.state.doc.textBetween(
scanStart,
selection.from,
"\n",
"\n",
);

for (const trigger of triggers) {
const match = triggerPattern(trigger, { windowed }).exec(textBeforeCursor);
if (!match) continue;

const query = match[2] ?? "";
const from = selection.from - query.length - 1;
if (from < 0) continue;

if (trigger.kind === "mention") {
return {
char: trigger.char,
kind: "mention",
query,
from,
to: selection.from,
};
}
return {
char: trigger.char,
kind: "command",
query,
from,
to: selection.from,
};
}

return null;
}
// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving.
export { findActiveTrigger } from "@bb/client-core";
export type { ActiveTriggerEditor } from "@bb/client-core";
Loading
Loading