feat(app): add custom tab colors

This commit is contained in:
Test
2026-06-25 07:02:41 +00:00
parent b44af11e03
commit 59320d2865
9 changed files with 282 additions and 36 deletions
@@ -0,0 +1,145 @@
import { createEffect, For, onCleanup, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLanguage } from "@/context/language"
import { normalizeTabColor } from "@/context/tab-color"
const colors = [
{ name: "tab.color.blue", value: "#4c8dff" },
{ name: "tab.color.cyan", value: "#35b5c5" },
{ name: "tab.color.green", value: "#4fbf80" },
{ name: "tab.color.yellow", value: "#e3c243" },
{ name: "tab.color.orange", value: "#f29d49" },
{ name: "tab.color.red", value: "#ef5b5b" },
{ name: "tab.color.pink", value: "#e65fa8" },
{ name: "tab.color.purple", value: "#9b6cff" },
] as const
export function TabColorMenu(props: {
color?: string
onColorChange: (color: string | undefined) => void
children: JSX.Element
}) {
const language = useLanguage()
const [menu, setMenu] = createStore({ open: false, x: 0, y: 0 })
let menuRef: HTMLDivElement | undefined
const choose = (color: string | undefined) => {
props.onColorChange(color)
setMenu("open", false)
}
createEffect(() => {
if (!menu.open) return
requestAnimationFrame(() => menuRef?.querySelector<HTMLElement>("button")?.focus())
const cleanups = [
makeEventListener(document, "pointerdown", (event) => {
if (event.target instanceof Node && menuRef?.contains(event.target)) return
setMenu("open", false)
}),
makeEventListener(document, "keydown", (event) => {
if (event.key === "Escape") setMenu("open", false)
}),
]
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
})
return (
<>
<div
class="flex w-full min-w-0"
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
setMenu({
open: true,
x: Math.max(4, Math.min(event.clientX || rect.left, window.innerWidth - 168)),
y: Math.max(4, Math.min(event.clientY || rect.bottom, window.innerHeight - 136)),
})
}}
>
{props.children}
</div>
<Show when={menu.open}>
<Portal>
<div
ref={menuRef}
role="menu"
data-component="context-menu-content"
class="titlebar-tab-color-menu fixed z-[100]"
style={{ left: `${menu.x}px`, top: `${menu.y}px` }}
>
<div data-slot="context-menu-group-label">{language.t("tab.color.label")}</div>
<div role="group" aria-label={language.t("tab.color.label")} class="grid grid-cols-5 gap-1 px-2 pb-2">
<For each={colors}>
{(color) => (
<button
type="button"
role="menuitemradio"
data-tab-color={color.value}
data-checked={normalizeTabColor(props.color) === color.value ? "" : undefined}
aria-label={language.t(color.name)}
aria-checked={normalizeTabColor(props.color) === color.value}
class="relative size-5 rounded-full border border-transparent p-[3px] outline-none hover:border-v2-border-border-strong focus-visible:border-v2-border-border-strong data-[checked]:border-v2-border-border-strong"
onClick={() => choose(color.value)}
>
<span class="block size-full rounded-full" style={{ "background-color": color.value }} />
</button>
)}
</For>
<label
class="relative flex size-5 cursor-pointer items-center justify-center rounded-full border border-transparent p-[3px] hover:border-v2-border-border-strong has-[:focus-visible]:border-v2-border-border-strong"
title={language.t("tab.color.custom")}
>
<span
class="block size-full rounded-full"
style={{
background:
"conic-gradient(#ef5b5b, #e3c243, #4fbf80, #35b5c5, #4c8dff, #9b6cff, #e65fa8, #ef5b5b)",
}}
/>
<input
data-tab-color-custom
type="color"
aria-label={language.t("tab.color.custom")}
value={normalizeTabColor(props.color) ?? colors[0].value}
class="absolute inset-0 cursor-pointer opacity-0"
onInput={(event) => choose(event.currentTarget.value)}
/>
</label>
</div>
<Show when={normalizeTabColor(props.color)}>
<div data-slot="context-menu-separator" />
<button
type="button"
role="menuitem"
data-slot="context-menu-item"
class="w-full"
onClick={() => choose(undefined)}
>
<span data-slot="context-menu-item-label">{language.t("tab.color.none")}</span>
</button>
</Show>
</div>
</Portal>
</Show>
</>
)
}
export function TabColorPill(props: { color?: string }) {
return (
<Show when={normalizeTabColor(props.color)}>
{(color) => (
<span
data-slot="tab-color"
aria-hidden="true"
class="pointer-events-none absolute bottom-px left-2 right-2 h-0.5 rounded-full"
style={{ "background-color": color() }}
/>
)}
</Show>
)
}
@@ -11,6 +11,7 @@ import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { showToast } from "@/utils/toast"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabColorPill } from "./titlebar-tab-color"
import "./titlebar-tab-nav.css"
export function TabNavItem(props: {
@@ -29,6 +30,7 @@ export function TabNavItem(props: {
dragging?: boolean
pressed?: boolean
hidden?: boolean
color?: string
}) {
const language = useLanguage()
const [editing, setEditing] = createSignal(false)
@@ -270,6 +272,7 @@ export function TabNavItem(props: {
icon={<IconV2 name="xmark-small" />}
/>
</div>
<TabColorPill color={props.color} />
</div>
)
}
@@ -285,6 +288,7 @@ export function DraftTabItem(props: {
dragging?: boolean
pressed?: boolean
hidden?: boolean
color?: string
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
@@ -346,6 +350,7 @@ export function DraftTabItem(props: {
aria-label="Close tab"
/>
</div>
<TabColorPill color={props.color} />
</div>
)
}
@@ -16,6 +16,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
import { ServerConnection } from "@/context/server"
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
import { TabColorMenu } from "@/components/titlebar-tab-color"
import { useGlobal, type ServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
@@ -56,6 +57,7 @@ function SessionTabSlot(props: {
onPointerDown: (event: PointerEvent) => void
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
onColorChange: (color: string | undefined) => void
}) {
const tabs = useTabs()
let ref!: HTMLDivElement
@@ -110,30 +112,33 @@ function SessionTabSlot(props: {
}}
onPointerDown={props.onPointerDown}
>
<TabNavItem
ref={ref}
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onTitleChangeFailed={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
activeServer={props.tab.server === props.activeServerKey}
forceTruncate={props.forceTruncate}
suppressNavigation={props.suppressNavigation}
pressed={props.pressed()}
hidden={props.dragged()}
/>
<TabColorMenu color={props.tab.color} onColorChange={props.onColorChange}>
<TabNavItem
ref={ref}
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onTitleChangeFailed={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
activeServer={props.tab.server === props.activeServerKey}
forceTruncate={props.forceTruncate}
color={props.tab.color}
suppressNavigation={props.suppressNavigation}
pressed={props.pressed()}
hidden={props.dragged()}
/>
</TabColorMenu>
</div>
)
}
@@ -145,6 +150,7 @@ export function TitlebarTabStrip(props: {
forceTruncate: boolean
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
onClose: (tab: Tab) => void
onColorChange: (tab: Tab, color: string | undefined) => void
onReorder: (keys: string[]) => void
onOverflowChange: (overflowing: boolean) => void
}) {
@@ -498,6 +504,7 @@ export function TitlebarTabStrip(props: {
}}
onNavigate={(element) => props.onNavigate(tab, element)}
onClose={() => props.onClose(tab)}
onColorChange={(color) => props.onColorChange(tab, color)}
/>
)
}
@@ -515,17 +522,20 @@ export function TitlebarTabStrip(props: {
onPointerDown(id, event)
}}
>
<DraftTabItem
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")}
onNavigate={() => props.onNavigate(tab, ref)}
onClose={() => props.onClose(tab)}
suppressNavigation={() => suppressNavigation()}
active={props.currentTab() === tab}
pressed={pressedId() === id}
hidden={dragged()}
/>
<TabColorMenu color={tab.color} onColorChange={(color) => props.onColorChange(tab, color)}>
<DraftTabItem
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")}
onNavigate={() => props.onNavigate(tab, ref)}
onClose={() => props.onClose(tab)}
color={tab.color}
suppressNavigation={() => suppressNavigation()}
active={props.currentTab() === tab}
pressed={pressedId() === id}
hidden={dragged()}
/>
</TabColorMenu>
</div>
)
}}
+36
View File
@@ -6,6 +6,42 @@
outline: none;
}
.titlebar-tab-color-menu {
min-width: 152px;
border-radius: 6px;
background: var(--v2-background-bg-layer-01);
box-shadow: var(--v2-elevation-floating);
[data-slot="context-menu-group-label"] {
color: var(--v2-text-text-faint);
}
[data-slot="context-menu-item"] {
color: var(--v2-text-text-base);
&:hover,
&[data-highlighted] {
background: var(--v2-overlay-simple-overlay-hover);
}
}
[data-tab-color] {
width: 20px;
height: 20px;
padding: 3px;
border: 1px solid transparent;
&:is(:hover, :focus-visible, [data-checked]) {
border-color: var(--v2-border-border-strong);
background: transparent;
}
}
[data-slot="context-menu-separator"] {
border-color: var(--v2-border-border-muted);
}
}
[data-slot="titlebar-v2"]
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:focus-visible, [data-state="focus"]):not(
:disabled
+1
View File
@@ -458,6 +458,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.removeTab(index)
}}
onColorChange={(tab, color) => tabsStoreActions.setColor(tab, color)}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Show when={!(creating() && params.dir)}>
+4
View File
@@ -0,0 +1,4 @@
export function normalizeTabColor(color: string | undefined) {
if (!color || !/^#[0-9a-f]{6}$/i.test(color)) return
return color.toLowerCase()
}
+15
View File
@@ -1,6 +1,21 @@
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./tab-memory"
import { normalizeTabColor } from "./tab-color"
describe("tab color", () => {
test("normalizes valid hex colors", () => {
expect(normalizeTabColor("#4C8DFF")).toBe("#4c8dff")
expect(normalizeTabColor("#123abc")).toBe("#123abc")
})
test("rejects invalid color values", () => {
expect(normalizeTabColor(undefined)).toBeUndefined()
expect(normalizeTabColor("red")).toBeUndefined()
expect(normalizeTabColor("#fff")).toBeUndefined()
expect(normalizeTabColor("#12345678")).toBeUndefined()
})
})
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
+20 -1
View File
@@ -10,11 +10,13 @@ import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
import { normalizeTabColor } from "./tab-color"
export type SessionTab = {
type: "session"
server: ServerConnection.Key
sessionId: string
color?: string
}
export type DraftTab = {
@@ -23,6 +25,7 @@ export type DraftTab = {
server: ServerConnection.Key
directory: string
worktree?: string
color?: string
}
export type Tab = SessionTab | DraftTab
@@ -156,6 +159,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}),
)
},
setColor(tab: Tab, color: string | undefined) {
const key = tabKey(tab)
const next = normalizeTabColor(color)
setStore(
produce((tabs) => {
const current = tabs.find((item) => tabKey(item) === key)
if (!current) return
if (next) {
current.color = next
return
}
delete current.color
}),
)
},
draft(draftID: string) {
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
@@ -182,7 +200,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
// and fall back home. Navigate to the new session first so we leave /new-session
// before the draft is removed from the store.
const active = location.pathname === "/new-session" && location.query.draftId === draftID
const next = { type: "session" as const, ...session }
const color = store.find((tab) => tab.type === "draft" && tab.draftID === draftID)?.color
const next = { type: "session" as const, ...session, color }
startTransition(() => {
setStore(
produce((tabs) => {
+11
View File
@@ -48,6 +48,17 @@ export const dict = {
"command.session.new": "New session",
"command.file.open": "Open file",
"command.tab.close": "Close tab",
"tab.color.label": "Tab color",
"tab.color.custom": "Custom color",
"tab.color.none": "Remove color",
"tab.color.blue": "Blue",
"tab.color.cyan": "Cyan",
"tab.color.green": "Green",
"tab.color.yellow": "Yellow",
"tab.color.orange": "Orange",
"tab.color.red": "Red",
"tab.color.pink": "Pink",
"tab.color.purple": "Purple",
"command.context.addSelection": "Add selection to context",
"command.context.addSelection.description": "Add selected lines from the current file",
"command.input.focus": "Focus input",