Compare commits

...

16 Commits

Author SHA1 Message Date
Brendan Allan ea2db98dbe Merge branch 'dev' into persisted-suspense 2026-07-07 14:08:32 +08:00
opencode-agent[bot] c5fe32fbb1 fix(app): show unread for pending questions (#34684)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-07 13:27:19 +08:00
opencode-agent[bot] c48b77d6f9 chore: generate 2026-07-07 05:12:15 +00:00
Brendan Allan 64fd7a2027 fix(app): persist session tab info (#34934) 2026-07-07 05:11:08 +00:00
opencode-agent[bot] ae259d87f0 fix(app): apply settings themes on select (#35011)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-07 13:03:25 +08:00
Aarav Sareen 50374abab8 feat(app): change traffic light position, fix sequoia bug (#35081) 2026-07-07 12:49:28 +08:00
opencode-agent[bot] 0497e8badf chore: generate 2026-07-07 04:46:52 +00:00
Aarav Sareen 2a08f8f5f4 feat(app): reintroduce model context tooltip in model selector (#35087) 2026-07-07 12:45:42 +08:00
opencode-agent[bot] 9353559088 chore: generate 2026-07-07 02:37:53 +00:00
Aarav Sareen 6aff2ee169 feat(app): visual improvements (#35134) 2026-07-07 10:36:27 +08:00
James Long 254a481e5d fix(config): handle unavailable config directories (#35632)
Co-authored-by: James Long <jlongster@users.noreply.github.com>
2026-07-06 22:11:13 -04:00
Aarav Sareen 26885e7118 feat(app): align cmd k menu with v2 styles (#35152)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-07 08:52:57 +08:00
opencode-agent[bot] dba08013d1 chore: generate 2026-07-06 20:10:55 +00:00
Adam fc09646973 fix(data): refine model trend charts 2026-07-06 15:03:07 -05:00
Adam c3b9c9d888 feat(data): improved model usage section 2026-07-06 15:03:07 -05:00
Brendan Allan 6910eee7fb refactor(app): add suspense support to persisted 2026-06-29 08:51:29 +08:00
51 changed files with 2053 additions and 717 deletions
+6 -6
View File
@@ -72,7 +72,7 @@ const SessionRoute = () => {
return (
<Show when={tabs.ready()}>
{(_) => {
const persisted = tabs.store.filter((item) => item.type === "session")
const persisted = tabs.store().filter((item) => item.type === "session")
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
}}
</Show>
@@ -140,7 +140,7 @@ function DraftRoute() {
return (
<Show when={tabs.ready()}>
<Show
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
when={tabs.store().find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
keyed
fallback={<Navigate href="/" />}
>
@@ -186,7 +186,7 @@ declare global {
deepLinks?: string[]
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
}
}
@@ -320,8 +320,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
<MetaProvider>
<Font />
<ThemeProvider
onThemeApplied={(_, mode) => {
void window.api?.setTitlebar?.({ mode })
onThemeApplied={(_, mode, scheme) => {
void window.api?.setTitlebar?.({ mode, scheme })
}}
>
<LanguageProvider locale={props.locale}>
@@ -568,7 +568,7 @@ function LegacyTargetSessionRoute() {
<Navigate
href={sessionHref(
legacySessionServer(
tabs.store.filter((item) => item.type === "session"),
tabs.store().filter((item) => item.type === "session"),
params.id,
server.key,
),
@@ -0,0 +1,325 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useNavigate } from "@solidjs/router"
import { createMemo, onCleanup } from "solid-js"
import { useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
export type CommandPaletteEntry = {
id: string
type: "command" | "file" | "session"
title: string
description?: string
keybind?: string
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
updated?: number
}
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
const seen = new Set<string>()
return items.filter((item) => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}
export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
return {
id: "file:" + path,
type: "file",
title: path,
category,
path,
}
}
export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
const file = useFile()
const layout = useLayout()
const { tabs, view } = useSessionLayout()
return (path: string) => {
const value = file.tab(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
onOpenFile?.(path)
tabs().setActive(value)
}
}
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()()
const serverSync = useServerSync()
const { params, tabs } = useSessionLayout()
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const filesOnly = () => props.filesOnly?.() ?? false
const allowedCommands = createMemo(() => {
if (filesOnly()) return []
return command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return allowedCommands().map((option) => createCommandEntry(option, category))
})
const preferredCommandEntries = createMemo(() => {
const all = allowedCommands()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const recentFileEntries = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = language.t("palette.group.files")
return order
.map((item) => file.pathFromTab(item))
.filter((path): path is string => {
if (!path || seen.has(path)) return false
seen.add(path)
return true
})
.slice(0, ENTRY_LIMIT)
.map((path) => createCommandPaletteFileEntry(path, category))
})
const rootFileEntries = createMemo(() => {
const category = language.t("palette.group.files")
return file.tree
.children("")
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
.slice(0, ENTRY_LIMIT)
.map((path) => createCommandPaletteFileEntry(path, category))
})
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return undefined
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const sessions = createSessionEntries({
workspaces,
label: (directory) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
},
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
openFile(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return {
language,
file,
commandEntries,
preferredCommandEntries,
recentFileEntries,
rootFileEntries,
sessions,
highlight,
select,
close: () => dialog.close(),
}
}
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
return {
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
}
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
untitled: () => string
category: () => string
}) {
const state: {
token: number
inflight: Promise<CommandPaletteEntry[]> | undefined
cached: CommandPaletteEntry[] | undefined
} = { token: 0, inflight: undefined, cached: undefined }
return (text: string) => {
if (!text.trim()) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as CommandPaletteEntry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as CommandPaletteEntry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props
.load(directory)
.then((result) =>
(result.data ?? [])
.filter((session) => !!session?.id)
.map((session) => ({
id: session.id,
title: session.title ?? props.untitled(),
description,
directory,
archived: session.time?.archived,
updated: session.time?.updated,
})),
)
.catch(() => [] as SessionEntryInput[])
}),
)
.then((results) => {
if (state.token !== current) return [] as CommandPaletteEntry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, props.category()))
state.cached = next
return next
})
.catch(() => [] as CommandPaletteEntry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
}
type SessionEntryInput = {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
return {
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
}
}
@@ -0,0 +1,220 @@
.command-palette-v2 {
overflow: hidden;
}
/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
top stays put while the content-driven height grows and shrinks. */
[data-component="dialog-v2"]:has(.command-palette-v2) {
align-items: flex-start;
}
[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
width: min(calc(100vw - 24px), 640px);
height: auto;
min-height: 280px;
max-height: min(calc(100vh - 96px), 480px);
margin-top: max(48px, calc((100vh - 480px) / 2));
border-radius: 12px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-floating);
}
.command-palette-v2-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 0;
padding: 0;
}
.command-palette-v2-search {
flex-shrink: 0;
padding: 6px;
}
.command-palette-v2-search [data-component="text-input-v2"] {
width: 100%;
height: 36px;
border-radius: 6px;
outline: 0;
background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
transition:
background-color 120ms ease-in-out,
box-shadow 120ms ease-in-out;
}
.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
background: var(--v2-background-bg-layer-02);
box-shadow: none;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
border-radius: 6px;
background: transparent;
gap: 8px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
padding-left: 12px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
border-radius: 6px;
background: transparent;
}
.command-palette-v2-scroll {
min-height: 0;
flex: 1;
}
.command-palette-v2-results {
display: flex;
flex-direction: column;
gap: 16px;
padding: 6px 6px 8px;
}
.command-palette-v2-group {
display: flex;
flex-direction: column;
gap: 1px;
}
.command-palette-v2-group-title {
margin: 6px 0;
padding: 0 12px;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
user-select: none;
}
.command-palette-v2-row {
display: flex;
width: 100%;
height: 36px;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--v2-text-text-base);
text-align: left;
cursor: default;
scroll-margin: 6px 0;
}
.command-palette-v2-row[data-active] {
background: var(--v2-overlay-simple-overlay-hover);
}
.command-palette-v2-row:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
.command-palette-v2-row-main {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 8px;
}
.command-palette-v2-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.command-palette-v2-row-text {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.command-palette-v2-title {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-description,
.command-palette-v2-meta {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-meta {
flex-shrink: 0;
}
.command-palette-v2-file-path {
display: flex;
min-width: 0;
align-items: baseline;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
}
.command-palette-v2-file-dir {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-weight: 440;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-file-name {
flex-shrink: 0;
color: var(--v2-text-text-base);
font-weight: 530;
white-space: nowrap;
}
.command-palette-v2-state {
display: grid;
min-height: 120px;
place-items: center;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
}
@media (max-width: 640px) {
.command-palette-v2-row-text {
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.command-palette-v2-description {
max-width: 100%;
}
}
@@ -0,0 +1,221 @@
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { formatKeybindParts } from "@/context/command"
import { useLanguage } from "@/context/language"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import "./dialog-command-palette-v2.css"
function groups(entries: CommandPaletteEntry[]) {
const map = new Map<string, CommandPaletteEntry[]>()
for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
}
function matchesEntry(entry: CommandPaletteEntry, query: string) {
const value = query.toLowerCase()
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
}
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => {
const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
const category = palette.language.t("palette.group.files")
return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions.filter((entry) => matchesEntry(entry, q)),
...files.map((path) => createCommandPaletteFileEntry(path, category)),
]
}
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()])
createEffect(() => {
query()
visibleEntries()
setActive(0)
})
createEffect(() => {
palette.highlight(activeEntry())
})
let resultsRef: HTMLDivElement | undefined
const move = (delta: -1 | 1) => {
const count = visibleEntries().length
if (count === 0) return
setActive((index) => (index + delta + count) % count)
requestAnimationFrame(() => {
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
})
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault()
move(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
move(-1)
return
}
if (event.key === "Enter") {
event.preventDefault()
palette.select(activeEntry())
return
}
if (event.key === "Escape") {
event.preventDefault()
palette.close()
}
}
return (
<Dialog class="command-palette-v2" size="large">
<DialogBody class="command-palette-v2-body">
<div class="command-palette-v2-search">
<TextInputV2
value={query()}
autofocus
autocomplete="off"
spellcheck={false}
appearance="large"
placeholder={palette.language.t("palette.search.placeholder")}
leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
</div>
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
<div class="command-palette-v2-results" role="listbox">
<Show
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-v2-state">
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
</div>
}
>
<For each={groupedEntries()}>
{(group) => (
<div class="command-palette-v2-group">
<Show when={group.category}>
<div class="command-palette-v2-group-title">{group.category}</div>
</Show>
<For each={group.entries}>
{(item) => (
<PaletteRow
item={item}
active={activeEntry()?.id === item.id}
language={palette.language}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => palette.select(item)}
/>
)}
</For>
</div>
)}
</For>
</Show>
</div>
</ScrollView>
</DialogBody>
</Dialog>
)
}
function PaletteRow(props: {
item: CommandPaletteEntry
active: boolean
language: ReturnType<typeof useLanguage>
onActive: () => void
onSelect: () => void
}) {
return (
<button
type="button"
class="command-palette-v2-row"
role="option"
aria-selected={props.active}
data-active={props.active ? "" : undefined}
onMouseMove={(event) => {
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
if (event.movementX === 0 && event.movementY === 0) return
props.onActive()
}}
onMouseDown={(event) => event.preventDefault()}
onClick={props.onSelect}
>
<Switch
fallback={
<div class="command-palette-v2-row-main">
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
<div class="command-palette-v2-file-path">
<span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
<span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
</div>
</div>
}
>
<Match when={props.item.type === "command"}>
<div class="command-palette-v2-row-main">
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title">{props.item.title}</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description">{props.item.description}</span>
</Show>
</div>
</div>
<Show when={props.item.keybind}>
<KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
</Show>
</Match>
<Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main">
<Icon name="status" class="command-palette-v2-row-icon" />
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title}
</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.description}
</span>
</Show>
</div>
</div>
<Show when={props.item.updated}>
<span class="command-palette-v2-meta">
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
</span>
</Show>
</Match>
</Switch>
</button>
)
}
@@ -1,329 +1,84 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteFileOpener,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
type EntryType = "command" | "file" | "session"
type Entry = {
id: string
type: EntryType
title: string
description?: string
keybind?: string
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
updated?: number
}
const DialogCommandPaletteV2 = lazy(() =>
import("./dialog-command-palette-v2").then((module) => ({ default: module.DialogCommandPaletteV2 })),
)
type DialogSelectFileMode = "all" | "files"
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
const uniqueEntries = (items: Entry[]) => {
const seen = new Set<string>()
const out: Entry[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
})
const createFileEntry = (path: string, category: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category,
path,
})
const createSessionEntry = (
input: {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
},
category: string,
): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
})
function createCommandEntries(props: {
filesOnly: () => boolean
command: ReturnType<typeof useCommand>
language: ReturnType<typeof useLanguage>
}) {
const allowed = createMemo(() => {
if (props.filesOnly()) return []
return props.command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const list = createMemo(() => {
const category = props.language.t("palette.group.commands")
return allowed().map((option) => createCommandEntry(option, category))
})
const picks = createMemo(() => {
const all = allowed()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = props.language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
return { allowed, list, picks }
}
function createFileEntries(props: {
file: ReturnType<typeof useFile>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
language: ReturnType<typeof useLanguage>
}) {
const tabState = createSessionTabs({
tabs: props.tabs,
pathFromTab: props.file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
})
const recent = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = props.language.t("palette.group.files")
const items: Entry[] = []
for (const item of order) {
const path = props.file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(createFileEntry(path, category))
}
return items.slice(0, ENTRY_LIMIT)
})
const root = createMemo(() => {
const category = props.language.t("palette.group.files")
const nodes = props.file.tree.children("")
const paths = nodes
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
})
return { recent, root }
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
serverSDK: ServerSDK
language: ReturnType<typeof useLanguage>
}) {
const state: {
token: number
inflight: Promise<Entry[]> | undefined
cached: Entry[] | undefined
} = {
token: 0,
inflight: undefined,
cached: undefined,
}
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as Entry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as Entry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props.serverSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? props.language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(
() =>
[] as {
id: string
title: string
description: string
directory: string
archived?: number
updated?: number
}[],
)
}),
)
.then((results) => {
if (state.token !== current) return [] as Entry[]
const seen = new Set<string>()
const category = props.language.t("command.category.session")
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, category))
state.cached = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
return { sessions }
}
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files"
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const commandEntries = createCommandEntries({ filesOnly, command, language })
const fileEntries = createFileEntries({ file, tabs, language })
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
if (!filesOnly() && settings.general.newLayoutDesigns()) {
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
}
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
}
return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
}
function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
const language = useLanguage()
const serverSDK = useServerSDK()
const { params } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
return (
<DialogSelectFileV2
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
openFile(result)
}}
/>
)
}
function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [grouped, setGrouped] = createSignal(false)
const items = async (text: string) => {
const query = text.trim()
setGrouped(query.length > 0)
if (!query && filesOnly()) {
const loaded = file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : file.tree.list("")
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
if (!query && props.filesOnly()) {
const loaded = palette.file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
if (loaded || next.length > 0) {
void pending
@@ -331,79 +86,24 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
}
await pending
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
}
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
if (filesOnly()) {
const files = await file.searchFiles(query)
const category = language.t("palette.group.files")
return files.map((path) => createFileEntry(path, category))
if (props.filesOnly()) {
const files = await palette.file.searchFiles(query)
const category = palette.language.t("palette.group.files")
return files.map((path) => createCommandPaletteFileEntry(path, category))
}
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const category = language.t("palette.group.files")
const entries = files.map((path) => createFileEntry(path, category))
return [...commandEntries.list(), ...nextSessions, ...entries]
}
const handleMove = (item: Entry | undefined) => {
state.cleanup?.()
if (!item) return
if (item.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const open = (path: string) => {
const value = file.tab(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)
tabs().setActive(value)
}
const handleSelect = (item: Entry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
open(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return (
<DialogSelectFileV2
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
open(result)
}}
/>
)
const [files, nextSessions] = await Promise.all([
palette.file.searchFiles(query),
Promise.resolve(palette.sessions(query)),
])
const category = palette.language.t("palette.group.files")
const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
return [...palette.commandEntries(), ...nextSessions, ...entries]
}
return (
@@ -411,21 +111,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
<List
class="px-3"
search={{
placeholder: filesOnly()
? language.t("session.header.searchFiles")
: language.t("palette.search.placeholder"),
placeholder: props.filesOnly()
? palette.language.t("session.header.searchFiles")
: palette.language.t("palette.search.placeholder"),
autofocus: true,
hideIcon: true,
}}
emptyMessage={language.t("palette.empty")}
loadingMessage={language.t("common.loading")}
emptyMessage={palette.language.t("palette.empty")}
loadingMessage={palette.language.t("common.loading")}
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove}
onSelect={handleSelect}
onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
>
{(item) => (
<Switch
@@ -452,7 +152,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
</Show>
</div>
</Match>
@@ -479,7 +179,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</div>
<Show when={item.updated}>
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
{getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
{getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
</span>
</Show>
</div>
@@ -24,6 +24,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
@@ -92,6 +93,7 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
@@ -446,26 +448,41 @@ export function ModelSelectorPopoverV2(props: {
<MenuV2.RadioGroup value={current()}>
<For each={group.items}>
{(item) => (
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
</TooltipV2>
)}
</For>
</MenuV2.RadioGroup>
+35 -2
View File
@@ -1,4 +1,4 @@
import { Show, type Component } from "solid-js"
import { Show, type Component, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
@@ -23,7 +23,18 @@ type ModelInfo = {
}
}
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => {
function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
return (
<div class="flex min-w-0 items-center gap-4">
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
</div>
)
}
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = (
props,
) => {
const language = useLanguage()
const sourceName = (model: ModelInfo) => {
const value = `${model.id} ${model.name}`.toLowerCase()
@@ -51,6 +62,13 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${sourceName(props.model)} ${props.model.name}${suffix}`
}
const name = () => {
const tags: Array<string> = []
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${props.model.name}${suffix}`
}
const inputs = () => {
if (props.model.capabilities) {
const input = props.model.capabilities.input
@@ -73,6 +91,21 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
: language.t("model.tooltip.reasoning.none")
}
const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
const contextLimit = () => props.model.limit.context.toLocaleString(language.intl())
if (props.v2) {
return (
<div class="flex w-[180px] flex-col gap-2">
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
<Show when={inputs()}>
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
</Show>
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
</div>
)
}
return (
<div class="flex flex-col gap-1 py-1">
+6 -5
View File
@@ -11,6 +11,7 @@ import {
createResource,
Switch,
Match,
type Accessor,
type JSX,
} from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
@@ -117,21 +118,21 @@ export type PromptInputControls = {
export function createPromptInputHistory(): PromptInputHistory {
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
return createPromptInputHistoryStore(() => normal, setNormal, () => shell, setShell)
}
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
function createPromptInputHistoryStore(
normal: Store<PromptHistoryState>,
normal: Accessor<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
shell: Store<PromptHistoryState>,
shell: Accessor<PromptHistoryState>,
setShell: SetStoreFunction<PromptHistoryState>,
): PromptInputHistory {
return {
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
entries: (mode) => (mode === "shell" ? shell().entries : normal().entries),
add(prompt, mode, comments) {
const current = mode === "shell" ? shell : normal
const current = mode === "shell" ? shell() : normal()
const setCurrent = mode === "shell" ? setShell : setNormal
const next = prependHistoryEntry(current.entries, prompt, comments)
if (next === current.entries) return
@@ -229,7 +229,7 @@ export function SessionHeader() {
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
options().find((o) => o.id === prefs().app) ??
options()[0] ??
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
@@ -440,11 +440,6 @@ export const SettingsGeneral: Component = () => {
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
@@ -471,11 +466,6 @@ export const SettingsGeneral: Component = () => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
@@ -422,11 +422,6 @@ export const SettingsGeneralV2: Component<{
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
@@ -454,11 +449,6 @@ export const SettingsGeneralV2: Component<{
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
@@ -246,18 +246,23 @@ export function TabNavItem(props: {
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<Show when={props.session()}>
{(session) => (
<span data-slot="project-avatar-slot">
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session()}
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
>
{(session) => (
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
server={props.server}
/>
</span>
)}
</Show>
)}
</Show>
</span>
<span
ref={(el) => {
titleEl = el
@@ -40,6 +40,7 @@ function SessionTabSlot(props: {
let ref!: HTMLDivElement
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info()[props.id])
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx()
@@ -71,8 +72,10 @@ function SessionTabSlot(props: {
createEffect(() => {
const value = session()
if (!value) return
tabs.rememberSessionInfo(props.tab, value)
const current = sdk()
if (!value || !current) return
if (!current) return
createTabPromptState(tabs, props.tab, current.scope, {
dir: base64Encode(value.directory),
id: value.id,
@@ -86,7 +89,7 @@ function SessionTabSlot(props: {
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() && !missingSession() }}
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
>
<TabNavItem
ref={(el) => {
@@ -95,7 +98,7 @@ function SessionTabSlot(props: {
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
+12 -12
View File
@@ -275,10 +275,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return
if (route.type === "draft") {
return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
return tabsStore().find((item) => item.type === "draft" && item.draftID === route.draftID)
}
if (route.type === "session") {
const main = tabsStore.find(
const main = tabsStore().find(
(item) =>
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
)
@@ -286,7 +286,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const s = session()
if (s?.parentID) {
const parentID = s.parentID
const parent = tabsStore.find(
const parent = tabsStore().find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
)
if (parent) return parent
@@ -395,7 +395,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
keybind: "mod+w",
hidden: true,
onSelect: () => {
tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab))
tabsStoreActions.closeTab(tabsStore().findIndex((tab) => current === tab))
},
},
{
@@ -412,13 +412,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab === currentTab())
let index = tabsStore().findIndex((tab) => tab === currentTab())
if (index === -1) return
index -= 1
if (index === -1) index = tabsStore.length - 1
if (index === -1) index = tabsStore().length - 1
const next = tabsStore[index]
const next = tabsStore()[index]
if (next) tabs.select(next)
},
},
@@ -429,13 +429,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
keybind: `mod+option+ArrowRight,ctrl+tab`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab === currentTab())
let index = tabsStore().findIndex((tab) => tab === currentTab())
if (index === -1) return
index += 1
if (index === tabsStore.length) index = 0
if (index === tabsStore().length) index = 0
const next = tabsStore[index]
const next = tabsStore()[index]
if (next) tabs.select(next)
},
},
@@ -482,7 +482,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
</TooltipV2>
<TitlebarTabStrip
tabs={tabsStore}
tabs={tabsStore()}
currentTab={currentTab}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
@@ -491,7 +491,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
el?.scrollIntoView({ behavior: "instant" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
const index = tabsStore().findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
+3 -3
View File
@@ -253,8 +253,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const warnedDuplicates = new Set<string>()
type CommandCatalog = Record<string, CommandCatalogItem>
const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"),
const [catalog, setCatalog, catalogReady] = persisted(
Persist.global("command.catalog().v1"),
createStore<CommandCatalog>({}),
)
@@ -416,7 +416,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const keybindConfig = (id: string) => {
if (id === PALETTE_ID) return settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
const base = actionId(id)
return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog[base]?.keybind)
return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog()[base]?.keybind)
}
return {
+2 -2
View File
@@ -181,13 +181,13 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted(
const [store, setStore, ready] = persisted(
Persist.serverScoped(scope, dir, id, "comments", [legacy]),
createStore<CommentStore>({
comments: {},
}),
)
const session = createCommentSessionState(store, setStore)
const session = createCommentSessionState(store(), setStore)
return {
ready,
+5 -5
View File
@@ -37,7 +37,7 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
function createViewSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
const [view, setView, _, ready] = persisted(
const [view, setView, ready] = persisted(
Persist.serverScoped(scope, dir, id, "file-view", [legacyViewKey]),
createStore<{
file: Record<string, FileViewState>
@@ -49,7 +49,7 @@ function createViewSession(scope: ServerScope, dir: string, id: string | undefin
const meta = { pruned: false }
const pruneView = (keep?: string) => {
const keys = Object.keys(view.file)
const keys = Object.keys(view().file)
if (keys.length <= MAX_VIEW_FILES) return
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
@@ -71,9 +71,9 @@ function createViewSession(scope: ServerScope, dir: string, id: string | undefin
pruneView()
})
const scrollTop = (path: string) => view.file[path]?.scrollTop
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
const selectedLines = (path: string) => view.file[path]?.selectedLines
const scrollTop = (path: string) => view().file[path]?.scrollTop
const scrollLeft = (path: string) => view().file[path]?.scrollLeft
const selectedLines = (path: string) => view().file[path]?.selectedLines
const setScrollTop = (path: string, top: number) => {
setView(
@@ -9,9 +9,8 @@ import { ServerScope } from "@/utils/server-scope"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
store[0],
() => store[0],
store[1],
null,
Object.assign(() => true, { promise: undefined }),
]
@@ -1,4 +1,4 @@
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
@@ -157,7 +157,7 @@ export function createChildStoreManager(input: {
)
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
const vcsStore = vcs[0]
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[2] })
const meta = runWithOwner(input.owner, () =>
input.persist(
@@ -166,7 +166,7 @@ export function createChildStoreManager(input: {
),
)
if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[2] })
const icon = runWithOwner(input.owner, () =>
input.persist(
@@ -175,12 +175,12 @@ export function createChildStoreManager(input: {
),
)
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] })
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[2] })
const init = () =>
createRoot((dispose) => {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const initialMeta = meta[0]().value
const initialIcon = icon[0]().value
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const pathQuery = useQuery(() => input.queryOptions.path(key))
@@ -241,7 +241,7 @@ export function createChildStoreManager(input: {
get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
},
vcs: vcsStore.value,
vcs: vcsStore().value,
limit: 5,
message: {},
part: {},
@@ -251,28 +251,28 @@ export function createChildStoreManager(input: {
disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
void init.then(() => {
const onPersistedInit = (ready: Accessor<boolean> & { promise: undefined | Promise<any> }, run: () => void) => {
if (!ready.promise) return
void ready.promise.then(() => {
if (children[key] !== child) return
run()
})
}
onPersistedInit(vcs[2], () => {
const cached = vcsStore.value
const cached = vcsStore().value
if (!cached?.branch) return
child[1]("vcs", (value) => value ?? cached)
})
onPersistedInit(meta[2], () => {
if (child[0].projectMeta !== initialMeta) return
child[1]("projectMeta", meta[0].value)
child[1]("projectMeta", meta[0]().value)
})
onPersistedInit(icon[2], () => {
if (child[0].icon !== initialIcon) return
child[1]("icon", icon[0].value)
child[1]("icon", icon[0]().value)
})
})
@@ -549,7 +549,7 @@ describe("applyDirectoryEvent", () => {
directory: "/tmp",
loadLsp() {},
vcsCache: {
store: cacheStore,
store: () => cacheStore,
setStore: setCacheStore,
ready: () => true,
},
@@ -19,7 +19,7 @@ import type {
} from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
import type { SetStoreFunction } from "solid-js/store"
export type ProjectMeta = {
name?: string
@@ -85,19 +85,19 @@ export type State = {
}
export type VcsCache = {
store: Store<{ value: VcsInfo | undefined }>
store: Accessor<{ value: VcsInfo | undefined }>
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
ready: Accessor<boolean>
}
export type MetaCache = {
store: Store<{ value: ProjectMeta | undefined }>
store: Accessor<{ value: ProjectMeta | undefined }>
setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
ready: Accessor<boolean>
}
export type IconCache = {
store: Store<{ value: string | undefined }>
store: Accessor<{ value: string | undefined }>
setStore: SetStoreFunction<{ value: string | undefined }>
ready: Accessor<boolean>
}
+3 -3
View File
@@ -144,7 +144,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
const platform = usePlatform()
const dialog = useDialog()
const settings = useSettings()
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
const [store, setStore, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
const [range, setRange] = createStore({
from: undefined as string | undefined,
@@ -208,7 +208,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
if (!platform.version) return
state.started = true
const previous = store.version
const previous = store().version
if (!previous) {
setStore("version", platform.version)
return
@@ -225,7 +225,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
from: () => range.from,
to: () => range.to,
get last() {
return store.version
return store().version
},
markSeen,
}
+2 -2
View File
@@ -201,14 +201,14 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
gate: false,
init: (props: { locale?: Locale }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted(
const [store, setStore, ready] = persisted(
Persist.global("language", ["language.v1"]),
createStore({
locale: initial,
}),
)
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
const locale = createMemo<Locale>(() => normalizeLocale(store().locale))
const intl = createMemo(() => INTL[locale()])
const [dict] = createResource(locale, loadDict, {
+45 -45
View File
@@ -171,7 +171,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (value.type === "home") return value
if (value.server) return value
if (value.type === "draft") {
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID)
const draft = tabs.store().find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID)
if (draft) return { ...value, server: draft.server }
}
return { ...value, server: server.key }
@@ -268,7 +268,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted(
const [store, setStore, ready] = persisted(
{ ...target, migrate },
createStore({
sidebar: {
@@ -350,8 +350,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
keep,
max: MAX_SESSION_KEYS,
used: usage.used,
view: Object.keys(store.sessionView),
tabs: Object.keys(store.sessionTabs),
view: Object.keys(store().sessionView),
tabs: Object.keys(store().sessionTabs),
})
if (drop.length === 0) return
@@ -385,9 +385,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const scroll = createScrollPersistence({
debounceMs: 250,
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
getSnapshot: (sessionKey) => store().sessionView[sessionKey]?.scroll,
onFlush: (sessionKey, next) => {
const current = store.sessionView[sessionKey]
const current = store().sessionView[sessionKey]
const keep = usage.active ?? sessionKey
if (!current) {
setStore("sessionView", sessionKey, { scroll: next })
@@ -597,18 +597,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
route,
ready,
home: {
selection: createMemo(() => store.home.selection),
selection: createMemo(() => store().home.selection),
setSelection(selection: HomeProjectSelection) {
setStore("home", "selection", reconcile(selection))
},
},
handoff: {
tabs: createMemo(() => store.handoff?.tabs),
tabs: createMemo(() => store().handoff?.tabs),
setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
},
clearTabs() {
if (!store.handoff?.tabs) return
if (!store().handoff?.tabs) return
setStore("handoff", "tabs", undefined)
},
},
@@ -642,7 +642,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
sidebar: {
opened: createMemo(() => store.sidebar.opened),
opened: createMemo(() => store().sidebar.opened),
open() {
setStore("sidebar", "opened", true)
},
@@ -652,31 +652,31 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
toggle() {
setStore("sidebar", "opened", (x) => !x)
},
width: createMemo(() => store.sidebar.width),
width: createMemo(() => store().sidebar.width),
resize(width: number) {
setStore("sidebar", "width", width)
},
workspaces(directory: string) {
return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
return () => store().sidebar.workspaces[directory] ?? store().sidebar.workspacesDefault ?? false
},
setWorkspaces(directory: string, value: boolean) {
setStore("sidebar", "workspaces", directory, value)
},
toggleWorkspaces(directory: string) {
const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
const current = store().sidebar.workspaces[directory] ?? store().sidebar.workspacesDefault ?? false
setStore("sidebar", "workspaces", directory, !current)
},
},
terminal: {
height: createMemo(() => store.terminal.height),
height: createMemo(() => store().terminal.height),
resize(height: number) {
setStore("terminal", "height", height)
},
},
review: {
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
diffStyle: createMemo(() => store().review?.diffStyle ?? "split"),
setDiffStyle(diffStyle: ReviewDiffStyle) {
if (!store.review) {
if (!store().review) {
setStore("review", { diffStyle, panelOpened: DEFAULT_REVIEW_PANEL_OPENED })
return
}
@@ -684,39 +684,39 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
fileTree: {
opened: createMemo(() => store.fileTree?.opened ?? true),
width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH),
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
opened: createMemo(() => store().fileTree?.opened ?? true),
width: createMemo(() => store().fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH),
tab: createMemo(() => store().fileTree?.tab ?? "changes"),
setTab(tab: "changes" | "all") {
if (!store.fileTree) {
if (!store().fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab })
return
}
setStore("fileTree", "tab", tab)
},
open() {
if (!store.fileTree) {
if (!store().fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", true)
},
close() {
if (!store.fileTree) {
if (!store().fileTree) {
setStore("fileTree", { opened: false, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", false)
},
toggle() {
if (!store.fileTree) {
if (!store().fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", (x) => !x)
},
resize(width: number) {
if (!store.fileTree) {
if (!store().fileTree) {
setStore("fileTree", { opened: true, width, tab: "changes" })
return
}
@@ -724,9 +724,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
session: {
width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH),
width: createMemo(() => store().session?.width ?? DEFAULT_SESSION_WIDTH),
resize(width: number) {
if (!store.session) {
if (!store().session) {
setStore("session", { width })
return
}
@@ -734,7 +734,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
mobileSidebar: {
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
opened: createMemo(() => store().mobileSidebar?.opened ?? false),
show() {
setStore("mobileSidebar", "opened", true)
},
@@ -749,7 +749,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
set(sessionKey: string, messageID: string) {
const at = Date.now()
touch(sessionKey)
const current = store.sessionView[sessionKey]
const current = store().sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, {
scroll: {},
@@ -770,7 +770,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
)
},
consume(sessionKey: string) {
const current = store.sessionView[sessionKey]
const current = store().sessionView[sessionKey]
const message = current?.pendingMessage
const at = current?.pendingMessageAt
if (!message || !at) return
@@ -790,13 +790,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
view(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const s = createMemo(() => store().sessionView[key()] ?? { scroll: {} })
const terminalOpened = createMemo(() => store().terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store().review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
function setTerminalOpened(next: boolean) {
const current = store.terminal
const current = store().terminal
if (!current) {
setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
return
@@ -809,7 +809,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
const nextSource = next ? source : "other"
const current = store.review
const current = store().review
if (!current) {
batch(() => {
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
@@ -840,7 +840,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
get: () => s().todoCollapsed ?? false,
set(collapsed: boolean) {
const session = key()
const current = store.sessionView[session]
const current = store().sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed })
} else {
@@ -878,7 +878,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setOpen(open: string[]) {
const session = key()
const next = Array.from(new Set(open))
const current = store.sessionView[session]
const current = store().sessionView[session]
if (!current) {
setStore("sessionView", session, {
scroll: {},
@@ -892,7 +892,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
openPath(path: string) {
const session = key()
const current = store.sessionView[session]
const current = store().sessionView[session]
if (!current) {
setStore("sessionView", session, {
scroll: {},
@@ -911,7 +911,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
closePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
const current = store().sessionView[session]?.reviewOpen
if (!current) return
const index = current.indexOf(path)
@@ -928,7 +928,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
togglePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
const current = store().sessionView[session]?.reviewOpen
if (!current || !current.includes(path)) {
this.openPath(path)
return
@@ -942,7 +942,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
tabs(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const path = createMemo(() => sessionPath(key()))
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
const tabs = createMemo(() => store().sessionTabs[key()] ?? { all: [] })
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
return {
@@ -952,7 +952,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setActive(tab: string | undefined) {
const session = key()
const next = tab ? normalize(tab) : tab
if (!store.sessionTabs[session]) {
if (!store().sessionTabs[session]) {
setStore("sessionTabs", session, { all: [], active: next })
} else {
setStore("sessionTabs", session, "active", next)
@@ -961,7 +961,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setAll(all: string[]) {
const session = key()
const next = normalizeAll(all).filter((tab) => tab !== "review")
if (!store.sessionTabs[session]) {
if (!store().sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
setStore("sessionTabs", session, "all", next)
@@ -969,12 +969,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
async open(tab: string) {
const session = key()
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
const next = nextSessionTabsForOpen(store().sessionTabs[session], normalize(tab))
setStore("sessionTabs", session, next)
},
close(tab: string) {
const session = key()
const current = store.sessionTabs[session]
const current = store().sessionTabs[session]
if (!current) return
if (tab === "review") {
@@ -998,7 +998,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
move(tab: string, to: number) {
const session = key()
const current = store.sessionTabs[session]
const current = store().sessionTabs[session]
if (!current) return
const index = current.all.findIndex((f) => f === tab)
if (index === -1) return
+3 -3
View File
@@ -125,7 +125,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => {
const session = id()
if (!session) return store.draft ?? store.promoting
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
return saved().session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
})
createEffect(() => {
@@ -135,7 +135,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const key = handoffKey(serverSDK().scope, sdk().directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
if (saved().session[session] !== undefined) {
handoff.delete(key)
setStore("promoting", undefined)
return
@@ -395,7 +395,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
if (saved().session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
setSaved("session", session, {
+8 -8
View File
@@ -28,7 +28,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
const providers = useProviders(props.directory)
const [store, setStore, _, ready] = persisted(
const [store, setStore, ready] = persisted(
Persist.global("model", ["model.v1"]),
createStore<Store>({
user: [],
@@ -89,7 +89,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
const visibility = createMemo(() => {
const map = new Map<string, Visibility>()
for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
for (const item of store().user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
return map
})
@@ -104,12 +104,12 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
function update(model: ModelKey, state: Visibility) {
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
const index = store().user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
if (index >= 0) {
setStore("user", index, (current) => ({ ...current, visibility: state }))
return
}
setStore("user", store.user.length, { ...model, visibility: state })
setStore("user", store().user.length, { ...model, visibility: state })
}
const visible = (model: ModelKey) => {
@@ -128,17 +128,17 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
}
const push = (model: ModelKey) => {
const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
const uniq = uniqueBy([model, ...store().recent], (x) => `${x.providerID}:${x.modelID}`)
if (uniq.length > RECENT_LIMIT) uniq.pop()
setStore("recent", uniq)
}
const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
const getVariant = (model: ModelKey) => store().variant?.[variantKey(model)]
const setVariant = (model: ModelKey, value: string | undefined) => {
const key = variantKey(model)
if (!store.variant) {
if (!store().variant) {
setStore("variant", { [key]: value })
return
}
@@ -147,7 +147,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
const [recentModels] = createResource(
async () => {
const recent = store.recent
const recent = store().recent
await ready.promise
return recent
},
+6 -6
View File
@@ -127,7 +127,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const activeServer = createMemo(() => {
if (params.serverKey) return requireServerKey(params.serverKey)
if (search.draftId) {
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
const draft = tabs.store().find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
if (draft) return draft.server
}
return server.key
@@ -222,13 +222,13 @@ function createServerNotificationState(input: {
const currentDirectory = input.directory
const currentSession = input.sessionID
const [store, setStore, _, ready] = persisted(
const [store, setStore, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
)
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store().list))
const meta = { pruned: false, disposed: false }
@@ -285,7 +285,7 @@ function createServerNotificationState(input: {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
const list = pruneNotifications(store.list)
const list = pruneNotifications(store().list)
batch(() => {
setStore("list", list)
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
@@ -293,9 +293,9 @@ function createServerNotificationState(input: {
})
const append = (notification: Notification) => {
const list = pruneNotifications([...store.list, notification])
const list = pruneNotifications([...store().list, notification])
const keep = new Set(list)
const removed = store.list.filter((n) => !keep.has(n))
const removed = store().list.filter((n) => !keep.has(n))
batch(() => {
if (keep.has(notification)) appendToIndex(notification)
+5 -5
View File
@@ -59,7 +59,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return hasPermissionPromptRules(store.config.permission)
})
const [store, setStore, _, ready] = persisted(
const [store, setStore, ready] = persisted(
{
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
migrate(value) {
@@ -91,7 +91,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] === undefined) {
if (store().autoAccept[key] === undefined) {
setStore(
produce((draft) => {
draft.autoAccept[key] = true
@@ -143,16 +143,16 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
function isAutoAccepting(sessionID: string, directory?: string) {
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
return autoRespondsPermission(store().autoAccept, session, { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
return isDirectoryAutoAccepting(store().autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, permission, directory)
return autoRespondsPermission(store().autoAccept, session, permission, directory)
}
function bumpEnableVersion(sessionID: string, directory?: string) {
+3 -3
View File
@@ -206,12 +206,12 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
}
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
const [store, setStore, ready] = persisted(
promptTarget(serverScope, scope),
createStore<PromptStore>(promptStore()),
)
return { ready, ...createPromptStateValue(store, setStore) }
return { ready, ...createPromptStateValue(store(), setStore) }
}
export function createPromptReady(session: Accessor<PromptSession>) {
@@ -327,7 +327,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const scope = () =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: Scope) => {
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store(), scope, serverKey()) : undefined
if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}
+7 -7
View File
@@ -260,7 +260,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
const [store, setStore, _, ready] = persisted(
const [store, setStore, ready] = persisted(
{
...Persist.global("server", ["server.v3"]),
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
@@ -276,7 +276,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => {
return resolveServerList({ stored: store.list, props: props.servers })
return resolveServerList({ stored: store().list, props: props.servers })
})
const [state, setState] = createStore({
@@ -292,11 +292,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (!url_) return
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_)
const existing = store().list.findIndex((x) => url(x) === url_)
if (existing !== -1) {
setStore("list", existing, conn)
} else {
setStore("list", store.list.length, conn)
setStore("list", store().list.length, conn)
}
setState("active", ServerConnection.key(conn))
return conn
@@ -305,7 +305,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
function remove(key: ServerConnection.Key) {
const next = nextServerAfterRemoval(allServers(), key, props.defaultServer)
const list = store.list.filter((x) => url(x) !== key)
const list = store().list.filter((x) => url(x) !== key)
batch(() => {
setStore("list", list)
if (state.active === key) setState("active", next)
@@ -318,12 +318,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
)
const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
const projects = createServerProjects({ scope, store, setStore })
const projects = createServerProjects({ scope, store: store(), setStore })
const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
const projectsForServer = (key: ServerConnection.Key) => {
const existing = projectStores.get(key)
if (existing) return existing
const next = createServerProjects({ scope: () => scope(key), store, setStore })
const next = createServerProjects({ scope: () => scope(key), store: store(), setStore })
projectStores.set(key, next)
return next
}
+34 -34
View File
@@ -152,45 +152,45 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
name: "Settings",
gate: false,
init: () => {
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
const [store, setStore, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const showFileTree = withFallback(() => store().general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store().general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store().general?.showStatus, defaultSettings.general.showStatus)
const showCustomAgents = withFallback(
() => store.general?.showCustomAgents,
() => store().general?.showCustomAgents,
defaultSettings.general.showCustomAgents,
)
const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault)
const newLayoutDesigns = withFallback(() => store().general?.newLayoutDesigns, newLayoutDesignsDefault)
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
createEffect(() => {
if (typeof document === "undefined") return
const root = document.documentElement
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
root.style.setProperty("--font-family-mono", monoFontFamily(store().appearance?.mono))
root.style.setProperty("--font-family-sans", sansFontFamily(store().appearance?.sans))
})
createEffect(() => {
if (store.general?.followup !== "queue") return
if (store().general?.followup !== "queue") return
setStore("general", "followup", "steer")
})
return {
ready,
get current() {
return store
return store()
},
general: {
autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave),
autoSave: withFallback(() => store().general?.autoSave, defaultSettings.general.autoSave),
setAutoSave(value: boolean) {
setStore("general", "autoSave", value)
},
releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes),
releaseNotes: withFallback(() => store().general?.releaseNotes, defaultSettings.general.releaseNotes),
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
followup: withFallback(
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
() => (store().general?.followup === "queue" ? "steer" : store().general?.followup),
defaultSettings.general.followup,
),
setFollowup(value: "queue" | "steer") {
@@ -200,7 +200,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowFileTree(value: boolean) {
setStore("general", "showFileTree", value)
},
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
showNavigation: withFallback(() => store().general?.showNavigation, defaultSettings.general.showNavigation),
setShowNavigation(value: boolean) {
setStore("general", "showNavigation", value)
},
@@ -212,26 +212,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowStatus(value: boolean) {
setStore("general", "showStatus", value)
},
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
showTerminal: withFallback(() => store().general?.showTerminal, defaultSettings.general.showTerminal),
setShowTerminal(value: boolean) {
setStore("general", "showTerminal", value)
},
showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries,
() => store().general?.showReasoningSummaries,
defaultSettings.general.showReasoningSummaries,
),
setShowReasoningSummaries(value: boolean) {
setStore("general", "showReasoningSummaries", value)
},
shellToolPartsExpanded: withFallback(
() => store.general?.shellToolPartsExpanded,
() => store().general?.shellToolPartsExpanded,
defaultSettings.general.shellToolPartsExpanded,
),
setShellToolPartsExpanded(value: boolean) {
setStore("general", "shellToolPartsExpanded", value)
},
editToolPartsExpanded: withFallback(
() => store.general?.editToolPartsExpanded,
() => store().general?.editToolPartsExpanded,
defaultSettings.general.editToolPartsExpanded,
),
setEditToolPartsExpanded(value: boolean) {
@@ -242,7 +242,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "showCustomAgents", value)
},
mobileTitlebarPosition: withFallback(
() => store.general?.mobileTitlebarPosition,
() => store().general?.mobileTitlebarPosition,
defaultSettings.general.mobileTitlebarPosition,
),
setMobileTitlebarPosition(value: "top" | "bottom") {
@@ -260,25 +260,25 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
customAgents: visible(showCustomAgents),
},
appearance: {
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
fontSize: withFallback(() => store().appearance?.fontSize, defaultSettings.appearance.fontSize),
setFontSize(value: number) {
setStore("appearance", "fontSize", value)
},
font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
font: withFallback(() => store().appearance?.mono, defaultSettings.appearance.mono),
setFont(value: string) {
setStore("appearance", "mono", value.trim() ? value : "")
},
uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
uiFont: withFallback(() => store().appearance?.sans, defaultSettings.appearance.sans),
setUIFont(value: string) {
setStore("appearance", "sans", value.trim() ? value : "")
},
terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal),
terminalFont: withFallback(() => store().appearance?.terminal, defaultSettings.appearance.terminal),
setTerminalFont(value: string) {
setStore("appearance", "terminal", value.trim() ? value : "")
},
},
keybinds: {
get: (action: string) => store.keybinds?.[action],
get: (action: string) => store().keybinds?.[action],
set(action: string, keybind: string) {
setStore("keybinds", action, keybind)
},
@@ -295,50 +295,50 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
},
},
permissions: {
autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
autoApprove: withFallback(() => store().permissions?.autoApprove, defaultSettings.permissions.autoApprove),
setAutoApprove(value: boolean) {
setStore("permissions", "autoApprove", value)
},
},
notifications: {
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
agent: withFallback(() => store().notifications?.agent, defaultSettings.notifications.agent),
setAgent(value: boolean) {
setStore("notifications", "agent", value)
},
permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions),
permissions: withFallback(() => store().notifications?.permissions, defaultSettings.notifications.permissions),
setPermissions(value: boolean) {
setStore("notifications", "permissions", value)
},
errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors),
errors: withFallback(() => store().notifications?.errors, defaultSettings.notifications.errors),
setErrors(value: boolean) {
setStore("notifications", "errors", value)
},
},
sounds: {
agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled),
agentEnabled: withFallback(() => store().sounds?.agentEnabled, defaultSettings.sounds.agentEnabled),
setAgentEnabled(value: boolean) {
setStore("sounds", "agentEnabled", value)
},
agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
agent: withFallback(() => store().sounds?.agent, defaultSettings.sounds.agent),
setAgent(value: string) {
setStore("sounds", "agent", value)
},
permissionsEnabled: withFallback(
() => store.sounds?.permissionsEnabled,
() => store().sounds?.permissionsEnabled,
defaultSettings.sounds.permissionsEnabled,
),
setPermissionsEnabled(value: boolean) {
setStore("sounds", "permissionsEnabled", value)
},
permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
permissions: withFallback(() => store().sounds?.permissions, defaultSettings.sounds.permissions),
setPermissions(value: string) {
setStore("sounds", "permissions", value)
},
errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled),
errorsEnabled: withFallback(() => store().sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled),
setErrorsEnabled(value: boolean) {
setStore("sounds", "errorsEnabled", value)
},
errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
errors: withFallback(() => store().sounds?.errors, defaultSettings.sounds.errors),
setErrors(value: string) {
setStore("sounds", "errors", value)
},
+64 -29
View File
@@ -28,6 +28,11 @@ export type DraftTab = {
export type Tab = SessionTab | DraftTab
export type TabInfo = {
title?: string
directory?: string
}
type RecentTab = {
key?: string
}
@@ -50,7 +55,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const server = useServer()
const platform = usePlatform()
const fallback = server.key
const [store, setStore, _, ready] = persisted(
const [store, setStore, ready] = persisted(
{
...Persist.window("tabs"),
migrate: (value: unknown) => {
@@ -63,8 +68,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const [recent, setRecent, recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore<Record<string, TabInfo>>({}))
const [closed, setClosed, closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const params = useParams()
const navigate = useNavigate()
@@ -75,7 +81,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
let recentWrite = 0
let recentValue: string | undefined
const recentKey = () => (recentWrite ? recentValue : recent.key)
const recentKey = () => (recentWrite ? recentValue : recent().key)
const setRecentKey = (key: string | undefined) => {
const write = ++recentWrite
@@ -102,26 +108,43 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
const removeInfo = (key: string) => {
if (!info()[key]) return
setInfo(
produce((draft) => {
delete draft[key]
}),
)
}
onCleanup(memory.dispose)
createEffect(() => {
if (!ready() || !recentReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
const next = store.filter((tab) => servers.has(tab.server))
if (next.length !== store.length) {
for (const tab of store) {
if (!servers.has(tab.server)) memory.remove(tabKey(tab))
const next = store().filter((tab) => servers.has(tab.server))
if (next.length !== store().length) {
for (const tab of store()) {
if (!servers.has(tab.server)) {
const key = tabKey(tab)
memory.remove(key)
removeInfo(key)
}
}
setStore(() => next)
}
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
if (recent().key && !next.some((tab) => tabKey(tab) === recent().key)) setRecentKey(undefined)
const keys = new Set(next.map(tabKey))
for (const key of Object.keys(info())) {
if (!keys.has(key)) removeInfo(key)
}
})
createEffect(() => {
if (!closedReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
const next = closed.filter((entry) => servers.has(entry.tab.server))
if (next.length !== closed.length) setClosed(() => next)
const next = closed().filter((entry) => servers.has(entry.tab.server))
if (next.length !== closed().length) setClosed(() => next)
})
const navigateTab = (tab: Tab) => {
@@ -131,11 +154,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}
const removeTab = (index: number) => {
const tab = store[index]
const tab = store()[index]
if (!tab) return
const key = tabKey(tab)
const draftID = tab.type === "draft" ? tab.draftID : undefined
const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/")
const nextTab = nextTabAfterClose(store(), index, recentKey() === key && location.pathname !== "/")
closing.add(key)
void startTransition(() => {
setStore(
@@ -150,13 +173,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (nextTab) navigateTab(nextTab)
}).finally(() => closing.delete(key))
memory.remove(key)
removeInfo(key)
if (draftID) removeDraftPersisted(draftID)
}
const actions = {
addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab }
const existing = store.find((item) => tabKey(item) === tabKey(next))
const existing = store().find((item) => tabKey(item) === tabKey(next))
if (existing) return existing
void startTransition(() => {
setStore(
@@ -179,7 +203,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
},
draft(draftID: string) {
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
const tab = store().find((item) => item.type === "draft" && item.draftID === draftID)
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab
},
@@ -204,7 +228,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
// Keep the replacement and navigation atomic so /new-session never renders
// after its backing draft tab has been removed from the store.
// after its backing draft tab has been removed from the store().
const active = location.pathname === "/new-session" && location.query.draftId === draftID
const next = { type: "session" as const, ...session }
void startTransition(() => {
@@ -214,7 +238,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (index !== -1) tabs[index] = next
}),
)
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
if (recent().key === `draft:${draftID}`) setRecentKey(tabKey(next))
if (active) navigateTab(next)
})
memory.remove(`draft:${draftID}`)
@@ -225,7 +249,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
// Cleanup paths (missing sessions, archive, server removal) go through
// removeTab and friends directly and are not recorded.
closeTab(index: number) {
const tab = store[index]
const tab = store()[index]
if (!tab) return
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index))
removeTab(index)
@@ -235,12 +259,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
void closedReady.promise?.then(() => actions.reopenClosedTab())
return
}
const result = takeClosedTab(closed, store)
if (result.stack.length === closed.length) return
const result = takeClosedTab(closed(), store())
if (result.stack.length === closed().length) return
setClosed(() => result.stack)
const entry = result.entry
if (!entry) return
const index = Math.min(entry.index, store.length)
const index = Math.min(entry.index, store().length)
void startTransition(() => {
setStore(
produce((tabs) => {
@@ -253,25 +277,27 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
removeSessionTab(input: Omit<SessionTab, "type">) {
updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId]))
const index = store.findIndex(
const index = store().findIndex(
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
)
if (index !== -1) removeTab(index)
},
removeServer(key: ServerConnection.Key) {
updateClosed((stack) => stack.filter((entry) => entry.tab.server !== key))
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
const removed = store.filter((tab) => tab.server === key).map(tabKey)
const drafts = store().flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
const removed = store().filter((tab) => tab.server === key).map(tabKey)
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
for (const key of removed) memory.remove(key)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
for (const key of removed) removeInfo(key)
const recentKey = recent().key
if (recentKey && removed.includes(recentKey)) setRecentKey(undefined)
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
},
removeSessions: (input: SessionTabsRemovedDetail) => {
const targetServer = input.server ?? server.key
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
const removed = store
const removed = store()
.filter(
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),
)
@@ -315,9 +341,18 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
else navigate("/")
}),
)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
const recentKey = recent().key
if (recentKey && removed.includes(recentKey)) setRecentKey(undefined)
})
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
},
rememberSessionInfo(tab: SessionTab, session: Session) {
const key = tabKey(tab)
const next = { title: session.title, directory: session.directory }
const current = info()[key]
if (current?.title === next.title && current.directory === next.directory) return
setInfo(key, next)
},
select: navigateTab,
remember(tab: Tab) {
@@ -326,7 +361,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
toggleHome(input: { home: boolean; current?: Tab }) {
if (input.home) {
const tab = store.find((tab) => tabKey(tab) === recentKey())
const tab = store().find((tab) => tabKey(tab) === recentKey())
if (tab) navigateTab(tab)
return
}
@@ -342,6 +377,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
}
return { ...actions, store, ready, recentReady }
return { ...actions, store, info, ready, recentReady }
},
})
+25 -25
View File
@@ -151,7 +151,7 @@ function createWorkspaceTerminalSession(
) {
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
const [store, setStore, _, ready] = persisted(
const [store, setStore, ready] = persisted(
{
...terminalPersistTarget(scope, dir, legacy),
migrate: migrateTerminalState,
@@ -166,7 +166,7 @@ function createWorkspaceTerminalSession(
const pickNextTerminalNumber = () => {
const existingTitleNumbers = new Set(
store.all.flatMap((pty) => {
store().all.flatMap((pty) => {
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
if (direct !== undefined) return [direct]
const parsed = numberFromTitle(pty.title)
@@ -183,10 +183,10 @@ function createWorkspaceTerminalSession(
}
const removeExited = (id: string) => {
const all = store.all
const all = store().all
const index = all.findIndex((x) => x.id === id)
if (index === -1) return
const active = store.active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store.active
const active = store().active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store().active
batch(() => {
setStore("active", active)
setStore(
@@ -204,8 +204,8 @@ function createWorkspaceTerminalSession(
onCleanup(unsub)
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
const index = store().all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store().all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
@@ -217,7 +217,7 @@ function createWorkspaceTerminalSession(
})
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
const currentIndex = store().all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
@@ -225,8 +225,8 @@ function createWorkspaceTerminalSession(
}
const clone = async (client: DirectorySDK["client"], id: string) => {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
const index = store().all.findIndex((x) => x.id === id)
const pty = store().all[index]
if (!pty) return
const next = await client.pty
.create({
@@ -238,7 +238,7 @@ function createWorkspaceTerminalSession(
})
if (!next?.data) return
const active = store.active === pty.id
const active = store().active === pty.id
batch(() => {
setStore("all", index, {
@@ -259,8 +259,8 @@ function createWorkspaceTerminalSession(
return {
ready,
all: createMemo(() => store.all),
active: createMemo(() => store.active),
all: createMemo(() => store().all),
active: createMemo(() => store().active),
clear() {
batch(() => {
setStore("active", undefined)
@@ -280,7 +280,7 @@ function createWorkspaceTerminalSession(
title: pty.data?.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber,
}
setStore("all", store.all.length, newTerminal)
setStore("all", store().all.length, newTerminal)
setStore("active", id)
})
.catch((error: unknown) => {
@@ -291,7 +291,7 @@ function createWorkspaceTerminalSession(
update(sdk.client, pty)
},
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const index = store().all.findIndex((x) => x.id === id)
if (index === -1) return
setStore("all", index, (pty) => trimTerminal(pty))
},
@@ -309,7 +309,7 @@ function createWorkspaceTerminalSession(
const client = sdk.client
return {
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const index = store().all.findIndex((x) => x.id === id)
if (index === -1) return
setStore("all", index, (pty) => trimTerminal(pty))
},
@@ -325,23 +325,23 @@ function createWorkspaceTerminalSession(
setStore("active", id)
},
next() {
const index = store.all.findIndex((x) => x.id === store.active)
const index = store().all.findIndex((x) => x.id === store().active)
if (index === -1) return
const nextIndex = (index + 1) % store.all.length
setStore("active", store.all[nextIndex]?.id)
const nextIndex = (index + 1) % store().all.length
setStore("active", store().all[nextIndex]?.id)
},
previous() {
const index = store.all.findIndex((x) => x.id === store.active)
const index = store().all.findIndex((x) => x.id === store().active)
if (index === -1) return
const prevIndex = index === 0 ? store.all.length - 1 : index - 1
setStore("active", store.all[prevIndex]?.id)
const prevIndex = index === 0 ? store().all.length - 1 : index - 1
setStore("active", store().all[prevIndex]?.id)
},
async close(id: string) {
const index = store.all.findIndex((f) => f.id === id)
const index = store().all.findIndex((f) => f.id === id)
if (index !== -1) {
batch(() => {
if (store.active === id) {
const next = index > 0 ? store.all[index - 1]?.id : store.all[1]?.id
if (store().active === id) {
const next = index > 0 ? store().all[index - 1]?.id : store().all[1]?.id
setStore("active", next)
}
setStore(
@@ -358,7 +358,7 @@ function createWorkspaceTerminalSession(
})
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
const index = store().all.findIndex((f) => f.id === id)
if (index === -1) return
setStore(
"all",
+5
View File
@@ -212,6 +212,11 @@ export const dict = {
"model.input.video": "video",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Allows: {{inputs}}",
"model.tooltip.context.label": "Context",
"model.tooltip.inputs": "Inputs",
"model.tooltip.model": "Model",
"model.tooltip.provider": "Provider",
"model.tooltip.reasoning": "Reasoning",
"model.tooltip.reasoning.allowed": "Allows reasoning",
"model.tooltip.reasoning.none": "No reasoning",
"model.tooltip.context": "Context limit {{limit}}",
+40 -2
View File
@@ -224,7 +224,26 @@
[data-slot="model-selector-scroll"]::before {
top: 0;
background: linear-gradient(to bottom, var(--v2-background-bg-layer-01), transparent);
height: 12px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--v2-background-bg-layer-01) 80%, transparent) 0%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 78.3336%, transparent) 4.7%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 76.4824%, transparent) 8.9%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 74.368%, transparent) 12.8%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 71.9123%, transparent) 16.56%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 69.037%, transparent) 20.37%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 65.664%, transparent) 24.4%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 61.715%, transparent) 28.83%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 57.1117%, transparent) 33.84%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 51.776%, transparent) 39.6%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 45.6296%, transparent) 46.3%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 38.5944%, transparent) 54.1%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 30.592%, transparent) 63.2%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 21.5443%, transparent) 73.76%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 11.373%, transparent) 85.97%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 0%, transparent) 100%
);
}
[data-slot="manage-models-scroll"]::before {
@@ -239,7 +258,26 @@
[data-slot="model-selector-scroll"]::after {
bottom: 0;
background: linear-gradient(to top, var(--v2-background-bg-layer-01), transparent);
height: 12px;
background: linear-gradient(
0deg,
color-mix(in srgb, var(--v2-background-bg-layer-01) 80%, transparent) 0%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 78.3336%, transparent) 4.7%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 76.4824%, transparent) 8.9%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 74.368%, transparent) 12.8%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 71.9123%, transparent) 16.56%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 69.037%, transparent) 20.37%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 65.664%, transparent) 24.4%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 61.715%, transparent) 28.83%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 57.1117%, transparent) 33.84%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 51.776%, transparent) 39.6%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 45.6296%, transparent) 46.3%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 38.5944%, transparent) 54.1%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 30.592%, transparent) 63.2%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 21.5443%, transparent) 73.76%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 11.373%, transparent) 85.97%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 0%, transparent) 100%
);
}
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
+5 -5
View File
@@ -685,14 +685,14 @@ function HomeProjectColumn(props: {
const global = useGlobal()
const dialog = useDialog()
const controller = useServerManagementController({ navigateOnAdd: false })
const [_state, setState, _, ready] = persisted(
const [_state, setState, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const [state] = createResource(
() => ready.promise ?? Promise.resolve(),
(p) => p.then(() => _state),
{ initialValue: _state },
(p) => p.then(() => _state()),
{ initialValue: _state() },
)
return (
@@ -1117,7 +1117,7 @@ function HomeSessionLeading(props: {
revealProjectOnHover: boolean
}) {
const tabs = useTabs()
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store(), props.server, props.session))
return (
<div class="relative shrink-0">
<Show when={hasOpenTab()}>
@@ -1278,7 +1278,7 @@ function HomeSessionSearch(props: {
</div>
</div>
</Show>
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02">
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02">
<IconV2 name="magnifying-glass" />
<input
ref={input}
+19 -19
View File
@@ -85,7 +85,7 @@ import { SidebarContent } from "./layout/sidebar-shell"
export default function LegacyLayout(props: ParentProps) {
const serverSDK = useServerSDK()
const [store, setStore, , ready] = persisted(
const [store, setStore, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
createStore({
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
@@ -556,11 +556,11 @@ export default function LegacyLayout(props: ParentProps) {
const workspaceName = (directory: string, projectId?: string, branch?: string) => {
const key = pathKey(directory)
const direct = store.workspaceName[key] ?? store.workspaceName[directory]
const direct = store().workspaceName[key] ?? store().workspaceName[directory]
if (direct) return direct
if (!projectId) return
if (!branch) return
return store.workspaceBranchName[projectId]?.[branch]
return store().workspaceBranchName[projectId]?.[branch]
}
const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
@@ -568,7 +568,7 @@ export default function LegacyLayout(props: ParentProps) {
setStore("workspaceName", key, next)
if (!projectId) return
if (!branch) return
if (!store.workspaceBranchName[projectId]) {
if (!store().workspaceBranchName[projectId]) {
setStore("workspaceBranchName", projectId, {})
}
setStore("workspaceBranchName", projectId, branch, next)
@@ -591,7 +591,7 @@ export default function LegacyLayout(props: ParentProps) {
const activeDir = currentDir()
return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const expanded = store().workspaceExpanded[directory] ?? directory === project.worktree
const active = pathKey(directory) === pathKey(activeDir)
return expanded || active
})
@@ -601,7 +601,7 @@ export default function LegacyLayout(props: ParentProps) {
if (!pageReady()) return
if (!layoutReady()) return
const projects = layout.projects.list()
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
for (const [directory, expanded] of Object.entries(store().workspaceExpanded)) {
if (!expanded) continue
const key = pathKey(directory)
const project = projects.find(
@@ -1127,7 +1127,7 @@ export default function LegacyLayout(props: ParentProps) {
.find((item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key))
if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find(
const known = Object.entries(store().workspaceOrder).find(
([root, dirs]) => pathKey(root) === key || dirs.some((item) => pathKey(item) === key),
)
if (known) return known[0]
@@ -1150,7 +1150,7 @@ export default function LegacyLayout(props: ParentProps) {
}
function clearLastProjectSession(root: string) {
if (!store.lastProjectSession[root]) return
if (!store().lastProjectSession[root]) return
setStore(
"lastProjectSession",
produce((draft) => {
@@ -1162,7 +1162,7 @@ export default function LegacyLayout(props: ParentProps) {
function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
rememberSessionRoute(directory, id, root)
notification.session.markViewed(id)
const expanded = untrack(() => store.workspaceExpanded[directory])
const expanded = untrack(() => store().workspaceExpanded[directory])
if (expanded === false) {
setStore("workspaceExpanded", directory, true)
}
@@ -1176,7 +1176,7 @@ export default function LegacyLayout(props: ParentProps) {
server.projects.touch(root)
const project = layout.projects.list().find((item) => item.worktree === root)
let dirs = project
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store().workspaceOrder[root])
: [root]
const canOpen = (value: string | undefined) => {
if (!value) return false
@@ -1188,7 +1188,7 @@ export default function LegacyLayout(props: ParentProps) {
.client.worktree.list({ directory: root })
.then((x) => x.data ?? [])
.catch(() => [] as string[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store().workspaceOrder[root])
return canOpen(target)
}
const openSession = async (target: { directory: string; id: string }) => {
@@ -1210,7 +1210,7 @@ export default function LegacyLayout(props: ParentProps) {
return true
}
const projectSession = store.lastProjectSession[root]
const projectSession = store().lastProjectSession[root]
if (projectSession?.id) {
await refreshDirs(projectSession.directory)
const opened = await openSession(projectSession)
@@ -1401,7 +1401,7 @@ export default function LegacyLayout(props: ParentProps) {
if (!result) return
if (pathKey(store.lastProjectSession[root]?.directory ?? "") === pathKey(directory)) {
if (pathKey(store().lastProjectSession[root]?.directory ?? "") === pathKey(directory)) {
clearLastProjectSession(root)
}
@@ -1424,7 +1424,7 @@ export default function LegacyLayout(props: ParentProps) {
const nextKey = pathKey(nextCurrent)
const project = layout.projects.list().find((item) => item.worktree === root)
const dirs = project
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store().workspaceOrder[root])
: [root]
const valid = dirs.some((item) => pathKey(item) === nextKey)
@@ -1771,7 +1771,7 @@ export default function LegacyLayout(props: ParentProps) {
: undefined
const pending = extra ? WorktreeState.get(serverSDK().scope, extra)?.status === "pending" : false
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
const ordered = effectiveWorkspaceOrder(local, dirs, store().workspaceOrder[project.worktree])
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
if (!extra) return ordered
if (pending) return ordered
@@ -1875,7 +1875,7 @@ export default function LegacyLayout(props: ParentProps) {
setEditor,
InlineEditor,
isBusy,
workspaceExpanded: (directory, local) => store.workspaceExpanded[directory] ?? local,
workspaceExpanded: (directory, local) => store().workspaceExpanded[directory] ?? local,
setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
showResetWorkspaceDialog: (root, directory) =>
dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
@@ -2171,7 +2171,7 @@ export default function LegacyLayout(props: ParentProps) {
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
activeWorkspace={() => store().activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
@@ -2187,7 +2187,7 @@ export default function LegacyLayout(props: ParentProps) {
<div
class="shrink-0 px-3 py-3"
classList={{
hidden: store.gettingStartedDismissed || !(providers.all().size > 0 && providers.paid().length === 0),
hidden: store().gettingStartedDismissed || !(providers.all().size > 0 && providers.paid().length === 0),
}}
>
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
@@ -2217,7 +2217,7 @@ export default function LegacyLayout(props: ParentProps) {
}
const projects = () => layout.projects.list()
const projectOverlay = () => <ProjectDragOverlay projects={projects} activeProject={() => store.activeProject} />
const projectOverlay = () => <ProjectDragOverlay projects={projects} activeProject={() => store().activeProject} />
const sidebarContent = (mobile?: boolean) => (
<SidebarContent
mobile={mobile}
@@ -2,8 +2,8 @@ import { createMemo, type Accessor } from "solid-js"
import { useGlobal } from "@/context/global"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { sessionPermissionRequest, sessionQuestionRequest } from "@/pages/session/composer/session-request-tree"
import { ServerConnection } from "@/context/server"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
export function useSessionTabAvatarState(
server: Accessor<ServerConnection.Key>,
@@ -26,15 +26,20 @@ export function useSessionTabAvatarState(
return !permission.autoResponds(item, directory())
})
})
const unread = createMemo(() => {
if (hasPermissions()) return true
if (!connection()) return false
return notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0
const hasQuestions = createMemo(() => {
const serverSync = sync()
if (!serverSync) return false
const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const unread = createMemo(
() => needsAttention() || notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0,
)
const loading = createMemo(() => {
const serverSync = sync()
if (!serverSync) return false
if (hasPermissions()) return false
if (needsAttention()) return false
return serverSync.session.data.session_working(sessionId())
})
return { unread, loading }
+7 -7
View File
@@ -545,7 +545,7 @@ export default function Page() {
})
const [followup, setFollowup] = persisted(
Persist.serverWorkspace(serverSDK().scope, sdk().directory, "followup", ["followup.v1"]),
Persist.serverWorkspace(serverSDK().scope, sdk().directory, "followup", ["followup().v1"]),
createStore<{
items: Record<string, FollowupItem[] | undefined>
failed: Record<string, string | undefined>
@@ -1614,19 +1614,19 @@ export default function Page() {
const queuedFollowups = createMemo(() => {
const id = params.id
if (!id) return emptyFollowups
return followup.items[id] ?? emptyFollowups
return followup().items[id] ?? emptyFollowups
})
const editingFollowup = createMemo(() => {
const id = params.id
if (!id) return
return followup.edit[id]
return followup().edit[id]
})
const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
const owner = sessionOwnership.capture()
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
const item = (followup().items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
if (!item) return
if (input.manual) setFollowup("paused", input.sessionID, undefined)
@@ -1696,7 +1696,7 @@ export default function Page() {
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
const item = (followup().items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve()
if (followupBusy(sessionID)) return Promise.resolve()
@@ -1818,8 +1818,8 @@ export default function Page() {
const item = queuedFollowups()[0]
if (!item) return
if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return
if (followup().failed[sessionID] === item.id) return
if (followup().paused[sessionID]) return
if (isChildSession()) return
if (composer.blocked()) return
if (busy(sessionID)) return
@@ -61,9 +61,9 @@ export function useUsageExceededDialogs() {
const keys = goUpsellKeys(evt.properties.status)
if (!keys) return
const seen = goUpsellState[keys.lastSeenAt]
const seen = goUpsellState()[keys.lastSeenAt]
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
if (goUpsellState[keys.dontShow]) return
if (goUpsellState()[keys.dontShow]) return
if (action.reason === "free_tier_limit") {
dialog.show(() => (
@@ -22,11 +22,11 @@ export function createReviewPanelV2State() {
const [filter, setFilter] = createSignal("")
return {
sidebarOpened: () => store.sidebarOpened,
sidebarWidth: () => store.sidebarWidth,
sidebarOpened: () => store().sidebarOpened,
sidebarWidth: () => store().sidebarWidth,
filter,
setFilter,
expandMode: () => store.expandMode,
expandMode: () => store().expandMode,
setExpandMode: (mode: SessionReviewExpandMode) => setStore("expandMode", mode),
resizeSidebar: (width: number) =>
setStore(
+9 -11
View File
@@ -6,11 +6,9 @@ import type { SetStoreFunction, Store } from "solid-js/store"
import { pathKey } from "@/utils/path-key"
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [
Store<T>,
Accessor<T>,
SetStoreFunction<T>,
InitType,
Accessor<boolean> & { promise: undefined | Promise<any> },
]
@@ -641,20 +639,20 @@ export function persisted<T>(
const [state, setState, init] = makePersisted(store, { name: config.key, storage })
const isAsync = init instanceof Promise
const [ready] = createResource(
const [storeResource] = createResource(
() => init,
async (initValue) => {
if (initValue instanceof Promise) await initValue
return true
async () => {
if (init instanceof Promise) await init
return state
},
{ initialValue: !isAsync },
{ initialValue: state },
)
return [
state,
() => storeResource() as T,
setState,
init,
Object.assign(() => (ready.loading ? false : ready.latest === true), {
Object.assign(() => !isAsync || !storeResource.loading, {
promise: init instanceof Promise ? init : undefined,
}),
]
+4 -3
View File
@@ -60,9 +60,10 @@ export namespace FSUtil {
})
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
return yield* fs
.readFileString(path)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
return yield* fs.readFileString(path).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
Effect.catchReason("PlatformError", "PermissionDenied", () => Effect.succeed(undefined)),
)
})
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
+12 -2
View File
@@ -71,7 +71,10 @@ export function setAppQuitting(quitting = true) {
export function setBackgroundColor(color: string) {
backgroundColor = color
BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color))
BrowserWindow.getAllWindows().forEach((win) => {
win.setBackgroundColor(color)
if (process.platform === "darwin") win.invalidateShadow()
})
}
export function getBackgroundColor(): string | undefined {
@@ -106,6 +109,13 @@ function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
titlebarThemes.set(win, theme)
// macOS draws the window frame hairline and shadow using the NSWindow
// appearance, which follows nativeTheme rather than the rendered content.
// Align it with the app theme so a light app on a dark system does not get
// the dark-appearance border and shadow. A "system" scheme must map to
// "system" (not the resolved mode) or prefers-color-scheme stops tracking
// OS appearance changes in the renderer.
if (process.platform === "darwin") nativeTheme.themeSource = theme.scheme ?? theme.mode ?? "system"
updateTitlebar(win)
}
@@ -172,7 +182,7 @@ export function createMainWindow(id: string = randomUUID()) {
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
trafficLightPosition: { x: 12, y: 14 },
trafficLightPosition: { x: 14, y: 14 },
}
: {}),
...(process.platform === "win32"
+1
View File
@@ -31,6 +31,7 @@ export type UpdaterAPI = {
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
mode: "light" | "dark"
scheme?: "system" | "light" | "dark"
}
export type FatalRendererError = {
error: string
+1
View File
@@ -293,6 +293,7 @@ const layer = Layer.effect(
})
const ensureGitignore = Effect.fn("Config.ensureGitignore")(function* (dir: string) {
yield* fs.ensureDir(dir)
const gitignore = path.join(dir, ".gitignore")
const hasIgnore = yield* fs.existsSafe(gitignore)
if (!hasIgnore) {
@@ -923,6 +923,31 @@ it.effect("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
)
it.effect("ignores an inaccessible OPENCODE_CONFIG_DIR", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const dir = yield* tmpdirScoped()
const configDir = path.join(dir, "inaccessible")
yield* FSUtil.use.ensureDir(configDir)
yield* FSUtil.use.chmod(configDir, 0o000)
yield* Effect.addFinalizer(() => FSUtil.use.chmod(configDir, 0o755).pipe(Effect.ignore))
yield* withProcessEnv("OPENCODE_CONFIG_DIR", configDir, Config.use.get().pipe(provideInstanceEffect(dir)))
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
)
it.effect("creates a missing OPENCODE_CONFIG_DIR", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const configDir = path.join(dir, "configdir")
yield* withProcessEnv("OPENCODE_CONFIG_DIR", configDir, Config.use.get().pipe(provideInstanceEffect(dir)))
expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("node_modules")
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
)
it.effect("installs dependencies in writable OPENCODE_CONFIG_DIR", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
+2 -2
View File
@@ -210,11 +210,11 @@ const en = {
"model.totalModels": "{{count}} models",
"model.momentum": "Momentum",
"model.vsPreviousWindow": "vs previous window",
"model.usageDescription": "Daily OpenCode Go token volume over the recent two-month window.",
"model.usageDescription": "Daily token volume.",
"model.noUsageTitle": "No usage",
"model.noUsageDescription": "No usage landed in the current window.",
"model.dailyTokenChart": "Daily token usage chart",
"model.usersDescription": "Daily unique OpenCode Go users over the recent two-month window.",
"model.usersDescription": "Daily unique users.",
"model.noUsersTitle": "No user data",
"model.noUsersDescription": "No user-bearing rows landed in the current window.",
"model.dailyUserChart": "Daily unique user chart",
+397 -1
View File
@@ -15,7 +15,7 @@ import {
type UsageRange,
} from "@opencode-ai/stats-core/domain/home"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, createSignal, For, onMount, Show, type JSX } from "solid-js"
import { createMemo, createSignal, createUniqueId, For, onMount, Show, type JSX } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
import type { GeometryCollection, Topology } from "topojson-specification"
@@ -121,6 +121,8 @@ export default function StatsModel() {
const modelHeaderLinks = createMemo<readonly HeaderLink[]>(() => [
{ href: "#overview", label: i18n.t("nav.overview") },
{ href: "#momentum", label: i18n.t("model.momentum") },
{ href: "#usage", label: i18n.t("nav.usage") },
{ href: "#unique-users", label: i18n.t("model.uniqueUsers") },
{ href: "#efficiency", label: i18n.t("nav.efficiency") },
{ href: "#geo-breakdown", label: i18n.t("nav.geoBreakdown") },
{ href: "#peers", label: i18n.t("nav.peers") },
@@ -183,6 +185,8 @@ export default function StatsModel() {
/>
<ModelOverview catalog={catalogEntry() ?? null} />
<ModelMomentumSection data={stats() ?? null} />
<ModelUsageSection data={stats() ?? null} />
<ModelUniqueUsersSection data={stats() ?? null} />
<ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
<ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} />
<ModelPeersSection data={stats() ?? null} />
@@ -561,6 +565,303 @@ function MomentumMetric(props: { label: string; value: string; watermark?: strin
)
}
function ModelUsageSection(props: { data: StatsModelData | null }) {
const i18n = useI18n()
return (
<ModelTrendSection
data={props.data}
id="usage"
title={i18n.t("nav.usage")}
description={i18n.t("model.usageDescription")}
ariaLabel={i18n.t("model.dailyTokenChart")}
emptyTitle={i18n.t("model.noUsageTitle")}
emptyDescription={i18n.t("model.noUsageDescription")}
value={(point) => point.tokens}
formatValue={formatTokens}
valueUnit={i18n.t("lab.tokens")}
rowLabel={i18n.t("lab.dailyTokens")}
lineTone="muted"
/>
)
}
function ModelUniqueUsersSection(props: { data: StatsModelData | null }) {
const i18n = useI18n()
return (
<ModelTrendSection
data={props.data}
id="unique-users"
title={i18n.t("model.uniqueUsers")}
description={i18n.t("model.usersDescription")}
ariaLabel={i18n.t("model.dailyUserChart")}
emptyTitle={i18n.t("model.noUsersTitle")}
emptyDescription={i18n.t("model.noUsersDescription")}
value={(point) => point.users}
formatValue={formatUsers}
valueUnit={i18n.t("format.users")}
rowLabel={i18n.t("model.uniqueUsers")}
lineTone="active"
activeLineBaseTone="muted"
highlightBars={false}
area
/>
)
}
function ModelTrendSection(props: {
data: StatsModelData | null
id: string
title: string
description: string
ariaLabel: string
emptyTitle: string
emptyDescription: string
value: (point: ModelUsagePoint) => number
formatValue: (value: number) => string
valueUnit: string
rowLabel: string
lineTone: "muted" | "active"
activeLineBaseTone?: "muted" | "active"
highlightBars?: boolean
area?: boolean
}) {
const activeLineClipId = createUniqueId()
const activeLineMaskId = createUniqueId()
const areaGradientId = createUniqueId()
const [activeIndex, setActiveIndex] = createSignal<number>()
const highlightBars = createMemo(() => props.highlightBars ?? true)
const lineScale = 326 / 450
const areaBottom = 100 / lineScale
const usage = createMemo(() => props.data?.usage ?? [])
const valueMax = createMemo(() => Math.max(0, ...usage().map((item) => props.value(item))) || 1)
const linePoints = createMemo(() =>
usage().map((point, index) => ({
point,
x: modelUsagePointX(index, usage().length),
y: modelUsageLineY(props.value(point), valueMax()),
})),
)
const linePath = createMemo(() => modelUsageLinePath(linePoints()))
const areaPath = createMemo(() => modelUsageAreaPath(linePoints(), areaBottom))
const activeLineBreak = createMemo(() => {
const index = activeIndex()
if (index === undefined || linePoints().length < 2) return undefined
return modelUsageColumnBounds(index, linePoints().length)
})
const activeLineClip = createMemo(() => {
const index = activeIndex()
if (index === undefined || linePoints().length < 2) return undefined
return modelUsageColumnInnerBounds(index, linePoints().length)
})
const activeTooltip = createMemo(() => {
const index = activeIndex()
const point = index === undefined ? undefined : usage()[index]
if (index === undefined || !point) return undefined
const bounds = modelUsageColumnBounds(index, usage().length)
return {
bounds,
index,
point,
tooltipY: (linePoints()[index]?.y ?? 100) * lineScale,
}
})
const monthTicks = createMemo(() => modelUsageMonthTicks(usage(), props.data?.updatedAt ?? null))
return (
<section id={props.id} data-section="model-panel">
<SectionTitle href={`#${props.id}`} title={props.title} description={props.description} />
<Show
when={usage().some((item) => props.value(item) > 0)}
fallback={<ModelEmptyState title={props.emptyTitle} description={props.emptyDescription} />}
>
<div
data-component="model-usage-chart"
data-variant="model-trend"
data-highlight-bars={highlightBars() ? "true" : undefined}
role="img"
aria-label={props.ariaLabel}
style={{ "--model-usage-count": usage().length } as JSX.CSSProperties}
onPointerLeave={(event) => {
if (event.pointerType === "touch") return
setActiveIndex(undefined)
}}
>
<div data-slot="model-trend-plot">
<Show when={linePath()}>
{(path) => (
<>
<Show when={props.area && areaPath()}>
{(area) => (
<svg
data-slot="model-trend-area-layer"
viewBox={`0 0 100 ${formatUsagePathNumber(areaBottom)}`}
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<linearGradient id={areaGradientId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="var(--model-trend-active)" stop-opacity="0.1" />
<stop offset="66%" stop-color="var(--model-trend-active)" stop-opacity="0.045" />
<stop offset="100%" stop-color="var(--model-trend-active)" stop-opacity="0" />
</linearGradient>
</defs>
<path data-slot="model-trend-area" d={area()} fill={`url(#${areaGradientId})`} />
</svg>
)}
</Show>
<svg
data-slot="model-trend-line"
data-layer="base"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<Show
when={activeLineBreak()}
fallback={<path data-slot="model-trend-line-base" data-tone={props.lineTone} d={path()} />}
>
{(lineBreak) => (
<>
<defs>
<mask id={activeLineMaskId} maskUnits="userSpaceOnUse">
<rect x="0" y="-2" width="100" height="104" fill="white" />
<rect x={lineBreak().x} y="-2" width={lineBreak().width} height="104" fill="black" />
</mask>
</defs>
<path
data-slot="model-trend-line-base"
data-tone={props.activeLineBaseTone ?? props.lineTone}
d={path()}
mask={`url(#${activeLineMaskId})`}
/>
</>
)}
</Show>
</svg>
<Show when={activeLineClip()}>
{(clip) => (
<svg
data-slot="model-trend-line"
data-layer="active"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<clipPath id={activeLineClipId} clipPathUnits="userSpaceOnUse">
<rect x={clip().x} y="-2" width={clip().width} height="104" />
</clipPath>
</defs>
<path data-slot="model-trend-line-active" d={path()} clip-path={`url(#${activeLineClipId})`} />
</svg>
)}
</Show>
</>
)}
</Show>
<Show when={linePoints().at(-1)}>
{(point) => (
<span
data-slot="model-trend-end-marker"
data-tone={props.lineTone}
aria-hidden="true"
style={
{
"--model-trend-end-x": `${point().x}%`,
"--model-trend-end-top": `${point().y * lineScale}%`,
} as JSX.CSSProperties
}
/>
)}
</Show>
<div data-slot="model-trend-bars">
<For each={usage()}>
{(point, index) => (
<div
data-slot="model-trend-column"
role="button"
tabIndex={0}
aria-label={`${point.date} ${props.formatValue(props.value(point))} ${props.valueUnit}`}
data-active={activeIndex() === index() ? "true" : undefined}
data-muted={
highlightBars() && activeIndex() !== undefined && activeIndex() !== index() ? "true" : undefined
}
style={
{
"--model-trend-token-height": modelUsageStripHeight(props.value(point), valueMax()),
} as JSX.CSSProperties
}
onPointerDown={(event) => {
if (event.pointerType !== "touch") return
setActiveIndex(index())
}}
onPointerEnter={() => setActiveIndex(index())}
onPointerMove={(event) => {
if (event.pointerType === "touch") return
setActiveIndex(index())
}}
onClick={() => setActiveIndex(index())}
onFocus={() => setActiveIndex(index())}
onBlur={() => setActiveIndex(undefined)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
setActiveIndex(index())
}}
>
<div data-slot="model-trend-token-band">
<div data-slot="model-trend-token-bar" />
</div>
</div>
)}
</For>
</div>
<Show when={activeTooltip()} keyed>
{(active) => (
<div
data-component="chart-tooltip"
data-placement={active.index > usage().length * 0.62 ? "left" : "right"}
style={
{
"--model-trend-tooltip-left": `${active.bounds.x}%`,
"--model-trend-tooltip-right": `${active.bounds.x + active.bounds.width}%`,
"--model-trend-tooltip-y": `${active.tooltipY}`,
} as JSX.CSSProperties
}
>
<strong>{formatModelUsageTooltipDate(active.point.date, props.data?.updatedAt ?? null)}</strong>
<span>
{props.formatValue(props.value(active.point))} {props.valueUnit}
</span>
<div data-slot="tooltip-divider" />
<p>
<span data-slot="tooltip-label">
<i data-kind={props.lineTone === "active" ? "users" : "tokens"} /> {props.rowLabel}
</span>
<b>{props.formatValue(props.value(active.point))}</b>
</p>
</div>
)}
</Show>
</div>
<div data-slot="model-trend-months" aria-hidden="true">
<For each={monthTicks()}>
{(tick) => (
<span
data-align={tick.align}
style={{ "--model-trend-month-left": `${tick.left}%` } as JSX.CSSProperties}
>
{tick.label}
</span>
)}
</For>
</div>
</div>
</Show>
</section>
)
}
function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) {
const i18n = useI18n()
return (
@@ -1014,6 +1315,101 @@ function formatMomentumDateLabel(date: string) {
return `${shortMonths[parsed.getUTCMonth()]} ${parsed.getUTCDate()}`
}
function modelUsageStripHeight(value: number, max: number) {
if (value <= 0 || max <= 0) return "0px"
return `max(2px, ${(value / max) * 100}%)`
}
function modelUsageLineY(value: number, max: number) {
if (value <= 0 || max <= 0) return 100
return Math.max(0, 100 - (value / max) * 100)
}
function modelUsagePointX(index: number, count: number) {
if (count <= 1) return 50
return ((index + 0.5) / count) * 100
}
function modelUsageColumnBounds(index: number, count: number) {
if (count <= 0) return { x: 0, width: 100 }
return { x: (index / count) * 100, width: 100 / count }
}
function modelUsageColumnInnerBounds(index: number, count: number) {
const bounds = modelUsageColumnBounds(index, count)
const inset = Math.min(bounds.width * 0.1, 0.24)
return { x: bounds.x + inset, width: Math.max(0.001, bounds.width - inset * 2) }
}
type ModelUsageLinePoint = { x: number; y: number }
function modelUsageLinePath(points: ModelUsageLinePoint[]) {
if (points.length === 0) return ""
if (points.length === 1) return `M ${formatUsagePathNumber(points[0].x)} ${formatUsagePathNumber(points[0].y)}`
return points.slice(0, -1).reduce(
(path, point, index) => {
const next = points[index + 1]
const previous = points[index - 1] ?? point
const afterNext = points[index + 2] ?? next
const controlA = {
x: point.x + (next.x - previous.x) / 6,
y: clampUsagePercent(point.y + (next.y - previous.y) / 6),
}
const controlB = {
x: next.x - (afterNext.x - point.x) / 6,
y: clampUsagePercent(next.y - (afterNext.y - point.y) / 6),
}
return `${path} C ${formatUsagePathNumber(controlA.x)} ${formatUsagePathNumber(controlA.y)}, ${formatUsagePathNumber(controlB.x)} ${formatUsagePathNumber(controlB.y)}, ${formatUsagePathNumber(next.x)} ${formatUsagePathNumber(next.y)}`
},
`M ${formatUsagePathNumber(points[0].x)} ${formatUsagePathNumber(points[0].y)}`,
)
}
function modelUsageAreaPath(points: ModelUsageLinePoint[], bottom: number) {
const path = modelUsageLinePath(points)
if (!path || points.length === 0) return ""
const first = points[0]
const last = points[points.length - 1]
return `${path} L ${formatUsagePathNumber(last.x)} ${formatUsagePathNumber(bottom)} L ${formatUsagePathNumber(first.x)} ${formatUsagePathNumber(bottom)} Z`
}
function formatUsagePathNumber(value: number) {
return Number(value.toFixed(3))
}
function clampUsagePercent(value: number) {
return Math.max(0, Math.min(100, value))
}
function modelUsageMonthTicks(points: ModelUsagePoint[], updatedAt: string | null) {
const seen = new Set<string>()
return points.flatMap((point, index) => {
const parsed = parseMomentumDate(point.date, updatedAt)
const label = shortMonths[parsed.getUTCMonth()]
if (!label || seen.has(label)) return []
seen.add(label)
return [
{
label,
left: modelUsagePointX(index, points.length),
align: index === 0 ? "start" : index >= points.length - 2 ? "end" : "center",
},
]
})
}
function formatModelUsageTooltipDate(value: string, updatedAt: string | null) {
const date = parseMomentumDate(value, updatedAt)
if (date.getUTCFullYear() === 1970) return value
return new Intl.DateTimeFormat("en", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(date)
}
function formatRankLabel(rank: number | null) {
if (rank === null) return "--"
return `#${String(rank).padStart(2, "0")}`
+315 -1
View File
@@ -4871,6 +4871,248 @@
background: var(--lab-usage-line);
}
[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] {
--model-trend-gap: 4px;
--model-trend-column-gutter: calc(var(--model-trend-gap) / 2);
--model-trend-bar-height: 76px;
--model-trend-line-bottom: 124px;
--model-trend-bar: #dbdbdb;
--model-trend-line: #808080;
--model-trend-active: #3b5cf6;
--model-trend-band: #f2f2f2;
grid-template-rows: minmax(0, 450px) 14px;
gap: 40px;
height: 504px;
}
[data-page="stats"] [data-slot="model-trend-plot"] {
position: relative;
min-width: 0;
min-height: 0;
overflow: visible;
}
[data-page="stats"] [data-slot="model-trend-plot"]::after {
position: absolute;
inset: 0;
z-index: 0;
background: linear-gradient(to bottom, transparent 42%, color-mix(in srgb, var(--stats-text) 3%, transparent));
content: "";
pointer-events: none;
}
[data-page="stats"] [data-slot="model-trend-line"] {
position: absolute;
top: 0;
right: 0;
bottom: auto;
left: 0;
display: block;
width: 100%;
height: calc(100% - var(--model-trend-line-bottom));
overflow: visible;
pointer-events: none;
}
[data-page="stats"] [data-slot="model-trend-line"][data-layer="base"] {
z-index: 2;
}
[data-page="stats"] [data-slot="model-trend-line"][data-layer="active"] {
z-index: 4;
}
[data-page="stats"] [data-slot="model-trend-area-layer"] {
position: absolute;
inset: 0;
z-index: 1;
display: block;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
[data-page="stats"] [data-slot="model-trend-area-layer"] [data-slot="model-trend-area"] {
stroke: none;
}
[data-page="stats"] [data-slot="model-trend-line"] path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
[data-page="stats"] [data-slot="model-trend-line-base"] {
stroke: var(--model-trend-line);
}
[data-page="stats"] [data-slot="model-trend-line-base"][data-tone="active"] {
stroke: var(--model-trend-active);
}
[data-page="stats"] [data-slot="model-trend-line-active"] {
stroke: var(--model-trend-active);
}
[data-page="stats"] [data-slot="model-trend-end-marker"] {
position: absolute;
top: var(--model-trend-end-top);
left: var(--model-trend-end-x);
z-index: 3;
width: 6px;
height: 6px;
background: var(--model-trend-bar);
transform: translate(-50%, -50%);
pointer-events: none;
}
[data-page="stats"] [data-slot="model-trend-end-marker"][data-tone="active"] {
background: var(--model-trend-active);
}
[data-page="stats"] [data-slot="model-trend-bars"] {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
gap: 0;
min-width: 0;
}
[data-page="stats"] [data-slot="model-trend-column"] {
position: relative;
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
height: 100%;
outline: none;
cursor: pointer;
}
[data-page="stats"] [data-slot="model-trend-column"]::before {
position: absolute;
inset: 0;
z-index: 1;
background: transparent;
content: "";
pointer-events: none;
transition: background 120ms ease;
}
[data-page="stats"] [data-slot="model-trend-column"][data-active="true"]::before,
[data-page="stats"] [data-slot="model-trend-column"]:focus-visible::before {
background: var(--model-trend-band);
box-shadow:
inset var(--model-trend-column-gutter) 0 0 var(--stats-bg),
inset calc(var(--model-trend-column-gutter) * -1) 0 0 var(--stats-bg);
}
[data-page="stats"] [data-slot="model-trend-token-band"] {
position: absolute;
right: var(--model-trend-column-gutter);
bottom: 0;
left: var(--model-trend-column-gutter);
z-index: 2;
height: var(--model-trend-bar-height);
}
[data-page="stats"] [data-slot="model-trend-token-bar"] {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: var(--model-trend-token-height);
background: var(--model-trend-bar);
transition:
background 120ms ease,
opacity 120ms ease;
}
[data-page="stats"]
[data-component="model-usage-chart"][data-highlight-bars="true"]
[data-slot="model-trend-column"][data-active="true"]
[data-slot="model-trend-token-bar"] {
background: var(--model-trend-active);
}
[data-page="stats"]
[data-component="model-usage-chart"][data-highlight-bars="true"]
[data-slot="model-trend-column"][data-muted="true"]
[data-slot="model-trend-token-bar"] {
opacity: 0.64;
}
[data-page="stats"] [data-slot="model-trend-months"] {
position: relative;
height: 14px;
color: var(--stats-muted);
font-size: 11px;
font-weight: 500;
line-height: 14px;
}
[data-page="stats"] [data-slot="model-trend-months"] span {
position: absolute;
top: 0;
left: var(--model-trend-month-left);
transform: translateX(-50%);
}
[data-page="stats"] [data-slot="model-trend-months"] span[data-align="start"] {
transform: none;
}
[data-page="stats"] [data-slot="model-trend-months"] span[data-align="end"] {
transform: translateX(-100%);
}
[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] [data-component="chart-tooltip"] {
z-index: 6;
top: clamp(12px, calc(var(--model-trend-tooltip-y) * 1% - 46px), calc(100% - 108px));
width: 192px;
min-width: 192px;
background: var(--stats-bg);
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"][data-placement="right"] {
right: auto;
left: calc(var(--model-trend-tooltip-right) + 8px);
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"][data-placement="left"] {
right: calc(100% - var(--model-trend-tooltip-left) + 8px);
left: auto;
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"]
p {
height: 16px;
margin: 8px 0;
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"]
i[data-kind="tokens"] {
background: var(--model-trend-active);
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"]
i[data-kind="users"] {
background: var(--model-trend-active);
}
[data-page="stats"] [data-component="model-peer-list"] {
display: grid;
gap: 8px;
@@ -4966,6 +5208,15 @@
--lab-usage-band: #242424;
}
[data-page="stats"][data-theme="dark"] [data-component="model-usage-chart"][data-variant="model-trend"],
:root[data-stats-theme="dark"]
[data-page="stats"]:not([data-theme="light"])
[data-component="model-usage-chart"][data-variant="model-trend"] {
--model-trend-bar: #3a3a3a;
--model-trend-line: #a3a3a3;
--model-trend-band: #242424;
}
[data-page="stats"][data-theme="dark"] [data-component="chart-tooltip"],
:root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) [data-component="chart-tooltip"] {
background: #242424f2;
@@ -5131,6 +5382,14 @@
--lab-usage-band: #242424;
}
:root:not([data-stats-theme="light"])
[data-page="stats"]:not([data-theme="light"])
[data-component="model-usage-chart"][data-variant="model-trend"] {
--model-trend-bar: #3a3a3a;
--model-trend-line: #a3a3a3;
--model-trend-band: #242424;
}
:root:not([data-stats-theme="light"]) [data-page="stats"]:not([data-theme="light"]) [data-component="chart-tooltip"] {
background: #242424f2;
}
@@ -5229,15 +5488,27 @@
[data-page="stats"][data-theme="dark"]
[data-component="model-usage-chart"][data-variant="lab-usage"]
[data-component="chart-tooltip"],
[data-page="stats"][data-theme="dark"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"],
:root[data-stats-theme="dark"]
[data-page="stats"]:not([data-theme="light"])
[data-component="model-usage-chart"][data-variant="lab-usage"]
[data-component="chart-tooltip"],
:root[data-stats-theme="dark"]
[data-page="stats"]:not([data-theme="light"])
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"],
:root:not([data-stats-theme="light"])
[data-page="stats"]:not([data-theme="light"])
[data-component="model-usage-chart"][data-variant="lab-usage"]
[data-component="chart-tooltip"],
[data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"] {
:root:not([data-stats-theme="light"])
[data-page="stats"]:not([data-theme="light"])
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"],
[data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"],
[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] [data-component="chart-tooltip"] {
background: var(--stats-bg);
}
@@ -5514,6 +5785,13 @@
margin-top: 16px;
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"] {
position: absolute;
margin-top: 0;
}
[data-page="stats"] [data-component="footer"] {
padding: 88px 24px 24px;
}
@@ -5935,6 +6213,42 @@
min-width: 0;
}
[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] {
--model-usage-mobile-bar-width: 20px;
--model-usage-mobile-edge-space: 48px;
--model-usage-mobile-track-width: calc(
var(--model-usage-count) * var(--model-usage-mobile-bar-width) + var(--model-usage-mobile-edge-space)
);
--model-trend-bar-height: 64px;
--model-trend-line-bottom: 104px;
grid-template-rows: minmax(0, 360px) 14px;
gap: 24px;
height: 398px;
}
[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] [data-slot="model-trend-plot"],
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-slot="model-trend-months"] {
direction: ltr;
width: var(--model-usage-mobile-track-width);
min-width: var(--model-usage-mobile-track-width);
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-slot="model-trend-column"] {
flex: 0 0 var(--model-usage-mobile-bar-width);
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"] {
top: auto;
width: auto;
min-width: 0;
}
[data-page="stats"] [data-component="model-peer-list"] a {
grid-template-columns: 30px minmax(0, 1fr) minmax(64px, auto);
}
+10 -7
View File
@@ -173,7 +173,10 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => {
init: (props: {
defaultTheme?: string
onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark", scheme: ColorScheme) => void
}) => {
const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2"
const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system"
const mode = colorScheme === "system" ? getSystemMode() : colorScheme
@@ -212,9 +215,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
return task
}
const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => {
const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark", scheme: ColorScheme) => {
applyThemeCss(theme, themeId, mode)
props.onThemeApplied?.(theme, mode)
props.onThemeApplied?.(theme, mode, scheme)
}
const ids = () => {
@@ -278,7 +281,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
createEffect(() => {
const theme = store.themes[store.themeId]
if (!theme) return
applyTheme(theme, store.themeId, store.mode)
applyTheme(theme, store.themeId, store.mode, store.colorScheme)
})
const setTheme = (id: string) => {
@@ -333,7 +336,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
? getSystemMode()
: store.previewScheme
: store.mode
applyTheme(theme, next, mode)
applyTheme(theme, next, mode, store.previewScheme ?? store.colorScheme)
})
},
previewColorScheme: (scheme: ColorScheme) => {
@@ -344,7 +347,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (!theme) return
if ((store.previewThemeId ?? store.themeId) !== id) return
if (store.previewScheme !== scheme) return
applyTheme(theme, id, mode)
applyTheme(theme, id, mode, scheme)
})
},
commitPreview: () => {
@@ -362,7 +365,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore("previewScheme", null)
void load(store.themeId).then((theme) => {
if (!theme) return
applyTheme(theme, store.themeId, store.mode)
applyTheme(theme, store.themeId, store.mode, store.colorScheme)
})
},
}