mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1140d8422e | |||
| 9ecfb847fa | |||
| 588288ce87 | |||
| 0156d3028f | |||
| 5e5e305bb7 | |||
| d87f987800 | |||
| 545b86587d | |||
| 7fb3fbdf27 | |||
| 31f946eed7 | |||
| 45daa7fece | |||
| a5bbe636e7 | |||
| 2e1ce87bf3 | |||
| 56e8a4f7d7 | |||
| b3a012cbdd | |||
| 51b9c726cf | |||
| b4e49d5b32 | |||
| 2b29854589 | |||
| 8ce5038bd8 | |||
| 446510a6a9 | |||
| 49028b6245 | |||
| d595c7e265 | |||
| 17cd4a8c32 | |||
| 9bbf7918de | |||
| 97f502e79a | |||
| ac5e249083 | |||
| 140e796b21 | |||
| dd02cea9e7 | |||
| a8062ea314 | |||
| f47684787a | |||
| 8168f0f0f6 | |||
| 8036440693 | |||
| cf7503687a | |||
| 6f8e1dda15 | |||
| 4dcfd9182c | |||
| d7c0db8cee | |||
| 184da0e42e | |||
| a244d82aba | |||
| 34e5809059 | |||
| 2db96c9b7e | |||
| 9976269ab1 | |||
| 5df822be11 | |||
| 233f069070 | |||
| 4a1982f5c9 | |||
| 1c7f65f105 | |||
| a84151e482 | |||
| 0340a4ff77 | |||
| f4faacbe87 | |||
| 7dbfd0b934 | |||
| 08a2a623dc | |||
| 60c88336b9 | |||
| 8a03fc265b | |||
| d0ba538924 | |||
| 772e976d80 | |||
| c769380776 | |||
| 01131c75d8 | |||
| ea982c3832 | |||
| d09d5612f3 | |||
| 85ef35dd64 | |||
| 251177d56c | |||
| 13b9e55c72 | |||
| b94e55c7c0 | |||
| 9c6bb4ff72 | |||
| c2e019ba7d | |||
| b8374b5a7c | |||
| b4665a8bf8 | |||
| 08096b5e61 |
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
|
|||||||
const TEAM = {
|
const TEAM = {
|
||||||
tui: ["kommander", "simonklee"],
|
tui: ["kommander", "simonklee"],
|
||||||
desktop_web: ["Hona", "Brendonovich"],
|
desktop_web: ["Hona", "Brendonovich"],
|
||||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
|
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
||||||
inference: ["fwang", "MrMushrooooom", "starptech"],
|
inference: ["fwang", "MrMushrooooom", "starptech"],
|
||||||
windows: ["Hona"],
|
windows: ["Hona"],
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/* This file is auto-generated by SST. Do not edit. */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/* deno-fmt-ignore-file */
|
||||||
|
/* biome-ignore-all lint: auto-generated */
|
||||||
|
|
||||||
|
/// <reference path="../../sst-env.d.ts" />
|
||||||
|
|
||||||
|
import "sst"
|
||||||
|
export {}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@corvu/drawer": "catalog:",
|
"@corvu/drawer": "catalog:",
|
||||||
"@dnd-kit/abstract": "0.5.0",
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lildax": "./bin/lildax.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
},
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "8.15.0",
|
"acorn": "8.15.0",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -266,7 +266,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
@@ -286,7 +286,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -380,7 +380,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -434,7 +434,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -448,7 +448,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
"packages/effect-sqlite-node": {
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -492,7 +492,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -508,7 +508,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||||
@@ -539,7 +539,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
@@ -558,7 +558,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -689,7 +689,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -765,7 +765,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -780,7 +780,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
@@ -795,7 +795,7 @@
|
|||||||
},
|
},
|
||||||
"packages/session-ui": {
|
"packages/session-ui": {
|
||||||
"name": "@opencode-ai/session-ui",
|
"name": "@opencode-ai/session-ui",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -839,7 +839,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -852,7 +852,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
"@opencode-ai/stats-core": "workspace:*",
|
"@opencode-ai/stats-core": "workspace:*",
|
||||||
@@ -885,7 +885,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-athena": "3.933.0",
|
"@aws-sdk/client-athena": "3.933.0",
|
||||||
"@planetscale/database": "1.19.0",
|
"@planetscale/database": "1.19.0",
|
||||||
@@ -904,7 +904,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -945,7 +945,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -972,7 +972,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
@@ -1023,7 +1023,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
|
|||||||
configuration: {
|
configuration: {
|
||||||
enforceWorkgroupConfiguration: true,
|
enforceWorkgroupConfiguration: true,
|
||||||
publishCloudwatchMetricsEnabled: true,
|
publishCloudwatchMetricsEnabled: true,
|
||||||
|
// Athena bills $5/TB scanned; kill any query that would scan more than 2 TB
|
||||||
|
// so a regression cannot silently burn money. Stats sync full passes scan
|
||||||
|
// ~250 GB as of 2026-07.
|
||||||
|
bytesScannedCutoffPerQuery: 2 * 1024 ** 4,
|
||||||
resultConfiguration: {
|
resultConfiguration: {
|
||||||
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
|
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-1
@@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
|
|||||||
cluster: lakeCluster,
|
cluster: lakeCluster,
|
||||||
architecture: "arm64",
|
architecture: "arm64",
|
||||||
cpu: "0.25 vCPU",
|
cpu: "0.25 vCPU",
|
||||||
memory: "0.5 GB",
|
// 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena
|
||||||
|
// stats queries (~$5/pass) every ~5 minutes instead of hourly.
|
||||||
|
memory: "2 GB",
|
||||||
image: {
|
image: {
|
||||||
context: ".",
|
context: ".",
|
||||||
dockerfile: "packages/stats/server/Dockerfile",
|
dockerfile: "packages/stats/server/Dockerfile",
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/FileBrowserSidebar"
|
||||||
|
const projectID = "proj_file_browser_sidebar"
|
||||||
|
const sessionID = "ses_file_browser_sidebar"
|
||||||
|
const title = "File browser sidebar"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
|
||||||
|
// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
|
||||||
|
const PROBE = "original"
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
|
// The file-browser sidebar must stay mounted across preview/pinned file-tab
|
||||||
|
// switches. Remounting resets scroll and filter state.
|
||||||
|
test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
|
||||||
|
await setup(page)
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const panel = page.locator("#review-panel")
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
|
||||||
|
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
||||||
|
await expect(sidebar).toBeVisible()
|
||||||
|
await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "file-00.ts" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||||
|
await viewport.hover()
|
||||||
|
await page.mouse.wheel(0, 100_000)
|
||||||
|
await expect
|
||||||
|
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
const scrolled = await viewport.evaluate((element) => element.scrollTop)
|
||||||
|
expect(scrolled).toBeGreaterThan(0)
|
||||||
|
await writeProbe(page)
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
|
||||||
|
expect(await readProbe(page)).toBe(PROBE)
|
||||||
|
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
|
||||||
|
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await panel.getByRole("tab", { name: "file-78.ts" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
expect(await readProbe(page)).toBe(PROBE)
|
||||||
|
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
||||||
|
})
|
||||||
|
|
||||||
|
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||||
|
|
||||||
|
async function writeProbe(page: Page) {
|
||||||
|
await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
|
||||||
|
;(el as Probed).__e2eProbe = probe
|
||||||
|
}, PROBE)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProbe(page: Page) {
|
||||||
|
return page
|
||||||
|
.locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
|
||||||
|
.evaluate((el) => (el as Probed).__e2eProbe)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(page: Page) {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "file-browser-sidebar",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: sessionID,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vcsDiff: [],
|
||||||
|
fileList: (path) => {
|
||||||
|
if (path) return []
|
||||||
|
return files.map((name) => ({
|
||||||
|
name,
|
||||||
|
path: name,
|
||||||
|
absolute: `${directory}/${name}`,
|
||||||
|
type: "file" as const,
|
||||||
|
ignored: false,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, server, sessionID }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:layout",
|
||||||
|
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:review-panel-v2",
|
||||||
|
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, server, sessionID },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -74,6 +74,10 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
"opencode.global.dat:layout",
|
"opencode.global.dat:layout",
|
||||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||||
)
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:review-panel-v2",
|
||||||
|
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
|
||||||
|
)
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||||
@@ -86,13 +90,18 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
const panel = page.locator("#review-panel")
|
const panel = page.locator("#review-panel")
|
||||||
|
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
||||||
|
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
||||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||||
await contextButton.click()
|
await contextButton.click()
|
||||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(sidebarToggle).toBeDisabled()
|
||||||
|
await expect(sidebar).toBeVisible()
|
||||||
await contextButton.click()
|
await contextButton.click()
|
||||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(sidebar).toBeHidden()
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||||
await expect(filter).toBeFocused()
|
await expect(filter).toBeFocused()
|
||||||
@@ -101,10 +110,13 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
|
|
||||||
await panel.getByRole("button", { name: "README.md" }).click()
|
await panel.getByRole("button", { name: "README.md" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||||
|
await expect(sidebar).toHaveCount(0)
|
||||||
|
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
|
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
|
||||||
|
await expect(sidebar).toBeVisible()
|
||||||
await filter.fill("nested")
|
await filter.fill("nested")
|
||||||
const result = panel.getByRole("option", { name: /nested\.ts/ })
|
const result = panel.getByRole("option", { name: /nested\.ts/ })
|
||||||
await expect(result).toBeVisible()
|
await expect(result).toBeVisible()
|
||||||
@@ -113,12 +125,17 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||||
await filter.press("Enter")
|
await filter.press("Enter")
|
||||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||||
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
||||||
|
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(sidebarToggle).toBeDisabled()
|
||||||
|
await panel.getByRole("tab", { name: /Review/ }).click()
|
||||||
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
|
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||||
await page.keyboard.press("Control+w")
|
await page.keyboard.press("Control+w")
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
||||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
|
|||||||
await expectSessionTitle(page, titleA)
|
await expectSessionTitle(page, titleA)
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||||
const reviewTab = page.getByRole("tab", { name: /Review/ })
|
const reviewTab = page.locator("#session-side-panel-review-tab")
|
||||||
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
|
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
|
||||||
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
||||||
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
||||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
await expectTree(page, 8, "git-0.ts")
|
await expectTree(page, 8, "git-0.ts")
|
||||||
|
|
||||||
await selectMode(page, "Git changes", "Branch changes")
|
await selectMode(page, "Git changes", "Branch changes")
|
||||||
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
|
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
|
||||||
await page.keyboard.press("Control+Backquote")
|
await page.keyboard.press("Control+Backquote")
|
||||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||||
await expectTree(page, 2_773, "action.yml")
|
await expectTree(page, 2_773, "action.yml")
|
||||||
|
|||||||
@@ -152,7 +152,10 @@ test.describe("session timeline projection", () => {
|
|||||||
parentID: "msg_2000_diff_next_user",
|
parentID: "msg_2000_diff_next_user",
|
||||||
created: 1700000011000,
|
created: 1700000011000,
|
||||||
})
|
})
|
||||||
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
|
await setupTimeline(page, {
|
||||||
|
messages: [user, assistantMessage(), nextUser, nextAssistant],
|
||||||
|
settings: { newLayoutDesigns: false },
|
||||||
|
})
|
||||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||||
|
import {
|
||||||
|
assistantMessage,
|
||||||
|
setupTimeline,
|
||||||
|
shell,
|
||||||
|
textPart,
|
||||||
|
toolPart,
|
||||||
|
userMessage,
|
||||||
|
} from "../performance/timeline-stability/fixture"
|
||||||
|
|
||||||
|
for (const deviceScaleFactor of [1.25, 1.5]) {
|
||||||
|
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
|
||||||
|
const shellID = "prt_shell_outline"
|
||||||
|
const timeline = await setupTimeline(page, {
|
||||||
|
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
|
||||||
|
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
|
||||||
|
reducedMotion: true,
|
||||||
|
deviceScaleFactor,
|
||||||
|
})
|
||||||
|
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
|
||||||
|
const output = part.locator('[data-component="bash-output"]')
|
||||||
|
const row = page.locator("[data-timeline-key]", { has: part })
|
||||||
|
await expect(output).toBeVisible()
|
||||||
|
await timeline.settle()
|
||||||
|
|
||||||
|
const geometry = await row.evaluate((element) => {
|
||||||
|
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
|
||||||
|
if (!output) throw new Error("Shell output is unavailable")
|
||||||
|
const rowRect = element.getBoundingClientRect()
|
||||||
|
const outputRect = output.getBoundingClientRect()
|
||||||
|
// Match a rounded-down measurement at a fractional device-pixel phase.
|
||||||
|
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
|
||||||
|
element.style.transform = "translateY(0.25px)"
|
||||||
|
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
|
||||||
|
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
|
||||||
|
const style = getComputedStyle(output)
|
||||||
|
return {
|
||||||
|
outputWidth: outputRect.width,
|
||||||
|
outputHeight: outputRect.height,
|
||||||
|
borderColor: style.borderTopColor,
|
||||||
|
boxShadow: style.boxShadow,
|
||||||
|
clipMargin: getComputedStyle(element).overflowClipMargin,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await timeline.settle()
|
||||||
|
|
||||||
|
const clipped = await row.evaluate((element) => {
|
||||||
|
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
|
||||||
|
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
|
||||||
|
})
|
||||||
|
expect(clipped).toBeCloseTo(0.49, 1)
|
||||||
|
|
||||||
|
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
|
||||||
|
const edges = await captureCardEdges(page, output)
|
||||||
|
|
||||||
|
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
|
||||||
|
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
|
||||||
|
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
|
||||||
|
expect(geometry.boxShadow).toBe("none")
|
||||||
|
expect(geometry.clipMargin).toBe("0.5px")
|
||||||
|
expect(edges.magenta.top).toBeGreaterThan(0.75)
|
||||||
|
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
|
||||||
|
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
|
||||||
|
const patchID = "prt_patch_outline"
|
||||||
|
const file = {
|
||||||
|
filePath: "src/outline.ts",
|
||||||
|
relativePath: "src/outline.ts",
|
||||||
|
type: "update",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
before: "const outline = false\n",
|
||||||
|
after: "const outline = true\n",
|
||||||
|
}
|
||||||
|
const timeline = await setupTimeline(page, {
|
||||||
|
messages: [
|
||||||
|
userMessage(),
|
||||||
|
assistantMessage([
|
||||||
|
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
|
||||||
|
reducedMotion: true,
|
||||||
|
})
|
||||||
|
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||||
|
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
|
||||||
|
const row = page.locator("[data-timeline-key]", { has: part })
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
await timeline.settle()
|
||||||
|
|
||||||
|
const geometry = await row.evaluate((element) => {
|
||||||
|
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
|
||||||
|
if (!card) throw new Error("Patch card is unavailable")
|
||||||
|
const rowRect = element.getBoundingClientRect()
|
||||||
|
const cardRect = card.getBoundingClientRect()
|
||||||
|
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
|
||||||
|
const clipMargin = getComputedStyle(element).overflowClipMargin
|
||||||
|
const bottom = element.getBoundingClientRect().bottom
|
||||||
|
return {
|
||||||
|
overflow: card.getBoundingClientRect().bottom - bottom,
|
||||||
|
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
|
||||||
|
clipMargin,
|
||||||
|
cardWidth: cardRect.width,
|
||||||
|
cardHeight: cardRect.height,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await timeline.settle()
|
||||||
|
|
||||||
|
expect(geometry.overflow).toBeCloseTo(0.49, 1)
|
||||||
|
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
|
||||||
|
const edges = await captureCardEdges(page, card)
|
||||||
|
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
|
||||||
|
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
|
||||||
|
expect(edges.luminance.top).toBeLessThan(245)
|
||||||
|
expect(edges.luminance.bottom).toBeLessThan(245)
|
||||||
|
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
|
||||||
|
expect(geometry.clipMargin).toBe("0.5px")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
|
||||||
|
const secondUserID = "msg_outline_second_user"
|
||||||
|
await setupTimeline(page, {
|
||||||
|
messages: [
|
||||||
|
userMessage(undefined, {
|
||||||
|
summary: {
|
||||||
|
diffs: [
|
||||||
|
{
|
||||||
|
file: "src/summary.ts",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
|
||||||
|
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
|
||||||
|
assistantMessage([], {
|
||||||
|
id: "msg_outline_second_assistant",
|
||||||
|
parentID: secondUserID,
|
||||||
|
created: 1700000011000,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||||
|
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||||
|
|
||||||
|
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
||||||
|
elements.map((element) => ({
|
||||||
|
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
|
||||||
|
clipMargin: getComputedStyle(element).overflowClipMargin,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
|
||||||
|
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function captureCardEdges(page: Page, card: Locator) {
|
||||||
|
const box = await card.boundingBox()
|
||||||
|
if (!box) throw new Error("Tool card bounds are unavailable")
|
||||||
|
const viewport = page.viewportSize()
|
||||||
|
if (!viewport) throw new Error("Viewport bounds are unavailable")
|
||||||
|
const screenshot = await page.screenshot()
|
||||||
|
return page.evaluate(
|
||||||
|
async ({ source, box, viewport }) => {
|
||||||
|
const image = new Image()
|
||||||
|
image.src = source
|
||||||
|
await image.decode()
|
||||||
|
const canvas = document.createElement("canvas")
|
||||||
|
canvas.width = image.naturalWidth
|
||||||
|
canvas.height = image.naturalHeight
|
||||||
|
const context = canvas.getContext("2d")
|
||||||
|
if (!context) throw new Error("2D canvas is unavailable")
|
||||||
|
context.drawImage(image, 0, 0)
|
||||||
|
const scale = {
|
||||||
|
x: image.naturalWidth / viewport.width,
|
||||||
|
y: image.naturalHeight / viewport.height,
|
||||||
|
}
|
||||||
|
const rows = (candidates: number[]) => {
|
||||||
|
const left = Math.floor((box.x + 8) * scale.x)
|
||||||
|
const width = Math.floor((box.width - 16) * scale.x)
|
||||||
|
return candidates.map((row) => {
|
||||||
|
const pixels = context.getImageData(left, row, width, 1).data
|
||||||
|
const indexes = Array.from({ length: width }, (_, index) => index * 4)
|
||||||
|
return {
|
||||||
|
luminance:
|
||||||
|
indexes
|
||||||
|
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
|
||||||
|
.reduce((sum, value) => sum + value, 0) / width,
|
||||||
|
magenta:
|
||||||
|
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
|
||||||
|
.length / width,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
|
||||||
|
const columns = new Uint32Array(image.naturalWidth)
|
||||||
|
for (let index = 0; index < pixels.length; index += 4) {
|
||||||
|
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
|
||||||
|
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
|
||||||
|
}
|
||||||
|
const top = box.y * scale.y
|
||||||
|
const bottom = (box.y + box.height) * scale.y
|
||||||
|
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
|
||||||
|
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
|
||||||
|
return {
|
||||||
|
box,
|
||||||
|
luminance: {
|
||||||
|
top: Math.min(...topRows.map((row) => row.luminance)),
|
||||||
|
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
|
||||||
|
},
|
||||||
|
magenta: {
|
||||||
|
top: Math.max(...topRows.map((row) => row.magenta)),
|
||||||
|
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
|
||||||
|
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
||||||
|
viewport,
|
||||||
|
box,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
|
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
|
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
|
import { createEditProjectModel } from "./edit-project"
|
||||||
|
|
||||||
|
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const model = createEditProjectModel(props)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fit>
|
||||||
|
<form onSubmit={model.submit} class="contents">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DividerV2 />
|
||||||
|
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
|
||||||
|
<Field>
|
||||||
|
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||||
|
<TextInputV2
|
||||||
|
autofocus
|
||||||
|
appearance="large"
|
||||||
|
class="!w-full"
|
||||||
|
value={model.store.name}
|
||||||
|
placeholder={model.folderName()}
|
||||||
|
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-2">
|
||||||
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||||
|
{language.t("dialog.project.edit.icon")}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||||
|
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||||
|
classList={{
|
||||||
|
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||||
|
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||||
|
onDrop={model.drop}
|
||||||
|
onDragOver={model.dragOver}
|
||||||
|
onDragLeave={model.dragLeave}
|
||||||
|
onClick={model.iconClick}
|
||||||
|
>
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={model.store.name || model.defaultName()}
|
||||||
|
src={getProjectAvatarSource(props.project.id, {
|
||||||
|
color: model.store.color,
|
||||||
|
url: props.project.icon?.url,
|
||||||
|
override: model.store.iconOverride,
|
||||||
|
})}
|
||||||
|
variant={getProjectAvatarVariant(model.store.color)}
|
||||||
|
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||||
|
classList={{
|
||||||
|
"opacity-100": model.store.iconHover,
|
||||||
|
"opacity-0": !model.store.iconHover,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={(element) => {
|
||||||
|
model.setIconInput(element)
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onChange={model.inputChange}
|
||||||
|
/>
|
||||||
|
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||||
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
|
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={!model.store.iconOverride}>
|
||||||
|
<div class="flex w-full flex-col gap-2">
|
||||||
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||||
|
{language.t("dialog.project.edit.color")}
|
||||||
|
</div>
|
||||||
|
<div class="-ml-1 flex gap-1.5">
|
||||||
|
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||||
|
{(color) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||||
|
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||||
|
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||||
|
classList={{
|
||||||
|
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||||
|
getProjectAvatarVariant(model.store.color) === color,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||||
|
model.setStore(
|
||||||
|
"color",
|
||||||
|
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={model.store.name || model.defaultName()}
|
||||||
|
variant={getProjectAvatarVariant(color)}
|
||||||
|
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||||
|
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||||
|
<TextareaV2
|
||||||
|
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||||
|
rows={3}
|
||||||
|
value={model.store.startup}
|
||||||
|
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
|
||||||
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,123 +1,32 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { createMemo, For, Show } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useGlobal } from "@/context/global"
|
import { createEditProjectModel } from "./edit-project"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
|
||||||
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
const dialog = useDialog()
|
|
||||||
const global = useGlobal()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
const model = createEditProjectModel(props)
|
||||||
const serverSDK = () => serverCtx().sdk
|
|
||||||
const serverSync = () => serverCtx().sync
|
|
||||||
|
|
||||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
|
||||||
const defaultName = createMemo(() => props.project.name || folderName())
|
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
name: defaultName(),
|
|
||||||
color: props.project.icon?.color,
|
|
||||||
iconOverride: props.project.icon?.override,
|
|
||||||
startup: props.project.commands?.start ?? "",
|
|
||||||
dragOver: false,
|
|
||||||
iconHover: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
let iconInput: HTMLInputElement | undefined
|
|
||||||
|
|
||||||
function handleFileSelect(file: File) {
|
|
||||||
if (!file.type.startsWith("image/")) return
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (e) => {
|
|
||||||
setStore("iconOverride", e.target?.result as string)
|
|
||||||
setStore("iconHover", false)
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(e: DragEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setStore("dragOver", false)
|
|
||||||
const file = e.dataTransfer?.files[0]
|
|
||||||
if (file) handleFileSelect(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragOver(e: DragEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setStore("dragOver", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragLeave() {
|
|
||||||
setStore("dragOver", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInputChange(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
if (file) handleFileSelect(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearIcon() {
|
|
||||||
setStore("iconOverride", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
|
||||||
const start = store.startup.trim()
|
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
|
||||||
await serverSDK().client.project.update({
|
|
||||||
projectID: props.project.id,
|
|
||||||
directory: props.project.worktree,
|
|
||||||
name,
|
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
|
||||||
commands: { start },
|
|
||||||
})
|
|
||||||
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
serverSync().project.meta(props.project.worktree, {
|
|
||||||
name,
|
|
||||||
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
|
||||||
commands: { start: start || undefined },
|
|
||||||
})
|
|
||||||
dialog.close()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
function handleSubmit(e: SubmitEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (saveMutation.isPending) return
|
|
||||||
saveMutation.mutate()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
|
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus
|
autofocus
|
||||||
type="text"
|
type="text"
|
||||||
label={language.t("dialog.project.edit.name")}
|
label={language.t("dialog.project.edit.name")}
|
||||||
placeholder={folderName()}
|
placeholder={model.folderName()}
|
||||||
value={store.name}
|
value={model.store.name}
|
||||||
onChange={(v) => setStore("name", v)}
|
onChange={(v) => model.setStore("name", v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<div class="flex gap-3 items-start">
|
<div class="flex gap-3 items-start">
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
onMouseEnter={() => setStore("iconHover", true)}
|
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||||
onMouseLeave={() => setStore("iconHover", false)}
|
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
||||||
classList={{
|
classList={{
|
||||||
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
|
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
|
||||||
"border-border-base hover:border-border-strong": !store.dragOver,
|
"border-border-base hover:border-border-strong": !model.store.dragOver,
|
||||||
"overflow-hidden": !!store.iconOverride,
|
"overflow-hidden": !!model.store.iconOverride,
|
||||||
}}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onClick={() => {
|
|
||||||
if (store.iconOverride && store.iconHover) {
|
|
||||||
clearIcon()
|
|
||||||
} else {
|
|
||||||
iconInput?.click()
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
onDrop={model.drop}
|
||||||
|
onDragOver={model.dragOver}
|
||||||
|
onDragLeave={model.dragLeave}
|
||||||
|
onClick={model.iconClick}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={getProjectAvatarSource(props.project.id, {
|
when={getProjectAvatarSource(props.project.id, {
|
||||||
color: store.color,
|
color: model.store.color,
|
||||||
url: props.project.icon?.url,
|
url: props.project.icon?.url,
|
||||||
override: store.iconOverride,
|
override: model.store.iconOverride,
|
||||||
})}
|
})}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="size-full flex items-center justify-center">
|
<div class="size-full flex items-center justify-center">
|
||||||
<Avatar
|
<Avatar
|
||||||
fallback={store.name || defaultName()}
|
fallback={model.store.name || model.defaultName()}
|
||||||
{...getAvatarColors(store.color)}
|
{...getAvatarColors(model.store.color)}
|
||||||
class="size-full text-[32px]"
|
class="size-full text-[32px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<div
|
<div
|
||||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100": store.iconHover && !store.iconOverride,
|
"opacity-100": model.store.iconHover && !model.store.iconOverride,
|
||||||
"opacity-0": !(store.iconHover && !store.iconOverride),
|
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
@@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<div
|
<div
|
||||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100": store.iconHover && !!store.iconOverride,
|
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
|
||||||
"opacity-0": !(store.iconHover && !!store.iconOverride),
|
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
@@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<input
|
<input
|
||||||
id="icon-upload"
|
id="icon-upload"
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
iconInput = el
|
model.setIconInput(el)
|
||||||
}}
|
}}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onChange={handleInputChange}
|
onChange={model.inputChange}
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
@@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!store.iconOverride}>
|
<Show when={!model.store.iconOverride}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
||||||
<div class="flex gap-1.5">
|
<div class="flex gap-1.5">
|
||||||
@@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||||
aria-pressed={store.color === color}
|
aria-pressed={model.store.color === color}
|
||||||
classList={{
|
classList={{
|
||||||
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
||||||
store.color === color,
|
model.store.color === color,
|
||||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||||
store.color !== color,
|
model.store.color !== color,
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (store.color === color && !props.project.icon?.url) return
|
if (model.store.color === color && !props.project.icon?.url) return
|
||||||
setStore("color", store.color === color ? undefined : color)
|
model.setStore("color", model.store.color === color ? undefined : color)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
fallback={store.name || defaultName()}
|
fallback={model.store.name || model.defaultName()}
|
||||||
{...getAvatarColors(color)}
|
{...getAvatarColors(color)}
|
||||||
class="size-full rounded"
|
class="size-full rounded"
|
||||||
/>
|
/>
|
||||||
@@ -246,19 +149,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
label={language.t("dialog.project.edit.worktree.startup")}
|
label={language.t("dialog.project.edit.worktree.startup")}
|
||||||
description={language.t("dialog.project.edit.worktree.startup.description")}
|
description={language.t("dialog.project.edit.worktree.startup.description")}
|
||||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||||
value={store.startup}
|
value={model.store.startup}
|
||||||
onChange={(v) => setStore("startup", v)}
|
onChange={(v) => model.setStore("startup", v)}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
|
||||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { createMemo } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { type LocalProject } from "@/context/layout"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
|
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const global = useGlobal()
|
||||||
|
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||||
|
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||||
|
const defaultName = createMemo(() => props.project.name || folderName())
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
name: defaultName(),
|
||||||
|
color: props.project.icon?.color,
|
||||||
|
iconOverride: props.project.icon?.override,
|
||||||
|
startup: props.project.commands?.start ?? "",
|
||||||
|
dragOver: false,
|
||||||
|
iconHover: false,
|
||||||
|
})
|
||||||
|
let iconInput: HTMLInputElement | undefined
|
||||||
|
|
||||||
|
function selectFile(file: File) {
|
||||||
|
if (!file.type.startsWith("image/")) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const result = event.target?.result
|
||||||
|
if (typeof result !== "string") return
|
||||||
|
setStore("iconOverride", result)
|
||||||
|
setStore("iconHover", false)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(event: DragEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setStore("dragOver", false)
|
||||||
|
const file = event.dataTransfer?.files[0]
|
||||||
|
if (file) selectFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragOver(event: DragEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setStore("dragOver", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragLeave() {
|
||||||
|
setStore("dragOver", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputChange(event: Event) {
|
||||||
|
const file = (event.currentTarget as HTMLInputElement).files?.[0]
|
||||||
|
if (file) selectFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconClick() {
|
||||||
|
if (store.iconOverride && store.iconHover) {
|
||||||
|
setStore("iconOverride", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iconInput?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = useMutation(() => ({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
const start = store.startup.trim()
|
||||||
|
|
||||||
|
if (props.project.id && props.project.id !== "global") {
|
||||||
|
await serverCtx().sdk.client.project.update({
|
||||||
|
projectID: props.project.id,
|
||||||
|
directory: props.project.worktree,
|
||||||
|
name,
|
||||||
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
|
commands: { start },
|
||||||
|
})
|
||||||
|
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||||
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverCtx().sync.project.meta(props.project.worktree, {
|
||||||
|
name,
|
||||||
|
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
||||||
|
commands: { start: start || undefined },
|
||||||
|
})
|
||||||
|
dialog.close()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (save.isPending) return
|
||||||
|
save.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
folderName,
|
||||||
|
defaultName,
|
||||||
|
save,
|
||||||
|
submit,
|
||||||
|
drop,
|
||||||
|
dragOver,
|
||||||
|
dragLeave,
|
||||||
|
inputChange,
|
||||||
|
iconClick,
|
||||||
|
close() {
|
||||||
|
dialog.close()
|
||||||
|
},
|
||||||
|
setIconInput(input: HTMLInputElement) {
|
||||||
|
iconInput = input
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
|
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
|
||||||
|
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
describe("file tree v2 model", () => {
|
describe("buildFileTreeV2Model", () => {
|
||||||
test("builds sorted depth-first rows", () => {
|
test("builds a sorted tree and flattens expanded directories", () => {
|
||||||
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
|
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
|
||||||
|
|
||||||
expect(model.total).toBe(8)
|
expect(model.total).toBe(8)
|
||||||
@@ -18,7 +19,7 @@ describe("file tree v2 model", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("omits descendants of collapsed directories", () => {
|
test("skips children of collapsed directories", () => {
|
||||||
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
|
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
|
||||||
|
|
||||||
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
|
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
|
||||||
@@ -28,19 +29,46 @@ describe("file tree v2 model", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("normalizes separators and duplicate paths", () => {
|
test("normalizes duplicate and messy paths", () => {
|
||||||
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
|
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
|
||||||
const rows = flattenFileTreeV2(model, () => true)
|
const rows = flattenFileTreeV2(model, () => true)
|
||||||
|
|
||||||
expect(model.total).toBe(4)
|
|
||||||
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
|
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
|
||||||
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
|
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("supports paths deeper than the legacy recursion limit", () => {
|
test("handles deeply nested paths", () => {
|
||||||
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts`
|
const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts"
|
||||||
const model = buildFileTreeV2Model([file])
|
const model = buildFileTreeV2Model([file])
|
||||||
|
|
||||||
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
|
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("flattenLiveFileTreeV2", () => {
|
||||||
|
test("flattens live children using original paths for nested lookups", () => {
|
||||||
|
const nodes: Record<string, FileNode[]> = {
|
||||||
|
"": [
|
||||||
|
{ name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false },
|
||||||
|
{ name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false },
|
||||||
|
],
|
||||||
|
src: [
|
||||||
|
{ name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false },
|
||||||
|
{ name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false },
|
||||||
|
],
|
||||||
|
"src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
flattenLiveFileTreeV2(
|
||||||
|
(path) => nodes[path] ?? [],
|
||||||
|
(path) => path === "src",
|
||||||
|
).map((row) => [row.node.path, row.node.originalPath, row.level]),
|
||||||
|
).toEqual([
|
||||||
|
["src", "src", 0],
|
||||||
|
["src/a.ts", "src/a.ts", 1],
|
||||||
|
["src/lib", "src/lib", 1],
|
||||||
|
["README.md", "README.md", 0],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -75,3 +75,33 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
|
|||||||
|
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function flattenLiveFileTreeV2(
|
||||||
|
children: (path: string) => readonly FileNode[],
|
||||||
|
expanded: (path: string) => boolean,
|
||||||
|
) {
|
||||||
|
const rows: FileTreeV2Row[] = []
|
||||||
|
const stack = children("")
|
||||||
|
.toReversed()
|
||||||
|
.map((node) => ({ node: toLiveNode(node), level: 0 }))
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const row = stack.pop()!
|
||||||
|
rows.push(row)
|
||||||
|
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
|
||||||
|
const nested = children(row.node.originalPath)
|
||||||
|
for (let index = nested.length - 1; index >= 0; index--) {
|
||||||
|
stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLiveNode(node: FileNode): FileTreeV2Node {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
path: normalizeFileTreeV2Path(node.path),
|
||||||
|
originalPath: node.path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
|
|||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
||||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||||
import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
import {
|
||||||
|
buildFileTreeV2Model,
|
||||||
|
flattenFileTreeV2,
|
||||||
|
flattenLiveFileTreeV2,
|
||||||
|
normalizeFileTreeV2Path,
|
||||||
|
type FileTreeV2Node,
|
||||||
|
} from "@/components/file-tree-v2-model"
|
||||||
import { virtualScrollElement } from "@/components/virtual-scroll-element"
|
import { virtualScrollElement } from "@/components/virtual-scroll-element"
|
||||||
|
|
||||||
export type { Kind } from "@/components/file-tree"
|
export type { Kind } from "@/components/file-tree"
|
||||||
@@ -36,7 +42,7 @@ function guideLineLeft(level: number) {
|
|||||||
export const kindLabel = (kind: Kind) => {
|
export const kindLabel = (kind: Kind) => {
|
||||||
if (kind === "add") return "A"
|
if (kind === "add") return "A"
|
||||||
if (kind === "del") return "D"
|
if (kind === "del") return "D"
|
||||||
return ""
|
return "M"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const kindChange = (kind: Kind) => {
|
export const kindChange = (kind: Kind) => {
|
||||||
@@ -68,7 +74,7 @@ const FileTreeNodeV2 = (
|
|||||||
"class",
|
"class",
|
||||||
"classList",
|
"classList",
|
||||||
])
|
])
|
||||||
const kind = () => local.kinds?.get(local.node.path)
|
const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dynamic
|
<Dynamic
|
||||||
@@ -110,12 +116,7 @@ const FileTreeNodeV2 = (
|
|||||||
function GuideLines(props: { level: number }) {
|
function GuideLines(props: { level: number }) {
|
||||||
return (
|
return (
|
||||||
<For each={Array.from({ length: props.level })}>
|
<For each={Array.from({ length: props.level })}>
|
||||||
{(_, index) => (
|
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
|
||||||
<div
|
|
||||||
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
|
|
||||||
style={`left: ${guideLineLeft(index())}px`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -126,12 +127,18 @@ export default function FileTreeV2(props: {
|
|||||||
kinds?: ReadonlyMap<string, Kind>
|
kinds?: ReadonlyMap<string, Kind>
|
||||||
draggable?: boolean
|
draggable?: boolean
|
||||||
onFileClick?: (file: FileNode) => void
|
onFileClick?: (file: FileNode) => void
|
||||||
|
onFileDoubleClick?: (file: FileNode) => void
|
||||||
}) {
|
}) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
|
const live = () => props.allowed === undefined
|
||||||
const draggable = () => props.draggable ?? true
|
const draggable = () => props.draggable ?? true
|
||||||
const active = () => normalizeFileTreeV2Path(props.active ?? "")
|
const active = () => normalizeFileTreeV2Path(props.active ?? "")
|
||||||
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
|
const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? [])))
|
||||||
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
|
const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live()
|
||||||
|
const rows = createMemo(() => {
|
||||||
|
if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded)
|
||||||
|
return flattenFileTreeV2(model()!, expanded)
|
||||||
|
})
|
||||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||||
const [focused, setFocused] = createSignal<string>()
|
const [focused, setFocused] = createSignal<string>()
|
||||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||||
@@ -155,16 +162,49 @@ export default function FileTreeV2(props: {
|
|||||||
return [...indexes, index].sort((a, b) => a - b)
|
return [...indexes, index].sort((a, b) => a - b)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!live()) return
|
||||||
|
void file.tree.list("")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Only scroll when the active path changes (or first appears in the tree).
|
||||||
|
// Do not re-scroll when expand/collapse reshuffles `rows()`.
|
||||||
|
let scrolledActive: string | undefined
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const path = active()
|
const path = active()
|
||||||
if (!path) return
|
if (!path) {
|
||||||
|
scrolledActive = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
const index = rows().findIndex((row) => row.node.path === path)
|
const index = rows().findIndex((row) => row.node.path === path)
|
||||||
if (index < 0) return
|
if (index < 0) return
|
||||||
|
if (scrolledActive === path) return
|
||||||
|
scrolledActive = path
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
|
const next = rows().findIndex((row) => row.node.path === path)
|
||||||
virtualizer.scrollToIndex(index, { align: "auto" })
|
if (next < 0) return
|
||||||
|
if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return
|
||||||
|
virtualizer.scrollToIndex(next, { align: "auto" })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => {
|
||||||
|
action?.({
|
||||||
|
...node,
|
||||||
|
path: node.originalPath,
|
||||||
|
absolute: node.originalPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDirectory = (path: string, originalPath: string) => {
|
||||||
|
if (expanded(path)) {
|
||||||
|
file.tree.collapse(originalPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file.tree.expand(originalPath, live() ? undefined : { list: false })
|
||||||
|
}
|
||||||
|
|
||||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
|
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
|
||||||
const virtualItemByKey = createMemo(
|
const virtualItemByKey = createMemo(
|
||||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||||
@@ -175,7 +215,7 @@ export default function FileTreeV2(props: {
|
|||||||
<div
|
<div
|
||||||
ref={setRoot}
|
ref={setRoot}
|
||||||
data-component="file-tree-v2"
|
data-component="file-tree-v2"
|
||||||
data-total-rows={model().total}
|
data-total-rows={live() ? rows().length : model()!.total}
|
||||||
class="group/file-tree-v2"
|
class="group/file-tree-v2"
|
||||||
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
|
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
|
||||||
>
|
>
|
||||||
@@ -209,13 +249,8 @@ export default function FileTreeV2(props: {
|
|||||||
class="relative"
|
class="relative"
|
||||||
onFocus={() => setFocused(row().node.path)}
|
onFocus={() => setFocused(row().node.path)}
|
||||||
onBlur={() => setFocused(undefined)}
|
onBlur={() => setFocused(undefined)}
|
||||||
onClick={() =>
|
onClick={() => selectFile(row().node, props.onFileClick)}
|
||||||
props.onFileClick?.({
|
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
|
||||||
...row().node,
|
|
||||||
path: row().node.originalPath,
|
|
||||||
absolute: row().node.originalPath,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<GuideLines level={row().level} />
|
<GuideLines level={row().level} />
|
||||||
<Show when={row().level > 0}>
|
<Show when={row().level > 0}>
|
||||||
@@ -239,17 +274,13 @@ export default function FileTreeV2(props: {
|
|||||||
class="relative"
|
class="relative"
|
||||||
onFocus={() => setFocused(row().node.path)}
|
onFocus={() => setFocused(row().node.path)}
|
||||||
onBlur={() => setFocused(undefined)}
|
onBlur={() => setFocused(undefined)}
|
||||||
aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
|
aria-expanded={expanded(row().node.path)}
|
||||||
onClick={() =>
|
onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
|
||||||
file.tree.state(row().node.path)?.expanded === false
|
|
||||||
? file.tree.expand(row().node.path, { list: false })
|
|
||||||
: file.tree.collapse(row().node.path)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<GuideLines level={row().level} />
|
<GuideLines level={row().level} />
|
||||||
<div
|
<div
|
||||||
data-slot="file-tree-v2-chevron"
|
data-slot="file-tree-v2-chevron"
|
||||||
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
|
data-expanded={expanded(row().node.path) ? "" : undefined}
|
||||||
class="size-4 flex items-center justify-center"
|
class="size-4 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Icon name="chevron-down" />
|
<Icon name="chevron-down" />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
|
|||||||
import {
|
import {
|
||||||
ContentPart,
|
ContentPart,
|
||||||
DEFAULT_PROMPT,
|
DEFAULT_PROMPT,
|
||||||
|
isCommentItem,
|
||||||
isPromptEqual,
|
isPromptEqual,
|
||||||
Prompt,
|
Prompt,
|
||||||
usePrompt,
|
usePrompt,
|
||||||
@@ -36,6 +37,8 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
@@ -1333,6 +1336,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onQueue: props.onQueue,
|
onQueue: props.onQueue,
|
||||||
onAbort: props.onAbort,
|
onAbort: props.onAbort,
|
||||||
onSubmit: props.onSubmit,
|
onSubmit: props.onSubmit,
|
||||||
|
model: props.controls.model.selection,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -1623,7 +1627,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<PromptContextItems
|
<PromptContextItems
|
||||||
items={contextItems()}
|
items={contextItems().filter((item) => !isCommentItem(item))}
|
||||||
active={(item) => {
|
active={(item) => {
|
||||||
const active = comments.active()
|
const active = comments.active()
|
||||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||||
@@ -1633,6 +1637,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||||
prompt.context.remove(item.key)
|
prompt.context.remove(item.key)
|
||||||
}}
|
}}
|
||||||
|
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<PromptImageAttachments
|
<PromptImageAttachments
|
||||||
@@ -1642,6 +1647,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
onRemove={removeAttachment}
|
onRemove={removeAttachment}
|
||||||
removeLabel={language.t("prompt.attachment.remove")}
|
removeLabel={language.t("prompt.attachment.remove")}
|
||||||
|
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||||
|
comments={contextItems().filter(isCommentItem)}
|
||||||
|
commentActive={(item) => {
|
||||||
|
const active = comments.active()
|
||||||
|
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||||
|
}}
|
||||||
|
onOpenComment={openComment}
|
||||||
|
onRemoveComment={(item) => {
|
||||||
|
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||||
|
prompt.context.remove(item.key)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="relative min-h-[52px]"
|
class="relative min-h-[52px]"
|
||||||
@@ -1704,22 +1720,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<MenuV2 gutter={6} modal={false} placement="top-start">
|
<MenuV2 gutter={6} modal={false} placement="top-start">
|
||||||
<MenuV2.Trigger
|
<MenuV2.Trigger
|
||||||
as={IconButton}
|
as={IconButtonV2}
|
||||||
data-action="prompt-attach"
|
data-action="prompt-attach"
|
||||||
type="button"
|
type="button"
|
||||||
icon="plus"
|
icon={<IconV2 name="plus" />}
|
||||||
variant="ghost"
|
variant="ghost-muted"
|
||||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
|
size="large"
|
||||||
style={buttons()}
|
style={buttons()}
|
||||||
disabled={store.mode !== "normal"}
|
disabled={store.mode !== "normal"}
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
aria-label={language.t("prompt.menu.addImagesAndFiles")}
|
aria-label={language.t("prompt.menu.addImagesAndFiles")}
|
||||||
/>
|
/>
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content
|
<MenuV2.Content style={{ "min-width": "180px" }}>
|
||||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
|
||||||
style={{ "min-width": "180px" }}
|
|
||||||
>
|
|
||||||
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
|
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
|
||||||
{language.t("prompt.menu.imagesAndFiles")}
|
{language.t("prompt.menu.imagesAndFiles")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
@@ -1852,6 +1865,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||||
prompt.context.remove(item.key)
|
prompt.context.remove(item.key)
|
||||||
}}
|
}}
|
||||||
|
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<PromptImageAttachments
|
<PromptImageAttachments
|
||||||
@@ -1861,6 +1875,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
onRemove={removeAttachment}
|
onRemove={removeAttachment}
|
||||||
removeLabel={language.t("prompt.attachment.remove")}
|
removeLabel={language.t("prompt.attachment.remove")}
|
||||||
|
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
@@ -2341,7 +2356,7 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<span class="truncate">{props.state.modelName}</span>
|
<span class="truncate leading-4">{props.state.modelName}</span>
|
||||||
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
||||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Component, For, Show } from "solid-js"
|
import { Component, For, Show } from "solid-js"
|
||||||
|
import { Dynamic } from "solid-js/web"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
|
||||||
import type { ContextItem } from "@/context/prompt"
|
import type { ContextItem } from "@/context/prompt"
|
||||||
@@ -12,6 +14,7 @@ type ContextItemsProps = {
|
|||||||
active: (item: PromptContextItem) => boolean
|
active: (item: PromptContextItem) => boolean
|
||||||
openComment: (item: PromptContextItem) => void
|
openComment: (item: PromptContextItem) => void
|
||||||
remove: (item: PromptContextItem) => void
|
remove: (item: PromptContextItem) => void
|
||||||
|
newLayoutDesigns: boolean
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,10 +30,17 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
const selected = props.active(item)
|
const selected = props.active(item)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipV2
|
<Dynamic
|
||||||
|
component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
|
||||||
value={
|
value={
|
||||||
<span class="flex max-w-[300px]">
|
<span class="flex max-w-[300px]">
|
||||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
<span
|
||||||
|
classList={{
|
||||||
|
"truncate-start [unicode-bidi:plaintext] min-w-0": true,
|
||||||
|
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||||
|
"text-text-invert-base": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{directory}
|
{directory}
|
||||||
</span>
|
</span>
|
||||||
<span class="shrink-0">{filename}</span>
|
<span class="shrink-0">{filename}</span>
|
||||||
@@ -78,7 +88,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</TooltipV2>
|
</Dynamic>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
@keyframes prompt-attachments-fade-left {
|
||||||
|
from {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes prompt-attachments-fade-right {
|
||||||
|
from {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="prompt-attachments"] {
|
||||||
|
timeline-scope: --prompt-attachments-scroll;
|
||||||
|
|
||||||
|
[data-slot^="prompt-attachments-fade-"] {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) {
|
||||||
|
[data-slot="prompt-attachments-scroll"] {
|
||||||
|
scroll-timeline: --prompt-attachments-scroll x;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="prompt-attachments-fade-left"] {
|
||||||
|
animation: prompt-attachments-fade-left linear both;
|
||||||
|
animation-timeline: --prompt-attachments-scroll;
|
||||||
|
animation-range: 0 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="prompt-attachments-fade-right"] {
|
||||||
|
animation: prompt-attachments-fade-right linear both;
|
||||||
|
animation-timeline: --prompt-attachments-scroll;
|
||||||
|
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,60 +1,167 @@
|
|||||||
import { Component, For, Show } from "solid-js"
|
import { Component, For, Show } from "solid-js"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import type { ImageAttachmentPart } from "@/context/prompt"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2"
|
||||||
|
import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2"
|
||||||
|
import { typeLabel } from "@opencode-ai/session-ui/message-file"
|
||||||
|
import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
|
||||||
|
import "./image-attachments.css"
|
||||||
|
|
||||||
|
type PromptCommentItem = ContextItem & { key: string }
|
||||||
|
|
||||||
type PromptImageAttachmentsProps = {
|
type PromptImageAttachmentsProps = {
|
||||||
attachments: ImageAttachmentPart[]
|
attachments: ImageAttachmentPart[]
|
||||||
onOpen: (attachment: ImageAttachmentPart) => void
|
onOpen: (attachment: ImageAttachmentPart) => void
|
||||||
onRemove: (id: string) => void
|
onRemove: (id: string) => void
|
||||||
removeLabel: string
|
removeLabel: string
|
||||||
|
newLayoutDesigns: boolean
|
||||||
|
comments?: PromptCommentItem[]
|
||||||
|
commentActive?: (item: PromptCommentItem) => boolean
|
||||||
|
onOpenComment?: (item: PromptCommentItem) => void
|
||||||
|
onRemoveComment?: (item: PromptCommentItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
|
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
|
||||||
const imageClass =
|
const imageClass =
|
||||||
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||||
|
const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||||
|
// inset box-shadows do not paint over <img> content, so the hairline is a separate overlay
|
||||||
|
const imageHairlineClassV2 =
|
||||||
|
"absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none"
|
||||||
const removeClass =
|
const removeClass =
|
||||||
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||||
|
const removeClassV2 =
|
||||||
|
"absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
|
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
|
||||||
|
|
||||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={props.attachments.length > 0}>
|
<Show when={props.attachments.length > 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
|
||||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
<div data-slot="prompt-attachments" classList={{ relative: props.newLayoutDesigns }}>
|
||||||
<For each={props.attachments}>
|
<div
|
||||||
{(attachment) => (
|
data-slot="prompt-attachments-scroll"
|
||||||
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
classList={{
|
||||||
<div class="relative group">
|
"flex gap-2": true,
|
||||||
|
"flex-nowrap overflow-x-auto no-scrollbar px-2 pt-2 pb-1": props.newLayoutDesigns,
|
||||||
|
"flex-wrap px-3 pt-3": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={props.newLayoutDesigns}>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<CommentCardV2
|
||||||
|
comment={item.comment ?? ""}
|
||||||
|
path={item.path}
|
||||||
|
selection={item.selection}
|
||||||
|
active={props.commentActive?.(item)}
|
||||||
|
onClick={() => props.onOpenComment?.(item)}
|
||||||
|
/>
|
||||||
|
</TooltipV2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onRemoveComment?.(item)}
|
||||||
|
class={removeClassV2}
|
||||||
|
aria-label={props.removeLabel}
|
||||||
|
>
|
||||||
|
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
<For each={props.attachments}>
|
||||||
|
{(attachment) => {
|
||||||
|
const image = attachment.mime.startsWith("image/")
|
||||||
|
const media = () => (
|
||||||
<Show
|
<Show
|
||||||
when={attachment.mime.startsWith("image/")}
|
when={image}
|
||||||
fallback={
|
fallback={
|
||||||
<div class={fallbackClass}>
|
<Show
|
||||||
<Icon name="folder" class="size-6 text-text-weak" />
|
when={props.newLayoutDesigns}
|
||||||
</div>
|
fallback={
|
||||||
|
<div class={fallbackClass}>
|
||||||
|
<Icon name="folder" class="size-6 text-text-weak" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AttachmentCardV2 title={attachment.filename}>
|
||||||
|
{typeLabel(attachment.filename, attachment.mime)}
|
||||||
|
</AttachmentCardV2>
|
||||||
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={attachment.dataUrl}
|
src={attachment.dataUrl}
|
||||||
alt={attachment.filename}
|
alt={attachment.filename}
|
||||||
class={imageClass}
|
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
|
||||||
onClick={() => props.onOpen(attachment)}
|
onClick={() => props.onOpen(attachment)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
)
|
||||||
type="button"
|
const name = () => (
|
||||||
onClick={() => props.onRemove(attachment.id)}
|
|
||||||
class={removeClass}
|
|
||||||
aria-label={props.removeLabel}
|
|
||||||
>
|
|
||||||
<Icon name="close" class="size-3 text-text-weak" />
|
|
||||||
</button>
|
|
||||||
<div class={nameClass}>
|
<div class={nameClass}>
|
||||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
</Tooltip>
|
const remove = () => (
|
||||||
)}
|
<button
|
||||||
</For>
|
type="button"
|
||||||
|
onClick={() => props.onRemove(attachment.id)}
|
||||||
|
class={props.newLayoutDesigns ? removeClassV2 : removeClass}
|
||||||
|
aria-label={props.removeLabel}
|
||||||
|
>
|
||||||
|
<Show when={props.newLayoutDesigns} fallback={<Icon name="close" class="size-3 text-text-weak" />}>
|
||||||
|
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
// v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={props.newLayoutDesigns}
|
||||||
|
fallback={
|
||||||
|
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
||||||
|
<div class="relative group">
|
||||||
|
{media()}
|
||||||
|
{name()}
|
||||||
|
{remove()}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="relative group shrink-0">
|
||||||
|
<TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
|
||||||
|
{media()}
|
||||||
|
<Show when={image}>
|
||||||
|
<div class={imageHairlineClassV2} />
|
||||||
|
</Show>
|
||||||
|
</TooltipV2>
|
||||||
|
{remove()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<Show when={props.newLayoutDesigns}>
|
||||||
|
<div
|
||||||
|
data-slot="prompt-attachments-fade-left"
|
||||||
|
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="prompt-attachments-fade-right"
|
||||||
|
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import type { Prompt } from "@/context/prompt"
|
import type { Prompt } from "@/context/prompt"
|
||||||
|
import type { ModelSelection } from "@/context/local"
|
||||||
|
|
||||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
@@ -33,6 +34,10 @@ const prompt = {
|
|||||||
current: () => promptValue,
|
current: () => promptValue,
|
||||||
cursor: () => 0,
|
cursor: () => 0,
|
||||||
dirty: () => true,
|
dirty: () => true,
|
||||||
|
model: {
|
||||||
|
current: () => undefined,
|
||||||
|
set: () => undefined,
|
||||||
|
},
|
||||||
reset: () => undefined,
|
reset: () => undefined,
|
||||||
set: () => undefined,
|
set: () => undefined,
|
||||||
context: {
|
context: {
|
||||||
@@ -378,6 +383,39 @@ describe("prompt submit worktree selection", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("uses an injected model selection", async () => {
|
||||||
|
params = { id: "session-1" }
|
||||||
|
const model = {
|
||||||
|
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
||||||
|
variant: { current: () => "draft-variant" },
|
||||||
|
} as unknown as ModelSelection
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => ({ id: "session-1" }),
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
|
||||||
|
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||||
|
|
||||||
|
expect(optimistic[0]).toMatchObject({
|
||||||
|
message: {
|
||||||
|
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
prompt,
|
prompt,
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { showToast } from "@/utils/toast"
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { batch, type Accessor } from "solid-js"
|
import { batch, startTransition, type Accessor } from "solid-js"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { 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"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||||
@@ -191,6 +191,7 @@ type PromptSubmitInput = {
|
|||||||
onQueue?: (draft: FollowupDraft) => void
|
onQueue?: (draft: FollowupDraft) => void
|
||||||
onAbort?: () => void
|
onAbort?: () => void
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
|
model?: ModelSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
@@ -298,9 +299,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentModel = local.model.current()
|
const modelSelection = input.model ?? local.model
|
||||||
|
const currentModel = modelSelection.current()
|
||||||
const currentAgent = local.agent.current()
|
const currentAgent = local.agent.current()
|
||||||
const variant = local.model.variant.current()
|
const variant = modelSelection.variant.current()
|
||||||
if (!currentModel || !currentAgent) {
|
if (!currentModel || !currentAgent) {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.modelAgentRequired.title"),
|
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||||
@@ -374,13 +376,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (created) {
|
if (created) {
|
||||||
seed(sessionDirectory, created)
|
seed(sessionDirectory, created)
|
||||||
session = created
|
session = created
|
||||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
await startTransition(() => {
|
||||||
local.session.promote(sessionDirectory, session.id)
|
if (!session) return
|
||||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||||
const draftID = search.draftId
|
local.session.promote(sessionDirectory, session.id, {
|
||||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
agent: currentAgent.name,
|
||||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
variant: variant ?? null,
|
||||||
|
})
|
||||||
|
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
|
const draftID = search.draftId
|
||||||
|
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||||
|
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
|
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { For, Show } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
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 { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
@@ -96,10 +97,17 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
{(branch) => (
|
{(branch) => (
|
||||||
<>
|
<>
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
<TooltipV2
|
||||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
placement="top"
|
||||||
<span class="min-w-0 truncate">{branch()}</span>
|
value={branch()}
|
||||||
</div>
|
class="min-w-0 max-w-[220px]"
|
||||||
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
|
>
|
||||||
|
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||||
|
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 truncate">{branch()}</span>
|
||||||
|
</div>
|
||||||
|
</TooltipV2>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
|
import { getSessionContext } from "@/components/session/session-context-metrics"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
@@ -74,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||||
const tokens = createMemo(() => info()?.tokens)
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(info()?.cost ?? 0)
|
return usd().format(info()?.cost ?? 0)
|
||||||
})
|
})
|
||||||
@@ -132,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
|
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
|
||||||
<ContextTooltipRow
|
<ContextTooltipRow
|
||||||
name={language.t("context.usage.tokens")}
|
name={language.t("context.usage.tokens")}
|
||||||
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
|
value={context()?.total.toLocaleString(language.intl()) ?? "0"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { SessionHeader } from "./session-header"
|
export { SessionHeader } from "./session-header"
|
||||||
export { SessionContextTab } from "./session-context-tab"
|
export { SessionContextTab } from "./session-context-tab"
|
||||||
export { SortableTab, FileVisual } from "./session-sortable-tab"
|
export { SortableTab, FileVisual } from "./session-sortable-tab"
|
||||||
|
export { SortableTabV2 } from "./session-sortable-tab-v2"
|
||||||
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
||||||
export { NewSessionView } from "./session-new-view"
|
export { NewSessionView } from "./session-new-view"
|
||||||
export { NewSessionDesignView } from "./session-new-design-view"
|
export { NewSessionDesignView } from "./session-new-design-view"
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
|
||||||
|
|
||||||
|
export function OpenInAppV2(props: { directory: () => string }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const state = useOpenInApp(props)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={props.directory() && state.canOpen()}>
|
||||||
|
<SplitButtonV2 class="session-review-v2-open-in-app" onPointerDown={(event) => event.stopPropagation()}>
|
||||||
|
<TooltipV2
|
||||||
|
placement="bottom"
|
||||||
|
value={language.t("session.header.open.ariaLabel", { app: state.current().label })}
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
|
<SplitButtonV2Action
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (state.opening()) return
|
||||||
|
state.openDir(state.current().id)
|
||||||
|
}}
|
||||||
|
disabled={state.opening()}
|
||||||
|
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
|
||||||
|
>
|
||||||
|
<Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}>
|
||||||
|
<Spinner class="size-3.5" />
|
||||||
|
</Show>
|
||||||
|
</SplitButtonV2Action>
|
||||||
|
</TooltipV2>
|
||||||
|
<MenuV2
|
||||||
|
gutter={4}
|
||||||
|
modal={false}
|
||||||
|
placement="bottom-end"
|
||||||
|
open={state.menu.open}
|
||||||
|
onOpenChange={(open) => state.setMenu("open", open)}
|
||||||
|
>
|
||||||
|
<MenuV2.Trigger
|
||||||
|
as={SplitButtonV2MenuTrigger}
|
||||||
|
disabled={state.opening()}
|
||||||
|
aria-label={language.t("session.header.open.menu")}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<IconV2 name="chevron-down" size="small" />
|
||||||
|
</MenuV2.Trigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content class="open-in-app-v2-menu">
|
||||||
|
<MenuV2.Group>
|
||||||
|
<MenuV2.GroupLabel>{language.t("session.header.openIn")}</MenuV2.GroupLabel>
|
||||||
|
<MenuV2.RadioGroup
|
||||||
|
value={state.current().id}
|
||||||
|
onChange={(value) => {
|
||||||
|
state.selectApp(value as OpenApp)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={state.options()}>
|
||||||
|
{(option) => (
|
||||||
|
<MenuV2.RadioItem
|
||||||
|
value={option.id}
|
||||||
|
disabled={state.opening()}
|
||||||
|
onSelect={() => {
|
||||||
|
state.selectApp(option.id)
|
||||||
|
state.setMenu("open", false)
|
||||||
|
state.openDir(option.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppIcon id={option.icon} />
|
||||||
|
{option.label}
|
||||||
|
</MenuV2.RadioItem>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</MenuV2.RadioGroup>
|
||||||
|
</MenuV2.Group>
|
||||||
|
<MenuV2.Separator />
|
||||||
|
<MenuV2.Item
|
||||||
|
onSelect={() => {
|
||||||
|
state.setMenu("open", false)
|
||||||
|
state.copyPath()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="copy" size="small" class="text-icon-weak" />
|
||||||
|
{language.t("session.header.open.copyPath")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
</SplitButtonV2>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { createEffect, createMemo } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
|
||||||
|
export const OPEN_APPS = [
|
||||||
|
"vscode",
|
||||||
|
"cursor",
|
||||||
|
"zed",
|
||||||
|
"textmate",
|
||||||
|
"antigravity",
|
||||||
|
"finder",
|
||||||
|
"terminal",
|
||||||
|
"iterm2",
|
||||||
|
"ghostty",
|
||||||
|
"warp",
|
||||||
|
"xcode",
|
||||||
|
"android-studio",
|
||||||
|
"powershell",
|
||||||
|
"sublime-text",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type OpenApp = (typeof OPEN_APPS)[number]
|
||||||
|
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
|
||||||
|
|
||||||
|
export const MAC_OPEN_APPS = [
|
||||||
|
{
|
||||||
|
id: "vscode",
|
||||||
|
label: "session.header.open.app.vscode",
|
||||||
|
icon: "vscode",
|
||||||
|
openWith: "Visual Studio Code",
|
||||||
|
},
|
||||||
|
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
|
||||||
|
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
|
||||||
|
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
|
||||||
|
{
|
||||||
|
id: "antigravity",
|
||||||
|
label: "session.header.open.app.antigravity",
|
||||||
|
icon: "antigravity",
|
||||||
|
openWith: "Antigravity",
|
||||||
|
},
|
||||||
|
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
|
||||||
|
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
|
||||||
|
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||||
|
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
|
||||||
|
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
|
||||||
|
{
|
||||||
|
id: "android-studio",
|
||||||
|
label: "session.header.open.app.androidStudio",
|
||||||
|
icon: "android-studio",
|
||||||
|
openWith: "Android Studio",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sublime-text",
|
||||||
|
label: "session.header.open.app.sublimeText",
|
||||||
|
icon: "sublime-text",
|
||||||
|
openWith: "Sublime Text",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const WINDOWS_OPEN_APPS = [
|
||||||
|
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
|
||||||
|
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
|
||||||
|
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
|
||||||
|
{
|
||||||
|
id: "powershell",
|
||||||
|
label: "session.header.open.app.powershell",
|
||||||
|
icon: "powershell",
|
||||||
|
openWith: "powershell",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sublime-text",
|
||||||
|
label: "session.header.open.app.sublimeText",
|
||||||
|
icon: "sublime-text",
|
||||||
|
openWith: "Sublime Text",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const LINUX_OPEN_APPS = [
|
||||||
|
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
|
||||||
|
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
|
||||||
|
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
|
||||||
|
{
|
||||||
|
id: "sublime-text",
|
||||||
|
label: "session.header.open.app.sublimeText",
|
||||||
|
icon: "sublime-text",
|
||||||
|
openWith: "Sublime Text",
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenAppOS {
|
||||||
|
if (platform.platform === "desktop" && platform.os) return platform.os
|
||||||
|
if (typeof navigator !== "object") return "unknown"
|
||||||
|
const value = navigator.platform || navigator.userAgent
|
||||||
|
if (/Mac/i.test(value)) return "macos"
|
||||||
|
if (/Win/i.test(value)) return "windows"
|
||||||
|
if (/Linux/i.test(value)) return "linux"
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openAppFileManager(os: OpenAppOS) {
|
||||||
|
if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
|
||||||
|
if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
|
||||||
|
return { label: "session.header.open.fileManager", icon: "finder" as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openAppsForOS(os: OpenAppOS) {
|
||||||
|
if (os === "macos") return MAC_OPEN_APPS
|
||||||
|
if (os === "windows") return WINDOWS_OPEN_APPS
|
||||||
|
return LINUX_OPEN_APPS
|
||||||
|
}
|
||||||
|
|
||||||
|
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpenInApp(input: { directory: () => string }) {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const server = useServer()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const os = createMemo(() => detectOpenAppOS(platform))
|
||||||
|
const apps = createMemo(() => openAppsForOS(os()))
|
||||||
|
const fileManager = createMemo(() => openAppFileManager(os()))
|
||||||
|
|
||||||
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||||
|
finder: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (platform.platform !== "desktop") return
|
||||||
|
if (!platform.checkAppExists) return
|
||||||
|
|
||||||
|
const list = apps()
|
||||||
|
|
||||||
|
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
|
||||||
|
|
||||||
|
void Promise.all(
|
||||||
|
list.map((app) =>
|
||||||
|
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
||||||
|
.then((value) => Boolean(value))
|
||||||
|
.catch(() => false)
|
||||||
|
.then((ok) => [app.id, ok] as const),
|
||||||
|
),
|
||||||
|
).then((entries) => {
|
||||||
|
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = createMemo(() => {
|
||||||
|
return [
|
||||||
|
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
|
||||||
|
...apps()
|
||||||
|
.filter((app) => exists[app.id])
|
||||||
|
.map((app) => ({ ...app, label: language.t(app.label) })),
|
||||||
|
] as const
|
||||||
|
})
|
||||||
|
|
||||||
|
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" }))
|
||||||
|
const [menu, setMenu] = createStore({ open: false })
|
||||||
|
const [openRequest, setOpenRequest] = createStore({
|
||||||
|
app: undefined as OpenApp | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||||
|
const current = createMemo(
|
||||||
|
() =>
|
||||||
|
options().find((o) => o.id === prefs.app) ??
|
||||||
|
options()[0] ??
|
||||||
|
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||||
|
)
|
||||||
|
const opening = createMemo(() => openRequest.app !== undefined)
|
||||||
|
|
||||||
|
const selectApp = (app: OpenApp | "finder") => {
|
||||||
|
if (!options().some((item) => item.id === app)) return
|
||||||
|
setPrefs("app", app)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDir = (app: OpenApp | "finder") => {
|
||||||
|
if (opening() || !canOpen() || !platform.openPath) return
|
||||||
|
const directory = input.directory()
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
const item = options().find((o) => o.id === app)
|
||||||
|
const openWith = item && "openWith" in item ? item.openWith : undefined
|
||||||
|
setOpenRequest("app", app)
|
||||||
|
platform
|
||||||
|
.openPath(directory, openWith)
|
||||||
|
.catch((err: unknown) => showRequestError(language, err))
|
||||||
|
.finally(() => {
|
||||||
|
setOpenRequest("app", undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyPath = () => {
|
||||||
|
const directory = input.directory()
|
||||||
|
if (!directory) return
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(directory)
|
||||||
|
.then(() => {
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("session.share.copy.copied"),
|
||||||
|
description: directory,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => showRequestError(language, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
canOpen,
|
||||||
|
opening,
|
||||||
|
current,
|
||||||
|
options,
|
||||||
|
menu,
|
||||||
|
setMenu,
|
||||||
|
openDir,
|
||||||
|
selectApp,
|
||||||
|
copyPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
import { getSessionContext } from "./session-context-metrics"
|
||||||
|
|
||||||
const assistant = (
|
const assistant = (
|
||||||
id: string,
|
id: string,
|
||||||
@@ -38,10 +38,10 @@ const user = (id: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("getSessionContext", () => {
|
describe("getSessionContext", () => {
|
||||||
test("computes usage from latest assistant with tokens", () => {
|
test("computes token totals and usage from latest assistant with tokens", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
user("u1"),
|
user("u1"),
|
||||||
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
|
||||||
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
|
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
|
||||||
]
|
]
|
||||||
const providers = [
|
const providers = [
|
||||||
@@ -60,6 +60,8 @@ describe("getSessionContext", () => {
|
|||||||
const ctx = getSessionContext(messages, providers)
|
const ctx = getSessionContext(messages, providers)
|
||||||
|
|
||||||
expect(ctx?.message.id).toBe("a2")
|
expect(ctx?.message.id).toBe("a2")
|
||||||
|
expect(ctx?.total).toBe(500)
|
||||||
|
expect(ctx?.input).toBe(300)
|
||||||
expect(ctx?.usage).toBe(50)
|
expect(ctx?.usage).toBe(50)
|
||||||
expect(ctx?.providerLabel).toBe("OpenAI")
|
expect(ctx?.providerLabel).toBe("OpenAI")
|
||||||
expect(ctx?.modelLabel).toBe("GPT-4.1")
|
expect(ctx?.modelLabel).toBe("GPT-4.1")
|
||||||
@@ -94,15 +96,4 @@ describe("getSessionContext", () => {
|
|||||||
|
|
||||||
expect(ctx).toBeUndefined()
|
expect(ctx).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("computes stored session token totals", () => {
|
|
||||||
expect(
|
|
||||||
getSessionTokenTotal({
|
|
||||||
input: 10,
|
|
||||||
output: 20,
|
|
||||||
reasoning: 30,
|
|
||||||
cache: { read: 40, write: 50 },
|
|
||||||
}),
|
|
||||||
).toBe(150)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
|
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
type Provider = {
|
type Provider = {
|
||||||
id: string
|
id: string
|
||||||
@@ -21,6 +21,7 @@ type Context = {
|
|||||||
modelLabel: string
|
modelLabel: string
|
||||||
limit: number | undefined
|
limit: number | undefined
|
||||||
input: number
|
input: number
|
||||||
|
total: number
|
||||||
usage: number | null
|
usage: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
|
|||||||
modelLabel: model?.name ?? message.modelID,
|
modelLabel: model?.name ?? message.modelID,
|
||||||
limit,
|
limit,
|
||||||
input: message.tokens.input,
|
input: message.tokens.input,
|
||||||
|
total,
|
||||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,8 +63,3 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
|
|||||||
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
||||||
return build(messages, providers)
|
return build(messages, providers)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
|
|
||||||
if (!tokens) return undefined
|
|
||||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
import { getSessionContext } from "./session-context-metrics"
|
||||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
import { createSessionContextFormatter } from "./session-context-format"
|
import { createSessionContextFormatter } from "./session-context-format"
|
||||||
|
|
||||||
@@ -135,7 +135,6 @@ export function SessionContextTab() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||||
const tokens = createMemo(() => info()?.tokens)
|
|
||||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
@@ -204,14 +203,15 @@ export function SessionContextTab() {
|
|||||||
{ label: "context.stats.provider", value: providerLabel },
|
{ label: "context.stats.provider", value: providerLabel },
|
||||||
{ label: "context.stats.model", value: modelLabel },
|
{ label: "context.stats.model", value: modelLabel },
|
||||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||||
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
|
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
||||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||||
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
|
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
||||||
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
|
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
|
||||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
|
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
|
||||||
{
|
{
|
||||||
label: "context.stats.cacheTokens",
|
label: "context.stats.cacheTokens",
|
||||||
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
|
value: () =>
|
||||||
|
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
|
||||||
},
|
},
|
||||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { createMemo, Show } from "solid-js"
|
||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { useFile } from "@/context/file"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
|
import { FileVisual } from "./session-sortable-tab"
|
||||||
|
|
||||||
|
export function SortableTabV2(props: {
|
||||||
|
tab: string
|
||||||
|
index: () => number
|
||||||
|
temporary?: boolean
|
||||||
|
onTabClose: (tab: string) => void
|
||||||
|
onTabDoubleClick?: (tab: string) => void
|
||||||
|
}): JSX.Element {
|
||||||
|
const file = useFile()
|
||||||
|
const language = useLanguage()
|
||||||
|
const command = useCommand()
|
||||||
|
const sortable = useSortable({
|
||||||
|
get id() {
|
||||||
|
return props.tab
|
||||||
|
},
|
||||||
|
get index() {
|
||||||
|
return props.index()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||||
|
const content = createMemo(() => {
|
||||||
|
const value = path()
|
||||||
|
if (!value) return
|
||||||
|
return <FileVisual path={value} temporary={props.temporary} />
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div ref={sortable.ref} class="h-full flex items-center">
|
||||||
|
<div class="relative">
|
||||||
|
<Tabs.Trigger
|
||||||
|
value={props.tab}
|
||||||
|
closeButton={
|
||||||
|
<TooltipKeybind
|
||||||
|
title={language.t("common.closeTab")}
|
||||||
|
keybind={command.keybind("tab.close")}
|
||||||
|
placement="bottom"
|
||||||
|
gutter={10}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="close-small"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-5 w-5"
|
||||||
|
onClick={() => props.onTabClose(props.tab)}
|
||||||
|
aria-label={language.t("common.closeTab")}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
hideCloseButton
|
||||||
|
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||||
|
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
|
||||||
|
>
|
||||||
|
<Show when={content()}>{(value) => value()}</Show>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -211,7 +211,17 @@ export function SortableTerminalTabV2(props: {
|
|||||||
<MenuV2.Context.Trigger class="relative" as="div">
|
<MenuV2.Context.Trigger class="relative" as="div">
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value={props.terminal.id}
|
value={props.terminal.id}
|
||||||
onClick={focus}
|
onMouseDown={(e) => {
|
||||||
|
// Switch on mousedown to shave the press-release delay off tab switches.
|
||||||
|
if (e.button !== 0) return
|
||||||
|
if (store.editing) return
|
||||||
|
focus()
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||||
|
if (e.detail > 0) return
|
||||||
|
focus()
|
||||||
|
}}
|
||||||
closeButton={
|
closeButton={
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="close-small"
|
icon="close-small"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select"
|
|||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
@@ -248,6 +249,50 @@ export const SettingsGeneral: Component = () => {
|
|||||||
triggerVariant: "settings" as const,
|
triggerVariant: "settings" as const,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const InterfaceSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
{language.t("settings.general.row.newInterface.title")}
|
||||||
|
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={language.t("settings.general.row.newInterface.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-new-layout-designs">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.newLayoutDesigns()}
|
||||||
|
onChange={(checked) => {
|
||||||
|
settings.general.setNewLayoutDesigns(checked)
|
||||||
|
if (!checked) return
|
||||||
|
void import("@/components/settings-v2").then((module) => {
|
||||||
|
void dialog.show(() => <module.DialogSettings />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const InterfaceNoticeSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||||
|
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||||
|
>
|
||||||
|
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
|
||||||
|
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||||
|
</Button>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const GeneralSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<SettingsList>
|
<SettingsList>
|
||||||
@@ -335,23 +380,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
|
||||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-new-layout-designs">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.newLayoutDesigns()}
|
|
||||||
onChange={(checked) => {
|
|
||||||
settings.general.setNewLayoutDesigns(checked)
|
|
||||||
if (!checked) return
|
|
||||||
void import("@/components/settings-v2").then((module) => {
|
|
||||||
dialog.show(() => <module.DialogSettings />)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -718,6 +746,14 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 w-full">
|
<div class="flex flex-col gap-8 w-full">
|
||||||
|
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||||
|
<InterfaceSection />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||||
|
<InterfaceNoticeSection />
|
||||||
|
</Show>
|
||||||
|
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
|||||||
import { Link } from "../link"
|
import { Link } from "../link"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
|
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
let demoSoundState = {
|
let demoSoundState = {
|
||||||
@@ -226,6 +227,31 @@ export const SettingsGeneralV2: Component<{
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const InterfaceSection = () => (
|
||||||
|
<LayoutTransitionToggle
|
||||||
|
title={language.t("settings.general.row.newInterface.title")}
|
||||||
|
badge={language.t("settings.general.row.newInterface.badge")}
|
||||||
|
description={language.t("settings.general.row.newInterface.description")}
|
||||||
|
checked={settings.general.newLayoutDesigns()}
|
||||||
|
onChange={(checked) => {
|
||||||
|
settings.general.setNewLayoutDesigns(checked)
|
||||||
|
if (checked) return
|
||||||
|
void import("@/components/dialog-settings").then((module) => {
|
||||||
|
void dialog.show(() => <module.DialogSettings />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const InterfaceNoticeSection = () => (
|
||||||
|
<LayoutRetirementNotice
|
||||||
|
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||||
|
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||||
|
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||||
|
onDismiss={settings.general.dismissNewInterfaceNotice}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
const GeneralSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
@@ -312,24 +338,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
|
||||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-new-layout-designs">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.newLayoutDesigns()}
|
|
||||||
onChange={(checked) => {
|
|
||||||
settings.general.setNewLayoutDesigns(checked)
|
|
||||||
if (checked) return
|
|
||||||
void import("@/components/dialog-settings").then((module) => {
|
|
||||||
dialog.show(() => <module.DialogSettings />)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||||
@@ -683,6 +691,14 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
<div class="settings-v2-tab-body">
|
||||||
|
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||||
|
<InterfaceSection />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||||
|
<InterfaceNoticeSection />
|
||||||
|
</Show>
|
||||||
|
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "New layout",
|
||||||
|
badge: "New",
|
||||||
|
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||||
|
noticeTitle: "You're now using new layout",
|
||||||
|
noticeDescription: "The previous layout is no longer available",
|
||||||
|
dismiss: "Dismiss",
|
||||||
|
}
|
||||||
|
|
||||||
|
function Frame(props) {
|
||||||
|
return <div class="w-[640px] max-w-full">{props.children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleExample(props) {
|
||||||
|
const [state, setState] = createStore({ checked: props.checked })
|
||||||
|
return (
|
||||||
|
<Frame>
|
||||||
|
<LayoutTransitionToggle
|
||||||
|
title={copy.title}
|
||||||
|
badge={copy.badge}
|
||||||
|
description={copy.description}
|
||||||
|
checked={state.checked}
|
||||||
|
onChange={(checked) => setState("checked", checked)}
|
||||||
|
/>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoticeExample() {
|
||||||
|
const [state, setState] = createStore({ dismissed: false })
|
||||||
|
return (
|
||||||
|
<Frame>
|
||||||
|
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
|
||||||
|
<LayoutRetirementNotice
|
||||||
|
title={copy.noticeTitle}
|
||||||
|
description={copy.noticeDescription}
|
||||||
|
dismiss={copy.dismiss}
|
||||||
|
onDismiss={() => setState("dismissed", true)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "App/Settings/Layout transition",
|
||||||
|
id: "app-settings-layout-transition",
|
||||||
|
component: LayoutTransitionToggle,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewLayoutEnabled = {
|
||||||
|
render: () => <ToggleExample checked />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviousLayoutEnabled = {
|
||||||
|
render: () => <ToggleExample checked={false} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviousLayoutRetired = {
|
||||||
|
render: () => <NoticeExample />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllStates = {
|
||||||
|
render: () => (
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<ToggleExample checked />
|
||||||
|
<ToggleExample checked={false} />
|
||||||
|
<NoticeExample />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
|
|
||||||
|
export function LayoutTransitionToggle(props: {
|
||||||
|
title: string
|
||||||
|
badge: string
|
||||||
|
description: string
|
||||||
|
checked: boolean
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="settings-v2-interface-feature">
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
{props.title}
|
||||||
|
<Tag variant="accent">{props.badge}</Tag>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={props.description}
|
||||||
|
>
|
||||||
|
<div data-action="settings-new-layout-designs">
|
||||||
|
<Switch checked={props.checked} onChange={props.onChange} />
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutRetirementNotice(props: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
dismiss: string
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2 title={props.title} description={props.description}>
|
||||||
|
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
|
||||||
|
{props.dismiss}
|
||||||
|
</ButtonV2>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-panel::-webkit-scrollbar {
|
.settings-v2-panel::-webkit-scrollbar {
|
||||||
@@ -85,6 +90,11 @@
|
|||||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-interface-feature [data-component="settings-v2-list"] {
|
||||||
|
background-color: var(--v2-background-bg-base);
|
||||||
|
box-shadow: var(--v2-elevation-raised);
|
||||||
|
}
|
||||||
|
|
||||||
[data-component="settings-v2-row"] {
|
[data-component="settings-v2-row"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -123,7 +133,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="settings-v2-row-description"] {
|
[data-slot="settings-v2-row-description"] {
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
font-weight: 440;
|
font-weight: 440;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--v2-text-text-muted);
|
||||||
@@ -181,6 +191,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px 0 4px 4px;
|
padding: 4px 0 4px 4px;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-nav-footer > span {
|
.settings-v2-nav-footer > span {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { withAlpha } from "@opencode-ai/ui/theme/color"
|
import { withAlpha } from "@opencode-ai/ui/theme/color"
|
||||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||||
import type { HexColor } from "@opencode-ai/ui/theme/types"
|
import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve"
|
||||||
|
import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||||
@@ -68,6 +69,19 @@ const debugTerminal = (...values: unknown[]) => {
|
|||||||
console.debug("[terminal]", ...values)
|
console.debug("[terminal]", ...values)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => {
|
||||||
|
let current = tokens[key]
|
||||||
|
for (let i = 0; i < 8 && current; i++) {
|
||||||
|
const match = /^var\(--([^)]+)\)$/.exec(current.trim())
|
||||||
|
if (!match) {
|
||||||
|
const hex = current.trim()
|
||||||
|
if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7)
|
||||||
|
return hex
|
||||||
|
}
|
||||||
|
current = tokens[match[1]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const useTerminalUiBindings = (input: {
|
const useTerminalUiBindings = (input: {
|
||||||
container: HTMLDivElement
|
container: HTMLDivElement
|
||||||
term: Term
|
term: Term
|
||||||
@@ -238,7 +252,10 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
if (!variant?.seeds && !variant?.palette) return fallback
|
if (!variant?.seeds && !variant?.palette) return fallback
|
||||||
const resolved = resolveThemeVariant(variant, mode === "dark")
|
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||||
const text = resolved["text-stronger"] ?? fallback.foreground
|
const text = resolved["text-stronger"] ?? fallback.foreground
|
||||||
const background = resolved["background-stronger"] ?? fallback.background
|
const background = settings.general.newLayoutDesigns()
|
||||||
|
? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ??
|
||||||
|
fallback.background)
|
||||||
|
: (resolved["background-stronger"] ?? fallback.background)
|
||||||
const alpha = mode === "dark" ? 0.25 : 0.2
|
const alpha = mode === "dark" ? 0.25 : 0.2
|
||||||
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
|
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
|
||||||
const selectionBackground = withAlpha(base, alpha)
|
const selectionBackground = withAlpha(base, alpha)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp
|
|||||||
import { useGlobal } from "@/context/global"
|
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 type { PromptSession } from "@/context/prompt"
|
||||||
import "./titlebar.css"
|
import "./titlebar.css"
|
||||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||||
|
|
||||||
@@ -324,13 +325,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
const route = layout.route()
|
const route = layout.route()
|
||||||
const activeSession = session()
|
const activeSession = session()
|
||||||
if (route.type === "session" && activeSession) {
|
if (route.type === "session" && activeSession) {
|
||||||
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
|
const sessionTab = {
|
||||||
|
type: "session" as const,
|
||||||
|
server: route.server ?? server.key,
|
||||||
|
sessionId: activeSession.id,
|
||||||
|
}
|
||||||
|
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
|
||||||
|
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTab = currentTab()
|
const activeTab = currentTab()
|
||||||
if (activeTab?.type === "draft") {
|
if (activeTab?.type === "draft") {
|
||||||
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
|
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
|
||||||
|
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,12 +47,20 @@ export function getAvatarColors(key?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
|
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
|
||||||
if (key === "orange") return "orange"
|
|
||||||
if (key === "pink") return "pink"
|
|
||||||
if (key === "cyan") return "cyan"
|
|
||||||
if (key === "purple") return "purple"
|
|
||||||
if (key === "mint") return "cyan"
|
if (key === "mint") return "cyan"
|
||||||
if (key === "lime") return "green"
|
if (key === "lime") return "green"
|
||||||
|
if (
|
||||||
|
key === "orange" ||
|
||||||
|
key === "yellow" ||
|
||||||
|
key === "cyan" ||
|
||||||
|
key === "green" ||
|
||||||
|
key === "red" ||
|
||||||
|
key === "pink" ||
|
||||||
|
key === "blue" ||
|
||||||
|
key === "purple" ||
|
||||||
|
key === "gray"
|
||||||
|
)
|
||||||
|
return key
|
||||||
return "gray"
|
return "gray"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||||
|
|
||||||
const [saved, setSaved] = persisted(
|
const [saved, setSaved, , savedReady] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
|
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
|
||||||
migrate,
|
migrate,
|
||||||
@@ -375,11 +375,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
model,
|
model,
|
||||||
agent,
|
agent,
|
||||||
session: {
|
session: {
|
||||||
|
ready: savedReady,
|
||||||
reset() {
|
reset() {
|
||||||
setStore({ draft: undefined, promoting: undefined })
|
setStore({ draft: undefined, promoting: undefined })
|
||||||
},
|
},
|
||||||
promote(dir: string, session: string) {
|
promote(dir: string, session: string, state?: State) {
|
||||||
const next = clone(snapshot())
|
const next = clone(state ?? snapshot())
|
||||||
if (!next) return
|
if (!next) return
|
||||||
const key = handoffKey(serverSDK().scope, dir, session)
|
const key = handoffKey(serverSDK().scope, dir, session)
|
||||||
handoff.set(key, next)
|
handoff.set(key, next)
|
||||||
@@ -409,3 +410,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export type ModelSelection = ReturnType<typeof useLocal>["model"]
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ type PlatformBase = {
|
|||||||
/** Open a local path in a local app (desktop only) */
|
/** Open a local path in a local app (desktop only) */
|
||||||
openPath?(path: string, app?: string): Promise<void>
|
openPath?(path: string, app?: string): Promise<void>
|
||||||
|
|
||||||
|
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
|
||||||
|
revealPath?(path: string): Promise<boolean>
|
||||||
|
|
||||||
/** Restart the app */
|
/** Restart the app */
|
||||||
restart(): Promise<void>
|
restart(): Promise<void>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { createRoot } from "solid-js"
|
||||||
|
import { createPromptState, DEFAULT_PROMPT } from "./prompt-state"
|
||||||
|
|
||||||
|
describe("prompt state initialization", () => {
|
||||||
|
test("initializes prompt text, cursor, and model together", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
|
||||||
|
const prompt = createPromptState({ prompt: "hello", model })
|
||||||
|
|
||||||
|
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
|
||||||
|
expect(prompt.cursor()).toBe(5)
|
||||||
|
expect(prompt.model.current()).toEqual(model)
|
||||||
|
expect(prompt.model.current()).not.toBe(model)
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the default prompt without initial values", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const prompt = createPromptState()
|
||||||
|
|
||||||
|
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
|
||||||
|
expect(prompt.cursor()).toBeUndefined()
|
||||||
|
expect(prompt.model.current()).toBeUndefined()
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { checksum } from "@opencode-ai/core/util/encode"
|
||||||
|
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { batch, createMemo, type Accessor } from "solid-js"
|
||||||
|
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||||
|
import type { FileSelection } from "@/context/file"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
|
interface PartBase {
|
||||||
|
content: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextPart extends PartBase {
|
||||||
|
type: "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileAttachmentPart extends PartBase {
|
||||||
|
type: "file"
|
||||||
|
path: string
|
||||||
|
selection?: FileSelection
|
||||||
|
mime?: string
|
||||||
|
filename?: string
|
||||||
|
url?: string
|
||||||
|
source?: FilePartSource
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentPart extends PartBase {
|
||||||
|
type: "agent"
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageAttachmentPart {
|
||||||
|
type: "image"
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
sourcePath?: string
|
||||||
|
mime: string
|
||||||
|
dataUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||||
|
export type Prompt = ContentPart[]
|
||||||
|
|
||||||
|
export type PromptModel = {
|
||||||
|
providerID: string
|
||||||
|
modelID: string
|
||||||
|
variant?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileContextItem = {
|
||||||
|
type: "file"
|
||||||
|
path: string
|
||||||
|
selection?: FileSelection
|
||||||
|
comment?: string
|
||||||
|
commentID?: string
|
||||||
|
commentOrigin?: "review" | "file"
|
||||||
|
preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextItem = FileContextItem
|
||||||
|
export type PromptScope = { draftID: string } | { dir: string; id?: string }
|
||||||
|
|
||||||
|
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
|
type PromptStore = {
|
||||||
|
prompt: Prompt
|
||||||
|
cursor?: number
|
||||||
|
model?: PromptModel
|
||||||
|
context: {
|
||||||
|
items: (ContextItem & { key: string })[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type InitialPrompt = {
|
||||||
|
prompt?: string
|
||||||
|
model?: PromptModel
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
||||||
|
if (!a && !b) return true
|
||||||
|
if (!a || !b) return false
|
||||||
|
return (
|
||||||
|
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
||||||
|
switch (partA.type) {
|
||||||
|
case "text":
|
||||||
|
return partB.type === "text" && partA.content === partB.content
|
||||||
|
case "file":
|
||||||
|
return (
|
||||||
|
partB.type === "file" &&
|
||||||
|
partA.path === partB.path &&
|
||||||
|
partA.mime === partB.mime &&
|
||||||
|
partA.filename === partB.filename &&
|
||||||
|
isSelectionEqual(partA.selection, partB.selection)
|
||||||
|
)
|
||||||
|
case "agent":
|
||||||
|
return partB.type === "agent" && partA.name === partB.name
|
||||||
|
case "image":
|
||||||
|
return partB.type === "image" && partA.id === partB.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||||
|
if (promptA.length !== promptB.length) return false
|
||||||
|
for (let i = 0; i < promptA.length; i++) {
|
||||||
|
if (!isPartEqual(promptA[i], promptB[i])) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneSelection(selection?: FileSelection) {
|
||||||
|
if (!selection) return undefined
|
||||||
|
return { ...selection }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clonePart(part: ContentPart): ContentPart {
|
||||||
|
if (part.type === "text") return { ...part }
|
||||||
|
if (part.type === "image") return { ...part }
|
||||||
|
if (part.type === "agent") return { ...part }
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
selection: cloneSelection(part.selection),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clonePrompt(prompt: Prompt): Prompt {
|
||||||
|
return prompt.map(clonePart)
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextItemKey(item: ContextItem) {
|
||||||
|
if (item.type !== "file") return item.type
|
||||||
|
const start = item.selection?.startLine
|
||||||
|
const end = item.selection?.endLine
|
||||||
|
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||||
|
|
||||||
|
if (item.commentID) return `${key}:c=${item.commentID}`
|
||||||
|
const comment = item.comment?.trim()
|
||||||
|
if (!comment) return key
|
||||||
|
const digest = checksum(comment) ?? comment
|
||||||
|
return `${key}:c=${digest.slice(0, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||||
|
return item.type === "file" && !!item.comment?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
|
||||||
|
return {
|
||||||
|
set(prompt: Prompt, cursorPosition?: number) {
|
||||||
|
const next = clonePrompt(prompt)
|
||||||
|
batch(() => {
|
||||||
|
setStore("prompt", next)
|
||||||
|
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
batch(() => {
|
||||||
|
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||||
|
setStore("cursor", 0)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||||
|
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
||||||
|
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||||
|
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptStore(initial?: InitialPrompt): PromptStore {
|
||||||
|
const text = initial?.prompt
|
||||||
|
return {
|
||||||
|
prompt:
|
||||||
|
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
|
||||||
|
cursor: text === undefined ? undefined : text.length,
|
||||||
|
model: initial?.model ? { ...initial.model } : undefined,
|
||||||
|
context: {
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||||
|
const actions = createPromptActions(setStore)
|
||||||
|
const value = {
|
||||||
|
current: () => store.prompt,
|
||||||
|
cursor: createMemo(() => store.cursor),
|
||||||
|
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||||
|
model: {
|
||||||
|
current: () => store.model,
|
||||||
|
set: (model: PromptModel | undefined) => setStore("model", model),
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
items: createMemo(() => store.context.items),
|
||||||
|
add(item: ContextItem) {
|
||||||
|
const key = contextItemKey(item)
|
||||||
|
if (store.context.items.find((x) => x.key === key)) return
|
||||||
|
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||||
|
},
|
||||||
|
remove(key: string) {
|
||||||
|
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||||
|
},
|
||||||
|
removeComment(path: string, commentID: string) {
|
||||||
|
setStore("context", "items", (items) =>
|
||||||
|
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
||||||
|
setStore("context", "items", (items) =>
|
||||||
|
items.map((item) => {
|
||||||
|
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
||||||
|
const value = { ...item, ...next }
|
||||||
|
return { ...value, key: contextItemKey(value) }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
replaceComments(items: FileContextItem[]) {
|
||||||
|
setStore("context", "items", (current) => [
|
||||||
|
...current.filter((item) => !isCommentItem(item)),
|
||||||
|
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
set: actions.set,
|
||||||
|
reset: actions.reset,
|
||||||
|
capture: () => value,
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
|
||||||
|
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
|
||||||
|
return { ready, ...createPromptStateValue(store, setStore) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
|
||||||
|
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
||||||
|
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptSession = ReturnType<typeof createPromptSession>
|
||||||
|
|
||||||
|
export function createPromptReady(session: Accessor<PromptSession>) {
|
||||||
|
return Object.defineProperty(() => session().ready(), "promise", {
|
||||||
|
get: () => session().ready.promise,
|
||||||
|
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptState(initial?: InitialPrompt) {
|
||||||
|
const [store, setStore] = createStore<PromptStore>(promptStore(initial))
|
||||||
|
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
...createPromptStateValue(store, setStore),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,186 +1,50 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
|
||||||
import { useParams, useSearchParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
|
import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
import type { FileSelection } from "@/context/file"
|
import { ServerConnection } from "./server"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import { useSettings } from "./settings"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useTabs, type Tab } from "./tabs"
|
import { useTabs, type Tab } from "./tabs"
|
||||||
import { ServerConnection } from "./server"
|
import {
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
createPromptReady,
|
||||||
import { useSettings } from "./settings"
|
createPromptSession,
|
||||||
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
type ContextItem,
|
||||||
|
type FileContextItem,
|
||||||
|
type Prompt,
|
||||||
|
type PromptModel,
|
||||||
|
type PromptScope,
|
||||||
|
type PromptSession,
|
||||||
|
} from "./prompt-state"
|
||||||
|
|
||||||
interface PartBase {
|
export {
|
||||||
content: string
|
createPromptReady,
|
||||||
start: number
|
createPromptSession,
|
||||||
end: number
|
createPromptState,
|
||||||
}
|
DEFAULT_PROMPT,
|
||||||
|
isCommentItem,
|
||||||
export interface TextPart extends PartBase {
|
isPromptEqual,
|
||||||
type: "text"
|
} from "./prompt-state"
|
||||||
}
|
export type {
|
||||||
|
AgentPart,
|
||||||
export interface FileAttachmentPart extends PartBase {
|
ContentPart,
|
||||||
type: "file"
|
ContextItem,
|
||||||
path: string
|
FileAttachmentPart,
|
||||||
selection?: FileSelection
|
FileContextItem,
|
||||||
mime?: string
|
ImageAttachmentPart,
|
||||||
filename?: string
|
Prompt,
|
||||||
url?: string
|
PromptModel,
|
||||||
source?: FilePartSource
|
PromptScope,
|
||||||
}
|
PromptSession,
|
||||||
|
TextPart,
|
||||||
export interface AgentPart extends PartBase {
|
} from "./prompt-state"
|
||||||
type: "agent"
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageAttachmentPart {
|
|
||||||
type: "image"
|
|
||||||
id: string
|
|
||||||
filename: string
|
|
||||||
sourcePath?: string
|
|
||||||
mime: string
|
|
||||||
dataUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
|
||||||
export type Prompt = ContentPart[]
|
|
||||||
|
|
||||||
export type FileContextItem = {
|
|
||||||
type: "file"
|
|
||||||
path: string
|
|
||||||
selection?: FileSelection
|
|
||||||
comment?: string
|
|
||||||
commentID?: string
|
|
||||||
commentOrigin?: "review" | "file"
|
|
||||||
preview?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ContextItem = FileContextItem
|
|
||||||
|
|
||||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
|
||||||
|
|
||||||
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
|
||||||
if (!a && !b) return true
|
|
||||||
if (!a || !b) return false
|
|
||||||
return (
|
|
||||||
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
|
||||||
switch (partA.type) {
|
|
||||||
case "text":
|
|
||||||
return partB.type === "text" && partA.content === partB.content
|
|
||||||
case "file":
|
|
||||||
return (
|
|
||||||
partB.type === "file" &&
|
|
||||||
partA.path === partB.path &&
|
|
||||||
partA.mime === partB.mime &&
|
|
||||||
partA.filename === partB.filename &&
|
|
||||||
isSelectionEqual(partA.selection, partB.selection)
|
|
||||||
)
|
|
||||||
case "agent":
|
|
||||||
return partB.type === "agent" && partA.name === partB.name
|
|
||||||
case "image":
|
|
||||||
return partB.type === "image" && partA.id === partB.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
|
||||||
if (promptA.length !== promptB.length) return false
|
|
||||||
for (let i = 0; i < promptA.length; i++) {
|
|
||||||
if (!isPartEqual(promptA[i], promptB[i])) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneSelection(selection?: FileSelection) {
|
|
||||||
if (!selection) return undefined
|
|
||||||
return { ...selection }
|
|
||||||
}
|
|
||||||
|
|
||||||
function clonePart(part: ContentPart): ContentPart {
|
|
||||||
if (part.type === "text") return { ...part }
|
|
||||||
if (part.type === "image") return { ...part }
|
|
||||||
if (part.type === "agent") return { ...part }
|
|
||||||
return {
|
|
||||||
...part,
|
|
||||||
selection: cloneSelection(part.selection),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clonePrompt(prompt: Prompt): Prompt {
|
|
||||||
return prompt.map(clonePart)
|
|
||||||
}
|
|
||||||
|
|
||||||
function contextItemKey(item: ContextItem) {
|
|
||||||
if (item.type !== "file") return item.type
|
|
||||||
const start = item.selection?.startLine
|
|
||||||
const end = item.selection?.endLine
|
|
||||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
|
||||||
|
|
||||||
if (item.commentID) {
|
|
||||||
return `${key}:c=${item.commentID}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const comment = item.comment?.trim()
|
|
||||||
if (!comment) return key
|
|
||||||
const digest = checksum(comment) ?? comment
|
|
||||||
return `${key}:c=${digest.slice(0, 8)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
|
||||||
return item.type === "file" && !!item.comment?.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPromptActions(
|
|
||||||
setStore: SetStoreFunction<{
|
|
||||||
prompt: Prompt
|
|
||||||
cursor?: number
|
|
||||||
context: {
|
|
||||||
items: (ContextItem & { key: string })[]
|
|
||||||
}
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
set(prompt: Prompt, cursorPosition?: number) {
|
|
||||||
const next = clonePrompt(prompt)
|
|
||||||
batch(() => {
|
|
||||||
setStore("prompt", next)
|
|
||||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
reset() {
|
|
||||||
batch(() => {
|
|
||||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
|
||||||
setStore("cursor", 0)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORKSPACE_KEY = "__workspace__"
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
const MAX_PROMPT_SESSIONS = 20
|
const MAX_PROMPT_SESSIONS = 20
|
||||||
|
|
||||||
type PromptSession = ReturnType<typeof createPromptSession>
|
export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) {
|
||||||
|
|
||||||
type PromptStore = {
|
|
||||||
prompt: Prompt
|
|
||||||
cursor?: number
|
|
||||||
context: {
|
|
||||||
items: (ContextItem & { key: string })[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Scope = { draftID: string } | { dir: string; id?: string }
|
|
||||||
|
|
||||||
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
|
|
||||||
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
|
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
|
||||||
if (!scope.id) return
|
if (!scope.id) return
|
||||||
return (
|
return (
|
||||||
@@ -189,7 +53,7 @@ export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnect
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopeKey(scope: Scope) {
|
function scopeKey(scope: PromptScope) {
|
||||||
if ("draftID" in scope) return `draft:${scope.draftID}`
|
if ("draftID" in scope) return `draft:${scope.draftID}`
|
||||||
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
||||||
}
|
}
|
||||||
@@ -199,91 +63,6 @@ type PromptCacheEntry = {
|
|||||||
dispose: VoidFunction
|
dispose: VoidFunction
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptTarget(serverScope: ServerScope, scope: Scope) {
|
|
||||||
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
|
||||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
|
||||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
|
||||||
promptTarget(serverScope, scope),
|
|
||||||
createStore<PromptStore>(promptStore()),
|
|
||||||
)
|
|
||||||
|
|
||||||
return { ready, ...createPromptStateValue(store, setStore) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPromptReady(session: Accessor<PromptSession>) {
|
|
||||||
return Object.defineProperty(() => session().ready(), "promise", {
|
|
||||||
get: () => session().ready.promise,
|
|
||||||
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptStore(): PromptStore {
|
|
||||||
return {
|
|
||||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
|
||||||
cursor: undefined,
|
|
||||||
context: {
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
|
||||||
const actions = createPromptActions(setStore)
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
current: () => store.prompt,
|
|
||||||
cursor: createMemo(() => store.cursor),
|
|
||||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
|
||||||
context: {
|
|
||||||
items: createMemo(() => store.context.items),
|
|
||||||
add(item: ContextItem) {
|
|
||||||
const key = contextItemKey(item)
|
|
||||||
if (store.context.items.find((x) => x.key === key)) return
|
|
||||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
|
||||||
},
|
|
||||||
remove(key: string) {
|
|
||||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
|
||||||
},
|
|
||||||
removeComment(path: string, commentID: string) {
|
|
||||||
setStore("context", "items", (items) =>
|
|
||||||
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
|
||||||
setStore("context", "items", (items) =>
|
|
||||||
items.map((item) => {
|
|
||||||
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
|
||||||
const value = { ...item, ...next }
|
|
||||||
return { ...value, key: contextItemKey(value) }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
replaceComments(items: FileContextItem[]) {
|
|
||||||
setStore("context", "items", (current) => [
|
|
||||||
...current.filter((item) => !isCommentItem(item)),
|
|
||||||
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
|
||||||
])
|
|
||||||
},
|
|
||||||
},
|
|
||||||
set: actions.set,
|
|
||||||
reset: actions.reset,
|
|
||||||
capture: () => value,
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPromptState() {
|
|
||||||
const [store, setStore] = createStore<PromptStore>(promptStore())
|
|
||||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
|
||||||
return {
|
|
||||||
ready,
|
|
||||||
...createPromptStateValue(store, setStore),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createTabPromptState = (
|
export const createTabPromptState = (
|
||||||
tabs: ReturnType<typeof useTabs>,
|
tabs: ReturnType<typeof useTabs>,
|
||||||
tab: Tab,
|
tab: Tab,
|
||||||
@@ -303,9 +82,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
const cache = new Map<string, PromptCacheEntry>()
|
const cache = new Map<string, PromptCacheEntry>()
|
||||||
|
|
||||||
const disposeAll = () => {
|
const disposeAll = () => {
|
||||||
for (const entry of cache.values()) {
|
for (const entry of cache.values()) entry.dispose()
|
||||||
entry.dispose()
|
|
||||||
}
|
|
||||||
cache.clear()
|
cache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,13 +101,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
const serverKey = () =>
|
const serverKey = () =>
|
||||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||||
const scope = () =>
|
const scope = (): PromptScope =>
|
||||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||||
const load = (scope: Scope) => {
|
const load = (scope: PromptScope) => {
|
||||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
||||||
if (current) {
|
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||||
return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = scopeKey(scope)
|
const key = scopeKey(scope)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
@@ -354,15 +129,19 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = createMemo(() => load(scope()))
|
const session = createMemo(() => load(scope()))
|
||||||
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||||
const ready = createPromptReady(session)
|
const ready = createPromptReady(session)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
capture: (scope?: Scope) => pick(scope).capture(),
|
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||||
current: () => session().current(),
|
current: () => session().current(),
|
||||||
cursor: () => session().cursor(),
|
cursor: () => session().cursor(),
|
||||||
dirty: () => session().dirty(),
|
dirty: () => session().dirty(),
|
||||||
|
model: {
|
||||||
|
current: () => session().model.current(),
|
||||||
|
set: (model: PromptModel | undefined) => session().model.set(model),
|
||||||
|
},
|
||||||
context: {
|
context: {
|
||||||
items: () => session().context.items(),
|
items: () => session().context.items(),
|
||||||
add: (item: ContextItem) => session().context.add(item),
|
add: (item: ContextItem) => session().context.add(item),
|
||||||
@@ -372,8 +151,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
session().context.updateComment(path, commentID, next),
|
session().context.updateComment(path, commentID, next),
|
||||||
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
|
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
|
||||||
},
|
},
|
||||||
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
|
set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
|
||||||
reset: (scope?: Scope) => pick(scope).reset(),
|
reset: (scope?: PromptScope) => pick(scope).reset(),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
layoutTransitionState,
|
||||||
|
maximumSunsetTimeout,
|
||||||
|
newLayoutDesignsDefault,
|
||||||
|
nextSunsetCheckDelay,
|
||||||
|
resolveNewLayoutDesigns,
|
||||||
|
} from "./settings"
|
||||||
|
|
||||||
|
describe("layout transition", () => {
|
||||||
|
test("blank profiles default to the new layout", () => {
|
||||||
|
expect(newLayoutDesignsDefault).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hides the transition until a sunset is scheduled", () => {
|
||||||
|
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("existing profiles can switch before sunset", () => {
|
||||||
|
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves explicit and default layout preferences", () => {
|
||||||
|
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
|
||||||
|
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
|
||||||
|
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sunset replaces the toggle with a dismissible notice", () => {
|
||||||
|
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
|
||||||
|
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
|
||||||
|
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("caps checks for sunsets beyond the browser timeout limit", () => {
|
||||||
|
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
|
||||||
|
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
|
||||||
|
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { createEffect, createMemo } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@ export interface Settings {
|
|||||||
showCustomAgents: boolean
|
showCustomAgents: boolean
|
||||||
mobileTitlebarPosition: "top" | "bottom"
|
mobileTitlebarPosition: "top" | "bottom"
|
||||||
newLayoutDesigns?: boolean
|
newLayoutDesigns?: boolean
|
||||||
|
layoutTransitionEligible?: boolean
|
||||||
|
newInterfaceNoticeDismissed?: boolean
|
||||||
}
|
}
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: number
|
fontSize: number
|
||||||
@@ -52,7 +54,28 @@ export interface Settings {
|
|||||||
export const monoDefault = "System Mono"
|
export const monoDefault = "System Mono"
|
||||||
export const sansDefault = "System Sans"
|
export const sansDefault = "System Sans"
|
||||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||||
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
|
export const newLayoutDesignsDefault = true
|
||||||
|
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
||||||
|
export const oldInterfaceSunset = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" ? new Date(2026, 7, 14) : null
|
||||||
|
|
||||||
|
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
||||||
|
return {
|
||||||
|
available: scheduled && eligible && !retired,
|
||||||
|
notice: scheduled && eligible && retired && !dismissed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const maximumSunsetTimeout = 2_147_483_647
|
||||||
|
|
||||||
|
export function nextSunsetCheckDelay(sunset: number, now: number) {
|
||||||
|
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
|
||||||
|
if (retired) return true
|
||||||
|
return preference ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
const monoFallback =
|
const monoFallback =
|
||||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||||
@@ -160,9 +183,50 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
() => store.general?.showCustomAgents,
|
() => store.general?.showCustomAgents,
|
||||||
defaultSettings.general.showCustomAgents,
|
defaultSettings.general.showCustomAgents,
|
||||||
)
|
)
|
||||||
const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault)
|
const sunset = oldInterfaceSunset
|
||||||
|
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
||||||
|
const layoutTransitionClassified = createMemo(
|
||||||
|
() => typeof store.general?.layoutTransitionEligible === "boolean",
|
||||||
|
)
|
||||||
|
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
||||||
|
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
||||||
|
const layoutTransition = createMemo(() =>
|
||||||
|
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
||||||
|
)
|
||||||
|
const newLayoutDesigns = createMemo(() => {
|
||||||
|
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
||||||
|
if (!layoutTransitionClassified()) {
|
||||||
|
return resolveNewLayoutDesigns(oldInterfaceRetired(), store.general?.newLayoutDesigns, legacyNewLayoutDesignsDefault)
|
||||||
|
}
|
||||||
|
return resolveNewLayoutDesigns(
|
||||||
|
oldInterfaceRetired(),
|
||||||
|
store.general?.newLayoutDesigns,
|
||||||
|
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
|
||||||
|
)
|
||||||
|
})
|
||||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
||||||
|
|
||||||
|
if (sunset && !oldInterfaceRetired()) {
|
||||||
|
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
|
const checkSunset = () => {
|
||||||
|
if (Date.now() >= sunset.getTime()) {
|
||||||
|
setOldInterfaceRetired(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
|
||||||
|
}
|
||||||
|
checkSunset()
|
||||||
|
onCleanup(() => {
|
||||||
|
if (timeout.current !== undefined) clearTimeout(timeout.current)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !oldInterfaceRetired()) return
|
||||||
|
if (store.general?.newLayoutDesigns === true) return
|
||||||
|
setStore("general", "newLayoutDesigns", true)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
@@ -250,7 +314,18 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
},
|
},
|
||||||
newLayoutDesigns,
|
newLayoutDesigns,
|
||||||
setNewLayoutDesigns(value: boolean) {
|
setNewLayoutDesigns(value: boolean) {
|
||||||
setStore("general", "newLayoutDesigns", value)
|
setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
|
||||||
|
},
|
||||||
|
layoutTransitionClassified,
|
||||||
|
setOldLayoutEligible(eligible: boolean) {
|
||||||
|
const current = store.general?.layoutTransitionEligible
|
||||||
|
if (typeof current === "boolean") return
|
||||||
|
setStore("general", "layoutTransitionEligible", eligible)
|
||||||
|
},
|
||||||
|
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
||||||
|
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
||||||
|
dismissNewInterfaceNotice() {
|
||||||
|
setStore("general", "newInterfaceNoticeDismissed", true)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
visibility: {
|
visibility: {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export function createTabMemory(owner: Owner | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
get<T>(key: string, name: string) {
|
||||||
|
return entries.get(key)?.get(name)?.value as T | undefined
|
||||||
|
},
|
||||||
ensure<T>(key: string, name: string, init: () => T) {
|
ensure<T>(key: string, name: string, init: () => T) {
|
||||||
const state = entries.get(key) ?? new Map<string, Entry>()
|
const state = entries.get(key) ?? new Map<string, Entry>()
|
||||||
if (!entries.has(key)) entries.set(key, state)
|
if (!entries.has(key)) entries.set(key, state)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ describe("tab memory", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
|
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
|
||||||
|
expect(memory.get<typeof first>("tab", "prompt")).toBe(first)
|
||||||
|
expect(memory.get("missing", "prompt")).toBeUndefined()
|
||||||
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
|
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
|
||||||
|
|
||||||
memory.remove("tab")
|
memory.remove("tab")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
|||||||
import { sessionHref } from "@/utils/session-route"
|
import { sessionHref } from "@/utils/session-route"
|
||||||
import { createTabMemory } from "./tab-memory"
|
import { createTabMemory } from "./tab-memory"
|
||||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||||
|
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||||
|
|
||||||
export type SessionTab = {
|
export type SessionTab = {
|
||||||
type: "session"
|
type: "session"
|
||||||
@@ -207,15 +208,17 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||||
return tab
|
return tab
|
||||||
},
|
},
|
||||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
||||||
const draftID = uuid()
|
const draftID = uuid()
|
||||||
|
const tab = { type: "draft" as const, draftID, ...draft }
|
||||||
|
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
|
||||||
void startTransition(() => {
|
void startTransition(() => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((tabs) => {
|
produce((tabs) => {
|
||||||
tabs.push({ type: "draft", draftID, ...draft })
|
tabs.push(tab)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
navigate(draftHref(draftID))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||||
@@ -373,6 +376,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
state<T>(tab: Tab, name: string, init: () => T) {
|
state<T>(tab: Tab, name: string, init: () => T) {
|
||||||
return memory.ensure(tabKey(tab), name, init)
|
return memory.ensure(tabKey(tab), name, init)
|
||||||
},
|
},
|
||||||
|
stateValue<T>(tab: Tab, name: string) {
|
||||||
|
return memory.get<T>(tabKey(tab), name)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...actions, store, info, ready, recentReady }
|
return { ...actions, store, info, ready, recentReady }
|
||||||
|
|||||||
@@ -735,9 +735,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
|
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
|
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
|
||||||
"settings.general.row.newLayoutDesigns.title": "التخطيط والتصاميم الجديدة",
|
"settings.general.row.newInterface.title": "التخطيط الجديد",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "جديد",
|
||||||
"تمكين التخطيط والصفحة الرئيسية ومحرر الرسائل وواجهة الجلسة المعاد تصميمها",
|
"settings.general.row.newInterface.description":
|
||||||
|
"استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "رفض",
|
||||||
"settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص",
|
"settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص",
|
||||||
"settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير",
|
"settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير",
|
||||||
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
|
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
|
||||||
|
|||||||
@@ -746,9 +746,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
|
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
|
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
|
||||||
"settings.general.row.newLayoutDesigns.title": "Novo layout e design",
|
"settings.general.row.newInterface.title": "Novo layout",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "Novo",
|
||||||
"Ativar o layout, a página inicial, a área de composição e a interface de sessão reformulados",
|
"settings.general.row.newInterface.description":
|
||||||
|
"Use as novas abas e o layout da página inicial. Alterne entre os layouts por tempo limitado.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "Agora você está usando o novo layout",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "O layout anterior não está mais disponível",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
|
||||||
"settings.general.row.pinchZoom.title": "Zoom com gesto de pinça",
|
"settings.general.row.pinchZoom.title": "Zoom com gesto de pinça",
|
||||||
"settings.general.row.pinchZoom.description":
|
"settings.general.row.pinchZoom.description":
|
||||||
"Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom",
|
"Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom",
|
||||||
|
|||||||
@@ -811,9 +811,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
|
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
|
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
|
||||||
"settings.general.row.newLayoutDesigns.title": "Novi raspored i dizajn",
|
"settings.general.row.newInterface.title": "Novi raspored",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "Novo",
|
||||||
"Omogući redizajnirani raspored, početnu stranicu, uređivač poruke i interfejs sesije",
|
"settings.general.row.newInterface.description":
|
||||||
|
"Koristite nove kartice i raspored početne stranice. Ograničeno vrijeme možete se prebacivati između rasporeda.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "Sada koristite novi raspored",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "Prethodni raspored više nije dostupan",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "Odbaci",
|
||||||
"settings.general.row.pinchZoom.title": "Zumiranje štipanjem",
|
"settings.general.row.pinchZoom.title": "Zumiranje štipanjem",
|
||||||
"settings.general.row.pinchZoom.description":
|
"settings.general.row.pinchZoom.description":
|
||||||
"Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja",
|
"Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja",
|
||||||
|
|||||||
@@ -804,9 +804,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
|
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
|
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
|
||||||
"settings.general.row.newLayoutDesigns.title": "Nyt layout og design",
|
"settings.general.row.newInterface.title": "Nyt layout",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "Ny",
|
||||||
"Aktivér det nydesignede layout, startsiden, promptfeltet og sessionsbrugerfladen",
|
"settings.general.row.newInterface.description":
|
||||||
|
"Brug de nye faner og startsidens layout. Du kan skifte mellem layoutene i en begrænset periode.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "Du bruger nu det nye layout",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "Det tidligere layout er ikke længere tilgængeligt",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "Afvis",
|
||||||
"settings.general.row.pinchZoom.title": "Knib for at zoome",
|
"settings.general.row.pinchZoom.title": "Knib for at zoome",
|
||||||
"settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome",
|
"settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome",
|
||||||
"settings.general.row.wayland.title": "Brug native Wayland",
|
"settings.general.row.wayland.title": "Brug native Wayland",
|
||||||
|
|||||||
@@ -759,9 +759,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
|
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||||
"settings.general.row.newLayoutDesigns.title": "Neues Layout und Design",
|
"settings.general.row.newInterface.title": "Neues Layout",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "Neu",
|
||||||
"Neu gestaltetes Layout, Startseite, Eingabebereich und Sitzungsoberfläche aktivieren",
|
"settings.general.row.newInterface.description":
|
||||||
|
"Verwenden Sie die neuen Tabs und das Startseitenlayout. Für begrenzte Zeit können Sie zwischen den Layouts wechseln.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "Sie verwenden jetzt das neue Layout",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "Das vorherige Layout ist nicht mehr verfügbar",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "Verwerfen",
|
||||||
"settings.general.row.pinchZoom.title": "Zoom per Fingergeste",
|
"settings.general.row.pinchZoom.title": "Zoom per Fingergeste",
|
||||||
"settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben",
|
"settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben",
|
||||||
"settings.general.row.wayland.title": "Natives Wayland verwenden",
|
"settings.general.row.wayland.title": "Natives Wayland verwenden",
|
||||||
|
|||||||
@@ -638,7 +638,7 @@ export const dict = {
|
|||||||
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
|
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
|
||||||
"session.error.notFound.closeTab": "Close Tab",
|
"session.error.notFound.closeTab": "Close Tab",
|
||||||
"session.error.serverConnection": "Can't connect to this server",
|
"session.error.serverConnection": "Can't connect to this server",
|
||||||
"session.review.filesChanged": "{{count}} Files Changed",
|
"session.review.filesChanged": "Files Changed {{count}}",
|
||||||
"session.review.change.one": "Change",
|
"session.review.change.one": "Change",
|
||||||
"session.review.change.other": "Changes",
|
"session.review.change.other": "Changes",
|
||||||
"session.review.loadingChanges": "Loading changes...",
|
"session.review.loadingChanges": "Loading changes...",
|
||||||
@@ -855,9 +855,8 @@ export const dict = {
|
|||||||
|
|
||||||
"settings.general.row.language.title": "Language",
|
"settings.general.row.language.title": "Language",
|
||||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
"settings.general.row.language.description": "Change the display language for OpenCode",
|
||||||
"settings.general.row.shell.title": "Terminal Shell",
|
"settings.general.row.shell.title": "Terminal shell",
|
||||||
"settings.general.row.shell.description":
|
"settings.general.row.shell.description": "Shell used by the terminal and agent tools",
|
||||||
"Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.",
|
|
||||||
"settings.general.row.shell.autoDefault": "Auto (Default)",
|
"settings.general.row.shell.autoDefault": "Auto (Default)",
|
||||||
"settings.general.row.shell.terminalOnly": "terminal only",
|
"settings.general.row.shell.terminalOnly": "terminal only",
|
||||||
"settings.general.row.appearance.title": "Appearance",
|
"settings.general.row.appearance.title": "Appearance",
|
||||||
@@ -889,8 +888,9 @@ export const dict = {
|
|||||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||||
"settings.general.row.mobileTitlebarBottom.description":
|
"settings.general.row.mobileTitlebarBottom.description":
|
||||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||||
"settings.general.row.showCustomAgents.title": "Custom agents",
|
"settings.general.row.showCustomAgents.title": "Show agent",
|
||||||
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
|
"settings.general.row.showCustomAgents.description":
|
||||||
|
"Switch between agents in the composer. When hidden, defaults to Build agent.",
|
||||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||||
@@ -899,8 +899,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
|
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"Show edit, write, and patch tool parts expanded by default in the timeline",
|
"Show edit, write, and patch tool parts expanded by default in the timeline",
|
||||||
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
|
"settings.general.row.newInterface.title": "New layout",
|
||||||
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
|
"settings.general.row.newInterface.badge": "New",
|
||||||
|
"settings.general.row.newInterface.description":
|
||||||
|
"Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "You're now using new layout",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "The previous layout is no longer available",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "Dismiss",
|
||||||
"settings.general.row.pinchZoom.title": "Pinch to zoom",
|
"settings.general.row.pinchZoom.title": "Pinch to zoom",
|
||||||
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
|
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
|
||||||
|
|
||||||
|
|||||||
@@ -816,9 +816,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
|
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
|
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
|
||||||
"settings.general.row.newLayoutDesigns.title": "Nuevo diseño y estilos",
|
"settings.general.row.newInterface.title": "Nuevo diseño",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "Nuevo",
|
||||||
"Activar el nuevo diseño de la interfaz, la página de inicio, el editor y las sesiones",
|
"settings.general.row.newInterface.description":
|
||||||
|
"Usa las nuevas pestañas y el diseño de la página de inicio. Durante un tiempo limitado, puedes cambiar entre los diseños.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "Ahora estás usando el nuevo diseño",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "El diseño anterior ya no está disponible",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
|
||||||
"settings.general.row.pinchZoom.title": "Pellizcar para ampliar",
|
"settings.general.row.pinchZoom.title": "Pellizcar para ampliar",
|
||||||
"settings.general.row.pinchZoom.description":
|
"settings.general.row.pinchZoom.description":
|
||||||
"Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll",
|
"Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll",
|
||||||
|
|||||||
@@ -756,9 +756,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
|
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
|
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
|
||||||
"settings.general.row.newLayoutDesigns.title": "Nouvelle mise en page et nouveaux designs",
|
"settings.general.row.newInterface.title": "Nouvelle mise en page",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "Nouveau",
|
||||||
"Activer la nouvelle mise en page et les nouvelles interfaces d'accueil, de saisie et de session",
|
"settings.general.row.newInterface.description":
|
||||||
|
"Utilisez les nouveaux onglets et la mise en page de l'accueil. Vous pouvez alterner entre les mises en page pendant une durée limitée.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "Vous utilisez maintenant la nouvelle mise en page",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "La mise en page précédente n'est plus disponible",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "Ignorer",
|
||||||
"settings.general.row.pinchZoom.title": "Pincer pour zoomer",
|
"settings.general.row.pinchZoom.title": "Pincer pour zoomer",
|
||||||
"settings.general.row.pinchZoom.description":
|
"settings.general.row.pinchZoom.description":
|
||||||
"Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer",
|
"Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer",
|
||||||
|
|||||||
@@ -741,9 +741,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
|
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
|
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
|
||||||
"settings.general.row.newLayoutDesigns.title": "新しいレイアウトとデザイン",
|
"settings.general.row.newInterface.title": "新しいレイアウト",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "新機能",
|
||||||
"刷新されたレイアウト、ホーム、コンポーザー、セッションUIを有効にします",
|
"settings.general.row.newInterface.description":
|
||||||
|
"新しいタブとホーム画面のレイアウトを使用します。期間限定でレイアウトを切り替えられます。",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "新しいレイアウトを使用しています",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "以前のレイアウトは利用できなくなりました",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "閉じる",
|
||||||
"settings.general.row.pinchZoom.title": "ピンチでズーム",
|
"settings.general.row.pinchZoom.title": "ピンチでズーム",
|
||||||
"settings.general.row.pinchZoom.description":
|
"settings.general.row.pinchZoom.description":
|
||||||
"トラックパッドのピンチ操作とCtrl+スクロール操作によるズームを許可します",
|
"トラックパッドのピンチ操作とCtrl+スクロール操作によるズームを許可します",
|
||||||
|
|||||||
@@ -999,8 +999,13 @@ export const dict = {
|
|||||||
"settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치",
|
"settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치",
|
||||||
"settings.general.row.showCustomAgents.title": "사용자 지정 에이전트",
|
"settings.general.row.showCustomAgents.title": "사용자 지정 에이전트",
|
||||||
"settings.general.row.showCustomAgents.description": "입력창에 에이전트 선택기 표시",
|
"settings.general.row.showCustomAgents.description": "입력창에 에이전트 선택기 표시",
|
||||||
"settings.general.row.newLayoutDesigns.title": "새 레이아웃 및 디자인",
|
"settings.general.row.newInterface.title": "새 레이아웃",
|
||||||
"settings.general.row.newLayoutDesigns.description": "새롭게 디자인된 레이아웃, 홈, 입력창 및 세션 UI 활성화",
|
"settings.general.row.newInterface.badge": "신규",
|
||||||
|
"settings.general.row.newInterface.description":
|
||||||
|
"새 탭과 홈 화면 레이아웃을 사용합니다. 제한된 기간 동안 레이아웃을 전환할 수 있습니다.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "이제 새 레이아웃을 사용 중입니다",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "이전 레이아웃은 더 이상 사용할 수 없습니다",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "닫기",
|
||||||
"settings.general.row.pinchZoom.title": "핀치 줌",
|
"settings.general.row.pinchZoom.title": "핀치 줌",
|
||||||
"settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용",
|
"settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용",
|
||||||
"settings.updates.action.downloading": "다운로드 중...",
|
"settings.updates.action.downloading": "다운로드 중...",
|
||||||
|
|||||||
@@ -1095,9 +1095,13 @@ export const dict = {
|
|||||||
"Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen",
|
"Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen",
|
||||||
"settings.general.row.showCustomAgents.title": "Egendefinerte agenter",
|
"settings.general.row.showCustomAgents.title": "Egendefinerte agenter",
|
||||||
"settings.general.row.showCustomAgents.description": "Vis agentvelgeren i skrivefeltet",
|
"settings.general.row.showCustomAgents.description": "Vis agentvelgeren i skrivefeltet",
|
||||||
"settings.general.row.newLayoutDesigns.title": "Ny layout og utforming",
|
"settings.general.row.newInterface.title": "Nytt oppsett",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "Ny",
|
||||||
"Aktiver ny layout og utforming for startsiden, skrivefeltet og sesjonsgrensesnittet",
|
"settings.general.row.newInterface.description":
|
||||||
|
"Bruk de nye fanene og startsidens oppsett. I en begrenset periode kan du bytte mellom oppsettene.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "Du bruker nå det nye oppsettet",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "Det forrige oppsettet er ikke lenger tilgjengelig",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "Avvis",
|
||||||
"settings.general.row.pinchZoom.title": "Knip for å zoome",
|
"settings.general.row.pinchZoom.title": "Knip for å zoome",
|
||||||
"settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome",
|
"settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome",
|
||||||
"settings.updates.action.downloading": "Laster ned...",
|
"settings.updates.action.downloading": "Laster ned...",
|
||||||
|
|||||||
@@ -746,9 +746,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
|
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
|
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
|
||||||
"settings.general.row.newLayoutDesigns.title": "Nowy układ i wygląd",
|
"settings.general.row.newInterface.title": "Nowy układ",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "Nowość",
|
||||||
"Włącz przeprojektowany układ, stronę główną, edytor wiadomości i interfejs sesji",
|
"settings.general.row.newInterface.description":
|
||||||
|
"Używaj nowych kart i układu strony głównej. Przez ograniczony czas możesz przełączać się między układami.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "Korzystasz teraz z nowego układu",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "Poprzedni układ nie jest już dostępny",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "Odrzuć",
|
||||||
"settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania",
|
"settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania",
|
||||||
"settings.general.row.pinchZoom.description":
|
"settings.general.row.pinchZoom.description":
|
||||||
"Zezwalaj na powiększanie gestem szczypania na gładziku i przewijaniem z klawiszem Ctrl",
|
"Zezwalaj na powiększanie gestem szczypania na gładziku i przewijaniem z klawiszem Ctrl",
|
||||||
|
|||||||
@@ -812,9 +812,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
|
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
|
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
|
||||||
"settings.general.row.newLayoutDesigns.title": "Новая компоновка и оформление",
|
"settings.general.row.newInterface.title": "Новая компоновка",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "Новое",
|
||||||
"Включить обновлённую компоновку, главную страницу, редактор запросов и интерфейс сессии",
|
"settings.general.row.newInterface.description":
|
||||||
|
"Используйте новые вкладки и компоновку главной страницы. В течение ограниченного времени можно переключаться между вариантами компоновки.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "Теперь вы используете новую компоновку",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "Прежняя компоновка больше недоступна",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "Закрыть",
|
||||||
"settings.general.row.pinchZoom.title": "Масштабирование щипком",
|
"settings.general.row.pinchZoom.title": "Масштабирование щипком",
|
||||||
"settings.general.row.pinchZoom.description":
|
"settings.general.row.pinchZoom.description":
|
||||||
"Разрешить масштабирование жестом щипка на трекпаде и прокруткой с Ctrl",
|
"Разрешить масштабирование жестом щипка на трекпаде и прокруткой с Ctrl",
|
||||||
|
|||||||
@@ -801,9 +801,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
|
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
|
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
|
||||||
"settings.general.row.newLayoutDesigns.title": "เลย์เอาต์และดีไซน์ใหม่",
|
"settings.general.row.newInterface.title": "เลย์เอาต์ใหม่",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "ใหม่",
|
||||||
"เปิดใช้เลย์เอาต์ หน้าแรก ช่องเขียนข้อความ และ UI เซสชันที่ออกแบบใหม่",
|
"settings.general.row.newInterface.description":
|
||||||
|
"ใช้แท็บใหม่และเลย์เอาต์หน้าแรก คุณสามารถสลับระหว่างเลย์เอาต์ได้ในช่วงเวลาจำกัด",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "ขณะนี้คุณกำลังใช้เลย์เอาต์ใหม่",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "เลย์เอาต์ก่อนหน้าไม่พร้อมใช้งานอีกต่อไป",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "ปิด",
|
||||||
"settings.general.row.pinchZoom.title": "บีบนิ้วเพื่อซูม",
|
"settings.general.row.pinchZoom.title": "บีบนิ้วเพื่อซูม",
|
||||||
"settings.general.row.pinchZoom.description": "อนุญาตให้ใช้ท่าบีบนิ้วบนแทร็คแพดและ Ctrl-scroll เพื่อซูม",
|
"settings.general.row.pinchZoom.description": "อนุญาตให้ใช้ท่าบีบนิ้วบนแทร็คแพดและ Ctrl-scroll เพื่อซูม",
|
||||||
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
|
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
|
||||||
|
|||||||
@@ -816,9 +816,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
|
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
|
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
|
||||||
"settings.general.row.newLayoutDesigns.title": "Yeni düzen ve tasarımlar",
|
"settings.general.row.newInterface.title": "Yeni düzen",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "Yeni",
|
||||||
"Yeniden tasarlanan düzeni, ana sayfayı, yazma alanını ve oturum arayüzünü etkinleştir",
|
"settings.general.row.newInterface.description":
|
||||||
|
"Yeni sekmeleri ve ana sayfa düzenini kullanın. Sınırlı bir süre boyunca düzenler arasında geçiş yapabilirsiniz.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "Artık yeni düzeni kullanıyorsunuz",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "Önceki düzen artık kullanılamıyor",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "Kapat",
|
||||||
"settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır",
|
"settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır",
|
||||||
"settings.general.row.pinchZoom.description":
|
"settings.general.row.pinchZoom.description":
|
||||||
"İzleme dörtgeninde sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver",
|
"İzleme dörtgeninde sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver",
|
||||||
|
|||||||
@@ -902,9 +902,13 @@ export const dict = {
|
|||||||
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
|
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
|
||||||
"settings.general.row.editToolPartsExpanded.description":
|
"settings.general.row.editToolPartsExpanded.description":
|
||||||
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
|
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
|
||||||
"settings.general.row.newLayoutDesigns.title": "Новий макет і дизайн",
|
"settings.general.row.newInterface.title": "Новий макет",
|
||||||
"settings.general.row.newLayoutDesigns.description":
|
"settings.general.row.newInterface.badge": "Нове",
|
||||||
"Увімкнути оновлений макет та інтерфейси головної сторінки, редактора запитів і сесії",
|
"settings.general.row.newInterface.description":
|
||||||
|
"Використовуйте нові вкладки та макет головної сторінки. Протягом обмеженого часу можна перемикатися між макетами.",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "Тепер ви використовуєте новий макет",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "Попередній макет більше недоступний",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "Відхилити",
|
||||||
"settings.general.row.pinchZoom.title": "Масштабування щипком",
|
"settings.general.row.pinchZoom.title": "Масштабування щипком",
|
||||||
"settings.general.row.pinchZoom.description":
|
"settings.general.row.pinchZoom.description":
|
||||||
"Дозволити масштабування жестом щипка на трекпаді та прокручуванням із Ctrl",
|
"Дозволити масштабування жестом щипка на трекпаді та прокручуванням із Ctrl",
|
||||||
|
|||||||
@@ -796,8 +796,12 @@ export const dict = {
|
|||||||
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
|
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
|
||||||
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
|
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
|
||||||
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
|
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
|
||||||
"settings.general.row.newLayoutDesigns.title": "新版布局和设计",
|
"settings.general.row.newInterface.title": "新布局",
|
||||||
"settings.general.row.newLayoutDesigns.description": "启用重新设计的布局、主页、输入框和会话界面",
|
"settings.general.row.newInterface.badge": "新",
|
||||||
|
"settings.general.row.newInterface.description": "使用新的标签页和主页布局。在有限时间内可在不同布局之间切换。",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "你现在使用的是新布局",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "之前的布局不再可用",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "忽略",
|
||||||
"settings.general.row.pinchZoom.title": "双指缩放",
|
"settings.general.row.pinchZoom.title": "双指缩放",
|
||||||
"settings.general.row.pinchZoom.description": "允许使用触控板双指捏合和 Ctrl-scroll 手势缩放",
|
"settings.general.row.pinchZoom.description": "允许使用触控板双指捏合和 Ctrl-scroll 手势缩放",
|
||||||
"settings.general.row.wayland.title": "使用原生 Wayland",
|
"settings.general.row.wayland.title": "使用原生 Wayland",
|
||||||
|
|||||||
@@ -792,8 +792,12 @@ export const dict = {
|
|||||||
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
|
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
|
||||||
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
|
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
|
||||||
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
|
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
|
||||||
"settings.general.row.newLayoutDesigns.title": "新版面與設計",
|
"settings.general.row.newInterface.title": "新版面",
|
||||||
"settings.general.row.newLayoutDesigns.description": "啟用重新設計的版面、首頁、輸入區和工作階段介面",
|
"settings.general.row.newInterface.badge": "新",
|
||||||
|
"settings.general.row.newInterface.description": "使用新的分頁和首頁版面。在限定時間內可切換版面。",
|
||||||
|
"settings.general.row.newInterfaceNotice.title": "你現在使用的是新版面",
|
||||||
|
"settings.general.row.newInterfaceNotice.description": "先前的版面已無法使用",
|
||||||
|
"settings.general.row.newInterfaceNotice.dismiss": "忽略",
|
||||||
"settings.general.row.pinchZoom.title": "雙指縮放",
|
"settings.general.row.pinchZoom.title": "雙指縮放",
|
||||||
"settings.general.row.pinchZoom.description": "允許使用觸控板雙指開合和 Ctrl-捲動手勢縮放",
|
"settings.general.row.pinchZoom.description": "允許使用觸控板雙指開合和 Ctrl-捲動手勢縮放",
|
||||||
"settings.general.row.wayland.title": "使用原生 Wayland",
|
"settings.general.row.wayland.title": "使用原生 Wayland",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export { useLayout } from "./context/layout"
|
|||||||
export { useServerSDK } from "./context/server-sdk"
|
export { useServerSDK } from "./context/server-sdk"
|
||||||
export { useServerSync } from "./context/server-sync"
|
export { useServerSync } from "./context/server-sync"
|
||||||
export { useServer } from "./context/server"
|
export { useServer } from "./context/server"
|
||||||
|
export { useSettings } from "./context/settings"
|
||||||
export { useTabs } from "./context/tabs"
|
export { useTabs } from "./context/tabs"
|
||||||
export { useProviders } from "./hooks/use-providers"
|
export { useProviders } from "./hooks/use-providers"
|
||||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||||
|
|||||||
@@ -2,11 +2,30 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { shouldOpenSessionInBackground } from "./home-session-open"
|
import { shouldOpenSessionInBackground } from "./home-session-open"
|
||||||
|
|
||||||
describe("shouldOpenSessionInBackground", () => {
|
describe("shouldOpenSessionInBackground", () => {
|
||||||
|
test("opens middle clicks in the background", () => {
|
||||||
|
expect(
|
||||||
|
shouldOpenSessionInBackground({ button: 1, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
shouldOpenSessionInBackground({ button: 2, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test("requires only the platform primary modifier", () => {
|
test("requires only the platform primary modifier", () => {
|
||||||
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true)
|
expect(
|
||||||
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true)
|
shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false }),
|
||||||
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false)
|
).toBe(true)
|
||||||
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false)
|
expect(
|
||||||
expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false)
|
shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: false }),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: true, alt: false }),
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: true }),
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
shouldOpenSessionInBackground({ button: 0, mac: false, meta: true, ctrl: false, shift: false, alt: false }),
|
||||||
|
).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
export function shouldOpenSessionInBackground(input: {
|
export function shouldOpenSessionInBackground(input: {
|
||||||
|
button: number
|
||||||
mac: boolean
|
mac: boolean
|
||||||
meta: boolean
|
meta: boolean
|
||||||
ctrl: boolean
|
ctrl: boolean
|
||||||
shift: boolean
|
shift: boolean
|
||||||
alt: boolean
|
alt: boolean
|
||||||
}) {
|
}) {
|
||||||
|
if (input.button === 1) return true
|
||||||
|
if (input.button !== 0) return false
|
||||||
if (input.shift || input.alt) return false
|
if (input.shift || input.alt) return false
|
||||||
if (input.mac) return input.meta && !input.ctrl
|
if (input.mac) return input.meta && !input.ctrl
|
||||||
return input.ctrl && !input.meta
|
return input.ctrl && !input.meta
|
||||||
|
|||||||
@@ -240,10 +240,11 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
|||||||
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
|
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the
|
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
|
||||||
// background without navigating, matching browser conventions.
|
// tab in the background without navigating, matching browser conventions.
|
||||||
function isBackgroundOpen(event: MouseEvent) {
|
function isBackgroundOpen(event: MouseEvent) {
|
||||||
return shouldOpenSessionInBackground({
|
return shouldOpenSessionInBackground({
|
||||||
|
button: event.button,
|
||||||
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
|
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
|
||||||
meta: event.metaKey,
|
meta: event.metaKey,
|
||||||
ctrl: event.ctrlKey,
|
ctrl: event.ctrlKey,
|
||||||
@@ -465,8 +466,8 @@ export function NewHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editProject(conn: ServerConnection.Any, project: LocalProject) {
|
function editProject(conn: ServerConnection.Any, project: LocalProject) {
|
||||||
void import("@/components/dialog-edit-project").then((x) => {
|
void import("@/components/dialog-edit-project-v2").then((x) => {
|
||||||
dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
|
void dialog.show(() => <x.DialogEditProjectV2 server={conn} project={project} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1386,7 +1387,15 @@ function HomeSessionSearchResultRow(props: {
|
|||||||
group: !!showProjectName(),
|
group: !!showProjectName(),
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => props.onHighlight()}
|
onMouseEnter={() => props.onHighlight()}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.button === 1) event.preventDefault()
|
||||||
|
}}
|
||||||
onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
|
onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
|
||||||
|
onAuxClick={(event) => {
|
||||||
|
if (!isBackgroundOpen(event)) return
|
||||||
|
event.preventDefault()
|
||||||
|
props.onSelect(props.record.session, { background: true })
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<HomeSessionLeading
|
<HomeSessionLeading
|
||||||
project={props.record.project}
|
project={props.record.project}
|
||||||
@@ -1446,7 +1455,15 @@ function HomeSessionRow(props: {
|
|||||||
type="button"
|
type="button"
|
||||||
data-component="home-session-row"
|
data-component="home-session-row"
|
||||||
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
|
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.button === 1) event.preventDefault()
|
||||||
|
}}
|
||||||
onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
|
onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
|
||||||
|
onAuxClick={(event) => {
|
||||||
|
if (!isBackgroundOpen(event)) return
|
||||||
|
event.preventDefault()
|
||||||
|
props.openSession(props.record.session, { background: true })
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<HomeSessionLeading
|
<HomeSessionLeading
|
||||||
project={props.record.project}
|
project={props.record.project}
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
|||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||||
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import createPresence from "solid-presence"
|
import createPresence from "solid-presence"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||||
|
|
||||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||||
@@ -48,12 +51,15 @@ export default function NewSessionPage() {
|
|||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const command = useCommand()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const openProviderSettings = useSettingsDialog("providers")
|
const openProviderSettings = useSettingsDialog("providers")
|
||||||
const route = useSessionKey()
|
const route = useSessionKey()
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
|
const local = useLocal()
|
||||||
|
const model = createPromptModelSelection({ agent: local.agent.current })
|
||||||
|
|
||||||
useComposerCommands()
|
useComposerCommands({ model })
|
||||||
|
|
||||||
let inputRef: HTMLDivElement | undefined
|
let inputRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
@@ -61,6 +67,7 @@ export default function NewSessionPage() {
|
|||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
sessionID: () => route.params.id,
|
sessionID: () => route.params.id,
|
||||||
queryOptions: serverSync().queryOptions,
|
queryOptions: serverSync().queryOptions,
|
||||||
|
model,
|
||||||
})
|
})
|
||||||
const projectControls = createPromptProjectControls()
|
const projectControls = createPromptProjectControls()
|
||||||
const projectController = createPromptProjectController({
|
const projectController = createPromptProjectController({
|
||||||
@@ -68,6 +75,16 @@ export default function NewSessionPage() {
|
|||||||
onDone: () => inputRef?.focus(),
|
onDone: () => inputRef?.focus(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
command.register("new-session", () => [
|
||||||
|
{
|
||||||
|
id: "input.focus",
|
||||||
|
title: language.t("command.input.focus"),
|
||||||
|
category: language.t("command.category.view"),
|
||||||
|
keybind: "ctrl+l",
|
||||||
|
onSelect: () => inputRef?.focus(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||||
const rightMount = useTitlebarRightMount()
|
const rightMount = useTitlebarRightMount()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||||
import {
|
import {
|
||||||
@@ -69,7 +70,7 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
|
|||||||
import { createTimelineModel } from "@/pages/session/timeline/model"
|
import { createTimelineModel } from "@/pages/session/timeline/model"
|
||||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||||
import {
|
import {
|
||||||
clampSessionPanelWidth,
|
clampSessionPanelWidth,
|
||||||
SESSION_PANEL_WIDTH_MIN,
|
SESSION_PANEL_WIDTH_MIN,
|
||||||
@@ -79,6 +80,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
|||||||
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
|
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
|
||||||
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
|
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
|
||||||
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
|
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
|
||||||
|
import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
|
||||||
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
|
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
|
||||||
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||||
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
|
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
|
||||||
@@ -483,7 +485,7 @@ export default function Page() {
|
|||||||
if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px`
|
if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px`
|
||||||
return `calc(100% - ${layout.fileTree.width()}px)`
|
return `calc(100% - ${layout.fileTree.width()}px)`
|
||||||
})
|
})
|
||||||
const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
|
const centered = createMemo(() => isDesktop() && (newSessionDesign() || !desktopReviewOpen()))
|
||||||
const desktopV2PanelLayout = createMemo(() =>
|
const desktopV2PanelLayout = createMemo(() =>
|
||||||
sessionPanelLayout({
|
sessionPanelLayout({
|
||||||
review: desktopV2ReviewOpen(),
|
review: desktopV2ReviewOpen(),
|
||||||
@@ -557,6 +559,17 @@ export default function Page() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let restoredModelSession: string | undefined
|
||||||
|
createEffect(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id || !prompt.ready() || !local.session.ready()) return
|
||||||
|
if (restoredModelSession !== id) {
|
||||||
|
restoredModelSession = id
|
||||||
|
if (restorePromptModel(local, prompt)) return
|
||||||
|
}
|
||||||
|
syncPromptModel(local, prompt)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => ({ dir: sdk().directory, id: params.id }),
|
() => ({ dir: sdk().directory, id: params.id }),
|
||||||
@@ -1304,7 +1317,7 @@ export default function Page() {
|
|||||||
const reviewPanelV2Rendered = createMemo<boolean>((prev) => prev || !store.deferRender, false)
|
const reviewPanelV2Rendered = createMemo<boolean>((prev) => prev || !store.deferRender, false)
|
||||||
|
|
||||||
const reviewPanelV2 = () => (
|
const reviewPanelV2 = () => (
|
||||||
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
|
<div class="flex flex-col h-full overflow-hidden bg-v2-background-bg-base contain-strict">
|
||||||
<Show when={reviewPanelV2Rendered()}>
|
<Show when={reviewPanelV2Rendered()}>
|
||||||
<ReviewPanelV2 {...reviewPanelV2Props()} />
|
<ReviewPanelV2 {...reviewPanelV2Props()} />
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1893,7 +1906,30 @@ export default function Page() {
|
|||||||
.map((item) => ({ id: item.id, text: line(item.id) }))
|
.map((item) => ({ id: item.id, text: line(item.id) }))
|
||||||
})
|
})
|
||||||
|
|
||||||
const actions = { revert }
|
// attachment bytes are embedded as a data URL, so downloading always works;
|
||||||
|
// revealing requires the on-disk path captured by the client that attached the file
|
||||||
|
const openAttachment = (file: FilePart) => {
|
||||||
|
const download = () => {
|
||||||
|
const anchor = document.createElement("a")
|
||||||
|
anchor.href = file.url
|
||||||
|
anchor.download = getFilename(file.filename) || "attachment"
|
||||||
|
anchor.click()
|
||||||
|
}
|
||||||
|
const path = file.filename ?? ""
|
||||||
|
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
|
||||||
|
if (platform.revealPath && absolute) {
|
||||||
|
void platform.revealPath(path).then(
|
||||||
|
(revealed) => {
|
||||||
|
if (!revealed) download()
|
||||||
|
},
|
||||||
|
() => download(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
download()
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = { revert, openAttachment }
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
@@ -2265,6 +2301,13 @@ export default function Page() {
|
|||||||
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
||||||
reviewCount={reviewCount}
|
reviewCount={reviewCount}
|
||||||
reviewPanel={reviewPanelV2}
|
reviewPanel={reviewPanelV2}
|
||||||
|
reviewSidebarToggle={(disabled) => (
|
||||||
|
<SessionReviewV2SidebarToggle
|
||||||
|
opened={reviewV2State.sidebarOpened()}
|
||||||
|
disabled={disabled}
|
||||||
|
onToggle={reviewV2State.toggleSidebar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
fileBrowserState={reviewV2State}
|
fileBrowserState={reviewV2State}
|
||||||
activeDiff={activeReviewFile()}
|
activeDiff={activeReviewFile()}
|
||||||
focusReviewDiff={focusReviewDiff}
|
focusReviewDiff={focusReviewDiff}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { batch, createMemo, startTransition } from "solid-js"
|
||||||
|
import { useModels } from "@/context/models"
|
||||||
|
import type { ModelKey, ModelSelection } from "@/context/local"
|
||||||
|
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
|
||||||
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
|
||||||
|
export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const models = useModels()
|
||||||
|
const prompt = usePrompt()
|
||||||
|
const providers = useProviders(() => sdk().directory)
|
||||||
|
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||||
|
|
||||||
|
const valid = (model: ModelKey) => {
|
||||||
|
const provider = providers.all().get(model.providerID)
|
||||||
|
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
const configured = () => {
|
||||||
|
const value = sync().data.config.model
|
||||||
|
if (!value) return
|
||||||
|
const [providerID, modelID] = value.split("/")
|
||||||
|
const model = { providerID, modelID }
|
||||||
|
if (valid(model)) return model
|
||||||
|
}
|
||||||
|
|
||||||
|
const recent = () => models.recent.list().find(valid)
|
||||||
|
const fallback = () => {
|
||||||
|
const defaults = providers.default()
|
||||||
|
return providers.connected().flatMap((provider) => {
|
||||||
|
const modelID = defaults[provider.id] ?? Object.values(provider.models)[0]?.id
|
||||||
|
return modelID ? [{ providerID: provider.id, modelID }] : []
|
||||||
|
})[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = () => {
|
||||||
|
const key = [prompt.model.current(), input.agent()?.model, configured(), recent(), fallback()].find(
|
||||||
|
(item): item is ModelKey => !!item && valid(item),
|
||||||
|
)
|
||||||
|
if (!key) return
|
||||||
|
return models.find(key)
|
||||||
|
}
|
||||||
|
const recentModels = createMemo(() =>
|
||||||
|
models.recent
|
||||||
|
.list()
|
||||||
|
.map(models.find)
|
||||||
|
.filter((item): item is NonNullable<typeof item> => !!item),
|
||||||
|
)
|
||||||
|
|
||||||
|
const selection = {
|
||||||
|
ready: models.ready,
|
||||||
|
current,
|
||||||
|
recent: recentModels,
|
||||||
|
list: models.list,
|
||||||
|
cycle(direction: 1 | -1) {
|
||||||
|
const items = recentModels()
|
||||||
|
const item = current()
|
||||||
|
if (!item) return
|
||||||
|
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
|
||||||
|
if (index === -1) return
|
||||||
|
const next = items[(index + direction + items.length) % items.length]
|
||||||
|
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
|
||||||
|
},
|
||||||
|
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||||
|
startTransition(() =>
|
||||||
|
batch(() => {
|
||||||
|
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
|
||||||
|
if (!item) return
|
||||||
|
models.setVisibility(item, true)
|
||||||
|
if (options?.recent) models.recent.push(item)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
visible: models.visible,
|
||||||
|
setVisibility: models.setVisibility,
|
||||||
|
variant: {
|
||||||
|
configured() {
|
||||||
|
const item = input.agent()
|
||||||
|
const model = current()
|
||||||
|
if (!item || !model) return
|
||||||
|
return getConfiguredAgentVariant({
|
||||||
|
agent: { model: item.model, variant: item.variant },
|
||||||
|
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
selected() {
|
||||||
|
return prompt.model.current()?.variant
|
||||||
|
},
|
||||||
|
current() {
|
||||||
|
const resolved = resolveModelVariant({
|
||||||
|
variants: this.list(),
|
||||||
|
selected: this.selected(),
|
||||||
|
configured: this.configured(),
|
||||||
|
})
|
||||||
|
if (resolved) return resolved
|
||||||
|
const model = current()
|
||||||
|
if (!model) return
|
||||||
|
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
|
||||||
|
if (saved && this.list().includes(saved)) return saved
|
||||||
|
},
|
||||||
|
list() {
|
||||||
|
return Object.keys(current()?.variants ?? {})
|
||||||
|
},
|
||||||
|
set(value: string | undefined) {
|
||||||
|
startTransition(() =>
|
||||||
|
batch(() => {
|
||||||
|
const model = current()
|
||||||
|
if (!model) return
|
||||||
|
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
|
||||||
|
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cycle() {
|
||||||
|
const variants = this.list()
|
||||||
|
if (variants.length === 0) return
|
||||||
|
this.set(
|
||||||
|
cycleModelVariant({
|
||||||
|
variants,
|
||||||
|
selected: this.selected(),
|
||||||
|
configured: this.configured(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ModelSelection
|
||||||
|
|
||||||
|
return selection
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import type { PromptProjectControls } from "@/components/prompt-project-selector
|
|||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import type { QueryOptionsApi } from "@/context/server-sync"
|
import type { QueryOptionsApi } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { serverName, ServerConnection, useServer } from "@/context/server"
|
import { serverName, ServerConnection, useServer } from "@/context/server"
|
||||||
@@ -22,6 +22,7 @@ export function createPromptInputController(input: {
|
|||||||
sessionKey: Accessor<string>
|
sessionKey: Accessor<string>
|
||||||
sessionID: Accessor<string | undefined>
|
sessionID: Accessor<string | undefined>
|
||||||
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
|
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
|
||||||
|
model?: ModelSelection
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
@@ -44,7 +45,7 @@ export function createPromptInputController(input: {
|
|||||||
select: local.agent.set,
|
select: local.agent.set,
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
selection: local.model,
|
selection: input.model ?? local.model,
|
||||||
paid: providers.paid().length > 0,
|
paid: providers.paid().length > 0,
|
||||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
|
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
@@ -6,9 +6,12 @@ import type { FileSearchHandle } from "@opencode-ai/session-ui/file"
|
|||||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||||
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||||
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
|
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
|
||||||
|
import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
|
||||||
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
@@ -16,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
|
|||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { getSessionHandoff } from "@/pages/session/handoff"
|
import { getSessionHandoff } from "@/pages/session/handoff"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
@@ -53,6 +57,30 @@ function FileCommentMenu(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FileCommentMenuV2(props: {
|
||||||
|
moreLabel: string
|
||||||
|
editLabel: string
|
||||||
|
deleteLabel: string
|
||||||
|
onEdit: VoidFunction
|
||||||
|
onDelete: VoidFunction
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
|
||||||
|
<MenuV2 gutter={4}>
|
||||||
|
<MenuV2.Trigger as="button" type="button" data-slot="line-comment-v2-overflow" aria-label={props.moreLabel}>
|
||||||
|
<LineCommentV2OverflowIcon />
|
||||||
|
</MenuV2.Trigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content>
|
||||||
|
<MenuV2.Item onSelect={props.onEdit}>{props.editLabel}</MenuV2.Item>
|
||||||
|
<MenuV2.Item onSelect={props.onDelete}>{props.deleteLabel}</MenuV2.Item>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type ScrollPos = { x: number; y: number }
|
type ScrollPos = { x: number; y: number }
|
||||||
|
|
||||||
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
|
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
|
||||||
@@ -180,6 +208,15 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SessionFileView(props: { tab: string }) {
|
export function SessionFileView(props: { tab: string }) {
|
||||||
|
const settings = useSettings()
|
||||||
|
return (
|
||||||
|
<Show when={settings.general.newLayoutDesigns()} fallback={<SessionFileViewV1 tab={props.tab} />}>
|
||||||
|
<SessionFileViewV2 tab={props.tab} />
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionFileViewV1(props: { tab: string }) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -463,3 +500,294 @@ export function SessionFileView(props: { tab: string }) {
|
|||||||
|
|
||||||
return content()
|
return content()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SessionFileViewV2(props: { tab: string }) {
|
||||||
|
const file = useFile()
|
||||||
|
const comments = useComments()
|
||||||
|
const language = useLanguage()
|
||||||
|
const prompt = usePrompt()
|
||||||
|
const fileComponent = useFileComponent()
|
||||||
|
const { sessionKey, tabs, view } = useSessionLayout()
|
||||||
|
const activeFileTab = createSessionTabs({
|
||||||
|
tabs,
|
||||||
|
pathFromTab: file.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
|
}).activeFileTab
|
||||||
|
|
||||||
|
let find: FileSearchHandle | null = null
|
||||||
|
|
||||||
|
const search = {
|
||||||
|
register: (handle: FileSearchHandle | null) => {
|
||||||
|
find = handle
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||||
|
const state = createMemo(() => {
|
||||||
|
const p = path()
|
||||||
|
if (!p) return
|
||||||
|
return file.get(p)
|
||||||
|
})
|
||||||
|
const contents = createMemo(() => state()?.content?.content ?? "")
|
||||||
|
const cacheKey = createMemo(() => sampledChecksum(contents()))
|
||||||
|
const selectedLines = createMemo<SelectedLineRange | null>(() => {
|
||||||
|
const p = path()
|
||||||
|
if (!p) return null
|
||||||
|
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
|
||||||
|
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
|
||||||
|
})
|
||||||
|
const scrollSync = createScrollSync({
|
||||||
|
tab: () => props.tab,
|
||||||
|
view,
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectionPreview = (source: string, selection: FileSelection) => {
|
||||||
|
return previewSelectedLines(source, {
|
||||||
|
start: selection.startLine,
|
||||||
|
end: selection.endLine,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPreview = (filePath: string, selection: FileSelection) => {
|
||||||
|
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
|
||||||
|
if (!source) return undefined
|
||||||
|
return selectionPreview(source, selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCommentToContext = (input: {
|
||||||
|
file: string
|
||||||
|
selection: SelectedLineRange
|
||||||
|
comment: string
|
||||||
|
preview?: string
|
||||||
|
origin?: "review" | "file"
|
||||||
|
}) => {
|
||||||
|
const selection = selectionFromLines(input.selection)
|
||||||
|
const preview = input.preview ?? buildPreview(input.file, selection)
|
||||||
|
|
||||||
|
const saved = comments.add({
|
||||||
|
file: input.file,
|
||||||
|
selection: input.selection,
|
||||||
|
comment: input.comment,
|
||||||
|
})
|
||||||
|
prompt.context.add({
|
||||||
|
type: "file",
|
||||||
|
path: input.file,
|
||||||
|
selection,
|
||||||
|
comment: input.comment,
|
||||||
|
commentID: saved.id,
|
||||||
|
commentOrigin: input.origin,
|
||||||
|
preview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCommentInContext = (input: {
|
||||||
|
id: string
|
||||||
|
file: string
|
||||||
|
selection: SelectedLineRange
|
||||||
|
comment: string
|
||||||
|
}) => {
|
||||||
|
comments.update(input.file, input.id, input.comment)
|
||||||
|
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
|
||||||
|
prompt.context.updateComment(input.file, input.id, {
|
||||||
|
comment: input.comment,
|
||||||
|
...(preview ? { preview } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCommentFromContext = (input: { id: string; file: string }) => {
|
||||||
|
comments.remove(input.file, input.id)
|
||||||
|
prompt.context.removeComment(input.file, input.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileComments = createMemo(() => {
|
||||||
|
const p = path()
|
||||||
|
if (!p) return []
|
||||||
|
return comments.list(p)
|
||||||
|
})
|
||||||
|
|
||||||
|
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
|
||||||
|
|
||||||
|
const [note, setNote] = createStore({
|
||||||
|
openedComment: null as string | null,
|
||||||
|
commenting: null as SelectedLineRange | null,
|
||||||
|
selected: null as SelectedLineRange | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const syncSelected = (range: SelectedLineRange | null) => {
|
||||||
|
const p = path()
|
||||||
|
if (!p) return
|
||||||
|
file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSelection = () => note.selected ?? selectedLines()
|
||||||
|
|
||||||
|
const commentsUi = createLineCommentControllerV2({
|
||||||
|
comments: fileComments,
|
||||||
|
label: language.t("ui.lineComment.submit"),
|
||||||
|
draftKey: () => path() ?? props.tab,
|
||||||
|
mention: {
|
||||||
|
items: file.searchFilesAndDirectories,
|
||||||
|
},
|
||||||
|
getSide: (range) => range.endSide ?? range.side ?? "additions",
|
||||||
|
state: {
|
||||||
|
opened: () => note.openedComment,
|
||||||
|
setOpened: (id) => setNote("openedComment", id),
|
||||||
|
selected: () => note.selected,
|
||||||
|
setSelected: (range) => setNote("selected", range),
|
||||||
|
commenting: () => note.commenting,
|
||||||
|
setCommenting: (range) => setNote("commenting", range),
|
||||||
|
syncSelected,
|
||||||
|
hoverSelected: syncSelected,
|
||||||
|
},
|
||||||
|
onSubmit: ({ comment, selection }) => {
|
||||||
|
const p = path()
|
||||||
|
if (!p) return
|
||||||
|
addCommentToContext({ file: p, selection, comment, origin: "file" })
|
||||||
|
},
|
||||||
|
onUpdate: ({ id, comment, selection }) => {
|
||||||
|
const p = path()
|
||||||
|
if (!p) return
|
||||||
|
updateCommentInContext({ id, file: p, selection, comment })
|
||||||
|
},
|
||||||
|
onDelete: (comment) => {
|
||||||
|
const p = path()
|
||||||
|
if (!p) return
|
||||||
|
removeCommentFromContext({ id: comment.id, file: p })
|
||||||
|
},
|
||||||
|
editSubmitLabel: language.t("common.save"),
|
||||||
|
renderCommentActions: (_, controls) => (
|
||||||
|
<FileCommentMenuV2
|
||||||
|
moreLabel={language.t("common.moreOptions")}
|
||||||
|
editLabel={language.t("common.edit")}
|
||||||
|
deleteLabel={language.t("common.delete")}
|
||||||
|
onEdit={controls.edit}
|
||||||
|
onDelete={controls.remove}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (activeFileTab() !== props.tab) return
|
||||||
|
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
|
||||||
|
if (event.key.toLowerCase() !== "f") return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
find?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
makeEventListener(window, "keydown", onKeyDown, { capture: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
path,
|
||||||
|
() => {
|
||||||
|
commentsUi.note.reset()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const focus = comments.focus()
|
||||||
|
const p = path()
|
||||||
|
if (!focus || !p) return
|
||||||
|
if (focus.file !== p) return
|
||||||
|
if (activeFileTab() !== props.tab) return
|
||||||
|
|
||||||
|
const target = fileComments().find((comment) => comment.id === focus.id)
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true })
|
||||||
|
requestAnimationFrame(() => comments.clearFocus())
|
||||||
|
})
|
||||||
|
|
||||||
|
let prev = {
|
||||||
|
loaded: false,
|
||||||
|
ready: false,
|
||||||
|
active: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const loaded = !!state()?.loaded
|
||||||
|
const ready = file.ready()
|
||||||
|
const active = activeFileTab() === props.tab
|
||||||
|
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
|
||||||
|
prev = { loaded, ready, active }
|
||||||
|
if (!restore) return
|
||||||
|
scrollSync.queueRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderFile = (source: string) => (
|
||||||
|
<div class="relative overflow-hidden pb-40">
|
||||||
|
<Dynamic
|
||||||
|
component={fileComponent}
|
||||||
|
mode="text"
|
||||||
|
file={{
|
||||||
|
name: path() ?? "",
|
||||||
|
contents: source,
|
||||||
|
cacheKey: cacheKey(),
|
||||||
|
}}
|
||||||
|
enableLineSelection
|
||||||
|
enableGutterUtility
|
||||||
|
selectedLines={activeSelection()}
|
||||||
|
commentedLines={commentedLines()}
|
||||||
|
onRendered={() => {
|
||||||
|
scrollSync.queueRestore()
|
||||||
|
}}
|
||||||
|
annotations={commentsUi.annotations()}
|
||||||
|
renderAnnotation={commentsUi.renderAnnotation}
|
||||||
|
renderGutterUtility={commentsUi.renderGutterUtility}
|
||||||
|
onLineSelected={(range: SelectedLineRange | null) => {
|
||||||
|
commentsUi.onLineSelected(range)
|
||||||
|
}}
|
||||||
|
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
||||||
|
if (!range) {
|
||||||
|
commentsUi.note.select(null)
|
||||||
|
commentsUi.note.cancelDraft()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commentsUi.onLineSelectionEnd(range)
|
||||||
|
}}
|
||||||
|
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
|
||||||
|
commentsUi.onLineNumberSelectionEnd(range)
|
||||||
|
}}
|
||||||
|
search={search}
|
||||||
|
class="select-text"
|
||||||
|
media={{
|
||||||
|
mode: "auto",
|
||||||
|
path: path(),
|
||||||
|
current: state()?.content,
|
||||||
|
onLoad: scrollSync.queueRestore,
|
||||||
|
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||||
|
if (args.kind !== "svg") return
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("toast.file.loadFailed.title"),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const content = () => (
|
||||||
|
<div class="mt-3 relative h-full min-h-0">
|
||||||
|
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
|
||||||
|
<Switch>
|
||||||
|
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||||
|
<Match when={state()?.loading}>
|
||||||
|
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||||
|
</Switch>
|
||||||
|
</ScrollView>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return content()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
|
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
|
||||||
|
|
||||||
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
|
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
|
||||||
({
|
({
|
||||||
@@ -50,3 +50,102 @@ describe("resetSessionModel", () => {
|
|||||||
expect(calls).toEqual(["reset"])
|
expect(calls).toEqual(["reset"])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("syncPromptModel", () => {
|
||||||
|
test("stores the effective session model in prompt state", () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
|
||||||
|
syncPromptModel(
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
|
||||||
|
set() {},
|
||||||
|
variant: { current: () => "high", set() {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => undefined,
|
||||||
|
set: (model) => calls.push(model),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not rewrite an unchanged prompt model", () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const model = { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }
|
||||||
|
|
||||||
|
syncPromptModel(
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
|
||||||
|
set() {},
|
||||||
|
variant: { current: () => model.variant, set() {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => model,
|
||||||
|
set: (value) => calls.push(value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("restorePromptModel", () => {
|
||||||
|
test("restores the persisted prompt model into session selection", () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const restored = restorePromptModel(
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => ({ id: "gpt", provider: { id: "openai" } }),
|
||||||
|
set: (model) => calls.push(model),
|
||||||
|
variant: {
|
||||||
|
current: () => undefined,
|
||||||
|
set: (variant) => calls.push(variant),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
|
||||||
|
set() {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(restored).toBe(true)
|
||||||
|
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does nothing without a persisted prompt model", () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const restored = restorePromptModel(
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => ({ id: "gpt", provider: { id: "openai" } }),
|
||||||
|
set: (model) => calls.push(model),
|
||||||
|
variant: {
|
||||||
|
current: () => undefined,
|
||||||
|
set: (variant) => calls.push(variant),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => undefined,
|
||||||
|
set() {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(restored).toBe(false)
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,6 +7,24 @@ type Local = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ModelSelection = {
|
||||||
|
model: {
|
||||||
|
current(): { id: string; provider: { id: string } } | undefined
|
||||||
|
set(model: { providerID: string; modelID: string }): void
|
||||||
|
variant: {
|
||||||
|
current(): string | undefined
|
||||||
|
set(variant: string | undefined): void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptState = {
|
||||||
|
model: {
|
||||||
|
current(): { providerID: string; modelID: string; variant?: string | null } | undefined
|
||||||
|
set(model: { providerID: string; modelID: string; variant?: string | null }): void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const resetSessionModel = (local: Local) => {
|
export const resetSessionModel = (local: Local) => {
|
||||||
local.session.reset()
|
local.session.reset()
|
||||||
}
|
}
|
||||||
@@ -14,3 +32,32 @@ export const resetSessionModel = (local: Local) => {
|
|||||||
export const syncSessionModel = (local: Local, msg: UserMessage) => {
|
export const syncSessionModel = (local: Local, msg: UserMessage) => {
|
||||||
local.session.restore(msg)
|
local.session.restore(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
|
||||||
|
const model = local.model.current()
|
||||||
|
if (!model) return
|
||||||
|
const next = {
|
||||||
|
providerID: model.provider.id,
|
||||||
|
modelID: model.id,
|
||||||
|
variant: local.model.variant.current(),
|
||||||
|
}
|
||||||
|
const current = prompt.model.current()
|
||||||
|
if (current?.providerID === next.providerID && current.modelID === next.modelID && current.variant === next.variant)
|
||||||
|
return
|
||||||
|
prompt.model.set(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
|
||||||
|
const model = prompt.model.current()
|
||||||
|
if (!model) return false
|
||||||
|
const current = local.model.current()
|
||||||
|
if (
|
||||||
|
current?.provider.id === model.providerID &&
|
||||||
|
current.id === model.modelID &&
|
||||||
|
local.model.variant.current() === (model.variant ?? undefined)
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
local.model.set({ providerID: model.providerID, modelID: model.modelID })
|
||||||
|
local.model.variant.set(model.variant ?? undefined)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,46 @@
|
|||||||
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
|
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
|
import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid"
|
||||||
|
import { isSortable } from "@dnd-kit/solid/sortable"
|
||||||
|
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||||
|
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
|
||||||
|
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||||
|
import {
|
||||||
|
DragDropProvider,
|
||||||
|
DragDropSensors,
|
||||||
|
DragOverlay,
|
||||||
|
SortableProvider,
|
||||||
|
closestCenter,
|
||||||
|
type DragEvent,
|
||||||
|
} from "@thisbeyond/solid-dnd"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
|
||||||
import FileTree from "@/components/file-tree"
|
import FileTree from "@/components/file-tree"
|
||||||
|
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
|
|
||||||
const reviewTabID = "session-side-panel-review-tab"
|
const reviewTabID = "session-side-panel-review-tab"
|
||||||
const reviewTabPanelID = "session-side-panel-review-tabpanel"
|
const reviewTabPanelID = "session-side-panel-review-tabpanel"
|
||||||
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
|
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
|
||||||
|
import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session"
|
||||||
|
import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||||
@@ -53,6 +71,7 @@ export function SessionSidePanel(props: {
|
|||||||
reviewHasFocusableContent: () => boolean
|
reviewHasFocusableContent: () => boolean
|
||||||
reviewCount: () => number
|
reviewCount: () => number
|
||||||
reviewPanel: () => JSX.Element
|
reviewPanel: () => JSX.Element
|
||||||
|
reviewSidebarToggle?: (disabled: boolean) => JSX.Element
|
||||||
fileBrowserState?: SessionFileBrowserState
|
fileBrowserState?: SessionFileBrowserState
|
||||||
activeDiff?: string
|
activeDiff?: string
|
||||||
focusReviewDiff: (path: string) => void
|
focusReviewDiff: (path: string) => void
|
||||||
@@ -66,7 +85,9 @@ export function SessionSidePanel(props: {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const sdk = useSDK()
|
||||||
const { sessionKey, tabs, view, params } = useSessionLayout()
|
const { sessionKey, tabs, view, params } = useSessionLayout()
|
||||||
|
const projectDirectory = createMemo(() => sdk().directory)
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
const shown = settings.visibility.fileTree
|
const shown = settings.visibility.fileTree
|
||||||
@@ -98,11 +119,9 @@ export function SessionSidePanel(props: {
|
|||||||
return "mix" as const
|
return "mix" as const
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
|
||||||
|
|
||||||
const out = new Map<string, "add" | "del" | "mix">()
|
const out = new Map<string, "add" | "del" | "mix">()
|
||||||
for (const diff of diffs()) {
|
for (const diff of diffs()) {
|
||||||
const file = normalize(diff.file)
|
const file = normalizeFileTreeV2Path(diff.file)
|
||||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||||
|
|
||||||
out.set(file, kind)
|
out.set(file, kind)
|
||||||
@@ -159,6 +178,7 @@ export function SessionSidePanel(props: {
|
|||||||
fileBrowser: () => !!props.fileBrowserState,
|
fileBrowser: () => !!props.fileBrowserState,
|
||||||
})
|
})
|
||||||
const contextOpen = tabState.contextOpen
|
const contextOpen = tabState.contextOpen
|
||||||
|
const openFileOpen = tabState.openFileOpen
|
||||||
const panelTabs = tabState.panelTabs
|
const panelTabs = tabState.panelTabs
|
||||||
const openedTabs = tabState.openedTabs
|
const openedTabs = tabState.openedTabs
|
||||||
const activeTab = tabState.activeTab
|
const activeTab = tabState.activeTab
|
||||||
@@ -176,10 +196,8 @@ export function SessionSidePanel(props: {
|
|||||||
layout.fileTree.setTab("all")
|
layout.fileTree.setTab("all")
|
||||||
}
|
}
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
activeDraggable: undefined as string | undefined,
|
|
||||||
})
|
|
||||||
let fileFilter: HTMLInputElement | undefined
|
let fileFilter: HTMLInputElement | undefined
|
||||||
|
let tabList: HTMLDivElement | undefined
|
||||||
const temporaryTab = tabs().preview
|
const temporaryTab = tabs().preview
|
||||||
const previewTab = (value: string) => {
|
const previewTab = (value: string) => {
|
||||||
const next = normalizeTab(value)
|
const next = normalizeTab(value)
|
||||||
@@ -202,10 +220,26 @@ export function SessionSidePanel(props: {
|
|||||||
}
|
}
|
||||||
const browserTab = createMemo(() => {
|
const browserTab = createMemo(() => {
|
||||||
if (!props.fileBrowserState) return undefined
|
if (!props.fileBrowserState) return undefined
|
||||||
if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
|
const active = activeTab()
|
||||||
|
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
|
||||||
|
if (active && file.pathFromTab(active)) return active
|
||||||
return activeFileTab()
|
return activeFileTab()
|
||||||
})
|
})
|
||||||
const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
|
// Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
|
||||||
|
// selects Review while the tab For replaces a preview trigger, which would
|
||||||
|
// otherwise dispose the sidebar and reset scroll.
|
||||||
|
const fileBrowserMounted = createMemo(() => {
|
||||||
|
if (!props.fileBrowserState) return false
|
||||||
|
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
|
||||||
|
})
|
||||||
|
const fileBrowserVisible = createMemo(() => {
|
||||||
|
const active = activeTab()
|
||||||
|
return active !== "review" && active !== "context" && active !== "empty"
|
||||||
|
})
|
||||||
|
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
activeDraggable: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
const handleDragStart = (event: unknown) => {
|
const handleDragStart = (event: unknown) => {
|
||||||
const id = getDraggableId(event)
|
const id = getDraggableId(event)
|
||||||
@@ -255,8 +289,10 @@ export function SessionSidePanel(props: {
|
|||||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||||
aria-hidden={!open()}
|
aria-hidden={!open()}
|
||||||
inert={!open()}
|
inert={!open()}
|
||||||
class="relative min-w-0 flex overflow-hidden bg-background-base"
|
class="relative min-w-0 flex overflow-hidden"
|
||||||
classList={{
|
classList={{
|
||||||
|
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||||
|
"bg-background-base": !settings.general.newLayoutDesigns(),
|
||||||
"h-full shrink-0": !props.stacked,
|
"h-full shrink-0": !props.stacked,
|
||||||
"h-full min-h-0": props.stacked,
|
"h-full min-h-0": props.stacked,
|
||||||
"pointer-events-none": !open(),
|
"pointer-events-none": !open(),
|
||||||
@@ -275,74 +311,297 @@ export function SessionSidePanel(props: {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={reviewOpen()}>
|
<Show when={reviewOpen()}>
|
||||||
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base">
|
<div
|
||||||
<div class="size-full min-w-0 h-full bg-background-base">
|
class="relative min-w-0 h-full flex-1 overflow-hidden"
|
||||||
<DragDropProvider
|
classList={{
|
||||||
onDragStart={handleDragStart}
|
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||||
onDragEnd={handleDragEnd}
|
"bg-background-base": !settings.general.newLayoutDesigns(),
|
||||||
onDragOver={handleDragOver}
|
}}
|
||||||
collisionDetector={closestCenter}
|
>
|
||||||
>
|
<div
|
||||||
<DragDropSensors />
|
class="size-full min-w-0 h-full"
|
||||||
<ConstrainDragYAxis />
|
classList={{
|
||||||
<Tabs value={activeTab()} onChange={activateTab}>
|
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||||
<div class="sticky top-0 shrink-0 flex">
|
"bg-background-base": !settings.general.newLayoutDesigns(),
|
||||||
<Tabs.List
|
}}
|
||||||
ref={(el: HTMLDivElement) => {
|
>
|
||||||
const stop = createFileTabListSync({ el, contextOpen })
|
<Show
|
||||||
onCleanup(stop)
|
when={props.fileBrowserState}
|
||||||
}}
|
fallback={
|
||||||
>
|
<DragDropProvider
|
||||||
<Show when={reviewTab() && props.canReview()}>
|
onDragStart={handleDragStart}
|
||||||
<Tabs.Trigger
|
onDragEnd={handleDragEnd}
|
||||||
value="review"
|
onDragOver={handleDragOver}
|
||||||
id={reviewTabID}
|
collisionDetector={closestCenter}
|
||||||
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
|
>
|
||||||
|
<DragDropSensors />
|
||||||
|
<ConstrainDragYAxis />
|
||||||
|
<Tabs value={activeTab()} onChange={activateTab}>
|
||||||
|
<div class="sticky top-0 shrink-0 flex">
|
||||||
|
<Tabs.List
|
||||||
|
ref={(el: HTMLDivElement) => {
|
||||||
|
const stop = createFileTabListSync({ el, contextOpen })
|
||||||
|
onCleanup(stop)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<Show when={reviewTab() && props.canReview()}>
|
||||||
<div>{language.t("session.tab.review")}</div>
|
<Tabs.Trigger
|
||||||
<Show when={props.hasReview()}>
|
value="review"
|
||||||
<div>{props.reviewCount()}</div>
|
id={reviewTabID}
|
||||||
</Show>
|
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
|
||||||
</div>
|
>
|
||||||
</Tabs.Trigger>
|
<div class="flex items-center gap-1.5">
|
||||||
</Show>
|
<div>{language.t("session.tab.review")}</div>
|
||||||
<Show when={contextOpen()}>
|
<Show when={props.hasReview()}>
|
||||||
<Tabs.Trigger
|
<div>{props.reviewCount()}</div>
|
||||||
value="context"
|
</Show>
|
||||||
closeButton={
|
</div>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
|
<Show when={contextOpen()}>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="context"
|
||||||
|
closeButton={
|
||||||
|
<TooltipKeybind
|
||||||
|
title={language.t("common.closeTab")}
|
||||||
|
keybind={command.keybind("tab.close")}
|
||||||
|
placement="bottom"
|
||||||
|
gutter={10}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="close-small"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-5 w-5"
|
||||||
|
onClick={() => tabs().close("context")}
|
||||||
|
aria-label={language.t("common.closeTab")}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
hideCloseButton
|
||||||
|
onMiddleClick={() => tabs().close("context")}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<SessionContextUsage variant="indicator" />
|
||||||
|
<div>{language.t("session.tab.context")}</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
|
<SortableProvider ids={openedTabs()}>
|
||||||
|
<For each={panelTabs()}>
|
||||||
|
{(tab) => (
|
||||||
|
<Show
|
||||||
|
when={tab === SESSION_OPEN_FILE_TAB}
|
||||||
|
fallback={
|
||||||
|
<SortableTab
|
||||||
|
tab={tab}
|
||||||
|
temporary={temporaryTab() === tab}
|
||||||
|
onTabClose={tabs().close}
|
||||||
|
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value={SESSION_OPEN_FILE_TAB}
|
||||||
|
closeButton={
|
||||||
|
<TooltipKeybind
|
||||||
|
title={language.t("common.closeTab")}
|
||||||
|
keybind={command.keybind("tab.close")}
|
||||||
|
placement="bottom"
|
||||||
|
gutter={10}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="close-small"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-5 w-5"
|
||||||
|
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||||
|
aria-label={language.t("common.closeTab")}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
hideCloseButton
|
||||||
|
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5 italic">
|
||||||
|
<Icon name="open-file" size="small" />
|
||||||
|
<span>{language.t("command.file.open")}</span>
|
||||||
|
</div>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</SortableProvider>
|
||||||
|
<div
|
||||||
|
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
|
||||||
|
classList={{
|
||||||
|
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||||
|
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
title={language.t("common.closeTab")}
|
title={language.t("command.file.open")}
|
||||||
keybind={command.keybind("tab.close")}
|
keybind={command.keybind("file.open")}
|
||||||
placement="bottom"
|
class="flex items-center"
|
||||||
gutter={10}
|
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="close-small"
|
icon="plus-small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="h-5 w-5"
|
iconSize="large"
|
||||||
onClick={() => tabs().close("context")}
|
class="!rounded-md"
|
||||||
aria-label={language.t("common.closeTab")}
|
onClick={() => {
|
||||||
|
void import("@/components/dialog-select-file").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
aria-label={language.t("command.file.open")}
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
}
|
|
||||||
hideCloseButton
|
|
||||||
onMiddleClick={() => tabs().close("context")}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<SessionContextUsage variant="indicator" />
|
|
||||||
<div>{language.t("session.tab.context")}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Trigger>
|
</Tabs.List>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
|
||||||
|
<div
|
||||||
|
id={reviewTabPanelID}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={reviewTabID}
|
||||||
|
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
|
||||||
|
data-slot="tabs-content"
|
||||||
|
class="flex flex-col h-full overflow-hidden contain-strict"
|
||||||
|
>
|
||||||
|
{props.reviewPanel()}
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<SortableProvider ids={openedTabs()}>
|
|
||||||
|
<Show when={activeTab() === "empty"}>
|
||||||
|
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
|
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||||
|
<Mark class="w-14 opacity-10" />
|
||||||
|
<div class="text-14-regular text-text-weak max-w-56">
|
||||||
|
{language.t("session.files.selectToOpen")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeTab() === "context"}>
|
||||||
|
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
|
<SessionContextTab />
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeFileTab()} keyed>
|
||||||
|
{(tab) => <FileTabContent tab={tab} />}
|
||||||
|
</Show>
|
||||||
|
</Tabs>
|
||||||
|
<DragOverlay>
|
||||||
|
<Show when={store.activeDraggable} keyed>
|
||||||
|
{(tab) => {
|
||||||
|
const path = file.pathFromTab(tab)
|
||||||
|
return (
|
||||||
|
<div data-component="tabs-drag-preview">
|
||||||
|
<Show when={path}>
|
||||||
|
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</DragOverlay>
|
||||||
|
</DragDropProvider>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DndKitProvider
|
||||||
|
sensors={[
|
||||||
|
PointerSensor.configure({
|
||||||
|
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||||
|
preventActivation: (event) =>
|
||||||
|
event.target instanceof Element &&
|
||||||
|
(!!event.target.closest('[data-slot="tabs-trigger-close-button"]') ||
|
||||||
|
!!event.target.closest(".session-review-v2-open-in-app-slot")),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
modifiers={[
|
||||||
|
RestrictToHorizontalAxis,
|
||||||
|
RestrictToElement.configure({ element: () => tabList ?? null }),
|
||||||
|
]}
|
||||||
|
plugins={(defaults) => [
|
||||||
|
...defaults.filter((plugin) => plugin !== Accessibility),
|
||||||
|
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
|
||||||
|
Feedback.configure({ dropAnimation: null }),
|
||||||
|
]}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
const source = event.operation.source
|
||||||
|
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
|
||||||
|
tabs().move(source.id.toString(), source.index)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs value={activeTab()} onChange={activateTab}>
|
||||||
|
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
|
||||||
|
<Tabs.List
|
||||||
|
ref={(el: HTMLDivElement) => {
|
||||||
|
tabList = el
|
||||||
|
const stop = createFileTabListSync({ el, contextOpen })
|
||||||
|
onCleanup(stop)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={props.reviewSidebarToggle}>
|
||||||
|
{(toggle) => (
|
||||||
|
<div class="h-full shrink-0 flex items-center justify-center">
|
||||||
|
{toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={reviewTab() && props.canReview()}>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="review"
|
||||||
|
id={reviewTabID}
|
||||||
|
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
|
||||||
|
>
|
||||||
|
{props.hasReview()
|
||||||
|
? language.t("session.review.filesChanged", { count: props.reviewCount() })
|
||||||
|
: language.t("session.tab.review")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
|
<Show when={contextOpen()}>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="context"
|
||||||
|
closeButton={
|
||||||
|
<TooltipKeybind
|
||||||
|
title={language.t("common.closeTab")}
|
||||||
|
keybind={command.keybind("tab.close")}
|
||||||
|
placement="bottom"
|
||||||
|
gutter={10}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="close-small"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-5 w-5"
|
||||||
|
onClick={() => tabs().close("context")}
|
||||||
|
aria-label={language.t("common.closeTab")}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
hideCloseButton
|
||||||
|
onMiddleClick={() => tabs().close("context")}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<SessionContextUsage variant="indicator" />
|
||||||
|
<div>{language.t("session.tab.context")}</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
<For each={panelTabs()}>
|
<For each={panelTabs()}>
|
||||||
{(tab) => (
|
{(tab) => (
|
||||||
<Show
|
<Show
|
||||||
when={tab === SESSION_OPEN_FILE_TAB}
|
when={tab === SESSION_OPEN_FILE_TAB}
|
||||||
fallback={
|
fallback={
|
||||||
<SortableTab
|
<SortableTabV2
|
||||||
tab={tab}
|
tab={tab}
|
||||||
|
index={() => tabs().all().indexOf(tab)}
|
||||||
temporary={temporaryTab() === tab}
|
temporary={temporaryTab() === tab}
|
||||||
onTabClose={tabs().close}
|
onTabClose={tabs().close}
|
||||||
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
|
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
|
||||||
@@ -378,100 +637,104 @@ export function SessionSidePanel(props: {
|
|||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</SortableProvider>
|
<div
|
||||||
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
|
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center"
|
||||||
<TooltipKeybind
|
classList={{
|
||||||
title={language.t("command.file.open")}
|
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||||
keybind={command.keybind("file.open")}
|
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||||
class="flex items-center"
|
}}
|
||||||
>
|
>
|
||||||
<IconButton
|
<TooltipV2
|
||||||
icon="plus-small"
|
value={
|
||||||
variant="ghost"
|
<>
|
||||||
iconSize="large"
|
{language.t("command.file.open")}
|
||||||
class="!rounded-md"
|
<Show when={openFileKeybind().length > 0}>
|
||||||
onClick={() => {
|
<KeybindV2 keys={openFileKeybind()} variant="neutral" />
|
||||||
if (props.fileBrowserState) {
|
</Show>
|
||||||
openFileBrowser()
|
</>
|
||||||
return
|
}
|
||||||
}
|
placement="bottom"
|
||||||
void import("@/components/dialog-select-file").then((x) => {
|
class="flex items-center"
|
||||||
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
>
|
||||||
})
|
<IconButtonV2
|
||||||
}}
|
icon={<Icon name="plus-small" />}
|
||||||
aria-label={language.t("command.file.open")}
|
variant="ghost-muted"
|
||||||
/>
|
size="large"
|
||||||
</TooltipKeybind>
|
onClick={() => openFileBrowser()}
|
||||||
|
aria-label={language.t("command.file.open")}
|
||||||
|
/>
|
||||||
|
</TooltipV2>
|
||||||
|
</div>
|
||||||
|
</Tabs.List>
|
||||||
|
<div
|
||||||
|
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<OpenInAppV2 directory={projectDirectory} />
|
||||||
</div>
|
</div>
|
||||||
</Tabs.List>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
|
|
||||||
<div
|
|
||||||
id={reviewTabPanelID}
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby={reviewTabID}
|
|
||||||
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
|
|
||||||
data-slot="tabs-content"
|
|
||||||
class="flex flex-col h-full overflow-hidden contain-strict"
|
|
||||||
>
|
|
||||||
{props.reviewPanel()}
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={activeTab() === "empty"}>
|
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
|
||||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
<div
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
id={reviewTabPanelID}
|
||||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
role="tabpanel"
|
||||||
<Mark class="w-14 opacity-10" />
|
aria-labelledby={reviewTabID}
|
||||||
<div class="text-14-regular text-text-weak max-w-56">
|
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
|
||||||
{language.t("session.files.selectToOpen")}
|
data-slot="tabs-content"
|
||||||
|
class="flex flex-col h-full overflow-hidden contain-strict"
|
||||||
|
>
|
||||||
|
{props.reviewPanel()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeTab() === "empty"}>
|
||||||
|
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
|
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||||
|
<Mark class="w-14 opacity-10" />
|
||||||
|
<div class="text-14-regular text-text-weak max-w-56">
|
||||||
|
{language.t("session.files.selectToOpen")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tabs.Content>
|
||||||
</Tabs.Content>
|
</Show>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={activeTab() === "context"}>
|
<Show when={activeTab() === "context"}>
|
||||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<SessionContextTab />
|
<SessionContextTab />
|
||||||
</div>
|
|
||||||
</Tabs.Content>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={browserTab()}>
|
|
||||||
<SessionFileBrowserTab
|
|
||||||
tab={browserTab()!}
|
|
||||||
placeholder={browserTab() === SESSION_OPEN_FILE_TAB}
|
|
||||||
active={file.pathFromTab(browserTab()!)}
|
|
||||||
kinds={browserKinds()}
|
|
||||||
state={props.fileBrowserState!}
|
|
||||||
onSelect={(path) => previewTab(file.tab(path))}
|
|
||||||
onSelectPermanent={(path) => openTab(file.tab(path))}
|
|
||||||
filterRef={(element) => (fileFilter = element)}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!props.fileBrowserState && activeFileTab()} keyed>
|
|
||||||
{(tab) => <FileTabContent tab={tab} />}
|
|
||||||
</Show>
|
|
||||||
</Tabs>
|
|
||||||
<DragOverlay>
|
|
||||||
<Show when={store.activeDraggable} keyed>
|
|
||||||
{(tab) => {
|
|
||||||
const path = file.pathFromTab(tab)
|
|
||||||
return (
|
|
||||||
<div data-component="tabs-drag-preview">
|
|
||||||
<Show when={path}>
|
|
||||||
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</Tabs.Content>
|
||||||
}}
|
</Show>
|
||||||
</Show>
|
|
||||||
</DragOverlay>
|
<Show when={fileBrowserMounted()}>
|
||||||
</DragDropProvider>
|
<div
|
||||||
|
id={fileBrowserTabPanelID}
|
||||||
|
role="tabpanel"
|
||||||
|
data-slot="tabs-content"
|
||||||
|
class="h-full min-h-0 overflow-hidden"
|
||||||
|
classList={{ hidden: !fileBrowserVisible() }}
|
||||||
|
inert={!fileBrowserVisible() || undefined}
|
||||||
|
>
|
||||||
|
<SessionFileBrowserTab
|
||||||
|
tab={browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
|
||||||
|
placeholder={
|
||||||
|
(browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
|
||||||
|
}
|
||||||
|
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
|
||||||
|
kinds={kinds()}
|
||||||
|
state={props.fileBrowserState!}
|
||||||
|
onSelect={(path) => previewTab(file.tab(path))}
|
||||||
|
onSelectPermanent={(path) => openTab(file.tab(path))}
|
||||||
|
filterRef={(element) => (fileFilter = element)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Tabs>
|
||||||
|
</DndKitProvider>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -499,10 +762,19 @@ export function SessionSidePanel(props: {
|
|||||||
>
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
{props.reviewCount()}{" "}
|
<Show
|
||||||
{language.t(
|
when={settings.general.newLayoutDesigns()}
|
||||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
fallback={
|
||||||
)}
|
<>
|
||||||
|
{props.reviewCount()}{" "}
|
||||||
|
{language.t(
|
||||||
|
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{language.t("session.review.filesChanged", { count: props.reviewCount() })}
|
||||||
|
</Show>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
{language.t("session.files.all")}
|
{language.t("session.files.all")}
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
|||||||
aria-label={language.t("terminal.title")}
|
aria-label={language.t("terminal.title")}
|
||||||
aria-hidden={!opened()}
|
aria-hidden={!opened()}
|
||||||
inert={!opened()}
|
inert={!opened()}
|
||||||
class="relative shrink-0 overflow-hidden bg-background-stronger"
|
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
|
||||||
classList={{
|
classList={{
|
||||||
"w-full": !isDesktop() || stacked(),
|
"w-full": !isDesktop() || stacked(),
|
||||||
"min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(),
|
"min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(),
|
||||||
@@ -237,7 +237,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
|||||||
when={terminal.ready()}
|
when={terminal.ready()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex flex-col h-full pointer-events-none">
|
<div class="flex flex-col h-full pointer-events-none">
|
||||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
|
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
|
||||||
<For each={handoff()}>
|
<For each={handoff()}>
|
||||||
{(title) => (
|
{(title) => (
|
||||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ export function MessageTimeline(props: {
|
|||||||
parts: getMsgParts,
|
parts: getMsgParts,
|
||||||
status: sessionStatus,
|
status: sessionStatus,
|
||||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||||
|
inlineComments: settings.general.newLayoutDesigns,
|
||||||
})
|
})
|
||||||
const activeMessageID = projection.activeMessageID
|
const activeMessageID = projection.activeMessageID
|
||||||
const assistantMessagesByParent = projection.assistantMessagesByParent
|
const assistantMessagesByParent = projection.assistantMessagesByParent
|
||||||
@@ -1135,6 +1136,10 @@ export function MessageTimeline(props: {
|
|||||||
const m = messageByID().get(userMessageRow().userMessageID)
|
const m = messageByID().get(userMessageRow().userMessageID)
|
||||||
if (m?.role === "user") return m
|
if (m?.role === "user") return m
|
||||||
})
|
})
|
||||||
|
const messageComments = createMemo(() => {
|
||||||
|
if (!settings.general.newLayoutDesigns()) return []
|
||||||
|
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
|
||||||
|
})
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={userMessageRow}>
|
<TimelineRowFrame row={userMessageRow}>
|
||||||
<Show when={message()}>
|
<Show when={message()}>
|
||||||
@@ -1146,6 +1151,7 @@ export function MessageTimeline(props: {
|
|||||||
parts={getMsgParts(userMessageRow().userMessageID)}
|
parts={getMsgParts(userMessageRow().userMessageID)}
|
||||||
actions={props.actions}
|
actions={props.actions}
|
||||||
useV2Actions={settings.general.newLayoutDesigns()}
|
useV2Actions={settings.general.newLayoutDesigns()}
|
||||||
|
comments={messageComments()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1243,12 +1249,13 @@ export function MessageTimeline(props: {
|
|||||||
const initialRow = timelineRowByKey().get(props.rowKey)!
|
const initialRow = timelineRowByKey().get(props.rowKey)!
|
||||||
const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem)
|
const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem)
|
||||||
const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow)
|
const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow)
|
||||||
const asyncFile = () => {
|
const tool = () => {
|
||||||
const value = row()
|
const value = row()
|
||||||
if (value._tag !== "AssistantPart" || value.group.type !== "part") return false
|
if (value._tag !== "AssistantPart" || value.group.type !== "part") return
|
||||||
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
|
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
|
||||||
return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool)
|
if (part?.type === "tool") return part
|
||||||
}
|
}
|
||||||
|
const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.tool ?? "")
|
||||||
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
|
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
|
||||||
let contentMeasureFrame: number | undefined
|
let contentMeasureFrame: number | undefined
|
||||||
|
|
||||||
@@ -1278,6 +1285,8 @@ export function MessageTimeline(props: {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: `${item().size}px`,
|
height: `${item().size}px`,
|
||||||
overflow: "clip",
|
overflow: "clip",
|
||||||
|
// Rounded virtual measurements can otherwise clip a framed row's outer paint.
|
||||||
|
"overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -1383,7 +1392,7 @@ export function MessageTimeline(props: {
|
|||||||
"w-full": true,
|
"w-full": true,
|
||||||
"pb-4": true,
|
"pb-4": true,
|
||||||
"pr-3": true,
|
"pr-3": true,
|
||||||
"pl-2": settings.general.newLayoutDesigns(),
|
"pl-2.5": settings.general.newLayoutDesigns(),
|
||||||
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
|
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
|
||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function createTimelineProjection(input: {
|
|||||||
parts: (messageID: string) => Part[]
|
parts: (messageID: string) => Part[]
|
||||||
status: Accessor<SessionStatus>
|
status: Accessor<SessionStatus>
|
||||||
showReasoningSummaries: Accessor<boolean>
|
showReasoningSummaries: Accessor<boolean>
|
||||||
|
inlineComments: Accessor<boolean>
|
||||||
}) {
|
}) {
|
||||||
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
|
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
|
||||||
const assistantMessagesByParent = createMemo(() => {
|
const assistantMessagesByParent = createMemo(() => {
|
||||||
@@ -59,6 +60,7 @@ export function createTimelineProjection(input: {
|
|||||||
input.showReasoningSummaries(),
|
input.showReasoningSummaries(),
|
||||||
input.status().type,
|
input.status().type,
|
||||||
activeMessageID() === userMessage.id,
|
activeMessageID() === userMessage.id,
|
||||||
|
input.inlineComments(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export namespace Timeline {
|
|||||||
showReasoning: boolean,
|
showReasoning: boolean,
|
||||||
status: SessionStatus["type"],
|
status: SessionStatus["type"],
|
||||||
isActive: boolean,
|
isActive: boolean,
|
||||||
|
// v2 renders comments inside the user message attachments row instead of a strip row
|
||||||
|
inlineComments: boolean,
|
||||||
) {
|
) {
|
||||||
const rows: TimelineRow.TimelineRow[] = []
|
const rows: TimelineRow.TimelineRow[] = []
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ export namespace Timeline {
|
|||||||
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
|
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
|
||||||
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
|
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
|
||||||
|
|
||||||
if (comments.length > 0)
|
if (comments.length > 0 && !inlineComments)
|
||||||
rows.push(
|
rows.push(
|
||||||
new TimelineRow.CommentStrip({
|
new TimelineRow.CommentStrip({
|
||||||
userMessageID: userMessage.id,
|
userMessageID: userMessage.id,
|
||||||
@@ -84,7 +86,7 @@ export namespace Timeline {
|
|||||||
rows.push(
|
rows.push(
|
||||||
new TimelineRow.UserMessage({
|
new TimelineRow.UserMessage({
|
||||||
userMessageID: userMessage.id,
|
userMessageID: userMessage.id,
|
||||||
anchor: comments.length === 0,
|
anchor: inlineComments || comments.length === 0,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
|
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||||
@@ -14,7 +14,7 @@ const withCategory = (category: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useComposerCommands = () => {
|
export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -22,6 +22,7 @@ export const useComposerCommands = () => {
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const { sessionKey } = useSessionLayout()
|
const { sessionKey } = useSessionLayout()
|
||||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||||
|
const model = input.model ?? local.model
|
||||||
const modelCommand = withCategory(language.t("command.category.model"))
|
const modelCommand = withCategory(language.t("command.category.model"))
|
||||||
const agentCommand = withCategory(language.t("command.category.agent"))
|
const agentCommand = withCategory(language.t("command.category.agent"))
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export const useComposerCommands = () => {
|
|||||||
}
|
}
|
||||||
const { DialogSelectModel } = await import("@/components/dialog-select-model")
|
const { DialogSelectModel } = await import("@/components/dialog-select-model")
|
||||||
owner.run(() => {
|
owner.run(() => {
|
||||||
void dialog.show(() => <DialogSelectModel model={local.model} />, restoreComposer)
|
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ export const useComposerCommands = () => {
|
|||||||
title: language.t("command.model.variant.cycle"),
|
title: language.t("command.model.variant.cycle"),
|
||||||
description: language.t("command.model.variant.cycle.description"),
|
description: language.t("command.model.variant.cycle.description"),
|
||||||
keybind: "shift+mod+d",
|
keybind: "shift+mod+d",
|
||||||
onSelect: () => local.model.variant.cycle(),
|
onSelect: () => model.variant.cycle(),
|
||||||
}),
|
}),
|
||||||
agentCommand({
|
agentCommand({
|
||||||
id: "agent.cycle",
|
id: "agent.cycle",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
||||||
SessionReviewV2,
|
SessionReviewV2,
|
||||||
SessionReviewV2Sidebar,
|
SessionReviewV2Sidebar,
|
||||||
SessionReviewV2SidebarToggle,
|
|
||||||
} from "@opencode-ai/session-ui/v2/session-review-v2"
|
} from "@opencode-ai/session-ui/v2/session-review-v2"
|
||||||
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
|
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
|
||||||
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
|
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
|
||||||
@@ -66,6 +65,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
|||||||
)
|
)
|
||||||
const searching = createMemo(() => props.state.filter().trim().length > 0)
|
const searching = createMemo(() => props.state.filter().trim().length > 0)
|
||||||
const kinds = createMemo(() => reviewDiffKinds(diffs()))
|
const kinds = createMemo(() => reviewDiffKinds(diffs()))
|
||||||
|
// Changes-only trees omit "M" — every row is already a change; A/D stay visible.
|
||||||
|
const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
|
||||||
const activeDiff = createMemo(() => {
|
const activeDiff = createMemo(() => {
|
||||||
// A focused comment takes over the preview until the preview applies it and
|
// A focused comment takes over the preview until the preview applies it and
|
||||||
// clears the focus; the owner then persists the file as the active selection.
|
// clears the focus; the owner then persists the file as the active selection.
|
||||||
@@ -113,9 +114,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
|||||||
stats={<DiffChanges changes={diffs()} />}
|
stats={<DiffChanges changes={diffs()} />}
|
||||||
empty={props.empty}
|
empty={props.empty}
|
||||||
sidebarOpen={props.state.sidebarOpened()}
|
sidebarOpen={props.state.sidebarOpened()}
|
||||||
sidebarToggle={
|
|
||||||
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
|
|
||||||
}
|
|
||||||
sidebar={
|
sidebar={
|
||||||
// Always mounted: the sidebar header hosts the changes-mode dropdown,
|
// Always mounted: the sidebar header hosts the changes-mode dropdown,
|
||||||
// which must stay reachable when the current mode has zero diffs.
|
// which must stay reachable when the current mode has zero diffs.
|
||||||
@@ -127,7 +125,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
|||||||
diffs={diffs}
|
diffs={diffs}
|
||||||
filteredFiles={filteredFiles}
|
filteredFiles={filteredFiles}
|
||||||
searching={searching}
|
searching={searching}
|
||||||
kinds={kinds}
|
kinds={treeKinds}
|
||||||
activeDiff={activeDiff}
|
activeDiff={activeDiff}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
|
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
|
||||||
import { createQuery } from "@tanstack/solid-query"
|
import { createQuery } from "@tanstack/solid-query"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import {
|
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2"
|
||||||
SessionFilePanelV2,
|
import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2"
|
||||||
SessionFilePanelV2Empty,
|
import FileTreeV2, { type Kind } from "@/components/file-tree-v2"
|
||||||
SessionFilePanelV2Title,
|
|
||||||
} from "@opencode-ai/session-ui/v2/session-file-panel-v2"
|
|
||||||
import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
|
|
||||||
import FileTree, { type Kind } from "@/components/file-tree"
|
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
@@ -46,6 +41,7 @@ export function SessionFileBrowserTab(props: {
|
|||||||
const resultsID = `session-file-browser-results-${createUniqueId()}`
|
const resultsID = `session-file-browser-results-${createUniqueId()}`
|
||||||
const [filter, setFilter] = createSignal("")
|
const [filter, setFilter] = createSignal("")
|
||||||
const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
|
const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
|
||||||
|
const sidebarOpened = () => props.placeholder || props.state.sidebarOpened()
|
||||||
const query = createMemo(() => filter().trim())
|
const query = createMemo(() => filter().trim())
|
||||||
const search = createQuery(() => {
|
const search = createQuery(() => {
|
||||||
const value = query()
|
const value = query()
|
||||||
@@ -92,102 +88,92 @@ export function SessionFileBrowserTab(props: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the sidebar outside Kobalte Tabs.Content: a morphing content value
|
||||||
|
// unmounts the whole panel on every file-tab switch and resets sidebar scroll.
|
||||||
return (
|
return (
|
||||||
<Tabs.Content value={props.tab} class="h-full min-h-0 overflow-hidden">
|
<SessionFilePanelV2
|
||||||
<SessionFilePanelV2
|
toolbar={false}
|
||||||
toolbar
|
sidebar={
|
||||||
toolbarStart={
|
<SessionReviewV2Sidebar
|
||||||
<>
|
open={sidebarOpened()}
|
||||||
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
|
title={<span class="truncate">{title()}</span>}
|
||||||
<Show when={!props.state.sidebarOpened()}>
|
filter={filter()}
|
||||||
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
|
onFilterChange={setFilter}
|
||||||
</Show>
|
onFilterKeyDown={onFilterKeyDown}
|
||||||
</>
|
filterAutofocus={props.placeholder}
|
||||||
}
|
filterRef={props.filterRef}
|
||||||
sidebar={
|
filterControls={resultsID}
|
||||||
<SessionReviewV2Sidebar
|
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
|
||||||
open={props.state.sidebarOpened()}
|
filterExpanded={query().length > 0 && files().length > 0}
|
||||||
title={<span class="truncate">{title()}</span>}
|
width={props.state.sidebarWidth()}
|
||||||
filter={filter()}
|
onWidthChange={props.state.resizeSidebar}
|
||||||
onFilterChange={setFilter}
|
>
|
||||||
onFilterKeyDown={onFilterKeyDown}
|
<Show
|
||||||
filterAutofocus={props.placeholder}
|
when={query()}
|
||||||
filterRef={props.filterRef}
|
fallback={
|
||||||
filterControls={resultsID}
|
<FileTreeV2
|
||||||
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
|
active={props.active}
|
||||||
filterExpanded={query().length > 0 && files().length > 0}
|
kinds={props.kinds}
|
||||||
width={props.state.sidebarWidth()}
|
onFileClick={(node) => props.onSelect(node.path)}
|
||||||
onWidthChange={props.state.resizeSidebar}
|
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={query()}
|
when={!loading()}
|
||||||
fallback={
|
fallback={
|
||||||
<FileTree
|
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
||||||
path=""
|
{language.t("common.loading")}
|
||||||
class="pt-1"
|
{language.t("common.loading.ellipsis")}
|
||||||
active={props.active}
|
</div>
|
||||||
kinds={props.kinds}
|
|
||||||
onFileClick={(node) => props.onSelect(node.path)}
|
|
||||||
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={!loading()}
|
when={files().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
||||||
{language.t("common.loading")}
|
{language.t("palette.empty")}
|
||||||
{language.t("common.loading.ellipsis")}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<SessionFileListV2
|
||||||
when={files().length > 0}
|
id={resultsID}
|
||||||
fallback={
|
role="listbox"
|
||||||
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
optionID={optionID}
|
||||||
{language.t("palette.empty")}
|
files={files()}
|
||||||
</div>
|
kinds={props.kinds}
|
||||||
}
|
active={props.active}
|
||||||
>
|
highlighted={highlighted()}
|
||||||
<SessionFileListV2
|
onFileClick={(path) => {
|
||||||
id={resultsID}
|
setExplicitHighlight(path)
|
||||||
role="listbox"
|
props.onSelect(path)
|
||||||
optionID={optionID}
|
}}
|
||||||
files={files()}
|
onFileDoubleClick={props.onSelectPermanent}
|
||||||
kinds={props.kinds}
|
/>
|
||||||
active={props.active}
|
|
||||||
highlighted={highlighted()}
|
|
||||||
onFileClick={(path) => {
|
|
||||||
setExplicitHighlight(path)
|
|
||||||
props.onSelect(path)
|
|
||||||
}}
|
|
||||||
onFileDoubleClick={props.onSelectPermanent}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</SessionReviewV2Sidebar>
|
</Show>
|
||||||
|
</SessionReviewV2Sidebar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={!props.placeholder}
|
||||||
|
fallback={
|
||||||
|
<SessionFilePanelV2Empty>
|
||||||
|
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
|
||||||
|
<Icon name="file-tree" size="large" />
|
||||||
|
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
|
||||||
|
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
|
||||||
|
</div>
|
||||||
|
</SessionFilePanelV2Empty>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<div class="min-h-0 flex-1">
|
||||||
when={!props.placeholder}
|
<Show when={props.tab} keyed>
|
||||||
fallback={
|
{(tab) => <SessionFileView tab={tab} />}
|
||||||
<SessionFilePanelV2Empty>
|
</Show>
|
||||||
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
|
</div>
|
||||||
<Icon name="file-tree" size="large" />
|
</Show>
|
||||||
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
|
</SessionFilePanelV2>
|
||||||
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
|
|
||||||
</div>
|
|
||||||
</SessionFilePanelV2Empty>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="min-h-0 flex-1">
|
|
||||||
<Show when={props.tab} keyed>
|
|
||||||
{(tab) => <SessionFileView tab={tab} />}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</SessionFilePanelV2>
|
|
||||||
</Tabs.Content>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ function localStorageDirect(): SyncStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"]
|
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"]
|
||||||
|
|
||||||
export function draftPersistedKeys() {
|
export function draftPersistedKeys() {
|
||||||
return DRAFT_PERSISTED_KEYS
|
return DRAFT_PERSISTED_KEYS
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"description": "Effect-native confined code execution over schema-described tools",
|
"description": "Effect-native confined code execution over schema-described tools",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/* This file is auto-generated by SST. Do not edit. */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/* deno-fmt-ignore-file */
|
||||||
|
/* biome-ignore-all lint: auto-generated */
|
||||||
|
|
||||||
|
/// <reference path="../../sst-env.d.ts" />
|
||||||
|
|
||||||
|
import "sst"
|
||||||
|
export {}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ Allow: /
|
|||||||
|
|
||||||
# Disallow shared content pages
|
# Disallow shared content pages
|
||||||
Disallow: /s/
|
Disallow: /s/
|
||||||
Disallow: /share/
|
Disallow: /share/
|
||||||
|
|
||||||
|
Sitemap: https://opencode.ai/sitemap.xml
|
||||||
|
Sitemap: https://opencode.ai/data/sitemap.xml
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ type Usage = {
|
|||||||
input_tokens?: number
|
input_tokens?: number
|
||||||
input_tokens_details?: {
|
input_tokens_details?: {
|
||||||
cached_tokens?: number
|
cached_tokens?: number
|
||||||
|
cache_write_tokens?: number
|
||||||
}
|
}
|
||||||
output_tokens?: number
|
output_tokens?: number
|
||||||
output_tokens_details?: {
|
output_tokens_details?: {
|
||||||
@@ -48,12 +49,13 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
|
|||||||
const outputTokens = usage.output_tokens ?? 0
|
const outputTokens = usage.output_tokens ?? 0
|
||||||
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
|
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
|
||||||
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
|
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
|
||||||
|
const cacheWriteTokens = usage.input_tokens_details?.cache_write_tokens ?? undefined
|
||||||
return {
|
return {
|
||||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||||
outputTokens,
|
outputTokens,
|
||||||
reasoningTokens,
|
reasoningTokens,
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
cacheWrite5mTokens: undefined,
|
cacheWrite5mTokens: cacheWriteTokens,
|
||||||
cacheWrite1hTokens: undefined,
|
cacheWrite1hTokens: undefined,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,4 +65,20 @@ describe("provider usage extraction", () => {
|
|||||||
output_tokens: 7,
|
output_tokens: 7,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("parses OpenAI stream cache write usage", () => {
|
||||||
|
const usageParser = providers.openai.createUsageParser()
|
||||||
|
usageParser.parse(
|
||||||
|
'event: response.completed\ndata: {"response":{"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":4,"cache_write_tokens":3},"output_tokens":2}}}',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(providers.openai.normalizeUsage(usageParser.retrieve())).toEqual({
|
||||||
|
inputTokens: 6,
|
||||||
|
outputTokens: 2,
|
||||||
|
reasoningTokens: undefined,
|
||||||
|
cacheReadTokens: 4,
|
||||||
|
cacheWrite5mTokens: 3,
|
||||||
|
cacheWrite1hTokens: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.17.17",
|
"version": "1.17.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user