Compare commits

...

12 Commits

Author SHA1 Message Date
Adam
3b2a33f5ed fix(app): persist quota 2026-01-19 14:41:24 -06:00
GitHub Action
2bdc385b7c chore: format code 2026-01-19 14:25:33 -06:00
David Hill
40c48b48ae wip: tabs in focus and accept left and right 2026-01-19 14:18:50 -06:00
David Hill
b383026d38 fix: indicator widths 2026-01-19 14:18:50 -06:00
David Hill
aeb89297ac wip: dropdown position fix 2026-01-19 14:18:50 -06:00
David Hill
8cab430199 wip: responsiveness to keyboard shortcuts panel 2026-01-19 14:17:04 -06:00
David Hill
7050bdd43a wip: keyboard shortcuts 2026-01-19 14:17:04 -06:00
David Hill
3ff6ce5967 wip: polish to shortcuts 2026-01-19 14:16:26 -06:00
David Hill
0f398e612f wip: shortcuts panel 2026-01-19 14:10:26 -06:00
David Hill
dad5dbc1cc wip: new release modal
- highlight key updates or new features
- needs some transition love
- all copy including text and video placeholder
2026-01-19 14:01:18 -06:00
David Hill
13446cf8cc wip: start screen opencode tips
show a tip on the start screen from a list of 101 opencode tips
2026-01-19 13:55:24 -06:00
David Hill
ed3ac35581 fix: search clear icon 2026-01-19 13:53:28 -06:00
19 changed files with 1220 additions and 37 deletions

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -0,0 +1,189 @@
import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { markReleaseNotesSeen } from "@/lib/release-notes"
export interface ReleaseFeature {
title: string
description: string
tag?: string
media?: {
type: "image" | "video"
src: string
alt?: string
}
}
export interface ReleaseNote {
version: string
features: ReleaseFeature[]
}
// Current release notes - update this with each release
export const CURRENT_RELEASE: ReleaseNote = {
version: "1.0.0",
features: [
{
title: "Cleaner tab experience",
description: "Chat is now fixed to the side of your tabs, and review is now available as a dedicated tab. ",
tag: "New",
media: {
type: "video",
src: "/release/release-example.mp4",
alt: "Cleaner tab experience",
},
},
{
title: "Share with control",
description: "Keep your sessions private by default, or publish them to the web with a shareable URL.",
tag: "New",
media: {
type: "image",
src: "/release/release-share.png",
alt: "Share with control",
},
},
{
title: "Improved attachment management",
description: "Upload and manage attachments more easily, to help build and maintain context.",
tag: "New",
media: {
type: "video",
src: "/release/release-example.mp4",
alt: "Improved attachment management",
},
},
],
}
export function DialogReleaseNotes(props: { release?: ReleaseNote }) {
const dialog = useDialog()
const release = props.release ?? CURRENT_RELEASE
const [index, setIndex] = createSignal(0)
const feature = () => release.features[index()]
const total = release.features.length
const isFirst = () => index() === 0
const isLast = () => index() === total - 1
function handleNext() {
if (!isLast()) setIndex(index() + 1)
}
function handleBack() {
if (!isFirst()) setIndex(index() - 1)
}
function handleClose() {
markReleaseNotesSeen()
dialog.close()
}
let focusTrap: HTMLDivElement | undefined
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowLeft" && !isFirst()) {
e.preventDefault()
setIndex(index() - 1)
}
if (e.key === "ArrowRight" && !isLast()) {
e.preventDefault()
setIndex(index() + 1)
}
}
onMount(() => {
focusTrap?.focus()
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
})
// Refocus the trap when index changes to ensure escape always works
createEffect(() => {
index() // track index
focusTrap?.focus()
})
return (
<Dialog class="dialog-release-notes">
{/* Hidden element to capture initial focus and handle escape */}
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
{/* Left side - Text content */}
<div class="flex flex-col flex-1 min-w-0 p-8">
{/* Top section - feature content (fixed position from top) */}
<div class="flex flex-col gap-2 pt-22">
<div class="flex items-center gap-2">
<h1 class="text-16-medium text-text-strong">{feature().title}</h1>
{feature().tag && (
<span
class="text-12-medium text-text-weak px-1.5 py-0.5 bg-surface-base rounded-sm border border-border-weak-base"
style={{ "border-width": "0.5px" }}
>
{feature().tag}
</span>
)}
</div>
<p class="text-14-regular text-text-base">{feature().description}</p>
</div>
{/* Spacer to push buttons to bottom */}
<div class="flex-1" />
{/* Bottom section - buttons and indicators (fixed position) */}
<div class="flex flex-col gap-12">
<div class="flex items-center gap-3">
{isLast() ? (
<Button variant="primary" size="large" onClick={handleClose}>
Get started
</Button>
) : (
<Button variant="secondary" size="large" onClick={handleNext}>
Next
</Button>
)}
</div>
{total > 1 && (
<div class="flex items-center gap-1.5 -my-2.5">
{release.features.map((_, i) => (
<button
type="button"
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
classList={{
"w-8": i === index(),
"w-3": i !== index(),
}}
onClick={() => setIndex(i)}
>
<div
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
classList={{
"bg-icon-strong-base": i === index(),
"bg-icon-weak-base": i !== index(),
}}
/>
</button>
))}
</div>
)}
</div>
</div>
{/* Right side - Media content (edge to edge) */}
{feature().media && (
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
{feature().media!.type === "image" ? (
<img
src={feature().media!.src}
alt={feature().media!.alt ?? "Release preview"}
class="w-full h-full object-cover"
/>
) : (
<video src={feature().media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
)}
</div>
)}
</Dialog>
)
}

