diff --git a/packages/app/src/components/dialog-edit-project-v2.css b/packages/app/src/components/dialog-edit-project-v2.css new file mode 100644 index 00000000000..e52dc7e012f --- /dev/null +++ b/packages/app/src/components/dialog-edit-project-v2.css @@ -0,0 +1,238 @@ +.project-settings-v2-dialog [data-slot="dialog-container"] { + background: var(--v2-background-bg-base); +} + +.project-settings-v2-dialog [data-slot="dialog-body"] { + padding: 0; + overflow: hidden; +} + +.project-settings-v2 { + height: 100%; +} + +.project-settings-v2-nav { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; +} + +.project-settings-v2-panel { + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; + overflow: hidden !important; + user-select: none; +} + +.project-settings-v2-panel :is(input, textarea, [contenteditable="true"]) { + user-select: text; +} + +.project-settings-v2-form { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.project-settings-v2-scroll { + display: flex; + flex: 1; + flex-direction: column; + gap: 24px; + min-height: 0; + padding: 40px; + overflow-y: auto; +} + +.project-settings-page-header { + display: flex; + flex-direction: column; + gap: 6px; +} + +.project-settings-page-header h2 { + color: var(--v2-text-text-base); + font-size: 15px; + font-weight: 640; + line-height: 1; +} + +.project-settings-page-header > span { + color: var(--v2-text-text-muted); + font-size: 11px; + font-weight: 440; + line-height: 1; +} + +.project-settings-extensions { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: 40px; +} + +.project-settings-extension-tabs { + flex: 1; + height: auto; + min-height: 0; + margin-top: 24px; + overflow: visible; +} + +.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] { + width: auto; + padding-inline: 0 !important; +} + +.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before { + display: none; +} + +.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] + > [data-slot="tabs-v2-list"] + [data-slot="tabs-v2-trigger-wrapper"] { + border-radius: 6px; +} + +.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] + > [data-slot="tabs-v2-list"] + [data-slot="tabs-v2-trigger"] { + padding-inline: 8px; +} + +.project-settings-extension-tabs [data-slot="tabs-v2-content"] { + overflow-y: auto; +} + +.project-settings-extension-section { + display: flex; + flex-direction: column; + gap: 16px; + padding-top: 20px; +} + +.project-settings-extension-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + color: var(--v2-text-text-base); + font-size: 13px; + font-weight: 530; + line-height: 1; +} + +.project-settings-extension-section-header > :last-child { + color: var(--v2-text-text-faint); + font-size: 11px; + font-weight: 440; +} + +.project-settings-extension-link { + color: var(--v2-text-text-accent) !important; + text-decoration: none; +} + +.project-settings-extension-link:hover { + text-decoration: underline; +} + +.project-settings-extension-card { + padding-inline: 12px; + overflow: hidden; + border: 0.5px solid var(--v2-border-border-base); + border-radius: 8px; + background: var(--v2-background-bg-base); +} + +.project-settings-extension-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 48px; + border-bottom: 0.5px solid var(--v2-border-border-base); +} + +.project-settings-extension-row:last-child { + border-bottom: 0; +} + +.project-settings-extension-row-main { + display: flex; + flex: 1; + align-items: center; + gap: 10px; + min-width: 0; +} + +.project-settings-extension-row-icon { + flex-shrink: 0; + color: var(--v2-icon-icon-muted); +} + +.project-settings-extension-row-name { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-base); + font-size: 13px; + font-weight: 530; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-settings-extension-row-status { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + color: var(--v2-text-text-muted); + font-size: 11px; +} + +.project-settings-extension-row-status-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--v2-state-fg-warning); +} + +.project-settings-shared { + display: flex; + flex-direction: column; + gap: 12px; +} + +.project-settings-shared-trigger { + display: flex; + align-items: center; + align-self: flex-start; + gap: 6px; + color: var(--v2-text-text-base); + font-size: 11px; + font-weight: 530; +} + +.project-settings-shared-chevron { + width: 12px; + height: 12px; + color: var(--v2-icon-icon-muted); + transition: transform 120ms ease; +} + +.project-settings-shared-chevron.open { + transform: rotate(90deg); +} + +.project-settings-shared-count { + padding: 1px 4px; + border-radius: 3px; + background: var(--v2-background-bg-layer-02); + color: var(--v2-text-text-muted); + font-size: 9px; +} diff --git a/packages/app/src/components/dialog-edit-project-v2.tsx b/packages/app/src/components/dialog-edit-project-v2.tsx index 77adec7b1ca..cfed789c792 100644 --- a/packages/app/src/components/dialog-edit-project-v2.tsx +++ b/packages/app/src/components/dialog-edit-project-v2.tsx @@ -1,156 +1,223 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" -import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2" import { Field } from "@opencode-ai/ui/v2/field-v2" -import { Icon } from "@opencode-ai/ui/v2/icon" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { Icon } from "@opencode-ai/ui/icon" import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2" +import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" -import { For, Show } from "solid-js" +import { For, Show, createSignal, startTransition } from "solid-js" import { useLanguage } from "@/context/language" import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" +import { SDKProvider } from "@/context/sdk" import { ServerConnection } from "@/context/server" -import { getProjectAvatarSource } from "@/pages/layout/helpers" +import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { createEditProjectModel } from "./edit-project" +import { ProjectSettingsExtensions } from "./project-settings-extensions" +import { SettingsServerDataScope } from "./settings-server-picker" +import "./settings-v2/settings-v2.css" +import "./dialog-edit-project-v2.css" export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) { + return ( + + + + + + ) +} + +function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) { const language = useLanguage() const model = createEditProjectModel(props) + const projectName = () => displayName(props.project) + const [tab, setTab] = createSignal("general") + + const Footer = () => ( + + + {language.t("common.cancel")} + + + {model.save.isPending ? language.t("common.saving") : language.t("common.save")} + + + ) return ( - -
- - {language.t("dialog.project.edit.title")} - - - - - {language.t("dialog.project.edit.name")} - model.setStore("name", event.currentTarget.value)} - /> - - -
-
- {language.t("dialog.project.edit.icon")} -
-
- - { - model.setIconInput(element) - }} - type="file" - accept="image/*" - class="hidden" - onChange={model.inputChange} + + void startTransition(() => setTab(value))} + class="project-settings-v2" + > + +
+ + -
- {language.t("dialog.project.edit.icon.hint")} - {language.t("dialog.project.edit.icon.recommended")} -
-
+ {projectName()} + + + + {language.t("project.settings.scripts")} + + + + {language.t("settings.tab.extensions")} +
+ - -
-
- {language.t("dialog.project.edit.color")} + + +
+
+

