feat(app): ui improvements (#32438)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
Aarav Sareen
2026-06-25 10:56:37 +05:30
committed by GitHub
parent 8cc39261c8
commit cb187496cf
29 changed files with 415 additions and 131 deletions
@@ -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")
}
+6 -2
View File
@@ -2196,7 +2196,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
)} )}
</Show> </Show>
<span class="truncate">{props.state.modelName}</span> <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> </Button>
</TooltipKeybind> </TooltipKeybind>
} }
@@ -2225,7 +2227,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
)} )}
</Show> </Show>
<span class="truncate">{props.state.modelName}</span> <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> </ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
@@ -19,7 +19,7 @@ export const ServerRowMenu: Component<{
const isDefault = () => props.controller.defaultKey() === key const isDefault = () => props.controller.defaultKey() === key
return ( 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 <MenuV2.Trigger
as={IconButtonV2} as={IconButtonV2}
variant="ghost-muted" variant="ghost-muted"
@@ -117,7 +117,7 @@ export function ServerHealthIndicator(props: { health?: ServerHealth }) {
return ( return (
<div <div
classList={{ 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-success-base": props.health?.healthy === true,
"bg-icon-critical-base": props.health?.healthy === false, "bg-icon-critical-base": props.health?.healthy === false,
"bg-border-weak-base": props.health === undefined, "bg-border-weak-base": props.health === undefined,
@@ -28,6 +28,9 @@ import { Persist, persisted } from "@/utils/persist"
import { StatusPopover, StatusPopoverV2 } from "../status-popover" import { StatusPopover, StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" 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 = [ const OPEN_APPS = [
"vscode", "vscode",
@@ -237,7 +240,7 @@ export function SessionHeader() {
statusVisible: status(), statusVisible: status(),
statusLabel: language.t("status.popover.trigger"), statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"), reviewLabel: language.t("command.review.toggle"),
reviewKeybind: command.keybind("review.toggle"), reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: isDesktop(), reviewVisible: isDesktop(),
reviewOpened: view().reviewPanel.opened(), reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(), onReviewToggle: () => view().reviewPanel.toggle(),
@@ -520,13 +523,15 @@ type SessionHeaderV2ActionsState = {
statusVisible: boolean statusVisible: boolean
statusLabel: string statusLabel: string
reviewLabel: string reviewLabel: string
reviewKeybind: string reviewKeybind: string[]
reviewVisible: boolean reviewVisible: boolean
reviewOpened: boolean reviewOpened: boolean
onReviewToggle: () => void onReviewToggle: () => void
} }
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
const language = useLanguage()
return ( return (
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Show when={props.state.statusVisible}> <Show when={props.state.statusVisible}>
@@ -535,7 +540,17 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
</Tooltip> </Tooltip>
</Show> </Show>
<Show when={props.state.reviewVisible}> <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 <IconButtonV2
type="button" type="button"
variant="ghost-muted" variant="ghost-muted"
@@ -548,7 +563,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
aria-controls="review-panel" aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />} icon={<IconV2 name="sidebar-right" />}
/> />
</TooltipKeybind> </TooltipV2>
</Show> </Show>
</div> </div>
) )
@@ -1,15 +1,19 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ServerConnection } from "@/context/server"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
const remote = "remote" as ServerConnection.Key
describe("titlebar session events", () => { describe("titlebar session events", () => {
test("reads valid removed session tab details", () => { test("reads valid removed session tab details", () => {
expect( expect(
readSessionTabsRemovedDetail( readSessionTabsRemovedDetail(
new CustomEvent(SESSION_TABS_REMOVED_EVENT, { 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({ ).toEqual({
server: remote,
directory: "/tmp/project", directory: "/tmp/project",
sessionIDs: ["ses_1", "ses_2"], 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 const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
export type SessionTabsRemovedDetail = { export type SessionTabsRemovedDetail = {
server?: ServerConnection.Key
directory: string directory: string
sessionIDs: string[] sessionIDs: string[]
} }
@@ -18,11 +21,14 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe
if (!("sessionIDs" in detail)) return undefined if (!("sessionIDs" in detail)) return undefined
if (typeof detail.directory !== "string") return undefined if (typeof detail.directory !== "string") return undefined
if (!Array.isArray(detail.sessionIDs)) 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") const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
if (sessionIDs.length === 0) return undefined if (sessionIDs.length === 0) return undefined
return { return {
server:
"server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined,
directory: detail.directory, directory: detail.directory,
sessionIDs, sessionIDs,
} }
@@ -175,9 +175,10 @@ export function TabNavItem(props: {
forwardTabRef(props.ref, el) forwardTabRef(props.ref, el)
}} }}
data-titlebar-tab data-titlebar-tab
data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()} data-title-overflow={titleOverflowing()}
data-editing={editing()} 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 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)] 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 }} classList={{ invisible: props.hidden }}
data-active={props.active} data-active={props.active}
data-dragging={props.dragging} data-dragging={props.dragging}
@@ -292,10 +293,11 @@ export function DraftTabItem(props: {
<div <div
ref={(el) => forwardTabRef(props.ref, el)} ref={(el) => forwardTabRef(props.ref, el)}
data-titlebar-tab data-titlebar-tab
data-slot="titlebar-tab-item"
data-active={props.active} data-active={props.active}
data-dragging={props.dragging} data-dragging={props.dragging}
data-pressed={props.pressed} 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 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)] 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)]"
classList={{ invisible: props.hidden }} classList={{ invisible: props.hidden }}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== 1) return if (event.button !== 1) return
+26
View File
@@ -1,3 +1,29 @@
[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 { @keyframes titlebar-tab-fade-left {
from { from {
visibility: hidden; visibility: hidden;
+24 -12
View File
@@ -26,6 +26,7 @@ import { useGlobal } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServer } from "@/context/server"
import { tabKey, useTabs } from "@/context/tabs" import { tabKey, useTabs } from "@/context/tabs"
import "./titlebar.css" import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
type TauriDesktopWindow = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@@ -217,6 +218,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return ( return (
<header <header
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
classList={{ classList={{
"shrink-0 relative flex flex-row": true, "shrink-0 relative flex flex-row": true,
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(), "h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
@@ -459,15 +461,25 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
onReorder={(keys) => tabsStoreActions.reorder(keys)} onReorder={(keys) => tabsStoreActions.reorder(keys)}
/> />
<Show when={!(creating() && params.dir)}> <Show when={!(creating() && params.dir)}>
<IconButtonV2 <TooltipV2
type="button" placement="bottom"
variant="ghost-muted" value={
size="large" <>
class="shrink-0" {language.t("command.session.new")}
icon={<IconV2 name="plus" />} <KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
onClick={openNewTab} </>
aria-label={language.t("command.session.new")} }
/> >
<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> </Show>
<div class="flex-1" /> <div class="flex-1" />
<TitlebarV2Right state={v2RightState()} /> <TitlebarV2Right state={v2RightState()} />
@@ -663,16 +675,16 @@ function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) { function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
return ( 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 <button
type="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} onClick={props.state.onInstall}
disabled={props.state.installing} disabled={props.state.installing}
aria-busy={props.state.installing} aria-busy={props.state.installing}
aria-label={props.state.ariaLabel} 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 Update
</span> </span>
<span class="flex size-5 shrink-0 items-center justify-center"> <span class="flex size-5 shrink-0 items-center justify-center">
+5
View File
@@ -227,6 +227,11 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
return IS_MAC ? parts.join("") : parts.join("+") 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) { function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true if (target.isContentEditable) return true
+7 -6
View File
@@ -205,9 +205,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (server.key === key) navigate("/") if (server.key === key) navigate("/")
}, },
removeSessions: (input: SessionTabsRemovedDetail) => { removeSessions: (input: SessionTabsRemovedDetail) => {
const targetServer = input.server ?? server.key
const removed = store const removed = store
.filter( .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) .map(tabKey)
void startTransition(() => { void startTransition(() => {
@@ -215,28 +216,28 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
produce((tabs) => { produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs) const sessionIDs = new Set(input.sessionIDs)
const currentHref = const currentHref =
params.dir && params.id targetServer === server.key && params.dir && params.id
? tabHref({ ? tabHref({
type: "session", type: "session",
server: server.key, server: targetServer,
sessionId: params.id, sessionId: params.id,
}) })
: undefined : undefined
const currentIndex = currentHref const currentIndex = currentHref
? tabs.findIndex( ? tabs.findIndex(
(tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref, (tab) => tab.type === "session" && tab.server === targetServer && tabHref(tab) === currentHref,
) )
: -1 : -1
const currentTab = tabs[currentIndex] const currentTab = tabs[currentIndex]
const removedCurrent = const removedCurrent =
currentTab?.type === "session" && currentTab?.type === "session" &&
currentTab.server === server.key && currentTab.server === targetServer &&
sessionIDs.has(currentTab.sessionId) sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) { for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i] const tab = tabs[i]
if (!tab || tab.type !== "session") continue if (!tab || tab.type !== "session") continue
if (tab.server !== server.key) continue if (tab.server !== targetServer) continue
if (!sessionIDs.has(tab.sessionId)) continue if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1) tabs.splice(i, 1)
} }
+1
View File
@@ -592,6 +592,7 @@ export const dict = {
"home.server.collapse": "Collapse server projects", "home.server.collapse": "Collapse server projects",
"home.server.expand": "Expand server projects", "home.server.expand": "Expand server projects",
"home.sessions.search.placeholder": "Search sessions", "home.sessions.search.placeholder": "Search sessions",
"home.sessions.search.placeholder.scoped": "Search sessions in {{scope}}",
"home.sessions.search.sessions": "Sessions", "home.sessions.search.sessions": "Sessions",
"home.sessions.search.noResults": "No sessions found for {{query}}", "home.sessions.search.noResults": "No sessions found for {{query}}",
"home.sessions.empty": "No sessions found", "home.sessions.empty": "No sessions found",
+1
View File
@@ -507,6 +507,7 @@ export const dict = {
"home.projects": "项目", "home.projects": "项目",
"home.project.add": "添加项目", "home.project.add": "添加项目",
"home.sessions.search.placeholder": "搜索会话", "home.sessions.search.placeholder": "搜索会话",
"home.sessions.search.placeholder.scoped": "在 {{scope}} 中搜索会话",
"home.sessions.search.sessions": "会话", "home.sessions.search.sessions": "会话",
"home.sessions.search.noResults": "未找到与 {{query}} 相关的会话", "home.sessions.search.noResults": "未找到与 {{query}} 相关的会话",
"home.sessions.empty": "未找到会话", "home.sessions.empty": "未找到会话",
@@ -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))
}
+164 -84
View File
@@ -14,7 +14,7 @@ import {
Switch, Switch,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" 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 { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo" 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 { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-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 { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode" 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 { useDirectoryPicker } from "@/components/directory-picker"
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server" import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" 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 { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync" import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -43,6 +44,7 @@ import { useNotification } from "@/context/notification"
import { import {
closeHomeProject, closeHomeProject,
displayName, displayName,
errorMessage,
getProjectAvatarSource, getProjectAvatarSource,
homeProjectDirectories, homeProjectDirectories,
type HomeProjectSelection, type HomeProjectSelection,
@@ -55,12 +57,15 @@ import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { Binary } from "@opencode-ai/core/util/binary"
import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useMarked } from "@opencode-ai/ui/context/marked" import { useMarked } from "@opencode-ai/ui/context/marked"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" 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_SESSION_LIMIT = 64
const HOME_ROW_LAYOUT = const HOME_ROW_LAYOUT =
@@ -85,7 +90,7 @@ type HomeSessionGroup = {
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results" const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
const HOME_SEARCH_RESULT_ROW = 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 = 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]" "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 = const HOME_SEARCH_RESULT_META =
@@ -172,6 +177,19 @@ export function NewHome() {
return directories(project) return directories(project)
}) })
const search = createMemo(() => state.search.trim()) 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(() => ({ const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const, queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
queryFn: async () => { queryFn: async () => {
@@ -258,7 +276,7 @@ export function NewHome() {
command.register("home", () => [ command.register("home", () => [
{ {
id: "home.sessions.search.focus", id: "home.sessions.search.focus",
title: language.t("home.sessions.search.placeholder"), title: searchPlaceholder(),
keybind: "mod+f", keybind: "mod+f",
hidden: true, hidden: true,
onSelect: () => focusSessionSearch?.(), onSelect: () => focusSessionSearch?.(),
@@ -350,6 +368,30 @@ 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) { function chooseProject(conn: ServerConnection.Any) {
function resolve(result: string | string[] | null) { function resolve(result: string | string[] | null) {
addProjects(conn, homeProjectDirectories(result)) addProjects(conn, homeProjectDirectories(result))
@@ -402,10 +444,11 @@ export function NewHome() {
> >
<HomeSessionSearch <HomeSessionSearch
value={state.search} value={state.search}
placeholder={language.t("home.sessions.search.placeholder")} placeholder={searchPlaceholder()}
open={searchOpen()} open={searchOpen()}
loading={sessionLoad.isLoading} loading={sessionLoad.isLoading}
results={searchResults()} results={searchResults()}
showProjectName={!selectedProject()}
server={state.selection.server} server={state.selection.server}
activeServer={state.selection.server === server.key} activeServer={state.selection.server === server.key}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })} noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
@@ -446,9 +489,11 @@ export function NewHome() {
{(record) => ( {(record) => (
<HomeSessionRow <HomeSessionRow
record={record} record={record}
showProjectName={!selectedProject()}
server={state.selection.server} server={state.selection.server}
activeServer={state.selection.server === server.key} activeServer={state.selection.server === server.key}
onClick={() => openSession(record.session)} openSession={openSession}
archiveSession={archiveSession}
/> />
)} )}
</For> </For>
@@ -502,15 +547,17 @@ function HomeProjectColumn(props: {
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5"> <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> <div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show when={global.servers.list().length === 1}> <Show when={global.servers.list().length === 1}>
<IconButtonV2 <TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
data-action="home-add-project" <IconButtonV2
variant="ghost-muted" data-action="home-add-project"
size="large" variant="ghost-muted"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted" size="large"
icon={<IconV2 name="folder-add-left" />} class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
onClick={() => props.chooseProject(global.servers.list()[0]!)} icon={<IconV2 name="folder-add-left" />}
aria-label={props.language.t("home.project.add")} onClick={() => props.chooseProject(global.servers.list()[0]!)}
/> aria-label={props.language.t("home.project.add")}
/>
</TooltipV2>
</Show> </Show>
</div> </div>
<Show <Show
@@ -646,7 +693,7 @@ function HomeServerRow(props: {
</span> </span>
</button> </button>
<div <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="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={state.menuOpen} data-menu={state.menuOpen}
> >
<ServerRowMenu <ServerRowMenu
@@ -656,14 +703,20 @@ function HomeServerRow(props: {
open={state.menuOpen} open={state.menuOpen}
onOpenChange={(open) => setState("menuOpen", open)} onOpenChange={(open) => setState("menuOpen", open)}
/> />
<IconButtonV2 <TooltipV2
data-action="home-add-project" class="flex shrink-0 items-center"
variant="ghost-muted" placement="bottom"
size="small" value={props.language.t("home.project.add")}
icon={<IconV2 name="folder-add-left" />} >
aria-label={props.language.t("home.project.add")} <IconButtonV2
onClick={() => props.chooseProject(props.server)} 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>
</div> </div>
</div> </div>
) )
@@ -733,19 +786,11 @@ function HomeProjectRow(props: {
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span> <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</button> </button>
<div <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="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={state.menuOpen} 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 <MenuV2
gutter={4} gutter={6}
modal={false} modal={false}
placement="bottom-end" placement="bottom-end"
open={state.menuOpen} open={state.menuOpen}
@@ -765,7 +810,7 @@ function HomeProjectRow(props: {
{props.language.t("command.session.new")} {props.language.t("command.session.new")}
</MenuV2.Item> </MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}> <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>
<MenuV2.Item <MenuV2.Item
disabled={props.unseenCount === 0} disabled={props.unseenCount === 0}
@@ -780,6 +825,14 @@ function HomeProjectRow(props: {
</MenuV2.Content> </MenuV2.Content>
</MenuV2.Portal> </MenuV2.Portal>
</MenuV2> </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>
</div> </div>
) )
@@ -810,7 +863,7 @@ function HomeSessionLeading(props: {
<span <span
aria-hidden="true" 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" 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> </Show>
<SessionTabAvatar <SessionTabAvatar
@@ -829,6 +882,7 @@ function HomeSessionSearch(props: {
open: boolean open: boolean
loading: boolean loading: boolean
results: HomeSessionRecord[] results: HomeSessionRecord[]
showProjectName: boolean
server: ServerConnection.Key server: ServerConnection.Key
activeServer: boolean activeServer: boolean
noResultsLabel: string noResultsLabel: string
@@ -906,12 +960,12 @@ function HomeSessionSearch(props: {
) )
return ( 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"> <div ref={root} data-component="home-session-search" class="relative z-10 w-full">
<Show when={props.open}> <Show when={props.open}>
<div <div
data-component="home-session-search-panel" 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={{ style={{
top: "-6px", top: "-6px",
left: "-6px", left: "-6px",
@@ -919,7 +973,7 @@ function HomeSessionSearch(props: {
}} }}
> >
<div class="flex flex-col pt-9"> <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 <Show
when={!props.loading} when={!props.loading}
fallback={ fallback={
@@ -931,29 +985,32 @@ function HomeSessionSearch(props: {
<Show <Show
when={props.results.length > 0} when={props.results.length > 0}
fallback={ 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} {props.noResultsLabel}
</p> </p>
} }
> >
<div class="flex flex-col"> <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")} {language.t("home.sessions.search.sessions")}
</p> </p>
<div ref={listRef} class="flex max-h-80 flex-col gap-px overflow-y-auto"> <ScrollView class="max-h-80" viewportRef={(el) => (listRef = el)}>
<For each={props.results}> <div class="flex flex-col gap-px pb-2">
{(record) => ( <For each={props.results}>
<HomeSessionSearchResultRow {(record) => (
record={record} <HomeSessionSearchResultRow
server={props.server} record={record}
activeServer={props.activeServer} showProjectName={props.showProjectName}
selected={store.active === homeSessionSearchKey(record)} server={props.server}
onHighlight={() => setStore("active", homeSessionSearchKey(record))} activeServer={props.activeServer}
onSelect={(session) => props.onSelect(session)} selected={store.active === homeSessionSearchKey(record)}
/> onHighlight={() => setStore("active", homeSessionSearchKey(record))}
)} onSelect={(session) => props.onSelect(session)}
</For> />
</div> )}
</For>
</div>
</ScrollView>
</div> </div>
</Show> </Show>
</Show> </Show>
@@ -962,11 +1019,10 @@ function HomeSessionSearch(props: {
</div> </div>
</Show> </Show>
<label <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={{ 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)]": "focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]": !props.open,
!props.open, "shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
"bg-transparent shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
}} }}
> >
<IconV2 name="magnifying-glass" /> <IconV2 name="magnifying-glass" />
@@ -1031,6 +1087,7 @@ function HomeSessionSearch(props: {
function HomeSessionSearchResultRow(props: { function HomeSessionSearchResultRow(props: {
record: HomeSessionRecord record: HomeSessionRecord
showProjectName: boolean
server: ServerConnection.Key server: ServerConnection.Key
activeServer: boolean activeServer: boolean
selected: boolean selected: boolean
@@ -1038,6 +1095,7 @@ function HomeSessionSearchResultRow(props: {
onSelect: (session: Session) => void onSelect: (session: Session) => void
}) { }) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName && props.record.projectName
const key = () => homeSessionSearchKey(props.record) const key = () => homeSessionSearchKey(props.record)
@@ -1064,11 +1122,11 @@ function HomeSessionSearchResultRow(props: {
/> />
<div class="flex min-w-0 flex-1 items-center gap-1.5"> <div class="flex min-w-0 flex-1 items-center gap-1.5">
<span <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()} {title()}
</span> </span>
<Show when={props.record.projectName}> <Show when={showProjectName()}>
<span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span> <span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
</Show> </Show>
</div> </div>
@@ -1079,7 +1137,7 @@ function HomeSessionSearchResultRow(props: {
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) { function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
const language = useLanguage() const language = useLanguage()
return ( 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> <div class={HOME_SECTION_LABEL}>{props.title}</div>
<Show when={props.onNewSession}> <Show when={props.onNewSession}>
{(onNewSession) => ( {(onNewSession) => (
@@ -1101,36 +1159,58 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
function HomeSessionRow(props: { function HomeSessionRow(props: {
record: HomeSessionRecord record: HomeSessionRecord
showProjectName: boolean
server: ServerConnection.Key server: ServerConnection.Key
activeServer: boolean 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 title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName && props.record.projectName
return ( return (
<button <div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]">
type="button" <button
data-component="home-session-row" type="button"
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`} data-component="home-session-row"
onClick={props.onClick} class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
> onClick={() => props.openSession(props.record.session)}
<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]"}`}
> >
{title()} <HomeSessionLeading
</span> project={props.record.project}
<Show when={props.record.projectName}> session={props.record.session}
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"> server={props.server}
{props.record.projectName} 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> </span>
</Show> <Show when={showProjectName()}>
</button> <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="absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity 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>
) )
} }
+2 -2
View File
@@ -130,8 +130,8 @@
} }
&[data-size="x-large"] [data-slot="dialog-container"] { &[data-size="x-large"] [data-slot="dialog-container"] {
width: min(calc(100vw - 32px), 960px); width: min(calc(100vw - 32px), 980px);
height: min(calc(100vh - 32px), 600px); height: min(calc(100vh - 92px), 600px);
} }
} }
+5 -1
View File
@@ -1,10 +1,14 @@
.scroll-view { .scroll-view {
position: relative; position: relative;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden; overflow: hidden;
} }
.scroll-view__viewport { .scroll-view__viewport {
height: 100%; flex: 1 1 auto;
min-height: 0;
width: 100%; width: 100%;
overflow-y: auto; overflow-y: auto;
scrollbar-width: none; scrollbar-width: none;
+1 -1
View File
@@ -416,7 +416,7 @@
"v2-overlay-simple-overlay-pressed": "var(--v2-alpha-light-10)", "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-hover": "var(--v2-alpha-dark-24)",
"v2-overlay-simple-overlay-contrast-pressed": "var(--v2-alpha-dark-40)", "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-top": "var(--v2-alpha-light-100)",
"v2-overlay-gradient-depth-overlay-depth-bot": "var(--v2-alpha-light-0)", "v2-overlay-gradient-depth-overlay-depth-bot": "var(--v2-alpha-light-0)",
"v2-overlay-simple-tab-active-scrim": "#24242400", "v2-overlay-simple-tab-active-scrim": "#24242400",
+1 -1
View File
@@ -90,7 +90,7 @@ const dark: Record<string, V2ColorValue> = {
"v2-overlay-simple-overlay-pressed": ref("v2-alpha-light-10"), "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-hover": ref("v2-alpha-dark-24"),
"v2-overlay-simple-overlay-contrast-pressed": ref("v2-alpha-dark-40"), "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-top": ref("v2-alpha-light-100"),
"v2-overlay-gradient-depth-overlay-depth-bot": ref("v2-alpha-light-0"), "v2-overlay-gradient-depth-overlay-depth-bot": ref("v2-alpha-light-0"),
"v2-overlay-simple-tab-active-scrim": "#24242400", "v2-overlay-simple-tab-active-scrim": "#24242400",
+2 -2
View File
@@ -144,7 +144,7 @@
} }
&[data-size="x-large"] [data-slot="dialog-container"] { &[data-size="x-large"] [data-slot="dialog-container"] {
width: 800px; width: min(calc(100vw - 32px), 980px);
height: 560px; height: min(calc(100vh - 92px), 600px);
} }
} }
@@ -112,9 +112,7 @@
color: var(--v2-text-text-base); color: var(--v2-text-text-base);
} }
[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled):not( [data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"], [data-expanded]):not(:disabled) {
[data-expanded]
) {
background-color: var(--v2-overlay-simple-overlay-hover); background-color: var(--v2-overlay-simple-overlay-hover);
} }
@@ -133,9 +131,7 @@
color: var(--v2-icon-icon-muted); color: var(--v2-icon-icon-muted);
} }
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not( [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"], [data-expanded]):not(:disabled) {
[data-expanded]
) {
background-color: var(--v2-overlay-simple-overlay-hover); background-color: var(--v2-overlay-simple-overlay-hover);
} }
+4
View File
@@ -65,6 +65,10 @@ const icons = {
viewBox: "0 0 16 16", 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"/>`, 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" const spriteID = "opencode-v2-icon-sprite"
@@ -120,7 +120,7 @@
flex-shrink: 0; flex-shrink: 0;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 22px; width: 16px;
height: 22px; height: 16px;
overflow: visible; overflow: visible;
} }
+1 -1
View File
@@ -134,7 +134,7 @@
font-style: normal; font-style: normal;
font-weight: 440; font-weight: 440;
font-size: 13px; font-size: 13px;
line-height: 1; line-height: 16px;
letter-spacing: -0.04px; letter-spacing: -0.04px;
color: var(--v2-text-text-base); color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0; font-variation-settings: "slnt" 0;
+3 -1
View File
@@ -45,6 +45,7 @@
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
user-select: none;
} }
[data-component="tabs-v2"] [data-slot="tabs-v2-trigger"] { [data-component="tabs-v2"] [data-slot="tabs-v2-trigger"] {
@@ -54,6 +55,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
outline: none; outline: none;
user-select: none;
font-size: 13px; font-size: 13px;
font-weight: 440; font-weight: 440;
@@ -61,7 +63,7 @@
letter-spacing: -0.04px; 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; outline: none;
box-shadow: none; box-shadow: none;
} }
+1 -1
View File
@@ -370,7 +370,7 @@
--v2-overlay-simple-overlay-pressed: var(--v2-alpha-light-10); --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-hover: var(--v2-alpha-dark-24);
--v2-overlay-simple-overlay-contrast-pressed: var(--v2-alpha-dark-40); --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-top: var(--v2-alpha-light-100);
--v2-overlay-gradient-depth-overlay-depth-bot: var(--v2-alpha-light-0); --v2-overlay-gradient-depth-overlay-depth-bot: var(--v2-alpha-light-0);
--v2-overlay-simple-tab-active-scrim: #24242400; --v2-overlay-simple-tab-active-scrim: #24242400;