View File

@@ -44,7 +44,7 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { formatKeybind, useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id"
import { SessionContextUsage } from "@/components/session-context-usage"
@@ -1400,8 +1400,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
custom
</span>
</Show>
<Show when={command.keybind(cmd.id)}>
<span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
<Show when={cmd.keybind}>
<span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
</Show>
</div>
</button>

View File

@@ -0,0 +1,31 @@
import { onMount } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogReleaseNotes } from "./dialog-release-notes"
import { shouldShowReleaseNotes, markReleaseNotesSeen } from "@/lib/release-notes"
/**
* Component that handles showing release notes modal on app startup.
* Shows the modal if:
* - DEV_ALWAYS_SHOW_RELEASE_NOTES is true in lib/release-notes.ts
* - OR the user hasn't seen the current version's release notes yet
*
* To disable the dev mode behavior, set DEV_ALWAYS_SHOW_RELEASE_NOTES to false
* in packages/app/src/lib/release-notes.ts
*/
export function ReleaseNotesHandler() {
const dialog = useDialog()
onMount(() => {
// Small delay to ensure app is fully loaded before showing modal
setTimeout(() => {
if (shouldShowReleaseNotes()) {
dialog.show(
() => <DialogReleaseNotes />,
() => markReleaseNotesSeen(),
)
}
}, 500)
})
return null
}

View File

@@ -0,0 +1,232 @@
import { For, createSignal, Show, onMount, onCleanup } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { parseKeybind, formatKeybind } from "@/context/command"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const SPECIAL_CHAR_NAMES: Record<string, string> = {
"^": "Control",
"⌥": "Option",
"⇧": "Shift",
"⌘": "Command",
"↑": "Arrow Up",
"↓": "Arrow Down",
"`": "Backtick",
"'": "Quote",
".": "Period",
",": "Comma",
"/": "Slash",
"\\": "Backslash",
"[": "Left Bracket",
"]": "Right Bracket",
"-": "Minus",
"=": "Equals",
";": "Semicolon",
}
const KEY_DISPLAY_MAP: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
backspace: "⌫",
}
interface Shortcut {
title: string
keybind: string
}
interface ShortcutCategory {
name: string
shortcuts: Shortcut[]
}
function isLetter(char: string): boolean {
return /^[A-Za-z]$/.test(char)
}
function getKeyChars(config: string): string[] {
const keybinds = parseKeybind(config)
if (keybinds.length === 0) return []
const kb = keybinds[0]
const chars: string[] = []
if (kb.ctrl) chars.push(IS_MAC ? "^" : "Ctrl")
if (kb.alt) chars.push(IS_MAC ? "⌥" : "Alt")
if (kb.shift) chars.push(IS_MAC ? "⇧" : "Shift")
if (kb.meta) chars.push(IS_MAC ? "⌘" : "Meta")
if (kb.key) {
const mapped = KEY_DISPLAY_MAP[kb.key.toLowerCase()]
if (mapped) {
chars.push(mapped)
} else {
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
for (const char of displayKey) {
chars.push(char)
}
}
}
return chars
}
const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{
name: "General",
shortcuts: [
{ title: "Command palette", keybind: "mod+shift+p" },
{ title: "Toggle sidebar", keybind: "mod+b" },
{ title: "Toggle shortcuts", keybind: "ctrl+/" },
{ title: "Open file", keybind: "mod+p" },
{ title: "Open project", keybind: "mod+o" },
],
},
{
name: "Session",
shortcuts: [
{ title: "New session", keybind: "mod+shift+s" },
{ title: "Previous session", keybind: "alt+arrowup" },
{ title: "Next session", keybind: "alt+arrowdown" },
{ title: "Archive session", keybind: "mod+shift+backspace" },
{ title: "Undo", keybind: "mod+z" },
{ title: "Redo", keybind: "mod+shift+z" },
],
},
{
name: "Navigation",
shortcuts: [
{ title: "Previous message", keybind: "mod+arrowup" },
{ title: "Next message", keybind: "mod+arrowdown" },
{ title: "Toggle steps", keybind: "mod+e" },
],
},
{
name: "Model and Agent",
shortcuts: [
{ title: "Choose model", keybind: "mod+'" },
{ title: "Cycle agent", keybind: "mod+." },
],
},
{
name: "Terminal",
shortcuts: [
{ title: "Toggle terminal", keybind: "ctrl+`" },
{ title: "New terminal", keybind: "ctrl+shift+`" },
],
},
]
const USED_SHORTCUTS_KEY = "opencode:used-shortcuts"
function getUsedShortcuts(): Set<string> {
const stored = localStorage.getItem(USED_SHORTCUTS_KEY)
return stored ? new Set(JSON.parse(stored)) : new Set()
}
const [usedShortcuts, setUsedShortcuts] = createSignal(getUsedShortcuts())
function formatKeybindForCopy(config: string): string {
const chars = getKeyChars(config)
return chars.join("")
}
function ShortcutItem(props: { shortcut: Shortcut }) {
const [copied, setCopied] = createSignal(false)
const used = () => usedShortcuts().has(props.shortcut.keybind)
function copyToClipboard() {
const text = formatKeybindForCopy(props.shortcut.keybind)
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<Tooltip value="Copy to clipboard" placement="top">
<button type="button" class="shortcut-item" classList={{ "shortcut-used": used() }} onClick={copyToClipboard}>
<span class="text-14-regular text-text-base">{props.shortcut.title}</span>
<Show
when={!copied()}
fallback={
<div class="shortcut-copied">
<Icon name="check" size="small" />
</div>
}
>
<div class="shortcut-keys">
<For each={getKeyChars(props.shortcut.keybind)}>
{(char) => {
const tooltip = SPECIAL_CHAR_NAMES[char]
const isSpecial = tooltip && !isLetter(char)
const isShift = char === "⇧"
return (
<Show when={isSpecial} fallback={<kbd class="shortcut-key">{char}</kbd>}>
<Tooltip value={tooltip} placement="top">
<kbd class="shortcut-key shortcut-key-special">
<span classList={{ "shortcut-key-shift": isShift }}>{char}</span>
</kbd>
</Tooltip>
</Show>
)
}}
</For>
</div>
</Show>
</button>
</Tooltip>
)
}
export function ShortcutsPanel(props: { onClose: () => void }) {
const [activeTab, setActiveTab] = createSignal(SHORTCUT_CATEGORIES[0].name)
let listRef: HTMLDivElement | undefined
onMount(() => {
const handler = () => setUsedShortcuts(getUsedShortcuts())
window.addEventListener("shortcut-used", handler)
onCleanup(() => window.removeEventListener("shortcut-used", handler))
// Focus the active tab trigger so arrow keys work immediately
const trigger = listRef?.querySelector<HTMLButtonElement>('[data-slot="tabs-trigger"][data-selected]')
trigger?.focus()
})
return (
<div class="shortcuts-panel" data-component="shortcuts-panel">
<Tabs value={activeTab()} onChange={setActiveTab}>
<div class="shortcuts-tabs-row">
<Tabs.List ref={listRef} class="shortcuts-tabs-list">
<For each={SHORTCUT_CATEGORIES}>
{(category) => <Tabs.Trigger value={category.name}>{category.name}</Tabs.Trigger>}
</For>
</Tabs.List>
<Tooltip
placement="top"
value={
<span>
Close <span class="text-text-weak">{formatKeybind("ctrl+/")}</span>
</span>
}
>
<IconButton icon="close" variant="ghost" onClick={props.onClose} />
</Tooltip>
</div>
<For each={SHORTCUT_CATEGORIES}>
{(category) => (
<Tabs.Content value={category.name} class="shortcuts-content">
<div class="shortcuts-grid">
<For each={category.shortcuts}>{(shortcut) => <ShortcutItem shortcut={shortcut} />}</For>
</div>
</Tabs.Content>
)}
</For>
</Tabs>
</div>
)
}

