mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 22:26:12 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d3237cb9b | ||
|
|
f28d206589 | ||
|
|
5ad6701f47 | ||
|
|
1e17856ba4 | ||
|
|
a6f7fe739c | ||
|
|
02f50a6e80 | ||
|
|
7565e03536 | ||
|
|
035a5a1a67 | ||
|
|
f77f5a343e | ||
|
|
985ee1e2ec | ||
|
|
83bee1e776 | ||
|
|
124714ca3a |
@@ -339,7 +339,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.12.0",
|
||||
"gitlab-ai-provider": "6.12.1",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -632,7 +632,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.12.0",
|
||||
"gitlab-ai-provider": "6.12.1",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -3855,7 +3855,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-uGan7MQaNfmV5SH9wiMJkZ7D5QxM4KVPRcjIHtPCM5icZjgXl5mrHMjr3/e7TAHa9OjEJKT0a2FxzPgwRtN3PQ=="],
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-/ZtluLolVq1ZFAYgbDScze9GZR0NF9baRQXvfEoOnkI=",
|
||||
"aarch64-linux": "sha256-kdh7Dpira7cx9lvtozFsjLrDqMBrUMcgxkhfsqrutws=",
|
||||
"aarch64-darwin": "sha256-chPeMCX+8hvOQ32hTANIn7FmONsSv9t3dKTWTaHerjs=",
|
||||
"x86_64-darwin": "sha256-rMc0dDZB+YbXrjYwXTIt7QvHUxSp//wDJQ5hUdR+LYM="
|
||||
"x86_64-linux": "sha256-Z/YgK5sf0L9MG7hMIb+wj3ias6iNo8L4oFHYNtRHreM=",
|
||||
"aarch64-linux": "sha256-MoYZmeh6RcDUN7a132wexFNwkHz+Q9Ijs+qiacuKJ90=",
|
||||
"aarch64-darwin": "sha256-OHo3x7UcJzrPMAF9Ij/9Rhamd+dfpb5QuxP89cy7Rw4=",
|
||||
"x86_64-darwin": "sha256-ElzZVqkQPYTAd0nvLCB5uel9xtOqMK07QFW8MDr174E="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
delay="intent"
|
||||
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||
value={
|
||||
<ModelTooltip
|
||||
|
||||
@@ -460,7 +460,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
delay="intent"
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -52,12 +52,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
||||
<For each={props.comments ?? []}>
|
||||
{(item) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2
|
||||
value={item.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||
<CommentCardV2
|
||||
comment={item.comment ?? ""}
|
||||
path={item.path}
|
||||
|
||||
@@ -9,53 +9,86 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] {
|
||||
--tab-base: var(--v2-background-bg-deep);
|
||||
--tab-overlay: transparent;
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
|
||||
[data-dragging="true"]
|
||||
):not([data-editing="true"]) {
|
||||
--tab-base: var(--v2-background-bg-layer-02);
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) {
|
||||
--tab-base: var(--v2-background-bg-layer-02);
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-list] {
|
||||
gap: 13.5px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot] {
|
||||
--tab-separator: var(--v2-background-bg-layer-03);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-titlebar-tab-slot] {
|
||||
--tab-separator: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: -6.75px;
|
||||
left: -3.75px;
|
||||
width: 1.5px;
|
||||
height: 12px;
|
||||
border-radius: 9999px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
background: var(--tab-separator);
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-close"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 28px;
|
||||
width: 16px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to right, transparent, var(--tab-bg));
|
||||
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before,
|
||||
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
|
||||
display: block;
|
||||
right: 0;
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: 4px;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
black 0,
|
||||
black calc(100% - var(--tab-title-fade-offset) - 16px),
|
||||
transparent calc(100% - var(--tab-title-fade-offset)),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
black 0,
|
||||
black calc(100% - var(--tab-title-fade-offset) - 16px),
|
||||
transparent calc(100% - var(--tab-title-fade-offset)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
|
||||
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
|
||||
[data-slot="tab-close"]::before {
|
||||
right: 100%;
|
||||
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
|
||||
[data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: 24px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
|
||||
|
||||
@@ -180,11 +180,11 @@ export function TabNavItem(props: {
|
||||
data-slot="titlebar-tab-item"
|
||||
data-title-overflow={titleOverflowing()}
|
||||
data-editing={editing()}
|
||||
class="group relative flex h-7 w-full min-w-0 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)]"
|
||||
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
data-state={props.active || props.pressed ? "pressed" : undefined}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||
event.preventDefault()
|
||||
@@ -276,7 +276,7 @@ export function TabNavItem(props: {
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<div data-slot="tab-close">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
@@ -334,8 +334,8 @@ export function DraftTabItem(props: {
|
||||
data-slot="titlebar-tab-item"
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [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']:[--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)]"
|
||||
data-state={props.active || props.pressed ? "pressed" : undefined}
|
||||
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
|
||||
classList={{ invisible: props.hidden }}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||
@@ -381,7 +381,7 @@ export function DraftTabItem(props: {
|
||||
{props.title}
|
||||
</span>
|
||||
</a>
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<div data-slot="tab-close">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
|
||||
@@ -338,6 +338,22 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
keybind: "mod+shift+t",
|
||||
onSelect: () => tabsStoreActions.reopenClosedTab(),
|
||||
},
|
||||
{
|
||||
id: `tab.prev`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
||||
hidden: true,
|
||||
onSelect: tabs.previous,
|
||||
},
|
||||
{
|
||||
id: `tab.next`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
||||
hidden: true,
|
||||
onSelect: tabs.next,
|
||||
},
|
||||
].filter((v) => v !== undefined)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||
|
||||
function history(): TabHistory {
|
||||
return { stack: [], index: -1 }
|
||||
}
|
||||
|
||||
describe("tab history", () => {
|
||||
test("moves backward and forward through selected tabs", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
const available = new Set(selected.stack)
|
||||
|
||||
const previous = previousTab(selected, available)
|
||||
expect(previous?.key).toBe("b")
|
||||
|
||||
const first = previousTab(previous!.state, available)
|
||||
expect(first?.key).toBe("a")
|
||||
|
||||
const next = nextTab(first!.state, available)
|
||||
expect(next?.key).toBe("b")
|
||||
})
|
||||
|
||||
test("replaces forward history after a new selection", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
const previous = previousTab(selected, new Set(selected.stack))
|
||||
const next = rememberTab(previous!.state, "d")
|
||||
|
||||
expect(next).toEqual({ stack: ["a", "b", "d"], index: 2 })
|
||||
expect(nextTab(next, new Set(next.stack))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("skips tabs that are no longer open", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
|
||||
expect(previousTab(selected, new Set(["a", "c"]))?.key).toBe("a")
|
||||
})
|
||||
|
||||
test("skips a repeated current tab after closing the previous selection", () => {
|
||||
const selected = ["a", "b", "c", "b"].reduce(rememberTab, history())
|
||||
|
||||
expect(previousTab(selected, new Set(["a", "b"]))?.key).toBe("a")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
const MAX_TAB_HISTORY = 100
|
||||
|
||||
export type TabHistory = {
|
||||
stack: string[]
|
||||
index: number
|
||||
}
|
||||
|
||||
export function rememberTab(state: TabHistory, key: string): TabHistory {
|
||||
if (state.stack[state.index] === key) return state
|
||||
const stack = state.stack.slice(0, state.index + 1).concat(key).slice(-MAX_TAB_HISTORY)
|
||||
return { stack, index: stack.length - 1 }
|
||||
}
|
||||
|
||||
export function previousTab(state: TabHistory, available: Set<string>) {
|
||||
return move(state, -1, available)
|
||||
}
|
||||
|
||||
export function nextTab(state: TabHistory, available: Set<string>) {
|
||||
return move(state, 1, available)
|
||||
}
|
||||
|
||||
function move(state: TabHistory, offset: -1 | 1, available: Set<string>) {
|
||||
const current = state.stack[state.index]
|
||||
for (let index = state.index + offset; index >= 0 && index < state.stack.length; index += offset) {
|
||||
const key = state.stack[index]
|
||||
if (key && key !== current && available.has(key)) return { state: { ...state, index }, key }
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { sessionHref } from "@/utils/session-route"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
@@ -79,6 +80,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const memory = createTabMemory(getOwner())
|
||||
|
||||
const closing = new Set<string>()
|
||||
let history: TabHistory = { stack: [], index: -1 }
|
||||
let recentWrite = 0
|
||||
let recentValue: string | undefined
|
||||
|
||||
@@ -150,10 +152,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
|
||||
const navigateTab = (tab: Tab) => {
|
||||
const href = tabHref(tab)
|
||||
history = rememberTab(history, tabKey(tab))
|
||||
setRecentKey(tabKey(tab))
|
||||
navigate(href)
|
||||
}
|
||||
|
||||
const moveHistory = (direction: "previous" | "next") => {
|
||||
const available = new Set(store.map(tabKey))
|
||||
const result = direction === "previous" ? previousTab(history, available) : nextTab(history, available)
|
||||
if (!result) return
|
||||
const tab = store.find((item) => tabKey(item) === result.key)
|
||||
if (!tab) return
|
||||
history = result.state
|
||||
navigateTab(tab)
|
||||
}
|
||||
|
||||
const removeTab = (index: number) => {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
@@ -359,8 +372,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
select: navigateTab,
|
||||
remember(tab: Tab) {
|
||||
const key = tabKey(tab)
|
||||
history = rememberTab(history, key)
|
||||
if (recentKey() !== key) setRecentKey(key)
|
||||
},
|
||||
previous: () => moveHistory("previous"),
|
||||
next: () => moveHistory("next"),
|
||||
toggleHome(input: { home: boolean; current?: Tab }) {
|
||||
if (input.home) {
|
||||
const tab = store.find((tab) => tabKey(tab) === recentKey())
|
||||
|
||||
@@ -2,6 +2,17 @@ import { describe, expect, test } from "bun:test"
|
||||
import { DESKTOP_MENU } from "./desktop-menu"
|
||||
|
||||
describe("desktop menu", () => {
|
||||
test("navigates between tabs", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && (item.label === "Previous Tab" || item.label === "Next Tab"),
|
||||
)
|
||||
|
||||
expect(items).toEqual([
|
||||
{ type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
|
||||
{ type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("exports logs through the desktop command registry", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && item.label === "Export Logs...",
|
||||
|
||||
@@ -168,8 +168,8 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
|
||||
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
|
||||
{ type: "separator" },
|
||||
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
|
||||
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
|
||||
{ type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
|
||||
{ type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } },
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
|
||||
@@ -143,7 +143,6 @@ function ProviderTip() {
|
||||
<TooltipV2
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={language.t("common.dismiss")}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -167,41 +167,4 @@ describe("current session timeline rows", () => {
|
||||
"thinking:msg_2",
|
||||
])
|
||||
})
|
||||
|
||||
test("removes a failed assistant error when the turn continues streaming", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "recover", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_failed",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
error: { type: "ProviderError", message: "temporary failure" },
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_recovery",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "streaming again" }],
|
||||
time: { created: 4 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
(messageID) => messages.get(messageID),
|
||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||
true,
|
||||
"busy",
|
||||
true,
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
)
|
||||
|
||||
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,8 +116,7 @@ export namespace Timeline {
|
||||
const compaction = userParts.some((p) => p.type === "compaction")
|
||||
const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError")
|
||||
const interrupted = interruptedMessageIndex !== -1
|
||||
const latestError = assistantMessages.at(-1)?.error
|
||||
const error = latestError?.name === "MessageAbortedError" ? undefined : latestError
|
||||
const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error
|
||||
|
||||
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
|
||||
getMessageParts(message.id)
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.12.0",
|
||||
"gitlab-ai-provider": "6.12.1",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.12.0",
|
||||
"gitlab-ai-provider": "6.12.1",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
|
||||
@@ -32,7 +32,6 @@ export function CommentCardV2(props: {
|
||||
return (
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={props.title ?? props.comment}
|
||||
disabled={!props.tooltip || !truncated()}
|
||||
class={props.wide ? "w-full" : undefined}
|
||||
|
||||
@@ -386,12 +386,7 @@ export function PromptInputV2Attachments(props: {
|
||||
<For each={props.comments ?? []}>
|
||||
{(comment) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2
|
||||
value={comment.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<TooltipV2 value={comment.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||
<CommentCardV2
|
||||
comment={comment.comment ?? ""}
|
||||
path={comment.path}
|
||||
|
||||
@@ -214,7 +214,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
</Show>
|
||||
<div class="flex items-center">
|
||||
<TooltipV2
|
||||
openDelay={2000}
|
||||
inactive={!prev()}
|
||||
value={
|
||||
<>
|
||||
@@ -234,7 +233,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
/>
|
||||
</TooltipV2>
|
||||
<TooltipV2
|
||||
openDelay={2000}
|
||||
inactive={!next()}
|
||||
value={
|
||||
<>
|
||||
@@ -268,12 +266,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
||||
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
|
||||
>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<Icon name="expand" />
|
||||
</SegmentedControlItemV2>
|
||||
</TooltipV2>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<Icon name="collapse" />
|
||||
</SegmentedControlItemV2>
|
||||
@@ -289,12 +287,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
||||
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
|
||||
>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<Icon name="unified" />
|
||||
</SegmentedControlItemV2>
|
||||
</TooltipV2>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<Icon name="split" />
|
||||
</SegmentedControlItemV2>
|
||||
|
||||
@@ -104,7 +104,6 @@ const appGlobalBindingCommands = [
|
||||
] as const
|
||||
|
||||
const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
"model.list",
|
||||
"model.cycle_recent",
|
||||
"model.cycle_recent_reverse",
|
||||
@@ -963,6 +962,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
commands: appCommands(),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: () => dialog.stack.length === 0,
|
||||
bindings: tuiConfig.keybinds.get(COMMAND_PALETTE_COMMAND),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
|
||||
@@ -5,7 +5,13 @@ import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { TuiKeybind } from "../src/config/keybind"
|
||||
import { getOpencodeModeStack, OPENCODE_BASE_MODE, OpencodeKeymapProvider, registerOpencodeKeymap } from "../src/keymap"
|
||||
import {
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
getOpencodeModeStack,
|
||||
OPENCODE_BASE_MODE,
|
||||
OpencodeKeymapProvider,
|
||||
registerOpencodeKeymap,
|
||||
} from "../src/keymap"
|
||||
|
||||
function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
|
||||
const keybinds = TuiKeybind.parse(input)
|
||||
@@ -73,12 +79,14 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||
const offGlobal = keymap.registerLayer({
|
||||
commands: [
|
||||
{ name: COMMAND_PALETTE_COMMAND, run() {} },
|
||||
{ name: "session.list", run() {} },
|
||||
{ name: "session.new", run() {} },
|
||||
{ name: "session.page.up", run() {} },
|
||||
{ name: "session.first", run() {} },
|
||||
],
|
||||
bindings: config.keybinds.gather("test.global", [
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.page.up",
|
||||
@@ -95,7 +103,14 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
Array.from(
|
||||
keymap.getCommandBindings({
|
||||
visibility: "active",
|
||||
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
|
||||
commands: [
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.page.up",
|
||||
"session.first",
|
||||
"model.list",
|
||||
],
|
||||
}),
|
||||
([command, bindings]) => [command, bindings.length],
|
||||
),
|
||||
@@ -125,9 +140,24 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const app = await testRender(() => <Harness />)
|
||||
try {
|
||||
expect(counts).toEqual({
|
||||
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
|
||||
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
|
||||
base: {
|
||||
[COMMAND_PALETTE_COMMAND]: 1,
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
"session.first": 2,
|
||||
"model.list": 1,
|
||||
},
|
||||
question: {
|
||||
[COMMAND_PALETTE_COMMAND]: 1,
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
"session.first": 2,
|
||||
"model.list": 0,
|
||||
},
|
||||
autocomplete: {
|
||||
[COMMAND_PALETTE_COMMAND]: 1,
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const OPEN_DELAY = 1_000
|
||||
|
||||
// Kobalte warms every tooltip globally. Keep intent previews isolated so opening
|
||||
// a model picker never inherits warm state from an unrelated tooltip.
|
||||
let warm = false
|
||||
let reset: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
export function openTooltipIntent(open: () => void) {
|
||||
clearTimeout(reset)
|
||||
reset = undefined
|
||||
if (warm) {
|
||||
open()
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
warm = true
|
||||
open()
|
||||
}, OPEN_DELAY)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
|
||||
export function closeTooltipIntent() {
|
||||
clearTimeout(reset)
|
||||
// Adjacent triggers enter before the next task; leaving the group does not.
|
||||
reset = setTimeout(() => {
|
||||
warm = false
|
||||
reset = undefined
|
||||
})
|
||||
}
|
||||
|
||||
export function resetTooltipIntent() {
|
||||
clearTimeout(reset)
|
||||
reset = undefined
|
||||
warm = false
|
||||
}
|
||||
@@ -2,19 +2,22 @@ import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"
|
||||
import { createEffect, Match, onCleanup, splitProps, Switch, type JSX } from "solid-js"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { closeTooltipIntent, openTooltipIntent, resetTooltipIntent } from "./tooltip-intent"
|
||||
import "./tooltip-v2.css"
|
||||
|
||||
export interface TooltipV2Props extends ComponentProps<typeof KobalteTooltip> {
|
||||
export interface TooltipV2Props extends Omit<ComponentProps<typeof KobalteTooltip>, "openDelay"> {
|
||||
value: JSX.Element
|
||||
class?: string
|
||||
contentClass?: string
|
||||
contentStyle?: JSX.CSSProperties
|
||||
inactive?: boolean
|
||||
delay?: "standard" | "intent"
|
||||
forceOpen?: boolean
|
||||
}
|
||||
|
||||
export function TooltipV2(props: TooltipV2Props) {
|
||||
let ref: HTMLDivElement | undefined
|
||||
let cancelIntent: (() => void) | undefined
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
block: false,
|
||||
@@ -26,19 +29,37 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
"contentClass",
|
||||
"contentStyle",
|
||||
"inactive",
|
||||
"delay",
|
||||
"forceOpen",
|
||||
"ignoreSafeArea",
|
||||
"value",
|
||||
])
|
||||
|
||||
const close = () => setState("open", false)
|
||||
|
||||
const inside = () => {
|
||||
const active = document.activeElement
|
||||
if (!ref || !active) return false
|
||||
return ref.contains(active)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
cancelIntent?.()
|
||||
cancelIntent = undefined
|
||||
if (local.delay === "intent") closeTooltipIntent()
|
||||
setState("open", false)
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
if (local.delay !== "intent" || inside()) {
|
||||
setState("open", true)
|
||||
return
|
||||
}
|
||||
if (cancelIntent) return
|
||||
cancelIntent = openTooltipIntent(() => {
|
||||
cancelIntent = undefined
|
||||
setState("open", true)
|
||||
})
|
||||
}
|
||||
|
||||
const drop = (expand = state.expand) => {
|
||||
if (expand) return
|
||||
if (ref?.matches(":hover")) return
|
||||
@@ -80,6 +101,11 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
onCleanup(() => obs.disconnect())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
cancelIntent?.()
|
||||
if (local.delay === "intent") resetTooltipIntent()
|
||||
})
|
||||
|
||||
let justClickedTrigger = false
|
||||
|
||||
return (
|
||||
@@ -88,8 +114,8 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
<Match when={true}>
|
||||
<KobalteTooltip
|
||||
gutter={4}
|
||||
openDelay={400}
|
||||
skipDelayDuration={300}
|
||||
openDelay={local.delay === "intent" ? 0 : 400}
|
||||
skipDelayDuration={local.delay === "intent" ? 0 : 300}
|
||||
{...others}
|
||||
closeDelay={0}
|
||||
ignoreSafeArea={local.ignoreSafeArea ?? true}
|
||||
@@ -101,7 +127,11 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
justClickedTrigger = false
|
||||
return
|
||||
}
|
||||
setState("open", open)
|
||||
if (open) {
|
||||
show()
|
||||
return
|
||||
}
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<KobalteTooltip.Trigger
|
||||
|
||||
@@ -54,6 +54,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
|
||||
| [opencode-firecrawl](https://github.com/firecrawl/opencode-firecrawl) | Web scraping, crawling, and search via the Firecrawl CLI |
|
||||
| [opencode-jfrog-plugin](https://github.com/jfrog/opencode-jfrog-plugin) | JFrog Plugin for seamless integration of Opencode users to JFrog platform |
|
||||
| [opencode-goal-plugin](https://github.com/willytop8/OpenCode-goal-plugin) | Session-scoped `/goal` workflow that keeps objectives in context and auto-continues until complete |
|
||||
| [opencode-tavily](https://github.com/tavily-ai/opencode-tavily) | Web search, extraction, crawling, and deep research via the Tavily CLI |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user