mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 09:16:20 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18e345ea34 | |||
| b44af11e03 | |||
| 717b740d24 | |||
| b2c688f2a8 | |||
| 1b107301fb | |||
| aee262f019 | |||
| 6ec6db4ad6 | |||
| 40ddede2b1 | |||
| 71402bcbc3 | |||
| d13779b1d5 | |||
| bdee94ca40 | |||
| 9c0d813b8e | |||
| 1bcb9d7cb9 | |||
| 7e71e37a9b |
@@ -58,6 +58,7 @@ import {
|
||||
selectSessionLineage,
|
||||
sessionHref,
|
||||
} from "./utils/session-route"
|
||||
import { isSessionNotFoundError } from "./utils/server-errors"
|
||||
|
||||
import Session from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
@@ -129,9 +130,13 @@ function ResolvedTargetSessionRoute() {
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (cached()) return
|
||||
return { id: params.id, sync: sync() }
|
||||
return { id: params.id, server: serverKey(), sync: sync() }
|
||||
},
|
||||
({ id, sync }) => sync.session.lineage.resolve(id),
|
||||
({ id, server, sync }) =>
|
||||
sync.session.lineage.resolve(id).catch((error) => {
|
||||
if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id })
|
||||
throw error
|
||||
}),
|
||||
)
|
||||
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
|
||||
const directory = createMemo(() => current()?.session.directory)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { newTabTooltipKeybind, reviewTooltipKeybind } from "./command-tooltip-keybind"
|
||||
|
||||
describe("command tooltip keybinds", () => {
|
||||
test("keeps localized review shortcut modifiers", () => {
|
||||
const command = {
|
||||
keybind: () => "Ctrl+Maj+R",
|
||||
keybindParts: () => ["Ctrl", "Maj", "R"],
|
||||
}
|
||||
|
||||
expect(reviewTooltipKeybind(command, (key) => key)).toEqual(["Ctrl", "Maj", "R"])
|
||||
})
|
||||
|
||||
test("uses the configured new-tab shortcut", () => {
|
||||
const command = {
|
||||
keybind: () => "Alt+N",
|
||||
keybindParts: () => ["Alt", "N"],
|
||||
}
|
||||
|
||||
expect(newTabTooltipKeybind(command, (key) => key)).toEqual(["Alt", "N"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
type CommandKeybind = {
|
||||
keybindParts: (id: string) => string[]
|
||||
}
|
||||
|
||||
export function reviewTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
|
||||
return command.keybindParts("review.toggle")
|
||||
}
|
||||
|
||||
export function newTabTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
|
||||
return command.keybindParts("tab.new")
|
||||
}
|
||||
@@ -2196,7 +2196,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
@@ -2225,7 +2227,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const ServerRowMenu: Component<{
|
||||
const isDefault = () => props.controller.defaultKey() === key
|
||||
|
||||
return (
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
variant="ghost-muted"
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ServerHealthIndicator(props: { health?: ServerHealth }) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
|
||||
"bg-icon-success-base": props.health?.healthy === true,
|
||||
"bg-icon-critical-base": props.health?.healthy === false,
|
||||
"bg-border-weak-base": props.health === undefined,
|
||||
|
||||
@@ -28,6 +28,9 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
||||
|
||||
const OPEN_APPS = [
|
||||
"vscode",
|
||||
@@ -237,7 +240,7 @@ export function SessionHeader() {
|
||||
statusVisible: status(),
|
||||
statusLabel: language.t("status.popover.trigger"),
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: command.keybind("review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
@@ -520,13 +523,15 @@ type SessionHeaderV2ActionsState = {
|
||||
statusVisible: boolean
|
||||
statusLabel: string
|
||||
reviewLabel: string
|
||||
reviewKeybind: string
|
||||
reviewKeybind: string[]
|
||||
reviewVisible: boolean
|
||||
reviewOpened: boolean
|
||||
onReviewToggle: () => void
|
||||
}
|
||||
|
||||
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={props.state.statusVisible}>
|
||||
@@ -535,7 +540,17 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.state.reviewVisible}>
|
||||
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{props.state.reviewLabel}
|
||||
<Show when={props.state.reviewKeybind.length > 0}>
|
||||
<KeybindV2 keys={props.state.reviewKeybind} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
@@ -548,7 +563,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
aria-controls="review-panel"
|
||||
icon={<IconV2 name="sidebar-right" />}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
|
||||
|
||||
const remote = "remote" as ServerConnection.Key
|
||||
|
||||
describe("titlebar session events", () => {
|
||||
test("reads valid removed session tab details", () => {
|
||||
expect(
|
||||
readSessionTabsRemovedDetail(
|
||||
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
|
||||
detail: { directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
|
||||
detail: { server: "remote", directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
server: remote,
|
||||
directory: "/tmp/project",
|
||||
sessionIDs: ["ses_1", "ses_2"],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
|
||||
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
|
||||
|
||||
export type SessionTabsRemovedDetail = {
|
||||
server?: ServerConnection.Key
|
||||
directory: string
|
||||
sessionIDs: string[]
|
||||
}
|
||||
@@ -18,11 +21,14 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe
|
||||
if (!("sessionIDs" in detail)) return undefined
|
||||
if (typeof detail.directory !== "string") return undefined
|
||||
if (!Array.isArray(detail.sessionIDs)) return undefined
|
||||
if ("server" in detail && detail.server !== undefined && typeof detail.server !== "string") return undefined
|
||||
|
||||
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
|
||||
if (sessionIDs.length === 0) return undefined
|
||||
|
||||
return {
|
||||
server:
|
||||
"server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined,
|
||||
directory: detail.directory,
|
||||
sessionIDs,
|
||||
}
|
||||
|
||||
@@ -56,6 +56,28 @@ describe("titlebar tab drag", () => {
|
||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
|
||||
list.remove()
|
||||
})
|
||||
|
||||
test("uses the list gap as the divider width", () => {
|
||||
const list = document.createElement("div")
|
||||
const first = document.createElement("div")
|
||||
const second = document.createElement("div")
|
||||
const firstTab = document.createElement("div")
|
||||
const secondTab = document.createElement("div")
|
||||
first.dataset.titlebarTabSlot = ""
|
||||
first.dataset.tabKey = "a"
|
||||
second.dataset.titlebarTabSlot = ""
|
||||
second.dataset.tabKey = "b"
|
||||
firstTab.dataset.titlebarTab = ""
|
||||
secondTab.dataset.titlebarTab = ""
|
||||
first.append(firstTab)
|
||||
second.append(secondTab)
|
||||
list.append(first, second)
|
||||
list.style.columnGap = "13.5px"
|
||||
document.body.append(list)
|
||||
|
||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13.5)
|
||||
list.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe("titlebar tab gestures", () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ export function captureTabDragLayout(list: HTMLElement, order: string[]) {
|
||||
|
||||
let dividerWidth = 0
|
||||
if (order.length >= 2) {
|
||||
const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0
|
||||
const secondId = order[1]
|
||||
for (const slot of slots) {
|
||||
if (slot.dataset.tabKey !== secondId) continue
|
||||
@@ -36,10 +37,11 @@ export function captureTabDragLayout(list: HTMLElement, order: string[]) {
|
||||
if (!tab) break
|
||||
const style = getComputedStyle(slot)
|
||||
dividerWidth =
|
||||
gap ||
|
||||
slot.getBoundingClientRect().width -
|
||||
tab.getBoundingClientRect().width +
|
||||
(Number.parseFloat(style.marginLeft) || 0) +
|
||||
(Number.parseFloat(style.marginRight) || 0)
|
||||
tab.getBoundingClientRect().width +
|
||||
(Number.parseFloat(style.marginLeft) || 0) +
|
||||
(Number.parseFloat(style.marginRight) || 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,29 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tabs"] {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-list] {
|
||||
gap: calc(min(12px, 2cqw) + 1.5px);
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot]:not(:first-child)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: calc(0px - min(6px, 1cqw) - 0.75px);
|
||||
width: 1.5px;
|
||||
height: 12px;
|
||||
border-radius: 9999px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-close"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -62,8 +85,14 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:hover [data-slot="tab-close"],
|
||||
[data-titlebar-tab][data-active="true"] [data-slot="tab-close"],
|
||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
||||
background: var(--tab-bg);
|
||||
@container (max-width: 64px) {
|
||||
[data-titlebar-tab-link] {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-title] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +175,10 @@ export function TabNavItem(props: {
|
||||
forwardTabRef(props.ref, el)
|
||||
}}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-title-overflow={titleOverflowing()}
|
||||
data-editing={editing()}
|
||||
class="group relative flex h-7 min-w-24 max-w-60 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
class="group relative flex h-7 w-full min-w-0 max-w-56 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
@@ -192,6 +193,7 @@ export function TabNavItem(props: {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
@@ -220,6 +222,7 @@ export function TabNavItem(props: {
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
@@ -258,7 +261,7 @@ export function TabNavItem(props: {
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="relative z-10 opacity-0 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -292,10 +295,11 @@ export function DraftTabItem(props: {
|
||||
<div
|
||||
ref={(el) => forwardTabRef(props.ref, el)}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
class="group relative flex h-7 w-full min-w-0 max-w-56 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true'][data-pressed='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
@@ -304,6 +308,7 @@ export function DraftTabItem(props: {
|
||||
>
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
@@ -317,10 +322,12 @@ export function DraftTabItem(props: {
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span class="truncate leading-5">{props.title}</span>
|
||||
<span data-titlebar-tab-title class="truncate leading-5">
|
||||
{props.title}
|
||||
</span>
|
||||
</a>
|
||||
<div data-slot="tab-close" class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||
<IconButtonV2
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
first: () => boolean
|
||||
active: () => boolean
|
||||
activeServerKey: ServerConnection.Key
|
||||
forceTruncate: boolean
|
||||
@@ -104,10 +103,9 @@ function SessionTabSlot(props: {
|
||||
<div
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
class="flex shrink-0"
|
||||
class="flex min-w-0 max-w-56 flex-1 basis-0"
|
||||
classList={{
|
||||
hidden: !session(),
|
||||
"ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !props.first(),
|
||||
"pointer-events-none": props.dragActive,
|
||||
}}
|
||||
onPointerDown={props.onPointerDown}
|
||||
@@ -461,17 +459,16 @@ export function TitlebarTabStrip(props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||
<div data-slot="titlebar-tabs" class="relative min-w-0 flex-1">
|
||||
<div
|
||||
data-slot="titlebar-tabs-scroll"
|
||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div class="flex min-w-0 flex-row items-center" ref={listRef}>
|
||||
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
||||
<For each={displayTabs()}>
|
||||
{(tab, index) => {
|
||||
const id = tabKey(tab)
|
||||
const first = () => index() === 0
|
||||
let ref!: HTMLDivElement
|
||||
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
||||
|
||||
@@ -487,7 +484,6 @@ export function TitlebarTabStrip(props: {
|
||||
<SessionTabSlot
|
||||
tab={tab}
|
||||
id={id}
|
||||
first={first}
|
||||
active={() => props.currentTab() === tab}
|
||||
activeServerKey={props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
@@ -510,9 +506,8 @@ export function TitlebarTabStrip(props: {
|
||||
<div
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={id}
|
||||
class="flex shrink-0"
|
||||
class="flex min-w-0 max-w-56 flex-1 basis-0"
|
||||
classList={{
|
||||
"ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !first(),
|
||||
"pointer-events-none": drag.active,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
|
||||
@@ -1,3 +1,34 @@
|
||||
[data-slot="titlebar-tab-item"] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"] a {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-v2"]
|
||||
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:focus-visible, [data-state="focus"]):not(
|
||||
:disabled
|
||||
) {
|
||||
outline: none;
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"]
|
||||
[data-component="icon-button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"]:has([data-component="icon-button-v2"]:focus-visible) [data-slot="titlebar-tab-close"] {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"]:has([data-component="icon-button-v2"]:focus-visible)
|
||||
[data-slot="titlebar-tab-close-fade"] {
|
||||
background-image: var(--active-bg);
|
||||
}
|
||||
|
||||
@keyframes titlebar-tab-fade-left {
|
||||
from {
|
||||
visibility: hidden;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabKey, useTabs } from "@/context/tabs"
|
||||
import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
@@ -217,6 +218,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
|
||||
return (
|
||||
<header
|
||||
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
|
||||
classList={{
|
||||
"shrink-0 relative flex flex-row": true,
|
||||
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
|
||||
@@ -459,17 +461,26 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Show when={!(creating() && params.dir)}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("command.session.new")}
|
||||
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
<div class="flex-1" />
|
||||
<TitlebarV2Right state={v2RightState()} />
|
||||
<Show when={windows() && !electronWindows()}>
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
@@ -555,7 +566,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={creating() ? "new-session-active" : "new-session"}
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
disabled={layout.sidebar.opened()}
|
||||
tabIndex={layout.sidebar.opened() ? -1 : undefined}
|
||||
@@ -565,7 +575,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
aria-current={creating() ? "page" : undefined}
|
||||
/>
|
||||
>
|
||||
<IconV2 name="edit" size="small" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
@@ -663,16 +675,16 @@ function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
|
||||
|
||||
function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
return (
|
||||
<div class="relative isolate mr-3 size-5 shrink-0">
|
||||
<div class="group relative mr-3 h-5 w-5 shrink-0 rounded-full bg-v2-background-bg-deep transition-[width] duration-150 ease-out hover:z-30 hover:w-[68px] focus-within:z-30 focus-within:w-[68px] motion-reduce:transition-none">
|
||||
<button
|
||||
type="button"
|
||||
class="group absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out hover:z-30 hover:w-[68px] hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:z-30 focus-visible:w-[68px] focus-visible:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none"
|
||||
class="absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out group-hover:w-[68px] group-hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] group-focus-within:w-[68px] group-focus-within:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none"
|
||||
onClick={props.state.onInstall}
|
||||
disabled={props.state.installing}
|
||||
aria-busy={props.state.installing}
|
||||
aria-label={props.state.ariaLabel}
|
||||
>
|
||||
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-visible:opacity-100 group-focus-visible:translate-x-0 motion-reduce:translate-x-0">
|
||||
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0">
|
||||
Update
|
||||
</span>
|
||||
<span class="flex size-5 shrink-0 items-center justify-center">
|
||||
|
||||
@@ -227,6 +227,11 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
// KeybindV2 takes an array instead of a string
|
||||
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
|
||||
return formatKeybindParts(config, t)
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
|
||||
@@ -471,14 +471,18 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
case "permission.asked": {
|
||||
const permission = event.properties as PermissionRequest
|
||||
const permissions = data.permission[permission.sessionID] ?? []
|
||||
const permissions = data.permission[permission.sessionID]
|
||||
if (!permissions) {
|
||||
setData("permission", permission.sessionID, [permission])
|
||||
return
|
||||
}
|
||||
const result = Binary.search(permissions, permission.id, (item) => item.id)
|
||||
if (result.found) setData("permission", permission.sessionID, result.index, reconcile(permission))
|
||||
if (!result.found)
|
||||
setData(
|
||||
"permission",
|
||||
permission.sessionID,
|
||||
produce((draft = []) => void draft.splice(result.index, 0, permission)),
|
||||
produce((draft) => void draft.splice(result.index, 0, permission)),
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -497,14 +501,18 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
case "question.asked": {
|
||||
const question = event.properties as QuestionRequest
|
||||
const questions = data.question[question.sessionID] ?? []
|
||||
const questions = data.question[question.sessionID]
|
||||
if (!questions) {
|
||||
setData("question", question.sessionID, [question])
|
||||
return
|
||||
}
|
||||
const result = Binary.search(questions, question.id, (item) => item.id)
|
||||
if (result.found) setData("question", question.sessionID, result.index, reconcile(question))
|
||||
if (!result.found)
|
||||
setData(
|
||||
"question",
|
||||
question.sessionID,
|
||||
produce((draft = []) => void draft.splice(result.index, 0, question)),
|
||||
produce((draft) => void draft.splice(result.index, 0, question)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,6 +112,27 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
navigate(href)
|
||||
}
|
||||
|
||||
const removeTab = (index: number) => {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||
const nextTab = store[index + 1] ?? store[index - 1]
|
||||
closing.add(key)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
|
||||
if (nextTab) navigateTab(nextTab)
|
||||
else navigate("/")
|
||||
}).finally(() => closing.delete(key))
|
||||
memory.remove(key)
|
||||
if (draftID) removeDraftPersisted(draftID)
|
||||
}
|
||||
|
||||
const actions = {
|
||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||
const next = { type: "session" as const, ...tab }
|
||||
@@ -175,25 +196,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
memory.remove(`draft:${draftID}`)
|
||||
removeDraftPersisted(draftID)
|
||||
},
|
||||
removeTab: (index: number) => {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||
const nextTab = store[index + 1] ?? store[index - 1]
|
||||
closing.add(key)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
|
||||
if (nextTab) navigateTab(nextTab)
|
||||
else navigate("/")
|
||||
}).finally(() => closing.delete(key))
|
||||
memory.remove(key)
|
||||
if (draftID) removeDraftPersisted(draftID)
|
||||
removeTab,
|
||||
removeSessionTab(input: Omit<SessionTab, "type">) {
|
||||
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) {
|
||||
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||
@@ -205,9 +213,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (server.key === key) navigate("/")
|
||||
},
|
||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||
const targetServer = input.server ?? server.key
|
||||
const removed = store
|
||||
.filter(
|
||||
(tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
|
||||
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),
|
||||
)
|
||||
.map(tabKey)
|
||||
void startTransition(() => {
|
||||
@@ -215,28 +224,28 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
produce((tabs) => {
|
||||
const sessionIDs = new Set(input.sessionIDs)
|
||||
const currentHref =
|
||||
params.dir && params.id
|
||||
targetServer === server.key && params.dir && params.id
|
||||
? tabHref({
|
||||
type: "session",
|
||||
server: server.key,
|
||||
server: targetServer,
|
||||
sessionId: params.id,
|
||||
})
|
||||
: undefined
|
||||
const currentIndex = currentHref
|
||||
? tabs.findIndex(
|
||||
(tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref,
|
||||
(tab) => tab.type === "session" && tab.server === targetServer && tabHref(tab) === currentHref,
|
||||
)
|
||||
: -1
|
||||
const currentTab = tabs[currentIndex]
|
||||
const removedCurrent =
|
||||
currentTab?.type === "session" &&
|
||||
currentTab.server === server.key &&
|
||||
currentTab.server === targetServer &&
|
||||
sessionIDs.has(currentTab.sessionId)
|
||||
|
||||
for (let i = tabs.length - 1; i >= 0; i--) {
|
||||
const tab = tabs[i]
|
||||
if (!tab || tab.type !== "session") continue
|
||||
if (tab.server !== server.key) continue
|
||||
if (tab.server !== targetServer) continue
|
||||
if (!sessionIDs.has(tab.sessionId)) continue
|
||||
tabs.splice(i, 1)
|
||||
}
|
||||
|
||||
@@ -592,10 +592,11 @@ export const dict = {
|
||||
"home.server.collapse": "Collapse server projects",
|
||||
"home.server.expand": "Expand server projects",
|
||||
"home.sessions.search.placeholder": "Search sessions",
|
||||
"home.sessions.search.placeholder.scoped": "Search sessions in {{scope}}",
|
||||
"home.sessions.search.sessions": "Sessions",
|
||||
"home.sessions.search.noResults": "No sessions found for {{query}}",
|
||||
"home.sessions.empty": "No sessions found",
|
||||
"home.sessions.empty.description": "Start a new session for this project",
|
||||
"home.sessions.empty": "Nothing here yet",
|
||||
"home.sessions.empty.description": "Create a session to get started",
|
||||
"home.sessions.group.today": "Today",
|
||||
"home.sessions.group.yesterday": "Yesterday",
|
||||
"home.sessions.group.older": "Older",
|
||||
|
||||
@@ -507,9 +507,11 @@ export const dict = {
|
||||
"home.projects": "项目",
|
||||
"home.project.add": "添加项目",
|
||||
"home.sessions.search.placeholder": "搜索会话",
|
||||
"home.sessions.search.placeholder.scoped": "在 {{scope}} 中搜索会话",
|
||||
"home.sessions.search.sessions": "会话",
|
||||
"home.sessions.search.noResults": "未找到与 {{query}} 相关的会话",
|
||||
"home.sessions.empty": "未找到会话",
|
||||
"home.sessions.empty": "这里还没有内容",
|
||||
"home.sessions.empty.description": "创建一个会话以开始。",
|
||||
"home.sessions.group.today": "今天",
|
||||
"home.sessions.group.yesterday": "昨天",
|
||||
"home.sessions.group.older": "更早",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
import { archiveHomeSession } from "./home-session-archive"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
|
||||
const remote = "remote" as ServerConnection.Key
|
||||
|
||||
test("archiving a Home session removes its open titlebar tab", async () => {
|
||||
let detail: ReturnType<typeof readSessionTabsRemovedDetail>
|
||||
let removed = false
|
||||
window.addEventListener(
|
||||
SESSION_TABS_REMOVED_EVENT,
|
||||
(event) => {
|
||||
detail = readSessionTabsRemovedDetail(event)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", directory: "/workspace" },
|
||||
update: async () => undefined,
|
||||
remove: () => {
|
||||
removed = true
|
||||
},
|
||||
})
|
||||
|
||||
expect(removed).toBe(true)
|
||||
expect(detail).toEqual({ server: remote, directory: "/workspace", sessionIDs: ["ses_1"] })
|
||||
})
|
||||
|
||||
test("reports archive failures without removing the session", async () => {
|
||||
const failure = new Error("offline")
|
||||
let error: unknown
|
||||
let removed = false
|
||||
|
||||
await archiveHomeSession({
|
||||
server: remote,
|
||||
session: { id: "ses_1", directory: "/workspace" },
|
||||
update: async () => Promise.reject(failure),
|
||||
remove: () => {
|
||||
removed = true
|
||||
},
|
||||
onError: (value) => {
|
||||
error = value
|
||||
},
|
||||
})
|
||||
|
||||
expect(error).toBe(failure)
|
||||
expect(removed).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
|
||||
type HomeSession = {
|
||||
id: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
type SessionUpdate = {
|
||||
directory: string
|
||||
sessionID: string
|
||||
time: { archived: number }
|
||||
}
|
||||
|
||||
export async function archiveHomeSession(input: {
|
||||
server: ServerConnection.Key
|
||||
session: HomeSession
|
||||
update: (value: SessionUpdate) => Promise<unknown>
|
||||
remove: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}) {
|
||||
await input
|
||||
.update({
|
||||
directory: input.session.directory,
|
||||
sessionID: input.session.id,
|
||||
time: { archived: Date.now() },
|
||||
})
|
||||
.then(() => {
|
||||
input.remove()
|
||||
notifySessionTabsRemoved({
|
||||
server: input.server,
|
||||
directory: input.session.directory,
|
||||
sessionIDs: [input.session.id],
|
||||
})
|
||||
})
|
||||
.catch((error) => input.onError?.(error))
|
||||
}
|
||||
+208
-102
@@ -14,7 +14,7 @@ import {
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
@@ -25,6 +25,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
@@ -35,7 +36,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -43,6 +44,7 @@ import { useNotification } from "@/context/notification"
|
||||
import {
|
||||
closeHomeProject,
|
||||
displayName,
|
||||
errorMessage,
|
||||
getProjectAvatarSource,
|
||||
homeProjectDirectories,
|
||||
type HomeProjectSelection,
|
||||
@@ -55,12 +57,15 @@ import { sessionTitle } from "@/utils/session-title"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||
import { archiveHomeSession } from "./home-session-archive"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const HOME_ROW_LAYOUT =
|
||||
@@ -85,7 +90,7 @@ type HomeSessionGroup = {
|
||||
|
||||
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
|
||||
const HOME_SEARCH_RESULT_ROW =
|
||||
"flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-4 pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
"flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
const HOME_SEARCH_RESULT_TITLE =
|
||||
"min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]"
|
||||
const HOME_SEARCH_RESULT_META =
|
||||
@@ -172,6 +177,19 @@ export function NewHome() {
|
||||
return directories(project)
|
||||
})
|
||||
const search = createMemo(() => state.search.trim())
|
||||
const searchPlaceholder = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (project) {
|
||||
return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
|
||||
}
|
||||
if (global.servers.list().length > 1) {
|
||||
const conn = focusedServer()
|
||||
if (conn) {
|
||||
return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
|
||||
}
|
||||
}
|
||||
return language.t("home.sessions.search.placeholder")
|
||||
})
|
||||
const sessionLoad = useQuery(() => ({
|
||||
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
|
||||
queryFn: async () => {
|
||||
@@ -258,7 +276,7 @@ export function NewHome() {
|
||||
command.register("home", () => [
|
||||
{
|
||||
id: "home.sessions.search.focus",
|
||||
title: language.t("home.sessions.search.placeholder"),
|
||||
title: searchPlaceholder(),
|
||||
keybind: "mod+f",
|
||||
hidden: true,
|
||||
onSelect: () => focusSessionSearch?.(),
|
||||
@@ -350,7 +368,33 @@ export function NewHome() {
|
||||
})
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
const conn = focusedServer()
|
||||
const ctx = focusedServerCtx()
|
||||
if (!conn || !ctx) return
|
||||
const [, setStore] = ctx.sync.child(session.directory)
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
session,
|
||||
update: (value) => ctx.sdk.client.session.update(value),
|
||||
remove: () =>
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
),
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(error, language.t("common.requestFailed")),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function chooseProject(conn: ServerConnection.Any) {
|
||||
if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
||||
|
||||
function resolve(result: string | string[] | null) {
|
||||
addProjects(conn, homeProjectDirectories(result))
|
||||
}
|
||||
@@ -402,10 +446,11 @@ export function NewHome() {
|
||||
>
|
||||
<HomeSessionSearch
|
||||
value={state.search}
|
||||
placeholder={language.t("home.sessions.search.placeholder")}
|
||||
placeholder={searchPlaceholder()}
|
||||
open={searchOpen()}
|
||||
loading={sessionLoad.isLoading}
|
||||
results={searchResults()}
|
||||
showProjectName={!selectedProject()}
|
||||
server={state.selection.server}
|
||||
activeServer={state.selection.server === server.key}
|
||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||
@@ -418,22 +463,19 @@ export function NewHome() {
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<ScrollView class="mt-3 min-h-0 flex-1">
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}
|
||||
when={groups().length > 0}
|
||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||
>
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<HomeSessionGroupHeader
|
||||
title={language.t("home.sessions.empty")}
|
||||
onNewSession={newSessionProject() ? openNewSession : undefined}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<For each={groups()}>
|
||||
{(group, index) => (
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
@@ -446,9 +488,11 @@ export function NewHome() {
|
||||
{(record) => (
|
||||
<HomeSessionRow
|
||||
record={record}
|
||||
showProjectName={!selectedProject()}
|
||||
server={state.selection.server}
|
||||
activeServer={state.selection.server === server.key}
|
||||
onClick={() => openSession(record.session)}
|
||||
openSession={openSession}
|
||||
archiveSession={archiveSession}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -456,9 +500,9 @@ export function NewHome() {
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
</section>
|
||||
<HomeUtilityNav
|
||||
@@ -502,15 +546,18 @@ function HomeProjectColumn(props: {
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<Show when={global.servers.list().length === 1}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
onClick={() => props.chooseProject(global.servers.list()[0]!)}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
disabled={global.servers.health[ServerConnection.key(global.servers.list()[0]!)]?.healthy === false}
|
||||
onClick={() => props.chooseProject(global.servers.list()[0]!)}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
@@ -646,7 +693,7 @@ function HomeServerRow(props: {
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={state.menuOpen}
|
||||
>
|
||||
<ServerRowMenu
|
||||
@@ -656,14 +703,17 @@ function HomeServerRow(props: {
|
||||
open={state.menuOpen}
|
||||
onOpenChange={(open) => setState("menuOpen", open)}
|
||||
/>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
onClick={() => props.chooseProject(props.server)}
|
||||
/>
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
disabled={props.health?.healthy === false}
|
||||
onClick={() => props.chooseProject(props.server)}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -733,19 +783,11 @@ function HomeProjectRow(props: {
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</button>
|
||||
<div
|
||||
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={state.menuOpen}
|
||||
>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="edit" />}
|
||||
aria-label={props.language.t("command.session.new")}
|
||||
onClick={() => props.openNewSession(props.server, props.project.worktree)}
|
||||
/>
|
||||
<MenuV2
|
||||
gutter={4}
|
||||
gutter={6}
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
open={state.menuOpen}
|
||||
@@ -765,7 +807,7 @@ function HomeProjectRow(props: {
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}>
|
||||
{props.language.t("common.edit")}
|
||||
{props.language.t("dialog.project.edit.title")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item
|
||||
disabled={props.unseenCount === 0}
|
||||
@@ -780,6 +822,14 @@ function HomeProjectRow(props: {
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="edit" />}
|
||||
aria-label={props.language.t("command.session.new")}
|
||||
onClick={() => props.openNewSession(props.server, props.project.worktree)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -810,7 +860,7 @@ function HomeSessionLeading(props: {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||
style={{ right: "calc(100% + 12px)" }}
|
||||
style={{ right: "calc(100% + 5px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTabAvatar
|
||||
@@ -829,6 +879,7 @@ function HomeSessionSearch(props: {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
results: HomeSessionRecord[]
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
noResultsLabel: string
|
||||
@@ -906,12 +957,12 @@ function HomeSessionSearch(props: {
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="ml-4 mr-2 w-[calc(100%_-_24px)]">
|
||||
<div class="mr-2 w-[calc(100%_-_8px)]">
|
||||
<div ref={root} data-component="home-session-search" class="relative z-10 w-full">
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
data-component="home-session-search-panel"
|
||||
class="absolute flex flex-col rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
|
||||
class="absolute flex flex-col overflow-hidden rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
|
||||
style={{
|
||||
top: "-6px",
|
||||
left: "-6px",
|
||||
@@ -919,7 +970,7 @@ function HomeSessionSearch(props: {
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col pt-9">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4 pb-2">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
||||
<Show
|
||||
when={!props.loading}
|
||||
fallback={
|
||||
@@ -931,29 +982,32 @@ function HomeSessionSearch(props: {
|
||||
<Show
|
||||
when={props.results.length > 0}
|
||||
fallback={
|
||||
<p class="my-1.5 px-4 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
<p class="my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
{props.noResultsLabel}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<p class="my-1.5 px-4 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
<p class="my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
{language.t("home.sessions.search.sessions")}
|
||||
</p>
|
||||
<div ref={listRef} class="flex max-h-80 flex-col gap-px overflow-y-auto">
|
||||
<For each={props.results}>
|
||||
{(record) => (
|
||||
<HomeSessionSearchResultRow
|
||||
record={record}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
selected={store.active === homeSessionSearchKey(record)}
|
||||
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
||||
onSelect={(session) => props.onSelect(session)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<ScrollView class="max-h-80" viewportRef={(el) => (listRef = el)}>
|
||||
<div class="flex flex-col gap-px pb-2">
|
||||
<For each={props.results}>
|
||||
{(record) => (
|
||||
<HomeSessionSearchResultRow
|
||||
record={record}
|
||||
showProjectName={props.showProjectName}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
selected={store.active === homeSessionSearchKey(record)}
|
||||
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
||||
onSelect={(session) => props.onSelect(session)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -962,11 +1016,10 @@ function HomeSessionSearch(props: {
|
||||
</div>
|
||||
</Show>
|
||||
<label
|
||||
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
|
||||
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
|
||||
classList={{
|
||||
"bg-v2-background-bg-layer-03 focus-within:bg-v2-background-bg-layer-03 focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]":
|
||||
!props.open,
|
||||
"bg-transparent shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
|
||||
"focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]": !props.open,
|
||||
"shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
|
||||
}}
|
||||
>
|
||||
<IconV2 name="magnifying-glass" />
|
||||
@@ -1031,6 +1084,7 @@ function HomeSessionSearch(props: {
|
||||
|
||||
function HomeSessionSearchResultRow(props: {
|
||||
record: HomeSessionRecord
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
selected: boolean
|
||||
@@ -1038,6 +1092,7 @@ function HomeSessionSearchResultRow(props: {
|
||||
onSelect: (session: Session) => void
|
||||
}) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
@@ -1064,11 +1119,11 @@ function HomeSessionSearchResultRow(props: {
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span
|
||||
class={`${HOME_SEARCH_RESULT_TITLE} ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
class={`${HOME_SEARCH_RESULT_TITLE} ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
>
|
||||
{title()}
|
||||
</span>
|
||||
<Show when={props.record.projectName}>
|
||||
<Show when={showProjectName()}>
|
||||
<span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -1079,7 +1134,7 @@ function HomeSessionSearchResultRow(props: {
|
||||
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-4 pr-2">
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-[18px] pr-2">
|
||||
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
@@ -1101,36 +1156,79 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
|
||||
|
||||
function HomeSessionRow(props: {
|
||||
record: HomeSessionRecord
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
onClick: () => void
|
||||
openSession: (session: Session) => void
|
||||
archiveSession: (session: Session) => Promise<void>
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
<span
|
||||
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
<div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
|
||||
onClick={() => props.openSession(props.record.session)}
|
||||
>
|
||||
{title()}
|
||||
</span>
|
||||
<Show when={props.record.projectName}>
|
||||
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
|
||||
{props.record.projectName}
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
<span
|
||||
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
>
|
||||
{title()}
|
||||
</span>
|
||||
<Show when={showProjectName()}>
|
||||
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
|
||||
{props.record.projectName}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionsEmpty(props: { onNewSession?: () => void }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
|
||||
<div class="shrink-0 text-[13px] leading-[13px] tracking-[-0.04px] text-v2-text-text-base [font-weight:530]">
|
||||
{language.t("home.sessions.empty")}
|
||||
</div>
|
||||
<p class="mb-1 text-center text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
||||
{language.t("home.sessions.empty.description")}
|
||||
</p>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
<ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
|
||||
{language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1182,6 +1280,7 @@ export function LegacyHome() {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const homedir = createMemo(() => sync().data.path.home)
|
||||
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
|
||||
const recent = createMemo(() => {
|
||||
return sync()
|
||||
.data.project.slice()
|
||||
@@ -1204,6 +1303,7 @@ export function LegacyHome() {
|
||||
}
|
||||
|
||||
function chooseProject() {
|
||||
if (serverUnreachable()) return
|
||||
const s = server.current
|
||||
if (!s) return
|
||||
|
||||
@@ -1247,7 +1347,13 @@ export function LegacyHome() {
|
||||
<div class="mt-20 w-full flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center justify-between pl-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
|
||||
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
|
||||
<Button
|
||||
icon="folder-add-left"
|
||||
size="normal"
|
||||
class="pl-2 pr-3"
|
||||
disabled={serverUnreachable()}
|
||||
onClick={chooseProject}
|
||||
>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1273,7 +1379,7 @@ export function LegacyHome() {
|
||||
<Match when={!sync().ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" onClick={chooseProject}>
|
||||
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1285,7 +1391,7 @@ export function LegacyHome() {
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
||||
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
||||
</div>
|
||||
<Button class="px-3 mt-1" onClick={chooseProject}>
|
||||
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
@@ -2115,7 +2116,6 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
<div class="shrink-0 py-4">
|
||||
<Button
|
||||
size="large"
|
||||
icon="new-session"
|
||||
class="w-full"
|
||||
onClick={() => {
|
||||
const dir = worktree()
|
||||
@@ -2123,6 +2123,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
|
||||
}}
|
||||
>
|
||||
<IconV2 name="edit" size="small" />
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
@@ -300,7 +301,7 @@ export const NewSessionItem = (props: {
|
||||
}}
|
||||
>
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<Icon name="new-session" size="small" class="text-icon-weak" />
|
||||
<IconV2 name="edit" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
|
||||
</A>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -214,9 +216,10 @@ const WorkspaceActions = (props: {
|
||||
</DropdownMenu>
|
||||
<Show when={!props.touch()}>
|
||||
<Tooltip value={props.language.t("command.session.new")} placement="top">
|
||||
<IconButton
|
||||
icon="new-session"
|
||||
<IconButtonV2
|
||||
icon={<IconV2 name="edit" size="small" />}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
|
||||
data-action="workspace-new-session"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
|
||||
@@ -252,7 +252,7 @@ export function SessionComposerRegion(props: {
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-10": true,
|
||||
"relative z-30": true,
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${-lift()}px`,
|
||||
|
||||
@@ -1239,26 +1239,56 @@ export function MessageTimeline(props: {
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"bottom-8": settings.general.newLayoutDesigns(),
|
||||
"bottom-6": !settings.general.newLayoutDesigns(),
|
||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump,
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
|
||||
"opacity-0 translate-y-2 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
|
||||
"scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && settings.general.newLayoutDesigns(),
|
||||
"scale-95": (!props.scroll.overflow || !props.scroll.jump) && !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
|
||||
onClick={props.onResumeScroll}
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("session.messages.jumpToLatest")}
|
||||
class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||
style={{
|
||||
"box-shadow":
|
||||
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||
}}
|
||||
>
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("session.messages.jumpToLatest")}
|
||||
class="pointer-events-auto flex items-center justify-center w-8 h-7 px-2 py-1.5 rounded-lg border-none cursor-pointer text-v2-text-text-base backdrop-blur-[2px]"
|
||||
style={{
|
||||
"box-shadow":
|
||||
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||
background: "color-mix(in srgb, var(--v2-background-bg-base) 92%, transparent)",
|
||||
"box-shadow": "var(--v2-elevation-raised), 0px 2px 8px var(--v2-background-bg-base)",
|
||||
}}
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
</div>
|
||||
</button>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12.3333 8.66665L8 13L3.66667 8.66665M8 12.6667V2.83332"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<ScrollView
|
||||
viewportRef={bindListRoot}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotFoundError } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors"
|
||||
import { formatServerError, parseReadableConfigInvalidError } from "./server-errors"
|
||||
import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./server-errors"
|
||||
|
||||
function fill(text: string, vars?: Record<string, string | number>) {
|
||||
if (!vars) return text
|
||||
@@ -142,3 +143,33 @@ describe("formatServerError", () => {
|
||||
expect(formatServerError(wrapped, language.t)).toBe("Arquivo de config em config invalido: Missing host")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSessionNotFoundError", () => {
|
||||
test("matches an SDK-wrapped error for the requested session", () => {
|
||||
const body = {
|
||||
_tag: "SessionNotFoundError",
|
||||
sessionID: "ses_missing",
|
||||
message: "Session not found",
|
||||
} satisfies SessionNotFoundError
|
||||
|
||||
expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), body.sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects errors for other sessions and other 404 responses", () => {
|
||||
const body = {
|
||||
_tag: "SessionNotFoundError",
|
||||
sessionID: "ses_parent",
|
||||
message: "Session not found",
|
||||
} satisfies SessionNotFoundError
|
||||
|
||||
expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), "ses_tab")).toBe(false)
|
||||
expect(
|
||||
isSessionNotFoundError(
|
||||
new Error("Provider not found", {
|
||||
cause: { body: { _tag: "ProviderNotFoundError", providerID: "missing" }, status: 404 },
|
||||
}),
|
||||
"ses_tab",
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,13 @@ function unwrapNamedError(error: unknown): unknown {
|
||||
return error
|
||||
}
|
||||
|
||||
export function isSessionNotFoundError(error: unknown, sessionID: string) {
|
||||
const unwrapped = unwrapNamedError(error)
|
||||
if (typeof unwrapped !== "object" || unwrapped === null) return false
|
||||
const value = unwrapped as Record<string, unknown>
|
||||
return value._tag === "SessionNotFoundError" && value.sessionID === sessionID
|
||||
}
|
||||
|
||||
function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
|
||||
if (typeof error !== "object" || error === null) return false
|
||||
const o = error as Record<string, unknown>
|
||||
|
||||
@@ -82,6 +82,7 @@ import { getRevertDiffFiles } from "../../util/revert-diff"
|
||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { LocationProvider } from "../../context/location"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -91,6 +92,7 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_
|
||||
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
|
||||
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
|
||||
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
|
||||
const SESSION_RENDER_STATE_CHANNELS = new Set(["local", "dev", "beta"])
|
||||
|
||||
export const alwaysSeparate = new WeakSet<BoxRenderable>()
|
||||
|
||||
@@ -420,6 +422,56 @@ export function Session() {
|
||||
}, 50)
|
||||
}
|
||||
|
||||
async function dumpRenderState() {
|
||||
const timestamp = Date.now()
|
||||
const maxTop = Math.max(0, scroll.scrollHeight - scroll.viewport.height)
|
||||
const filename = `session-${route.sessionID.slice(0, 8)}-render-state-${timestamp}.json`
|
||||
renderer.dumpBuffers(timestamp)
|
||||
await writeExport(
|
||||
path.join(process.cwd(), filename),
|
||||
JSON.stringify(
|
||||
{
|
||||
capturedAt: timestamp,
|
||||
channel: InstallationChannel,
|
||||
version: InstallationVersion,
|
||||
sessionID: route.sessionID,
|
||||
scroll: {
|
||||
top: scroll.scrollTop,
|
||||
height: scroll.scrollHeight,
|
||||
viewportHeight: scroll.viewport.height,
|
||||
maxTop,
|
||||
atBottom: scroll.scrollTop >= maxTop,
|
||||
},
|
||||
messages: messages().map((message) => ({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
parts: (sync.data.part[message.id] ?? []).map((part) => {
|
||||
const renderable = scroll.findDescendantById(`text-${part.id}`)
|
||||
return {
|
||||
id: part.id,
|
||||
type: part.type,
|
||||
...((part.type === "text" || part.type === "reasoning") && { textLength: part.text.length }),
|
||||
...(renderable && {
|
||||
renderable: {
|
||||
id: renderable.id,
|
||||
x: renderable.x,
|
||||
y: renderable.y,
|
||||
width: renderable.width,
|
||||
height: renderable.height,
|
||||
visible: renderable.visible,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
toast.show({ message: `Session render state written to ${filename}`, variant: "success" })
|
||||
}
|
||||
|
||||
const local = useLocal()
|
||||
|
||||
function enterChild(sessionID: string) {
|
||||
@@ -1015,6 +1067,23 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
...(SESSION_RENDER_STATE_CHANNELS.has(InstallationChannel)
|
||||
? [
|
||||
{
|
||||
title: "Dump session render state",
|
||||
value: "session.debug.dump_render_state",
|
||||
category: "Debug",
|
||||
run: async () => {
|
||||
try {
|
||||
await dumpRenderState()
|
||||
} catch {
|
||||
toast.show({ message: "Failed to dump session render state", variant: "error" })
|
||||
}
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "Background subagents",
|
||||
value: "session.background",
|
||||
|
||||
@@ -130,8 +130,8 @@
|
||||
}
|
||||
|
||||
&[data-size="x-large"] [data-slot="dialog-container"] {
|
||||
width: min(calc(100vw - 32px), 960px);
|
||||
height: min(calc(100vh - 32px), 600px);
|
||||
width: min(calc(100vw - 32px), 980px);
|
||||
height: min(calc(100vh - 92px), 600px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
.scroll-view {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scroll-view__viewport {
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
@utility hover-reveal {
|
||||
@apply opacity-0 transition-opacity;
|
||||
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@utility badge-mask {
|
||||
-webkit-mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);
|
||||
mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);
|
||||
|
||||
@@ -416,7 +416,7 @@
|
||||
"v2-overlay-simple-overlay-pressed": "var(--v2-alpha-light-10)",
|
||||
"v2-overlay-simple-overlay-contrast-hover": "var(--v2-alpha-dark-24)",
|
||||
"v2-overlay-simple-overlay-contrast-pressed": "var(--v2-alpha-dark-40)",
|
||||
"v2-overlay-simple-overlay-scrim": "var(--v2-alpha-light-30)",
|
||||
"v2-overlay-simple-overlay-scrim": "var(--v2-alpha-dark-60)",
|
||||
"v2-overlay-gradient-depth-overlay-depth-top": "var(--v2-alpha-light-100)",
|
||||
"v2-overlay-gradient-depth-overlay-depth-bot": "var(--v2-alpha-light-0)",
|
||||
"v2-overlay-simple-tab-active-scrim": "#24242400",
|
||||
|
||||
@@ -90,7 +90,7 @@ const dark: Record<string, V2ColorValue> = {
|
||||
"v2-overlay-simple-overlay-pressed": ref("v2-alpha-light-10"),
|
||||
"v2-overlay-simple-overlay-contrast-hover": ref("v2-alpha-dark-24"),
|
||||
"v2-overlay-simple-overlay-contrast-pressed": ref("v2-alpha-dark-40"),
|
||||
"v2-overlay-simple-overlay-scrim": ref("v2-alpha-light-30"),
|
||||
"v2-overlay-simple-overlay-scrim": ref("v2-alpha-dark-60"),
|
||||
"v2-overlay-gradient-depth-overlay-depth-top": ref("v2-alpha-light-100"),
|
||||
"v2-overlay-gradient-depth-overlay-depth-bot": ref("v2-alpha-light-0"),
|
||||
"v2-overlay-simple-tab-active-scrim": "#24242400",
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
}
|
||||
|
||||
&[data-size="x-large"] [data-slot="dialog-container"] {
|
||||
width: 800px;
|
||||
height: 560px;
|
||||
width: min(calc(100vw - 32px), 980px);
|
||||
height: min(calc(100vh - 92px), 600px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +112,8 @@
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled):not(
|
||||
[data-expanded]
|
||||
[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"], [data-expanded]):not(
|
||||
:disabled
|
||||
) {
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
@@ -133,8 +133,8 @@
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not(
|
||||
[data-expanded]
|
||||
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"], [data-expanded]):not(
|
||||
:disabled
|
||||
) {
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M2.5 7.5H3.5V8.5H2.5V7.5Z" stroke="currentColor"/><path d="M7.5 7.5H8.5V8.5H7.5V7.5Z" stroke="currentColor"/><path d="M12.5 7.5H13.5V8.5H12.5V7.5Z" stroke="currentColor"/>`,
|
||||
},
|
||||
archive: {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M13.1112 13.5555V14.0555H13.6112V13.5555H13.1112ZM2.889 13.5555H2.389L2.389 14.0555H2.889V13.5555ZM3.38901 5.55546L3.38901 5.05546L2.38901 5.05546L2.38901 5.55546L2.88901 5.55546L3.38901 5.55546ZM14.4446 2.44434H14.9446V1.94434L14.4446 1.94434L14.4446 2.44434ZM14.4446 5.55545L14.4446 6.05545L14.9446 6.05545V5.55545H14.4446ZM1.55566 5.55546L1.05566 5.55545L1.05566 6.05546L1.55566 6.05546L1.55566 5.55546ZM1.5557 2.44436L1.5557 1.94436L1.05571 1.94436L1.0557 2.44435L1.5557 2.44436ZM13.1112 5.55546H12.6112V13.5555H13.1112H13.6112V5.55546H13.1112ZM2.889 13.5555H3.389L3.38901 5.55546L2.88901 5.55546L2.38901 5.55546L2.389 13.5555H2.889ZM14.4446 2.44434H13.9446V5.55545H14.4446H14.9446V2.44434H14.4446ZM1.55566 5.55546L2.05566 5.55547L2.0557 2.44436L1.5557 2.44436L1.0557 2.44435L1.05566 5.55545L1.55566 5.55546ZM6.22234 8.22213V8.72213H9.7779V8.22213V7.72213H6.22234V8.22213ZM13.1112 13.5555V13.0555H2.889V13.5555V14.0555H13.1112V13.5555ZM1.5557 2.44436L1.5557 2.94436L14.4446 2.94434L14.4446 2.44434L14.4446 1.94434L1.5557 1.94436L1.5557 2.44436ZM14.4446 5.55545L14.4446 5.05545L1.55566 5.05546L1.55566 5.55546L1.55566 6.05546L14.4446 6.05545L14.4446 5.55545Z" fill="currentColor"/>`,
|
||||
},
|
||||
}
|
||||
|
||||
const spriteID = "opencode-v2-icon-sprite"
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-variation-settings: "slnt" 0;
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-component="tabs-v2"] [data-slot="tabs-v2-trigger"] {
|
||||
@@ -54,6 +55,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
@@ -61,7 +63,7 @@
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
[data-component="tabs-v2"] [data-slot="tabs-v2-trigger"]:focus-visible {
|
||||
[data-component="tabs-v2"] [data-slot="tabs-v2-trigger"]:is(:focus, :focus-visible) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
--v2-overlay-simple-overlay-pressed: var(--v2-alpha-light-10);
|
||||
--v2-overlay-simple-overlay-contrast-hover: var(--v2-alpha-dark-24);
|
||||
--v2-overlay-simple-overlay-contrast-pressed: var(--v2-alpha-dark-40);
|
||||
--v2-overlay-simple-overlay-scrim: var(--v2-alpha-light-30);
|
||||
--v2-overlay-simple-overlay-scrim: var(--v2-alpha-dark-60);
|
||||
--v2-overlay-gradient-depth-overlay-depth-top: var(--v2-alpha-light-100);
|
||||
--v2-overlay-gradient-depth-overlay-depth-bot: var(--v2-alpha-light-0);
|
||||
--v2-overlay-simple-tab-active-scrim: #24242400;
|
||||
|
||||
Reference in New Issue
Block a user