View File

@@ -1,6 +1,8 @@
import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -120,6 +122,73 @@ export function formatKeybind(config: string): string {
return IS_MAC ? parts.join("") : parts.join("+")
}
function DialogCommand(props: { options: CommandOption[] }) {
const dialog = useDialog()
const state = { cleanup: undefined as (() => void) | void, committed: false }
const handleMove = (option: CommandOption | undefined) => {
state.cleanup?.()
if (!option) return
state.cleanup = option.onHighlight?.()
}
const handleSelect = (option: CommandOption | undefined) => {
if (!option) return
state.committed = true
state.cleanup = undefined
dialog.close()
option.onSelect?.("palette")
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return (
<Dialog title="Commands">
<List
class="px-2.5"
search={{ placeholder: "Search commands", autofocus: true }}
emptyMessage="No commands found"
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
key={(x) => x?.id}
filterKeys={["title", "description", "category"]}
groupBy={(x) => x.category ?? ""}
onMove={handleMove}
onSelect={handleSelect}
>
{(option) => (
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
<Show when={option.description}>
<span class="text-14-regular text-text-weak truncate">{option.description}</span>
</Show>
</div>
<Show when={option.keybind}>
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
</Show>
</div>
)}
</List>
</Dialog>
)
}
const USED_SHORTCUTS_KEY = "opencode:used-shortcuts"
function getUsedShortcuts(): Set<string> {
const stored = localStorage.getItem(USED_SHORTCUTS_KEY)
return stored ? new Set(JSON.parse(stored)) : new Set()
}
function markShortcutUsed(keybind: string) {
const used = getUsedShortcuts()
used.add(keybind)
localStorage.setItem(USED_SHORTCUTS_KEY, JSON.stringify([...used]))
window.dispatchEvent(new CustomEvent("shortcut-used", { detail: keybind }))
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {
@@ -163,7 +232,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
const showPalette = () => {
run("file.open", "palette")
if (dialog.active) return
dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
}
const handleKeyDown = (event: KeyboardEvent) => {
@@ -172,6 +242,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const paletteKeybinds = parseKeybind("mod+shift+p")
if (matchKeybind(paletteKeybinds, event)) {
event.preventDefault()
markShortcutUsed("mod+shift+p")
showPalette()
return
}
@@ -183,6 +254,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const keybinds = parseKeybind(option.keybind)
if (matchKeybind(keybinds, event)) {
event.preventDefault()
markShortcutUsed(option.keybind)
option.onSelect?.("keybind")
return
}

View File

@@ -1,5 +1,5 @@
import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { batch, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
@@ -93,6 +93,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}),
)
const [shortcutsOpened, setShortcutsOpened] = createSignal(false)
const MAX_SESSION_KEYS = 50
const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>()
@@ -353,6 +355,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "diffStyle", diffStyle)
},
},
shortcuts: {
opened: shortcutsOpened,
open() {
setShortcutsOpened(true)
},
close() {
setShortcutsOpened(false)
},
toggle() {
setShortcutsOpened((x) => !x)
},
},
session: {
width: createMemo(() => store.session?.width ?? 600),
resize(width: number) {

View File

@@ -9,3 +9,262 @@
*[data-tauri-drag-region] {
app-region: drag;
}
/* Wider dialog variant for release notes modal */
[data-component="dialog"]:has(.dialog-release-notes) {
padding: 20px;
box-sizing: border-box;
[data-slot="dialog-container"] {
width: min(100%, 720px);
height: min(100%, 400px);
margin-top: -80px;
[data-slot="dialog-content"] {
min-height: auto;
overflow: hidden;
height: 100%;
border: none;
box-shadow: var(--shadow-lg-border-base);
}
[data-slot="dialog-body"] {
overflow: hidden;
height: 100%;
display: flex;
flex-direction: row;
}
}
}
/* Shortcuts panel */
[data-component="shortcuts-panel"] {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-background-base);
border-top: 1px solid var(--color-border-weak-base);
z-index: 100;
display: flex;
flex-direction: column;
height: 280px;
}
.shortcuts-panel [data-component="tabs"] {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.shortcuts-tabs-row {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
position: relative;
padding: 12px 16px;
flex-shrink: 0;
}
.shortcuts-tabs-row > [data-component="icon-button"] {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
}
.shortcuts-panel [data-slot="tabs-list"] {
height: auto;
justify-content: center;
gap: 24px;
padding: 4px;
}
.shortcuts-panel [data-slot="tabs-list"]::after {
display: none;
}
.shortcuts-panel [data-slot="tabs-trigger-wrapper"] {
background: transparent;
border: none;
font-weight: var(--font-weight-regular);
border-radius: 6px;
color: var(--color-text-weak);
}
.shortcuts-panel [data-slot="tabs-trigger-wrapper"]:hover {
background: var(--color-surface-base);
}
.shortcuts-panel [data-slot="tabs-trigger-wrapper"]:has([data-selected]) {
background: var(--color-surface-raised-base-active);
color: var(--color-text-strong);
}
.shortcuts-panel [data-slot="tabs-trigger"] {
padding: 4px 12px;
border-radius: 6px;
}
.shortcuts-panel [data-slot="tabs-trigger"]:focus-visible {
outline: none;
box-shadow: var(--shadow-xs-border-focus);
}
.shortcuts-panel [data-slot="tabs-content"] {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
justify-content: center;
}
.shortcuts-grid {
display: grid;
grid-template-columns: repeat(4, 240px);
gap: 4px 48px;
align-content: start;
}
@media (max-width: 1200px) {
.shortcuts-grid {
grid-template-columns: repeat(3, 240px);
gap: 4px 32px;
}
.shortcuts-panel [data-slot="tabs-list"] {
gap: 16px;
}
}
@media (max-width: 900px) {
.shortcuts-grid {
grid-template-columns: repeat(2, 240px);
gap: 4px 24px;
}
.shortcuts-panel [data-slot="tabs-list"] {
gap: 8px;
}
}
@media (max-width: 600px) {
.shortcuts-grid {
grid-template-columns: repeat(2, minmax(160px, 1fr));
gap: 4px 24px;
}
.shortcuts-panel [data-slot="tabs-list"] {
gap: 4px;
}
.shortcuts-panel [data-slot="tabs-trigger"] {
padding: 4px 8px;
}
}
@media (max-width: 420px) {
.shortcuts-grid {
grid-template-columns: 1fr;
}
}
.shortcut-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 6px 6px 12px;
margin: 0 -12px;
gap: 16px;
height: 36px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.1s ease;
text-align: left;
width: calc(100% + 24px);
box-sizing: border-box;
}
.shortcut-item:hover {
background: var(--color-surface-base);
}
.shortcut-item:hover .shortcut-key {
background: var(--color-background-stronger);
}
.shortcut-item:active {
background: var(--color-surface-raised-base);
}
.shortcut-item.shortcut-used span {
color: var(--color-text-interactive-base);
}
.shortcut-item.shortcut-used .shortcut-key {
color: var(--color-text-interactive-base);
border-color: var(--color-border-interactive-base);
background: var(--color-surface-interactive-base);
box-shadow: var(--shadow-xs-border-interactive);
}
.shortcut-keys {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.shortcut-copied {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-success-base);
margin-right: 4px;
}
.shortcut-key {
display: flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 6px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-subtle);
box-shadow: var(--shadow-xs-border-base);
border-radius: 4px;
white-space: nowrap;
}
.shortcut-key-special {
font-size: 18px;
}
.shortcut-key-shift {
position: relative;
top: -2px;
}
/* Adjust main content and sidebar when shortcuts panel is open */
main.shortcuts-open {
padding-bottom: 280px;
}
.sidebar-shortcuts-open {
padding-bottom: 280px;
}
/* Adjust dialogs when shortcuts panel is open */
body:has([data-component="shortcuts-panel"]) [data-component="dialog"] {
bottom: 280px;
}
body:has([data-component="shortcuts-panel"]) [data-component="dialog-overlay"] {
bottom: 280px;
}

