mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-27 13:41:34 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6daa96d97 | |||
| 0a8259bc15 |
@@ -0,0 +1,73 @@
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
export type ShellOption = {
|
||||
path: string
|
||||
name: string
|
||||
acceptable: boolean
|
||||
}
|
||||
|
||||
export type ShellSelectOption = {
|
||||
id: string
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export function createShellOptions(input: {
|
||||
shells: ShellOption[]
|
||||
current: string | undefined
|
||||
automatic: string
|
||||
terminalOnly: string
|
||||
}) {
|
||||
const counts = input.shells.reduce((result, shell) => {
|
||||
result.set(shell.name, (result.get(shell.name) ?? 0) + 1)
|
||||
return result
|
||||
}, new Map<string, number>())
|
||||
const options: ShellSelectOption[] = [
|
||||
{ id: "auto", value: "", label: input.automatic },
|
||||
...input.shells.map((shell) => {
|
||||
const ambiguous = (counts.get(shell.name) ?? 0) > 1
|
||||
const name = ambiguous ? shell.path : shell.name
|
||||
return {
|
||||
id: shell.path,
|
||||
value: ambiguous ? shell.path : shell.name,
|
||||
label: shell.acceptable ? name : `${name} (${input.terminalOnly})`,
|
||||
}
|
||||
}),
|
||||
]
|
||||
if (input.current && !options.some((option) => option.value === input.current)) {
|
||||
options.push({ id: input.current, value: input.current, label: input.current })
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) {
|
||||
let cleanup: (() => void) | undefined
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
let run = 0
|
||||
|
||||
const stop = () => {
|
||||
run += 1
|
||||
cleanup?.()
|
||||
clearTimeout(timeout)
|
||||
cleanup = undefined
|
||||
timeout = undefined
|
||||
}
|
||||
const play = (id: string | undefined) => {
|
||||
stop()
|
||||
if (!id) return
|
||||
const current = ++run
|
||||
timeout = setTimeout(() => {
|
||||
timeout = undefined
|
||||
void player(id).then((next) => {
|
||||
if (run === current) {
|
||||
cleanup = next
|
||||
return
|
||||
}
|
||||
next?.()
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
onCleanup(stop)
|
||||
return { play, stop }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test, vi } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
||||
|
||||
describe("settings v2 controllers", () => {
|
||||
test("normalizes shell names and preserves an unavailable configured shell", () => {
|
||||
expect(
|
||||
createShellOptions({
|
||||
shells: [
|
||||
{ path: "/bin/bash", name: "bash", acceptable: true },
|
||||
{ path: "/opt/bash", name: "bash", acceptable: false },
|
||||
{ path: "/bin/zsh", name: "zsh", acceptable: true },
|
||||
],
|
||||
current: "fish",
|
||||
automatic: "Automatic",
|
||||
terminalOnly: "Terminal only",
|
||||
}),
|
||||
).toEqual([
|
||||
{ id: "auto", value: "", label: "Automatic" },
|
||||
{ id: "/bin/bash", value: "/bin/bash", label: "/bin/bash" },
|
||||
{ id: "/opt/bash", value: "/opt/bash", label: "/opt/bash (Terminal only)" },
|
||||
{ id: "/bin/zsh", value: "zsh", label: "zsh" },
|
||||
{ id: "fish", value: "fish", label: "fish" },
|
||||
])
|
||||
})
|
||||
|
||||
test("debounces previews and stops owned audio on disposal", async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const played: string[] = []
|
||||
const stopped: string[] = []
|
||||
const owned = createRoot((dispose) => ({
|
||||
dispose,
|
||||
preview: createSoundPreviewController(async (id) => {
|
||||
played.push(id ?? "")
|
||||
return () => stopped.push(id ?? "")
|
||||
}),
|
||||
}))
|
||||
|
||||
owned.preview.play("first")
|
||||
vi.advanceTimersByTime(99)
|
||||
expect(played).toEqual([])
|
||||
|
||||
owned.preview.play("second")
|
||||
vi.advanceTimersByTime(100)
|
||||
await Promise.resolve()
|
||||
expect(played).toEqual(["second"])
|
||||
|
||||
owned.dispose()
|
||||
expect(stopped).toEqual(["second"])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
|
||||
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
monoInput,
|
||||
sansDefault,
|
||||
sansFontFamily,
|
||||
sansInput,
|
||||
terminalDefault,
|
||||
terminalFontFamily,
|
||||
terminalInput,
|
||||
useSettings,
|
||||
} from "@/context/settings"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import {
|
||||
createShellOptions,
|
||||
createSoundPreviewController,
|
||||
type ShellOption,
|
||||
type ShellSelectOption,
|
||||
} from "./general-controller-behavior"
|
||||
|
||||
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
||||
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
|
||||
|
||||
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
|
||||
const permission = usePermission()
|
||||
const serverSync = useServerSync()
|
||||
const directory = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return undefined
|
||||
return serverSync().session.lineage.peek(id)?.session.directory
|
||||
})
|
||||
|
||||
return {
|
||||
accepting: createMemo(() => {
|
||||
const id = sessionID()
|
||||
const dir = directory()
|
||||
if (!id || !dir) return false
|
||||
return permission.isAutoAccepting(id, dir)
|
||||
}),
|
||||
enabled: createMemo(() => !!directory()),
|
||||
set: (checked: boolean) => {
|
||||
const id = sessionID()
|
||||
const dir = directory()
|
||||
if (!id || !dir) return
|
||||
if (checked) return permission.enableAutoAccept(id, dir)
|
||||
permission.disableAutoAccept(id, dir)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createShellSettingsController() {
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const [shells] = createResource(
|
||||
async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? []
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
const current = createMemo(() => serverSync().data.config.shell ?? "")
|
||||
const options = createMemo(() =>
|
||||
createShellOptions({
|
||||
shells: shells.latest,
|
||||
current: current(),
|
||||
automatic: language.t("settings.general.row.shell.autoDefault"),
|
||||
terminalOnly: language.t("settings.general.row.shell.terminalOnly"),
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
current: createMemo(() => options().find((option) => option.value === current()) ?? options()[0]),
|
||||
options,
|
||||
select: (option: ShellSelectOption | null) => {
|
||||
if (!option || option.value === current()) return
|
||||
void serverSync().updateConfig({ shell: option.value })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createAppearanceSettingsController() {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const schemes = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
{ value: "light", label: language.t("theme.scheme.light") },
|
||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||
])
|
||||
const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
onMount(() => void theme.loadThemes())
|
||||
|
||||
return {
|
||||
scheme: {
|
||||
options: schemes,
|
||||
current: createMemo(() => schemes().find((option) => option.value === theme.colorScheme())),
|
||||
select: (option: { value: ColorScheme } | null) => option && theme.setColorScheme(option.value),
|
||||
},
|
||||
theme: {
|
||||
options: themes,
|
||||
current: createMemo(() => themes().find((option) => option.id === theme.themeId())),
|
||||
select: (option: { id: string } | null) => option && theme.setTheme(option.id),
|
||||
},
|
||||
fonts: {
|
||||
ui: createMemo(() => ({
|
||||
value: sansInput(settings.appearance.uiFont()),
|
||||
family: sansFontFamily(settings.appearance.uiFont()),
|
||||
placeholder: sansDefault,
|
||||
})),
|
||||
code: createMemo(() => ({
|
||||
value: monoInput(settings.appearance.font()),
|
||||
family: monoFontFamily(settings.appearance.font()),
|
||||
placeholder: monoDefault,
|
||||
})),
|
||||
terminal: createMemo(() => ({
|
||||
value: terminalInput(settings.appearance.terminalFont()),
|
||||
family: terminalFontFamily(settings.appearance.terminalFont()),
|
||||
placeholder: terminalDefault,
|
||||
})),
|
||||
setUI: (value: string) => settings.appearance.setUIFont(value),
|
||||
setCode: (value: string) => settings.appearance.setFont(value),
|
||||
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
export const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
export type SoundSelectOption = (typeof soundOptions)[number]
|
||||
|
||||
export function createSoundSettingsController() {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const preview = createSoundPreviewController(playSoundById)
|
||||
const channel = (
|
||||
enabled: Accessor<boolean>,
|
||||
current: Accessor<string>,
|
||||
setEnabled: (value: boolean) => void,
|
||||
set: (id: string) => void,
|
||||
) => ({
|
||||
current: createMemo(() =>
|
||||
enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound,
|
||||
),
|
||||
highlight: (option: SoundSelectOption | undefined) => {
|
||||
if (!option) return
|
||||
preview.play(option.id === "none" ? undefined : option.id)
|
||||
},
|
||||
select: (option: SoundSelectOption | null) => {
|
||||
if (!option) return
|
||||
if (option.id === "none") {
|
||||
setEnabled(false)
|
||||
preview.stop()
|
||||
return
|
||||
}
|
||||
setEnabled(true)
|
||||
set(option.id)
|
||||
preview.play(option.id)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
options: soundOptions,
|
||||
label: (option: SoundSelectOption) => language.t(option.label),
|
||||
agent: channel(
|
||||
settings.sounds.agentEnabled,
|
||||
settings.sounds.agent,
|
||||
(value) => settings.sounds.setAgentEnabled(value),
|
||||
(id) => settings.sounds.setAgent(id),
|
||||
),
|
||||
permissions: channel(
|
||||
settings.sounds.permissionsEnabled,
|
||||
settings.sounds.permissions,
|
||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
||||
(id) => settings.sounds.setPermissions(id),
|
||||
),
|
||||
errors: channel(
|
||||
settings.sounds.errorsEnabled,
|
||||
settings.sounds.errors,
|
||||
(value) => settings.sounds.setErrorsEnabled(value),
|
||||
(id) => settings.sounds.setErrors(id),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
|
||||
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
|
||||
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
|
||||
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
|
||||
@@ -1,182 +1,346 @@
|
||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||
import { Component, Show, createMemo, createResource, type Accessor } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
monoInput,
|
||||
sansDefault,
|
||||
sansFontFamily,
|
||||
sansInput,
|
||||
terminalDefault,
|
||||
terminalFontFamily,
|
||||
terminalInput,
|
||||
useSettings,
|
||||
} from "@/context/settings"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { Link } from "../link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
import {
|
||||
createAppearanceSettingsController,
|
||||
createPermissionScopeController,
|
||||
createShellSettingsController,
|
||||
createSoundSettingsController,
|
||||
type AppearanceSettingsController,
|
||||
type PermissionScopeController,
|
||||
type ShellSelectOption,
|
||||
type ShellSettingsController,
|
||||
type SoundSelectOption,
|
||||
type SoundSettingsController,
|
||||
} from "./general-controllers"
|
||||
import "./settings-v2.css"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
timeout: undefined as NodeJS.Timeout | undefined,
|
||||
run: 0,
|
||||
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<PermissionScopeSettingView
|
||||
title={language.t("command.permissions.autoaccept.enable")}
|
||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||
checked={props.controller.accepting}
|
||||
enabled={props.controller.enabled}
|
||||
onChange={(checked) => props.controller.set(checked)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ThemeOption = {
|
||||
id: string
|
||||
name: string
|
||||
const PermissionScopeSettingView: Component<{
|
||||
title: string
|
||||
description: string
|
||||
checked: Accessor<boolean>
|
||||
enabled: Accessor<boolean>
|
||||
onChange: (checked: boolean) => void
|
||||
}> = (props) => (
|
||||
<SettingsRowV2 title={props.title} description={props.description}>
|
||||
<div data-action="settings-auto-accept-permissions">
|
||||
<Switch checked={props.checked()} disabled={!props.enabled()} onChange={props.onChange} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
|
||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<ShellSettingView
|
||||
title={language.t("settings.general.row.shell.title")}
|
||||
description={language.t("settings.general.row.shell.description")}
|
||||
options={props.controller.options}
|
||||
current={props.controller.current}
|
||||
onSelect={(option) => props.controller.select(option)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ShellOption = {
|
||||
path: string
|
||||
name: string
|
||||
acceptable: boolean
|
||||
const ShellSettingView: Component<{
|
||||
title: string
|
||||
description: string
|
||||
options: Accessor<ShellSelectOption[]>
|
||||
current: Accessor<ShellSelectOption | undefined>
|
||||
onSelect: (option: ShellSelectOption | null) => void
|
||||
}> = (props) => (
|
||||
<SettingsRowV2 title={props.title} description={props.description}>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-shell"
|
||||
options={props.options()}
|
||||
current={props.current()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(option) => option.id}
|
||||
label={(option) => option.label}
|
||||
onSelect={props.onSelect}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
|
||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<AppearanceSectionView
|
||||
t={language.t}
|
||||
schemeOptions={props.controller.scheme.options}
|
||||
currentScheme={props.controller.scheme.current}
|
||||
onSchemeSelect={props.controller.scheme.select}
|
||||
themeOptions={props.controller.theme.options}
|
||||
currentTheme={props.controller.theme.current}
|
||||
onThemeSelect={props.controller.theme.select}
|
||||
uiFont={props.controller.fonts.ui}
|
||||
codeFont={props.controller.fonts.code}
|
||||
terminalFont={props.controller.fonts.terminal}
|
||||
onUIFontInput={props.controller.fonts.setUI}
|
||||
onCodeFontInput={props.controller.fonts.setCode}
|
||||
onTerminalFontInput={props.controller.fonts.setTerminal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ShellSelectOption = {
|
||||
id: string
|
||||
value: string
|
||||
label: string
|
||||
type AppearanceSectionViewProps = {
|
||||
t: ReturnType<typeof useLanguage>["t"]
|
||||
schemeOptions: AppearanceSettingsController["scheme"]["options"]
|
||||
currentScheme: AppearanceSettingsController["scheme"]["current"]
|
||||
onSchemeSelect: AppearanceSettingsController["scheme"]["select"]
|
||||
themeOptions: AppearanceSettingsController["theme"]["options"]
|
||||
currentTheme: AppearanceSettingsController["theme"]["current"]
|
||||
onThemeSelect: AppearanceSettingsController["theme"]["select"]
|
||||
uiFont: AppearanceSettingsController["fonts"]["ui"]
|
||||
codeFont: AppearanceSettingsController["fonts"]["code"]
|
||||
terminalFont: AppearanceSettingsController["fonts"]["terminal"]
|
||||
onUIFontInput: (value: string) => void
|
||||
onCodeFontInput: (value: string) => void
|
||||
onTerminalFontInput: (value: string) => void
|
||||
}
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const stopDemoSound = () => {
|
||||
demoSoundState.run += 1
|
||||
if (demoSoundState.cleanup) {
|
||||
demoSoundState.cleanup()
|
||||
}
|
||||
clearTimeout(demoSoundState.timeout)
|
||||
demoSoundState.cleanup = undefined
|
||||
const AppearanceSectionView: Component<AppearanceSectionViewProps> = (props) => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{props.t("settings.general.section.appearance")}</h3>
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={props.t("settings.general.row.colorScheme.title")}
|
||||
description={props.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-color-scheme"
|
||||
options={props.schemeOptions()}
|
||||
current={props.currentScheme()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
onSelect={props.onSchemeSelect}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={props.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{props.t("settings.general.row.theme.description")}{" "}
|
||||
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||
{props.t("common.learnMore")}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-theme"
|
||||
options={props.themeOptions()}
|
||||
current={props.currentTheme()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(option) => option.id}
|
||||
label={(option) => option.name}
|
||||
onSelect={props.onThemeSelect}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<FontSettingView
|
||||
action="settings-ui-font"
|
||||
title={props.t("settings.general.row.uiFont.title")}
|
||||
description={props.t("settings.general.row.uiFont.description")}
|
||||
font={props.uiFont}
|
||||
onInput={props.onUIFontInput}
|
||||
/>
|
||||
<FontSettingView
|
||||
action="settings-code-font"
|
||||
title={props.t("settings.general.row.font.title")}
|
||||
description={props.t("settings.general.row.font.description")}
|
||||
font={props.codeFont}
|
||||
onInput={props.onCodeFontInput}
|
||||
/>
|
||||
<FontSettingView
|
||||
action="settings-terminal-font"
|
||||
title={props.t("settings.general.row.terminalFont.title")}
|
||||
description={props.t("settings.general.row.terminalFont.description")}
|
||||
font={props.terminalFont}
|
||||
onInput={props.onTerminalFontInput}
|
||||
/>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const FontSettingView: Component<{
|
||||
action: string
|
||||
title: string
|
||||
description: string
|
||||
font: Accessor<{ value: string; family: string; placeholder: string }>
|
||||
onInput: (value: string) => void
|
||||
}> = (props) => (
|
||||
<SettingsRowV2 title={props.title} description={props.description}>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action={props.action}
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={props.font().value}
|
||||
onInput={(event) => props.onInput(event.currentTarget.value)}
|
||||
placeholder={props.font().placeholder}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={props.title}
|
||||
style={{ "font-family": props.font().family }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
|
||||
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<SoundsSectionView
|
||||
t={language.t}
|
||||
options={props.controller.options}
|
||||
label={props.controller.label}
|
||||
agentCurrent={props.controller.agent.current}
|
||||
onAgentHighlight={(option) => props.controller.agent.highlight(option)}
|
||||
onAgentSelect={(option) => props.controller.agent.select(option)}
|
||||
permissionsCurrent={props.controller.permissions.current}
|
||||
onPermissionsHighlight={(option) => props.controller.permissions.highlight(option)}
|
||||
onPermissionsSelect={(option) => props.controller.permissions.select(option)}
|
||||
errorsCurrent={props.controller.errors.current}
|
||||
onErrorsHighlight={(option) => props.controller.errors.highlight(option)}
|
||||
onErrorsSelect={(option) => props.controller.errors.select(option)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const playDemoSound = (id: string | undefined) => {
|
||||
stopDemoSound()
|
||||
if (!id) return
|
||||
const SoundsSectionView: Component<{
|
||||
t: ReturnType<typeof useLanguage>["t"]
|
||||
options: SoundSelectOption[]
|
||||
label: (option: SoundSelectOption) => string
|
||||
agentCurrent: Accessor<SoundSelectOption>
|
||||
onAgentHighlight: (option: SoundSelectOption | undefined) => void
|
||||
onAgentSelect: (option: SoundSelectOption | null) => void
|
||||
permissionsCurrent: Accessor<SoundSelectOption>
|
||||
onPermissionsHighlight: (option: SoundSelectOption | undefined) => void
|
||||
onPermissionsSelect: (option: SoundSelectOption | null) => void
|
||||
errorsCurrent: Accessor<SoundSelectOption>
|
||||
onErrorsHighlight: (option: SoundSelectOption | undefined) => void
|
||||
onErrorsSelect: (option: SoundSelectOption | null) => void
|
||||
}> = (props) => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{props.t("settings.general.section.sounds")}</h3>
|
||||
<SettingsListV2>
|
||||
<SoundSettingView
|
||||
action="settings-sounds-agent"
|
||||
title={props.t("settings.general.sounds.agent.title")}
|
||||
description={props.t("settings.general.sounds.agent.description")}
|
||||
options={props.options}
|
||||
label={props.label}
|
||||
current={props.agentCurrent}
|
||||
onHighlight={props.onAgentHighlight}
|
||||
onSelect={props.onAgentSelect}
|
||||
/>
|
||||
<SoundSettingView
|
||||
action="settings-sounds-permissions"
|
||||
title={props.t("settings.general.sounds.permissions.title")}
|
||||
description={props.t("settings.general.sounds.permissions.description")}
|
||||
options={props.options}
|
||||
label={props.label}
|
||||
current={props.permissionsCurrent}
|
||||
onHighlight={props.onPermissionsHighlight}
|
||||
onSelect={props.onPermissionsSelect}
|
||||
/>
|
||||
<SoundSettingView
|
||||
action="settings-sounds-errors"
|
||||
title={props.t("settings.general.sounds.errors.title")}
|
||||
description={props.t("settings.general.sounds.errors.description")}
|
||||
options={props.options}
|
||||
label={props.label}
|
||||
current={props.errorsCurrent}
|
||||
onHighlight={props.onErrorsHighlight}
|
||||
onSelect={props.onErrorsSelect}
|
||||
/>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const run = ++demoSoundState.run
|
||||
demoSoundState.timeout = setTimeout(() => {
|
||||
void playSoundById(id).then((cleanup) => {
|
||||
if (demoSoundState.run !== run) {
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
demoSoundState.cleanup = cleanup
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
const SoundSettingView: Component<{
|
||||
action: string
|
||||
title: string
|
||||
description: string
|
||||
options: SoundSelectOption[]
|
||||
label: (option: SoundSelectOption) => string
|
||||
current: Accessor<SoundSelectOption>
|
||||
onHighlight: (option: SoundSelectOption | undefined) => void
|
||||
onSelect: (option: SoundSelectOption | null) => void
|
||||
}> = (props) => (
|
||||
<SettingsRowV2 title={props.title} description={props.description}>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action={props.action}
|
||||
options={props.options}
|
||||
current={props.current()}
|
||||
value={(option) => option.id}
|
||||
label={props.label}
|
||||
onHighlight={props.onHighlight}
|
||||
onSelect={props.onSelect}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
|
||||
export const SettingsGeneralV2: Component<{
|
||||
sessionID?: string
|
||||
}> = (props) => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
|
||||
const dir = createMemo(() => {
|
||||
if (!props.sessionID) return undefined
|
||||
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
|
||||
})
|
||||
const accepting = createMemo(() => {
|
||||
const value = dir()
|
||||
if (!value || !props.sessionID) return false
|
||||
return permission.isAutoAccepting(props.sessionID, value)
|
||||
})
|
||||
|
||||
const toggleAccept = (checked: boolean) => {
|
||||
const value = dir()
|
||||
if (!value || !props.sessionID) return
|
||||
|
||||
if (checked) {
|
||||
permission.enableAutoAccept(props.sessionID, value)
|
||||
return
|
||||
}
|
||||
|
||||
permission.disableAutoAccept(props.sessionID, value)
|
||||
}
|
||||
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
||||
const shell = createShellSettingsController()
|
||||
const appearance = createAppearanceSettingsController()
|
||||
const sounds = createSoundSettingsController()
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const [shells] = createResource(
|
||||
async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
return (await sdk.client.pty.shells()).data ?? []
|
||||
}
|
||||
// return (await sdk.api.pty.shells()).data
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
||||
() => desktop() && "getPinchZoomEnabled" in platform,
|
||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||
{ initialValue: false },
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
})
|
||||
|
||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
||||
|
||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||
const list = shells.latest
|
||||
const current = serverSync().data.config.shell
|
||||
|
||||
const nameCounts = new Map<string, number>()
|
||||
for (const s of list) {
|
||||
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
|
||||
}
|
||||
|
||||
const options = [
|
||||
autoOption,
|
||||
...list.map((s) => {
|
||||
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
|
||||
const text = ambiguousName ? s.path : s.name
|
||||
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
|
||||
return {
|
||||
id: s.path,
|
||||
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
|
||||
value: ambiguousName ? s.path : s.name,
|
||||
label,
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
if (current && !options.some((o) => o.value === current)) {
|
||||
options.push({ id: current, value: current, label: current })
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const onPinchZoomChange = (checked: boolean) => {
|
||||
setPinchZoom(checked)
|
||||
const update = platform.setPinchZoomEnabled?.(checked)
|
||||
@@ -184,12 +348,6 @@ export const SettingsGeneralV2: Component<{
|
||||
void update.catch(() => setPinchZoom(!checked))
|
||||
}
|
||||
|
||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
{ value: "light", label: language.t("theme.scheme.light") },
|
||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||
])
|
||||
|
||||
const languageOptions = createMemo(() =>
|
||||
language.locales.map((locale) => ({
|
||||
value: locale,
|
||||
@@ -197,39 +355,6 @@ export const SettingsGeneralV2: Component<{
|
||||
})),
|
||||
)
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
const mono = () => monoInput(settings.appearance.font())
|
||||
const sans = () => sansInput(settings.appearance.uiFont())
|
||||
const terminal = () => terminalInput(settings.appearance.terminalFont())
|
||||
|
||||
const soundSelectProps = (
|
||||
enabled: () => boolean,
|
||||
current: () => string,
|
||||
setEnabled: (value: boolean) => void,
|
||||
set: (id: string) => void,
|
||||
) => ({
|
||||
options: soundOptions,
|
||||
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
||||
value: (o: (typeof soundOptions)[number]) => o.id,
|
||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||
},
|
||||
onSelect: (option: (typeof soundOptions)[number] | null) => {
|
||||
if (!option) return
|
||||
if (option.id === "none") {
|
||||
setEnabled(false)
|
||||
stopDemoSound()
|
||||
return
|
||||
}
|
||||
setEnabled(true)
|
||||
set(option.id)
|
||||
playDemoSound(option.id)
|
||||
},
|
||||
})
|
||||
|
||||
const InterfaceSection = () => (
|
||||
<LayoutTransitionToggle
|
||||
title={language.t("settings.general.row.newInterface.title")}
|
||||
@@ -251,7 +376,7 @@ export const SettingsGeneralV2: Component<{
|
||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||
onDismiss={settings.general.dismissNewInterfaceNotice}
|
||||
onDismiss={() => settings.general.dismissNewInterfaceNotice()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -275,35 +400,9 @@ export const SettingsGeneralV2: Component<{
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("command.permissions.autoaccept.enable")}
|
||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||
>
|
||||
<div data-action="settings-auto-accept-permissions">
|
||||
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
<PermissionScopeSetting controller={permissionScope} />
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.shell.title")}
|
||||
description={language.t("settings.general.row.shell.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-shell"
|
||||
options={shellOptions()}
|
||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
serverSync().updateConfig({ shell: option.value })
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
<ShellSetting controller={shell} />
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
@@ -414,124 +513,6 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
)
|
||||
|
||||
const AppearanceSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.colorScheme.title")}
|
||||
description={language.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-color-scheme"
|
||||
options={colorSchemeOptions()}
|
||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||
{language.t("common.learnMore")}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-theme"
|
||||
options={themeOptions()}
|
||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.name}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
theme.setTheme(option.id)
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.uiFont.title")}
|
||||
description={language.t("settings.general.row.uiFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action="settings-ui-font"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={sans()}
|
||||
onInput={(event) => settings.appearance.setUIFont(event.currentTarget.value)}
|
||||
placeholder={sansDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.general.row.uiFont.title")}
|
||||
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.font.title")}
|
||||
description={language.t("settings.general.row.font.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action="settings-code-font"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={mono()}
|
||||
onInput={(event) => settings.appearance.setFont(event.currentTarget.value)}
|
||||
placeholder={monoDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.general.row.font.title")}
|
||||
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.terminalFont.title")}
|
||||
description={language.t("settings.general.row.terminalFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action="settings-terminal-font"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={terminal()}
|
||||
onInput={(event) => settings.appearance.setTerminalFont(event.currentTarget.value)}
|
||||
placeholder={terminalDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.general.row.terminalFont.title")}
|
||||
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const NotificationsSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||
@@ -576,68 +557,6 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
)
|
||||
|
||||
const SoundsSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.sounds.agent.title")}
|
||||
description={language.t("settings.general.sounds.agent.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-sounds-agent"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.agentEnabled(),
|
||||
() => settings.sounds.agent(),
|
||||
(value) => settings.sounds.setAgentEnabled(value),
|
||||
(id) => settings.sounds.setAgent(id),
|
||||
)}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.sounds.permissions.title")}
|
||||
description={language.t("settings.general.sounds.permissions.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-sounds-permissions"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.permissionsEnabled(),
|
||||
() => settings.sounds.permissions(),
|
||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
||||
(id) => settings.sounds.setPermissions(id),
|
||||
)}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.sounds.errors.title")}
|
||||
description={language.t("settings.general.sounds.errors.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-sounds-errors"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.errorsEnabled(),
|
||||
() => settings.sounds.errors(),
|
||||
(value) => settings.sounds.setErrorsEnabled(value),
|
||||
(id) => settings.sounds.setErrors(id),
|
||||
)}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const UpdatesSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
||||
@@ -659,7 +578,7 @@ export const SettingsGeneralV2: Component<{
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
|
||||
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}>
|
||||
{language.t(updater.action().label)}
|
||||
</ButtonV2>
|
||||
</SettingsRowV2>
|
||||
@@ -704,11 +623,11 @@ export const SettingsGeneralV2: Component<{
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection />
|
||||
<AppearanceSection controller={appearance} />
|
||||
|
||||
<NotificationsSection />
|
||||
|
||||
<SoundsSection />
|
||||
<SoundsSection controller={sounds} />
|
||||
|
||||
<Show when={desktop()}>
|
||||
<UpdatesSection />
|
||||
|
||||
@@ -1,290 +1,27 @@
|
||||
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { NewSessionDesignView } from "@/components/session"
|
||||
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import {
|
||||
PromptProjectAddButton,
|
||||
PromptProjectSelector,
|
||||
createPromptProjectController,
|
||||
} from "@/components/prompt-project-selector"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import createPresence from "solid-presence"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||
import { createPromptProjectController } from "@/components/prompt-project-selector"
|
||||
import { createNewSessionCommandController } from "./new-session/new-session-command-controller"
|
||||
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
|
||||
import { NewSession } from "./new-session/new-session"
|
||||
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
|
||||
|
||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
const providerTipExitDuration = 250
|
||||
|
||||
/**
|
||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||
* composer for a brand-new session — no terminal, review pane, file tree, or message
|
||||
* timeline. Submitting promotes the draft into a real session (see prompt-input/submit).
|
||||
*/
|
||||
/** The draft-only V2 session page. Submitting promotes the draft into a real session. */
|
||||
export default function NewSessionPage() {
|
||||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const openProviders = () => {
|
||||
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
|
||||
})
|
||||
}
|
||||
useSettingsCommand()
|
||||
const route = useSessionKey()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const local = useLocal()
|
||||
const model = createPromptModelSelection({ agent: local.agent.current })
|
||||
|
||||
useComposerCommands({ model })
|
||||
|
||||
const inputController = createPromptInputController({
|
||||
sessionKey: route.sessionKey,
|
||||
sessionID: () => route.params.id,
|
||||
queryOptions: serverSync().queryOptions,
|
||||
model,
|
||||
const workspace = createNewSessionWorkspaceController()
|
||||
const draft = createNewSessionDraftController({
|
||||
worktree: workspace.selection.value,
|
||||
resetWorktree: workspace.selection.reset,
|
||||
})
|
||||
const projectControls = createPromptProjectControls()
|
||||
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
const rightMount = useTitlebarRightMount()
|
||||
|
||||
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (!showWorkspaceBar()) return "main"
|
||||
if (store.worktree) return store.worktree
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
return "main"
|
||||
const project = createPromptProjectController({
|
||||
controls: draft.project.controls,
|
||||
onDone: draft.input.restoreFocus,
|
||||
})
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
|
||||
const selectedBranch = createMemo(() => {
|
||||
const worktree = newSessionWorktree()
|
||||
if (worktree === "main" || worktree === "create") return localBranch()
|
||||
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
||||
})
|
||||
const promptInputV2Controller = usePromptInputV2Controller({
|
||||
get controls() {
|
||||
return inputController()
|
||||
const commands = createNewSessionCommandController({
|
||||
restoreFocus: draft.input.restoreFocus,
|
||||
project: {
|
||||
empty: project.empty,
|
||||
open: () => project.setOpen(true),
|
||||
},
|
||||
get newSessionWorktree() {
|
||||
return newSessionWorktree()
|
||||
},
|
||||
onNewSessionWorktreeReset: () => setStore("worktree", undefined),
|
||||
onSubmit: () => comments.clear(),
|
||||
})
|
||||
const projectController = createPromptProjectController({
|
||||
controls: projectControls,
|
||||
onDone: promptInputV2Controller.restoreFocus,
|
||||
})
|
||||
|
||||
command.register("new-session", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
||||
void dialog.show(() => <DialogSelectFile />)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: () => promptInputV2Controller.restoreFocus(),
|
||||
},
|
||||
{
|
||||
id: "project.select",
|
||||
title: language.t("session.new.project.search"),
|
||||
category: language.t("command.category.project"),
|
||||
keybind: "mod+shift+o",
|
||||
disabled: projectController.empty(),
|
||||
onSelect: () => projectController.setOpen(true),
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
untrack(() => {
|
||||
const text = searchParams.prompt
|
||||
if (!text) return
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
setSearchParams({ ...searchParams, prompt: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
promptInputV2Controller.restoreFocus()
|
||||
})
|
||||
|
||||
const ready = Promise.resolve()
|
||||
const [suspendUntilPromptReady] = createResource(
|
||||
() => prompt.ready.promise ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<Show when={rightMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Show when={settings.visibility.status()}>
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||
<NewSessionDesignView>
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<div class="flex flex-col gap-8">
|
||||
<PromptInputV2Composer controller={promptInputV2Controller} />
|
||||
<Show when={projectController.empty()}>
|
||||
<PromptProjectAddButton controller={projectController} />
|
||||
</Show>
|
||||
<Show when={projectController.selected()}>
|
||||
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
|
||||
<PromptProjectSelector controller={projectController} placement="bottom" />
|
||||
<Show
|
||||
when={showWorkspaceBar()}
|
||||
fallback={<PromptGitStatus branch={selectedBranch()} noGit={sync().project?.vcs !== "git"} />}
|
||||
>
|
||||
<PromptWorkspaceSelector
|
||||
value={newSessionWorktree()}
|
||||
projectRoot={projectRoot()}
|
||||
workspaces={sync().project?.sandboxes ?? []}
|
||||
branch={selectedBranch()}
|
||||
onChange={(value) =>
|
||||
setStore(
|
||||
"worktree",
|
||||
value === "main" && sync().project?.worktree !== sdk().directory
|
||||
? sync().project?.worktree
|
||||
: value,
|
||||
)
|
||||
}
|
||||
onDone={promptInputV2Controller.restoreFocus}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
{/*</Show>*/}
|
||||
</div>
|
||||
</NewSessionDesignView>
|
||||
<ProviderTip
|
||||
ready={() => serverSync().child(sdk().directory)[0].provider_ready}
|
||||
connected={() => providers.paid().length > 0}
|
||||
openProviders={openProviders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderTip(props: { ready: () => boolean; connected: () => boolean; openProviders: () => void }) {
|
||||
const language = useLanguage()
|
||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||
Persist.global("new-session.provider-tip"),
|
||||
createStore({ dismissedAt: 0 }),
|
||||
)
|
||||
const visible = createMemo(
|
||||
() =>
|
||||
props.ready() &&
|
||||
persistedReady() &&
|
||||
!props.connected() &&
|
||||
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
||||
)
|
||||
|
||||
function dismiss() {
|
||||
setPersistedState("dismissedAt", Date.now())
|
||||
}
|
||||
|
||||
const [ref, setRef] = createSignal<HTMLDivElement>()
|
||||
const presence = createPresence({
|
||||
show: () => visible(),
|
||||
element: () => ref() ?? null,
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={presence.present()}>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
|
||||
<div
|
||||
ref={setRef}
|
||||
data-component="provider-tip"
|
||||
data-visible={visible()}
|
||||
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||
classList={{
|
||||
"data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
onClick={props.openProviders}
|
||||
>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
|
||||
</span>
|
||||
</button>
|
||||
<TooltipV2
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={language.t("common.dismiss")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={dismiss}
|
||||
>
|
||||
<IconV2 name="xmark-small" />
|
||||
</button>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
return <NewSession draft={draft} commands={commands} workspace={workspace} project={project} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
|
||||
export function createNewSessionCommandController(input: {
|
||||
restoreFocus: () => void
|
||||
project: {
|
||||
empty: () => boolean
|
||||
open: () => void
|
||||
}
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
|
||||
useSettingsCommand()
|
||||
command.register("new-session", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
||||
void dialog.show(() => <DialogSelectFile />)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: input.restoreFocus,
|
||||
},
|
||||
{
|
||||
id: "project.select",
|
||||
title: language.t("session.new.project.search"),
|
||||
category: language.t("command.category.project"),
|
||||
keybind: "mod+shift+o",
|
||||
disabled: input.project.empty(),
|
||||
onSelect: input.project.open,
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
provider: {
|
||||
ready: () => serverSync().child(sdk().directory)[0].provider_ready,
|
||||
connected: () => providers.paid().length > 0,
|
||||
open: () => {
|
||||
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type NewSessionCommandController = ReturnType<typeof createNewSessionCommandController>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { createEffect, untrack } from "solid-js"
|
||||
import { usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
|
||||
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
|
||||
const prompt = usePrompt()
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
const local = useLocal()
|
||||
const route = useSessionKey()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const model = createPromptModelSelection({ agent: () => local.agent.current() })
|
||||
|
||||
useComposerCommands({ model })
|
||||
|
||||
const controls = createPromptInputController({
|
||||
sessionKey: route.sessionKey,
|
||||
sessionID: () => route.params.id,
|
||||
queryOptions: serverSync().queryOptions,
|
||||
model,
|
||||
})
|
||||
const projectControls = createPromptProjectControls()
|
||||
const input = usePromptInputV2Controller({
|
||||
get controls() {
|
||||
return controls()
|
||||
},
|
||||
get newSessionWorktree() {
|
||||
return workspace.worktree()
|
||||
},
|
||||
onNewSessionWorktreeReset: workspace.resetWorktree,
|
||||
onSubmit: comments.clear,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
untrack(() => {
|
||||
const text = searchParams.prompt
|
||||
if (!text) return
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
setSearchParams({ ...searchParams, prompt: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
input,
|
||||
prompt: {
|
||||
ready: prompt.ready,
|
||||
readyPromise: () => prompt.ready.promise,
|
||||
},
|
||||
project: {
|
||||
controls: projectControls,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type NewSessionDraftController = ReturnType<typeof createNewSessionDraftController>
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Show, createEffect, createMemo, createResource, createSignal, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import createPresence from "solid-presence"
|
||||
import { NewSessionDesignView } from "@/components/session"
|
||||
import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
import type { PromptProject } from "@/components/prompt-project-selector"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
export function NewSessionView(props: {
|
||||
rightMount: Accessor<HTMLElement | null>
|
||||
statusVisible: Accessor<boolean>
|
||||
statusLabel: Accessor<string>
|
||||
promptReady: Accessor<boolean>
|
||||
promptReadyPromise: Accessor<Promise<unknown> | undefined>
|
||||
restoreFocus: () => void
|
||||
composer: () => JSX.Element
|
||||
projectEmpty: Accessor<boolean>
|
||||
projectSelected: Accessor<PromptProject | undefined>
|
||||
projectAdd: () => JSX.Element
|
||||
projectSelector: () => JSX.Element
|
||||
workspaceVisible: Accessor<boolean>
|
||||
workspaceValue: Accessor<string>
|
||||
workspaceRoot: Accessor<string>
|
||||
workspaces: Accessor<string[]>
|
||||
branch: Accessor<string | undefined>
|
||||
noGit: Accessor<boolean>
|
||||
onWorkspaceChange: (value: string) => void
|
||||
providerReady: Accessor<boolean>
|
||||
providerConnected: Accessor<boolean>
|
||||
onOpenProviders: () => void
|
||||
}) {
|
||||
createEffect(() => {
|
||||
if (!props.promptReady()) return
|
||||
props.restoreFocus()
|
||||
})
|
||||
|
||||
const ready = Promise.resolve()
|
||||
const [suspendUntilPromptReady] = createResource(
|
||||
() => props.promptReadyPromise() ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<Show when={props.rightMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Show when={props.statusVisible()}>
|
||||
<Tooltip placement="bottom" value={props.statusLabel()}>
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||
<NewSessionDesignView>
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<div class="flex flex-col gap-8">
|
||||
{props.composer()}
|
||||
<Show when={props.projectEmpty()}>{props.projectAdd()}</Show>
|
||||
<Show when={props.projectSelected()}>
|
||||
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
|
||||
{props.projectSelector()}
|
||||
<Show
|
||||
when={props.workspaceVisible()}
|
||||
fallback={<PromptGitStatus branch={props.branch()} noGit={props.noGit()} />}
|
||||
>
|
||||
<PromptWorkspaceSelector
|
||||
value={props.workspaceValue()}
|
||||
projectRoot={props.workspaceRoot()}
|
||||
workspaces={props.workspaces()}
|
||||
branch={props.branch()}
|
||||
onChange={props.onWorkspaceChange}
|
||||
onDone={props.restoreFocus}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</NewSessionDesignView>
|
||||
<ProviderTip
|
||||
ready={props.providerReady}
|
||||
connected={props.providerConnected}
|
||||
openProviders={props.onOpenProviders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderTip(props: { ready: Accessor<boolean>; connected: Accessor<boolean>; openProviders: () => void }) {
|
||||
const language = useLanguage()
|
||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||
Persist.global("new-session.provider-tip"),
|
||||
createStore({ dismissedAt: 0 }),
|
||||
)
|
||||
const visible = createMemo(
|
||||
() =>
|
||||
props.ready() &&
|
||||
persistedReady() &&
|
||||
!props.connected() &&
|
||||
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
||||
)
|
||||
const [ref, setRef] = createSignal<HTMLDivElement>()
|
||||
const presence = createPresence({
|
||||
show: visible,
|
||||
element: () => ref() ?? null,
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={presence.present()}>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
|
||||
<div
|
||||
ref={setRef}
|
||||
data-component="provider-tip"
|
||||
data-visible={visible()}
|
||||
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
onClick={props.openProviders}
|
||||
>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
|
||||
</span>
|
||||
</button>
|
||||
<TooltipV2
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={language.t("common.dismiss")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
||||
>
|
||||
<IconV2 name="xmark-small" />
|
||||
</button>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
normalizeNewSessionWorktree,
|
||||
resolveNewSessionBranch,
|
||||
resolveNewSessionWorktree,
|
||||
} from "./new-session-workspace-controller"
|
||||
|
||||
describe("new session workspace selection", () => {
|
||||
test("uses main when the workspace bar is unavailable", () => {
|
||||
expect(
|
||||
resolveNewSessionWorktree({
|
||||
enabled: false,
|
||||
selected: "/project/feature",
|
||||
directory: "/project/feature",
|
||||
projectWorktree: "/project",
|
||||
}),
|
||||
).toBe("main")
|
||||
})
|
||||
|
||||
test("derives an existing worktree from the current directory", () => {
|
||||
expect(
|
||||
resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }),
|
||||
).toBe("/project/feature")
|
||||
expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe(
|
||||
"main",
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes main to the project root outside the main worktree", () => {
|
||||
expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project")
|
||||
expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main")
|
||||
})
|
||||
|
||||
test("falls back to the local branch for main, create, and unknown worktrees", () => {
|
||||
const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined)
|
||||
expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev")
|
||||
expect(resolveNewSessionBranch({ worktree: "create", local: "dev", worktreeBranch: branch })).toBe("dev")
|
||||
expect(resolveNewSessionBranch({ worktree: "/project/feature", local: "dev", worktreeBranch: branch })).toBe(
|
||||
"feature",
|
||||
)
|
||||
expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
export function resolveNewSessionWorktree(input: {
|
||||
enabled: boolean
|
||||
selected?: string
|
||||
directory: string
|
||||
projectWorktree?: string
|
||||
}) {
|
||||
if (!input.enabled) return "main"
|
||||
if (input.selected) return input.selected
|
||||
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
|
||||
return "main"
|
||||
}
|
||||
|
||||
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
|
||||
if (value === "main" && projectWorktree !== directory) return projectWorktree
|
||||
return value
|
||||
}
|
||||
|
||||
export function resolveNewSessionBranch(input: {
|
||||
worktree: string
|
||||
local?: string
|
||||
worktreeBranch: (worktree: string) => string | undefined
|
||||
}) {
|
||||
if (input.worktree === "main" || input.worktree === "create") return input.local
|
||||
return input.worktreeBranch(input.worktree) ?? input.local
|
||||
}
|
||||
|
||||
export function createNewSessionWorkspaceController() {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||
const value = createMemo(() =>
|
||||
resolveNewSessionWorktree({
|
||||
enabled: visible(),
|
||||
selected: store.worktree,
|
||||
directory: sdk().directory,
|
||||
projectWorktree: sync().project?.worktree,
|
||||
}),
|
||||
)
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
|
||||
const branch = createMemo(() =>
|
||||
resolveNewSessionBranch({
|
||||
worktree: value(),
|
||||
local: localBranch(),
|
||||
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
selection: {
|
||||
value,
|
||||
reset: () => setStore("worktree", undefined),
|
||||
set: (worktree: string) =>
|
||||
setStore("worktree", normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
|
||||
},
|
||||
project: {
|
||||
root: projectRoot,
|
||||
workspaces: () => sync().project?.sandboxes ?? [],
|
||||
git: () => sync().project?.vcs === "git",
|
||||
},
|
||||
bar: {
|
||||
visible,
|
||||
branch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type NewSessionWorkspaceController = ReturnType<typeof createNewSessionWorkspaceController>
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PromptProjectController } from "@/components/prompt-project-selector"
|
||||
import { PromptProjectAddButton, PromptProjectSelector } from "@/components/prompt-project-selector"
|
||||
import { PromptInputV2Composer } from "@/components/prompt-input-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||
import type { NewSessionCommandController } from "./new-session-command-controller"
|
||||
import type { NewSessionDraftController } from "./new-session-draft-controller"
|
||||
import { NewSessionView } from "./new-session-view"
|
||||
import type { NewSessionWorkspaceController } from "./new-session-workspace-controller"
|
||||
|
||||
export function NewSession(props: {
|
||||
draft: NewSessionDraftController
|
||||
commands: NewSessionCommandController
|
||||
workspace: NewSessionWorkspaceController
|
||||
project: PromptProjectController
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const rightMount = useTitlebarRightMount()
|
||||
|
||||
return (
|
||||
<NewSessionView
|
||||
rightMount={rightMount}
|
||||
statusVisible={settings.visibility.status}
|
||||
statusLabel={() => language.t("status.popover.trigger")}
|
||||
promptReady={props.draft.prompt.ready}
|
||||
promptReadyPromise={props.draft.prompt.readyPromise}
|
||||
restoreFocus={props.draft.input.restoreFocus}
|
||||
composer={() => <PromptInputV2Composer controller={props.draft.input} />}
|
||||
projectEmpty={props.project.empty}
|
||||
projectSelected={props.project.selected}
|
||||
projectAdd={() => <PromptProjectAddButton controller={props.project} />}
|
||||
projectSelector={() => <PromptProjectSelector controller={props.project} placement="bottom" />}
|
||||
workspaceVisible={props.workspace.bar.visible}
|
||||
workspaceValue={props.workspace.selection.value}
|
||||
workspaceRoot={props.workspace.project.root}
|
||||
workspaces={props.workspace.project.workspaces}
|
||||
branch={props.workspace.bar.branch}
|
||||
noGit={() => !props.workspace.project.git()}
|
||||
onWorkspaceChange={props.workspace.selection.set}
|
||||
providerReady={props.commands.provider.ready}
|
||||
providerConnected={props.commands.provider.connected}
|
||||
onOpenProviders={props.commands.provider.open}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user