mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* feat(app): add fullscreen editing to the compact composer * fix(app): preserve read-only terminal command previews Static command previews must render their published source; the live editor buffer is updated after render and has no mounted input to repaint read-only content. * fix(app): import React for composer editing scope rendering Workspace pane unit tests use the classic JSX transform, so the editing-scope provider needs React in scope when mounted. * fix(app): preserve fullscreen composer controls and editing Keep the fullscreen frame inside the visual viewport, gate hidden autocomplete keys, apply replacement commands to local line measurement, and return to inline before starting voice so its controls stay accessible. * test(app): cover draft replacement through the production composer Replace the measurement-hook fixture with profile switching and typing through the real composer. Keep measurement state inside its production surface; Android manual red/green evidence covers the native replacement flow.
203 lines
5.8 KiB
TypeScript
203 lines
5.8 KiB
TypeScript
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
|
import type { MessagePayload, TextReplacement } from "@/composer/types";
|
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
|
|
|
export type SendBehavior = ActiveTurnBehavior | "queue";
|
|
|
|
export function resolveActiveSendBehavior(
|
|
sendBehavior: SendBehavior,
|
|
hasPendingPermission: boolean,
|
|
): SendBehavior {
|
|
return sendBehavior === "queue" && hasPendingPermission ? "interrupt" : sendBehavior;
|
|
}
|
|
|
|
interface ComposerSurfaceState {
|
|
opacity: 0 | 1;
|
|
pointerEvents: "auto" | "none";
|
|
}
|
|
|
|
export interface ComposerSurfacePresentation {
|
|
input: ComposerSurfaceState;
|
|
overlay: ComposerSurfaceState;
|
|
}
|
|
|
|
const INPUT_PRESENTATION: ComposerSurfacePresentation = {
|
|
input: { opacity: 1, pointerEvents: "auto" },
|
|
overlay: { opacity: 0, pointerEvents: "none" },
|
|
};
|
|
|
|
const OVERLAY_PRESENTATION: ComposerSurfacePresentation = {
|
|
input: { opacity: 0, pointerEvents: "none" },
|
|
overlay: { opacity: 1, pointerEvents: "auto" },
|
|
};
|
|
|
|
export function resolveComposerSurfacePresentation(
|
|
showOverlay: boolean,
|
|
): ComposerSurfacePresentation {
|
|
return showOverlay ? OVERLAY_PRESENTATION : INPUT_PRESENTATION;
|
|
}
|
|
|
|
interface StopRealtimeVoiceContext {
|
|
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
|
|
isRealtimeVoiceForCurrentAgent: boolean;
|
|
isAgentRunning: boolean;
|
|
client: { cancelAgent: (agentId: string) => Promise<unknown> } | null;
|
|
voiceAgentId: string | undefined;
|
|
}
|
|
|
|
interface SendActionContext {
|
|
defaultSendBehavior: SendBehavior;
|
|
isAgentRunning: boolean;
|
|
onQueue: ((payload: MessagePayload) => void) | undefined;
|
|
handleSendMessage: () => void;
|
|
handleQueueMessage: () => void;
|
|
}
|
|
|
|
interface DictationTranscriptContext {
|
|
value: string;
|
|
defaultSendBehavior: SendBehavior;
|
|
isAgentRunning: boolean;
|
|
onQueue: ((payload: MessagePayload) => void) | undefined;
|
|
onSubmit: (payload: MessagePayload) => void;
|
|
replaceText: (text: string) => void;
|
|
attachments: MessagePayload["attachments"];
|
|
cwd: string;
|
|
autoSend: boolean;
|
|
}
|
|
|
|
export function applyDictationTranscript(text: string, ctx: DictationTranscriptContext): void {
|
|
if (!text) return;
|
|
const shouldPad = ctx.value.length > 0 && !/\s$/.test(ctx.value);
|
|
const nextValue = `${ctx.value}${shouldPad ? " " : ""}${text}`;
|
|
|
|
if (!ctx.autoSend) {
|
|
ctx.replaceText(nextValue);
|
|
return;
|
|
}
|
|
|
|
ctx.replaceText(nextValue);
|
|
|
|
if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) {
|
|
ctx.onQueue({ text: nextValue, attachments: ctx.attachments, cwd: ctx.cwd });
|
|
ctx.replaceText("");
|
|
return;
|
|
}
|
|
|
|
ctx.onSubmit({
|
|
text: nextValue,
|
|
attachments: ctx.attachments,
|
|
cwd: ctx.cwd,
|
|
forceSend: ctx.isAgentRunning || undefined,
|
|
});
|
|
}
|
|
|
|
interface MessageInputKeyboardActions {
|
|
focusInput: () => void;
|
|
isDictationRecording: () => boolean;
|
|
markTranscriptForSend: () => void;
|
|
confirmDictation: () => void | Promise<void>;
|
|
cancelDictation: () => void | Promise<void>;
|
|
startDictation: () => void | Promise<void>;
|
|
toggleRealtimeVoice: () => void;
|
|
isRealtimeVoiceActive: boolean;
|
|
toggleRealtimeVoiceMute: () => void;
|
|
}
|
|
|
|
export function computeCanStartDictation(input: {
|
|
client: DaemonClient | null;
|
|
isReadyForDictation: boolean | undefined;
|
|
disabled: boolean;
|
|
dictationUnavailableMessage: string | null | undefined;
|
|
}): boolean {
|
|
const socketConnected = input.client?.isConnected ?? false;
|
|
const readyForDictation = input.isReadyForDictation ?? socketConnected;
|
|
return (
|
|
socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage
|
|
);
|
|
}
|
|
|
|
export function runDefaultSendAction(ctx: SendActionContext): void {
|
|
if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) {
|
|
ctx.handleQueueMessage();
|
|
return;
|
|
}
|
|
ctx.handleSendMessage();
|
|
}
|
|
|
|
export function runAlternateSendAction(ctx: SendActionContext): void {
|
|
if (ctx.defaultSendBehavior === "queue") {
|
|
ctx.handleSendMessage();
|
|
return;
|
|
}
|
|
if (ctx.isAgentRunning && ctx.onQueue) {
|
|
ctx.handleQueueMessage();
|
|
}
|
|
}
|
|
|
|
export function runMessageInputKeyboardAction(
|
|
action: MessageInputKeyboardActionKind,
|
|
actions: MessageInputKeyboardActions,
|
|
): boolean {
|
|
if (action === "focus") {
|
|
actions.focusInput();
|
|
return true;
|
|
}
|
|
if (action === "send" || action === "dictation-confirm") {
|
|
if (actions.isDictationRecording()) {
|
|
actions.markTranscriptForSend();
|
|
void actions.confirmDictation();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
if (action === "voice-toggle") {
|
|
actions.toggleRealtimeVoice();
|
|
return true;
|
|
}
|
|
if (action === "voice-mute-toggle") {
|
|
if (actions.isRealtimeVoiceActive) {
|
|
actions.toggleRealtimeVoiceMute();
|
|
}
|
|
return true;
|
|
}
|
|
if (action === "dictation-cancel") {
|
|
if (actions.isDictationRecording()) {
|
|
void actions.cancelDictation();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
if (action === "dictation-toggle") {
|
|
if (actions.isDictationRecording()) {
|
|
actions.markTranscriptForSend();
|
|
void actions.confirmDictation();
|
|
} else {
|
|
void actions.startDictation();
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<void> {
|
|
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
|
|
|
|
if (ctx.isAgentRunning) {
|
|
if (!ctx.client || !ctx.voiceAgentId) {
|
|
throw new Error("Cannot stop the running voice agent while the host is unavailable");
|
|
}
|
|
await ctx.client.cancelAgent(ctx.voiceAgentId);
|
|
}
|
|
|
|
await ctx.voice.stopVoice();
|
|
}
|
|
|
|
export function resolveComposerSurfaceText(
|
|
liveText: string,
|
|
replacement: TextReplacement,
|
|
appliedReplacementKey: string,
|
|
): string {
|
|
return replacement.key === appliedReplacementKey ? liveText : replacement.text;
|
|
}
|