View File

@@ -0,0 +1,53 @@
import { CURRENT_RELEASE } from "@/components/dialog-release-notes"
const STORAGE_KEY = "opencode:last-seen-version"
// ============================================================================
// DEV MODE: Set this to true to always show the release notes modal on startup
// Set to false for production behavior (only shows after updates)
// ============================================================================
const DEV_ALWAYS_SHOW_RELEASE_NOTES = true
/**
* Check if release notes should be shown
* Returns true if:
* - DEV_ALWAYS_SHOW_RELEASE_NOTES is true (for development)
* - OR the current version is newer than the last seen version
*/
export function shouldShowReleaseNotes(): boolean {
if (DEV_ALWAYS_SHOW_RELEASE_NOTES) {
console.log("[ReleaseNotes] DEV mode: always showing release notes")
return true
}
const lastSeen = localStorage.getItem(STORAGE_KEY)
if (!lastSeen) {
// First time user - show release notes
return true
}
// Compare versions - show if current is newer
return CURRENT_RELEASE.version !== lastSeen
}
/**
* Mark the current release notes as seen
* Call this when the user closes the release notes modal
*/
export function markReleaseNotesSeen(): void {
localStorage.setItem(STORAGE_KEY, CURRENT_RELEASE.version)
}
/**
* Get the current version
*/
export function getCurrentVersion(): string {
return CURRENT_RELEASE.version
}
/**
* Reset the seen status (useful for testing)
*/
export function resetReleaseNotesSeen(): void {
localStorage.removeItem(STORAGE_KEY)
}

