Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] c17bd1e614 docs(voice): scope prompt hints and translate the new section
Vocabulary hints apply to compatible transcription endpoints; local speech
providers ignore them. Keep the new section available at the same anchor
in both documentation languages.
2026-11-06 03:20:13 +00:00
d0zingcatandJoey Hotz f898537233 fix(dictation): keep transcription context opt-in
An English instruction used as transcript context caused the transcription
model to discard Portuguese list items. Omit built-in context and retain
explicit vocabulary overrides, including an empty override to disable it.

Co-authored-by: d0zingcat <d0zingcat@users.noreply.github.com>
Co-authored-by: Joey Hotz <joeyhotz1@gmail.com>
2026-11-06 03:13:21 +00:00
18 changed files with 145 additions and 329 deletions
@@ -18,8 +18,6 @@ final class WindowLayoutView: ExpoView {
private var barGuide: UILayoutGuide?
private var barEdge = "none"
private var bottomBarGuide: UILayoutGuide?
private var topBarGuide: UILayoutGuide?
private var occlusions: [CGRect] = []
private var measuredBounds = CGRect.zero
required init(appContext: AppContext? = nil) {
@@ -40,7 +38,6 @@ final class WindowLayoutView: ExpoView {
super.didMoveToWindow()
previousPayload = nil
bottomBarGuide = nil
topBarGuide = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
@@ -65,8 +62,6 @@ final class WindowLayoutView: ExpoView {
if #available(iOS 27.1, *) {
// UIKit tracks these reads and invalidates constraints when regions change.
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
// Frames already include the system's interactive margins.
occlusions = reservedRegions(kind: .occlusion).compactMap { $0.isActive ? $0.frame : nil }
let edge: String
switch traitCollection.verticalBarEdge {
case .leading: edge = "leading"
@@ -78,11 +73,6 @@ final class WindowLayoutView: ExpoView {
// screen's corners rather than the rectangular safe area.
bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44))
}
if topBarGuide == nil {
// Where a top bar rests. Beside a partial status region it can start above the
// rectangular safe area; content still keeps out of the occlusions.
topBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.top, extent: 44))
}
if edge != barEdge {
barEdge = edge
barGuide = edge == "none" ? nil : layoutGuide(
@@ -116,17 +106,11 @@ final class WindowLayoutView: ExpoView {
if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 {
bottomClearance = max(0, bounds.maxY - bottomBar.maxY)
}
var topBar: Any = NSNull()
if let frame = topBarGuide?.layoutFrame, frame.width > 0, frame.height > 0 {
topBar = rect(frame)
}
let payload: [String: Any] = [
"bounds": rect(bounds),
"verticalBarEdge": barEdge,
"verticalBar": barPayload,
"bottomClearance": bottomClearance,
"topBar": topBar,
"occlusions": occlusions.map(rect),
"divisions": divisions.map(rect)
]
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
@@ -11,7 +11,6 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
opacity: { 50: 0.5 },
borderRadius: { full: 999, md: 6, lg: 8 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
@@ -12,19 +12,10 @@ import {
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { WindowChromeSafeArea } from "@/utils/desktop-window";
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useTopBand } from "@/window-layout";
const ThemedArrowLeft = withUnistyles(ArrowLeft);
const HeaderSafeArea = withUnistyles(WindowChromeSafeArea, (theme) => ({
horizontalPadding: theme.spacing[3],
}));
const NO_SHORTCUT: ShortcutKey[] = [];
@@ -48,8 +39,7 @@ interface CompactHeaderProps {
* left-aligned between them. Neither the header nor its islands draw a background, so it reads the
* same in flow and floating over content (see `FloatingHeaderLayout`, whose fade gives the icons
* contrast). Its height matches the old flat header, so anything
* offset by the header height still lines up. In a window's top band it takes the top bar's
* place, like the full header, and keeps clear of the status region as it does of window controls.
* offset by the header height still lines up.
*/
export function CompactHeader({
navigation,
@@ -61,20 +51,22 @@ export function CompactHeader({
actions,
}: CompactHeaderProps) {
const insets = useSafeAreaInsets();
const inTopBand = useTopBand() !== null;
const rootStyle = useMemo(
() => ({
paddingTop: insets.top + (inTopBand ? 0 : HEADER_TOP_PADDING_MOBILE),
marginLeft: insets.left,
marginRight: insets.right,
}),
[inTopBand, insets.top, insets.left, insets.right],
() => [
styles.root,
{
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
marginLeft: insets.left,
marginRight: insets.right,
},
],
[insets.top, insets.left, insets.right],
);
return (
<IconButtonChromeProvider value="header-island">
<HeaderSafeArea placement="inline" style={rootStyle}>
<View style={inTopBand ? styles.topBandRow : styles.row}>
<View style={rootStyle}>
<View style={styles.row}>
<View style={styles.island}>
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
</View>
@@ -88,7 +80,7 @@ export function CompactHeader({
</View>
{actions ? <View style={styles.island}>{actions}</View> : null}
</View>
</HeaderSafeArea>
</View>
</IconButtonChromeProvider>
);
}
@@ -147,18 +139,15 @@ function BackButton({ onBack }: { onBack?: () => void }) {
}
const styles = StyleSheet.create((theme) => ({
root: {
paddingHorizontal: theme.spacing[3],
},
row: {
height: HEADER_INNER_HEIGHT_MOBILE,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
topBandRow: {
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
titleGroup: {
flex: 1,
minWidth: 0,
+4 -6
View File
@@ -29,7 +29,7 @@ import {
resolveDesktopSidebarInsetLeft,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
import { useTopBand, useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -748,11 +748,9 @@ function DesktopSidebar({
],
[insetsTop, insets.bottom, insets.left, overlay, divisionLayout],
);
// Below the window controls' row, or in the top band's place, the first row starts flush.
const inTopBand = useTopBand() !== null;
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, (ownsTopLeft || inTopBand) && styles.sidebarHeaderGroupFlush],
[ownsTopLeft, inTopBand],
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
[ownsTopLeft],
);
return (
<MobilePanelOverlay
@@ -877,7 +875,7 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
sidebarHeaderGroupFlush: {
sidebarHeaderGroupBelowChrome: {
paddingTop: 0,
},
// The nav block caps at half the sidebar; a long item list scrolls inside the
@@ -13,7 +13,6 @@ import {
type ControlSizing,
} from "@/components/ui/control-geometry";
import { StyleSheet } from "react-native-unistyles";
import { WindowChromeSafeArea } from "@/utils/desktop-window";
import { useComposerFullscreenExit } from "./fullscreen-exit";
import { resolveGlyphRailOffset } from "./state";
@@ -91,7 +90,7 @@ export function ComposerInputPresentation({
testID={fullscreen ? "composer-fullscreen" : undefined}
>
{fullscreen ? (
<WindowChromeSafeArea placement="inline" style={styles.exitRow}>
<View style={styles.exitRow}>
<Button
variant="ghost"
size="sm"
@@ -104,7 +103,7 @@ export function ComposerInputPresentation({
>
{null}
</Button>
</WindowChromeSafeArea>
</View>
) : null}
<ComposerFullscreenBody fullscreen={fullscreen}>
<View
@@ -163,7 +163,7 @@ import {
WorkspaceExplorerSidebarToggle,
WorkspaceHeaderExplorerToggle,
} from "@/screens/workspace/workspace-explorer-toggle";
import { useHasWindowChromeObstruction, WindowChromeRegion } from "@/utils/desktop-window";
import { useHasWindowChromeObstruction } from "@/utils/desktop-window";
import {
resolveWorkspaceHeaderRenderState,
type WorkspaceHeaderCheckoutState,
@@ -1024,23 +1024,6 @@ function useLastMainPane(input: {
return lastMainPaneRef;
}
/** The docked explorer beside the center column takes the top-right window-chrome corner. */
function DesktopCenterColumnChrome({
explorerOpen,
focusModeEnabled,
children,
}: {
explorerOpen: boolean;
focusModeEnabled: boolean;
children: ReactNode;
}) {
return (
<WindowChromeRegion corners={explorerOpen && !focusModeEnabled ? "top-left" : "both"}>
<View style={styles.centerColumn}>{children}</View>
</WindowChromeRegion>
);
}
function WorkspaceScreenContent({
serverId,
workspaceId,
@@ -3652,10 +3635,7 @@ function WorkspaceScreenContent({
<SheetTabs onCloseTab={handleCloseTabById} />
</View>
) : (
<DesktopCenterColumnChrome
explorerOpen={isExplorerSidebarShowing}
focusModeEnabled={desktopFocusModeEnabled}
>
<View style={styles.centerColumn}>
{rendersDesktopSplitContent ? null : (
<AdaptiveWorkspaceChrome compactHeader={compactWorkspaceHeader}>
{fullWorkspaceActions}
@@ -3694,7 +3674,7 @@ function WorkspaceScreenContent({
<View style={styles.centerContent}>
<View style={styles.content}>{desktopContent}</View>
</View>
</DesktopCenterColumnChrome>
</View>
);
const [workspaceViewportWidth, setWorkspaceViewportWidth] = useState(0);
+7 -38
View File
@@ -9,61 +9,30 @@ import {
describe("window chrome", () => {
it("has no corner obstruction outside Electron or in fullscreen", () => {
expect(
resolveWindowChromeObstruction({ mode: null, isFullscreen: false, topBand: null }),
).toEqual({
expect(resolveWindowChromeObstruction({ mode: null, isFullscreen: false })).toEqual({
topLeft: null,
topRight: null,
});
expect(
resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: true, topBand: null }),
).toEqual({
expect(resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: true })).toEqual({
topLeft: null,
topRight: null,
});
});
it("places native controls in their physical top corner", () => {
expect(
resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: false, topBand: null }),
).toEqual({
expect(resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: false })).toEqual({
topLeft: { width: 78, height: 45 },
topRight: null,
});
expect(
resolveWindowChromeObstruction({
mode: "custom-windows",
isFullscreen: false,
topBand: null,
}),
).toEqual({ topLeft: null, topRight: { width: 138, height: 36 } });
expect(
resolveWindowChromeObstruction({ mode: "custom-linux", isFullscreen: false, topBand: null }),
).toEqual({
expect(resolveWindowChromeObstruction({ mode: "custom-windows", isFullscreen: false })).toEqual(
{ topLeft: null, topRight: { width: 138, height: 36 } },
);
expect(resolveWindowChromeObstruction({ mode: "custom-linux", isFullscreen: false })).toEqual({
topLeft: null,
topRight: { width: 108, height: 36 },
});
});
// Measured: iPhone Duo inner screen, portrait. The top bar ends at x 525, where the status
// region (0..82) starts; the band starts at y 26.
it("treats the status region beside a native top band as top-right window controls", () => {
expect(
resolveWindowChromeObstruction({
mode: null,
isFullscreen: false,
topBand: { top: 26, trailingObstruction: { width: 144, height: 56 } },
}),
).toEqual({ topLeft: null, topRight: { width: 144, height: 56 } });
expect(
resolveWindowChromeObstruction({
mode: null,
isFullscreen: false,
topBand: { top: 26, trailingObstruction: null },
}),
).toEqual({ topLeft: null, topRight: null });
});
it("insets and reserves only claimed corners", () => {
const obstruction = { topLeft: { width: 80, height: 28 }, topRight: { width: 48, height: 32 } };
expect(
+1 -9
View File
@@ -12,7 +12,6 @@ import {
getDesktopWindowControlsWidth,
} from "@/desktop/window-chrome-presentation";
import { isNative } from "@/constants/platform";
import { useTopBand, type TopBand } from "@/window-layout";
export type WindowChromeCorners = "none" | "top-left" | "top-right" | "both";
type WindowChromeSafeAreaPlacement = "inline" | "below";
@@ -106,15 +105,10 @@ export function removeWindowChromeCorner(
return corner === "top-left" ? "top-right" : "top-left";
}
/** Desktop window controls, or the system region a native top band gives way to. */
export function resolveWindowChromeObstruction(input: {
mode: DesktopWindowChromeMode | null;
isFullscreen: boolean;
topBand: TopBand | null;
}): WindowChromeObstruction {
if (input.topBand?.trailingObstruction) {
return { topLeft: null, topRight: input.topBand.trailingObstruction };
}
if (!input.mode || input.isFullscreen) return EMPTY_OBSTRUCTION;
if (input.mode === "native-mac") {
return {
@@ -223,15 +217,13 @@ export function WindowChromeProvider({ children }: { children: ReactNode }) {
};
}, []);
const topBand = useTopBand();
const obstruction = useMemo(
() =>
resolveWindowChromeObstruction({
mode: isElectronReady ? getDesktopWindowChromeMode() : null,
isFullscreen: windowState.isFullscreen,
topBand,
}),
[isElectronReady, windowState.isFullscreen, topBand],
[isElectronReady, windowState.isFullscreen],
);
const desktopWindowChrome = useMemo(
() => ({
+1 -9
View File
@@ -1,12 +1,9 @@
import { createContext, useContext } from "react";
import { useWindowDimensions } from "react-native";
import type { TopBand, WindowLayout } from "./model";
import type { WindowLayout } from "./model";
export const WindowLayoutContext = createContext<WindowLayout | null>(null);
/** Published by `WindowLayoutProvider` beside the safe-area insets it derives from it. */
export const TopBandContext = createContext<TopBand | null>(null);
export function useWindowLayout(): WindowLayout | null {
const layout = useContext(WindowLayoutContext);
const { width, height } = useWindowDimensions();
@@ -16,8 +13,3 @@ export function useWindowLayout(): WindowLayout | null {
? layout
: null;
}
/** The top band beside the system's status region, or null where the window has none. */
export function useTopBand(): TopBand | null {
return useContext(TopBandContext);
}
+5 -17
View File
@@ -2,19 +2,18 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
import { requireNativeViewManager } from "expo-modules-core";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { TopBandContext, WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./model";
import { WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBand } from "./context";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
} from "./model";
export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model";
export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model";
const GeometryProbe = requireNativeViewManager<
ViewProps & {
@@ -42,23 +41,12 @@ export function WindowLayoutProvider({ children }: { children: ReactNode }) {
);
}
/**
* The one top and bottom inset every descendant reads through `useSafeAreaInsets()`, and
* the top band that `WindowChromeProvider` reads through `useTopBand()`.
*/
/** The one bottom inset every descendant reads through `useSafeAreaInsets()`. */
function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
const systemInsets = useSafeAreaInsets();
const layout = useWindowLayout();
const insets = useMemo(() => resolveSafeAreaInsets(systemInsets, layout), [systemInsets, layout]);
const topBand = useMemo(
() => resolveTopBand(systemInsets.top, layout),
[systemInsets.top, layout],
);
return (
<TopBandContext.Provider value={topBand}>
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
</TopBandContext.Provider>
);
return <SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>;
}
const styles = StyleSheet.create({ root: { flex: 1 } });
+1 -2
View File
@@ -3,13 +3,12 @@ import type { ReactNode } from "react";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBand } from "./context";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
} from "./model";
export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model";
export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model";
export function WindowLayoutProvider({ children }: { children: ReactNode }) {
return children;
+6 -126
View File
@@ -1,155 +1,35 @@
import { describe, expect, it } from "vitest";
import {
resolveSafeAreaInsets,
resolveTopBand,
type WindowLayout,
type WindowLayoutRect,
} from "./model";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
const systemInsets = { top: 59, right: 0, bottom: 34, left: 0 };
function layout(input: Partial<WindowLayout> = {}): WindowLayout {
function layout(bottomClearance: number | null | undefined): WindowLayout {
return {
bounds: { x: 0, y: 0, width: 402, height: 874 },
verticalBarEdge: "none",
verticalBar: null,
bottomClearance,
divisions: [],
...input,
};
}
describe("resolveSafeAreaInsets", () => {
it("replaces only the bottom with the measured bar clearance", () => {
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 24 }))).toEqual({
expect(resolveSafeAreaInsets(systemInsets, layout(24))).toEqual({
...systemInsets,
bottom: 24,
});
});
it("keeps a measured zero instead of falling back to the system inset", () => {
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 0 })).bottom).toBe(0);
expect(resolveSafeAreaInsets(systemInsets, layout(0)).bottom).toBe(0);
});
it.each([null, undefined])("leaves system insets alone without a clearance (%s)", (value) => {
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: value }))).toBe(
systemInsets,
);
expect(resolveSafeAreaInsets(systemInsets, layout(value))).toBe(systemInsets);
});
it("leaves system insets alone before the native layout arrives", () => {
expect(resolveSafeAreaInsets(systemInsets, null)).toBe(systemInsets);
});
it("starts the top where the top bar rests when it sits above the system top", () => {
const insets = resolveSafeAreaInsets(
systemInsets,
layout({ topBar: { x: 0, y: 12, width: 402, height: 44 } }),
);
expect(insets).toEqual({ ...systemInsets, top: 12 });
});
it("keeps the system top when the top bar rests at or below it", () => {
const insets = resolveSafeAreaInsets(
systemInsets,
layout({ topBar: { x: 0, y: 59, width: 402, height: 44 } }),
);
expect(insets).toBe(systemInsets);
});
});
// Measured: iPhone Duo inner screen, portrait (669×951), iOS 27.1 simulator unfolded in
// DeviceHub.
const duo = {
systemTop: 82,
topBar: { x: 16, y: 26, width: 509, height: 44 },
statusRegion: { x: 525, y: 0, width: 144, height: 82 },
};
function duoLayout(occlusions: WindowLayoutRect[]): WindowLayout {
return layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: duo.topBar,
occlusions,
});
}
describe("resolveTopBand", () => {
it("publishes the top bar's start and the status region past its trailing end", () => {
expect(resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion]))).toEqual({
top: 26,
trailingObstruction: { width: 144, height: 56 },
});
});
it("reaches as low as the deepest occlusion beside the bar", () => {
const deeper = { x: 600, y: 0, width: 69, height: 96 };
expect(
resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion, deeper]))?.trailingObstruction,
).toEqual({ width: 144, height: 70 });
});
it("rounds a fractional obstruction up so controls never touch the status region", () => {
const fractional = layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: { x: 16, y: 26, width: 509.25, height: 44 },
occlusions: [{ x: 525.25, y: 0, width: 143.75, height: 82.5 }],
});
expect(resolveTopBand(duo.systemTop, fractional)?.trailingObstruction).toEqual({
width: 144,
height: 57,
});
});
it("has no trailing obstruction when the bar reaches the window's trailing edge", () => {
const fullWidth = layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: { x: 0, y: 26, width: 669, height: 44 },
});
expect(resolveTopBand(duo.systemTop, fullWidth)).toEqual({
top: 26,
trailingObstruction: null,
});
});
it("ignores occlusions without a top bar guide (before iOS 27.1 or unresolved)", () => {
const withoutGuide = layout({ topBar: null, occlusions: [duo.statusRegion] });
expect(resolveTopBand(duo.systemTop, withoutGuide)).toBeNull();
expect(resolveTopBand(duo.systemTop, layout({ occlusions: [duo.statusRegion] }))).toBeNull();
});
it("ignores occlusions when the top bar guide sits below the status region", () => {
const below = layout({
topBar: { x: 16, y: 82, width: 637, height: 44 },
occlusions: [duo.statusRegion],
});
expect(resolveTopBand(duo.systemTop, below)).toBeNull();
});
// Measured: iPhone 17 Pro, iOS 27.2 simulator, portrait.
it("is null on a regular iPhone, whose top bar starts at the status bar's bottom", () => {
const iphone = layout({
topBar: { x: 18, y: 62, width: 366, height: 44 },
occlusions: [{ x: 0, y: 0, width: 402, height: 54 }],
});
expect(resolveTopBand(62, iphone)).toBeNull();
});
// Measured: iPhone Duo outer screen, iOS 27.1 simulator. The status region is a trailing
// column, which the vertical bar layout already owns.
it("is null on the Duo outer screen, whose top bar starts below a zero system top", () => {
const outer = layout({
bounds: { x: 0, y: 0, width: 466, height: 678 },
verticalBarEdge: "trailing",
topBar: { x: 7 / 3, y: 26, width: 1139 / 3, height: 44 },
occlusions: [
{ x: 1199 / 3, y: 88 / 3, width: 37, height: 37 },
{ x: 382, y: 0, width: 84, height: 174 },
],
});
expect(resolveTopBand(0, outer)).toBeNull();
});
it("is null before the native layout arrives", () => {
expect(resolveTopBand(duo.systemTop, null)).toBeNull();
});
});
+5 -43
View File
@@ -14,56 +14,18 @@ export interface WindowLayout {
* the system has no bottom bar guide. Zero is a measured value.
*/
bottomClearance?: number | null;
/** Where a top bar rests, or null until the system resolves its top bar guide. */
topBar?: WindowLayoutRect | null;
/** Active system occlusions (status region, camera, Live Activities), margins included. */
occlusions?: WindowLayoutRect[];
divisions: WindowLayoutRect[];
}
/**
* The band above the rectangular safe area that a top bar may use because the system
* occupies only its trailing end, like window controls on a desktop title bar.
*/
export interface TopBand {
/** Where the top bar starts, in window points. */
top: number;
/**
* The system region past the bar's trailing end, measured from the band's top, or null
* when the bar reaches the window's trailing edge.
*/
trailingObstruction: { width: number; height: number } | null;
}
/** Null unless the top bar guide starts above the system's rectangular safe area. */
export function resolveTopBand(systemTop: number, layout: WindowLayout | null): TopBand | null {
const bar = layout?.topBar;
if (!layout || !bar || bar.y >= systemTop) return null;
const top = Math.max(0, bar.y);
const barEnd = bar.x + bar.width;
const width = Math.ceil(layout.bounds.width - barEnd);
if (width <= 0) return { top, trailingObstruction: null };
const beside = (layout.occlusions ?? []).filter(
(frame) => frame.x + frame.width > barEnd && frame.y < bar.y + bar.height,
);
const bottom = Math.max(bar.y + bar.height, ...beside.map((frame) => frame.y + frame.height));
return { top, trailingObstruction: { width, height: Math.ceil(bottom - top) } };
}
/**
* The window's measured bars replace the rectangular safe area: the bottom bar's clearance,
* and the top bar's start when it sits above the system top.
*/
export function resolveSafeAreaInsets<Insets extends { top: number; bottom: number }>(
/** The window's measured bottom clearance replaces the rectangular safe-area bottom. */
export function resolveSafeAreaInsets<Insets extends { bottom: number }>(
systemInsets: Insets,
layout: WindowLayout | null,
): Insets {
const clearance = layout?.bottomClearance;
const bottom = typeof clearance === "number" ? clearance : systemInsets.bottom;
const top = resolveTopBand(systemInsets.top, layout)?.top ?? systemInsets.top;
return top === systemInsets.top && bottom === systemInsets.bottom
? systemInsets
: { ...systemInsets, top, bottom };
return typeof clearance === "number" && clearance !== systemInsets.bottom
? { ...systemInsets, bottom: clearance }
: systemInsets;
}
export interface DesktopDivisionLayout {
@@ -33,9 +33,9 @@ async function main(): Promise<void> {
const apiKey = requireEnv("OPENAI_API_KEY");
const transcriptionModel = process.env.STT_MODEL ?? "gpt-4o-transcribe";
const prompt =
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT ??
"Transcribe only what the speaker says. Do not add words. Preserve punctuation and casing. If the audio is silence or non-speech noise, return an empty transcript.";
// Mirrors the daemon: no built-in prompt, only an explicit override. Whisper
// treats `prompt` as preceding transcript, so a default would bias the baseline.
const prompt = process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT?.trim() || undefined;
const openai = new OpenAI({ apiKey });
@@ -43,7 +43,7 @@ async function main(): Promise<void> {
file: await import("node:fs").then((fs) => fs.createReadStream(audioPath)),
language: "en",
model: transcriptionModel,
prompt,
...(prompt ? { prompt } : {}),
response_format: "json",
// Aim for determinism.
temperature: 0,
@@ -57,11 +57,13 @@ class FakeRealtimeSession extends EventEmitter implements StreamingTranscription
class FakeSttProvider implements SpeechToTextProvider {
public readonly id = "fake";
public lastLanguage?: string;
public lastPrompt?: string;
constructor(private readonly session: FakeRealtimeSession) {}
createSession(
params: Parameters<SpeechToTextProvider["createSession"]>[0],
): StreamingTranscriptionSession {
this.lastLanguage = params.language;
this.lastPrompt = params.prompt;
return this.session;
}
}
@@ -594,3 +596,58 @@ it("closes every dictation stream when one provider cleanup fails", async () =>
expect(second.closed).toBe(true);
expect(manager.hasDemand).toBe(false);
});
describe("DictationStreamManager (transcription prompt)", () => {
const originalPromptEnv = process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT;
afterEach(() => {
if (originalPromptEnv === undefined) {
delete process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT;
} else {
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT = originalPromptEnv;
}
});
const startDictation = async (
options: { language?: string } = {},
): Promise<{ provider: FakeSttProvider; manager: DictationStreamManager }> => {
const session = new FakeRealtimeSession();
const provider = new FakeSttProvider(session);
const manager = new DictationStreamManager({
logger: pino({ level: "silent" }),
emit: () => {},
sessionId: "prompt",
stt: provider,
...(options.language ? { language: options.language } : {}),
});
await manager.handleStart("d1", "audio/pcm;rate=24000;bits=16");
manager.cleanupAll();
return { provider, manager };
};
it("sends no prompt by default", async () => {
delete process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT;
const { provider } = await startDictation();
expect(provider.lastPrompt).toBeUndefined();
});
it("sends no prompt for non-English dictation by default", async () => {
delete process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT;
const { provider } = await startDictation({ language: "zh" });
expect(provider.lastLanguage).toBe("zh");
expect(provider.lastPrompt).toBeUndefined();
});
it("passes an explicit prompt override through", async () => {
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT =
" Vocabulary: Paseo, Dokploy, Kubernetes. ";
const { provider } = await startDictation();
expect(provider.lastPrompt).toBe("Vocabulary: Paseo, Dokploy, Kubernetes.");
});
it.each(["", " "])("treats a blank override (%j) as no prompt", async (value) => {
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT = value;
const { provider } = await startDictation();
expect(provider.lastPrompt).toBeUndefined();
});
});
@@ -38,6 +38,12 @@ function parseNonNegativeNumber(value: string | undefined): number | null {
return parsed;
}
/** Unset, empty, and whitespace-only overrides all mean "send no prompt". */
function readTranscriptionPromptOverride(env: NodeJS.ProcessEnv): string | undefined {
const value = env.PASEO_DICTATION_TRANSCRIPTION_PROMPT?.trim();
return value ? value : undefined;
}
function convertPCMToWavBuffer(
pcmBuffer: Buffer,
sampleRate: number,
@@ -185,16 +191,16 @@ export class DictationStreamManager {
return;
}
const transcriptionPrompt =
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT ??
"Transcribe only what the speaker says. Do not add words. Preserve punctuation and casing. If the audio is silence or non-speech noise, return an empty transcript.";
// Whisper uses the prompt as transcript context, not silence instructions.
// Keep context opt-in so it does not bias speech or introduce unspoken text.
const transcriptionPrompt = readTranscriptionPromptOverride(process.env);
let stt: ReturnType<SpeechToTextProvider["createSession"]>;
try {
stt = sttProvider.createSession({
logger: this.logger.child({ dictationId }),
language: this.language,
prompt: transcriptionPrompt,
...(transcriptionPrompt ? { prompt: transcriptionPrompt } : {}),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
+11
View File
@@ -142,8 +142,19 @@ Keys and base URLs follow the fallback rule above.
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs
- `PASEO_DICTATION_LANGUAGE`, dictation STT language
- `PASEO_VOICE_LANGUAGE`, voice mode STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset
- `PASEO_DICTATION_TRANSCRIPTION_PROMPT`, optional OpenAI dictation transcription prompt (including compatible servers). Unset by default; empty or whitespace-only is treated as unset
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning
## Transcription Prompt
With the OpenAI provider, including compatible servers, Paseo sends no transcription prompt by default. Whisper-family models use `prompt` as transcript context, so instruction text can distort the transcript or bias its language. Omitting it does not prevent every hallucination on silence or noise.
For OpenAI dictation, set `PASEO_DICTATION_TRANSCRIPTION_PROMPT` to send a prompt explicitly, for example to bias toward project vocabulary. An empty or whitespace-only value means no prompt. Local providers ignore this setting, and voice mode never sends a prompt.
```bash
PASEO_DICTATION_TRANSCRIPTION_PROMPT="Vocabulary: Paseo, TypeScript."
```
## Operational Notes
Voice mode can launch and control agents. Treat voice prompts with the same care as direct agent instructions, especially when specifying working directories or destructive operations.
+11
View File
@@ -120,8 +120,19 @@ Paseo 在配置的 OpenAI base URL 下使用以下路径:
- `PASEO_VOICE_LOCAL_STT_MODEL`、`PASEO_VOICE_LOCAL_TTS_MODEL`:本地语音 STT/TTS 模型 ID
- `PASEO_DICTATION_LANGUAGE`:口述 STT 语言(仅限 OpenAI STT;本地 Parakeet 会忽略)
- `PASEO_VOICE_LANGUAGE`:语音模式 STT 语言;未设置时退回到 `PASEO_DICTATION_LANGUAGE`(仅限 OpenAI STT;本地 Parakeet 会忽略)
- `PASEO_DICTATION_TRANSCRIPTION_PROMPT`:可选的 OpenAI 口述转录提示词(包括兼容服务器)。默认不设置;空值或仅含空白字符的值视为未设置
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`、`PASEO_VOICE_LOCAL_TTS_SPEED`:可选的本地语音 TTS 调优
## 转录提示词 {#transcription-prompt}
使用 OpenAI Provider(包括兼容服务器)进行口述时,Paseo 默认不发送转录提示词。Whisper 系列模型把 `prompt` 当作转录上下文,因此指令文本可能使转录失真或影响输出语言。不发送提示词也无法消除静音或噪声中的所有幻觉。
对于 OpenAI 口述,可以设置 `PASEO_DICTATION_TRANSCRIPTION_PROMPT` 来显式发送提示词,例如提示项目术语。空值或仅含空白字符的值表示不发送提示词。本地 Provider 会忽略此设置,语音模式也不会发送提示词。
```bash
PASEO_DICTATION_TRANSCRIPTION_PROMPT="Vocabulary: Paseo, TypeScript."
```
## 使用须知 {#operational-notes}
语音模式可以启动和控制 Agent。请像对待直接的 Agent 指令一样谨慎对待语音提示词,尤其是在指定工作目录或执行破坏性操作时。