Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/app/src/components/command-tooltip-keybind.test.ts
Original file line number Diff line number Diff line change
@@ -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"])
})
})
11 changes: 11 additions & 0 deletions packages/app/src/components/command-tooltip-keybind.ts
Original file line number Diff line number Diff line change
@@ -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")
}
8 changes: 6 additions & 2 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
}
Expand Down Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/components/server/server-row-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/components/server/server-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 19 additions & 4 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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}>
Expand All @@ -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"
Expand All @@ -548,7 +563,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />}
/>
</TooltipKeybind>
</TooltipV2>
</Show>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"],
})
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/components/titlebar-session-events.ts
Original file line number Diff line number Diff line change
@@ -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[]
}
Expand All @@ -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,
}
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/components/titlebar-tab-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 }}
data-active={props.active}
data-dragging={props.dragging}
Expand Down Expand Up @@ -292,10 +293,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 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 }}
onMouseDown={(event) => {
if (event.button !== 1) return
Expand Down
26 changes: 26 additions & 0 deletions packages/app/src/components/titlebar.css
Original file line number Diff line number Diff line change
@@ -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 {
from {
visibility: hidden;
Expand Down
36 changes: 24 additions & 12 deletions packages/app/src/components/titlebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -459,15 +461,25 @@ 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()} />
Expand Down Expand Up @@ -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">
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/context/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions packages/app/src/context/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,38 +205,39 @@ 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(() => {
setStore(
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)
}
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ 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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ 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": "未找到会话",
Expand Down
Loading
Loading