View File

@@ -59,11 +59,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { ReleaseNotesHandler } from "@/components/release-notes-handler"
import { ShortcutsPanel } from "@/components/shortcuts-panel"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
@@ -85,6 +87,7 @@ export default function Layout(props: ParentProps) {
let scrollContainerRef: HTMLDivElement | undefined
const xlQuery = window.matchMedia("(min-width: 1280px)")
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
const [helpDropdownWidth, setHelpDropdownWidth] = createSignal<number | undefined>(undefined)
const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
xlQuery.addEventListener("change", handleViewportChange)
onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
@@ -749,6 +752,13 @@ export default function Layout(props: ParentProps) {
keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(),
},
{
id: "shortcuts.toggle",
title: "Toggle shortcuts panel",
category: "View",
keybind: "ctrl+/",
onSelect: () => layout.shortcuts.toggle(),
},
{
id: "project.open",
title: "Open project",
@@ -1181,6 +1191,11 @@ export default function Layout(props: ParentProps) {
),
)
createEffect(() => {
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
})
createEffect(() => {
const project = currentProject()
if (!project) return
@@ -1882,7 +1897,7 @@ export default function Layout(props: ParentProps) {
const homedir = createMemo(() => sync.data.path.home)
return (
<div class="flex h-full w-full overflow-hidden">
<div class="flex h-full w-full overflow-hidden" classList={{ "sidebar-shortcuts-open": layout.shortcuts.opened() }}>
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden">
<div class="flex-1 min-h-0 w-full">
<DragDropProvider
@@ -1922,14 +1937,35 @@ export default function Layout(props: ParentProps) {
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings" class="hidden">
<IconButton disabled icon="settings-gear" variant="ghost" size="large" />
</Tooltip>
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
<IconButton
icon="help"
variant="ghost"
size="large"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
/>
</Tooltip>
<DropdownMenu
placement="top-start"
gutter={8}
onOpenChange={(open) => {
if (open && layout.sidebar.opened()) {
setHelpDropdownWidth(layout.sidebar.width() - 16)
return
}
if (!open) setHelpDropdownWidth(undefined)
}}
>
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
<DropdownMenu.Trigger as={IconButton} icon="question-mark" variant="ghost" size="large" />
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{
width: helpDropdownWidth() ? `${helpDropdownWidth()}px` : undefined,
}}
>
<DropdownMenu.Item onSelect={() => platform.openLink("https://opencode.ai/desktop-feedback")}>
Submit feedback
</DropdownMenu.Item>
<DropdownMenu.Item class="flex justify-between gap-6" onSelect={() => layout.shortcuts.toggle()}>
Keyboard shortcuts <span class="text-text-weaker">{formatKeybind("ctrl+/")}</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
@@ -2140,12 +2176,17 @@ export default function Layout(props: ParentProps) {
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
"xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(),
"shortcuts-open": layout.shortcuts.opened(),
}}
>
{props.children}
</main>
</div>
<Show when={layout.shortcuts.opened()}>
<ShortcutsPanel onClose={() => layout.shortcuts.close()} />
</Show>
<Toast.Region />
<ReleaseNotesHandler />
</div>
)
}

View File

@@ -17,6 +17,36 @@ type PersistTarget = {
const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
function quota(error: unknown) {
if (error instanceof DOMException) {
if (error.name === "QuotaExceededError") return true
if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true
if (error.code === 22 || error.code === 1014) return true
return false
}
if (!error || typeof error !== "object") return false
const name = (error as { name?: string }).name
if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true
return false
}
function write(storage: Storage, key: string, value: string) {
try {
storage.setItem(key, value)
return
} catch (error) {
if (!quota(error)) throw error
}
try {
storage.removeItem(key)
storage.setItem(key, value)
} catch (error) {
if (!quota(error)) throw error
}
}
function snapshot(value: unknown) {
return JSON.parse(JSON.stringify(value)) as unknown
}
@@ -67,10 +97,19 @@ function workspaceStorage(dir: string) {
function localStorageWithPrefix(prefix: string): SyncStorage {
const base = `${prefix}:`
const item = (key: string) => base + key
return {
getItem: (key) => localStorage.getItem(base + key),
setItem: (key, value) => localStorage.setItem(base + key, value),
removeItem: (key) => localStorage.removeItem(base + key),
getItem: (key) => localStorage.getItem(item(key)),
setItem: (key, value) => write(localStorage, item(key), value),
removeItem: (key) => localStorage.removeItem(item(key)),
}
}
function localStorageDirect(): SyncStorage {
return {
getItem: (key) => localStorage.getItem(key),
setItem: (key, value) => write(localStorage, key, value),
removeItem: (key) => localStorage.removeItem(key),
}
}
@@ -99,7 +138,7 @@ export function removePersisted(target: { storage?: string; key: string }) {
}
if (!target.storage) {
localStorage.removeItem(target.key)
localStorageDirect().removeItem(target.key)
return
}
@@ -120,12 +159,12 @@ export function persisted<T>(
const currentStorage = (() => {
if (isDesktop) return platform.storage?.(config.storage)
if (!config.storage) return localStorage
if (!config.storage) return localStorageDirect()
return localStorageWithPrefix(config.storage)
})()
const legacyStorage = (() => {
if (!isDesktop) return localStorage
if (!isDesktop) return localStorageDirect()
if (!config.storage) return platform.storage?.()
return platform.storage?.(LEGACY_STORAGE)
})()

View File

@@ -0,0 +1,94 @@
import { createMemo, For } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { TIPS } from "./tips"
import { EmptyBorder } from "./border"
const tip = TIPS[Math.floor(Math.random() * TIPS.length)]
type TipPart = { text: string; highlight: boolean }
function parseTip(tip: string): TipPart[] {
const parts: TipPart[] = []
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
let lastIndex = 0
let match
while ((match = regex.exec(tip)) !== null) {
if (match.index > lastIndex) {
parts.push({ text: tip.slice(lastIndex, match.index), highlight: false })
}
parts.push({ text: match[1], highlight: true })
lastIndex = regex.lastIndex
}
if (lastIndex < tip.length) {
parts.push({ text: tip.slice(lastIndex), highlight: false })
}
return parts
}
const tipParts = parseTip(tip)
const BOX_WIDTH = 42
const TITLE = " 🅘 Did you know? "
export function DidYouKnow() {
const { theme } = useTheme()
const dashes = createMemo(() => {
// ╭─ + title + ─...─ + ╮ = BOX_WIDTH
// 1 + 1 + title.length + dashes + 1 = BOX_WIDTH
return Math.max(0, BOX_WIDTH - 2 - TITLE.length - 1)
})
return (
<box position="absolute" bottom={3} right={2} width={BOX_WIDTH}>
<text>
<span style={{ fg: theme.border }}></span>
<span style={{ fg: theme.text }}>{TITLE}</span>
<span style={{ fg: theme.border }}>{"─".repeat(dashes())}</span>
</text>
<box
border={["left", "right", "bottom"]}
borderColor={theme.border}
customBorderChars={{
...EmptyBorder,
bottomLeft: "╰",
bottomRight: "╯",
horizontal: "─",
vertical: "│",
}}
>
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<text>
<For each={tipParts}>
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
</For>
</text>
</box>
</box>
<box flexDirection="row" justifyContent="flex-end">
<text>
<span style={{ fg: theme.text }}>ctrl+h</span>
<span style={{ fg: theme.textMuted }}> hide tips</span>
</text>
</box>
</box>
)
}
export function ShowTipsHint() {
const { theme } = useTheme()
return (
<box position="absolute" bottom={3} right={2}>
<box flexDirection="row" justifyContent="flex-end">
<text>
<span style={{ fg: theme.text }}>ctrl+h</span>
<span style={{ fg: theme.textMuted }}> show tips</span>
</text>
</box>
</box>
)
}

View File

@@ -0,0 +1,103 @@
export const TIPS = [
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files to your prompt.",
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight}).",
"Press {highlight}Tab{/highlight} to cycle between Build (full access) and Plan (read-only) agents.",
"Use {highlight}/undo{/highlight} to revert the last message and any file changes made by OpenCode.",
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes.",
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai.",
"Drag and drop images into the terminal to add them as context for your prompts.",
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard directly into the prompt.",
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor.",
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase structure.",
"Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models.",
"Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to preview and switch between 50+ built-in themes.",
"Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session.",
"Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations.",
"Run {highlight}/compact{/highlight} to summarize long sessions when approaching context limits.",
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown.",
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard.",
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands.",
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers.",
"The default leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions.",
"Press {highlight}F2{/highlight} to quickly switch between recently used models.",
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel.",
"Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history.",
"Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation.",
"Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message.",
"Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt.",
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents.",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor.",
"Configure {highlight}model{/highlight} in config to set your default model.",
"Override any keybind in config via the {highlight}keybinds{/highlight} section.",
"Set any keybind to {highlight}none{/highlight} to disable it completely.",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section.",
"OpenCode auto-handles OAuth for remote MCP servers requiring auth.",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts.",
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input.",
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight}).",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas.",
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools.",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions.',
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands.',
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing.',
"OpenCode auto-formats files using prettier, gofmt, ruff, and more.",
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting.',
"Define custom formatter commands with file extensions in config.",
"OpenCode uses LSP servers for intelligent code analysis.",
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools.",
"Tool definitions can invoke scripts written in Python, Go, etc.",
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks.",
"Use plugins to send OS notifications when sessions complete.",
"Create a plugin to prevent OpenCode from reading sensitive files.",
"Use {highlight}opencode run{/highlight} for non-interactive scripting.",
"Use {highlight}opencode run --continue{/highlight} to resume the last session.",
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI.",
"Use {highlight}--format json{/highlight} for machine-readable output in scripts.",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode.",
"Use {highlight}opencode run --attach{/highlight} to connect to a running server for faster runs.",
"Run {highlight}opencode upgrade{/highlight} to update to the latest version.",
"Run {highlight}opencode auth list{/highlight} to see all configured providers.",
"Run {highlight}opencode agent create{/highlight} for guided agent creation.",
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions.",
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow.",
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs.",
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews.",
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors.',
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory.",
"Themes support dark/light variants for both modes.",
"Reference ANSI colors 0-255 in custom themes.",
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config.",
"Use {highlight}{file:path}{/highlight} to include file contents in config values.",
"Use {highlight}instructions{/highlight} in config to load additional rules files.",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative).",
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request.",
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools.',
'Use {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server.',
"Override global tool settings per agent configuration.",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions.',
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing.',
"Run {highlight}/unshare{/highlight} to remove a session from public access.",
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops.",
"Permission {highlight}external_directory{/highlight} protects files outside project.",
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration.",
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr.",
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages.",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages.",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info.",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling.",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight}).",
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use.",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models.",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing.",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs.",
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog.",
"Use {highlight}/details{/highlight} to toggle tool execution details visibility.",
"Use {highlight}/rename{/highlight} to rename the current session.",
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell.",
]

