diff --git a/apps/app/src/react-app/domains/session/modals/model-picker-modal-components.tsx b/apps/app/src/react-app/domains/session/modals/model-picker-modal-components.tsx new file mode 100644 index 000000000..d26ab9422 --- /dev/null +++ b/apps/app/src/react-app/domains/session/modals/model-picker-modal-components.tsx @@ -0,0 +1,414 @@ +/** @jsxImportSource react */ +import type { KeyboardEvent as ReactKeyboardEvent, RefObject } from "react"; +import { CheckCircle2, Circle, Search, X } from "lucide-react"; + +import { t } from "../../../../i18n"; +import { modelEquals } from "../../../../app/utils"; +import type { ModelOption, ModelRef } from "../../../../app/types"; + +function ProviderIcon({ + providerId, + size = 16, + className, +}: { + providerId: string; + size?: number; + className?: string; +}) { + const initial = providerId.trim().charAt(0).toUpperCase() || "?"; + return ( + + {initial} + + ); +} + +export type ProviderLinkItem = { + providerID: string; + title: string; + matchCount: number; + index: number; +}; + +export function ModelPickerDialog(props: { + target: "default" | "session"; + query: string; + totalOptions: number; + filteredCount: number; + current: ModelRef; + searchInputRef: RefObject; + activeIndex: number; + renderedCount: number; + recommendedOptions: { opt: ModelOption; index: number }[]; + otherEnabledOptions: { opt: ModelOption; index: number }[]; + otherOptions: ProviderLinkItem[]; + registerOptionRef: (index: number) => (el: HTMLDivElement | null) => void; + onSetQuery: (value: string) => void; + onSetActiveIndex: (index: number) => void; + onSelect: (model: ModelRef) => void; + onBehaviorChange: (model: ModelRef, value: string | null) => void; + onOpenSettings: () => void; + onClose: (options?: { restorePromptFocus?: boolean }) => void; +}) { + return ( +
+
+
+ + + +
+ +
+
+
+
+ ); +} + +function ModelPickerHeader(props: { + target: "default" | "session"; + onClose: (options?: { restorePromptFocus?: boolean }) => void; +}) { + return ( +
+
+

+ {t(props.target === "default" ? "model_picker.default_model_title" : "model_picker.chat_model_title")} +

+

+ {t(props.target === "default" ? "model_picker.default_model_desc" : "model_picker.chat_model_desc")} +

+
+ +
+ ); +} + +function ModelPickerSearch(props: { + query: string; + totalOptions: number; + filteredCount: number; + searchInputRef: RefObject; + onSetQuery: (value: string) => void; +}) { + return ( +
+
+ + props.onSetQuery(event.currentTarget.value)} + placeholder={t("settings.search_models")} + className="w-full bg-dls-surface border border-dls-border rounded-xl py-2.5 pl-9 pr-3 text-sm text-dls-text placeholder:text-dls-secondary focus:outline-none focus:ring-1 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] focus:border-dls-accent" + /> +
+ {props.query.trim() ? ( +
+ {t("settings.showing_models", { count: props.filteredCount, total: props.totalOptions })} +
+ ) : null} +
+ ); +} + +function ModelPickerSections(props: { + current: ModelRef; + activeIndex: number; + renderedCount: number; + recommendedOptions: { opt: ModelOption; index: number }[]; + otherEnabledOptions: { opt: ModelOption; index: number }[]; + otherOptions: ProviderLinkItem[]; + registerOptionRef: (index: number) => (el: HTMLDivElement | null) => void; + onSetActiveIndex: (index: number) => void; + onSelect: (model: ModelRef) => void; + onBehaviorChange: (model: ModelRef, value: string | null) => void; + onOpenSettings: () => void; + onClose: (options?: { restorePromptFocus?: boolean }) => void; +}) { + return ( +
+ + + {props.otherOptions.length > 0 ? ( +
+
+ {t("model_picker.more_providers")} +
+ {props.otherOptions.map((provider) => ( + + ))} +
+ ) : null} + {props.renderedCount === 0 ? ( +
+ {t("model_picker.no_results")} +
+ ) : null} +
+ ); +} + +function ModelOptionsSection(props: { + title: string; + options: { opt: ModelOption; index: number }[]; + current: ModelRef; + activeIndex: number; + registerOptionRef: (index: number) => (el: HTMLDivElement | null) => void; + onSetActiveIndex: (index: number) => void; + onSelect: (model: ModelRef) => void; + onBehaviorChange: (model: ModelRef, value: string | null) => void; +}) { + if (props.options.length === 0) return null; + return ( +
+
+ {props.title} +
+ {props.options.map(({ opt, index }) => ( + + ))} +
+ ); +} + +function ModelOptionRow(props: { + opt: ModelOption; + index: number; + activeIndex: number; + current: ModelRef; + registerOptionRef: (index: number) => (el: HTMLDivElement | null) => void; + onSetActiveIndex: (index: number) => void; + onSelect: (model: ModelRef) => void; + onBehaviorChange: (model: ModelRef, value: string | null) => void; +}) { + const { opt } = props; + const active = modelEquals(props.current, { + providerID: opt.providerID, + modelID: opt.modelID, + }); + const isKeyboardActive = props.index === props.activeIndex; + const selectOption = () => props.onSelect({ providerID: opt.providerID, modelID: opt.modelID }); + + return ( +
props.onSetActiveIndex(props.index)} + onClick={selectOption} + onKeyDown={(event: ReactKeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + if (event.nativeEvent.isComposing) return; + event.preventDefault(); + selectOption(); + }} + > +
+ +
+
+ {opt.title} +
+
+ {opt.description ?? opt.providerID} + + {opt.providerID}/{opt.modelID} + +
+ {opt.footer ? ( +
+ {opt.footer} +
+ ) : null} + {active && (opt.behaviorOptions?.length ?? 0) > 0 ? : null} +
+
+
+ ); +} + +function ModelBehaviorOptions(props: { + opt: ModelOption; + onBehaviorChange: (model: ModelRef, value: string | null) => void; +}) { + return ( +
event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}> + {props.opt.behaviorTitle}: +
+ {(props.opt.behaviorOptions ?? []).map((option) => ( + + ))} +
+
+ ); +} + +function ProviderLinkRow(props: { + provider: ProviderLinkItem; + activeIndex: number; + registerOptionRef: (index: number) => (el: HTMLDivElement | null) => void; + onSetActiveIndex: (index: number) => void; + onOpenSettings: () => void; + onClose: (options?: { restorePromptFocus?: boolean }) => void; +}) { + const isKeyboardActive = props.provider.index === props.activeIndex; + const openProviderSettings = () => { + props.onClose({ restorePromptFocus: false }); + props.onOpenSettings(); + }; + + return ( +
props.onSetActiveIndex(props.provider.index)} + onClick={openProviderSettings} + onKeyDown={(event: ReactKeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + if (event.nativeEvent.isComposing) return; + event.preventDefault(); + openProviderSettings(); + }} + > +
+ +
+
+ {props.provider.title} +
+
+ {t("model_picker.connect_provider_hint")} + {t("model_picker.model_count", { count: props.provider.matchCount })} +
+
+
+
+ ); +} + +export function ModelPickerSelectedIcon({ active }: { active: boolean }) { + return active ? : ; +} diff --git a/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx b/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx index 9af2c776f..a992957ca 100644 --- a/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx +++ b/apps/app/src/react-app/domains/session/modals/model-picker-modal.tsx @@ -6,44 +6,11 @@ import { useMemo, useRef, useState, - type KeyboardEvent as ReactKeyboardEvent, } from "react"; -import { CheckCircle2, Circle, Search, X } from "lucide-react"; -import { t } from "../../../../i18n"; import { modelEquals } from "../../../../app/utils"; import type { ModelOption, ModelRef } from "../../../../app/types"; - -// Minimal inline provider icon placeholder. The full ProviderIcon gets ported -// from src/app/components/provider-icon.tsx in a later step of the plan. -function ProviderIcon({ - providerId, - size = 16, - className, -}: { - providerId: string; - size?: number; - className?: string; -}) { - const initial = providerId.trim().charAt(0).toUpperCase() || "?"; - return ( - - {initial} - - ); -} +import { ModelPickerDialog, type ProviderLinkItem } from "./model-picker-modal-components"; export type ModelPickerModalProps = { open: boolean; @@ -272,317 +239,26 @@ export function ModelPickerModal(props: ModelPickerModalProps) { optionRefs.current[index] = el; }; - const renderOption = (opt: ModelOption, index: number) => { - const active = modelEquals(props.current, { - providerID: opt.providerID, - modelID: opt.modelID, - }); - const isKeyboardActive = index === activeIndex; - - return ( -
setActiveIndex(index)} - onClick={() => - props.onSelect({ - providerID: opt.providerID, - modelID: opt.modelID, - }) - } - onKeyDown={(event: ReactKeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - if (event.nativeEvent.isComposing) return; - event.preventDefault(); - props.onSelect({ - providerID: opt.providerID, - modelID: opt.modelID, - }); - }} - > -
- -
-
- {opt.title} -
-
- - {opt.description ?? opt.providerID} - - - {opt.providerID}/{opt.modelID} - -
- {opt.footer ? ( -
- {opt.footer} -
- ) : null} - {active && (opt.behaviorOptions?.length ?? 0) > 0 ? ( -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > - - {opt.behaviorTitle}: - -
- {(opt.behaviorOptions ?? []).map((option) => ( - - ))} -
-
- ) : null} -
-
-
- ); - }; - - const renderProviderLink = (provider: { - providerID: string; - title: string; - matchCount: number; - index: number; - }) => { - const isKeyboardActive = provider.index === activeIndex; - return ( -
setActiveIndex(provider.index)} - onClick={() => { - props.onClose({ restorePromptFocus: false }); - props.onOpenSettings(); - }} - onKeyDown={(event: ReactKeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - if (event.nativeEvent.isComposing) return; - event.preventDefault(); - props.onClose({ restorePromptFocus: false }); - props.onOpenSettings(); - }} - > -
- -
-
- {provider.title} -
-
- - {t("model_picker.connect_provider_hint")} - - - {t("model_picker.model_count", { count: provider.matchCount })} - -
-
-
-
- ); - }; - return ( -
-
-
-
-
-

- {t( - props.target === "default" - ? "model_picker.default_model_title" - : "model_picker.chat_model_title", - )} -

-

- {t( - props.target === "default" - ? "model_picker.default_model_desc" - : "model_picker.chat_model_desc", - )} -

-
- -
- -
-
- - props.setQuery(event.currentTarget.value)} - placeholder={t("settings.search_models")} - className="w-full bg-dls-surface border border-dls-border rounded-xl py-2.5 pl-9 pr-3 text-sm text-dls-text placeholder:text-dls-secondary focus:outline-none focus:ring-1 focus:ring-[rgba(var(--dls-accent-rgb),0.2)] focus:border-dls-accent" - /> -
- {props.query.trim() ? ( -
- {t("settings.showing_models", { - count: filteredOptions.length, - total: props.options.length, - })} -
- ) : null} -
- -
- {recommendedOptions.length > 0 ? ( -
-
- {t("model_picker.recommended")} -
- {recommendedOptions.map(({ opt, index }) => - renderOption(opt, index), - )} -
- ) : null} - - {otherEnabledOptions.length > 0 ? ( -
-
- {t("model_picker.other_connected_models")} -
- {otherEnabledOptions.map(({ opt, index }) => - renderOption(opt, index), - )} -
- ) : null} - - {otherOptions.length > 0 ? ( -
-
- {t("model_picker.more_providers")} -
- {otherOptions.map(renderProviderLink)} -
- ) : null} - - {renderedItems.length === 0 ? ( -
- {t("model_picker.no_results")} -
- ) : null} -
- -
- -
-
-
-
+ ); } - -// Small helper so downstream callers can keep the check/circle icons -// colocated with the picker (future use for selected-state ornaments). -export function ModelPickerSelectedIcon({ active }: { active: boolean }) { - return active ? : ; -} diff --git a/apps/app/src/react-app/domains/session/surface/scroll-controller-state.ts b/apps/app/src/react-app/domains/session/surface/scroll-controller-state.ts new file mode 100644 index 000000000..49ddbf0f5 --- /dev/null +++ b/apps/app/src/react-app/domains/session/surface/scroll-controller-state.ts @@ -0,0 +1,30 @@ +type SessionScrollMode = "follow-latest" | "manual-browse"; + +type SessionScrollState = { + mode: SessionScrollMode; + topClippedMessageId: string | null; +}; + +type SessionScrollAction = + | { type: "mode"; mode: SessionScrollMode } + | { type: "topClippedMessage"; id: string | null } + | { type: "followLatest" }; + +export const initialSessionScrollState: SessionScrollState = { + mode: "follow-latest", + topClippedMessageId: null, +}; + +export function sessionScrollReducer( + state: SessionScrollState, + action: SessionScrollAction, +): SessionScrollState { + switch (action.type) { + case "mode": + return { ...state, mode: action.mode }; + case "topClippedMessage": + return { ...state, topClippedMessageId: action.id }; + case "followLatest": + return { mode: "follow-latest", topClippedMessageId: null }; + } +} diff --git a/apps/app/src/react-app/domains/session/surface/scroll-controller.ts b/apps/app/src/react-app/domains/session/surface/scroll-controller.ts index 6d9ab4fee..dc273e6c9 100644 --- a/apps/app/src/react-app/domains/session/surface/scroll-controller.ts +++ b/apps/app/src/react-app/domains/session/surface/scroll-controller.ts @@ -1,4 +1,6 @@ -import { useCallback, useEffect, useRef, useState, type RefObject, type UIEventHandler } from "react"; +import { useCallback, useEffect, useReducer, useRef, type RefObject, type UIEventHandler } from "react"; + +import { initialSessionScrollState, sessionScrollReducer } from "./scroll-controller-state"; const FOLLOW_LATEST_BOTTOM_GAP_PX = 96; // Widened from 250ms so a single wheel or trackpad flick isn't missed between @@ -9,8 +11,6 @@ const SCROLL_GESTURE_WINDOW_MS = 600; // follow-latest for pixel-level content growth. const MANUAL_BROWSE_UPWARD_THRESHOLD_PX = 16; -type SessionScrollMode = "follow-latest" | "manual-browse"; - type SessionScrollControllerOptions = { selectedSessionId: string | null; renderedMessages: unknown; @@ -21,8 +21,10 @@ type SessionScrollControllerOptions = { export function useSessionScrollController( options: SessionScrollControllerOptions, ) { - const [mode, setMode] = useState("follow-latest"); - const [topClippedMessageId, setTopClippedMessageId] = useState(null); + const [{ mode, topClippedMessageId }, dispatchScroll] = useReducer( + sessionScrollReducer, + initialSessionScrollState, + ); const lastKnownScrollTopRef = useRef(0); const programmaticScrollRef = useRef(false); @@ -84,7 +86,7 @@ export function useSessionScrollController( const refreshTopClippedMessage = useCallback(() => { const container = options.containerRef.current; if (!container) { - setTopClippedMessageId(null); + dispatchScroll({ type: "topClippedMessage", id: null }); return; } @@ -113,7 +115,7 @@ export function useSessionScrollController( break; } - setTopClippedMessageId(nextId); + dispatchScroll({ type: "topClippedMessage", id: nextId }); }, [options.containerRef]); const scrollToBottom = useCallback( @@ -121,8 +123,7 @@ export function useSessionScrollController( const container = options.containerRef.current; if (!container) return; - setMode("follow-latest"); - setTopClippedMessageId(null); + dispatchScroll({ type: "followLatest" }); programmaticScrollRef.current = true; if (behavior === "smooth") { @@ -162,7 +163,7 @@ export function useSessionScrollController( if (programmaticScrollRef.current && (userGestured || scrolledUp)) { programmaticScrollRef.current = false; clearProgrammaticScrollReset(); - setMode("manual-browse"); + dispatchScroll({ type: "mode", mode: "manual-browse" }); lastKnownScrollTopRef.current = currentTop; refreshTopClippedMessage(); return; @@ -185,9 +186,9 @@ export function useSessionScrollController( const bottomGap = container.scrollHeight - (currentTop + container.clientHeight); if (bottomGap <= FOLLOW_LATEST_BOTTOM_GAP_PX) { - setMode("follow-latest"); + dispatchScroll({ type: "mode", mode: "follow-latest" }); } else if (scrolledUp) { - setMode("manual-browse"); + dispatchScroll({ type: "mode", mode: "manual-browse" }); } lastKnownScrollTopRef.current = currentTop; refreshTopClippedMessage(); @@ -214,7 +215,7 @@ export function useSessionScrollController( ) as HTMLElement | null; if (!target) return; - setMode("manual-browse"); + dispatchScroll({ type: "mode", mode: "manual-browse" }); target.scrollIntoView({ behavior, block: "start" }); }, [options.containerRef, topClippedMessageId], @@ -260,8 +261,7 @@ export function useSessionScrollController( previousSessionIdRef.current = options.selectedSessionId; if (!options.selectedSessionId) return; - setMode("follow-latest"); - setTopClippedMessageId(null); + dispatchScroll({ type: "followLatest" }); observedContentHeightRef.current = 0; queueMicrotask(() => { const container = options.containerRef.current; @@ -277,8 +277,8 @@ export function useSessionScrollController( const latestHeight = latest.getBoundingClientRect().height; const containerHeight = container.getBoundingClientRect().height; if (latestHeight > containerHeight) { - setMode("manual-browse"); - setTopClippedMessageId(null); + dispatchScroll({ type: "mode", mode: "manual-browse" }); + dispatchScroll({ type: "topClippedMessage", id: null }); programmaticScrollRef.current = true; latest.scrollIntoView({ block: "start" }); releaseProgrammaticScrollSoon(); diff --git a/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx b/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx new file mode 100644 index 000000000..30658a67f --- /dev/null +++ b/apps/app/src/react-app/domains/settings/pages/advanced-view-sections.tsx @@ -0,0 +1,317 @@ +/** @jsxImportSource react */ +import type { ReactNode } from "react"; +import { CircleAlert, Cpu, RefreshCcw, Server, Zap } from "lucide-react"; + +import type { OpenworkServerStatus } from "../../../../app/lib/openwork-server"; +import type { EngineInfo } from "../../../../app/lib/desktop"; +import { isDesktopRuntime } from "../../../../app/utils"; +import { t } from "../../../../i18n"; +import { Button } from "../../../design-system/button"; + +const settingsPanelClass = "rounded-[28px] border border-dls-border bg-dls-surface p-5 md:p-6"; +const settingsPanelSoftClass = "rounded-2xl border border-gray-6/60 bg-gray-1/40 p-4"; + +type RuntimeStatusCardProps = { + icon: ReactNode; + title: string; + description: string; + statusLabel: string; + statusStyle: string; + statusDot: string; + detailLines?: string[]; +}; + +function RuntimeStatusCard(props: RuntimeStatusCardProps) { + return ( +
+
+
+ {props.icon} +
+
+
{props.title}
+
{props.description}
+
+
+
+ + {props.statusLabel} +
+ {props.detailLines?.length ? ( +
+ {props.detailLines.map((line) => ( +
+ {line} +
+ ))} +
+ ) : null} +
+ ); +} + +function formatOpencodeBinary(info: EngineInfo | null) { + const binary = info?.opencodeBinPath?.trim(); + if (!binary) return "—"; + const source = info?.opencodeBinSource?.trim(); + return source ? `${binary} (${source})` : binary; +} + +export function AdvancedRuntimeSection(props: { + engineInfo: EngineInfo | null; + clientStatusLabel: string; + clientStatusStyle: string; + clientStatusDot: string; + openworkStatusLabel: string; + openworkStatusStyle: string; + openworkStatusDot: string; +}) { + return ( +
+
+
{t("settings.runtime_title")}
+
{t("settings.runtime_desc")}
+
+ +
+ } + title={t("settings.opencode_engine_label")} + description={t("settings.opencode_engine_desc")} + statusLabel={props.clientStatusLabel} + statusStyle={props.clientStatusStyle} + statusDot={props.clientStatusDot} + detailLines={[ + t("settings.diag_opencode_binary", undefined, { + binary: formatOpencodeBinary(props.engineInfo), + }), + ]} + /> + } + title={t("settings.openwork_server_label")} + description={t("settings.openwork_server_desc")} + statusLabel={props.openworkStatusLabel} + statusStyle={props.openworkStatusStyle} + statusDot={props.openworkStatusDot} + /> +
+
+ ); +} + +export function AdvancedOpencodeSection(props: { busy: boolean; enabled: boolean; onToggle: () => void }) { + return ( +
+
+
{t("settings.opencode_section_label")}
+
{t("settings.opencode_engine_desc")}
+
+ +
+
+
{t("settings.enable_exa")}
+
{t("settings.enable_exa_desc")}
+
+ +
+ +
{t("settings.exa_restart_hint")}
+
+ ); +} + +export function AdvancedFeatureFlagsSection(props: { + busy: boolean; + microsandboxCreateSandboxEnabled: boolean; + onToggleMicrosandboxCreateSandbox: () => void; +}) { + return ( +
+
+
Feature flags
+
Experimental controls for sandbox and workspace behaviors.
+
+ +
+
+
Create Sandbox uses microsandbox image
+
+ When enabled, Create Sandbox launches the detached worker with the microsandbox image flow instead of the default Docker image flow. +
+
+ +
+
+ ); +} + +export function AdvancedDeveloperSection(props: { + busy: boolean; + developerMode: boolean; + opencodeDevModeEnabled: boolean; + deepLinkOpen: boolean; + deepLinkInput: string; + deepLinkBusy: boolean; + deepLinkStatus: string | null; + onToggleDeveloperMode: () => void; + onToggleDeepLink: () => void; + onDeepLinkInput: (input: string) => void; + onSubmitDeepLink: () => Promise; +}) { + return ( +
+
{t("settings.developer_mode_title")}
+
{t("settings.developer_mode_desc")}
+
+ +
+ {props.developerMode ? t("settings.developer_panel_enabled") : t("settings.developer_panel_disabled")} +
+
+ + {isDesktopRuntime() && props.opencodeDevModeEnabled && props.developerMode ? ( +
+
+
+
{t("settings.open_deeplink_title")}
+
{t("settings.open_deeplink_desc")}
+
+ +
+ + {props.deepLinkOpen ? ( +
+