{language.t("dialog.project.edit.title")}

+ {language.t("project.settings.general.description")}
-
- - {(color) => ( - - )} - + + + + model.setIconInput(element)} + type="file" + accept="image/*" + class="hidden" + onChange={model.inputChange} + /> +
+ {language.t("dialog.project.edit.icon.hint")} + {language.t("dialog.project.edit.icon.recommended")} +
+
-
- - - {language.t("dialog.project.edit.worktree.startup")} - {language.t("dialog.project.edit.worktree.startup.description")} - model.setStore("startup", event.currentTarget.value)} - /> - - - - - {language.t("common.cancel")} - - - {model.save.isPending ? language.t("common.saving") : language.t("common.save")} - - - + +
+
+ {language.t("dialog.project.edit.color")} +
+
+ + {(color) => ( + + )} + +
+
+
+
+
) } diff --git a/packages/app/src/components/project-settings-extensions.tsx b/packages/app/src/components/project-settings-extensions.tsx new file mode 100644 index 00000000000..4c427a22580 --- /dev/null +++ b/packages/app/src/components/project-settings-extensions.tsx @@ -0,0 +1,238 @@ +import { Icon } from "@opencode-ai/ui/icon" +import { Switch } from "@opencode-ai/ui/v2/switch-v2" +import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" +import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js" +import { useLanguage } from "@/context/language" +import { useMcpToggle } from "@/context/mcp" +import { useSDK } from "@/context/sdk" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { useSync } from "@/context/sync" +import { ExternalLink } from "./external-link" + +type SkillItem = { + name: string + location: string +} + +const pluginName = (item: string | [string, Record]) => (typeof item === "string" ? item : item[0]) +const skillKey = (item: SkillItem) => `${item.name}\n${item.location}` + +const ExtensionCard: Component<{ children: unknown }> = (props) => ( +
{props.children as any}
+) + +const ExtensionRow: Component<{ + icon: "mcp" | "cube" | "post-skill" | "code" + name: string + status?: string + children?: unknown +}> = (props) => ( +
+
+ + {props.name} +
+ + {(status) => ( + + + {status()} + + )} + + {props.children as any} +
+) + +const SharedSection: Component<{ + count: number + children: unknown +}> = (props) => { + const language = useLanguage() + const [open, setOpen] = createSignal(false) + return ( + 0}> +
+ + + {props.children} + +
+
+ ) +} + +export const ProjectSettingsExtensions: Component = () => { + const language = useLanguage() + const serverSDK = useServerSDK() + const directorySDK = useSDK() + const serverSync = useServerSync() + const sync = useSync() + const toggleMcp = useMcpToggle() + + const [serverMcp] = createResource( + serverSDK, + (sdk) => + sdk.api.mcp + .list() + .then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status]))) + .catch(() => ({})), + { initialValue: {} }, + ) + const globalMcpNames = createMemo(() => + [...new Set([...Object.keys(serverSync().data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(), + ) + const projectMcpNames = createMemo(() => { + const shared = new Set(globalMcpNames()) + const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name)) + if (configured.length > 0) return configured.sort() + return Object.keys(sync().data.mcp ?? {}) + .filter((name) => !shared.has(name)) + .sort() + }) + const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected" + + const globalPlugins = createMemo(() => (serverSync().data.config.plugin ?? []).map(pluginName)) + const projectPlugins = createMemo(() => { + const shared = new Set(globalPlugins()) + return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name)) + }) + + const [serverSkills] = createResource( + serverSDK, + (sdk): Promise => + sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))), + { initialValue: [] }, + ) + const [directorySkills] = createResource( + directorySDK, + (sdk): Promise => + sdk.api.skill + .list({ location: { directory: sdk.directory } }) + .then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))), + { initialValue: [] }, + ) + const projectSkills = createMemo(() => { + const shared = new Set(serverSkills.latest.map(skillKey)) + return directorySkills.latest.filter((item) => !shared.has(skillKey(item))) + }) + + const mcpRows = (items: string[]) => ( + + {(name) => ( + + { + if (toggleMcp.isPending) return + toggleMcp.mutate(name) + }} + > + {name} + + + )} + + ) + + const pluginRows = (items: string[]) => {(name) => } + + const skillRows = (items: SkillItem[]) => ( + {(item) => } + ) + + return ( +
+
+

{language.t("settings.tab.extensions")}

+ {language.t("project.settings.extensions.description")} +
+ + + + {language.t("settings.extensions.tab.mcps")} + {language.t("status.popover.tab.plugins")} + {language.t("settings.extensions.tab.skills")} + {language.t("project.settings.extensions.tab.lsps")} + + + +
+
+ {language.t("project.settings.extensions.added")} + {language.t("settings.extensions.manageConfig")} +
+ 0}> + {mcpRows(projectMcpNames())} + + {mcpRows(globalMcpNames())} +
+
+ + +
+
+ {language.t("project.settings.extensions.added")} + {language.t("settings.extensions.manageConfig")} +
+ 0}> + {pluginRows(projectPlugins())} + + {pluginRows(globalPlugins())} +
+
+ + +
+
+ {language.t("project.settings.extensions.added")} + + {language.t("settings.extensions.addSkills")} + +
+ 0}> + {skillRows(projectSkills())} + + {skillRows(serverSkills.latest)} +
+
+ + +
+
+ {language.t("project.settings.extensions.lsp.detected")} + {language.t("project.settings.extensions.lsp.description")} +
+ 0}> + + + {(item) => ( + + )} + + + +
+
+
+
+ ) +} diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 638565f535c..5e4631addd2 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -452,7 +452,10 @@ function SettingsKeybindsV2View(props: { <>
-

{language.t("settings.shortcuts.title")}

+
+

{language.t("settings.shortcuts.title")}

+ {language.t("settings.shortcuts.description")} +
{language.t("settings.shortcuts.reset.button")} diff --git a/packages/app/src/components/settings-server-picker.tsx b/packages/app/src/components/settings-server-picker.tsx new file mode 100644 index 00000000000..b73ad0d23cd --- /dev/null +++ b/packages/app/src/components/settings-server-picker.tsx @@ -0,0 +1,30 @@ +import { QueryClientProvider } from "@tanstack/solid-query" +import { type ParentProps, Show } from "solid-js" +import { useGlobal } from "@/context/global" +import { ModelsProvider } from "@/context/models" +import { ServerConnection } from "@/context/server" +import { ServerSDKProvider } from "@/context/server-sdk" +import { ServerSyncProvider } from "@/context/server-sync" + +export function SettingsServerScope(props: ParentProps) { + const global = useGlobal() + return ( + + {(server) => {props.children}} + + ) +} + +export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any }>) { + const global = useGlobal() + const serverCtx = () => global.ensureServerCtx(props.server) + return ( + + + + {props.children} + + + + ) +} diff --git a/packages/app/src/components/settings-v2/appearance.tsx b/packages/app/src/components/settings-v2/appearance.tsx new file mode 100644 index 00000000000..beb72b0b850 --- /dev/null +++ b/packages/app/src/components/settings-v2/appearance.tsx @@ -0,0 +1,134 @@ +import { Component, createMemo } from "solid-js" +import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useLanguage } from "@/context/language" +import { ExternalLink } from "../external-link" +import { SettingsListV2 } from "./parts/list" +import { SettingsRowV2 } from "./parts/row" +import { createAppearanceSettingsController, type AppearanceSettingsController } from "./general-controllers" +import "./settings-v2.css" + +const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] +const fontSettings = { + ui: { + action: "settings-ui-font", + title: "settings.general.row.uiFont.title", + description: "settings.general.row.uiFont.description", + font: "ui", + input: "setUI", + }, + code: { + action: "settings-code-font", + title: "settings.general.row.font.title", + description: "settings.general.row.font.description", + font: "code", + input: "setCode", + }, + terminal: { + action: "settings-terminal-font", + title: "settings.general.row.terminalFont.title", + description: "settings.general.row.terminalFont.description", + font: "terminal", + input: "setTerminal", + }, +} as const + +const FontSetting: Component<{ + kind: "ui" | "code" | "terminal" + fonts: AppearanceSettingsController["fonts"] +}> = (props) => { + const language = useLanguage() + const config = () => fontSettings[props.kind] + return ( + +
+ props.fonts[config().input](event.currentTarget.value)} + placeholder={props.fonts[config().font]().placeholder} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={language.t(config().title)} + style={{ "font-family": props.fonts[config().font]().family }} + /> +
+
+ ) +} + +export const SettingsAppearanceV2: Component = () => { + const language = useLanguage() + const appearance = createAppearanceSettingsController() + + return ( + <> +
+
+
+

{language.t("settings.general.section.appearance")}

+ {language.t("settings.appearance.description")} +
+
+
+ +
+
+ + + option === appearance.scheme.current())} + placement="bottom-end" + gutter={6} + label={(option) => { + if (option === "system") return language.t("theme.scheme.system") + if (option === "light") return language.t("theme.scheme.light") + return language.t("theme.scheme.dark") + }} + onSelect={(option) => option && appearance.scheme.select(option)} + /> + + + + {language.t("settings.general.row.theme.description")}{" "} + + {language.t("common.learnMore")} + + + } + > + option.id} + label={(option) => option.name} + onSelect={appearance.theme.select} + /> + + + + + + +
+
+ + ) +} diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index 8e84c786d7a..fc2230bbc76 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -5,15 +5,22 @@ import { Icon } from "@opencode-ai/ui/icon" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { SettingsGeneralV2 } from "./general" +import { SettingsAppearanceV2 } from "./appearance" import { SettingsKeybinds } from "../settings-keybinds" +import { SettingsNotificationsV2 } from "./notifications" import { SettingsProvidersV2 } from "./providers" import { SettingsModelsV2 } from "./models" -import "./settings-v2.css" import { SettingsServersV2 } from "./servers" +import { SettingsProjectsV2 } from "./projects" +import { SettingsExtensionsV2 } from "./extensions" +import { SettingsServerScope } from "../settings-server-picker" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLayout } from "@/context/layout" import { useTabs } from "@/context/tabs" import { useServerSync } from "@/context/server-sync" +import { useGlobal } from "@/context/global" +import { ServerConnection } from "@/context/server" +import "./settings-v2.css" export const DialogSettings: Component<{ sessionID?: string @@ -25,8 +32,13 @@ export const DialogSettings: Component<{ const layout = useLayout() const tabs = useTabs() const serverSync = useServerSync() + const global = useGlobal() + const currentServer = global.servers.list().find((server) => global.ensureServerCtx(server).sync === serverSync()) + if (currentServer) global.settings.server.set(ServerConnection.key(currentServer)) const [tab, setTab] = createSignal(props.defaultValue ?? "general") const directory = createMemo(() => { + const server = global.settings.server.selected() + if (!server || serverSync() !== global.ensureServerCtx(server).sync) return const route = layout.route() if (route.type === "dir-new-sesssion") return route.dir if (route.type === "draft") { @@ -52,62 +64,92 @@ export const DialogSettings: Component<{ >
-
-
-
- {language.t("settings.section.desktop")} -
- - - {language.t("settings.tab.general")} - - - - {language.t("settings.tab.shortcuts")} - -
-
+
+ {/* Group 1: Preferences */} +
+ + + {language.t("settings.tab.preferences")} + + + + {language.t("settings.general.section.appearance")} + + + + {language.t("settings.tab.notifications")} + + + + {language.t("settings.tab.shortcuts")} + +
-
- {language.t("settings.section.server")} -
- - - {language.t("status.popover.tab.servers")} - - - - {language.t("settings.providers.title")} - - - - {language.t("settings.models.title")} - -
-
+ {/* Group 2: Environment & Workspaces */} +
+ + + {language.t("status.popover.tab.servers")} + + + + {language.t("settings.tab.projects")} + +
+ + {/* Group 3: Capabilities & Extensions */} +
+ + + {language.t("settings.providers.title")} + + + + {language.t("settings.models.title")} + + + + {language.t("settings.tab.extensions")} +
+
+ + + + + + + - - - - - + + + + + + + + + + + + + ) diff --git a/packages/app/src/components/settings-v2/extensions.tsx b/packages/app/src/components/settings-v2/extensions.tsx new file mode 100644 index 00000000000..89d1a4da4c2 --- /dev/null +++ b/packages/app/src/components/settings-v2/extensions.tsx @@ -0,0 +1,156 @@ +import { Component, For, createMemo, createResource } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { Switch } from "@opencode-ai/ui/v2/switch-v2" +import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" +import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { ExternalLink } from "../external-link" +import { InlineServerSelect } from "./parts/server-select" +import "./settings-v2.css" + +interface McpRowItem { + name: string + enabled: boolean +} + +interface PluginRowItem { + name: string +} + +export const SettingsExtensionsV2: Component = () => { + const language = useLanguage() + const serverSdk = useServerSDK() + const serverSync = useServerSync() + const mcps = createMemo(() => { + const configMcp = serverSync().data.config.mcp ?? {} + return Object.entries(configMcp).map(([name, config]) => ({ + name, + enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false, + })) + }) + + const handleMcpToggle = (item: McpRowItem, checked: boolean) => { + const before = serverSync().data.config.mcp ?? {} + const config = before[item.name] + if (typeof config !== "object" || config === null) return + const next = { ...before, [item.name]: { ...config, enabled: checked } } + serverSync().set("config", "mcp", next) + void serverSync() + .updateConfig({ mcp: next }) + .catch(() => serverSync().set("config", "mcp", before)) + } + + const plugins = createMemo(() => { + const raw = serverSync().data.config.plugin ?? [] + return raw.map((item) => { + const name = typeof item === "string" ? item : item[0] + return { name } + }) + }) + + const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), { + initialValue: [], + }) + + return ( + <> +
+
+
+

{language.t("settings.tab.extensions")}

+ {language.t("settings.extensions.description")} +
+ +
+
+ +
+ + + {language.t("settings.extensions.tab.mcps")} + {language.t("status.popover.tab.plugins")} + {language.t("settings.extensions.tab.skills")} + + + +
+
+ + {language.t("settings.extensions.availableAll")} + + {language.t("settings.extensions.manageConfig")} +
+
+ + {(item) => ( +
+
+ + {item.name} +
+ handleMcpToggle(item, checked)} hideLabel> + {item.name} + +
+ )} +
+
+
+
+ + +
+
+ + {language.t("settings.extensions.availableAll")} + + {language.t("settings.extensions.manageConfig")} +
+
+ + {(plugin) => ( +
+
+ + {plugin.name} +
+
+ )} +
+
+
+
+ + +
+
+ + {language.t("settings.extensions.availableAll")} + + + {language.t("settings.extensions.addSkills")} + +
+
+ + {(skill) => ( +
+
+ + {skill.name} +
+
+ )} +
+
+
+
+
+
+ + ) +} diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 66b8ccd69c2..2c51252857c 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -279,8 +279,6 @@ export const SettingsGeneralV2: Component<{ const updater = useUpdaterAction() const permissionScope = createPermissionScopeController(() => props.sessionID) const shell = createShellSettingsController() - const appearance = createAppearanceSettingsController() - const sounds = createSoundSettingsController() const desktop = createMemo(() => platform.platform === "desktop") const [pinchZoom, { mutate: setPinchZoom }] = createResource( @@ -298,6 +296,7 @@ export const SettingsGeneralV2: Component<{ const GeneralSection = () => (
+

{language.t("settings.general.section.general")}

@@ -510,18 +509,19 @@ export const SettingsGeneralV2: Component<{ return ( <>
-

{language.t("settings.tab.general")}

+
+
+

{language.t("settings.tab.preferences")}

+ + {language.t("settings.preferences.description")} + +
+
- - - - - - diff --git a/packages/app/src/components/settings-v2/models.tsx b/packages/app/src/components/settings-v2/models.tsx index 482e8cbffbd..738d282d72d 100644 --- a/packages/app/src/components/settings-v2/models.tsx +++ b/packages/app/src/components/settings-v2/models.tsx @@ -11,6 +11,7 @@ import { useModels } from "@/context/models" import { useServerSDK } from "@/context/server-sdk" import { popularProviders } from "@/hooks/use-providers" import { Persist, persisted } from "@/utils/persist" +import { InlineServerSelect } from "./parts/server-select" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import "./settings-v2.css" @@ -53,7 +54,13 @@ export const SettingsModelsV2: Component = () => { return ( <>
-

{language.t("settings.models.title")}

+
+
+

{language.t("settings.models.title")}

+ {language.t("settings.models.description")} +
+ +
+ + ) +} diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index 452cdb77239..496b5ff8bd4 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -10,6 +10,8 @@ import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogCustomProvider } from "../dialog-custom-provider" +import { SettingsServerScope } from "../settings-server-picker" +import { InlineServerSelect } from "./parts/server-select" import { SettingsListV2 } from "./parts/list" import "./settings-v2.css" @@ -42,13 +44,23 @@ export const SettingsProvidersV2: Component<{ const connect = (provider?: string) => { providerConnect.select(provider) - void dialog.show(() => ) + void dialog.show(() => ( + + + + )) } const connected = createMemo(() => { - return providers - .connected() - .filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)) + return providers.connected().filter( + (provider) => + provider.id !== "opencode" || + Object.values(provider.models).some((model) => { + if (typeof model !== "object" || model === null || !("cost" in model)) return false + const cost = model.cost + return typeof cost === "object" && cost !== null && "input" in cost + }), + ) }) const popular = createMemo(() => { @@ -92,29 +104,6 @@ export const SettingsProvidersV2: Component<{ return true } - const disableProvider = async (providerID: string, name: string) => { - return - const before = serverSync().data.config.disabled_providers ?? [] - const next = before.includes(providerID) ? before : [...before, providerID] - serverSync().set("config", "disabled_providers", next) - - await serverSync() - .updateConfig({ disabled_providers: next }) - .then(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), - description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), - }) - }) - .catch((err: unknown) => { - serverSync().set("config", "disabled_providers", before) - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) - } - const disconnect = async (providerID: string, name: string) => { const location = props.directory ? { directory: props.directory } : undefined await serverSdk() @@ -141,7 +130,13 @@ export const SettingsProvidersV2: Component<{ return ( <>
-

{language.t("settings.providers.title")}

+
+
+

{language.t("settings.providers.title")}

+ {language.t("settings.providers.description")} +
+ +
@@ -244,7 +239,11 @@ export const SettingsProvidersV2: Component<{ variant="neutral" icon="plus" onClick={() => { - dialog.show(() => ) + dialog.show(() => ( + + + + )) }} > {language.t("common.connect")} diff --git a/packages/app/src/components/settings-v2/servers.tsx b/packages/app/src/components/settings-v2/servers.tsx index 78b6c5fd95e..e30e9e99dbd 100644 --- a/packages/app/src/components/settings-v2/servers.tsx +++ b/packages/app/src/components/settings-v2/servers.tsx @@ -53,7 +53,10 @@ export const SettingsServersV2: Component = () => { classList={{ "settings-v2-tab-header--stacked": showSearch() }} >
-

{language.t("status.popover.tab.servers")}

+
+

{language.t("status.popover.tab.servers")}

+ {language.t("settings.servers.description")} +
diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index b73269b496d..f8cbfdc7596 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -39,6 +39,14 @@ background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent); } +.settings-v2-tab-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; +} + .settings-v2-tab-title { font-size: 15px; font-weight: 640; @@ -174,13 +182,13 @@ max-width: 100%; } -[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] { +[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] > [data-slot="tabs-v2-list"] { background-color: var(--v2-background-bg-layer-01); } @media (max-width: 639px) { .settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] - [data-slot="tabs-v2-list"] { + > [data-slot="tabs-v2-list"] { width: 144px; min-width: 144px; padding-inline: 8px; @@ -727,3 +735,24 @@ line-height: 1; color: var(--v2-state-fg-danger); } + +.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] { + width: 280px; + padding-inline: 0 !important; +} + +.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before { + display: none; +} + +.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] + > [data-slot="tabs-v2-list"] + [data-slot="tabs-v2-trigger-wrapper"] { + border-radius: 6px; +} + +.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] + > [data-slot="tabs-v2-list"] + [data-slot="tabs-v2-trigger"] { + padding-inline: 8px; +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 62d4dc1ae6e..ac1fe1ff1e1 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -892,14 +892,46 @@ export const dict = { "settings.section.desktop": "Desktop", "settings.section.server": "Server", "settings.tab.general": "General", + "settings.tab.preferences": "Preferences", "settings.tab.shortcuts": "Shortcuts", + "settings.tab.notifications": "Notifications", + "settings.tab.projects": "Projects", + "settings.tab.extensions": "Extensions", + "settings.preferences.description": "Customize preferences and theme and default behavior", + "settings.appearance.description": "Customize theme and fonts", + "settings.notifications.description": "Choose when to receive notifications and hear sounds", + "settings.shortcuts.description": "Customize shortcuts for common actions", + "settings.servers.description": "Manage server connections", + "settings.projects.title": "Projects", + "settings.projects.description": "Manage project settings on this server", + "settings.projects.empty": "No projects found", + "settings.projects.server.all": "All servers", + "settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools", + "settings.extensions.description": "Manage extensions available on this server", + "settings.extensions.tab.mcps": "MCPs", + "settings.extensions.tab.skills": "Skills", + "settings.extensions.availableAll": "Available to all projects", + "settings.extensions.manageConfig": "Manage in opencode.json", + "settings.extensions.addSkills": "How to add skills", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL integration", "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", + "dialog.server.authenticate.title": "Authenticate", + "project.settings.general.description": "Manage project name and appearance", + "project.settings.scripts": "Scripts", + "project.settings.scripts.description": "Configure scripts for this project", + "project.settings.extensions.description": "View extensions available to this project", + "project.settings.extensions.tab.lsps": "LSPs", + "project.settings.extensions.added": "Added to this project", + "project.settings.extensions.shared": "Shared with all projects", + "project.settings.extensions.lsp.detected": "Detected language servers", + "project.settings.extensions.lsp.description": "Auto-detected from file types", + "project.settings.extensions.setupRequired": "Setup required", "settings.general.section.appearance": "Appearance", + "settings.general.section.general": "General", "settings.general.section.advanced": "Advanced", - "settings.general.section.notifications": "System notifications", + "settings.general.section.notifications": "Desktop notifications", "settings.general.section.updates": "Updates", "settings.general.section.sounds": "Sound effects", "settings.general.section.feed": "Feed", @@ -1060,7 +1092,7 @@ export const dict = { "settings.shortcuts.group.prompt": "Prompt", "settings.providers.title": "Providers", - "settings.providers.description": "Provider settings will be configurable here.", + "settings.providers.description": "Connect and manage model providers", "settings.providers.section.connected": "Connected providers", "settings.providers.connected.empty": "No connected providers", "settings.providers.connected.environmentDescription": "Connected from your environment variables", @@ -1071,7 +1103,7 @@ export const dict = { "settings.providers.tag.custom": "Custom", "settings.providers.tag.other": "Other", "settings.models.title": "Models", - "settings.models.description": "Model settings will be configurable here.", + "settings.models.description": "Choose which models appear in model picker", "settings.agents.title": "Agents", "settings.agents.description": "Agent settings will be configurable here.", "settings.commands.title": "Commands", diff --git a/packages/app/src/i18n/parity.test.ts b/packages/app/src/i18n/parity.test.ts index d2f1ed35d14..50da0bb37d5 100644 --- a/packages/app/src/i18n/parity.test.ts +++ b/packages/app/src/i18n/parity.test.ts @@ -74,6 +74,53 @@ const pluralCategories = new Map( ] as const, ), ) +const appFallbackKeys = new Set([ + "dialog.provider.custom.label", + "dialog.model.unpaid.viewMoreProviders", + "session.header.reveal.finder", + "session.header.reveal.fileExplorer", + "session.header.reveal.containingFolder", + "command.session.export", + "command.session.export.description", + "context.export.session", + "toast.session.export.success.title", + "toast.session.export.success.description", + "toast.session.export.failed.title", + "toast.session.export.failed.description", + "common.export", + "settings.tab.preferences", + "settings.tab.notifications", + "settings.tab.projects", + "settings.tab.extensions", + "settings.preferences.description", + "settings.appearance.description", + "settings.notifications.description", + "settings.shortcuts.description", + "settings.servers.description", + "settings.projects.title", + "settings.projects.description", + "settings.projects.empty", + "settings.projects.server.all", + "settings.mcps.description", + "settings.extensions.description", + "settings.extensions.tab.mcps", + "settings.extensions.tab.skills", + "settings.extensions.availableAll", + "settings.extensions.manageConfig", + "settings.extensions.addSkills", + "settings.general.section.general", + "dialog.server.authenticate.title", + "project.settings.general.description", + "project.settings.scripts", + "project.settings.scripts.description", + "project.settings.extensions.description", + "project.settings.extensions.tab.lsps", + "project.settings.extensions.added", + "project.settings.extensions.shared", + "project.settings.extensions.lsp.detected", + "project.settings.extensions.lsp.description", + "project.settings.extensions.setupRequired", +]) const domains = [ { @@ -102,7 +149,9 @@ describe("i18n parity", () => { const source = await dictionary(domain.source) for (const locale of domain.locales) { const target = await dictionary(domain.target(locale)) - const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key)) + const missing = Object.keys(source).filter( + (key) => !Object.hasOwn(target, key) && (domain.name !== "app" || !appFallbackKeys.has(key)), + ) const extra = Object.keys(target) .filter((key) => !Object.hasOwn(source, key)) .sort() diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index a16fad0c32a..516620006ac 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -103,6 +103,11 @@ const icons = { link: ``, providers: ``, models: ``, + appearance: ``, + notifications: ``, + extensions: ``, + cube: ``, + "post-skill": ``, "arrow-undo-down": ``, } diff --git a/packages/ui/src/v2/components/tabs-v2.css b/packages/ui/src/v2/components/tabs-v2.css index 8d684f92b9b..0438ca2a5d9 100644 --- a/packages/ui/src/v2/components/tabs-v2.css +++ b/packages/ui/src/v2/components/tabs-v2.css @@ -175,7 +175,7 @@ border: 0.5px solid var(--v2-border-border-muted); } -[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] { +[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] > [data-slot="tabs-v2-list"] { flex-direction: column; width: 240px; min-width: 200px; @@ -186,7 +186,9 @@ border-inline-end: 1px solid var(--v2-border-border-base); } -[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-section-title"] { +[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] + > [data-slot="tabs-v2-list"] + [data-slot="tabs-v2-section-title"] { width: 100%; padding-inline-start: 4px; color: var(--v2-text-text-muted); @@ -195,7 +197,9 @@ user-select: none; } -[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"] { +[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] + > [data-slot="tabs-v2-list"] + [data-slot="tabs-v2-trigger-wrapper"] { width: 100%; height: 28px; border-radius: 4px; @@ -203,7 +207,9 @@ color: var(--v2-text-text-muted); } -[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger"] { +[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] + > [data-slot="tabs-v2-list"] + [data-slot="tabs-v2-trigger"] { width: 100%; height: 100%; justify-content: flex-start; @@ -212,12 +218,14 @@ } [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] + > [data-slot="tabs-v2-list"] [data-slot="tabs-v2-trigger-wrapper"]:hover:not(:disabled):not(:has([data-selected])) { background-color: var(--v2-background-bg-layer-03); color: var(--v2-text-text-base); } [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] + > [data-slot="tabs-v2-list"] [data-slot="tabs-v2-trigger-wrapper"]:has([data-selected]) { background-color: var(--v2-background-bg-layer-03); color: var(--v2-text-text-base);