View File

@@ -1,9 +1,10 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { useKeybind } from "@tui/context/keybind"
import { useKeyboard } from "@opentui/solid"
import { Logo } from "../component/logo"
import { Tips } from "../component/tips"
import { DidYouKnow, ShowTipsHint } from "../component/did-you-know"
import { Locale } from "@/util/locale"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
@@ -42,6 +43,22 @@ export function Home() {
return !tipsHidden()
})
function hideTips() {
kv.set("tips_hidden", true)
}
function enableTips() {
kv.set("tips_hidden", false)
}
function toggleTips() {
if (showTips()) {
hideTips()
return
}
enableTips()
}
command.register(() => [
{
title: tipsHidden() ? "Show tips" : "Hide tips",
@@ -49,12 +66,20 @@ export function Home() {
keybind: "tips_toggle",
category: "System",
onSelect: (dialog) => {
kv.set("tips_hidden", !tipsHidden())
toggleTips()
dialog.clear()
},
},
])
useKeyboard((evt) => {
// Don't handle tips keybind for first-time users
if (isFirstTimeUser()) return
if (evt.name !== "h" || !evt.ctrl || evt.meta || evt.shift) return
toggleTips()
evt.preventDefault()
})
const Hint = (
<Show when={connectedMcpCount() > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
@@ -89,8 +114,6 @@ export function Home() {
})
const directory = useDirectory()
const keybind = useKeybind()
return (
<>
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
@@ -112,6 +135,11 @@ export function Home() {
</box>
<Toast />
</box>
<Show when={!isFirstTimeUser()}>
<Show when={showTips()} fallback={<ShowTipsHint />}>
<DidYouKnow />
</Show>
</Show>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
<text fg={theme.textMuted}>{directory()}</text>
<box gap={1} flexDirection="row" flexShrink={0}>

View File

@@ -66,6 +66,7 @@ const icons = {
dash: `<rect x="5" y="9.5" width="10" height="1" fill="currentColor"/>`,
"cloud-upload": `<path d="M12.0833 16.25H15C17.0711 16.25 18.75 14.5711 18.75 12.5C18.75 10.5649 17.2843 8.97217 15.4025 8.77133C15.2 6.13103 12.8586 4.08333 10 4.08333C7.71532 4.08333 5.76101 5.49781 4.96501 7.49881C2.84892 7.90461 1.25 9.76559 1.25 11.6667C1.25 13.9813 3.30203 16.25 5.83333 16.25H7.91667M10 16.25V10.4167M12.0833 11.875L10 9.79167L7.91667 11.875" stroke="currentColor" stroke-linecap="square"/>`,
trash: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
"question-mark": `<path d="M6 3.9975V2H14V6.99376L10 9.99001V11.9875M10 17.98V18" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" />`,
}
export interface IconProps extends ComponentProps<"svg"> {

View File

@@ -74,6 +74,24 @@
color: var(--icon-active);
}
}
> [data-component="icon-button"] {
background-color: transparent;
&:hover:not(:disabled),
&:focus:not(:disabled),
&:active:not(:disabled) {
background-color: transparent;
}
&:hover:not(:disabled) [data-slot="icon-svg"] {
color: var(--icon-hover);
}
&:active:not(:disabled) [data-slot="icon-svg"] {
color: var(--icon-active);
}
}
}
[data-slot="list-scroll"] {

View File

@@ -15,6 +15,7 @@ export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
children?: (item: T | undefined) => JSX.Element
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end"
}
export function Select<T>(props: SelectProps<T> & ButtonProps) {
@@ -29,6 +30,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
"groupBy",
"onSelect",
"children",
"placement",
])
const grouped = createMemo(() => {
const result = pipe(
@@ -46,7 +48,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
<Kobalte<T, { category: string; options: T[] }>
{...others}
data-component="select"
placement="bottom-start"
placement={local.placement ?? "bottom-start"}
value={local.current}
options={grouped()}
optionValue={(x) => (local.value ? local.value(x) : (x as string))}

View File

@@ -73,6 +73,13 @@
0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.25),
0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12), 0 0 0 2px var(--background-weak, #f1f0f0),
0 0 0 3px var(--border-selected, rgba(0, 74, 255, 0.99));
--shadow-xs-border-interactive:
0 0 0 1px var(--border-selected, rgba(0, 74, 255, 0.99)), 0 1px 2px -1px rgba(19, 16, 16, 0.25),
0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12);
--shadow-lg-border-base:
0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)), 0 36px 80px 0 rgba(0, 0, 0, 0.03),
0 13.141px 29.201px 0 rgba(0, 0, 0, 0.04), 0 6.38px 14.177px 0 rgba(0, 0, 0, 0.05),
0 3.127px 6.95px 0 rgba(0, 0, 0, 0.06), 0 1.237px 2.748px 0 rgba(0, 0, 0, 0.09);
color-scheme: light;
--text-mix-blend-mode: multiply;
@@ -364,10 +371,10 @@
--surface-raised-stronger-non-alpha: var(--smoke-dark-3);
--surface-brand-base: var(--yuzu-light-9);
--surface-brand-hover: var(--yuzu-light-10);
--surface-interactive-base: var(--cobalt-light-3);
--surface-interactive-hover: var(--cobalt-light-4);
--surface-interactive-weak: var(--cobalt-light-2);
--surface-interactive-weak-hover: var(--cobalt-light-3);
--surface-interactive-base: var(--cobalt-dark-3);
--surface-interactive-hover: var(--cobalt-dark-4);
--surface-interactive-weak: var(--cobalt-dark-2);
--surface-interactive-weak-hover: var(--cobalt-dark-3);
--surface-success-base: var(--apple-light-3);
--surface-success-weak: var(--apple-light-2);
--surface-success-strong: var(--apple-light-9);
@@ -454,12 +461,12 @@
--border-weak-selected: var(--cobalt-dark-alpha-6);
--border-weak-disabled: var(--smoke-dark-alpha-6);
--border-weak-focus: var(--smoke-dark-alpha-8);
--border-interactive-base: var(--cobalt-light-7);
--border-interactive-hover: var(--cobalt-light-8);
--border-interactive-active: var(--cobalt-light-9);
--border-interactive-selected: var(--cobalt-light-9);
--border-interactive-disabled: var(--smoke-light-8);
--border-interactive-focus: var(--cobalt-light-9);
--border-interactive-base: var(--cobalt-dark-7);
--border-interactive-hover: var(--cobalt-dark-8);
--border-interactive-active: var(--cobalt-dark-9);
--border-interactive-selected: var(--cobalt-dark-9);
--border-interactive-disabled: var(--smoke-dark-8);
--border-interactive-focus: var(--cobalt-dark-9);
--border-success-base: var(--apple-light-6);
--border-success-hover: var(--apple-light-7);
--border-success-selected: var(--apple-light-9);