Compare commits
254
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
940257baeb | ||
|
|
717e74f3e5 | ||
|
|
b5632ea700 | ||
|
|
8845a43e9e | ||
|
|
7e5305c765 | ||
|
|
245f00a4b3 | ||
|
|
15c5ec6d24 | ||
|
|
49707c2117 | ||
|
|
dbb7872326 | ||
|
|
87e9e700cd | ||
|
|
4862c3e765 | ||
|
|
b0fcba5724 | ||
|
|
2b3ddf9f34 | ||
|
|
3c7f608475 | ||
|
|
1ed14151a5 | ||
|
|
16a30041b3 | ||
|
|
5b02ac4d33 | ||
|
|
633b5d6208 | ||
|
|
d595e472b3 | ||
|
|
0bfa55b623 | ||
|
|
d96e3a5423 | ||
|
|
dabf2dc013 | ||
|
|
775321173d | ||
|
|
b46cec2a7d | ||
|
|
a99ebd1d65 | ||
|
|
a5ea910eac | ||
|
|
56743dcf04 | ||
|
|
00ea47a502 | ||
|
|
0373ea9128 | ||
|
|
e1406e05a3 | ||
|
|
b5553839d0 | ||
|
|
249381e742 | ||
|
|
7499021132 | ||
|
|
9dd24d7d03 | ||
|
|
b2d76434ba | ||
|
|
d18eab5b85 | ||
|
|
7a5a997173 | ||
|
|
d200da121b | ||
|
|
fe482fe3dd | ||
|
|
2fce3c1370 | ||
|
|
7060cfa59b | ||
|
|
8077e8a9d1 | ||
|
|
d37166397c | ||
|
|
756c7c60bd | ||
|
|
94fb361591 | ||
|
|
fdff82e6fc | ||
|
|
b14e7451ab | ||
|
|
0de5f1ff36 | ||
|
|
7703786498 | ||
|
|
748fcb7ebd | ||
|
|
d5f397a2da | ||
|
|
5ca613e28e | ||
|
|
9c7de47031 | ||
|
|
f023c63a60 | ||
|
|
9495ecd536 | ||
|
|
03bb53c389 | ||
|
|
eab34b46d0 | ||
|
|
c5c9a1d435 | ||
|
|
47f333299b | ||
|
|
db63eaf6ea | ||
|
|
0cf99cf5f9 | ||
|
|
d16bfe850a | ||
|
|
728eae9c25 | ||
|
|
eb84f461b8 | ||
|
|
0b3a1c2fdf | ||
|
|
1ccd14b0e1 | ||
|
|
61e7cdfbff | ||
|
|
7fe7b9f258 | ||
|
|
a5ba1d075d | ||
|
|
ba437069e6 | ||
|
|
387c5a02c8 | ||
|
|
4b1b722e98 | ||
|
|
6b03be5468 | ||
|
|
7d2c1ce6c6 | ||
|
|
a9ef5a0fae | ||
|
|
0cf9a5d20b | ||
|
|
2c4ad9f405 | ||
|
|
3bf054c1d9 | ||
|
|
14c511e380 | ||
|
|
5ee0238c4a | ||
|
|
871d38cbd7 | ||
|
|
1a329e4e67 | ||
|
|
bfb2d8dc76 | ||
|
|
8f7a6c4a00 | ||
|
|
1857c73565 | ||
|
|
5f42351159 | ||
|
|
968aaa3cfe | ||
|
|
b8266e5819 | ||
|
|
ba746e36d8 | ||
|
|
d5068ba28e | ||
|
|
7265c46af6 | ||
|
|
536ee857c6 | ||
|
|
dda69d77e8 | ||
|
|
0e14404e5f | ||
|
|
05f51bfe45 | ||
|
|
aee552c043 | ||
|
|
4f6eaf859b | ||
|
|
d0cb58782f | ||
|
|
a3430db73a | ||
|
|
3e1972fd92 | ||
|
|
b368e5adbe | ||
|
|
69e4f52272 | ||
|
|
8a55920531 | ||
|
|
9db90a0b76 | ||
|
|
700d012025 | ||
|
|
59e486a917 | ||
|
|
8596967415 | ||
|
|
00038027c8 | ||
|
|
5cf597d583 | ||
|
|
d92b8d8009 | ||
|
|
854c535536 | ||
|
|
3cf955e9ad | ||
|
|
63f3e84792 | ||
|
|
0beb4de3e8 | ||
|
|
51da3483a9 | ||
|
|
060fbc9ce7 | ||
|
|
7a769dab3a | ||
|
|
2663ecd39d | ||
|
|
4ce247eaba | ||
|
|
76d9c2cd76 | ||
|
|
f6101aef8a | ||
|
|
f3874ec2f9 | ||
|
|
6466fcfdea | ||
|
|
1f0390cfbb | ||
|
|
9f06accfb4 | ||
|
|
7a9724496b | ||
|
|
3e931152d1 | ||
|
|
ad1d14775d | ||
|
|
fcf4dff2ce | ||
|
|
56714327f4 | ||
|
|
21f338652d | ||
|
|
8115f0c661 | ||
|
|
87d4cb07b0 | ||
|
|
ee008923f3 | ||
|
|
bbbef0da1c | ||
|
|
1268f8657e | ||
|
|
39e7ff932d | ||
|
|
e92c4fb460 | ||
|
|
4b496066bf | ||
|
|
e63dcd30f3 | ||
|
|
2935d1819e | ||
|
|
86907e2e4a | ||
|
|
31d2d38d7a | ||
|
|
d21477d55a | ||
|
|
80fa6e6c49 | ||
|
|
b99787e95b | ||
|
|
562d299a41 | ||
|
|
9ecb04e35b | ||
|
|
231689c767 | ||
|
|
d70942079a | ||
|
|
0cc55c11ac | ||
|
|
40da77e77c | ||
|
|
0d2de7db65 | ||
|
|
a568f616c9 | ||
|
|
e4957a78ea | ||
|
|
7e35cff4f5 | ||
|
|
82b796ce31 | ||
|
|
6c24062d2f | ||
|
|
3ca09ee3ef | ||
|
|
003f1ce0f4 | ||
|
|
61390dbb49 | ||
|
|
a58c3c53a9 | ||
|
|
75ce8feff5 | ||
|
|
6bee6ee755 | ||
|
|
9739d75892 | ||
|
|
4a976482b1 | ||
|
|
2caac055ef | ||
|
|
2697cb8001 | ||
|
|
dbd709376b | ||
|
|
fc08292136 | ||
|
|
ee594ba55b | ||
|
|
516af86d53 | ||
|
|
f5d20c580b | ||
|
|
7d5e91b801 | ||
|
|
f70afbea34 | ||
|
|
b275b12e90 | ||
|
|
4d900b2f46 | ||
|
|
b2661c4a03 | ||
|
|
6de584f25c | ||
|
|
8c72edb3ff | ||
|
|
b207e32249 | ||
|
|
6602341c0d | ||
|
|
b32debb8a3 | ||
|
|
7ff392abe3 | ||
|
|
7566cfe602 | ||
|
|
bea3ca5b05 | ||
|
|
c64ac905e1 | ||
|
|
7a554441b4 | ||
|
|
14366af069 | ||
|
|
d9ed62e51c | ||
|
|
39ea816a72 | ||
|
|
69eee26f30 | ||
|
|
8f3949d235 | ||
|
|
facd207396 | ||
|
|
2b28cc9024 | ||
|
|
d37f9e770b | ||
|
|
8fc02b0130 | ||
|
|
ddf18a7f9c | ||
|
|
fccfef1aaf | ||
|
|
e0e8159965 | ||
|
|
9cd6e07a50 | ||
|
|
c05ce3b725 | ||
|
|
26008696e1 | ||
|
|
6e177ee84a | ||
|
|
172fd971c5 | ||
|
|
8bfa188e07 | ||
|
|
5079fed63a | ||
|
|
4fae476873 | ||
|
|
4487fbf52f | ||
|
|
fb9d69ef62 | ||
|
|
ddd6eb4496 | ||
|
|
12bbe84360 | ||
|
|
661df8fcf8 | ||
|
|
16fb6dac8d | ||
|
|
93131b6e4c | ||
|
|
8643c0721e | ||
|
|
bd41dac88f | ||
|
|
41f6daf96a | ||
|
|
5381795844 | ||
|
|
7b9d7a7b7d | ||
|
|
7b0fd9fed2 | ||
|
|
b4a01cc3cd | ||
|
|
a6e1aa085f | ||
|
|
6a9cbe7de0 | ||
|
|
0e17c4f299 | ||
|
|
7c121d48b9 | ||
|
|
f5a8202b41 | ||
|
|
43c24d8d0f | ||
|
|
09603ed52f | ||
|
|
38e3b4087b | ||
|
|
eb52362e96 | ||
|
|
2969a513ce | ||
|
|
de672fd214 | ||
|
|
d0779d2aca | ||
|
|
650594e801 | ||
|
|
58143c4b07 | ||
|
|
4cbeacbc76 | ||
|
|
b0ca0419be | ||
|
|
4308dd75fb | ||
|
|
7690481fc1 | ||
|
|
ec6d42d41d | ||
|
|
3553754083 | ||
|
|
ed839846d1 | ||
|
|
ba803dd89a | ||
|
|
0e118d1961 | ||
|
|
40e73c4910 | ||
|
|
9559e2425b | ||
|
|
ac76af8c56 | ||
|
|
b67b03e0ae | ||
|
|
ef82426e28 | ||
|
|
14e9e5d9d6 | ||
|
|
9324ef0d08 | ||
|
|
8940572098 | ||
|
|
17d66ee4fe |
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.opencode
|
||||||
|
.sst
|
||||||
|
.turbo
|
||||||
|
.wrangler
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/.output
|
||||||
|
**/dist
|
||||||
|
**/.turbo
|
||||||
|
**/.vite
|
||||||
|
**/coverage
|
||||||
@@ -9,9 +9,15 @@ on:
|
|||||||
|
|
||||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment: ${{ github.ref_name }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|
||||||
@@ -21,14 +27,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
|
|
||||||
# Workaround for Pulumi version conflict:
|
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
|
||||||
# GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag
|
with:
|
||||||
# from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065).
|
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
|
||||||
# SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict.
|
role-session-name: opencode-${{ github.run_id }}
|
||||||
# Removing the system language plugin forces SST to use its bundled compatible version.
|
aws-region: us-east-1
|
||||||
# TODO: Remove when sst supports Pulumi >3.210.0
|
|
||||||
- name: Fix Pulumi version conflict
|
|
||||||
run: sudo rm -f /usr/local/bin/pulumi-language-nodejs
|
|
||||||
|
|
||||||
- run: bun sst deploy --stage=${{ github.ref_name }}
|
- run: bun sst deploy --stage=${{ github.ref_name }}
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||||
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
||||||
|
|
||||||
|
## Commits and PR Titles
|
||||||
|
|
||||||
|
Use conventional commit-style messages and PR titles: `type(scope): summary`.
|
||||||
|
|
||||||
|
Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes are optional; use the affected package or area when helpful, e.g. `core`, `opencode`, `tui`, `app`, `desktop`, `sdk`, or `plugin`.
|
||||||
|
|
||||||
|
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
|
||||||
|
|
||||||
## Style Guide
|
## Style Guide
|
||||||
|
|
||||||
### General Principles
|
### General Principles
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ export const api = new sst.cloudflare.Worker("Api", {
|
|||||||
transform: {
|
transform: {
|
||||||
worker: (args) => {
|
worker: (args) => {
|
||||||
args.logpush = true
|
args.logpush = true
|
||||||
if ($app.stage === "vimtor") return
|
if ($app.stage === "vimtor" || $app.stage === "adam") return
|
||||||
args.bindings = $resolve(args.bindings).apply((bindings) => [
|
args.bindings = $resolve(args.bindings).apply((bindings) => [
|
||||||
...bindings,
|
...bindings,
|
||||||
{
|
{
|
||||||
|
|||||||
+7
-3
@@ -1,7 +1,9 @@
|
|||||||
import { domain } from "./stage"
|
import { deployAws, domain } from "./stage"
|
||||||
import { EMAILOCTOPUS_API_KEY } from "./app"
|
import { EMAILOCTOPUS_API_KEY } from "./app"
|
||||||
import { SECRET } from "./secret"
|
import { SECRET } from "./secret"
|
||||||
|
|
||||||
|
const lake = deployAws ? await import("./lake") : undefined
|
||||||
|
|
||||||
////////////////
|
////////////////
|
||||||
// DATABASE
|
// DATABASE
|
||||||
////////////////
|
////////////////
|
||||||
@@ -240,7 +242,7 @@ const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
|
|||||||
|
|
||||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||||
handler: "packages/console/function/src/log-processor.ts",
|
handler: "packages/console/function/src/log-processor.ts",
|
||||||
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
link: [SECRET.HoneycombApiKey, ...(lake?.lakeIngest ? [lake.lakeIngest] : [])],
|
||||||
})
|
})
|
||||||
|
|
||||||
new sst.cloudflare.x.SolidStart("Console", {
|
new sst.cloudflare.x.SolidStart("Console", {
|
||||||
@@ -250,6 +252,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
bucket,
|
bucket,
|
||||||
bucketNew,
|
bucketNew,
|
||||||
database,
|
database,
|
||||||
|
SECRET.UpstashRedisRestUrl,
|
||||||
|
SECRET.UpstashRedisRestToken,
|
||||||
AUTH_API_URL,
|
AUTH_API_URL,
|
||||||
STRIPE_WEBHOOK_SECRET,
|
STRIPE_WEBHOOK_SECRET,
|
||||||
DISCORD_INCIDENT_WEBHOOK_URL,
|
DISCORD_INCIDENT_WEBHOOK_URL,
|
||||||
@@ -281,7 +285,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
},
|
},
|
||||||
transform: {
|
transform: {
|
||||||
server: {
|
server: {
|
||||||
placement: { region: "aws:us-east-1" },
|
placement: { region: "aws:us-east-2" },
|
||||||
transform: {
|
transform: {
|
||||||
worker: {
|
worker: {
|
||||||
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
||||||
|
|||||||
+322
@@ -0,0 +1,322 @@
|
|||||||
|
import { domain } from "./stage"
|
||||||
|
|
||||||
|
const current = aws.getCallerIdentityOutput({})
|
||||||
|
const partition = aws.getPartitionOutput({})
|
||||||
|
const region = aws.getRegionOutput({})
|
||||||
|
|
||||||
|
const tableBucketName = `opencode-${$app.stage}-lake`
|
||||||
|
const glueCatalogName = "s3tablescatalog"
|
||||||
|
const glueCatalogArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:catalog`
|
||||||
|
const glueS3TablesCatalogArn = $interpolate`${glueCatalogArn}/${glueCatalogName}`
|
||||||
|
const glueS3TablesChildCatalogArn = $interpolate`${glueS3TablesCatalogArn}/${tableBucketName}`
|
||||||
|
const glueS3TablesDatabaseWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/${glueCatalogName}/${tableBucketName}/*`
|
||||||
|
const glueS3TablesTableWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/${tableBucketName}/*/*`
|
||||||
|
const s3TablesBucketWildcardArn = $interpolate`arn:${partition.partition}:s3tables:${region.region}:${current.accountId}:bucket/*`
|
||||||
|
|
||||||
|
export const tableBucket = new aws.s3tables.TableBucket("LakeTableBucket", {
|
||||||
|
name: tableBucketName,
|
||||||
|
forceDestroy: $app.stage !== "production",
|
||||||
|
})
|
||||||
|
|
||||||
|
const s3TablesCatalog = new aws.cloudcontrol.Resource(
|
||||||
|
"LakeS3TablesCatalog",
|
||||||
|
{
|
||||||
|
typeName: "AWS::Glue::Catalog",
|
||||||
|
desiredState: $jsonStringify({
|
||||||
|
Name: glueCatalogName,
|
||||||
|
Description: "Federated catalog for S3 Tables",
|
||||||
|
FederatedCatalog: {
|
||||||
|
Identifier: s3TablesBucketWildcardArn,
|
||||||
|
ConnectionName: "aws:s3tables",
|
||||||
|
},
|
||||||
|
CreateDatabaseDefaultPermissions: [
|
||||||
|
{
|
||||||
|
Principal: {
|
||||||
|
DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
|
||||||
|
},
|
||||||
|
Permissions: ["ALL"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
CreateTableDefaultPermissions: [
|
||||||
|
{
|
||||||
|
Principal: {
|
||||||
|
DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
|
||||||
|
},
|
||||||
|
Permissions: ["ALL"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
AllowFullTableExternalDataAccess: "True",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ dependsOn: [tableBucket] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const athenaResultsBucket = new aws.s3.Bucket("LakeAthenaResults", {
|
||||||
|
bucket: `opencode-${$app.stage}-lake-athena-results`,
|
||||||
|
forceDestroy: $app.stage !== "production",
|
||||||
|
})
|
||||||
|
|
||||||
|
const firehoseErrorBucket = new aws.s3.Bucket("LakeFirehoseErrors", {
|
||||||
|
bucket: `opencode-${$app.stage}-lake-firehose-errors`,
|
||||||
|
forceDestroy: $app.stage !== "production",
|
||||||
|
})
|
||||||
|
|
||||||
|
const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
|
||||||
|
name: `opencode-${$app.stage}-lake-workgroup`,
|
||||||
|
forceDestroy: $app.stage !== "production",
|
||||||
|
configuration: {
|
||||||
|
enforceWorkgroupConfiguration: true,
|
||||||
|
publishCloudwatchMetricsEnabled: true,
|
||||||
|
resultConfiguration: {
|
||||||
|
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const firehoseRole = new aws.iam.Role("LakeFirehoseRole", {
|
||||||
|
assumeRolePolicy: aws.iam.getPolicyDocumentOutput({
|
||||||
|
statements: [
|
||||||
|
{
|
||||||
|
effect: "Allow",
|
||||||
|
actions: ["sts:AssumeRole"],
|
||||||
|
principals: [
|
||||||
|
{
|
||||||
|
type: "Service",
|
||||||
|
identifiers: ["firehose.amazonaws.com"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).json,
|
||||||
|
})
|
||||||
|
|
||||||
|
const firehosePolicy = new aws.iam.RolePolicy("LakeFirehosePolicy", {
|
||||||
|
role: firehoseRole.id,
|
||||||
|
policy: aws.iam.getPolicyDocumentOutput({
|
||||||
|
statements: [
|
||||||
|
{
|
||||||
|
effect: "Allow",
|
||||||
|
actions: [
|
||||||
|
"s3tables:ListTableBuckets",
|
||||||
|
"s3tables:GetTableBucket",
|
||||||
|
"s3tables:GetNamespace",
|
||||||
|
"s3tables:GetTable",
|
||||||
|
"s3tables:GetTableData",
|
||||||
|
"s3tables:GetTableMetadataLocation",
|
||||||
|
"s3tables:ListNamespaces",
|
||||||
|
"s3tables:ListTables",
|
||||||
|
"s3tables:PutTableData",
|
||||||
|
"s3tables:UpdateTableMetadataLocation",
|
||||||
|
],
|
||||||
|
resources: ["*"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
effect: "Allow",
|
||||||
|
actions: [
|
||||||
|
"glue:GetCatalog",
|
||||||
|
"glue:GetCatalogs",
|
||||||
|
"glue:GetDatabase",
|
||||||
|
"glue:GetDatabases",
|
||||||
|
"glue:GetTable",
|
||||||
|
"glue:GetTables",
|
||||||
|
"glue:UpdateTable",
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
glueCatalogArn,
|
||||||
|
glueS3TablesCatalogArn,
|
||||||
|
$interpolate`${glueS3TablesCatalogArn}/*`,
|
||||||
|
glueS3TablesDatabaseWildcardArn,
|
||||||
|
glueS3TablesTableWildcardArn,
|
||||||
|
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
|
||||||
|
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
|
||||||
|
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
effect: "Allow",
|
||||||
|
actions: [
|
||||||
|
"s3:AbortMultipartUpload",
|
||||||
|
"s3:GetBucketLocation",
|
||||||
|
"s3:GetObject",
|
||||||
|
"s3:ListBucket",
|
||||||
|
"s3:ListBucketMultipartUploads",
|
||||||
|
"s3:PutObject",
|
||||||
|
],
|
||||||
|
resources: [firehoseErrorBucket.arn, $interpolate`${firehoseErrorBucket.arn}/*`],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
effect: "Allow",
|
||||||
|
actions: ["lakeformation:GetDataAccess"],
|
||||||
|
resources: ["*"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).json,
|
||||||
|
})
|
||||||
|
|
||||||
|
const firehose = new aws.kinesis.FirehoseDeliveryStream(
|
||||||
|
"LakeFirehose",
|
||||||
|
{
|
||||||
|
name: `opencode-${$app.stage}-lake-ingest`,
|
||||||
|
destination: "iceberg",
|
||||||
|
icebergConfiguration: {
|
||||||
|
appendOnly: true,
|
||||||
|
bufferingInterval: 60,
|
||||||
|
bufferingSize: 1,
|
||||||
|
catalogArn: glueS3TablesChildCatalogArn,
|
||||||
|
processingConfiguration: {
|
||||||
|
enabled: true,
|
||||||
|
processors: [
|
||||||
|
{
|
||||||
|
type: "MetadataExtraction",
|
||||||
|
parameters: [
|
||||||
|
{ parameterName: "JsonParsingEngine", parameterValue: "JQ-1.6" },
|
||||||
|
{
|
||||||
|
parameterName: "MetadataExtractionQuery",
|
||||||
|
parameterValue:
|
||||||
|
'{destinationDatabaseName:._lake_database,destinationTableName:._lake_table,operation:(._lake_operation // "insert")}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
roleArn: firehoseRole.arn,
|
||||||
|
s3BackupMode: "FailedDataOnly",
|
||||||
|
s3Configuration: {
|
||||||
|
roleArn: firehoseRole.arn,
|
||||||
|
bucketArn: firehoseErrorBucket.arn,
|
||||||
|
errorOutputPrefix: "errors/!{firehose:error-output-type}/",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ dependsOn: [s3TablesCatalog, firehosePolicy] },
|
||||||
|
)
|
||||||
|
|
||||||
|
export const lakeVpc = new sst.aws.Vpc("LakeVpc")
|
||||||
|
export const lakeCluster = new sst.aws.Cluster("LakeCluster", { vpc: lakeVpc })
|
||||||
|
export const lakeRegion = region.region
|
||||||
|
export const lakeCatalog = $interpolate`${glueCatalogName}/${tableBucket.name}`
|
||||||
|
export const lakeAthenaWorkgroup = athenaWorkgroup
|
||||||
|
|
||||||
|
const ingestSecret = new random.RandomPassword("LakeIngestSecret", { length: 32 })
|
||||||
|
|
||||||
|
const ingestConfig = new sst.Linkable("LakeIngestConfig", {
|
||||||
|
properties: {
|
||||||
|
streamName: firehose.name,
|
||||||
|
secret: ingestSecret.result,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ingestService = new sst.aws.Service("LakeIngestService", {
|
||||||
|
cluster: lakeCluster,
|
||||||
|
architecture: "arm64",
|
||||||
|
cpu: "0.5 vCPU",
|
||||||
|
memory: "1 GB",
|
||||||
|
image: {
|
||||||
|
context: ".",
|
||||||
|
dockerfile: "packages/stats/server/Dockerfile",
|
||||||
|
},
|
||||||
|
link: [ingestConfig],
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
actions: ["firehose:PutRecord", "firehose:PutRecordBatch"],
|
||||||
|
resources: [firehose.arn],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scaling: {
|
||||||
|
min: $app.stage === "production" ? 2 : 1,
|
||||||
|
max: $app.stage === "production" ? 32 : 4,
|
||||||
|
cpuUtilization: 60,
|
||||||
|
memoryUtilization: 70,
|
||||||
|
},
|
||||||
|
loadBalancer: {
|
||||||
|
domain: {
|
||||||
|
name: `lake.${domain}`,
|
||||||
|
dns: sst.cloudflare.dns(),
|
||||||
|
},
|
||||||
|
rules: [
|
||||||
|
{ listen: "80/http", redirect: "443/https" },
|
||||||
|
{ listen: "443/https", forward: "3000/http" },
|
||||||
|
],
|
||||||
|
health: {
|
||||||
|
"3000/http": {
|
||||||
|
path: "/ready",
|
||||||
|
successCodes: "200-299",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
health: {
|
||||||
|
command: [
|
||||||
|
"CMD-SHELL",
|
||||||
|
"bun --eval \"fetch('http://localhost:3000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\"",
|
||||||
|
],
|
||||||
|
interval: "30 seconds",
|
||||||
|
retries: 3,
|
||||||
|
startPeriod: "30 seconds",
|
||||||
|
timeout: "5 seconds",
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
command: "bun run start",
|
||||||
|
directory: "packages/stats/server",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
wait: $app.stage === "production",
|
||||||
|
})
|
||||||
|
|
||||||
|
export const lakeIngest = new sst.Linkable("LakeIngest", {
|
||||||
|
properties: {
|
||||||
|
url: ingestService.url,
|
||||||
|
secret: ingestSecret.result,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const lakeQueryPermissions = [
|
||||||
|
{
|
||||||
|
actions: ["athena:StartQueryExecution", "athena:GetQueryExecution", "athena:GetQueryResults"],
|
||||||
|
resources: [athenaWorkgroup.arn],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
"glue:GetCatalog",
|
||||||
|
"glue:GetCatalogs",
|
||||||
|
"glue:GetDatabase",
|
||||||
|
"glue:GetDatabases",
|
||||||
|
"glue:GetTable",
|
||||||
|
"glue:GetTables",
|
||||||
|
"glue:GetPartitions",
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
glueCatalogArn,
|
||||||
|
glueS3TablesCatalogArn,
|
||||||
|
$interpolate`${glueS3TablesCatalogArn}/*`,
|
||||||
|
glueS3TablesDatabaseWildcardArn,
|
||||||
|
glueS3TablesTableWildcardArn,
|
||||||
|
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
|
||||||
|
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
|
||||||
|
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: ["s3:GetBucketLocation", "s3:ListBucket"],
|
||||||
|
resources: [athenaResultsBucket.arn],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: ["s3:GetObject", "s3:PutObject", "s3:AbortMultipartUpload", "s3:ListBucketMultipartUploads"],
|
||||||
|
resources: [$interpolate`${athenaResultsBucket.arn}/*`],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
"s3tables:GetTableBucket",
|
||||||
|
"s3tables:GetNamespace",
|
||||||
|
"s3tables:GetTable",
|
||||||
|
"s3tables:GetTableData",
|
||||||
|
"s3tables:GetTableMetadataLocation",
|
||||||
|
"s3tables:ListNamespaces",
|
||||||
|
"s3tables:ListTables",
|
||||||
|
],
|
||||||
|
resources: ["*"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: ["lakeformation:GetDataAccess"],
|
||||||
|
resources: ["*"],
|
||||||
|
},
|
||||||
|
]
|
||||||
+20
-13
@@ -2,8 +2,9 @@ import { SECRET } from "./secret"
|
|||||||
import { domain } from "./stage"
|
import { domain } from "./stage"
|
||||||
|
|
||||||
const description = "Managed by SST (Don't edit in Honeycomb UI)"
|
const description = "Managed by SST (Don't edit in Honeycomb UI)"
|
||||||
|
const alertsDisabled = $app.stage !== "production"
|
||||||
|
|
||||||
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
|
const webhookRecipient = new honeycombio.WebhookRecipient("DiscordAlerts", {
|
||||||
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
|
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
|
||||||
url: `https://${domain}/honeycomb/webhook`,
|
url: `https://${domain}/honeycomb/webhook`,
|
||||||
secret: SECRET.HoneycombWebhookSecret.result,
|
secret: SECRET.HoneycombWebhookSecret.result,
|
||||||
@@ -66,7 +67,7 @@ IF(
|
|||||||
)`,
|
)`,
|
||||||
})
|
})
|
||||||
|
|
||||||
return honeycomb.getQuerySpecificationOutput({
|
return honeycombio.getQuerySpecificationOutput({
|
||||||
breakdowns: ["model"],
|
breakdowns: ["model"],
|
||||||
calculatedFields: [failedHttpStatus],
|
calculatedFields: [failedHttpStatus],
|
||||||
calculations: [
|
calculations: [
|
||||||
@@ -79,7 +80,7 @@ IF(
|
|||||||
filters,
|
filters,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 200), DIV($FAILED, $TOTAL), 0)" }],
|
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 150), DIV($FAILED, $TOTAL), 0)" }],
|
||||||
timeRange: 900,
|
timeRange: 900,
|
||||||
}).json
|
}).json
|
||||||
}
|
}
|
||||||
@@ -98,7 +99,7 @@ const providerHttpErrorsQuery = () => {
|
|||||||
expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
|
expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
|
||||||
})
|
})
|
||||||
|
|
||||||
return honeycomb.getQuerySpecificationOutput({
|
return honeycombio.getQuerySpecificationOutput({
|
||||||
breakdowns: ["provider"],
|
breakdowns: ["provider"],
|
||||||
calculatedFields: [successHttpStatus, failedProviderHttpStatus],
|
calculatedFields: [successHttpStatus, failedProviderHttpStatus],
|
||||||
calculations: [
|
calculations: [
|
||||||
@@ -122,7 +123,7 @@ const providerHttpErrorsQuery = () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
formulas: [
|
formulas: [
|
||||||
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
|
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 150), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
|
||||||
],
|
],
|
||||||
timeRange: 900,
|
timeRange: 900,
|
||||||
}).json
|
}).json
|
||||||
@@ -139,7 +140,7 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
|
|||||||
{ column: "tps.output", op: "exists" },
|
{ column: "tps.output", op: "exists" },
|
||||||
]
|
]
|
||||||
|
|
||||||
return honeycomb.getQuerySpecificationOutput({
|
return honeycombio.getQuerySpecificationOutput({
|
||||||
breakdowns: ["model"],
|
breakdowns: ["model"],
|
||||||
calculations: [
|
calculations: [
|
||||||
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
|
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
|
||||||
@@ -156,9 +157,10 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
|
|||||||
}).json
|
}).json
|
||||||
}
|
}
|
||||||
|
|
||||||
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
|
new honeycombio.Trigger("IncreasedModelHttpErrorsGo", {
|
||||||
name: "Increased Model HTTP Errors [Go]",
|
name: "Increased Model HTTP Errors [Go]",
|
||||||
description,
|
description,
|
||||||
|
disabled: alertsDisabled,
|
||||||
queryJson: modelHttpErrorsQuery("go"),
|
queryJson: modelHttpErrorsQuery("go"),
|
||||||
alertType: "on_change",
|
alertType: "on_change",
|
||||||
frequency: 300,
|
frequency: 300,
|
||||||
@@ -175,9 +177,10 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
|
new honeycombio.Trigger("IncreasedModelHttpErrorsZen", {
|
||||||
name: "Increased Model HTTP Errors [Zen]",
|
name: "Increased Model HTTP Errors [Zen]",
|
||||||
description,
|
description,
|
||||||
|
disabled: alertsDisabled,
|
||||||
queryJson: modelHttpErrorsQuery("zen"),
|
queryJson: modelHttpErrorsQuery("zen"),
|
||||||
alertType: "on_change",
|
alertType: "on_change",
|
||||||
frequency: 300,
|
frequency: 300,
|
||||||
@@ -194,9 +197,10 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
new honeycomb.Trigger("LowModelTpsGo", {
|
new honeycombio.Trigger("LowModelTpsGo", {
|
||||||
name: "Low Model TPS [Go]",
|
name: "Low Model TPS [Go]",
|
||||||
description,
|
description,
|
||||||
|
disabled: alertsDisabled,
|
||||||
queryJson: modelLowTpsQuery("go"),
|
queryJson: modelLowTpsQuery("go"),
|
||||||
alertType: "on_change",
|
alertType: "on_change",
|
||||||
frequency: 600,
|
frequency: 600,
|
||||||
@@ -213,9 +217,10 @@ new honeycomb.Trigger("LowModelTpsGo", {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
new honeycomb.Trigger("LowModelTpsZen", {
|
new honeycombio.Trigger("LowModelTpsZen", {
|
||||||
name: "Low Model TPS [Zen]",
|
name: "Low Model TPS [Zen]",
|
||||||
description,
|
description,
|
||||||
|
disabled: alertsDisabled,
|
||||||
queryJson: modelLowTpsQuery("zen"),
|
queryJson: modelLowTpsQuery("zen"),
|
||||||
alertType: "on_change",
|
alertType: "on_change",
|
||||||
frequency: 600,
|
frequency: 600,
|
||||||
@@ -232,9 +237,10 @@ new honeycomb.Trigger("LowModelTpsZen", {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
new honeycomb.Trigger("IncreasedProviderHttpErrors", {
|
new honeycombio.Trigger("IncreasedProviderHttpErrors", {
|
||||||
name: "Increased Provider HTTP Errors",
|
name: "Increased Provider HTTP Errors",
|
||||||
description,
|
description,
|
||||||
|
disabled: alertsDisabled,
|
||||||
queryJson: providerHttpErrorsQuery(),
|
queryJson: providerHttpErrorsQuery(),
|
||||||
alertType: "on_change",
|
alertType: "on_change",
|
||||||
frequency: 300,
|
frequency: 300,
|
||||||
@@ -251,10 +257,11 @@ new honeycomb.Trigger("IncreasedProviderHttpErrors", {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
new honeycomb.Trigger("IncreasedFreeTierRequests", {
|
new honeycombio.Trigger("IncreasedFreeTierRequests", {
|
||||||
name: "Increased Free Tier Requests",
|
name: "Increased Free Tier Requests",
|
||||||
description,
|
description,
|
||||||
queryJson: honeycomb.getQuerySpecificationOutput({
|
disabled: alertsDisabled,
|
||||||
|
queryJson: honeycombio.getQuerySpecificationOutput({
|
||||||
calculations: [{ op: "COUNT" }],
|
calculations: [{ op: "COUNT" }],
|
||||||
filters: [
|
filters: [
|
||||||
{ column: "event_type", op: "=", value: "completions" },
|
{ column: "event_type", op: "=", value: "completions" },
|
||||||
|
|||||||
@@ -7,5 +7,8 @@ sst.Linkable.wrap(random.RandomPassword, (resource) => ({
|
|||||||
export const SECRET = {
|
export const SECRET = {
|
||||||
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
|
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
|
||||||
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
||||||
|
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
|
||||||
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
||||||
|
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
|
||||||
|
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export const domain = (() => {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
||||||
|
// Dev owns the shared AWS lake/stats infra for all non-production stages.
|
||||||
|
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
||||||
|
export const deployAws = $app.stage === awsStage
|
||||||
|
|
||||||
new cloudflare.RegionalHostname("RegionalHostname", {
|
new cloudflare.RegionalHostname("RegionalHostname", {
|
||||||
hostname: domain,
|
hostname: domain,
|
||||||
|
|||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
import { lakeAthenaWorkgroup, lakeCatalog, lakeCluster, lakeQueryPermissions, lakeRegion, tableBucket } from "./lake"
|
||||||
|
|
||||||
|
const domain = (() => {
|
||||||
|
if ($app.stage === "production") return "stats.opencode.ai"
|
||||||
|
if ($app.stage === "dev") return "stats.dev.opencode.ai"
|
||||||
|
return `stats.${$app.stage}.dev.opencode.ai`
|
||||||
|
})()
|
||||||
|
|
||||||
|
////////////////
|
||||||
|
// LAKE
|
||||||
|
////////////////
|
||||||
|
|
||||||
|
const inferenceNamespace = new aws.s3tables.Namespace("LakeInferenceNamespace", {
|
||||||
|
namespace: "inference",
|
||||||
|
tableBucketArn: tableBucket.arn,
|
||||||
|
})
|
||||||
|
|
||||||
|
const inferenceEventTable = new aws.s3tables.Table(
|
||||||
|
"LakeInferenceEventTable",
|
||||||
|
{
|
||||||
|
name: "event",
|
||||||
|
namespace: inferenceNamespace.namespace,
|
||||||
|
tableBucketArn: inferenceNamespace.tableBucketArn,
|
||||||
|
format: "ICEBERG",
|
||||||
|
metadata: {
|
||||||
|
iceberg: {
|
||||||
|
schema: {
|
||||||
|
fields: [
|
||||||
|
{ name: "event_timestamp", type: "string", required: false },
|
||||||
|
{ name: "event_date", type: "string", required: false },
|
||||||
|
{ name: "event_type", type: "string", required: false },
|
||||||
|
{ name: "dataset", type: "string", required: false },
|
||||||
|
{ name: "cf_continent", type: "string", required: false },
|
||||||
|
{ name: "cf_country", type: "string", required: false },
|
||||||
|
{ name: "cf_city", type: "string", required: false },
|
||||||
|
{ name: "cf_region", type: "string", required: false },
|
||||||
|
{ name: "cf_latitude", type: "double", required: false },
|
||||||
|
{ name: "cf_longitude", type: "double", required: false },
|
||||||
|
{ name: "cf_timezone", type: "string", required: false },
|
||||||
|
{ name: "duration", type: "double", required: false },
|
||||||
|
{ name: "request_length", type: "long", required: false },
|
||||||
|
{ name: "status", type: "int", required: false },
|
||||||
|
{ name: "ip", type: "string", required: false },
|
||||||
|
{ name: "is_stream", type: "boolean", required: false },
|
||||||
|
{ name: "session", type: "string", required: false },
|
||||||
|
{ name: "request", type: "string", required: false },
|
||||||
|
{ name: "client", type: "string", required: false },
|
||||||
|
{ name: "user_agent", type: "string", required: false },
|
||||||
|
{ name: "model_variant", type: "string", required: false },
|
||||||
|
{ name: "source", type: "string", required: false },
|
||||||
|
{ name: "provider", type: "string", required: false },
|
||||||
|
{ name: "provider_model", type: "string", required: false },
|
||||||
|
{ name: "model", type: "string", required: false },
|
||||||
|
{ name: "llm_error_code", type: "int", required: false },
|
||||||
|
{ name: "llm_error_message", type: "string", required: false },
|
||||||
|
{ name: "error_response", type: "string", required: false },
|
||||||
|
{ name: "error_type", type: "string", required: false },
|
||||||
|
{ name: "error_message", type: "string", required: false },
|
||||||
|
{ name: "error_cause", type: "string", required: false },
|
||||||
|
{ name: "error_cause2", type: "string", required: false },
|
||||||
|
{ name: "api_key", type: "string", required: false },
|
||||||
|
{ name: "workspace", type: "string", required: false },
|
||||||
|
{ name: "is_subscription", type: "boolean", required: false },
|
||||||
|
{ name: "subscription", type: "string", required: false },
|
||||||
|
{ name: "response_length", type: "long", required: false },
|
||||||
|
{ name: "time_to_first_byte", type: "long", required: false },
|
||||||
|
{ name: "timestamp_first_byte", type: "long", required: false },
|
||||||
|
{ name: "timestamp_last_byte", type: "long", required: false },
|
||||||
|
{ name: "tokens_input", type: "long", required: false },
|
||||||
|
{ name: "tokens_output", type: "long", required: false },
|
||||||
|
{ name: "tokens_reasoning", type: "long", required: false },
|
||||||
|
{ name: "tokens_cache_read", type: "long", required: false },
|
||||||
|
{ name: "tokens_cache_write_5m", type: "long", required: false },
|
||||||
|
{ name: "tokens_cache_write_1h", type: "long", required: false },
|
||||||
|
{ name: "cost_input_microcents", type: "long", required: false },
|
||||||
|
{ name: "cost_output_microcents", type: "long", required: false },
|
||||||
|
{ name: "cost_cache_read_microcents", type: "long", required: false },
|
||||||
|
{ name: "cost_cache_write_microcents", type: "long", required: false },
|
||||||
|
{ name: "cost_total_microcents", type: "long", required: false },
|
||||||
|
{ name: "cost_input", type: "long", required: false },
|
||||||
|
{ name: "cost_output", type: "long", required: false },
|
||||||
|
{ name: "cost_cache_read", type: "long", required: false },
|
||||||
|
{ name: "cost_cache_write_5m", type: "long", required: false },
|
||||||
|
{ name: "cost_cache_write_1h", type: "long", required: false },
|
||||||
|
{ name: "cost_total", type: "long", required: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ deleteBeforeReplace: $app.stage !== "production" },
|
||||||
|
)
|
||||||
|
|
||||||
|
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
|
||||||
|
properties: {
|
||||||
|
region: lakeRegion,
|
||||||
|
catalog: lakeCatalog,
|
||||||
|
database: inferenceNamespace.namespace,
|
||||||
|
table: inferenceEventTable.name,
|
||||||
|
tableBucket: tableBucket.name,
|
||||||
|
workgroup: lakeAthenaWorkgroup.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
////////////////
|
||||||
|
// DATABASE
|
||||||
|
////////////////
|
||||||
|
|
||||||
|
const cluster = planetscale.getDatabaseOutput({
|
||||||
|
name: "opencode-stats",
|
||||||
|
organization: "anomalyco",
|
||||||
|
})
|
||||||
|
|
||||||
|
const branch =
|
||||||
|
$app.stage === "production"
|
||||||
|
? planetscale.getBranchOutput({
|
||||||
|
name: "production",
|
||||||
|
organization: cluster.organization,
|
||||||
|
database: cluster.name,
|
||||||
|
})
|
||||||
|
: new planetscale.Branch("StatsDatabaseBranch", {
|
||||||
|
database: cluster.name,
|
||||||
|
organization: cluster.organization,
|
||||||
|
name: $app.stage,
|
||||||
|
parentBranch: "production",
|
||||||
|
})
|
||||||
|
|
||||||
|
const password = new planetscale.Password("StatsDatabasePassword", {
|
||||||
|
name: $app.stage,
|
||||||
|
database: cluster.name,
|
||||||
|
organization: cluster.organization,
|
||||||
|
branch: branch.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
const databaseUrl = $interpolate`mysql://${password.username.apply(encodeURIComponent)}:${password.plaintext.apply(
|
||||||
|
encodeURIComponent,
|
||||||
|
)}@${password.accessHostUrl}/${cluster.name}`
|
||||||
|
|
||||||
|
export const database = new sst.Linkable("StatsDatabase", {
|
||||||
|
properties: {
|
||||||
|
host: password.accessHostUrl,
|
||||||
|
database: cluster.name,
|
||||||
|
username: password.username,
|
||||||
|
password: password.plaintext,
|
||||||
|
port: 3306,
|
||||||
|
url: databaseUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
new sst.x.DevCommand("StatsStudio", {
|
||||||
|
link: [database],
|
||||||
|
environment: {
|
||||||
|
DATABASE_URL: databaseUrl,
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
command: "bun db:studio",
|
||||||
|
directory: "packages/stats/core",
|
||||||
|
autostart: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
////////////////
|
||||||
|
// APP
|
||||||
|
////////////////
|
||||||
|
|
||||||
|
// export const app = new sst.cloudflare.x.SolidStart("Stats", {
|
||||||
|
// path: "packages/stats/app",
|
||||||
|
// buildCommand: "bun run build",
|
||||||
|
// domain,
|
||||||
|
// link: [database],
|
||||||
|
// environment: {
|
||||||
|
// PUBLIC_URL: `https://${domain}`,
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
|
||||||
|
////////////////
|
||||||
|
// SERVICES
|
||||||
|
////////////////
|
||||||
|
|
||||||
|
const statsSyncConfig = new sst.Linkable("StatsSyncConfig", {
|
||||||
|
properties: {
|
||||||
|
dataset: "zen",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const statSync = new sst.aws.Service("StatsSyncService", {
|
||||||
|
cluster: lakeCluster,
|
||||||
|
architecture: "arm64",
|
||||||
|
cpu: "0.25 vCPU",
|
||||||
|
memory: "0.5 GB",
|
||||||
|
image: {
|
||||||
|
context: ".",
|
||||||
|
dockerfile: "packages/stats/server/Dockerfile",
|
||||||
|
},
|
||||||
|
command: ["bun", "src/stat-sync.ts"],
|
||||||
|
link: [database, inferenceEvent, statsSyncConfig],
|
||||||
|
permissions: lakeQueryPermissions,
|
||||||
|
scaling: {
|
||||||
|
min: 1,
|
||||||
|
max: 1,
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
command: "bun src/stat-sync.ts",
|
||||||
|
directory: "packages/stats/server",
|
||||||
|
autostart: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
+10
-1
@@ -3,6 +3,7 @@
|
|||||||
stdenv,
|
stdenv,
|
||||||
bun,
|
bun,
|
||||||
nodejs,
|
nodejs,
|
||||||
|
darwin,
|
||||||
electron_41,
|
electron_41,
|
||||||
makeWrapper,
|
makeWrapper,
|
||||||
writableTmpDirAsHomeHook,
|
writableTmpDirAsHomeHook,
|
||||||
@@ -14,7 +15,12 @@ let
|
|||||||
in
|
in
|
||||||
stdenv.mkDerivation (finalAttrs: {
|
stdenv.mkDerivation (finalAttrs: {
|
||||||
pname = "opencode-desktop";
|
pname = "opencode-desktop";
|
||||||
inherit (opencode) version src node_modules;
|
inherit (opencode)
|
||||||
|
version
|
||||||
|
src
|
||||||
|
node_modules
|
||||||
|
patches
|
||||||
|
;
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
bun
|
bun
|
||||||
@@ -23,6 +29,9 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
writableTmpDirAsHomeHook
|
writableTmpDirAsHomeHook
|
||||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
autoPatchelfHook
|
autoPatchelfHook
|
||||||
|
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||||
|
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
||||||
|
darwin.autoSignDarwinBinariesHook
|
||||||
];
|
];
|
||||||
|
|
||||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-FI1mX42vJuYdUDdWevlfHz+OcYkDn/I/HUbHE/jdQvs=",
|
"x86_64-linux": "sha256-gqXxbi1OwLoDSDtlmYWcTTPT/fqhVGb53JXS/9a1vWw=",
|
||||||
"aarch64-linux": "sha256-3CQzzKnh/4Zf5vyn56yR5P3ULsW7K7Fr8/RQpekEJDk=",
|
"aarch64-linux": "sha256-+ARbWtOUDx0J5k55mmre/2kPekUsQtWWS0QZnyX6NTo=",
|
||||||
"aarch64-darwin": "sha256-XPDVHMxlPpXlf43BRqNnwF809unk6iE8tvd0o92d0/w=",
|
"aarch64-darwin": "sha256-Q6ioI6lFUhNKogbvbtGWsZcXJfbRgUAWS5X2X4mKdg4=",
|
||||||
"x86_64-darwin": "sha256-dFXTi13RSgL62lMsep1EoE/KSEPF7Oh31PVdxW1tkzg="
|
"x86_64-darwin": "sha256-8K/hmcvXi9FGqprNbTKzMB80h5zZhO2TjOQbtve3ltQ="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-11
@@ -10,6 +10,7 @@
|
|||||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
|
"dev:stats": "bun run --cwd packages/stats/app dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
@@ -17,27 +18,29 @@
|
|||||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"hello": "echo 'Hello World!'",
|
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||||
"test": "echo 'do not run tests from root' && exit 1"
|
"test": "echo 'do not run tests from root' && exit 1"
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"packages/console/*",
|
"packages/console/*",
|
||||||
|
"packages/stats/*",
|
||||||
"packages/sdk/js",
|
"packages/sdk/js",
|
||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/opentelemetry": "4.0.0-beta.65",
|
"@effect/opentelemetry": "4.0.0-beta.66",
|
||||||
"@effect/platform-node": "4.0.0-beta.65",
|
"@effect/platform-node": "4.0.0-beta.66",
|
||||||
|
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.2.14",
|
"@opentui/core": "0.2.15",
|
||||||
"@opentui/keymap": "0.2.14",
|
"@opentui/keymap": "0.2.15",
|
||||||
"@opentui/solid": "0.2.14",
|
"@opentui/solid": "0.2.15",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
@@ -53,9 +56,9 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
"drizzle-kit": "1.0.0-rc.2",
|
||||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
"drizzle-orm": "1.0.0-rc.2",
|
||||||
"effect": "4.0.0-beta.65",
|
"effect": "4.0.0-beta.66",
|
||||||
"ai": "6.0.168",
|
"ai": "6.0.168",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
@@ -71,6 +74,7 @@
|
|||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
|
"sst": "4.13.1",
|
||||||
"shiki": "3.20.0",
|
"shiki": "3.20.0",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
@@ -97,7 +101,7 @@
|
|||||||
"oxlint-tsgolint": "0.21.0",
|
"oxlint-tsgolint": "0.21.0",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
"sst": "3.18.10",
|
"sst": "catalog:",
|
||||||
"turbo": "2.8.13"
|
"turbo": "2.8.13"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -138,6 +142,8 @@
|
|||||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
|
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||||
|
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import { expect, test, type Locator, type Page, type Route } from "@playwright/test"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/TimelineStateRegression"
|
||||||
|
const projectID = "proj_timeline_state_regression"
|
||||||
|
const sessionID = "ses_timeline_state_regression"
|
||||||
|
const userMessageID = "msg_user_regression"
|
||||||
|
const assistantMessageID = "msg_assistant_regression"
|
||||||
|
const editPartID = "prt_0001_edit"
|
||||||
|
const textPartID = "prt_9999_text"
|
||||||
|
const title = "Timeline collapse state regression"
|
||||||
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
|
|
||||||
|
type EventPayload = {
|
||||||
|
directory: string
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__timelineDiffProbe: {
|
||||||
|
reset: () => void
|
||||||
|
shadowRoots: () => number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMessage = {
|
||||||
|
info: {
|
||||||
|
id: userMessageID,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1700000000000 },
|
||||||
|
summary: { diffs: [] },
|
||||||
|
agent: "build",
|
||||||
|
model,
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: "prt_user_text",
|
||||||
|
sessionID,
|
||||||
|
messageID: userMessageID,
|
||||||
|
type: "text",
|
||||||
|
text: "Please edit the file.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const editPart = {
|
||||||
|
id: editPartID,
|
||||||
|
sessionID,
|
||||||
|
messageID: assistantMessageID,
|
||||||
|
type: "tool",
|
||||||
|
callID: "call_edit_regression",
|
||||||
|
tool: "edit",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: { filePath: "src/regression.ts" },
|
||||||
|
output: "Edited src/regression.ts",
|
||||||
|
title: "src/regression.ts",
|
||||||
|
metadata: {
|
||||||
|
filediff: {
|
||||||
|
file: "src/regression.ts",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
before: "export const value = 'before'\n",
|
||||||
|
after: "export const value = 'after'\n",
|
||||||
|
},
|
||||||
|
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||||
|
},
|
||||||
|
time: { start: 1700000001000, end: 1700000002000 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamedTextPart = {
|
||||||
|
id: textPartID,
|
||||||
|
sessionID,
|
||||||
|
messageID: assistantMessageID,
|
||||||
|
type: "text",
|
||||||
|
text: "Streaming added a later assistant text part.",
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessage = {
|
||||||
|
info: {
|
||||||
|
id: assistantMessageID,
|
||||||
|
sessionID,
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: 1700000001000 },
|
||||||
|
parentID: userMessageID,
|
||||||
|
modelID: model.modelID,
|
||||||
|
providerID: model.providerID,
|
||||||
|
mode: "build",
|
||||||
|
agent: "build",
|
||||||
|
path: { cwd: directory, root: directory },
|
||||||
|
cost: 0.01,
|
||||||
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
variant: "max",
|
||||||
|
},
|
||||||
|
parts: [editPart],
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("regression: session timeline local row state", () => {
|
||||||
|
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
|
||||||
|
const events: EventPayload[] = []
|
||||||
|
await mockServer(page, events)
|
||||||
|
await configurePage(page)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expect(page.getByRole("heading", { name: title })).toBeVisible()
|
||||||
|
|
||||||
|
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||||
|
await expect(wrapper).toBeVisible()
|
||||||
|
await expectExpanded(wrapper, true)
|
||||||
|
|
||||||
|
await wrapper.evaluate((element) => {
|
||||||
|
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
|
||||||
|
})
|
||||||
|
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
|
||||||
|
await expectExpanded(wrapper, false)
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
directory,
|
||||||
|
payload: {
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: { part: streamedTextPart },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
|
expect(await readToolState(page)).toEqual({
|
||||||
|
expanded: false,
|
||||||
|
row: "AssistantPart",
|
||||||
|
streamedTextVisible: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
|
||||||
|
const events: EventPayload[] = []
|
||||||
|
await installDiffProbe(page)
|
||||||
|
await mockServer(page, events)
|
||||||
|
await configurePage(page)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expect(page.getByRole("heading", { name: title })).toBeVisible()
|
||||||
|
|
||||||
|
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||||
|
await expect(wrapper).toBeVisible()
|
||||||
|
await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible()
|
||||||
|
await markDiffProbe(page)
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
directory,
|
||||||
|
payload: {
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: { part: streamedTextPart },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||||
|
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
|
||||||
|
|
||||||
|
await markDiffProbe(page)
|
||||||
|
events.push({
|
||||||
|
directory,
|
||||||
|
payload: {
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: { part: editPartWithAdditions(2) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
)
|
||||||
|
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function configurePage(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"settings.v3",
|
||||||
|
JSON.stringify({
|
||||||
|
general: {
|
||||||
|
editToolPartsExpanded: true,
|
||||||
|
shellToolPartsExpanded: true,
|
||||||
|
showReasoningSummaries: true,
|
||||||
|
showSessionProgressBar: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectExpanded(locator: Locator, expected: boolean) {
|
||||||
|
await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readToolState(page: Page) {
|
||||||
|
return page
|
||||||
|
.locator(`[data-timeline-part-id="${editPartID}"]`)
|
||||||
|
.first()
|
||||||
|
.evaluate(
|
||||||
|
(element, textPartID) => ({
|
||||||
|
expanded: (() => {
|
||||||
|
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
||||||
|
const aria = trigger?.getAttribute("aria-expanded")
|
||||||
|
if (aria === "true") return true
|
||||||
|
if (aria === "false") return false
|
||||||
|
|
||||||
|
const root = element.querySelector('[data-component="collapsible"]')
|
||||||
|
if (root?.hasAttribute("data-expanded")) return true
|
||||||
|
if (root?.hasAttribute("data-closed")) return false
|
||||||
|
|
||||||
|
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
|
||||||
|
return !!content && content.getBoundingClientRect().height > 0
|
||||||
|
})(),
|
||||||
|
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
|
||||||
|
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
|
||||||
|
}),
|
||||||
|
textPartID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installDiffProbe(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
let shadowRootCount = 0
|
||||||
|
const attachShadow = Element.prototype.attachShadow
|
||||||
|
Element.prototype.attachShadow = function (init) {
|
||||||
|
shadowRootCount += 1
|
||||||
|
return attachShadow.call(this, init)
|
||||||
|
}
|
||||||
|
window.__timelineDiffProbe = {
|
||||||
|
reset: () => {
|
||||||
|
shadowRootCount = 0
|
||||||
|
},
|
||||||
|
shadowRoots: () => shadowRootCount,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markDiffProbe(page: Page) {
|
||||||
|
await page
|
||||||
|
.locator(`[data-timeline-part-id="${editPartID}"]`)
|
||||||
|
.first()
|
||||||
|
.evaluate((element) => {
|
||||||
|
const tool = element as HTMLElement
|
||||||
|
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
||||||
|
if (!file) throw new Error("missing edit diff file")
|
||||||
|
|
||||||
|
tool.dataset.timelineProbe = "before"
|
||||||
|
file.dataset.timelineProbe = "before"
|
||||||
|
window.__timelineDiffProbe.reset()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readDiffProbe(page: Page) {
|
||||||
|
return page
|
||||||
|
.locator(`[data-timeline-part-id="${editPartID}"]`)
|
||||||
|
.first()
|
||||||
|
.evaluate((element) => {
|
||||||
|
const tool = element as HTMLElement
|
||||||
|
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
||||||
|
return {
|
||||||
|
fileMarker: file?.dataset.timelineProbe,
|
||||||
|
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
|
||||||
|
toolMarker: tool.dataset.timelineProbe,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPartWithAdditions(additions: number) {
|
||||||
|
return {
|
||||||
|
...editPart,
|
||||||
|
state: {
|
||||||
|
...editPart.state,
|
||||||
|
metadata: {
|
||||||
|
...editPart.state.metadata,
|
||||||
|
filediff: {
|
||||||
|
...editPart.state.metadata.filediff,
|
||||||
|
additions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExpanded(element: Element) {
|
||||||
|
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
||||||
|
const aria = trigger?.getAttribute("aria-expanded")
|
||||||
|
if (aria === "true") return true
|
||||||
|
if (aria === "false") return false
|
||||||
|
|
||||||
|
const root = element.querySelector('[data-component="collapsible"]')
|
||||||
|
if (root?.hasAttribute("data-expanded")) return true
|
||||||
|
if (root?.hasAttribute("data-closed")) return false
|
||||||
|
|
||||||
|
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
|
||||||
|
return !!content && content.getBoundingClientRect().height > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockServer(page: Page, events: EventPayload[]) {
|
||||||
|
await page.route("**/*", async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||||
|
if (url.port !== targetPort) return route.fallback()
|
||||||
|
|
||||||
|
const path = url.pathname
|
||||||
|
if (path === "/global/event") return sse(route, events.splice(0))
|
||||||
|
if (
|
||||||
|
path === "/global/config" ||
|
||||||
|
path === "/config" ||
|
||||||
|
path === "/provider/auth" ||
|
||||||
|
path === "/mcp" ||
|
||||||
|
path === "/session/status"
|
||||||
|
)
|
||||||
|
return json(route, {})
|
||||||
|
if (
|
||||||
|
["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return json(route, [])
|
||||||
|
if (path === "/provider") return json(route, provider())
|
||||||
|
if (path === "/path")
|
||||||
|
return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
|
||||||
|
if (path === "/project") return json(route, [project()])
|
||||||
|
if (path === "/project/current") return json(route, project())
|
||||||
|
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
|
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (path === "/session") return json(route, [session()])
|
||||||
|
if (path === `/session/${sessionID}`) return json(route, session())
|
||||||
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
|
||||||
|
if (path === `/session/${sessionID}/message`) return json(route, [userMessage, assistantMessage])
|
||||||
|
return json(route, {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function project() {
|
||||||
|
return {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "timeline-state-regression",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function session() {
|
||||||
|
return {
|
||||||
|
id: sessionID,
|
||||||
|
slug: "timeline-state-regression",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function provider() {
|
||||||
|
return {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
|
||||||
|
body: JSON.stringify(body ?? null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sse(route: Route, events: EventPayload[]) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Encode(value: string) {
|
||||||
|
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/ContextResizeRegression"
|
||||||
|
const projectID = "proj_context_resize_regression"
|
||||||
|
const sessionID = "ses_context_resize_regression"
|
||||||
|
const title = "Context resize regression"
|
||||||
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
|
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
|
||||||
|
const followingTextID = "prt_0104_text"
|
||||||
|
|
||||||
|
type Message = {
|
||||||
|
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||||
|
parts: Record<string, unknown>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
|
||||||
|
|
||||||
|
test.describe("regression: session timeline context group resize", () => {
|
||||||
|
test("remeasures a recent explored context group before the next paint", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
|
await mockServer(page)
|
||||||
|
await configurePage(page)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expect(page.getByRole("heading", { name: title })).toBeVisible()
|
||||||
|
await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible()
|
||||||
|
await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible()
|
||||||
|
await settle(page)
|
||||||
|
|
||||||
|
const samples = await sampleExpansion(page)
|
||||||
|
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
|
||||||
|
|
||||||
|
console.log("context resize samples", JSON.stringify(samples, null, 2))
|
||||||
|
|
||||||
|
expect(samples[0]?.overlap).toBe(0)
|
||||||
|
expect(visibleOverlap).toEqual([])
|
||||||
|
expect(samples.at(-1)?.expanded).toBe("true")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function configurePage(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"settings.v3",
|
||||||
|
JSON.stringify({
|
||||||
|
general: {
|
||||||
|
editToolPartsExpanded: true,
|
||||||
|
shellToolPartsExpanded: true,
|
||||||
|
showReasoningSummaries: true,
|
||||||
|
showSessionProgressBar: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sampleExpansion(page: Page) {
|
||||||
|
return page.evaluate(
|
||||||
|
({ contextIDs, followingTextID }) =>
|
||||||
|
new Promise<
|
||||||
|
{
|
||||||
|
frame: number
|
||||||
|
label: string
|
||||||
|
scrollTop: number
|
||||||
|
scrollHeight: number
|
||||||
|
contextBottom: number
|
||||||
|
textTop: number
|
||||||
|
overlap: number
|
||||||
|
gap: number
|
||||||
|
expanded: string | null
|
||||||
|
}[]
|
||||||
|
>((resolve) => {
|
||||||
|
const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
|
||||||
|
const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
|
||||||
|
const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
|
||||||
|
const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
|
||||||
|
const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
|
||||||
|
const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
|
||||||
|
if (!context || !text || !scroller || !trigger || !contextRow || !textRow)
|
||||||
|
throw new Error("missing regression nodes")
|
||||||
|
|
||||||
|
scroller.scrollTop = scroller.scrollHeight
|
||||||
|
const samples: {
|
||||||
|
frame: number
|
||||||
|
label: string
|
||||||
|
scrollTop: number
|
||||||
|
scrollHeight: number
|
||||||
|
contextBottom: number
|
||||||
|
textTop: number
|
||||||
|
overlap: number
|
||||||
|
gap: number
|
||||||
|
expanded: string | null
|
||||||
|
}[] = []
|
||||||
|
const capture = (frame: number, label: string) => {
|
||||||
|
const contextRect = contextRow.getBoundingClientRect()
|
||||||
|
const textRect = textRow.getBoundingClientRect()
|
||||||
|
samples.push({
|
||||||
|
frame,
|
||||||
|
label,
|
||||||
|
scrollTop: Math.round(scroller.scrollTop * 10) / 10,
|
||||||
|
scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
|
||||||
|
contextBottom: Math.round(contextRect.bottom * 10) / 10,
|
||||||
|
textTop: Math.round(textRect.top * 10) / 10,
|
||||||
|
overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
|
||||||
|
gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
|
||||||
|
expanded: trigger.getAttribute("aria-expanded"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
capture(-1, "before")
|
||||||
|
trigger.click()
|
||||||
|
capture(0, "sync-after-click")
|
||||||
|
|
||||||
|
let frame = 1
|
||||||
|
const tick = () => {
|
||||||
|
capture(frame, "raf")
|
||||||
|
frame += 1
|
||||||
|
if (frame > 8) {
|
||||||
|
resolve(samples)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick)
|
||||||
|
}),
|
||||||
|
{ contextIDs, followingTextID },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function turn(index: number, target: boolean): Message[] {
|
||||||
|
const userID = id("msg_user", index)
|
||||||
|
const assistantID = id("msg_assistant", index)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: userID,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1700000000000 + index * 10_000 },
|
||||||
|
summary: { diffs: [] },
|
||||||
|
agent: "build",
|
||||||
|
model,
|
||||||
|
},
|
||||||
|
parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: assistantID,
|
||||||
|
sessionID,
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
|
||||||
|
parentID: userID,
|
||||||
|
modelID: model.modelID,
|
||||||
|
providerID: model.providerID,
|
||||||
|
mode: "build",
|
||||||
|
agent: "build",
|
||||||
|
path: { cwd: directory, root: directory },
|
||||||
|
cost: 0.01,
|
||||||
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
variant: "max",
|
||||||
|
finish: "stop",
|
||||||
|
},
|
||||||
|
parts: target
|
||||||
|
? [
|
||||||
|
contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }),
|
||||||
|
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
|
||||||
|
contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
|
||||||
|
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
|
||||||
|
{
|
||||||
|
id: followingTextID,
|
||||||
|
sessionID,
|
||||||
|
messageID: assistantID,
|
||||||
|
type: "text",
|
||||||
|
text: "This assistant text is immediately after the explored context group.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: id("prt_text", index),
|
||||||
|
sessionID,
|
||||||
|
messageID: assistantID,
|
||||||
|
type: "text",
|
||||||
|
text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
id: partID,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
type: "tool",
|
||||||
|
callID: `call_${partID}`,
|
||||||
|
tool,
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input,
|
||||||
|
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
|
||||||
|
title: input.filePath || input.path || input.pattern || "completed",
|
||||||
|
metadata: {},
|
||||||
|
time: { start: 1700000000000, end: 1700000000100 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockServer(page: Page) {
|
||||||
|
await page.route("**/*", async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||||
|
if (url.port !== targetPort) return route.fallback()
|
||||||
|
|
||||||
|
const path = url.pathname
|
||||||
|
if (path === "/global/event" || path === "/event") return sse(route)
|
||||||
|
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(path))
|
||||||
|
return json(route, {})
|
||||||
|
if (
|
||||||
|
["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return json(route, [])
|
||||||
|
if (path === "/provider") return json(route, provider())
|
||||||
|
if (path === "/path")
|
||||||
|
return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
|
||||||
|
if (path === "/project") return json(route, [project()])
|
||||||
|
if (path === "/project/current") return json(route, project())
|
||||||
|
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
|
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (path === "/session") return json(route, [session()])
|
||||||
|
if (path === `/session/${sessionID}`) return json(route, session())
|
||||||
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
|
||||||
|
if (path === `/session/${sessionID}/message`) return json(route, messages)
|
||||||
|
return json(route, {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle(page: Page) {
|
||||||
|
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function id(prefix: string, index: number) {
|
||||||
|
return `${prefix}_${String(index).padStart(4, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function project() {
|
||||||
|
return {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "context-resize-regression",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function session() {
|
||||||
|
return {
|
||||||
|
id: sessionID,
|
||||||
|
slug: "context-resize-regression",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function provider() {
|
||||||
|
return {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
|
||||||
|
body: JSON.stringify(body ?? null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sse(route: Route) {
|
||||||
|
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Encode(value: string) {
|
||||||
|
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { fixture, pageMessages } from "./session-timeline.fixture"
|
import { fixture, pageMessages } from "./session-timeline.fixture"
|
||||||
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
@@ -37,12 +38,12 @@ test.describe("smoke: session timeline", () => {
|
|||||||
project: fixture.project,
|
project: fixture.project,
|
||||||
pageMessages,
|
pageMessages,
|
||||||
})
|
})
|
||||||
await configureSmokePage(page)
|
await configureSmokePage(page, fixture.directory)
|
||||||
|
|
||||||
await openProject(page, "SmokeProject")
|
await selectHomeProject(page, fixture.project.name)
|
||||||
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
|
||||||
await expectSessionReady(page, "smoke-project")
|
await expectSessionReady(page)
|
||||||
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
||||||
const expectedPartIDs = fixture.expected.targetPartIDs
|
const expectedPartIDs = fixture.expected.targetPartIDs
|
||||||
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
||||||
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||||
@@ -50,7 +51,7 @@ test.describe("smoke: session timeline", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function configureSmokePage(page: Page) {
|
async function configureSmokePage(page: Page, directory: string) {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"settings.v3",
|
"settings.v3",
|
||||||
@@ -63,7 +64,23 @@ async function configureSmokePage(page: Page) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.addInitScript((directory) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: {
|
||||||
|
local: [{ worktree: directory, expanded: true }],
|
||||||
|
},
|
||||||
|
lastProject: {
|
||||||
|
local: directory,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, directory)
|
||||||
|
|
||||||
|
await page.addInitScript(() => {
|
||||||
const smoke = window as SmokeWindow
|
const smoke = window as SmokeWindow
|
||||||
smoke.__timelineSmokeErrorToasts = []
|
smoke.__timelineSmokeErrorToasts = []
|
||||||
smoke.__timelineSmokeForbiddenText = []
|
smoke.__timelineSmokeForbiddenText = []
|
||||||
@@ -392,21 +409,20 @@ function expectCompleteScroll(
|
|||||||
expect(expectedPartIDs.length).toBe(331)
|
expect(expectedPartIDs.length).toBe(331)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openProject(page: Page, projectName: string) {
|
async function selectHomeProject(page: Page, projectName: string) {
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
|
await page
|
||||||
|
.locator('[data-component="home-project-row"]')
|
||||||
|
.filter({ hasText: new RegExp(projectName, "i") })
|
||||||
|
.click()
|
||||||
|
await expect(page).toHaveURL(/\/$/)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
|
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
|
||||||
// Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
|
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
|
||||||
await page
|
|
||||||
.locator(`a[href*="${sessionId}"]`)
|
|
||||||
.first()
|
|
||||||
.evaluate((el) => (el as HTMLElement).click())
|
|
||||||
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
|
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectSessionReady(page: Page, projectName: string) {
|
async function expectSessionReady(page: Page) {
|
||||||
await expect(page.getByText(projectName).first()).toBeVisible()
|
await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible()
|
||||||
await expect(page.getByText("Ask anything...")).toBeVisible()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
"/agent": [{ name: "build", mode: "primary" }],
|
"/agent": [{ name: "build", mode: "primary" }],
|
||||||
"/vcs": { branch: "main", default_branch: "main" },
|
"/vcs": { branch: "main", default_branch: "main" },
|
||||||
"/session": config.sessions,
|
"/session": config.sessions,
|
||||||
|
"/global/health": {
|
||||||
|
healthy: true,
|
||||||
|
version: "mock",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.15.5",
|
"version": "1.15.10",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -42,10 +42,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@sentry/solid": "catalog:",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@sentry/solid": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
"@solid-primitives/audio": "1.4.2",
|
"@solid-primitives/audio": "1.4.2",
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
"@solid-primitives/i18n": "2.2.1",
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.5",
|
"@solid-primitives/resize-observer": "2.1.5",
|
||||||
|
"@solid-primitives/scheduled": "1.5.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@solid-primitives/timer": "1.4.4",
|
"@solid-primitives/timer": "1.4.4",
|
||||||
|
|||||||
Binary file not shown.
+22
-22
@@ -30,8 +30,8 @@ import { Dynamic } from "solid-js/web"
|
|||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
import { ServerSyncProvider } from "@/context/server-sync"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { LayoutProvider } from "@/context/layout"
|
||||||
@@ -46,24 +46,25 @@ import DirectoryLayout from "@/pages/directory-layout"
|
|||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
|
import { ServersProvider } from "./context/servers"
|
||||||
|
|
||||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "prod") {
|
||||||
const loadSession = () => import("@/pages/session")
|
document.body.classList.remove("text-12-regular")
|
||||||
const Session = lazy(loadSession)
|
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
||||||
const Loading = () => <div class="size-full" />
|
|
||||||
|
|
||||||
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
|
|
||||||
void loadSession()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionRoute = () => (
|
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||||
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
|
|
||||||
|
const SessionRoute = Object.assign(
|
||||||
|
() => (
|
||||||
<SessionProviders>
|
<SessionProviders>
|
||||||
<Session />
|
<Session />
|
||||||
</SessionProviders>
|
</SessionProviders>
|
||||||
|
),
|
||||||
|
{ preload: Session.preload },
|
||||||
)
|
)
|
||||||
|
|
||||||
const SessionIndexRoute = () => <Navigate href="session" />
|
|
||||||
|
|
||||||
function UiI18nBridge(props: ParentProps) {
|
function UiI18nBridge(props: ParentProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
@@ -78,6 +79,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
api?: {
|
api?: {
|
||||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||||
|
exportDebugLogs?: () => Promise<string>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,31 +302,29 @@ export function AppInterface(props: {
|
|||||||
disableHealthCheck?: boolean
|
disableHealthCheck?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ServerProvider
|
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||||
defaultServer={props.defaultServer}
|
<ServersProvider>
|
||||||
disableHealthCheck={props.disableHealthCheck}
|
|
||||||
servers={props.servers}
|
|
||||||
>
|
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<GlobalSDKProvider>
|
<ServerSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<ServerSyncProvider>
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||||
>
|
>
|
||||||
<Route path="/" component={HomeRoute} />
|
<Route path="/" component={HomeRoute} />
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/" component={SessionIndexRoute} />
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
</Route>
|
</Route>
|
||||||
</Dynamic>
|
</Dynamic>
|
||||||
</GlobalSyncProvider>
|
</ServerSyncProvider>
|
||||||
</GlobalSDKProvider>
|
</ServerSDKProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</ServerKey>
|
</ServerKey>
|
||||||
</ConnectionGate>
|
</ConnectionGate>
|
||||||
|
</ServersProvider>
|
||||||
</ServerProvider>
|
</ServerProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
|
||||||
export function DialogConnectProvider(props: { provider: string }) {
|
export function DialogConnectProvider(props: { provider: string }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const globalSDK = useGlobalSDK()
|
const serverSDK = useServerSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
|
|
||||||
@@ -41,9 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const provider = createMemo(
|
const provider = createMemo(
|
||||||
() =>
|
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
|
||||||
providers.all().find((x) => x.id === props.provider) ??
|
|
||||||
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
|
|
||||||
)
|
)
|
||||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||||
{
|
{
|
||||||
@@ -54,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
const [auth] = createResource(
|
const [auth] = createResource(
|
||||||
() => props.provider,
|
() => props.provider,
|
||||||
async () => {
|
async () => {
|
||||||
const cached = globalSync.data.provider_auth[props.provider]
|
const cached = serverSync.data.provider_auth[props.provider]
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
const res = await globalSDK.client.provider.auth()
|
const res = await serverSDK.client.provider.auth()
|
||||||
if (!alive.value) return fallback()
|
if (!alive.value) return fallback()
|
||||||
globalSync.set("provider_auth", res.data ?? {})
|
serverSync.set("provider_auth", res.data ?? {})
|
||||||
return res.data?.[props.provider] ?? fallback()
|
return res.data?.[props.provider] ?? fallback()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
|
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
|
||||||
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
|
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||||
@@ -160,7 +158,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await globalSDK.client.provider.oauth
|
await serverSDK.client.provider.oauth
|
||||||
.authorize(
|
.authorize(
|
||||||
{
|
{
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
@@ -332,7 +330,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
await globalSDK.client.global.dispose()
|
await serverSDK.client.global.dispose()
|
||||||
dialog.close()
|
dialog.close()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
@@ -409,7 +407,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
await globalSDK.client.auth.set({
|
await serverSDK.client.auth.set({
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
@@ -480,7 +478,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
const result = await globalSDK.client.provider.oauth
|
const result = await serverSDK.client.provider.oauth
|
||||||
.callback({
|
.callback({
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
method: store.methodIndex,
|
method: store.methodIndex,
|
||||||
@@ -526,14 +524,14 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
const code = createMemo(() => {
|
const code = createMemo(() => {
|
||||||
const instructions = store.authorization?.instructions
|
const instructions = store.authorization?.instructions
|
||||||
if (instructions?.includes(":")) {
|
if (instructions?.includes(":")) {
|
||||||
return instructions.split(":")[1]?.trim()
|
return instructions.split(":").pop()?.trim()
|
||||||
}
|
}
|
||||||
return instructions
|
return instructions
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const result = await globalSDK.client.provider.oauth
|
const result = await serverSDK.client.provider.oauth
|
||||||
.callback({
|
.callback({
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
method: store.methodIndex,
|
method: store.methodIndex,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import { batch, For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
@@ -21,8 +21,8 @@ type Props = {
|
|||||||
|
|
||||||
export function DialogCustomProvider(props: Props) {
|
export function DialogCustomProvider(props: Props) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const globalSDK = useGlobalSDK()
|
const serverSDK = useServerSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const [form, setForm] = createStore<FormState>({
|
const [form, setForm] = createStore<FormState>({
|
||||||
@@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
const output = validateCustomProvider({
|
const output = validateCustomProvider({
|
||||||
form,
|
form,
|
||||||
t: language.t,
|
t: language.t,
|
||||||
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
||||||
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setForm("err", output.err)
|
setForm("err", output.err)
|
||||||
@@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
const disabledProviders = serverSync.data.config.disabled_providers ?? []
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
if (result.key) {
|
if (result.key) {
|
||||||
await globalSDK.client.auth.set({
|
await serverSDK.client.auth.set({
|
||||||
providerID: result.providerID,
|
providerID: result.providerID,
|
||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await globalSync.updateConfig({
|
await serverSync.updateConfig({
|
||||||
provider: { [result.providerID]: result.config },
|
provider: { [result.providerID]: result.config },
|
||||||
disabled_providers: nextDisabled,
|
disabled_providers: nextDisabled,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,20 +6,20 @@ 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 { createMemo, For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
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/sidebar-items"
|
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
|
|
||||||
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 }) {
|
export function DialogEditProject(props: { project: LocalProject }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const globalSDK = useGlobalSDK()
|
const serverSDK = useServerSDK()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||||
@@ -78,19 +78,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
const start = store.startup.trim()
|
const start = store.startup.trim()
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
if (props.project.id && props.project.id !== "global") {
|
||||||
await globalSDK.client.project.update({
|
await serverSDK.client.project.update({
|
||||||
projectID: props.project.id,
|
projectID: props.project.id,
|
||||||
directory: props.project.worktree,
|
directory: props.project.worktree,
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
commands: { start },
|
commands: { start },
|
||||||
})
|
})
|
||||||
globalSync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
serverSync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
globalSync.project.meta(props.project.worktree, {
|
serverSync.project.meta(props.project.worktree, {
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
||||||
commands: { start: start || undefined },
|
commands: { start: start || undefined },
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type { ListRef } from "@opencode-ai/ui/list"
|
|||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { createMemo, createResource, createSignal } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ function uniqueRows(rows: Row[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useDirectorySearch(args: {
|
function useDirectorySearch(args: {
|
||||||
sdk: ReturnType<typeof useGlobalSDK>
|
sdk: ReturnType<typeof useServerSDK>
|
||||||
start: () => string | undefined
|
start: () => string | undefined
|
||||||
home: () => string
|
home: () => string
|
||||||
}) {
|
}) {
|
||||||
@@ -246,8 +246,8 @@ function useDirectorySearch(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const sync = useGlobalSync()
|
const sync = useServerSync()
|
||||||
const sdk = useGlobalSDK()
|
const sdk = useServerSDK()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
|||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -175,7 +175,7 @@ function createFileEntries(props: {
|
|||||||
function createSessionEntries(props: {
|
function createSessionEntries(props: {
|
||||||
workspaces: () => string[]
|
workspaces: () => string[]
|
||||||
label: (directory: string) => string
|
label: (directory: string) => string
|
||||||
globalSDK: ReturnType<typeof useGlobalSDK>
|
serverSDK: ReturnType<typeof useServerSDK>
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}) {
|
}) {
|
||||||
const state: {
|
const state: {
|
||||||
@@ -207,7 +207,7 @@ function createSessionEntries(props: {
|
|||||||
state.inflight = Promise.all(
|
state.inflight = Promise.all(
|
||||||
dirs.map((directory) => {
|
dirs.map((directory) => {
|
||||||
const description = props.label(directory)
|
const description = props.label(directory)
|
||||||
return props.globalSDK.client.session
|
return props.serverSDK.client.session
|
||||||
.list({ directory, roots: true })
|
.list({ directory, roots: true })
|
||||||
.then((x) =>
|
.then((x) =>
|
||||||
(x.data ?? [])
|
(x.data ?? [])
|
||||||
@@ -268,8 +268,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const file = useFile()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const globalSDK = useGlobalSDK()
|
const serverSDK = useServerSDK()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const { params, tabs, view } = useSessionLayout()
|
const { params, tabs, view } = useSessionLayout()
|
||||||
const filesOnly = () => props.mode === "files"
|
const filesOnly = () => props.mode === "files"
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
@@ -292,21 +292,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||||
return dirs
|
return dirs
|
||||||
})
|
})
|
||||||
const homedir = createMemo(() => globalSync.data.path.home)
|
const homedir = createMemo(() => serverSync.data.path.home)
|
||||||
const label = (directory: string) => {
|
const label = (directory: string) => {
|
||||||
const current = project()
|
const current = project()
|
||||||
const kind =
|
const kind =
|
||||||
current && directory === current.worktree
|
current && directory === current.worktree
|
||||||
? language.t("workspace.type.local")
|
? language.t("workspace.type.local")
|
||||||
: language.t("workspace.type.sandbox")
|
: language.t("workspace.type.sandbox")
|
||||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
const path = home ? directory.replace(home, "~") : directory
|
const path = home ? directory.replace(home, "~") : directory
|
||||||
const name = store.vcs?.branch ?? getFilename(directory)
|
const name = store.vcs?.branch ?? getFilename(directory)
|
||||||
return `${kind} : ${name || path}`
|
return `${kind} : ${name || path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
|
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
|
||||||
|
|
||||||
const items = async (text: string) => {
|
const items = async (text: string) => {
|
||||||
const query = text.trim()
|
const query = text.trim()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
|||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useQueryOptions } from "@/context/global-sync"
|
import { useQueryOptions } from "@/context/server-sync"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
const statusLabels = {
|
const statusLabels = {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
|||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<List
|
<List
|
||||||
class="w-full px-0"
|
class="w-full px-0"
|
||||||
key={(x) => x?.id}
|
key={(p) => p.id}
|
||||||
items={providers.popular}
|
items={providers.popular}
|
||||||
activeIcon="plus-small"
|
activeIcon="plus-small"
|
||||||
sortBy={(a, b) => {
|
sortBy={(a, b) => {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
items={() => {
|
items={() => {
|
||||||
language.locale()
|
language.locale()
|
||||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
|
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
|
||||||
}}
|
}}
|
||||||
filterKeys={["id", "name"]}
|
filterKeys={["id", "name"]}
|
||||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal, createResource } from "solid-js"
|
import {
|
||||||
|
createEffect,
|
||||||
|
on,
|
||||||
|
Component,
|
||||||
|
Show,
|
||||||
|
onCleanup,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
createResource,
|
||||||
|
Switch,
|
||||||
|
Match,
|
||||||
|
} from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||||
@@ -55,13 +66,16 @@ import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
|||||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
import { useQueries } from "@tanstack/solid-query"
|
import { useQueries } from "@tanstack/solid-query"
|
||||||
import { useQueryOptions } from "@/context/global-sync"
|
import { useQueryOptions } from "@/context/server-sync"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
|
||||||
interface PromptInputProps {
|
interface PromptInputProps {
|
||||||
class?: string
|
class?: string
|
||||||
|
variant?: "dock" | "new-session"
|
||||||
ref?: (el: HTMLDivElement) => void
|
ref?: (el: HTMLDivElement) => void
|
||||||
newSessionWorktree?: string
|
newSessionWorktree?: string
|
||||||
|
onNewSessionWorktreeChange?: (worktree: string) => void
|
||||||
onNewSessionWorktreeReset?: () => void
|
onNewSessionWorktreeReset?: () => void
|
||||||
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||||
onEditLoaded?: () => void
|
onEditLoaded?: () => void
|
||||||
@@ -99,6 +113,9 @@ const EXAMPLES = [
|
|||||||
"prompt.example.25",
|
"prompt.example.25",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const MAIN_WORKTREE = "main"
|
||||||
|
const CREATE_WORKTREE = "create"
|
||||||
|
|
||||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const queryOptions = useQueryOptions()
|
const queryOptions = useQueryOptions()
|
||||||
@@ -1055,6 +1072,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
readClipboardImage: platform.readClipboardImage,
|
readClipboardImage: platform.readClipboardImage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fileAttachmentInput = () => (
|
||||||
|
<input
|
||||||
|
ref={(el) => (fileInputRef = el)}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||||
|
class="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const list = e.currentTarget.files
|
||||||
|
if (list) void addAttachments(Array.from(list))
|
||||||
|
e.currentTarget.value = ""
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||||
const accepting = createMemo(() => {
|
const accepting = createMemo(() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
@@ -1266,8 +1298,99 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
(p) => p,
|
(p) => p,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const designPlaceholder = () => {
|
||||||
|
if (store.mode === "shell") return placeholder()
|
||||||
|
return "Ask anything, / for commands, @ for context..."
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelControl = () => (
|
||||||
|
<Show when={!providersLoading()}>
|
||||||
|
<Show
|
||||||
|
when={providers.paid().length > 0}
|
||||||
|
fallback={
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybind("model.choose")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
data-action="prompt-model"
|
||||||
|
as="div"
|
||||||
|
variant="ghost"
|
||||||
|
size="normal"
|
||||||
|
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group"
|
||||||
|
style={control()}
|
||||||
|
onClick={() => {
|
||||||
|
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={local.model.current()?.provider?.id ?? ""}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybind("model.choose")}
|
||||||
|
>
|
||||||
|
<ModelSelectorPopover
|
||||||
|
model={local.model}
|
||||||
|
triggerAs={Button}
|
||||||
|
triggerProps={{
|
||||||
|
variant: "ghost",
|
||||||
|
size: "normal",
|
||||||
|
style: control(),
|
||||||
|
class:
|
||||||
|
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group",
|
||||||
|
"data-action": "prompt-model",
|
||||||
|
}}
|
||||||
|
onClose={restoreFocus}
|
||||||
|
>
|
||||||
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={local.model.current()?.provider?.id ?? ""}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
</ModelSelectorPopover>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
|
||||||
|
const newSession = () => props.variant === "new-session"
|
||||||
|
const worktrees = createMemo(() => [MAIN_WORKTREE, ...(sync.project?.sandboxes ?? []), CREATE_WORKTREE])
|
||||||
|
const currentWorktree = createMemo(() => {
|
||||||
|
if (worktrees().includes(props.newSessionWorktree ?? MAIN_WORKTREE))
|
||||||
|
return props.newSessionWorktree ?? MAIN_WORKTREE
|
||||||
|
return MAIN_WORKTREE
|
||||||
|
})
|
||||||
|
const worktreeLabel = (value: string) => {
|
||||||
|
if (value === MAIN_WORKTREE) return MAIN_WORKTREE
|
||||||
|
if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create")
|
||||||
|
return getFilename(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
<div class="relative size-full flex flex-col gap-0">
|
||||||
{(promptReady(), null)}
|
{(promptReady(), null)}
|
||||||
<PromptPopover
|
<PromptPopover
|
||||||
popover={store.popover}
|
popover={store.popover}
|
||||||
@@ -1284,6 +1407,158 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
commandKeybind={command.keybind}
|
commandKeybind={command.keybind}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
|
<Switch>
|
||||||
|
<Match when={USE_V2_INPUT}>
|
||||||
|
<DockShellForm
|
||||||
|
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
classList={{
|
||||||
|
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
|
||||||
|
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||||
|
[props.class ?? ""]: !!props.class,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PromptDragOverlay
|
||||||
|
type={store.draggingType}
|
||||||
|
label={language.t(
|
||||||
|
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PromptContextItems
|
||||||
|
items={contextItems()}
|
||||||
|
active={(item) => {
|
||||||
|
const active = comments.active()
|
||||||
|
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||||
|
}}
|
||||||
|
openComment={openComment}
|
||||||
|
remove={(item) => {
|
||||||
|
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||||
|
prompt.context.remove(item.key)
|
||||||
|
}}
|
||||||
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
|
/>
|
||||||
|
<PromptImageAttachments
|
||||||
|
attachments={imageAttachments()}
|
||||||
|
onOpen={(attachment) =>
|
||||||
|
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||||
|
}
|
||||||
|
onRemove={removeAttachment}
|
||||||
|
removeLabel={language.t("prompt.attachment.remove")}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="relative min-h-[52px]"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
const target = e.target
|
||||||
|
if (!(target instanceof HTMLElement)) return
|
||||||
|
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) return
|
||||||
|
editorRef?.focus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
||||||
|
<div
|
||||||
|
data-component="prompt-input"
|
||||||
|
ref={(el) => {
|
||||||
|
editorRef = el
|
||||||
|
props.ref?.(el)
|
||||||
|
}}
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
aria-label={designPlaceholder()}
|
||||||
|
contenteditable="true"
|
||||||
|
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||||
|
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||||
|
spellcheck={store.mode === "normal"}
|
||||||
|
inputMode="text"
|
||||||
|
// @ts-expect-error
|
||||||
|
autocomplete="off"
|
||||||
|
onInput={handleInput}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onCompositionStart={handleCompositionStart}
|
||||||
|
onCompositionEnd={handleCompositionEnd}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
classList={{
|
||||||
|
"select-text": true,
|
||||||
|
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
|
||||||
|
"[&_[data-type=file]]:text-syntax-property": true,
|
||||||
|
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||||
|
"font-mono!": store.mode === "shell",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
|
||||||
|
class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
|
||||||
|
classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
|
||||||
|
>
|
||||||
|
{designPlaceholder()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex h-11 items-center px-2">
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-0">
|
||||||
|
{fileAttachmentInput()}
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
title={language.t("prompt.action.attachFile")}
|
||||||
|
keybind={command.keybind("file.attach")}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
data-action="prompt-attach"
|
||||||
|
type="button"
|
||||||
|
icon="plus"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
|
||||||
|
style={buttons()}
|
||||||
|
onClick={pick}
|
||||||
|
disabled={store.mode !== "normal"}
|
||||||
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
|
aria-label={language.t("prompt.action.attachFile")}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
<Show when={newSession()}>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
|
||||||
|
<Icon name="sliders" size="small" />
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
size="normal"
|
||||||
|
options={worktrees()}
|
||||||
|
current={currentWorktree()}
|
||||||
|
label={worktreeLabel}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (value) props.onNewSessionWorktreeChange?.(value)
|
||||||
|
restoreFocus()
|
||||||
|
}}
|
||||||
|
class="max-w-[175px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
|
||||||
|
valueClass="truncate pl-5 text-[13px] font-[440] leading-4 text-v2-text-text-faint"
|
||||||
|
triggerStyle={control()}
|
||||||
|
triggerProps={{ "data-action": "prompt-workspace" }}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
{modelControl()}
|
||||||
|
</div>
|
||||||
|
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||||
|
<IconButton
|
||||||
|
data-action="prompt-submit"
|
||||||
|
type="submit"
|
||||||
|
disabled={!working() && blank()}
|
||||||
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
|
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||||
|
variant="primary"
|
||||||
|
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
"background-image":
|
||||||
|
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||||
|
}}
|
||||||
|
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</DockShellForm>
|
||||||
|
</Match>
|
||||||
|
<Match when>
|
||||||
<DockShellForm
|
<DockShellForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
classList={{
|
classList={{
|
||||||
@@ -1295,7 +1570,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<PromptDragOverlay
|
<PromptDragOverlay
|
||||||
type={store.draggingType}
|
type={store.draggingType}
|
||||||
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
|
label={language.t(
|
||||||
|
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<PromptContextItems
|
<PromptContextItems
|
||||||
items={contextItems()}
|
items={contextItems()}
|
||||||
@@ -1610,6 +1887,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</DockTray>
|
</DockTray>
|
||||||
</Show>
|
</Show>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ beforeAll(async () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/global-sync", () => ({
|
mock.module("@/context/server-sync", () => ({
|
||||||
useGlobalSync: () => ({
|
useServerSync: () => ({
|
||||||
child: (directory: string) => {
|
child: (directory: string) => {
|
||||||
syncedDirectories.push(directory)
|
syncedDirectories.push(directory)
|
||||||
storedSessions[directory] ??= []
|
storedSessions[directory] ??= []
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
|
|||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { batch, type Accessor } from "solid-js"
|
import { batch, type Accessor } from "solid-js"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } 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 } from "@/context/local"
|
||||||
@@ -38,7 +38,7 @@ export type FollowupDraft = {
|
|||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
client: ReturnType<typeof useSDK>["client"]
|
client: ReturnType<typeof useSDK>["client"]
|
||||||
globalSync: ReturnType<typeof useGlobalSync>
|
serverSync: ReturnType<typeof useServerSync>
|
||||||
sync: ReturnType<typeof useSync>
|
sync: ReturnType<typeof useSync>
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
messageID?: string
|
messageID?: string
|
||||||
@@ -53,7 +53,7 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
|
|||||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
const text = draftText(input.draft.prompt)
|
const text = draftText(input.draft.prompt)
|
||||||
const images = draftImages(input.draft.prompt)
|
const images = draftImages(input.draft.prompt)
|
||||||
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
|
const [, setStore] = input.serverSync.child(input.draft.sessionDirectory)
|
||||||
|
|
||||||
const setBusy = () => {
|
const setBusy = () => {
|
||||||
if (!input.optimisticBusy) return
|
if (!input.optimisticBusy) return
|
||||||
@@ -205,7 +205,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
@@ -226,8 +226,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
|
|
||||||
globalSync.todo.set(sessionID, [])
|
serverSync.todo.set(sessionID, [])
|
||||||
const [, setStore] = globalSync.child(sdk.directory)
|
const [, setStore] = serverSync.child(sdk.directory)
|
||||||
setStore("todo", sessionID, [])
|
setStore("todo", sessionID, [])
|
||||||
|
|
||||||
input.onAbort?.()
|
input.onAbort?.()
|
||||||
@@ -273,7 +273,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seed = (dir: string, info: Session) => {
|
const seed = (dir: string, info: Session) => {
|
||||||
const [, setStore] = globalSync.child(dir)
|
const [, setStore] = serverSync.child(dir)
|
||||||
setStore("session", (list: Session[]) => {
|
setStore("session", (list: Session[]) => {
|
||||||
const result = Binary.search(list, info.id, (item) => item.id)
|
const result = Binary.search(list, info.id, (item) => item.id)
|
||||||
const next = [...list]
|
const next = [...list]
|
||||||
@@ -354,7 +354,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
directory: sessionDirectory,
|
directory: sessionDirectory,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
globalSync.child(sessionDirectory)
|
serverSync.child(sessionDirectory)
|
||||||
}
|
}
|
||||||
|
|
||||||
input.onNewSessionWorktreeReset?.()
|
input.onNewSessionWorktreeReset?.()
|
||||||
@@ -557,7 +557,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
client,
|
client,
|
||||||
sync,
|
sync,
|
||||||
globalSync,
|
serverSync,
|
||||||
draft,
|
draft,
|
||||||
messageID,
|
messageID,
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
||||||
const context = createMemo(() => metrics().context)
|
const context = createMemo(() => metrics().context)
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(metrics().totalCost)
|
return usd().format(metrics().totalCost)
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export { SessionContextTab } from "./session-context-tab"
|
|||||||
export { SortableTab, FileVisual } from "./session-sortable-tab"
|
export { SortableTab, FileVisual } from "./session-sortable-tab"
|
||||||
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"
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export function SessionContextTab() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
||||||
const ctx = createMemo(() => metrics().context)
|
const ctx = createMemo(() => metrics().context)
|
||||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import { createMemo } from "solid-js"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
|
import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
|
||||||
|
|
||||||
|
const MAIN_WORKTREE = "main"
|
||||||
|
|
||||||
|
export function NewSessionDesignView(props: { worktree: string; children: JSX.Element }) {
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const layout = useLayout()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const server = useServer()
|
||||||
|
const sync = useSync()
|
||||||
|
|
||||||
|
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
|
||||||
|
const projects = createMemo(() => {
|
||||||
|
const roots = serverSync.data.project.map((project) => project.worktree)
|
||||||
|
if (roots.includes(projectRoot())) return roots
|
||||||
|
return [projectRoot(), ...roots]
|
||||||
|
})
|
||||||
|
const branch = createMemo(() => sync.data.vcs?.branch ?? MAIN_WORKTREE)
|
||||||
|
|
||||||
|
const openProject = (directory: string | undefined) => {
|
||||||
|
if (!directory) return
|
||||||
|
if (directory === projectRoot()) return
|
||||||
|
layout.projects.open(directory)
|
||||||
|
server.projects.touch(directory)
|
||||||
|
navigate(`/${base64Encode(directory)}/session`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
|
||||||
|
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||||
|
<div class="w-full max-w-[720px]">
|
||||||
|
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
|
||||||
|
<div class="mt-8">
|
||||||
|
{props.children}
|
||||||
|
<div class="mt-3 flex h-7 items-center gap-0 pl-2">
|
||||||
|
<Select
|
||||||
|
size="normal"
|
||||||
|
variant="ghost"
|
||||||
|
options={projects()}
|
||||||
|
current={projectRoot()}
|
||||||
|
label={getFilename}
|
||||||
|
onSelect={openProject}
|
||||||
|
class="max-w-[203px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
|
||||||
|
valueClass="truncate text-[length:13px] font-[440] text-v2-text-text-faint"
|
||||||
|
/>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
|
||||||
|
<Icon name="branch" size="small" />
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
size="normal"
|
||||||
|
variant="ghost"
|
||||||
|
options={[branch()]}
|
||||||
|
current={branch()}
|
||||||
|
class="max-w-[240px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
|
||||||
|
valueClass="truncate pl-5 font-[440] text-v2-text-text-faint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@ import { useParams } from "@solidjs/router"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
monoFontFamily,
|
monoFontFamily,
|
||||||
@@ -175,8 +175,8 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const globalSdk = useGlobalSDK()
|
const globalSdk = useServerSDK()
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
() =>
|
||||||
@@ -193,16 +193,22 @@ export const SettingsGeneral: Component = () => {
|
|||||||
{ initialValue: null as DisplayBackend | null },
|
{ initialValue: null as DisplayBackend | null },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||||
|
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
||||||
|
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||||
|
{ initialValue: false },
|
||||||
|
)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void theme.loadThemes()
|
void theme.loadThemes()
|
||||||
})
|
})
|
||||||
|
|
||||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||||
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
|
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
|
||||||
|
|
||||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||||
const list = shells.latest
|
const list = shells.latest
|
||||||
const current = globalSync.data.config.shell
|
const current = serverSync.data.config.shell
|
||||||
|
|
||||||
const nameCounts = new Map<string, number>()
|
const nameCounts = new Map<string, number>()
|
||||||
for (const s of list) {
|
for (const s of list) {
|
||||||
@@ -239,6 +245,13 @@ export const SettingsGeneral: Component = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onPinchZoomChange = (checked: boolean) => {
|
||||||
|
setPinchZoom(checked)
|
||||||
|
const update = platform.setPinchZoomEnabled?.(checked)
|
||||||
|
if (!update) return
|
||||||
|
void update.catch(() => setPinchZoom(!checked))
|
||||||
|
}
|
||||||
|
|
||||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||||
{ value: "system", label: language.t("theme.scheme.system") },
|
{ value: "system", label: language.t("theme.scheme.system") },
|
||||||
{ value: "light", label: language.t("theme.scheme.light") },
|
{ value: "light", label: language.t("theme.scheme.light") },
|
||||||
@@ -330,7 +343,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
if (option.value === currentShell()) return
|
if (option.value === currentShell()) return
|
||||||
globalSync.updateConfig({ shell: option.value })
|
serverSync.updateConfig({ shell: option.value })
|
||||||
}}
|
}}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -729,6 +742,45 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const DisplaySection = () => (
|
||||||
|
<Show when={desktop()}>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
||||||
|
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.pinchZoom.title")}
|
||||||
|
description={language.t("settings.general.row.pinchZoom.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-pinch-zoom">
|
||||||
|
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<Show when={linux()}>
|
||||||
|
<SettingsRow
|
||||||
|
title={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("settings.general.row.wayland.title")}</span>
|
||||||
|
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
||||||
|
<span class="text-text-weak">
|
||||||
|
<Icon name="help" size="small" />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={language.t("settings.general.row.wayland.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-wayland">
|
||||||
|
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</Show>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
|
||||||
console.log(import.meta.env)
|
console.log(import.meta.env)
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
@@ -749,31 +801,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
<UpdatesSection />
|
<UpdatesSection />
|
||||||
|
|
||||||
<Show when={linux()}>
|
<DisplaySection />
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
|
||||||
|
|
||||||
<SettingsList>
|
|
||||||
<SettingsRow
|
|
||||||
title={
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("settings.general.row.wayland.title")}</span>
|
|
||||||
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
|
||||||
<span class="text-text-weak">
|
|
||||||
<Icon name="help" size="small" />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
description={language.t("settings.general.row.wayland.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-wayland">
|
|
||||||
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
|
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
|
||||||
<AdvancedSection />
|
<AdvancedSection />
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
@@ -30,8 +30,8 @@ const PROVIDER_NOTES = [
|
|||||||
export const SettingsProviders: Component = () => {
|
export const SettingsProviders: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const globalSDK = useGlobalSDK()
|
const serverSDK = useServerSDK()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
|
|
||||||
const connected = createMemo(() => {
|
const connected = createMemo(() => {
|
||||||
@@ -74,7 +74,7 @@ export const SettingsProviders: Component = () => {
|
|||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
const isConfigCustom = (providerID: string) => {
|
const isConfigCustom = (providerID: string) => {
|
||||||
const provider = globalSync.data.config.provider?.[providerID]
|
const provider = serverSync.data.config.provider?.[providerID]
|
||||||
if (!provider) return false
|
if (!provider) return false
|
||||||
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
||||||
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
||||||
@@ -82,11 +82,11 @@ export const SettingsProviders: Component = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
const before = globalSync.data.config.disabled_providers ?? []
|
const before = serverSync.data.config.disabled_providers ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
globalSync.set("config", "disabled_providers", next)
|
serverSync.set("config", "disabled_providers", next)
|
||||||
|
|
||||||
await globalSync
|
await serverSync
|
||||||
.updateConfig({ disabled_providers: next })
|
.updateConfig({ disabled_providers: next })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -97,7 +97,7 @@ export const SettingsProviders: Component = () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
globalSync.set("config", "disabled_providers", before)
|
serverSync.set("config", "disabled_providers", before)
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
})
|
})
|
||||||
@@ -105,14 +105,14 @@ export const SettingsProviders: Component = () => {
|
|||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
if (isConfigCustom(providerID)) {
|
if (isConfigCustom(providerID)) {
|
||||||
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
|
await serverSDK.client.auth.remove({ providerID }).catch(() => undefined)
|
||||||
await disableProvider(providerID, name)
|
await disableProvider(providerID, name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await globalSDK.client.auth
|
await serverSDK.client.auth
|
||||||
.remove({ providerID })
|
.remove({ providerID })
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await globalSDK.client.global.dispose()
|
await serverSDK.client.global.dispose()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
icon: "circle-check",
|
icon: "circle-check",
|
||||||
|
|||||||
@@ -7,16 +7,17 @@ import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
|||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { type ServerHealth } from "@/utils/server-health"
|
||||||
import { useQueryOptions } from "@/context/global-sync"
|
import { useQueryOptions } from "@/context/server-sync"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { useServers } from "@/context/servers"
|
||||||
|
|
||||||
const pollMs = 10_000
|
const pollMs = 10_000
|
||||||
|
|
||||||
@@ -54,40 +55,6 @@ const listServersByHealth = (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
|
||||||
const checkServerHealth = useCheckServerHealth()
|
|
||||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!enabled()) {
|
|
||||||
setStatus(reconcile({}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const list = servers()
|
|
||||||
let dead = false
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
const results: Record<string, ServerHealth> = {}
|
|
||||||
await Promise.all(
|
|
||||||
list.map(async (conn) => {
|
|
||||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (dead) return
|
|
||||||
setStatus(reconcile(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
void refresh()
|
|
||||||
const id = setInterval(() => void refresh(), pollMs)
|
|
||||||
onCleanup(() => {
|
|
||||||
dead = true
|
|
||||||
clearInterval(id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
const useDefaultServerKey = (
|
const useDefaultServerKey = (
|
||||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||||
) => {
|
) => {
|
||||||
@@ -168,6 +135,7 @@ const useMcpToggleMutation = () => {
|
|||||||
|
|
||||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const servers = useServers()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -192,15 +160,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
dialogDead = true
|
dialogDead = true
|
||||||
dialogRun += 1
|
dialogRun += 1
|
||||||
})
|
})
|
||||||
const servers = createMemo(() => {
|
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
|
||||||
const current = server.current
|
|
||||||
const list = server.list
|
|
||||||
if (!current) return list
|
|
||||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
|
||||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
|
||||||
})
|
|
||||||
const health = useServerHealth(servers, props.shown)
|
|
||||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
|
||||||
const toggleMcp = useMcpToggleMutation()
|
const toggleMcp = useMcpToggleMutation()
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
@@ -226,7 +186,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
>
|
>
|
||||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||||
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
{servers.list().length > 0 ? `${servers.list().length} ` : ""}
|
||||||
{language.t("status.popover.tab.servers")}
|
{language.t("status.popover.tab.servers")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||||
@@ -249,7 +209,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
<For each={sortedServers()}>
|
<For each={sortedServers()}>
|
||||||
{(s) => {
|
{(s) => {
|
||||||
const key = ServerConnection.key(s)
|
const key = ServerConnection.key(s)
|
||||||
const blocked = () => health[key]?.healthy === false
|
const blocked = () => servers.health[key]?.healthy === false
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -265,11 +225,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
queueMicrotask(() => server.setActive(key))
|
queueMicrotask(() => server.setActive(key))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ServerHealthIndicator health={health[key]} />
|
<ServerHealthIndicator health={servers.health[key]} />
|
||||||
<ServerRow
|
<ServerRow
|
||||||
conn={s}
|
conn={s}
|
||||||
dimmed={blocked()}
|
dimmed={blocked()}
|
||||||
status={health[key]}
|
status={servers.health[key]}
|
||||||
class="flex items-center gap-2 w-full min-w-0"
|
class="flex items-center gap-2 w-full min-w-0"
|
||||||
nameClass="text-14-regular text-text-base truncate"
|
nameClass="text-14-regular text-text-base truncate"
|
||||||
versionClass="text-12-regular text-text-weak truncate"
|
versionClass="text-12-regular text-text-weak truncate"
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useServers } from "@/context/servers"
|
||||||
|
|
||||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||||
|
|
||||||
export function StatusPopover() {
|
export function StatusPopover() {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
const servers = useServers()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
|
const ready = createMemo(() => servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
|
||||||
const mcpIssue = createMemo(() => {
|
const mcpIssue = createMemo(() => {
|
||||||
const mcp = Object.values(sync.data.mcp ?? {})
|
const mcp = Object.values(sync.data.mcp ?? {})
|
||||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||||
@@ -21,7 +23,8 @@ export function StatusPopover() {
|
|||||||
if (failed) return "critical" as const
|
if (failed) return "critical" as const
|
||||||
if (warn) return "warning" as const
|
if (warn) return "warning" as const
|
||||||
})
|
})
|
||||||
const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
|
const serverHealthy = () => servers.health[server.key]?.healthy === true
|
||||||
|
const healthy = createMemo(() => servers.health[server.key]?.healthy === true && !mcpIssue())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -43,10 +46,9 @@ export function StatusPopover() {
|
|||||||
classList={{
|
classList={{
|
||||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||||
"bg-icon-success-base": ready() && healthy(),
|
"bg-icon-success-base": ready() && healthy(),
|
||||||
"bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
|
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
|
||||||
"bg-icon-critical-base":
|
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
|
||||||
server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
|
"bg-border-weak-base": serverHealthy() || !ready(),
|
||||||
"bg-border-weak-base": server.healthy() === undefined || !ready(),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,17 +7,24 @@ import { Button } from "@opencode-ai/ui/button"
|
|||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||||
|
|
||||||
import { useLayout } from "@/context/layout"
|
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { WindowsAppMenu } from "./windows-app-menu"
|
import { WindowsAppMenu } from "./windows-app-menu"
|
||||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { decodeDirectory } from "@/pages/directory-layout"
|
import { decodeDirectory } from "@/pages/directory-layout"
|
||||||
import { iife } from "@opencode-ai/core/util/iife"
|
import { iife } from "@opencode-ai/core/util/iife"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
|
||||||
|
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
import { StatusPopover } from "./status-popover"
|
||||||
|
import { SDKProvider } from "@/context/sdk"
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
startDragging?: () => Promise<void>
|
startDragging?: () => Promise<void>
|
||||||
@@ -40,13 +47,21 @@ type TauriApi = {
|
|||||||
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
|
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
|
||||||
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
|
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
|
||||||
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
|
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
|
||||||
const titlebarHeight = 40
|
const legacyTitlebarHeight = 40
|
||||||
|
const v2TitlebarHeight = 44
|
||||||
const minTitlebarZoom = 0.25
|
const minTitlebarZoom = 0.25
|
||||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||||
|
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
|
|
||||||
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
|
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
|
||||||
|
|
||||||
export function Titlebar() {
|
export type TitlebarUpdate = {
|
||||||
|
version: () => string | undefined
|
||||||
|
installing: () => boolean
|
||||||
|
install: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
@@ -59,14 +74,16 @@ export function Titlebar() {
|
|||||||
|
|
||||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||||
|
const electronWindows = createMemo(() => windows() && !tauriApi())
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
const web = createMemo(() => platform.platform === "web")
|
const web = createMemo(() => platform.platform === "web")
|
||||||
const zoom = () => platform.webviewZoom?.() ?? 1
|
const zoom = () => platform.webviewZoom?.() ?? 1
|
||||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||||
const minHeight = () => {
|
const minHeight = () => {
|
||||||
if (mac()) return `${titlebarHeight / zoom()}px`
|
const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
|
||||||
if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px`
|
if (mac()) return `${height / zoom()}px`
|
||||||
|
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
|
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
|
||||||
@@ -183,19 +200,41 @@ export function Titlebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
class="h-10 shrink-0 bg-background-base relative overflow-hidden flex flex-row"
|
classList={{
|
||||||
style={{ "min-height": minHeight(), "padding-left": mac() ? `${84 / zoom()}px` : 0 }}
|
"shrink-0 relative overflow-hidden flex flex-row": true,
|
||||||
|
"h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
|
||||||
|
"h-10 bg-background-base": !USE_V2_TITLEBAR,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
"min-height": minHeight(),
|
||||||
|
"padding-left": mac() ? `${84 / zoom()}px` : 0,
|
||||||
|
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||||
|
"max-width": electronWindows()
|
||||||
|
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
|
||||||
|
: undefined,
|
||||||
|
"align-self": electronWindows() ? "flex-start" : undefined,
|
||||||
|
}}
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
onMouseDown={drag}
|
onMouseDown={drag}
|
||||||
onDblClick={maximize}
|
onDblClick={maximize}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
<Match when={USE_V2_TITLEBAR}>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const homeMatch = useMatch(() => "/")
|
||||||
|
|
||||||
type Tab = { dir: string; sessionId: string; params: any; href: string }
|
const newSessionHref = () => {
|
||||||
|
if (params.dir) return `/${params.dir}/session`
|
||||||
|
|
||||||
|
const project = layout.projects.list()[0]
|
||||||
|
if (!project) return "/"
|
||||||
|
|
||||||
|
return `/${base64Encode(project.worktree)}/session`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = { dir: string; sessionId: string; href: string }
|
||||||
|
|
||||||
const [tabsStore, tabsStoreActions] = iife(() => {
|
const [tabsStore, tabsStoreActions] = iife(() => {
|
||||||
const [store, setStore] = createStore<Tab[]>(
|
const [store, setStore] = createStore<Tab[]>(
|
||||||
@@ -205,7 +244,6 @@ export function Titlebar() {
|
|||||||
{
|
{
|
||||||
dir: decodeDirectory(params.dir) ?? "",
|
dir: decodeDirectory(params.dir) ?? "",
|
||||||
sessionId: params.id,
|
sessionId: params.id,
|
||||||
params: { id: params.id, dir: params.dir },
|
|
||||||
href: makeSessionHref(params.dir, params.id),
|
href: makeSessionHref(params.dir, params.id),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -248,16 +286,112 @@ export function Titlebar() {
|
|||||||
tabsStoreActions.addTab({
|
tabsStoreActions.addTab({
|
||||||
dir: decodeDirectory(params.dir) ?? "",
|
dir: decodeDirectory(params.dir) ?? "",
|
||||||
sessionId: params.id,
|
sessionId: params.id,
|
||||||
params: { id: params.id, dir: params.dir },
|
|
||||||
href: makeSessionHref(params.dir, params.id),
|
href: makeSessionHref(params.dir, params.id),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const projects = createMemo(() => layout.projects.list())
|
||||||
|
const projectByID = createMemo(
|
||||||
|
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentSessionTab = () => {
|
||||||
|
if (!params.dir || !params.id) return
|
||||||
|
const href = makeSessionHref(params.dir, params.id)
|
||||||
|
return tabsStore.find((tab) => tab.href === href)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeCurrentSessionTab = () => {
|
||||||
|
const tab = currentSessionTab()
|
||||||
|
if (!tab) return false
|
||||||
|
tabsStoreActions.removeTab(tab.href)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeNewSessionTab = () => {
|
||||||
|
if (!(params.dir && !params.id)) return false
|
||||||
|
const last = tabsStore[tabsStore.length - 1]
|
||||||
|
if (last) navigate(last.href)
|
||||||
|
else navigate("/")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
makeEventListener(
|
||||||
|
document,
|
||||||
|
"keydown",
|
||||||
|
(event) => {
|
||||||
|
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
|
||||||
|
if (event.key.toLowerCase() !== "w") return
|
||||||
|
if (!(closeCurrentSessionTab() || closeNewSessionTab())) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
command.register(() => {
|
||||||
|
const commands = [
|
||||||
|
{
|
||||||
|
id: `tab.prev`,
|
||||||
|
category: "tab",
|
||||||
|
title: "",
|
||||||
|
keybind: `mod+option+ArrowLeft`,
|
||||||
|
hidden: true,
|
||||||
|
onSelect: () => {
|
||||||
|
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
index -= 1
|
||||||
|
if (index === -1) index = tabsStore.length - 1
|
||||||
|
|
||||||
|
const next = tabsStore[index]
|
||||||
|
if (next) navigate(next.href)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `tab.next`,
|
||||||
|
category: "tab",
|
||||||
|
title: "",
|
||||||
|
keybind: `mod+option+ArrowRight`,
|
||||||
|
hidden: true,
|
||||||
|
onSelect: () => {
|
||||||
|
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
index += 1
|
||||||
|
if (index === tabsStore.length) index = 0
|
||||||
|
|
||||||
|
const next = tabsStore[index]
|
||||||
|
if (next) navigate(next.href)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...Array.from({ length: 9 }, (_, i) => {
|
||||||
|
const index = i
|
||||||
|
const number = index + 1
|
||||||
|
return {
|
||||||
|
id: `tab.${number}`,
|
||||||
|
category: "tab",
|
||||||
|
title: "",
|
||||||
|
keybind: `mod+${number}`,
|
||||||
|
disabled: layout.projects.list().length <= index,
|
||||||
|
hidden: true,
|
||||||
|
onSelect: () => {
|
||||||
|
const tab = tabsStore[index]
|
||||||
|
if (tab) navigate(tab.href)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
return commands
|
||||||
|
})
|
||||||
|
|
||||||
const tabsEnriched = iife(() => {
|
const tabsEnriched = iife(() => {
|
||||||
const base = mapArray(
|
const base = mapArray(
|
||||||
() => tabsStore,
|
() => tabsStore,
|
||||||
(tab) => {
|
(tab) => {
|
||||||
const sync = globalSync.createDirSyncContext(tab.dir)
|
const sync = serverSync.createDirSyncContext(tab.dir)
|
||||||
const session = sync.session.get(tab.sessionId)
|
const session = sync.session.get(tab.sessionId)
|
||||||
return session ? { ...tab, info: session } : null
|
return session ? { ...tab, info: session } : null
|
||||||
},
|
},
|
||||||
@@ -267,36 +401,40 @@ export function Titlebar() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3">
|
<div
|
||||||
|
class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3 py-2"
|
||||||
|
classList={{
|
||||||
|
"pl-2": mac(),
|
||||||
|
"pl-4": !mac(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ChannelIndicator />
|
<ChannelIndicator />
|
||||||
<Show when={windows() || linux()}>
|
<Show when={windows() || linux()}>
|
||||||
<WindowsAppMenu command={command} platform={platform} />
|
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
||||||
</Show>
|
</Show>
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
as="a"
|
|
||||||
href="/"
|
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
size="large"
|
size="large"
|
||||||
class="!w-8"
|
as="a"
|
||||||
state={!!useMatch(() => "/")() ? "pressed" : undefined}
|
href="/"
|
||||||
>
|
class="!w-9"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
icon={<IconV2 name="grid-plus" />}
|
||||||
<path
|
state={!!homeMatch() ? "pressed" : undefined}
|
||||||
d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-miterlimit="10"
|
|
||||||
stroke-linecap="square"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
|
||||||
</IconButtonV2>
|
<div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden">
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden">
|
||||||
<For each={tabsEnriched()}>
|
<For each={tabsEnriched()}>
|
||||||
{(tab, i) => (
|
{(tab, i) => (
|
||||||
<>
|
<>
|
||||||
{i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
|
{i() !== 0 && (
|
||||||
|
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||||
|
)}
|
||||||
<TabNavItem
|
<TabNavItem
|
||||||
href={tab.href}
|
href={tab.href}
|
||||||
title={tab.info.title}
|
title={tab.info.title}
|
||||||
|
project={projectForSession(tab.info, projects(), projectByID())}
|
||||||
|
directory={tab.dir}
|
||||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||||
hideClose={tabsEnriched().length < 2}
|
hideClose={tabsEnriched().length < 2}
|
||||||
/>
|
/>
|
||||||
@@ -304,41 +442,42 @@ export function Titlebar() {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
<button>
|
<Show
|
||||||
<div class="p-1.5">
|
when={creating() && params.dir}
|
||||||
<svg
|
fallback={
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<IconButtonV2
|
||||||
width="16"
|
type="button"
|
||||||
height="16"
|
variant="ghost-muted"
|
||||||
viewBox="0 0 16 16"
|
size="large"
|
||||||
fill="none"
|
class="shrink-0"
|
||||||
class="size-4"
|
icon={<IconV2 name="plus" />}
|
||||||
>
|
as="a"
|
||||||
<path
|
href={newSessionHref()}
|
||||||
d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
|
aria-label={language.t("command.session.new")}
|
||||||
stroke="#808080"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
}
|
||||||
|
>
|
||||||
|
<NewSessionTabItem
|
||||||
|
href={`/${params.dir}/session`}
|
||||||
|
title={language.t("command.session.new")}
|
||||||
|
onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<div class="min-w-0 flex-1" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<Show when={currentSessionTab()?.dir} keyed>
|
||||||
|
{(dir) => (
|
||||||
<div class="flex-1" />
|
<SDKProvider directory={dir}>
|
||||||
{/*<button class="px-2.5 py-1.5 bg-[rgba(0,0,0,0.08)] rounded-[6px]">
|
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||||
<svg
|
<StatusPopover />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</Tooltip>
|
||||||
width="16"
|
</SDKProvider>
|
||||||
height="16"
|
)}
|
||||||
viewBox="0 0 16 16"
|
</Show>
|
||||||
fill="none"
|
<TitlebarUpdatePill update={props.update} />
|
||||||
class="size-4"
|
<Show when={windows() && !electronWindows()}>
|
||||||
>
|
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||||
<path
|
</Show>
|
||||||
d="M10.4443 2.44436V13.5555M1.55546 13.5554H14.4443V2.44434H1.55542L1.55546 13.5554Z"
|
|
||||||
stroke="#3A3A3A"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>*/}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -358,7 +497,7 @@ export function Titlebar() {
|
|||||||
<WindowsAppMenu command={command} platform={platform} />
|
<WindowsAppMenu command={command} platform={platform} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={mac()}>
|
<Show when={mac()}>
|
||||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
|
{/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/}
|
||||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="menu"
|
icon="menu"
|
||||||
@@ -502,19 +641,51 @@ export function Titlebar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) {
|
function TitlebarUpdatePill(props: { update?: TitlebarUpdate }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const version = () => props.update?.version()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={version() !== undefined}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-accent)] px-2.5 text-[11px] font-[530] leading-[1.1] tracking-[-0.04px] text-[var(--v2-text-text-contrast)] disabled:opacity-60"
|
||||||
|
onClick={() => props.update?.install()}
|
||||||
|
disabled={props.update?.installing()}
|
||||||
|
aria-label={language.t("toast.update.action.installRestart")}
|
||||||
|
title={version() ? `Update ${version()}` : undefined}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DesktopTitlebarIconButton(props: Parameters<typeof IconButtonV2>[0]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabNavItem(props: {
|
||||||
|
href: string
|
||||||
|
title: string
|
||||||
|
project?: LocalProject
|
||||||
|
directory: string
|
||||||
|
hideClose?: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
const match = useMatch(() => props.href)
|
const match = useMatch(() => props.href)
|
||||||
const isActive = () => !!match()
|
const isActive = () => !!match()
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="group flex flex-row items-center max-w-60 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] bg-[var(--tab-bg)] h-7 rounded-[6px] relative overflow-hidden"
|
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||||
data-active={isActive()}
|
data-active={isActive()}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={props.href}
|
href={props.href}
|
||||||
class="w-full h-full pl-1.5 flex-1 max-w-full flex flex-row items-center overflow-hidden font-medium"
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
|
||||||
>
|
>
|
||||||
{props.title}
|
<ProjectTabAvatar project={props.project} directory={props.directory} />
|
||||||
|
<span class="text-clip">{props.title}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||||
@@ -530,23 +701,60 @@ function TabNavItem(props: { href: string; title: string; hideClose?: boolean; o
|
|||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
|
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
icon={
|
icon={<IconV2 name="xmark-small" />}
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
class="size-4"
|
|
||||||
>
|
|
||||||
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProjectTabAvatar(props: { project?: LocalProject; directory: string }) {
|
||||||
|
return (
|
||||||
|
<AvatarV2
|
||||||
|
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||||
|
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||||
|
kind="org"
|
||||||
|
size="small"
|
||||||
|
{...getAvatarColors(props.project?.icon?.color)}
|
||||||
|
class="size-4 rounded"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<div class="group relative flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]">
|
||||||
|
<a
|
||||||
|
href={props.href}
|
||||||
|
aria-current="page"
|
||||||
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none text-[var(--v2-text-text-base)]"
|
||||||
|
>
|
||||||
|
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||||
|
<IconV2 name="edit" />
|
||||||
|
</span>
|
||||||
|
<span class="truncate">{props.title}</span>
|
||||||
|
</a>
|
||||||
|
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||||
|
<IconButtonV2
|
||||||
|
size="small"
|
||||||
|
variant="ghost-muted"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
props.onClose()
|
||||||
|
}}
|
||||||
|
icon={<IconV2 name="xmark-small" />}
|
||||||
|
aria-label="Close tab"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ChannelIndicator() {
|
function ChannelIndicator() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Show, type JSX } from "solid-js"
|
|||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||||
|
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
||||||
@@ -10,6 +12,7 @@ import { usePlatform } from "@/context/platform"
|
|||||||
export function WindowsAppMenu(props: {
|
export function WindowsAppMenu(props: {
|
||||||
command: ReturnType<typeof useCommand>
|
command: ReturnType<typeof useCommand>
|
||||||
platform: ReturnType<typeof usePlatform>
|
platform: ReturnType<typeof usePlatform>
|
||||||
|
variant?: "legacy" | "v2"
|
||||||
}) {
|
}) {
|
||||||
let lastFocused: HTMLElement | undefined
|
let lastFocused: HTMLElement | undefined
|
||||||
|
|
||||||
@@ -45,6 +48,22 @@ export function WindowsAppMenu(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
||||||
|
{props.variant === "v2" ? (
|
||||||
|
<div
|
||||||
|
data-component="desktop-icon-button"
|
||||||
|
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
|
||||||
|
>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={IconButtonV2}
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="large"
|
||||||
|
icon={<IconV2 name="menu" />}
|
||||||
|
aria-label="OpenCode menu"
|
||||||
|
onPointerDown={rememberFocus}
|
||||||
|
onKeyDown={rememberFocus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
icon="menu"
|
icon="menu"
|
||||||
@@ -54,6 +73,7 @@ export function WindowsAppMenu(props: {
|
|||||||
onPointerDown={rememberFocus}
|
onPointerDown={rememberFocus}
|
||||||
onKeyDown={rememberFocus}
|
onKeyDown={rememberFocus}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content class="desktop-app-menu">
|
<DropdownMenu.Content class="desktop-app-menu">
|
||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
getSessionPrefetchPromise,
|
getSessionPrefetchPromise,
|
||||||
setSessionPrefetch,
|
setSessionPrefetch,
|
||||||
} from "./global-sync/session-prefetch"
|
} from "./global-sync/session-prefetch"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { createServerSyncContext, useServerSync } from "./server-sync"
|
||||||
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||||
|
import { useServerSDK } from "./server-sdk"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
|
||||||
@@ -164,16 +165,17 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createDirSyncContext = (client: OpencodeClient, directory: string) => {
|
export const createDirSyncContext = (directory: string, serverSync: ReturnType<typeof createServerSyncContext>) => {
|
||||||
const globalSync = useGlobalSync()
|
const serverSDK = useServerSDK()
|
||||||
|
const client = serverSDK.createClient({ directory, throwOnError: true })
|
||||||
|
|
||||||
type Child = ReturnType<(typeof globalSync)["child"]>
|
type Child = ReturnType<(typeof serverSync)["child"]>
|
||||||
type Setter = Child[1]
|
type Setter = Child[1]
|
||||||
|
|
||||||
const current = createMemo(() => globalSync.child(directory))
|
const current = createMemo(() => serverSync.child(directory))
|
||||||
const target = (directory?: string) => {
|
const target = (directory?: string) => {
|
||||||
if (!directory || directory === directory) return current()
|
if (!directory || directory === directory) return current()
|
||||||
return globalSync.child(directory)
|
return serverSync.child(directory)
|
||||||
}
|
}
|
||||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||||
const initialMessagePageSize = 80
|
const initialMessagePageSize = 80
|
||||||
@@ -239,7 +241,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
|||||||
if (!first) break
|
if (!first) break
|
||||||
const stale = [...(seen.get(first) ?? [])]
|
const stale = [...(seen.get(first) ?? [])]
|
||||||
seen.delete(first)
|
seen.delete(first)
|
||||||
const [, setStore] = globalSync.child(first, { bootstrap: false })
|
const [, setStore] = serverSync.child(first, { bootstrap: false })
|
||||||
evict(first, setStore, stale)
|
evict(first, setStore, stale)
|
||||||
}
|
}
|
||||||
return created
|
return created
|
||||||
@@ -267,7 +269,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
|||||||
if (sessionIDs.length === 0) return
|
if (sessionIDs.length === 0) return
|
||||||
clearSessionPrefetch(directory, sessionIDs)
|
clearSessionPrefetch(directory, sessionIDs)
|
||||||
for (const sessionID of sessionIDs) {
|
for (const sessionID of sessionIDs) {
|
||||||
globalSync.todo.set(sessionID, undefined)
|
serverSync.todo.set(sessionID, undefined)
|
||||||
}
|
}
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -324,7 +326,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
|||||||
for (const messageID of next.confirmed) {
|
for (const messageID of next.confirmed) {
|
||||||
clearOptimistic(input.directory, input.sessionID, messageID)
|
clearOptimistic(input.directory, input.sessionID, messageID)
|
||||||
}
|
}
|
||||||
const [store] = globalSync.child(input.directory, { bootstrap: false })
|
const [store] = serverSync.child(input.directory, { bootstrap: false })
|
||||||
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
|
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
|
||||||
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
|
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
|
||||||
batch(() => {
|
batch(() => {
|
||||||
@@ -373,8 +375,8 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
|||||||
},
|
},
|
||||||
get project() {
|
get project() {
|
||||||
const store = current()[0]
|
const store = current()[0]
|
||||||
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
|
const match = Binary.search(serverSync.data.project, store.project, (p) => p.id)
|
||||||
if (match.found) return globalSync.data.project[match.index]
|
if (match.found) return serverSync.data.project[match.index]
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
@@ -418,7 +420,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
async sync(sessionID: string, opts?: { force?: boolean }) {
|
async sync(sessionID: string, opts?: { force?: boolean }) {
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = serverSync.child(directory)
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
|
|
||||||
touch(directory, setStore, sessionID)
|
touch(directory, setStore, sessionID)
|
||||||
@@ -488,7 +490,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
async diff(sessionID: string, opts?: { force?: boolean }) {
|
async diff(sessionID: string, opts?: { force?: boolean }) {
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = serverSync.child(directory)
|
||||||
touch(directory, setStore, sessionID)
|
touch(directory, setStore, sessionID)
|
||||||
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
|
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
|
||||||
|
|
||||||
@@ -501,13 +503,13 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
async todo(sessionID: string, opts?: { force?: boolean }) {
|
async todo(sessionID: string, opts?: { force?: boolean }) {
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = serverSync.child(directory)
|
||||||
touch(directory, setStore, sessionID)
|
touch(directory, setStore, sessionID)
|
||||||
const existing = store.todo[sessionID]
|
const existing = store.todo[sessionID]
|
||||||
const cached = globalSync.data.session_todo[sessionID]
|
const cached = serverSync.data.session_todo[sessionID]
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
if (cached === undefined) {
|
if (cached === undefined) {
|
||||||
globalSync.todo.set(sessionID, existing)
|
serverSync.todo.set(sessionID, existing)
|
||||||
}
|
}
|
||||||
if (!opts?.force) return
|
if (!opts?.force) return
|
||||||
}
|
}
|
||||||
@@ -522,7 +524,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
|||||||
if (!tracked(directory, sessionID)) return
|
if (!tracked(directory, sessionID)) return
|
||||||
const list = todo.data ?? []
|
const list = todo.data ?? []
|
||||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||||
globalSync.todo.set(sessionID, list)
|
serverSync.todo.set(sessionID, list)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -540,7 +542,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
|||||||
return meta.loading[key] ?? false
|
return meta.loading[key] ?? false
|
||||||
},
|
},
|
||||||
async loadMore(sessionID: string, count?: number) {
|
async loadMore(sessionID: string, count?: number) {
|
||||||
const [, setStore] = globalSync.child(directory)
|
const [, setStore] = serverSync.child(directory)
|
||||||
touch(directory, setStore, sessionID)
|
touch(directory, setStore, sessionID)
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const step = count ?? historyMessagePageSize
|
const step = count ?? historyMessagePageSize
|
||||||
@@ -561,12 +563,12 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
evict(sessionID: string, _directory = directory) {
|
evict(sessionID: string, _directory = directory) {
|
||||||
const [, setStore] = globalSync.child(_directory)
|
const [, setStore] = serverSync.child(_directory)
|
||||||
seenFor(_directory).delete(sessionID)
|
seenFor(_directory).delete(sessionID)
|
||||||
evict(_directory, setStore, [sessionID])
|
evict(_directory, setStore, [sessionID])
|
||||||
},
|
},
|
||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = serverSync.child(directory)
|
||||||
setStore("limit", (x) => x + count)
|
setStore("limit", (x) => x + count)
|
||||||
await client.session.list().then((x) => {
|
await client.session.list().then((x) => {
|
||||||
const sessions = (x.data ?? [])
|
const sessions = (x.data ?? [])
|
||||||
@@ -578,7 +580,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
|||||||
},
|
},
|
||||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||||
archive: async (sessionID: string) => {
|
archive: async (sessionID: string) => {
|
||||||
const [, setStore] = globalSync.child(directory)
|
const [, setStore] = serverSync.child(directory)
|
||||||
await client.session.update({ sessionID, time: { archived: Date.now() } })
|
await client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
|
||||||
import { batch, onCleanup, onMount } from "solid-js"
|
|
||||||
import { createSdkForServer } from "@/utils/server"
|
|
||||||
import { useLanguage } from "./language"
|
|
||||||
import { usePlatform } from "./platform"
|
|
||||||
import { useServer } from "./server"
|
|
||||||
|
|
||||||
const isAbortError = (error: unknown) =>
|
|
||||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
|
||||||
|
|
||||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
|
||||||
name: "GlobalSDK",
|
|
||||||
init: () => {
|
|
||||||
const language = useLanguage()
|
|
||||||
const server = useServer()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const abort = new AbortController()
|
|
||||||
|
|
||||||
const eventFetch = (() => {
|
|
||||||
if (!platform.fetch || !server.current) return
|
|
||||||
try {
|
|
||||||
const url = new URL(server.current.http.url)
|
|
||||||
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
|
||||||
if (url.protocol === "http:" && !loopback) return platform.fetch
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const currentServer = server.current
|
|
||||||
if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable"))
|
|
||||||
|
|
||||||
const eventSdk = createSdkForServer({
|
|
||||||
signal: abort.signal,
|
|
||||||
fetch: eventFetch,
|
|
||||||
server: currentServer.http,
|
|
||||||
})
|
|
||||||
const emitter = createGlobalEmitter<{
|
|
||||||
[key: string]: Event
|
|
||||||
}>()
|
|
||||||
|
|
||||||
type Queued = { directory: string; payload: Event }
|
|
||||||
const FLUSH_FRAME_MS = 16
|
|
||||||
const STREAM_YIELD_MS = 8
|
|
||||||
const RECONNECT_DELAY_MS = 250
|
|
||||||
|
|
||||||
let queue: Queued[] = []
|
|
||||||
let buffer: Queued[] = []
|
|
||||||
const coalesced = new Map<string, number>()
|
|
||||||
const staleDeltas = new Set<string>()
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
let last = 0
|
|
||||||
|
|
||||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
|
||||||
|
|
||||||
const key = (directory: string, payload: Event) => {
|
|
||||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
|
||||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
|
||||||
if (payload.type === "message.part.updated") {
|
|
||||||
const part = payload.properties.part
|
|
||||||
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const flush = () => {
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
timer = undefined
|
|
||||||
|
|
||||||
if (queue.length === 0) return
|
|
||||||
|
|
||||||
const events = queue
|
|
||||||
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
|
||||||
queue = buffer
|
|
||||||
buffer = events
|
|
||||||
queue.length = 0
|
|
||||||
coalesced.clear()
|
|
||||||
staleDeltas.clear()
|
|
||||||
|
|
||||||
last = Date.now()
|
|
||||||
batch(() => {
|
|
||||||
for (const event of events) {
|
|
||||||
if (skip && event.payload.type === "message.part.delta") {
|
|
||||||
const props = event.payload.properties
|
|
||||||
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
|
|
||||||
}
|
|
||||||
emitter.emit(event.directory, event.payload)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
buffer.length = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const schedule = () => {
|
|
||||||
if (timer) return
|
|
||||||
const elapsed = Date.now() - last
|
|
||||||
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
|
||||||
}
|
|
||||||
|
|
||||||
let streamErrorLogged = false
|
|
||||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
||||||
const aborted = isAbortError
|
|
||||||
|
|
||||||
let attempt: AbortController | undefined
|
|
||||||
let run: Promise<void> | undefined
|
|
||||||
let started = false
|
|
||||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
|
||||||
let lastEventAt = Date.now()
|
|
||||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
|
||||||
const resetHeartbeat = () => {
|
|
||||||
lastEventAt = Date.now()
|
|
||||||
if (heartbeat) clearTimeout(heartbeat)
|
|
||||||
heartbeat = setTimeout(() => {
|
|
||||||
attempt?.abort()
|
|
||||||
}, HEARTBEAT_TIMEOUT_MS)
|
|
||||||
}
|
|
||||||
const clearHeartbeat = () => {
|
|
||||||
if (!heartbeat) return
|
|
||||||
clearTimeout(heartbeat)
|
|
||||||
heartbeat = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = () => {
|
|
||||||
if (started) return run
|
|
||||||
started = true
|
|
||||||
run = (async () => {
|
|
||||||
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
|
||||||
while (!abort.signal.aborted && started) {
|
|
||||||
attempt = new AbortController()
|
|
||||||
lastEventAt = Date.now()
|
|
||||||
const onAbort = () => {
|
|
||||||
attempt?.abort()
|
|
||||||
}
|
|
||||||
abort.signal.addEventListener("abort", onAbort)
|
|
||||||
try {
|
|
||||||
const events = await eventSdk.global.event({
|
|
||||||
signal: attempt.signal,
|
|
||||||
onSseError: (error) => {
|
|
||||||
if (aborted(error)) return
|
|
||||||
if (streamErrorLogged) return
|
|
||||||
streamErrorLogged = true
|
|
||||||
console.error("[global-sdk] event stream error", {
|
|
||||||
url: currentServer.http.url,
|
|
||||||
fetch: eventFetch ? "platform" : "webview",
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
let yielded = Date.now()
|
|
||||||
resetHeartbeat()
|
|
||||||
for await (const event of events.stream) {
|
|
||||||
resetHeartbeat()
|
|
||||||
streamErrorLogged = false
|
|
||||||
const directory = event.directory ?? "global"
|
|
||||||
if (event.payload.type === "sync") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = event.payload as Event
|
|
||||||
|
|
||||||
const k = key(directory, payload)
|
|
||||||
if (k) {
|
|
||||||
const i = coalesced.get(k)
|
|
||||||
if (i !== undefined) {
|
|
||||||
queue[i] = { directory, payload }
|
|
||||||
if (payload.type === "message.part.updated") {
|
|
||||||
const part = payload.properties.part
|
|
||||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
coalesced.set(k, queue.length)
|
|
||||||
}
|
|
||||||
queue.push({ directory, payload })
|
|
||||||
schedule()
|
|
||||||
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
|
||||||
yielded = Date.now()
|
|
||||||
await wait(0)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!aborted(error) && !streamErrorLogged) {
|
|
||||||
streamErrorLogged = true
|
|
||||||
console.error("[global-sdk] event stream failed", {
|
|
||||||
url: currentServer.http.url,
|
|
||||||
fetch: eventFetch ? "platform" : "webview",
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
abort.signal.removeEventListener("abort", onAbort)
|
|
||||||
attempt = undefined
|
|
||||||
clearHeartbeat()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (abort.signal.aborted || !started) return
|
|
||||||
await wait(RECONNECT_DELAY_MS)
|
|
||||||
}
|
|
||||||
})().finally(() => {
|
|
||||||
run = undefined
|
|
||||||
flush()
|
|
||||||
})
|
|
||||||
return run
|
|
||||||
}
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
started = false
|
|
||||||
attempt?.abort()
|
|
||||||
clearHeartbeat()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
makeEventListener(document, "visibilitychange", () => {
|
|
||||||
if (document.visibilityState !== "visible") return
|
|
||||||
if (!started) return
|
|
||||||
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
|
||||||
attempt?.abort()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
stop()
|
|
||||||
abort.abort()
|
|
||||||
flush()
|
|
||||||
})
|
|
||||||
|
|
||||||
const sdk = createSdkForServer({
|
|
||||||
server: server.current.http,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
throwOnError: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: currentServer.http.url,
|
|
||||||
client: sdk,
|
|
||||||
event: {
|
|
||||||
on: emitter.on.bind(emitter),
|
|
||||||
listen: emitter.listen.bind(emitter),
|
|
||||||
start,
|
|
||||||
},
|
|
||||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
|
||||||
const s = server.current
|
|
||||||
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
|
|
||||||
return createSdkForServer({
|
|
||||||
server: s.http,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
...opts,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
|
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
|
import { bootstrapDirectory } from "./bootstrap"
|
||||||
|
import type { State, VcsCache } from "./types"
|
||||||
|
|
||||||
|
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||||
|
|
||||||
|
describe("bootstrapDirectory", () => {
|
||||||
|
test("marks a loading directory partial during bootstrap and complete after success", async () => {
|
||||||
|
const [store, setStore] = createStore<State>({
|
||||||
|
status: "loading",
|
||||||
|
agent: [],
|
||||||
|
command: [],
|
||||||
|
project: "",
|
||||||
|
projectMeta: undefined,
|
||||||
|
icon: undefined,
|
||||||
|
provider_ready: true,
|
||||||
|
provider,
|
||||||
|
config: {},
|
||||||
|
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||||
|
session: [],
|
||||||
|
sessionTotal: 0,
|
||||||
|
session_status: {},
|
||||||
|
session_working(id: string) {
|
||||||
|
return this.session_status[id]?.type !== "idle"
|
||||||
|
},
|
||||||
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
|
permission: {},
|
||||||
|
question: {},
|
||||||
|
mcp_ready: true,
|
||||||
|
mcp: {},
|
||||||
|
lsp_ready: true,
|
||||||
|
lsp: [],
|
||||||
|
vcs: undefined,
|
||||||
|
limit: 5,
|
||||||
|
message: {},
|
||||||
|
part: {},
|
||||||
|
part_text_accum_delta: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
await bootstrapDirectory({
|
||||||
|
directory: "/project",
|
||||||
|
global: {
|
||||||
|
config: {} satisfies Config,
|
||||||
|
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||||
|
project: [{ id: "project", worktree: "/project" } as Project],
|
||||||
|
provider,
|
||||||
|
},
|
||||||
|
sdk: {
|
||||||
|
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||||
|
config: { get: async () => ({ data: {} }) },
|
||||||
|
session: { status: async () => ({ data: {} }) },
|
||||||
|
vcs: { get: async () => ({ data: undefined }) },
|
||||||
|
command: { list: async () => ({ data: [] }) },
|
||||||
|
permission: { list: async () => ({ data: [] }) },
|
||||||
|
question: { list: async () => ({ data: [] }) },
|
||||||
|
mcp: { status: async () => ({ data: {} }) },
|
||||||
|
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||||
|
} as unknown as OpencodeClient,
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||||
|
loadSessions() {},
|
||||||
|
translate: (key) => key,
|
||||||
|
queryClient: new QueryClient(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.status).toBe("partial")
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||||
|
|
||||||
|
expect(store.status).toBe("complete")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
Project,
|
Project,
|
||||||
ProviderAuthResponse,
|
ProviderAuthResponse,
|
||||||
ProviderListResponse,
|
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
Todo,
|
Todo,
|
||||||
@@ -19,7 +18,8 @@ import type { State, VcsCache } from "./types"
|
|||||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||||
import { loadMcpQuery } from "../global-sync"
|
import { loadMcpQuery } from "../server-sync"
|
||||||
|
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -28,7 +28,7 @@ type GlobalStore = {
|
|||||||
session_todo: {
|
session_todo: {
|
||||||
[sessionID: string]: Todo[]
|
[sessionID: string]: Todo[]
|
||||||
}
|
}
|
||||||
provider: ProviderListResponse
|
provider: NormalizedProviderListResponse
|
||||||
provider_auth: ProviderAuthResponse
|
provider_auth: ProviderAuthResponse
|
||||||
config: Config
|
config: Config
|
||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
@@ -105,7 +105,7 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
globalSDK: OpencodeClient
|
serverSDK: OpencodeClient
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
formatMoreCount: (count: number) => string
|
formatMoreCount: (count: number) => string
|
||||||
@@ -113,12 +113,12 @@ export async function bootstrapGlobal(input: {
|
|||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.globalSDK)),
|
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.serverSDK)),
|
||||||
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.globalSDK)),
|
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.serverSDK)),
|
||||||
() => input.queryClient.fetchQuery(loadPathQuery(null, input.globalSDK)),
|
() => input.queryClient.fetchQuery(loadPathQuery(null, input.serverSDK)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.globalSDK))
|
.fetchQuery(loadProjectsQuery(input.serverSDK))
|
||||||
.then((data) => input.setGlobalStore("project", data)),
|
.then((data) => input.setGlobalStore("project", data)),
|
||||||
]
|
]
|
||||||
await runAll(slow)
|
await runAll(slow)
|
||||||
@@ -208,7 +208,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
config: Config
|
config: Config
|
||||||
path: Path
|
path: Path
|
||||||
project: Project[]
|
project: Project[]
|
||||||
provider: ProviderListResponse
|
provider: NormalizedProviderListResponse
|
||||||
}
|
}
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -1,10 +1,63 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||||
import { createRoot, getOwner } from "solid-js"
|
import { createRoot, getOwner, type Owner } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import { createChildStoreManager } from "./child-store"
|
import type { QueryOptionsApi } from "../server-sync"
|
||||||
|
|
||||||
|
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||||
|
|
||||||
const child = () => createStore({} as State)
|
const child = () => createStore({} as State)
|
||||||
|
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||||
|
|
||||||
|
const queryOptionsApi = {
|
||||||
|
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
||||||
|
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
|
||||||
|
providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
|
||||||
|
path: (directory: string | null) => ({
|
||||||
|
queryKey: [directory, "path"],
|
||||||
|
queryFn: async () => ({
|
||||||
|
state: "",
|
||||||
|
config: "",
|
||||||
|
worktree: "",
|
||||||
|
directory: directory ?? "",
|
||||||
|
home: "",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
|
||||||
|
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
|
||||||
|
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
|
||||||
|
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
|
||||||
|
} as unknown as QueryOptionsApi
|
||||||
|
|
||||||
|
function createOwner(callback: (owner: Owner) => void) {
|
||||||
|
return createRoot((dispose) => {
|
||||||
|
const owner = getOwner()
|
||||||
|
if (!owner) throw new Error("owner required")
|
||||||
|
callback(owner)
|
||||||
|
|
||||||
|
return dispose
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mock.module("@/utils/persist", () => ({
|
||||||
|
Persist: {
|
||||||
|
workspace: (...parts: string[]) => parts.join(":"),
|
||||||
|
},
|
||||||
|
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
|
||||||
|
}))
|
||||||
|
mock.module("@tanstack/solid-query", () => ({
|
||||||
|
useQueries: () => [
|
||||||
|
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
|
||||||
|
{ isLoading: false, data: {} },
|
||||||
|
{ isLoading: false, data: [] },
|
||||||
|
{ isLoading: false, data: provider },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
createChildStoreManager = (await import("./child-store")).createChildStoreManager
|
||||||
|
})
|
||||||
|
|
||||||
describe("createChildStoreManager", () => {
|
describe("createChildStoreManager", () => {
|
||||||
test("does not evict the active directory during mark", () => {
|
test("does not evict the active directory during mark", () => {
|
||||||
@@ -22,8 +75,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onBootstrap() {},
|
onBootstrap() {},
|
||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: {} as any,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider: null! },
|
global: { provider },
|
||||||
})
|
})
|
||||||
|
|
||||||
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
||||||
@@ -37,4 +90,35 @@ describe("createChildStoreManager", () => {
|
|||||||
|
|
||||||
expect(manager.children[directory]).toBeDefined()
|
expect(manager.children[directory]).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("starts new child stores as loading and bootstraps them on first access", () => {
|
||||||
|
const bootstraps: string[] = []
|
||||||
|
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||||
|
|
||||||
|
const dispose = createOwner((owner) => {
|
||||||
|
manager = createChildStoreManager({
|
||||||
|
owner,
|
||||||
|
isBooting: () => false,
|
||||||
|
isLoadingSessions: () => false,
|
||||||
|
onBootstrap(directory) {
|
||||||
|
bootstraps.push(directory)
|
||||||
|
},
|
||||||
|
onDispose() {},
|
||||||
|
translate: (key) => key,
|
||||||
|
queryOptions: queryOptionsApi,
|
||||||
|
global: { provider },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!manager) throw new Error("manager required")
|
||||||
|
|
||||||
|
const [store] = manager.child("/project")
|
||||||
|
|
||||||
|
expect(store.status).toBe("loading")
|
||||||
|
expect(bootstraps).toEqual(["/project"])
|
||||||
|
} finally {
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client"
|
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
import {
|
import {
|
||||||
DIR_IDLE_TTL_MS,
|
DIR_IDLE_TTL_MS,
|
||||||
MAX_DIR_STORES,
|
MAX_DIR_STORES,
|
||||||
@@ -15,8 +15,9 @@ import {
|
|||||||
} from "./types"
|
} from "./types"
|
||||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
||||||
import { useQueries } from "@tanstack/solid-query"
|
import { useQueries } from "@tanstack/solid-query"
|
||||||
import { QueryOptionsApi } from "../global-sync"
|
import { QueryOptionsApi } from "../server-sync"
|
||||||
import { directoryKey, type DirectoryKey } from "./utils"
|
import { directoryKey, type DirectoryKey } from "./utils"
|
||||||
|
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
|
|
||||||
export function createChildStoreManager(input: {
|
export function createChildStoreManager(input: {
|
||||||
owner: Owner
|
owner: Owner
|
||||||
@@ -27,7 +28,7 @@ export function createChildStoreManager(input: {
|
|||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
queryOptions: QueryOptionsApi
|
queryOptions: QueryOptionsApi
|
||||||
global: {
|
global: {
|
||||||
provider: ProviderListResponse
|
provider: NormalizedProviderListResponse
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||||
@@ -190,10 +191,9 @@ export function createChildStoreManager(input: {
|
|||||||
return !providerQuery.isLoading
|
return !providerQuery.isLoading
|
||||||
},
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
const EMPTY = { all: [], connected: [], default: {} }
|
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||||
if (providerQuery.isLoading) return EMPTY
|
if (providerQuery.isLoading) return EMPTY
|
||||||
if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0)
|
if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
||||||
return input.global.provider
|
|
||||||
return providerQuery.data ?? EMPTY
|
return providerQuery.data ?? EMPTY
|
||||||
},
|
},
|
||||||
config: {},
|
config: {},
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
Part,
|
Part,
|
||||||
Path,
|
Path,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
ProviderListResponse,
|
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
@@ -16,6 +15,7 @@ import type {
|
|||||||
Todo,
|
Todo,
|
||||||
VcsInfo,
|
VcsInfo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export type State = {
|
|||||||
projectMeta: ProjectMeta | undefined
|
projectMeta: ProjectMeta | undefined
|
||||||
icon: string | undefined
|
icon: string | undefined
|
||||||
provider_ready: boolean
|
provider_ready: boolean
|
||||||
provider: ProviderListResponse
|
provider: NormalizedProviderListResponse
|
||||||
config: Config
|
config: Config
|
||||||
path: Path
|
path: Path
|
||||||
session: Session[]
|
session: Session[]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||||
|
|
||||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
@@ -17,13 +18,23 @@ export function normalizeAgentList(input: unknown): Agent[] {
|
|||||||
return Object.values(input).filter(isAgent)
|
return Object.values(input).filter(isAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
|
||||||
return {
|
return {
|
||||||
...input,
|
...input,
|
||||||
all: input.all.map((provider) => ({
|
all: new Map(
|
||||||
|
input.all.map(
|
||||||
|
(provider) =>
|
||||||
|
[
|
||||||
|
provider.id,
|
||||||
|
{
|
||||||
...provider,
|
...provider,
|
||||||
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
|
models: Object.fromEntries(
|
||||||
})),
|
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] as const,
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { createStore, produce } from "solid-js/store"
|
|||||||
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useServerSync } from "./server-sync"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { Project } from "@opencode-ai/sdk/v2"
|
import { Project } from "@opencode-ai/sdk/v2"
|
||||||
@@ -136,8 +136,8 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
|||||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
init: () => {
|
init: () => {
|
||||||
const globalSdk = useGlobalSDK()
|
const globalSdk = useServerSDK()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
@@ -386,11 +386,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||||
const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
|
const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
|
||||||
const projectID = childStore.project
|
const projectID = childStore.project
|
||||||
const metadata = projectID
|
const metadata = projectID
|
||||||
? globalSync.data.project.find((x) => x.id === projectID)
|
? serverSync.data.project.find((x) => x.id === projectID)
|
||||||
: globalSync.data.project.find((x) => x.worktree === project.worktree)
|
: serverSync.data.project.find((x) => x.worktree === project.worktree)
|
||||||
|
|
||||||
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
||||||
// Without this, different subdirectories of the same git repo would share the same
|
// Without this, different subdirectories of the same git repo would share the same
|
||||||
@@ -404,7 +404,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const roots = createMemo(() => {
|
const roots = createMemo(() => {
|
||||||
const map = new Map<string, string>()
|
const map = new Map<string, string>()
|
||||||
for (const project of globalSync.data.project) {
|
for (const project of serverSync.data.project) {
|
||||||
const sandboxes = project.sandboxes ?? []
|
const sandboxes = project.sandboxes ?? []
|
||||||
for (const sandbox of sandboxes) {
|
for (const sandbox of sandboxes) {
|
||||||
map.set(sandbox, project.worktree)
|
map.set(sandbox, project.worktree)
|
||||||
@@ -470,12 +470,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const projects = enriched()
|
const projects = enriched()
|
||||||
if (projects.length === 0) return
|
if (projects.length === 0) return
|
||||||
if (!globalSync.ready) return
|
if (!serverSync.ready) return
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
if (!project.id) continue
|
if (!project.id) continue
|
||||||
if (project.id === "global") continue
|
if (project.id === "global") continue
|
||||||
globalSync.project.icon(project.worktree, project.icon?.override)
|
serverSync.project.icon(project.worktree, project.icon?.override)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -509,7 +509,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
colorRequested.set(worktree, color)
|
colorRequested.set(worktree, color)
|
||||||
|
|
||||||
if (project.id === "global") {
|
if (project.id === "global") {
|
||||||
globalSync.project.meta(worktree, { icon: { color } })
|
serverSync.project.meta(worktree, { icon: { color } })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +531,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
sessionTimer = undefined
|
sessionTimer = undefined
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
server.projects.list().map((project) => {
|
server.projects.list().map((project) => {
|
||||||
return globalSync.project.loadSessions(project.worktree)
|
return serverSync.project.loadSessions(project.worktree)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}, 0)
|
}, 0)
|
||||||
@@ -560,7 +560,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
open(directory: string) {
|
open(directory: string) {
|
||||||
const root = rootFor(directory)
|
const root = rootFor(directory)
|
||||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||||
void globalSync.project.loadSessions(root)
|
void serverSync.project.loadSessions(root)
|
||||||
server.projects.open(root)
|
server.projects.open(root)
|
||||||
},
|
},
|
||||||
close(directory: string) {
|
close(directory: string) {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const validModel = (model: ModelKey) => {
|
const validModel = (model: ModelKey) => {
|
||||||
const provider = providers.all().find((item) => item.id === model.providerID)
|
const provider = providers.all().get(model.providerID)
|
||||||
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { createStore, reconcile } from "solid-js/store"
|
|||||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useServerSync } from "./server-sync"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
@@ -109,8 +109,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
name: "Notification",
|
name: "Notification",
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const globalSDK = useGlobalSDK()
|
const serverSDK = useServerSDK()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -207,10 +207,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
|
|
||||||
const lookup = async (directory: string, sessionID?: string) => {
|
const lookup = async (directory: string, sessionID?: string) => {
|
||||||
if (!sessionID) return undefined
|
if (!sessionID) return undefined
|
||||||
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
const [syncStore] = serverSync.child(directory, { bootstrap: false })
|
||||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||||
if (match.found) return syncStore.session[match.index]
|
if (match.found) return syncStore.session[match.index]
|
||||||
return globalSDK.client.session
|
return serverSDK.client.session
|
||||||
.get({ directory, sessionID })
|
.get({ directory, sessionID })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
@@ -285,7 +285,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = globalSDK.event.listen((e) => {
|
const unsub = serverSDK.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { createStore, produce } from "solid-js/store"
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useServerSync } from "./server-sync"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import {
|
import {
|
||||||
@@ -48,13 +48,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
name: "Permission",
|
name: "Permission",
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const globalSDK = useGlobalSDK()
|
const serverSDK = useServerSDK()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
|
|
||||||
const permissionsEnabled = createMemo(() => {
|
const permissionsEnabled = createMemo(() => {
|
||||||
const directory = decode64(params.dir)
|
const directory = decode64(params.dir)
|
||||||
if (!directory) return false
|
if (!directory) return false
|
||||||
const [store] = globalSync.child(directory)
|
const [store] = serverSync.child(directory)
|
||||||
return hasPermissionPromptRules(store.config.permission)
|
return hasPermissionPromptRules(store.config.permission)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
if (!ready()) return
|
if (!ready()) return
|
||||||
const directory = decode64(params.dir)
|
const directory = decode64(params.dir)
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
const [childStore] = globalSync.child(directory)
|
const [childStore] = serverSync.child(directory)
|
||||||
const perm = childStore.config.permission
|
const perm = childStore.config.permission
|
||||||
if (typeof perm === "string" && perm === "allow") {
|
if (typeof perm === "string" && perm === "allow") {
|
||||||
const key = directoryAcceptKey(directory)
|
const key = directoryAcceptKey(directory)
|
||||||
@@ -118,7 +118,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}
|
}
|
||||||
|
|
||||||
const respond: PermissionRespondFn = (input) => {
|
const respond: PermissionRespondFn = (input) => {
|
||||||
globalSDK.client.permission.respond(input).catch(() => {
|
serverSDK.client.permission.respond(input).catch(() => {
|
||||||
responded.delete(input.permissionID)
|
responded.delete(input.permissionID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||||
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
|
||||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||||
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
|
||||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = globalSDK.event.listen((e) => {
|
const unsubscribe = serverSDK.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event?.type !== "permission.asked") return
|
if (event?.type !== "permission.asked") return
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
globalSDK.client.permission
|
serverSDK.client.permission
|
||||||
.list({ directory })
|
.list({ directory })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (!isAutoAcceptingDirectory(directory)) return
|
if (!isAutoAcceptingDirectory(directory)) return
|
||||||
@@ -210,7 +210,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
globalSDK.client.permission
|
serverSDK.client.permission
|
||||||
.list({ directory })
|
.list({ directory })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (enableVersion.get(key) !== version) return
|
if (enableVersion.get(key) !== version) return
|
||||||
@@ -268,7 +268,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
},
|
},
|
||||||
permissionsEnabled,
|
permissionsEnabled,
|
||||||
isPermissionAllowAll(directory: string) {
|
isPermissionAllowAll(directory: string) {
|
||||||
const [childStore] = globalSync.child(directory)
|
const [childStore] = serverSync.child(directory)
|
||||||
const perm = childStore.config.permission
|
const perm = childStore.config.permission
|
||||||
return typeof perm === "string" && perm === "allow"
|
return typeof perm === "string" && perm === "allow"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,13 +9,23 @@ type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
|||||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
||||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||||
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
||||||
|
type PlatformName = "web" | "desktop"
|
||||||
|
type DesktopOS = "macos" | "windows" | "linux"
|
||||||
|
|
||||||
|
export type FatalRendererErrorLog = {
|
||||||
|
error: string
|
||||||
|
url: string
|
||||||
|
version?: string
|
||||||
|
platform: PlatformName
|
||||||
|
os?: DesktopOS
|
||||||
|
}
|
||||||
|
|
||||||
export type Platform = {
|
export type Platform = {
|
||||||
/** Platform discriminator */
|
/** Platform discriminator */
|
||||||
platform: "web" | "desktop"
|
platform: PlatformName
|
||||||
|
|
||||||
/** Desktop OS (Tauri only) */
|
/** Desktop OS (Tauri only) */
|
||||||
os?: "macos" | "windows" | "linux"
|
os?: DesktopOS
|
||||||
|
|
||||||
/** App version */
|
/** App version */
|
||||||
version?: string
|
version?: string
|
||||||
@@ -83,6 +93,12 @@ export type Platform = {
|
|||||||
/** Webview zoom level (desktop only) */
|
/** Webview zoom level (desktop only) */
|
||||||
webviewZoom?: Accessor<number>
|
webviewZoom?: Accessor<number>
|
||||||
|
|
||||||
|
/** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */
|
||||||
|
getPinchZoomEnabled?(): Promise<boolean> | boolean
|
||||||
|
|
||||||
|
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
|
||||||
|
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
|
||||||
|
|
||||||
/** Run a desktop-only menu action from the app chrome */
|
/** Run a desktop-only menu action from the app chrome */
|
||||||
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
|
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
|
||||||
|
|
||||||
@@ -91,6 +107,12 @@ export type Platform = {
|
|||||||
|
|
||||||
/** Read image from clipboard (desktop only) */
|
/** Read image from clipboard (desktop only) */
|
||||||
readClipboardImage?(): Promise<File | null>
|
readClipboardImage?(): Promise<File | null>
|
||||||
|
|
||||||
|
/** Export collected diagnostic logs (desktop only) */
|
||||||
|
exportDebugLogs?(): Promise<string>
|
||||||
|
|
||||||
|
/** Record a fatal renderer error in platform logs (desktop only) */
|
||||||
|
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisplayBackend = "auto" | "wayland"
|
export type DisplayBackend = "auto" | "wayland"
|
||||||
|
|||||||
@@ -1,49 +1,11 @@
|
|||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
|
||||||
|
|
||||||
type SDKEventMap = {
|
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
init: (props: { directory: Accessor<string> }) => {
|
init: (props: { directory: string }) => {
|
||||||
const globalSDK = useGlobalSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
|
||||||
const directory = createMemo(props.directory)
|
return serverSDK.createDirSdkContext(props.directory)
|
||||||
const client = createMemo(() =>
|
|
||||||
globalSDK.createClient({
|
|
||||||
directory: directory(),
|
|
||||||
throwOnError: true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const unsub = globalSDK.event.on(directory(), (event) => {
|
|
||||||
emitter.emit(event.type, event)
|
|
||||||
})
|
|
||||||
onCleanup(unsub)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
get directory() {
|
|
||||||
return directory()
|
|
||||||
},
|
|
||||||
get client() {
|
|
||||||
return client()
|
|
||||||
},
|
|
||||||
event: emitter,
|
|
||||||
get url() {
|
|
||||||
return globalSDK.url
|
|
||||||
},
|
|
||||||
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
|
|
||||||
return globalSDK.createClient(opts)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
|
import { createSdkForServer } from "@/utils/server"
|
||||||
|
import { useLanguage } from "./language"
|
||||||
|
import { usePlatform } from "./platform"
|
||||||
|
import { ServerConnection, useServer } from "./server"
|
||||||
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
|
|
||||||
|
const isAbortError = (error: unknown) =>
|
||||||
|
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||||
|
|
||||||
|
function createServerSdkContext(server: ServerConnection.Any) {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const abort = new AbortController()
|
||||||
|
|
||||||
|
const eventFetch = (() => {
|
||||||
|
if (!platform.fetch || !server) return
|
||||||
|
try {
|
||||||
|
const url = new URL(server.http.url)
|
||||||
|
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
||||||
|
if (url.protocol === "http:" && !loopback) return platform.fetch
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const eventSdk = createSdkForServer({
|
||||||
|
signal: abort.signal,
|
||||||
|
fetch: eventFetch,
|
||||||
|
server: server.http,
|
||||||
|
})
|
||||||
|
const emitter = createGlobalEmitter<{
|
||||||
|
[key: string]: Event
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type Queued = { directory: string; payload: Event }
|
||||||
|
const FLUSH_FRAME_MS = 16
|
||||||
|
const STREAM_YIELD_MS = 8
|
||||||
|
const RECONNECT_DELAY_MS = 250
|
||||||
|
|
||||||
|
let queue: Queued[] = []
|
||||||
|
let buffer: Queued[] = []
|
||||||
|
const coalesced = new Map<string, number>()
|
||||||
|
const staleDeltas = new Set<string>()
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let last = 0
|
||||||
|
|
||||||
|
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
||||||
|
|
||||||
|
const key = (directory: string, payload: Event) => {
|
||||||
|
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||||
|
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||||
|
if (payload.type === "message.part.updated") {
|
||||||
|
const part = payload.properties.part
|
||||||
|
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
|
||||||
|
if (queue.length === 0) return
|
||||||
|
|
||||||
|
const events = queue
|
||||||
|
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
||||||
|
queue = buffer
|
||||||
|
buffer = events
|
||||||
|
queue.length = 0
|
||||||
|
coalesced.clear()
|
||||||
|
staleDeltas.clear()
|
||||||
|
|
||||||
|
last = Date.now()
|
||||||
|
batch(() => {
|
||||||
|
for (const event of events) {
|
||||||
|
if (skip && event.payload.type === "message.part.delta") {
|
||||||
|
const props = event.payload.properties
|
||||||
|
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
|
||||||
|
}
|
||||||
|
emitter.emit(event.directory, event.payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
buffer.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (timer) return
|
||||||
|
const elapsed = Date.now() - last
|
||||||
|
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
let streamErrorLogged = false
|
||||||
|
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
|
const aborted = isAbortError
|
||||||
|
|
||||||
|
let attempt: AbortController | undefined
|
||||||
|
let run: Promise<void> | undefined
|
||||||
|
let started = false
|
||||||
|
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||||
|
let lastEventAt = Date.now()
|
||||||
|
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const resetHeartbeat = () => {
|
||||||
|
lastEventAt = Date.now()
|
||||||
|
if (heartbeat) clearTimeout(heartbeat)
|
||||||
|
heartbeat = setTimeout(() => {
|
||||||
|
attempt?.abort()
|
||||||
|
}, HEARTBEAT_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
const clearHeartbeat = () => {
|
||||||
|
if (!heartbeat) return
|
||||||
|
clearTimeout(heartbeat)
|
||||||
|
heartbeat = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (started) return run
|
||||||
|
started = true
|
||||||
|
run = (async () => {
|
||||||
|
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||||
|
while (!abort.signal.aborted && started) {
|
||||||
|
attempt = new AbortController()
|
||||||
|
lastEventAt = Date.now()
|
||||||
|
const onAbort = () => {
|
||||||
|
attempt?.abort()
|
||||||
|
}
|
||||||
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
|
try {
|
||||||
|
const events = await eventSdk.global.event({
|
||||||
|
signal: attempt.signal,
|
||||||
|
onSseError: (error) => {
|
||||||
|
if (aborted(error)) return
|
||||||
|
if (streamErrorLogged) return
|
||||||
|
streamErrorLogged = true
|
||||||
|
console.error("[global-sdk] event stream error", {
|
||||||
|
url: server.http.url,
|
||||||
|
fetch: eventFetch ? "platform" : "webview",
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let yielded = Date.now()
|
||||||
|
resetHeartbeat()
|
||||||
|
for await (const event of events.stream) {
|
||||||
|
resetHeartbeat()
|
||||||
|
streamErrorLogged = false
|
||||||
|
const directory = event.directory ?? "global"
|
||||||
|
if (event.payload.type === "sync") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = event.payload as Event
|
||||||
|
|
||||||
|
const k = key(directory, payload)
|
||||||
|
if (k) {
|
||||||
|
const i = coalesced.get(k)
|
||||||
|
if (i !== undefined) {
|
||||||
|
queue[i] = { directory, payload }
|
||||||
|
if (payload.type === "message.part.updated") {
|
||||||
|
const part = payload.properties.part
|
||||||
|
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
coalesced.set(k, queue.length)
|
||||||
|
}
|
||||||
|
queue.push({ directory, payload })
|
||||||
|
schedule()
|
||||||
|
|
||||||
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
|
yielded = Date.now()
|
||||||
|
await wait(0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!aborted(error) && !streamErrorLogged) {
|
||||||
|
streamErrorLogged = true
|
||||||
|
console.error("[global-sdk] event stream failed", {
|
||||||
|
url: server.http.url,
|
||||||
|
fetch: eventFetch ? "platform" : "webview",
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abort.signal.removeEventListener("abort", onAbort)
|
||||||
|
attempt = undefined
|
||||||
|
clearHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abort.signal.aborted || !started) return
|
||||||
|
await wait(RECONNECT_DELAY_MS)
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
run = undefined
|
||||||
|
flush()
|
||||||
|
})
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
started = false
|
||||||
|
attempt?.abort()
|
||||||
|
clearHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
makeEventListener(document, "visibilitychange", () => {
|
||||||
|
if (document.visibilityState !== "visible") return
|
||||||
|
if (!started) return
|
||||||
|
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||||
|
attempt?.abort()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stop()
|
||||||
|
abort.abort()
|
||||||
|
flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
const sdk = createSdkForServer({
|
||||||
|
server: server.http,
|
||||||
|
fetch: platform.fetch,
|
||||||
|
throwOnError: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: server.http.url,
|
||||||
|
client: sdk,
|
||||||
|
event: {
|
||||||
|
on: emitter.on.bind(emitter),
|
||||||
|
listen: emitter.listen.bind(emitter),
|
||||||
|
start,
|
||||||
|
},
|
||||||
|
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||||
|
return createSdkForServer({
|
||||||
|
server: server.http,
|
||||||
|
fetch: platform.fetch,
|
||||||
|
...opts,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
|
||||||
|
name: "ServerSDK",
|
||||||
|
init: () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
|
|
||||||
|
if (!server.current) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||||
|
const sdk = createServerSdkContext(server.current)
|
||||||
|
return {
|
||||||
|
...sdk,
|
||||||
|
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type SDKEventMap = {
|
||||||
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDirSdkContext(directory: string, serverSDK: ReturnType<typeof createServerSdkContext>) {
|
||||||
|
const client = serverSDK.createClient({
|
||||||
|
directory,
|
||||||
|
throwOnError: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||||
|
|
||||||
|
const unsub = serverSDK.event.on(directory, (event) => {
|
||||||
|
emitter.emit(event.type, event)
|
||||||
|
})
|
||||||
|
onCleanup(unsub)
|
||||||
|
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
client,
|
||||||
|
event: emitter,
|
||||||
|
get url() {
|
||||||
|
return serverSDK.url
|
||||||
|
},
|
||||||
|
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
|
||||||
|
return serverSDK.createClient(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-65
@@ -1,19 +1,11 @@
|
|||||||
import type {
|
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
|
||||||
Config,
|
|
||||||
OpencodeClient,
|
|
||||||
Path,
|
|
||||||
Project,
|
|
||||||
ProviderAuthResponse,
|
|
||||||
ProviderListResponse,
|
|
||||||
Todo,
|
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { InitError } from "../pages/error"
|
import type { InitError } from "../pages/error"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import {
|
import {
|
||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
bootstrapGlobal,
|
bootstrapGlobal,
|
||||||
@@ -37,6 +29,8 @@ import { createRefreshQueue } from "./global-sync/queue"
|
|||||||
import { directoryKey } from "./global-sync/utils"
|
import { directoryKey } from "./global-sync/utils"
|
||||||
import { PathKey } from "@/utils/path-key"
|
import { PathKey } from "@/utils/path-key"
|
||||||
import { createDirSyncContext } from "./directory-sync"
|
import { createDirSyncContext } from "./directory-sync"
|
||||||
|
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -46,7 +40,7 @@ type GlobalStore = {
|
|||||||
session_todo: {
|
session_todo: {
|
||||||
[sessionID: string]: Todo[]
|
[sessionID: string]: Todo[]
|
||||||
}
|
}
|
||||||
provider: ProviderListResponse
|
provider: NormalizedProviderListResponse
|
||||||
provider_auth: ProviderAuthResponse
|
provider_auth: ProviderAuthResponse
|
||||||
config: Config
|
config: Config
|
||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
@@ -64,13 +58,13 @@ export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
|
|||||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
|
function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(globalSDK()),
|
globalConfig: () => loadGlobalConfigQuery(serverSDK()),
|
||||||
projects: () => loadProjectsQuery(globalSDK()),
|
projects: () => loadProjectsQuery(serverSDK()),
|
||||||
providers: (directory: PathKey | null) =>
|
providers: (directory: PathKey | null) =>
|
||||||
loadProvidersQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
|
loadProvidersQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||||
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
|
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||||
agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
|
agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
|
||||||
mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
|
mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
|
||||||
lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
|
lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
|
||||||
@@ -79,11 +73,11 @@ function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: Path
|
|||||||
}
|
}
|
||||||
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
||||||
|
|
||||||
function createGlobalSync() {
|
export function createServerSyncContext() {
|
||||||
const globalSDK = useGlobalSDK()
|
const serverSDK = useServerSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
if (!owner) throw new Error("GlobalSync must be created within owner")
|
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||||
|
|
||||||
const sdkCache = new Map<string, OpencodeClient>()
|
const sdkCache = new Map<string, OpencodeClient>()
|
||||||
const booting = new Map<string, Promise<void>>()
|
const booting = new Map<string, Promise<void>>()
|
||||||
@@ -94,7 +88,7 @@ function createGlobalSync() {
|
|||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const cached = sdkCache.get(key)
|
const cached = sdkCache.get(key)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
const sdk = globalSDK.createClient({
|
const sdk = serverSDK.createClient({
|
||||||
directory,
|
directory,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
@@ -102,7 +96,7 @@ function createGlobalSync() {
|
|||||||
return sdk
|
return sdk
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryOptionsApi = makeQueryOptionsApi(() => globalSDK.client, sdkFor)
|
const queryOptionsApi = makeQueryOptionsApi(() => serverSDK.client, sdkFor)
|
||||||
|
|
||||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||||
@@ -121,7 +115,7 @@ function createGlobalSync() {
|
|||||||
return pathQuery.data ?? EMPTY
|
return pathQuery.data ?? EMPTY
|
||||||
},
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
const EMPTY = { all: [], connected: [], default: {} }
|
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||||
if (providerQuery.isLoading) return EMPTY
|
if (providerQuery.isLoading) return EMPTY
|
||||||
return providerQuery.data ?? EMPTY
|
return providerQuery.data ?? EMPTY
|
||||||
},
|
},
|
||||||
@@ -161,7 +155,7 @@ function createGlobalSync() {
|
|||||||
queryKey: ["bootstrap"],
|
queryKey: ["bootstrap"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
await bootstrapGlobal({
|
await bootstrapGlobal({
|
||||||
globalSDK: globalSDK.client,
|
serverSDK: serverSDK.client,
|
||||||
requestFailedTitle: language.t("common.requestFailed"),
|
requestFailedTitle: language.t("common.requestFailed"),
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||||
@@ -255,7 +249,7 @@ function createGlobalSync() {
|
|||||||
loadRootSessionsWithFallback({
|
loadRootSessionsWithFallback({
|
||||||
directory,
|
directory,
|
||||||
limit,
|
limit,
|
||||||
list: (query) => globalSDK.client.session.list(query),
|
list: (query) => serverSDK.client.session.list(query),
|
||||||
})
|
})
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const nonArchived = (x.data ?? [])
|
const nonArchived = (x.data ?? [])
|
||||||
@@ -341,7 +335,7 @@ function createGlobalSync() {
|
|||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = globalSDK.event.listen((e) => {
|
const unsub = serverSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const event = e.details
|
const event = e.details
|
||||||
@@ -400,13 +394,13 @@ function createGlobalSync() {
|
|||||||
eventFrame = undefined
|
eventFrame = undefined
|
||||||
eventTimer = setTimeout(() => {
|
eventTimer = setTimeout(() => {
|
||||||
eventTimer = undefined
|
eventTimer = undefined
|
||||||
void globalSDK.event.start()
|
void serverSDK.event.start()
|
||||||
}, 0)
|
}, 0)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
eventTimer = setTimeout(() => {
|
eventTimer = setTimeout(() => {
|
||||||
eventTimer = undefined
|
eventTimer = undefined
|
||||||
void globalSDK.event.start()
|
void serverSDK.event.start()
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -422,7 +416,7 @@ function createGlobalSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateConfigMutation = useMutation(() => ({
|
const updateConfigMutation = useMutation(() => ({
|
||||||
mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }),
|
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
bootstrap.refetch()
|
bootstrap.refetch()
|
||||||
// Invalidate all provider queries so newly configured custom providers
|
// Invalidate all provider queries so newly configured custom providers
|
||||||
@@ -432,9 +426,6 @@ function createGlobalSync() {
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSyncContext>>()
|
|
||||||
const dirSyncContextRefCounts = new Map<string, number>()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: globalStore,
|
data: globalStore,
|
||||||
set,
|
set,
|
||||||
@@ -453,42 +444,21 @@ function createGlobalSync() {
|
|||||||
todo: {
|
todo: {
|
||||||
set: setSessionTodo,
|
set: setSessionTodo,
|
||||||
},
|
},
|
||||||
createDirSyncContext: (directory: string) => {
|
|
||||||
onCleanup(() => {
|
|
||||||
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) - 1)
|
|
||||||
if (dirSyncContextRefCounts.get(directory) === 0) {
|
|
||||||
dirSyncContexts.delete(directory)
|
|
||||||
dirSyncContextRefCounts.delete(directory)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
|
||||||
|
name: "ServerSync",
|
||||||
|
init: () => {
|
||||||
|
const sync = createServerSyncContext()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...sync,
|
||||||
|
createDirSyncContext: createRefCountMap((dir) => createDirSyncContext(dir, sync)),
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const cached = dirSyncContexts.get(directory)
|
|
||||||
if (cached) {
|
|
||||||
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) + 1)
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
const ctx = createDirSyncContext(globalSDK.createClient({ directory, throwOnError: true }), directory)
|
|
||||||
dirSyncContexts.set(directory, ctx)
|
|
||||||
dirSyncContextRefCounts.set(directory, 1)
|
|
||||||
|
|
||||||
return ctx
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
|
|
||||||
|
|
||||||
export function GlobalSyncProvider(props: ParentProps) {
|
|
||||||
const value = createGlobalSync()
|
|
||||||
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGlobalSync() {
|
|
||||||
const context = useContext(GlobalSyncContext)
|
|
||||||
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useQueryOptions() {
|
export function useQueryOptions() {
|
||||||
return useGlobalSync().queryOptions
|
return useServerSync().queryOptions
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
import { type Accessor, batch, createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { useCheckServerHealth } from "@/utils/server-health"
|
|
||||||
|
|
||||||
type StoredProject = { worktree: string; expanded: boolean }
|
type StoredProject = { worktree: string; expanded: boolean }
|
||||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||||
@@ -37,24 +36,30 @@ export function resolveServerList(input: {
|
|||||||
props?: Array<ServerConnection.Any>
|
props?: Array<ServerConnection.Any>
|
||||||
stored: StoredServer[]
|
stored: StoredServer[]
|
||||||
}): Array<ServerConnection.Any> {
|
}): Array<ServerConnection.Any> {
|
||||||
const servers = [
|
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
|
||||||
...input.stored.map((value) =>
|
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const value of input.stored) {
|
||||||
|
const conn: ServerConnection.Http =
|
||||||
typeof value === "string"
|
typeof value === "string"
|
||||||
? {
|
? {
|
||||||
type: "http" as const,
|
type: "http" as const,
|
||||||
http: { url: value },
|
http: { url: value },
|
||||||
}
|
}
|
||||||
: value,
|
: "http" in value
|
||||||
),
|
? value
|
||||||
...(input.props ?? []),
|
: { type: "http", http: value }
|
||||||
]
|
|
||||||
|
|
||||||
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
|
|
||||||
for (const value of servers) {
|
|
||||||
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
|
|
||||||
const key = ServerConnection.key(conn)
|
const key = ServerConnection.key(conn)
|
||||||
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
|
|
||||||
deduped.set(key, conn)
|
const existing = deduped.get(key)
|
||||||
|
if (existing)
|
||||||
|
deduped.set(key, {
|
||||||
|
...existing,
|
||||||
|
...conn,
|
||||||
|
http: { ...existing.http, ...conn.http },
|
||||||
|
})
|
||||||
|
else deduped.set(key, conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...deduped.values()]
|
return [...deduped.values()]
|
||||||
@@ -122,13 +127,7 @@ export namespace ServerConnection {
|
|||||||
|
|
||||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||||
name: "Server",
|
name: "Server",
|
||||||
init: (props: {
|
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
||||||
defaultServer: ServerConnection.Key
|
|
||||||
disableHealthCheck?: boolean
|
|
||||||
servers?: Array<ServerConnection.Any>
|
|
||||||
}) => {
|
|
||||||
const checkServerHealth = useCheckServerHealth()
|
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("server", ["server.v3"]),
|
Persist.global("server", ["server.v3"]),
|
||||||
createStore({
|
createStore({
|
||||||
@@ -146,36 +145,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
active: props.defaultServer,
|
active: props.defaultServer,
|
||||||
healthy: undefined as boolean | undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const healthy = () => state.healthy
|
|
||||||
|
|
||||||
function startHealthPolling(conn: ServerConnection.Any) {
|
|
||||||
let alive = true
|
|
||||||
let busy = false
|
|
||||||
|
|
||||||
const run = () => {
|
|
||||||
if (busy) return
|
|
||||||
busy = true
|
|
||||||
void check(conn)
|
|
||||||
.then((next) => {
|
|
||||||
if (!alive) return
|
|
||||||
setState("healthy", next)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
busy = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
run()
|
|
||||||
const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS)
|
|
||||||
return () => {
|
|
||||||
alive = false
|
|
||||||
clearInterval(interval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setActive(input: ServerConnection.Key) {
|
function setActive(input: ServerConnection.Key) {
|
||||||
if (state.active !== input) setState("active", input)
|
if (state.active !== input) setState("active", input)
|
||||||
}
|
}
|
||||||
@@ -209,20 +180,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const isReady = createMemo(() => ready() && !!state.active)
|
const isReady = createMemo(() => ready() && !!state.active)
|
||||||
|
|
||||||
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const current_ = current()
|
|
||||||
if (!current_) return
|
|
||||||
|
|
||||||
if (props.disableHealthCheck) {
|
|
||||||
setState("healthy", true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setState("healthy", undefined)
|
|
||||||
onCleanup(startHealthPolling(current_))
|
|
||||||
})
|
|
||||||
|
|
||||||
const origin = createMemo(() => projectsKey(state.active))
|
const origin = createMemo(() => projectsKey(state.active))
|
||||||
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
||||||
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
|
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
|
||||||
@@ -235,7 +192,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ready: isReady,
|
ready: isReady,
|
||||||
healthy,
|
|
||||||
isLocal,
|
isLocal,
|
||||||
get key() {
|
get key() {
|
||||||
return state.active
|
return state.active
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { useServer } from "./server"
|
||||||
|
import { useServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
|
export const { use: useServers, provider: ServersProvider } = createSimpleContext({
|
||||||
|
name: "Servers",
|
||||||
|
init: () => {
|
||||||
|
const server = useServer()
|
||||||
|
|
||||||
|
const health = useServerHealth(
|
||||||
|
() => server.list,
|
||||||
|
() => true,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: () => server.list,
|
||||||
|
health,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { useServerSync } from "./server-sync"
|
||||||
import { useGlobalSync } from "./global-sync"
|
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
@@ -10,26 +9,8 @@ function sortParts(parts: Part[]) {
|
|||||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
|
||||||
const pending = map.get(key)
|
|
||||||
if (pending) return pending
|
|
||||||
const promise = task().finally(() => {
|
|
||||||
map.delete(key)
|
|
||||||
})
|
|
||||||
map.set(key, promise)
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
|
||||||
const map = new Map(a.map((item) => [item.id, item] as const))
|
|
||||||
for (const item of b) map.set(item.id, item)
|
|
||||||
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
type OptimisticStore = {
|
type OptimisticStore = {
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
@@ -127,40 +108,9 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
|
|||||||
delete draft.part[input.messageID]
|
delete draft.part[input.messageID]
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
|
export const useSync = () => {
|
||||||
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
|
const serverSync = useServerSync()
|
||||||
if (!messages) return [input.message]
|
|
||||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
|
||||||
const next = [...messages]
|
|
||||||
next.splice(result.index, 0, input.message)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
setStore("part", input.message.id, sortParts(input.parts))
|
|
||||||
}
|
|
||||||
|
|
||||||
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
|
|
||||||
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
|
|
||||||
if (!messages) return messages
|
|
||||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
|
||||||
if (!result.found) return messages
|
|
||||||
const next = [...messages]
|
|
||||||
next.splice(result.index, 1)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
setStore("part", (part: Record<string, Part[] | undefined>) => {
|
|
||||||
if (!(input.messageID in part)) return part
|
|
||||||
const next = { ...part }
|
|
||||||
delete next[input.messageID]
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|
||||||
name: "Sync",
|
|
||||||
init: () => {
|
|
||||||
const globalSync = useGlobalSync()
|
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
return globalSync.createDirSyncContext(sdk.directory)
|
return serverSync.createDirSyncContext(sdk.directory)
|
||||||
},
|
}
|
||||||
})
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
|||||||
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
|
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
|
||||||
{ type: "item", label: "Reload Webview", action: "view.reload" },
|
{ type: "item", label: "Reload Webview", action: "view.reload" },
|
||||||
{ type: "item", label: "Restart", action: "app.relaunch" },
|
{ type: "item", label: "Restart", action: "app.relaunch" },
|
||||||
|
{ type: "item", label: "Export Logs...", command: "logs.export" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ type: "item", role: "hide" },
|
{ type: "item", role: "hide" },
|
||||||
{ type: "item", role: "hideOthers" },
|
{ type: "item", role: "hideOthers" },
|
||||||
@@ -201,6 +202,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
|
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
|
||||||
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
|
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
|
||||||
|
{ type: "item", label: "Export Logs...", command: "logs.export" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
|
|||||||
@@ -168,11 +168,7 @@ if (root instanceof HTMLElement) {
|
|||||||
() => (
|
() => (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
<AppBaseProviders>
|
<AppBaseProviders>
|
||||||
<AppInterface
|
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
|
||||||
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
|
|
||||||
servers={[server]}
|
|
||||||
disableHealthCheck
|
|
||||||
/>
|
|
||||||
</AppBaseProviders>
|
</AppBaseProviders>
|
||||||
</PlatformProvider>
|
</PlatformProvider>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { Iterable, pipe } from "effect"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
|
|
||||||
export const popularProviders = [
|
export const popularProviders = [
|
||||||
@@ -16,29 +17,45 @@ export const popularProviders = [
|
|||||||
const popularProviderSet = new Set(popularProviders)
|
const popularProviderSet = new Set(popularProviders)
|
||||||
|
|
||||||
export function useProviders() {
|
export function useProviders() {
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const dir = createMemo(() => decode64(params.dir) ?? "")
|
const dir = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const providers = () => {
|
const providers = () => {
|
||||||
if (dir()) {
|
if (dir()) {
|
||||||
const [projectStore] = globalSync.child(dir())
|
const [projectStore] = serverSync.child(dir())
|
||||||
if (projectStore.provider_ready) return projectStore.provider
|
if (projectStore.provider_ready) return projectStore.provider
|
||||||
}
|
}
|
||||||
return globalSync.data.provider
|
return serverSync.data.provider
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
all: () => providers().all,
|
all: () => providers().all,
|
||||||
default: () => providers().default,
|
default: () => providers().default,
|
||||||
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
|
popular: () =>
|
||||||
|
pipe(
|
||||||
|
providers().all,
|
||||||
|
Iterable.map(([, p]) => p),
|
||||||
|
Iterable.filter((p) => popularProviderSet.has(p.id)),
|
||||||
|
(v) => Array.from(v),
|
||||||
|
),
|
||||||
connected: () => {
|
connected: () => {
|
||||||
const connected = new Set(providers().connected)
|
const connected = new Set(providers().connected)
|
||||||
return providers().all.filter((p) => connected.has(p.id))
|
return pipe(
|
||||||
|
providers().all,
|
||||||
|
Iterable.map(([, p]) => p),
|
||||||
|
Iterable.filter((p) => connected.has(p.id)),
|
||||||
|
(v) => Array.from(v),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
paid: () => {
|
paid: () => {
|
||||||
const connected = new Set(providers().connected)
|
const connected = new Set(providers().connected)
|
||||||
return providers().all.filter(
|
return [
|
||||||
(p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
|
...Iterable.filter(
|
||||||
)
|
providers().all,
|
||||||
|
([id]) =>
|
||||||
|
connected.has(id) &&
|
||||||
|
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
|
||||||
|
),
|
||||||
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ export const dict = {
|
|||||||
"error.page.version": "الإصدار: {{version}}",
|
"error.page.version": "الإصدار: {{version}}",
|
||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
|
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
|
||||||
"error.globalSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
|
"error.serverSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
|
||||||
"directory.error.invalidUrl": "دليل غير صالح في عنوان URL.",
|
"directory.error.invalidUrl": "دليل غير صالح في عنوان URL.",
|
||||||
"error.chain.unknown": "خطأ غير معروف",
|
"error.chain.unknown": "خطأ غير معروف",
|
||||||
"error.chain.causedBy": "بسبب:",
|
"error.chain.causedBy": "بسبب:",
|
||||||
@@ -837,8 +837,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "غير معروف",
|
"common.unknown": "غير معروف",
|
||||||
"error.page.circular": "[دائري]",
|
"error.page.circular": "[دائري]",
|
||||||
"error.globalSDK.noServerAvailable": "لا يوجد خادم متاح",
|
"error.serverSDK.noServerAvailable": "لا يوجد خادم متاح",
|
||||||
"error.globalSDK.serverNotAvailable": "الخادم غير متاح",
|
"error.serverSDK.serverNotAvailable": "الخادم غير متاح",
|
||||||
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
|
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "فشل إنشاء بيانات تعريف المشروع الدائمة",
|
"error.childStore.persistedProjectMetadataCreateFailed": "فشل إنشاء بيانات تعريف المشروع الدائمة",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
|
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ export const dict = {
|
|||||||
"error.page.version": "Versão: {{version}}",
|
"error.page.version": "Versão: {{version}}",
|
||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"Elemento raiz não encontrado. Você esqueceu de adicioná-lo ao seu index.html? Ou talvez o atributo id foi escrito incorretamente?",
|
"Elemento raiz não encontrado. Você esqueceu de adicioná-lo ao seu index.html? Ou talvez o atributo id foi escrito incorretamente?",
|
||||||
"error.globalSync.connectFailed": "Não foi possível conectar ao servidor. Há um servidor executando em `{{url}}`?",
|
"error.serverSync.connectFailed": "Não foi possível conectar ao servidor. Há um servidor executando em `{{url}}`?",
|
||||||
"directory.error.invalidUrl": "Diretório inválido na URL.",
|
"directory.error.invalidUrl": "Diretório inválido na URL.",
|
||||||
"error.chain.unknown": "Erro desconhecido",
|
"error.chain.unknown": "Erro desconhecido",
|
||||||
"error.chain.causedBy": "Causado por:",
|
"error.chain.causedBy": "Causado por:",
|
||||||
@@ -850,8 +850,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "desconhecido",
|
"common.unknown": "desconhecido",
|
||||||
"error.page.circular": "[Circular]",
|
"error.page.circular": "[Circular]",
|
||||||
"error.globalSDK.noServerAvailable": "Nenhum servidor disponível",
|
"error.serverSDK.noServerAvailable": "Nenhum servidor disponível",
|
||||||
"error.globalSDK.serverNotAvailable": "Servidor indisponível",
|
"error.serverSDK.serverNotAvailable": "Servidor indisponível",
|
||||||
"error.childStore.persistedCacheCreateFailed": "Falha ao criar cache persistente",
|
"error.childStore.persistedCacheCreateFailed": "Falha ao criar cache persistente",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "Falha ao criar metadados de projeto persistentes",
|
"error.childStore.persistedProjectMetadataCreateFailed": "Falha ao criar metadados de projeto persistentes",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
|
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ export const dict = {
|
|||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"Korijenski element nije pronađen. Da li si zaboravio da ga dodaš u index.html? Ili je možda id atribut pogrešno napisan?",
|
"Korijenski element nije pronađen. Da li si zaboravio da ga dodaš u index.html? Ili je možda id atribut pogrešno napisan?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "Nije moguće povezati se na server. Da li server radi na `{{url}}`?",
|
"error.serverSync.connectFailed": "Nije moguće povezati se na server. Da li server radi na `{{url}}`?",
|
||||||
"directory.error.invalidUrl": "Nevažeći direktorij u URL-u.",
|
"directory.error.invalidUrl": "Nevažeći direktorij u URL-u.",
|
||||||
|
|
||||||
"error.chain.unknown": "Nepoznata greška",
|
"error.chain.unknown": "Nepoznata greška",
|
||||||
@@ -926,8 +926,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "nepoznato",
|
"common.unknown": "nepoznato",
|
||||||
"error.page.circular": "[Kružno]",
|
"error.page.circular": "[Kružno]",
|
||||||
"error.globalSDK.noServerAvailable": "Nema dostupnog servera",
|
"error.serverSDK.noServerAvailable": "Nema dostupnog servera",
|
||||||
"error.globalSDK.serverNotAvailable": "Server nije dostupan",
|
"error.serverSDK.serverNotAvailable": "Server nije dostupan",
|
||||||
"error.childStore.persistedCacheCreateFailed": "Nije uspjelo kreiranje trajnog keša",
|
"error.childStore.persistedCacheCreateFailed": "Nije uspjelo kreiranje trajnog keša",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "Nije uspjelo kreiranje trajnih metapodataka projekta",
|
"error.childStore.persistedProjectMetadataCreateFailed": "Nije uspjelo kreiranje trajnih metapodataka projekta",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
|
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ export const dict = {
|
|||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"Rodelement ikke fundet. Har du glemt at tilføje det til din index.html? Eller måske er id-attributten stavet forkert?",
|
"Rodelement ikke fundet. Har du glemt at tilføje det til din index.html? Eller måske er id-attributten stavet forkert?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "Kunne ikke forbinde til server. Kører der en server på `{{url}}`?",
|
"error.serverSync.connectFailed": "Kunne ikke forbinde til server. Kører der en server på `{{url}}`?",
|
||||||
"directory.error.invalidUrl": "Ugyldig mappe i URL.",
|
"directory.error.invalidUrl": "Ugyldig mappe i URL.",
|
||||||
|
|
||||||
"error.chain.unknown": "Ukendt fejl",
|
"error.chain.unknown": "Ukendt fejl",
|
||||||
@@ -920,8 +920,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "ukendt",
|
"common.unknown": "ukendt",
|
||||||
"error.page.circular": "[Cirkulær]",
|
"error.page.circular": "[Cirkulær]",
|
||||||
"error.globalSDK.noServerAvailable": "Ingen server tilgængelig",
|
"error.serverSDK.noServerAvailable": "Ingen server tilgængelig",
|
||||||
"error.globalSDK.serverNotAvailable": "Server ikke tilgængelig",
|
"error.serverSDK.serverNotAvailable": "Server ikke tilgængelig",
|
||||||
"error.childStore.persistedCacheCreateFailed": "Kunne ikke oprette vedvarende cache",
|
"error.childStore.persistedCacheCreateFailed": "Kunne ikke oprette vedvarende cache",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke oprette vedvarende projektmetadata",
|
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke oprette vedvarende projektmetadata",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
|
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ export const dict = {
|
|||||||
"error.page.version": "Version: {{version}}",
|
"error.page.version": "Version: {{version}}",
|
||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?",
|
"Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?",
|
||||||
"error.globalSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
|
"error.serverSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
|
||||||
"directory.error.invalidUrl": "Ungültiges Verzeichnis in der URL.",
|
"directory.error.invalidUrl": "Ungültiges Verzeichnis in der URL.",
|
||||||
"error.chain.unknown": "Unbekannter Fehler",
|
"error.chain.unknown": "Unbekannter Fehler",
|
||||||
"error.chain.causedBy": "Verursacht durch:",
|
"error.chain.causedBy": "Verursacht durch:",
|
||||||
@@ -862,8 +862,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Einfg",
|
"common.key.insert": "Einfg",
|
||||||
"common.unknown": "unbekannt",
|
"common.unknown": "unbekannt",
|
||||||
"error.page.circular": "[Zirkulär]",
|
"error.page.circular": "[Zirkulär]",
|
||||||
"error.globalSDK.noServerAvailable": "Kein Server verfügbar",
|
"error.serverSDK.noServerAvailable": "Kein Server verfügbar",
|
||||||
"error.globalSDK.serverNotAvailable": "Server nicht verfügbar",
|
"error.serverSDK.serverNotAvailable": "Server nicht verfügbar",
|
||||||
"error.childStore.persistedCacheCreateFailed": "Dauerhafter Cache konnte nicht erstellt werden",
|
"error.childStore.persistedCacheCreateFailed": "Dauerhafter Cache konnte nicht erstellt werden",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "Dauerhafte Projektmetadaten konnten nicht erstellt werden",
|
"error.childStore.persistedProjectMetadataCreateFailed": "Dauerhafte Projektmetadaten konnten nicht erstellt werden",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "Dauerhaftes Projekticon konnte nicht erstellt werden",
|
"error.childStore.persistedProjectIconCreateFailed": "Dauerhaftes Projekticon konnte nicht erstellt werden",
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ export const dict = {
|
|||||||
"error.page.action.restart": "Restart",
|
"error.page.action.restart": "Restart",
|
||||||
"error.page.action.report": "Report Error",
|
"error.page.action.report": "Report Error",
|
||||||
"error.page.action.reported": "Error Reported",
|
"error.page.action.reported": "Error Reported",
|
||||||
|
"error.page.action.exportLogs": "Export Logs",
|
||||||
"error.page.action.checking": "Checking...",
|
"error.page.action.checking": "Checking...",
|
||||||
"error.page.action.checkUpdates": "Check for updates",
|
"error.page.action.checkUpdates": "Check for updates",
|
||||||
"error.page.action.updateTo": "Update to {{version}}",
|
"error.page.action.updateTo": "Update to {{version}}",
|
||||||
@@ -481,9 +482,9 @@ export const dict = {
|
|||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
|
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
|
"error.serverSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
|
||||||
"error.globalSDK.noServerAvailable": "No server available",
|
"error.serverSDK.noServerAvailable": "No server available",
|
||||||
"error.globalSDK.serverNotAvailable": "Server not available",
|
"error.serverSDK.serverNotAvailable": "Server not available",
|
||||||
"error.childStore.persistedCacheCreateFailed": "Failed to create persisted cache",
|
"error.childStore.persistedCacheCreateFailed": "Failed to create persisted cache",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "Failed to create persisted project metadata",
|
"error.childStore.persistedProjectMetadataCreateFailed": "Failed to create persisted project metadata",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "Failed to create persisted project icon",
|
"error.childStore.persistedProjectIconCreateFailed": "Failed to create persisted project icon",
|
||||||
@@ -525,6 +526,14 @@ export const dict = {
|
|||||||
"home.recentProjects": "Recent projects",
|
"home.recentProjects": "Recent projects",
|
||||||
"home.empty.title": "No recent projects",
|
"home.empty.title": "No recent projects",
|
||||||
"home.empty.description": "Get started by opening a local project",
|
"home.empty.description": "Get started by opening a local project",
|
||||||
|
"home.title": "Home",
|
||||||
|
"home.projects": "Projects",
|
||||||
|
"home.project.add": "Add project",
|
||||||
|
"home.sessions.search.placeholder": "Search sessions",
|
||||||
|
"home.sessions.empty": "No sessions found",
|
||||||
|
"home.sessions.group.today": "Today",
|
||||||
|
"home.sessions.group.yesterday": "Yesterday",
|
||||||
|
"home.sessions.group.older": "Older",
|
||||||
|
|
||||||
"session.tab.session": "Session",
|
"session.tab.session": "Session",
|
||||||
"session.tab.review": "Review",
|
"session.tab.review": "Review",
|
||||||
@@ -775,6 +784,8 @@ export const dict = {
|
|||||||
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
|
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
|
||||||
"settings.general.row.showSessionProgressBar.description":
|
"settings.general.row.showSessionProgressBar.description":
|
||||||
"Display the animated progress bar at the top of the session when the agent is working",
|
"Display the animated progress bar at the top of the session when the agent is working",
|
||||||
|
"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.wayland.title": "Use native Wayland",
|
"settings.general.row.wayland.title": "Use native Wayland",
|
||||||
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
|
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ export const dict = {
|
|||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"Elemento raíz no encontrado. ¿Olvidaste añadirlo a tu index.html? ¿O tal vez el atributo id está mal escrito?",
|
"Elemento raíz no encontrado. ¿Olvidaste añadirlo a tu index.html? ¿O tal vez el atributo id está mal escrito?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "No se pudo conectar al servidor. ¿Hay un servidor ejecutándose en `{{url}}`?",
|
"error.serverSync.connectFailed": "No se pudo conectar al servidor. ¿Hay un servidor ejecutándose en `{{url}}`?",
|
||||||
"directory.error.invalidUrl": "URL de directorio inválida.",
|
"directory.error.invalidUrl": "URL de directorio inválida.",
|
||||||
|
|
||||||
"error.chain.unknown": "Error desconocido",
|
"error.chain.unknown": "Error desconocido",
|
||||||
@@ -933,8 +933,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "desconocido",
|
"common.unknown": "desconocido",
|
||||||
"error.page.circular": "[Circular]",
|
"error.page.circular": "[Circular]",
|
||||||
"error.globalSDK.noServerAvailable": "Ningún servidor disponible",
|
"error.serverSDK.noServerAvailable": "Ningún servidor disponible",
|
||||||
"error.globalSDK.serverNotAvailable": "Servidor no disponible",
|
"error.serverSDK.serverNotAvailable": "Servidor no disponible",
|
||||||
"error.childStore.persistedCacheCreateFailed": "Error al crear caché persistente",
|
"error.childStore.persistedCacheCreateFailed": "Error al crear caché persistente",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "Error al crear metadatos de proyecto persistentes",
|
"error.childStore.persistedProjectMetadataCreateFailed": "Error al crear metadatos de proyecto persistentes",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente",
|
"error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente",
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ export const dict = {
|
|||||||
"error.page.version": "Version : {{version}}",
|
"error.page.version": "Version : {{version}}",
|
||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"Élément racine introuvable. Avez-vous oublié de l'ajouter à votre index.html ? Ou peut-être que l'attribut id est mal orthographié ?",
|
"Élément racine introuvable. Avez-vous oublié de l'ajouter à votre index.html ? Ou peut-être que l'attribut id est mal orthographié ?",
|
||||||
"error.globalSync.connectFailed":
|
"error.serverSync.connectFailed":
|
||||||
"Impossible de se connecter au serveur. Y a-t-il un serveur en cours d'exécution à `{{url}}` ?",
|
"Impossible de se connecter au serveur. Y a-t-il un serveur en cours d'exécution à `{{url}}` ?",
|
||||||
"directory.error.invalidUrl": "Répertoire invalide dans l'URL.",
|
"directory.error.invalidUrl": "Répertoire invalide dans l'URL.",
|
||||||
"error.chain.unknown": "Erreur inconnue",
|
"error.chain.unknown": "Erreur inconnue",
|
||||||
@@ -860,8 +860,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Inser",
|
"common.key.insert": "Inser",
|
||||||
"common.unknown": "inconnu",
|
"common.unknown": "inconnu",
|
||||||
"error.page.circular": "[Circulaire]",
|
"error.page.circular": "[Circulaire]",
|
||||||
"error.globalSDK.noServerAvailable": "Aucun serveur disponible",
|
"error.serverSDK.noServerAvailable": "Aucun serveur disponible",
|
||||||
"error.globalSDK.serverNotAvailable": "Serveur non disponible",
|
"error.serverSDK.serverNotAvailable": "Serveur non disponible",
|
||||||
"error.childStore.persistedCacheCreateFailed": "Échec de la création du cache persistant",
|
"error.childStore.persistedCacheCreateFailed": "Échec de la création du cache persistant",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed":
|
"error.childStore.persistedProjectMetadataCreateFailed":
|
||||||
"Échec de la création des métadonnées de projet persistantes",
|
"Échec de la création des métadonnées de projet persistantes",
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ export const dict = {
|
|||||||
"error.page.version": "バージョン: {{version}}",
|
"error.page.version": "バージョン: {{version}}",
|
||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"ルート要素が見つかりません。index.htmlに追加するのを忘れていませんか?またはid属性のスペルが間違っていませんか?",
|
"ルート要素が見つかりません。index.htmlに追加するのを忘れていませんか?またはid属性のスペルが間違っていませんか?",
|
||||||
"error.globalSync.connectFailed": "サーバーに接続できませんでした。`{{url}}`でサーバーが実行されていますか?",
|
"error.serverSync.connectFailed": "サーバーに接続できませんでした。`{{url}}`でサーバーが実行されていますか?",
|
||||||
"directory.error.invalidUrl": "URL内のディレクトリが無効です。",
|
"directory.error.invalidUrl": "URL内のディレクトリが無効です。",
|
||||||
"error.chain.unknown": "不明なエラー",
|
"error.chain.unknown": "不明なエラー",
|
||||||
"error.chain.causedBy": "原因:",
|
"error.chain.causedBy": "原因:",
|
||||||
@@ -844,8 +844,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "不明",
|
"common.unknown": "不明",
|
||||||
"error.page.circular": "[循環]",
|
"error.page.circular": "[循環]",
|
||||||
"error.globalSDK.noServerAvailable": "利用可能なサーバーがありません",
|
"error.serverSDK.noServerAvailable": "利用可能なサーバーがありません",
|
||||||
"error.globalSDK.serverNotAvailable": "サーバーが利用できません",
|
"error.serverSDK.serverNotAvailable": "サーバーが利用できません",
|
||||||
"error.childStore.persistedCacheCreateFailed": "永続キャッシュの作成に失敗しました",
|
"error.childStore.persistedCacheCreateFailed": "永続キャッシュの作成に失敗しました",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "永続プロジェクトメタデータの作成に失敗しました",
|
"error.childStore.persistedProjectMetadataCreateFailed": "永続プロジェクトメタデータの作成に失敗しました",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "永続プロジェクトアイコンの作成に失敗しました",
|
"error.childStore.persistedProjectIconCreateFailed": "永続プロジェクトアイコンの作成に失敗しました",
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ export const dict = {
|
|||||||
"error.page.version": "버전: {{version}}",
|
"error.page.version": "버전: {{version}}",
|
||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"루트 요소를 찾을 수 없습니다. index.html에 추가하는 것을 잊으셨나요? 또는 id 속성의 철자가 틀렸을 수 있습니다.",
|
"루트 요소를 찾을 수 없습니다. index.html에 추가하는 것을 잊으셨나요? 또는 id 속성의 철자가 틀렸을 수 있습니다.",
|
||||||
"error.globalSync.connectFailed": "서버에 연결할 수 없습니다. `{{url}}`에서 서버가 실행 중인가요?",
|
"error.serverSync.connectFailed": "서버에 연결할 수 없습니다. `{{url}}`에서 서버가 실행 중인가요?",
|
||||||
"directory.error.invalidUrl": "URL에 유효하지 않은 디렉터리가 있습니다.",
|
"directory.error.invalidUrl": "URL에 유효하지 않은 디렉터리가 있습니다.",
|
||||||
"error.chain.unknown": "알 수 없는 오류",
|
"error.chain.unknown": "알 수 없는 오류",
|
||||||
"error.chain.causedBy": "원인:",
|
"error.chain.causedBy": "원인:",
|
||||||
@@ -839,8 +839,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "알 수 없음",
|
"common.unknown": "알 수 없음",
|
||||||
"error.page.circular": "[순환]",
|
"error.page.circular": "[순환]",
|
||||||
"error.globalSDK.noServerAvailable": "사용 가능한 서버 없음",
|
"error.serverSDK.noServerAvailable": "사용 가능한 서버 없음",
|
||||||
"error.globalSDK.serverNotAvailable": "서버를 사용할 수 없음",
|
"error.serverSDK.serverNotAvailable": "서버를 사용할 수 없음",
|
||||||
"error.childStore.persistedCacheCreateFailed": "영구 캐시 생성 실패",
|
"error.childStore.persistedCacheCreateFailed": "영구 캐시 생성 실패",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "영구 프로젝트 메타데이터 생성 실패",
|
"error.childStore.persistedProjectMetadataCreateFailed": "영구 프로젝트 메타데이터 생성 실패",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패",
|
"error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패",
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ export const dict = {
|
|||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"Rotelement ikke funnet. Glemte du å legge det til i index.html? Eller kanskje id-attributten er feilstavet?",
|
"Rotelement ikke funnet. Glemte du å legge det til i index.html? Eller kanskje id-attributten er feilstavet?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "Kunne ikke koble til server. Kjører det en server på `{{url}}`?",
|
"error.serverSync.connectFailed": "Kunne ikke koble til server. Kjører det en server på `{{url}}`?",
|
||||||
"directory.error.invalidUrl": "Invalid directory in URL.",
|
"directory.error.invalidUrl": "Invalid directory in URL.",
|
||||||
|
|
||||||
"error.chain.unknown": "Ukjent feil",
|
"error.chain.unknown": "Ukjent feil",
|
||||||
@@ -927,8 +927,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "ukjent",
|
"common.unknown": "ukjent",
|
||||||
"error.page.circular": "[Sirkulær]",
|
"error.page.circular": "[Sirkulær]",
|
||||||
"error.globalSDK.noServerAvailable": "Ingen server tilgjengelig",
|
"error.serverSDK.noServerAvailable": "Ingen server tilgjengelig",
|
||||||
"error.globalSDK.serverNotAvailable": "Server ikke tilgjengelig",
|
"error.serverSDK.serverNotAvailable": "Server ikke tilgjengelig",
|
||||||
"error.childStore.persistedCacheCreateFailed": "Kunne ikke opprette vedvarende hurtigbuffer",
|
"error.childStore.persistedCacheCreateFailed": "Kunne ikke opprette vedvarende hurtigbuffer",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke opprette vedvarende prosjektmetadata",
|
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke opprette vedvarende prosjektmetadata",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke opprette vedvarende prosjektikon",
|
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke opprette vedvarende prosjektikon",
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ export const dict = {
|
|||||||
"error.page.version": "Wersja: {{version}}",
|
"error.page.version": "Wersja: {{version}}",
|
||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"Nie znaleziono elementu głównego. Czy zapomniałeś dodać go do swojego index.html? A może atrybut id został błędnie wpisany?",
|
"Nie znaleziono elementu głównego. Czy zapomniałeś dodać go do swojego index.html? A może atrybut id został błędnie wpisany?",
|
||||||
"error.globalSync.connectFailed": "Nie można połączyć się z serwerem. Czy serwer działa pod adresem `{{url}}`?",
|
"error.serverSync.connectFailed": "Nie można połączyć się z serwerem. Czy serwer działa pod adresem `{{url}}`?",
|
||||||
"directory.error.invalidUrl": "Nieprawidłowy katalog w URL.",
|
"directory.error.invalidUrl": "Nieprawidłowy katalog w URL.",
|
||||||
"error.chain.unknown": "Nieznany błąd",
|
"error.chain.unknown": "Nieznany błąd",
|
||||||
"error.chain.causedBy": "Spowodowany przez:",
|
"error.chain.causedBy": "Spowodowany przez:",
|
||||||
@@ -848,8 +848,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "nieznany",
|
"common.unknown": "nieznany",
|
||||||
"error.page.circular": "[Cykliczne]",
|
"error.page.circular": "[Cykliczne]",
|
||||||
"error.globalSDK.noServerAvailable": "Brak dostępnego serwera",
|
"error.serverSDK.noServerAvailable": "Brak dostępnego serwera",
|
||||||
"error.globalSDK.serverNotAvailable": "Serwer niedostępny",
|
"error.serverSDK.serverNotAvailable": "Serwer niedostępny",
|
||||||
"error.childStore.persistedCacheCreateFailed": "Nie udało się utworzyć trwałej pamięci podręcznej",
|
"error.childStore.persistedCacheCreateFailed": "Nie udało się utworzyć trwałej pamięci podręcznej",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "Nie udało się utworzyć trwałych metadanych projektu",
|
"error.childStore.persistedProjectMetadataCreateFailed": "Nie udało się utworzyć trwałych metadanych projektu",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu",
|
"error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu",
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ export const dict = {
|
|||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"Корневой элемент не найден. Вы забыли добавить его в index.html? Или, может быть, атрибут id был написан неправильно?",
|
"Корневой элемент не найден. Вы забыли добавить его в index.html? Или, может быть, атрибут id был написан неправильно?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "Не удалось подключиться к серверу. Запущен ли сервер по адресу `{{url}}`?",
|
"error.serverSync.connectFailed": "Не удалось подключиться к серверу. Запущен ли сервер по адресу `{{url}}`?",
|
||||||
"directory.error.invalidUrl": "Недопустимая директория в URL.",
|
"directory.error.invalidUrl": "Недопустимая директория в URL.",
|
||||||
|
|
||||||
"error.chain.unknown": "Неизвестная ошибка",
|
"error.chain.unknown": "Неизвестная ошибка",
|
||||||
@@ -929,8 +929,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "неизвестно",
|
"common.unknown": "неизвестно",
|
||||||
"error.page.circular": "[Циклично]",
|
"error.page.circular": "[Циклично]",
|
||||||
"error.globalSDK.noServerAvailable": "Нет доступного сервера",
|
"error.serverSDK.noServerAvailable": "Нет доступного сервера",
|
||||||
"error.globalSDK.serverNotAvailable": "Сервер недоступен",
|
"error.serverSDK.serverNotAvailable": "Сервер недоступен",
|
||||||
"error.childStore.persistedCacheCreateFailed": "Не удалось создать постоянный кэш",
|
"error.childStore.persistedCacheCreateFailed": "Не удалось создать постоянный кэш",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "Не удалось создать постоянные метаданные проекта",
|
"error.childStore.persistedProjectMetadataCreateFailed": "Не удалось создать постоянные метаданные проекта",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта",
|
"error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта",
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ export const dict = {
|
|||||||
|
|
||||||
"error.dev.rootNotFound": "ไม่พบองค์ประกอบรูท คุณลืมเพิ่มใน index.html หรือบางทีแอตทริบิวต์ id อาจสะกดผิด?",
|
"error.dev.rootNotFound": "ไม่พบองค์ประกอบรูท คุณลืมเพิ่มใน index.html หรือบางทีแอตทริบิวต์ id อาจสะกดผิด?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ มีเซิร์ฟเวอร์ทำงานอยู่ที่ `{{url}}` หรือไม่?",
|
"error.serverSync.connectFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ มีเซิร์ฟเวอร์ทำงานอยู่ที่ `{{url}}` หรือไม่?",
|
||||||
"directory.error.invalidUrl": "ไดเรกทอรีใน URL ไม่ถูกต้อง",
|
"directory.error.invalidUrl": "ไดเรกทอรีใน URL ไม่ถูกต้อง",
|
||||||
|
|
||||||
"error.chain.unknown": "ข้อผิดพลาดที่ไม่รู้จัก",
|
"error.chain.unknown": "ข้อผิดพลาดที่ไม่รู้จัก",
|
||||||
@@ -916,8 +916,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "ไม่ทราบ",
|
"common.unknown": "ไม่ทราบ",
|
||||||
"error.page.circular": "[วงกลม]",
|
"error.page.circular": "[วงกลม]",
|
||||||
"error.globalSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์",
|
"error.serverSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์",
|
||||||
"error.globalSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน",
|
"error.serverSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน",
|
||||||
"error.childStore.persistedCacheCreateFailed": "ไม่สามารถสร้างแคชถาวร",
|
"error.childStore.persistedCacheCreateFailed": "ไม่สามารถสร้างแคชถาวร",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "ไม่สามารถสร้างเมตาดาต้าโปรเจกต์ถาวร",
|
"error.childStore.persistedProjectMetadataCreateFailed": "ไม่สามารถสร้างเมตาดาต้าโปรเจกต์ถาวร",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร",
|
"error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร",
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ export const dict = {
|
|||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"Kök eleman bulunamadı. index.html dosyanıza eklemeyi unuttunuz mu? Ya da id özelliği yanlış mı yazıldı?",
|
"Kök eleman bulunamadı. index.html dosyanıza eklemeyi unuttunuz mu? Ya da id özelliği yanlış mı yazıldı?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?",
|
"error.serverSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?",
|
||||||
"directory.error.invalidUrl": "URL'de geçersiz dizin.",
|
"directory.error.invalidUrl": "URL'de geçersiz dizin.",
|
||||||
|
|
||||||
"error.chain.unknown": "Bilinmeyen hata",
|
"error.chain.unknown": "Bilinmeyen hata",
|
||||||
@@ -935,8 +935,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "bilinmiyor",
|
"common.unknown": "bilinmiyor",
|
||||||
"error.page.circular": "[Döngüsel]",
|
"error.page.circular": "[Döngüsel]",
|
||||||
"error.globalSDK.noServerAvailable": "Sunucu yok",
|
"error.serverSDK.noServerAvailable": "Sunucu yok",
|
||||||
"error.globalSDK.serverNotAvailable": "Sunucu mevcut değil",
|
"error.serverSDK.serverNotAvailable": "Sunucu mevcut değil",
|
||||||
"error.childStore.persistedCacheCreateFailed": "Kalıcı önbellek oluşturulamadı",
|
"error.childStore.persistedCacheCreateFailed": "Kalıcı önbellek oluşturulamadı",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "Kalıcı proje meta verileri oluşturulamadı",
|
"error.childStore.persistedProjectMetadataCreateFailed": "Kalıcı proje meta verileri oluşturulamadı",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı",
|
"error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı",
|
||||||
|
|||||||
@@ -483,9 +483,9 @@ export const dict = {
|
|||||||
"error.dev.rootNotFound":
|
"error.dev.rootNotFound":
|
||||||
"Кореневий елемент не знайдено. Ви забули додати його до index.html? Або, можливо, атрибут id було написано з помилкою?",
|
"Кореневий елемент не знайдено. Ви забули додати його до index.html? Або, можливо, атрибут id було написано з помилкою?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "Не вдалося підключитися до сервера. Чи працює сервер за адресою `{{url}}`?",
|
"error.serverSync.connectFailed": "Не вдалося підключитися до сервера. Чи працює сервер за адресою `{{url}}`?",
|
||||||
"error.globalSDK.noServerAvailable": "Сервер недоступний",
|
"error.serverSDK.noServerAvailable": "Сервер недоступний",
|
||||||
"error.globalSDK.serverNotAvailable": "Сервер недоступний",
|
"error.serverSDK.serverNotAvailable": "Сервер недоступний",
|
||||||
"error.childStore.persistedCacheCreateFailed": "Не вдалося створити постійний кеш",
|
"error.childStore.persistedCacheCreateFailed": "Не вдалося створити постійний кеш",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "Не вдалося створити постійні метадані проєкту",
|
"error.childStore.persistedProjectMetadataCreateFailed": "Не вдалося створити постійні метадані проєкту",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "Не вдалося створити постійну іконку проєкту",
|
"error.childStore.persistedProjectIconCreateFailed": "Не вдалося створити постійну іконку проєкту",
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ export const dict = {
|
|||||||
"error.page.report.discord": "在 Discord 上",
|
"error.page.report.discord": "在 Discord 上",
|
||||||
"error.page.version": "版本:{{version}}",
|
"error.page.version": "版本:{{version}}",
|
||||||
"error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?",
|
"error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?",
|
||||||
"error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
|
"error.serverSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
|
||||||
|
|
||||||
"directory.error.invalidUrl": "URL 中的目录无效。",
|
"directory.error.invalidUrl": "URL 中的目录无效。",
|
||||||
|
|
||||||
@@ -498,6 +498,14 @@ export const dict = {
|
|||||||
"home.recentProjects": "最近项目",
|
"home.recentProjects": "最近项目",
|
||||||
"home.empty.title": "没有最近项目",
|
"home.empty.title": "没有最近项目",
|
||||||
"home.empty.description": "通过打开本地项目开始使用",
|
"home.empty.description": "通过打开本地项目开始使用",
|
||||||
|
"home.title": "主页",
|
||||||
|
"home.projects": "项目",
|
||||||
|
"home.project.add": "添加项目",
|
||||||
|
"home.sessions.search.placeholder": "搜索会话",
|
||||||
|
"home.sessions.empty": "未找到会话",
|
||||||
|
"home.sessions.group.today": "今天",
|
||||||
|
"home.sessions.group.yesterday": "昨天",
|
||||||
|
"home.sessions.group.older": "更早",
|
||||||
|
|
||||||
"session.tab.session": "会话",
|
"session.tab.session": "会话",
|
||||||
"session.tab.review": "审查",
|
"session.tab.review": "审查",
|
||||||
@@ -911,8 +919,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "未知",
|
"common.unknown": "未知",
|
||||||
"error.page.circular": "[循环]",
|
"error.page.circular": "[循环]",
|
||||||
"error.globalSDK.noServerAvailable": "无可用服务器",
|
"error.serverSDK.noServerAvailable": "无可用服务器",
|
||||||
"error.globalSDK.serverNotAvailable": "服务器不可用",
|
"error.serverSDK.serverNotAvailable": "服务器不可用",
|
||||||
"error.childStore.persistedCacheCreateFailed": "创建持久化缓存失败",
|
"error.childStore.persistedCacheCreateFailed": "创建持久化缓存失败",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "创建持久化项目元数据失败",
|
"error.childStore.persistedProjectMetadataCreateFailed": "创建持久化项目元数据失败",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "创建持久化项目图标失败",
|
"error.childStore.persistedProjectIconCreateFailed": "创建持久化项目图标失败",
|
||||||
|
|||||||
@@ -457,7 +457,7 @@ export const dict = {
|
|||||||
|
|
||||||
"error.dev.rootNotFound": "找不到根元素。你是不是忘了把它新增到 index.html? 或者 id 屬性拼錯了?",
|
"error.dev.rootNotFound": "找不到根元素。你是不是忘了把它新增到 index.html? 或者 id 屬性拼錯了?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?",
|
"error.serverSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?",
|
||||||
"directory.error.invalidUrl": "URL 中的目錄無效。",
|
"directory.error.invalidUrl": "URL 中的目錄無效。",
|
||||||
|
|
||||||
"error.chain.unknown": "未知錯誤",
|
"error.chain.unknown": "未知錯誤",
|
||||||
@@ -907,8 +907,8 @@ export const dict = {
|
|||||||
"common.key.insert": "Insert",
|
"common.key.insert": "Insert",
|
||||||
"common.unknown": "未知",
|
"common.unknown": "未知",
|
||||||
"error.page.circular": "[循環]",
|
"error.page.circular": "[循環]",
|
||||||
"error.globalSDK.noServerAvailable": "無可用的伺服器",
|
"error.serverSDK.noServerAvailable": "無可用的伺服器",
|
||||||
"error.globalSDK.serverNotAvailable": "伺服器無法使用",
|
"error.serverSDK.serverNotAvailable": "伺服器無法使用",
|
||||||
"error.childStore.persistedCacheCreateFailed": "建立持續性快取失敗",
|
"error.childStore.persistedCacheCreateFailed": "建立持續性快取失敗",
|
||||||
"error.childStore.persistedProjectMetadataCreateFailed": "建立持續性專案中繼資料失敗",
|
"error.childStore.persistedProjectMetadataCreateFailed": "建立持續性專案中繼資料失敗",
|
||||||
"error.childStore.persistedProjectIconCreateFailed": "建立持續性專案圖示失敗",
|
"error.childStore.persistedProjectIconCreateFailed": "建立持續性專案圖示失敗",
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter";
|
||||||
|
src: url("/assets/Inter.ttf") format("truetype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
@keyframes session-progress-whip {
|
@keyframes session-progress-whip {
|
||||||
0% {
|
0% {
|
||||||
@@ -43,9 +50,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--session-progress-color);
|
|
||||||
clip-path: inset(0 100% 0 0 round 999px);
|
clip-path: inset(0 100% 0 0 round 999px);
|
||||||
animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
|
|
||||||
will-change: clip-path;
|
will-change: clip-path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ export { AppBaseProviders, AppInterface } from "./app"
|
|||||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||||
export { useCommand } from "./context/command"
|
export { useCommand } from "./context/command"
|
||||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||||
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
|
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
|
||||||
export { ServerConnection } from "./context/server"
|
export { ServerConnection } from "./context/server"
|
||||||
export { handleNotificationClick } from "./utils/notification-click"
|
export { handleNotificationClick } from "./utils/notification-click"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createEffect, createMemo, createResource, type ParentProps, Show } from
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { LocalProvider } from "@/context/local"
|
import { LocalProvider } from "@/context/local"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
import { SyncProvider, useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
|
||||||
@@ -81,10 +81,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
return (
|
return (
|
||||||
<Show when={resolved()} keyed>
|
<Show when={resolved()} keyed>
|
||||||
{(resolved) => (
|
{(resolved) => (
|
||||||
<SDKProvider directory={() => resolved}>
|
<SDKProvider directory={resolved}>
|
||||||
<SyncProvider>
|
|
||||||
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
|
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
|
||||||
</SyncProvider>
|
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import * as Sentry from "@sentry/solid"
|
import * as Sentry from "@sentry/solid"
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
import { Logo } from "@opencode-ai/ui/logo"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Component, createSignal, Show } from "solid-js"
|
import { Component, createSignal, onMount, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -221,12 +221,30 @@ interface ErrorPageProps {
|
|||||||
export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const formattedError = () => formatError(props.error, language.t)
|
||||||
|
let recordedFatalError: Promise<void> | undefined
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
checking: false,
|
checking: false,
|
||||||
version: undefined as string | undefined,
|
version: undefined as string | undefined,
|
||||||
actionError: undefined as string | undefined,
|
actionError: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function ensureFatalErrorRecorded() {
|
||||||
|
recordedFatalError ??=
|
||||||
|
platform.recordFatalRendererError?.({
|
||||||
|
error: formattedError(),
|
||||||
|
url: location.href,
|
||||||
|
version: platform.version,
|
||||||
|
platform: platform.platform,
|
||||||
|
os: platform.os,
|
||||||
|
}) ?? Promise.resolve()
|
||||||
|
return recordedFatalError
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void ensureFatalErrorRecorded().catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
if (!platform.checkUpdate) return
|
if (!platform.checkUpdate) return
|
||||||
setStore("checking", true)
|
setStore("checking", true)
|
||||||
@@ -254,6 +272,17 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportDebugLogs() {
|
||||||
|
const exportLogs = platform.exportDebugLogs
|
||||||
|
if (!exportLogs) return
|
||||||
|
await ensureFatalErrorRecorded()
|
||||||
|
.then(() => exportLogs())
|
||||||
|
.then(() => setStore("actionError", undefined))
|
||||||
|
.catch((err) => {
|
||||||
|
setStore("actionError", formatError(err, language.t))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
|
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
|
||||||
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
|
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
|
||||||
@@ -263,7 +292,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
<p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
|
<p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
value={formatError(props.error, language.t)}
|
value={formattedError()}
|
||||||
readOnly
|
readOnly
|
||||||
copyable
|
copyable
|
||||||
multiline
|
multiline
|
||||||
@@ -275,6 +304,11 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
<Button size="large" onClick={platform.restart}>
|
<Button size="large" onClick={platform.restart}>
|
||||||
{language.t("error.page.action.restart")}
|
{language.t("error.page.action.restart")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Show when={platform.platform === "desktop" && platform.exportDebugLogs}>
|
||||||
|
<Button size="large" variant="ghost" onClick={exportDebugLogs}>
|
||||||
|
{language.t("error.page.action.exportLogs")}
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
<Show when={Sentry.isEnabled}>
|
<Show when={Sentry.isEnabled}>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
const [reported, setReported] = createSignal(false)
|
const [reported, setReported] = createSignal(false)
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { createMemo, For, Match, Switch } from "solid-js"
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
import { Logo } from "@opencode-ai/ui/logo"
|
||||||
import { useLayout } from "@/context/layout"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
|
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/components/button-v2.jsx"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||||
|
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -10,18 +18,488 @@ import { DateTime } from "luxon"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||||
import { useServer } from "@/context/server"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useNotification } from "@/context/notification"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
|
import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
|
||||||
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
|
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
|
||||||
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
|
import { useServers } from "@/context/servers"
|
||||||
|
|
||||||
|
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
|
const HOME_SESSION_LIMIT = 15
|
||||||
|
const HOME_ROW =
|
||||||
|
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||||
|
const HOME_PROJECT_NAV_ROW = `${HOME_ROW} h-7 gap-2 px-1.5 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap`
|
||||||
|
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
|
||||||
|
|
||||||
|
type HomeSessionRecord = {
|
||||||
|
session: Session
|
||||||
|
project: LocalProject
|
||||||
|
projectName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HomeSessionGroup = {
|
||||||
|
id: "today" | "yesterday" | "older"
|
||||||
|
title: string
|
||||||
|
sessions: HomeSessionRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const sync = useGlobalSync()
|
if (USE_HOME_DESIGN) return <HomeDesign />
|
||||||
|
return <LegacyHome />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeDesign() {
|
||||||
|
const sync = useServerSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
|
||||||
|
|
||||||
|
const projects = createMemo(() => layout.projects.list())
|
||||||
|
const selectedProject = createMemo(
|
||||||
|
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
|
||||||
|
)
|
||||||
|
const projectDirectories = createMemo(() => {
|
||||||
|
const project = selectedProject()
|
||||||
|
if (!project) return []
|
||||||
|
return [project.worktree, ...(project.sandboxes ?? [])]
|
||||||
|
})
|
||||||
|
const search = createMemo(() => state.search.trim())
|
||||||
|
const sessionLoad = useQuery(() => ({
|
||||||
|
queryKey: ["home", "sessions", ...projectDirectories()] as const,
|
||||||
|
queryFn: async () => {
|
||||||
|
await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory)))
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const projectByID = createMemo(
|
||||||
|
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||||
|
)
|
||||||
|
const records = createMemo(() =>
|
||||||
|
[
|
||||||
|
...new Map(
|
||||||
|
projectDirectories()
|
||||||
|
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
|
||||||
|
.map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
|
||||||
|
).values(),
|
||||||
|
]
|
||||||
|
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||||
|
.flatMap((session) => {
|
||||||
|
const project = projectForSession(session, projects(), projectByID())
|
||||||
|
if (!project) return []
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
project,
|
||||||
|
projectName: displayName(project),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((record) => {
|
||||||
|
const value = search().toLowerCase()
|
||||||
|
if (!value) return true
|
||||||
|
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
|
||||||
|
})
|
||||||
|
.slice(0, HOME_SESSION_LIMIT),
|
||||||
|
)
|
||||||
|
const groups = createMemo(() => groupSessions(records(), language))
|
||||||
|
|
||||||
|
function selectProject(directory: string) {
|
||||||
|
if (!projects().some((project) => project.worktree === directory)) return
|
||||||
|
setState("project", directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProject(directory: string) {
|
||||||
|
layout.projects.open(directory)
|
||||||
|
server.projects.touch(directory)
|
||||||
|
setState("project", directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewSession() {
|
||||||
|
const project = selectedProject()
|
||||||
|
if (!project) {
|
||||||
|
void chooseProject()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
layout.projects.open(project.worktree)
|
||||||
|
server.projects.touch(project.worktree)
|
||||||
|
navigate(`/${base64Encode(project.worktree)}/session`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSession(session: Session) {
|
||||||
|
const project = projectForSession(session, projects(), projectByID())
|
||||||
|
layout.projects.open(project?.worktree ?? session.directory)
|
||||||
|
server.projects.touch(project?.worktree ?? session.directory)
|
||||||
|
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseProject() {
|
||||||
|
function resolve(result: string | string[] | null) {
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
result.forEach(addProject)
|
||||||
|
if (result[0]) setState("project", result[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result) addProject(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||||
|
const result = await platform.openDirectoryPickerDialog?.({
|
||||||
|
title: language.t("command.project.open"),
|
||||||
|
multiple: true,
|
||||||
|
})
|
||||||
|
resolve(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.show(
|
||||||
|
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
||||||
|
() => resolve(null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
void import("@/components/dialog-settings").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSettings />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
|
||||||
|
<HomeProjectColumn
|
||||||
|
selectedProject={state.project}
|
||||||
|
selectProject={selectProject}
|
||||||
|
chooseProject={() => void chooseProject()}
|
||||||
|
openSettings={openSettings}
|
||||||
|
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||||
|
language={language}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="min-w-0 flex-1 flex flex-col overflow-y-hidden pt-12"
|
||||||
|
aria-label={language.t("sidebar.project.recentSessions")}
|
||||||
|
>
|
||||||
|
<HomeSessionSearch
|
||||||
|
value={state.search}
|
||||||
|
placeholder={language.t("home.sessions.search.placeholder")}
|
||||||
|
onInput={(value) => setState("search", value)}
|
||||||
|
/>
|
||||||
|
<div class="mt-3 overflow-auto flex-1">
|
||||||
|
<div class="pt-3 flex flex-col gap-6">
|
||||||
|
<Show when={!sessionLoad.isLoading} fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}>
|
||||||
|
<Show
|
||||||
|
when={groups().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="flex min-w-0 flex-col gap-4">
|
||||||
|
<HomeSessionGroupHeader title={language.t("home.sessions.empty")} onNewSession={openNewSession} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={groups()}>
|
||||||
|
{(group, index) => (
|
||||||
|
<div class="flex min-w-0 flex-col gap-4">
|
||||||
|
<HomeSessionGroupHeader
|
||||||
|
title={group.title}
|
||||||
|
onNewSession={index() === 0 ? openNewSession : undefined}
|
||||||
|
/>
|
||||||
|
<div class="flex min-w-0 flex-col gap-px">
|
||||||
|
<For each={group.sessions}>
|
||||||
|
{(record) => <HomeSessionRow record={record} openSession={openSession} />}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeProjectColumn(props: {
|
||||||
|
selectedProject?: string
|
||||||
|
selectProject: (directory: string) => void
|
||||||
|
chooseProject: () => void
|
||||||
|
openSettings: () => void
|
||||||
|
openHelp: () => void
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const servers = useServers()
|
||||||
|
const layout = useLayout()
|
||||||
|
const projects = createMemo(() => layout.projects.list())
|
||||||
|
const selectedProject = createMemo(
|
||||||
|
() => projects().find((project) => project.worktree === props.selectedProject) ?? projects()[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
|
||||||
|
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
|
||||||
|
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||||
|
<IconButtonV2
|
||||||
|
data-action="home-add-project"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="large"
|
||||||
|
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||||
|
icon={<IconV2 name="folder-add-left" />}
|
||||||
|
onClick={props.chooseProject}
|
||||||
|
aria-label={props.language.t("home.project.add")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<For
|
||||||
|
each={servers.list()}
|
||||||
|
fallback={
|
||||||
|
<ProjectList
|
||||||
|
projects={projects()}
|
||||||
|
selectedProject={props.selectedProject}
|
||||||
|
onSelectedProjectChange={props.selectProject}
|
||||||
|
onChooseProject={props.chooseProject}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(server) => {
|
||||||
|
const key = ServerConnection.key(server)
|
||||||
|
const healthy = () => !!servers.health[key]?.healthy
|
||||||
|
const [open, setOpen] = createSignal(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="mt-4 max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
|
<div class="relative h-7 group">
|
||||||
|
<button
|
||||||
|
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
|
||||||
|
disabled={!healthy()}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<div class="size-4 flex items-center justify-center">
|
||||||
|
<ServerHealthIndicator health={servers.health[key]} />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1">
|
||||||
|
<span>{server.displayName ?? new URL(server.http.url).host}</span>
|
||||||
|
<Show when={healthy()}>
|
||||||
|
<IconV2
|
||||||
|
name="outline-chevron-down"
|
||||||
|
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
|
||||||
|
data-open={open()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<IconButtonV2
|
||||||
|
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
|
||||||
|
name="out"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when={healthy() && open()}>
|
||||||
|
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
|
||||||
|
<ProjectList
|
||||||
|
projects={projects()}
|
||||||
|
selectedProject={props.selectedProject}
|
||||||
|
onSelectedProjectChange={props.selectProject}
|
||||||
|
onChooseProject={props.chooseProject}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<div class="mt-4 flex min-w-0 flex-col gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||||
|
onClick={props.openSettings}
|
||||||
|
>
|
||||||
|
<IconV2 name="settings-gear" size="small" />
|
||||||
|
<span>{props.language.t("sidebar.settings")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||||
|
onClick={props.openHelp}
|
||||||
|
>
|
||||||
|
<IconV2 name="help" size="small" />
|
||||||
|
<span>{props.language.t("sidebar.help")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeProjectAvatar(props: { project: LocalProject }) {
|
||||||
|
const name = createMemo(() => displayName(props.project))
|
||||||
|
return (
|
||||||
|
<AvatarV2
|
||||||
|
fallback={name()}
|
||||||
|
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||||
|
kind="org"
|
||||||
|
size="small"
|
||||||
|
{...getAvatarColors(props.project.icon?.color)}
|
||||||
|
class="size-4 rounded"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeSessionSearch(props: { value: string; placeholder: string; onInput: (value: string) => void }) {
|
||||||
|
return (
|
||||||
|
<label class="ml-4 flex h-9 w-[calc(100%_-_48px)] sticky top-0 inset-x-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-deep px-3 py-1 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out focus-within:bg-v2-background-bg-base focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]">
|
||||||
|
<IconV2 name="magnifying-glass" size="small" />
|
||||||
|
<input
|
||||||
|
class="min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
|
||||||
|
value={props.value}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
aria-label={props.placeholder}
|
||||||
|
onInput={(event) => props.onInput(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
return (
|
||||||
|
<div class="flex h-7 min-w-0 items-center justify-between px-4">
|
||||||
|
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
||||||
|
<Show when={props.onNewSession}>
|
||||||
|
{(onNewSession) => (
|
||||||
|
<ButtonV2
|
||||||
|
data-action="home-new-session"
|
||||||
|
variant="ghost"
|
||||||
|
size="normal"
|
||||||
|
icon="edit"
|
||||||
|
class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
|
||||||
|
onClick={onNewSession()}
|
||||||
|
>
|
||||||
|
{language.t("command.session.new")}
|
||||||
|
</ButtonV2>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) {
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const notification = useNotification()
|
||||||
|
const permission = usePermission()
|
||||||
|
const [sessionStore] = serverSync.child(props.record.session.directory, { bootstrap: false })
|
||||||
|
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||||
|
const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
|
||||||
|
const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
|
||||||
|
const hasPermissions = createMemo(
|
||||||
|
() =>
|
||||||
|
!!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => {
|
||||||
|
return !permission.autoResponds(item, props.record.session.directory)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const isWorking = createMemo(() => {
|
||||||
|
if (hasPermissions()) return false
|
||||||
|
return sessionStore.session_working(props.record.session.id)
|
||||||
|
})
|
||||||
|
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent))
|
||||||
|
const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-component="home-session-row"
|
||||||
|
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
|
||||||
|
onClick={() => props.openSession(props.record.session)}
|
||||||
|
>
|
||||||
|
<Show when={showStatus()}>
|
||||||
|
<div
|
||||||
|
class="flex size-4 shrink-0 items-center justify-center"
|
||||||
|
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
|
||||||
|
>
|
||||||
|
<Switch>
|
||||||
|
<Match when={isWorking()}>
|
||||||
|
<Spinner class="size-[15px]" />
|
||||||
|
</Match>
|
||||||
|
<Match when={hasPermissions()}>
|
||||||
|
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||||
|
</Match>
|
||||||
|
<Match when={hasError()}>
|
||||||
|
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||||
|
</Match>
|
||||||
|
<Match when={unseenCount() > 0}>
|
||||||
|
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<span
|
||||||
|
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||||
|
>
|
||||||
|
{title()}
|
||||||
|
</span>
|
||||||
|
<Show when={props.record.projectName}>
|
||||||
|
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
|
||||||
|
{props.record.projectName}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeSessionSkeleton(props: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div class="flex min-w-0 flex-col gap-4">
|
||||||
|
<div class="flex h-7 min-w-0 items-center justify-between px-4">
|
||||||
|
<div class={HOME_SECTION_LABEL}>{props.label}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
|
||||||
|
<For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
|
||||||
|
const now = DateTime.local()
|
||||||
|
const yesterday = now.minus({ days: 1 })
|
||||||
|
const todaySessions = records.filter((record) =>
|
||||||
|
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
|
||||||
|
)
|
||||||
|
const yesterdaySessions = records.filter((record) =>
|
||||||
|
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
|
||||||
|
)
|
||||||
|
const olderSessions = records.filter((record) => {
|
||||||
|
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
|
||||||
|
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
|
||||||
|
})
|
||||||
|
const olderTitle =
|
||||||
|
todaySessions.length === 0 && yesterdaySessions.length === 0
|
||||||
|
? language.t("sidebar.project.recentSessions")
|
||||||
|
: language.t("home.sessions.group.older")
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
|
||||||
|
{ id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
|
||||||
|
{ id: "older" as const, title: olderTitle, sessions: olderSessions },
|
||||||
|
].filter((group) => group.sessions.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyHome() {
|
||||||
|
const sync = useServerSync()
|
||||||
|
const layout = useLayout()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const servers = useServers()
|
||||||
|
const server = useServer()
|
||||||
|
const language = useLanguage()
|
||||||
const homedir = createMemo(() => sync.data.path.home)
|
const homedir = createMemo(() => sync.data.path.home)
|
||||||
const recent = createMemo(() => {
|
const recent = createMemo(() => {
|
||||||
return sync.data.project
|
return sync.data.project
|
||||||
@@ -31,7 +509,7 @@ export default function Home() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const serverDotClass = createMemo(() => {
|
const serverDotClass = createMemo(() => {
|
||||||
const healthy = server.healthy()
|
const healthy = servers.health[server.key]?.healthy
|
||||||
if (healthy === true) return "bg-icon-success-base"
|
if (healthy === true) return "bg-icon-success-base"
|
||||||
if (healthy === false) return "bg-icon-critical-base"
|
if (healthy === false) return "bg-icon-critical-base"
|
||||||
return "bg-border-weak-base"
|
return "bg-border-weak-base"
|
||||||
@@ -137,3 +615,49 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProjectList(props: {
|
||||||
|
projects: LocalProject[]
|
||||||
|
selectedProject?: string
|
||||||
|
onSelectedProjectChange?(project: string): void
|
||||||
|
onChooseProject?(): void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={props.projects.length > 0}
|
||||||
|
fallback={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||||
|
onClick={() => props.onChooseProject?.()}
|
||||||
|
>
|
||||||
|
<IconV2 name="folder-add-left" size="small" />
|
||||||
|
<span>{language.t("home.project.add")}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<For each={props.projects}>
|
||||||
|
{(project) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-component="home-project-row"
|
||||||
|
class={HOME_PROJECT_NAV_ROW}
|
||||||
|
classList={{
|
||||||
|
"bg-v2-overlay-simple-overlay-hover": props.selectedProject === project.worktree,
|
||||||
|
}}
|
||||||
|
data-selected={props.selectedProject === project.worktree ? "" : undefined}
|
||||||
|
aria-current={props.selectedProject === project.worktree ? "page" : undefined}
|
||||||
|
onClick={() => props.onSelectedProjectChange?.(project.worktree)}
|
||||||
|
>
|
||||||
|
<HomeProjectAvatar project={project} />
|
||||||
|
<span>{displayName(project)}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+168
-115
@@ -14,8 +14,9 @@ import {
|
|||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
import { useLayout, LocalProject } from "@/context/layout"
|
import { useLayout, LocalProject } from "@/context/layout"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
@@ -34,7 +35,7 @@ import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, close
|
|||||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
|
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||||
import {
|
import {
|
||||||
@@ -61,7 +62,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
|||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { Titlebar } from "@/components/titlebar"
|
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useLanguage, type Locale } from "@/context/language"
|
import { useLanguage, type Locale } from "@/context/language"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
@@ -88,6 +89,8 @@ import {
|
|||||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||||
import { SidebarContent } from "./layout/sidebar-shell"
|
import { SidebarContent } from "./layout/sidebar-shell"
|
||||||
|
|
||||||
|
const USE_NEW_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const [store, setStore, , ready] = persisted(
|
const [store, setStore, , ready] = persisted(
|
||||||
Persist.global("layout.page", ["layout.page.v1"]),
|
Persist.global("layout.page", ["layout.page.v1"]),
|
||||||
@@ -110,8 +113,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
let dialogDead = false
|
let dialogDead = false
|
||||||
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const globalSDK = useGlobalSDK()
|
const serverSDK = useServerSDK()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const layoutReady = createMemo(() => layout.ready())
|
const layoutReady = createMemo(() => layout.ready())
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
@@ -133,7 +136,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (!slug) return { slug, dir: "" }
|
if (!slug) return { slug, dir: "" }
|
||||||
const dir = decode64(slug)
|
const dir = decode64(slug)
|
||||||
if (!dir) return { slug, dir: "" }
|
if (!dir) return { slug, dir: "" }
|
||||||
const store = globalSync.peek(dir, { bootstrap: false })
|
const store = serverSync.peek(dir, { bootstrap: false })
|
||||||
return {
|
return {
|
||||||
slug,
|
slug,
|
||||||
store,
|
store,
|
||||||
@@ -151,7 +154,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const currentDir = createMemo(() => route().dir)
|
const currentDir = createMemo(() => route().dir)
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
autoselect: !initialDirectory,
|
autoselect: !initialDirectory && !USE_NEW_DESIGN,
|
||||||
busyWorkspaces: {} as Record<string, boolean>,
|
busyWorkspaces: {} as Record<string, boolean>,
|
||||||
hoverProject: undefined as string | undefined,
|
hoverProject: undefined as string | undefined,
|
||||||
scrollSessionKey: undefined as string | undefined,
|
scrollSessionKey: undefined as string | undefined,
|
||||||
@@ -162,6 +165,35 @@ export default function Layout(props: ParentProps) {
|
|||||||
peeked: false,
|
peeked: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [update, setUpdate] = createStore({
|
||||||
|
installing: false,
|
||||||
|
})
|
||||||
|
const updateQuery = useQuery(() => ({
|
||||||
|
queryKey: ["desktop", "update"] as const,
|
||||||
|
enabled: () =>
|
||||||
|
!!platform.checkUpdate && !!platform.updateAndRestart && settings.ready() && settings.updates.startup(),
|
||||||
|
queryFn: () => platform.checkUpdate?.() ?? Promise.resolve({ updateAvailable: false, version: undefined }),
|
||||||
|
refetchInterval: (query) => (query.state.data?.updateAvailable ? false : 10 * 60 * 1000),
|
||||||
|
}))
|
||||||
|
const updateVersion = () => {
|
||||||
|
if (!settings.ready()) return
|
||||||
|
if (!settings.updates.startup()) return
|
||||||
|
if (!updateQuery.data?.updateAvailable) return
|
||||||
|
return updateQuery.data.version ?? ""
|
||||||
|
}
|
||||||
|
const installUpdate = () => {
|
||||||
|
if (!platform.updateAndRestart) return
|
||||||
|
setUpdate("installing", true)
|
||||||
|
void platform.updateAndRestart().catch(() => {
|
||||||
|
setUpdate("installing", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const titlebarUpdate: TitlebarUpdate = {
|
||||||
|
version: updateVersion,
|
||||||
|
installing: () => update.installing,
|
||||||
|
install: installUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
const editor = createInlineEditorController()
|
const editor = createInlineEditorController()
|
||||||
const setBusy = (directory: string, value: boolean) => {
|
const setBusy = (directory: string, value: boolean) => {
|
||||||
const key = pathKey(directory)
|
const key = pathKey(directory)
|
||||||
@@ -194,7 +226,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
active: () => state.hoverProject,
|
active: () => state.hoverProject,
|
||||||
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
|
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
|
||||||
onActivate: (directory) => {
|
onActivate: (directory) => {
|
||||||
globalSync.child(directory)
|
serverSync.child(directory)
|
||||||
setState("hoverProject", directory)
|
setState("hoverProject", directory)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -364,58 +396,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
setLocale(next)
|
setLocale(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const useUpdatePolling = () =>
|
|
||||||
onMount(() => {
|
|
||||||
if (!platform.checkUpdate || !platform.updateAndRestart) return
|
|
||||||
|
|
||||||
let toastId: number | undefined
|
|
||||||
let interval: ReturnType<typeof setInterval> | undefined
|
|
||||||
|
|
||||||
const pollUpdate = () =>
|
|
||||||
platform.checkUpdate!().then(({ updateAvailable, version }) => {
|
|
||||||
if (!updateAvailable) return
|
|
||||||
if (toastId !== undefined) return
|
|
||||||
toastId = showToast({
|
|
||||||
persistent: true,
|
|
||||||
icon: "download",
|
|
||||||
title: language.t("toast.update.title"),
|
|
||||||
description: language.t("toast.update.description", { version: version ?? "" }),
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: language.t("toast.update.action.installRestart"),
|
|
||||||
onClick: async () => {
|
|
||||||
await platform.updateAndRestart!()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: language.t("toast.update.action.notYet"),
|
|
||||||
onClick: "dismiss",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!settings.ready()) return
|
|
||||||
|
|
||||||
if (!settings.updates.startup()) {
|
|
||||||
if (interval === undefined) return
|
|
||||||
clearInterval(interval)
|
|
||||||
interval = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interval !== undefined) return
|
|
||||||
void pollUpdate()
|
|
||||||
interval = setInterval(pollUpdate, 10 * 60 * 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (interval === undefined) return
|
|
||||||
clearInterval(interval)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const useSDKNotificationToasts = () =>
|
const useSDKNotificationToasts = () =>
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const toastBySession = new Map<string, number>()
|
const toastBySession = new Map<string, number>()
|
||||||
@@ -430,7 +410,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
alertedAtBySession.delete(sessionKey)
|
alertedAtBySession.delete(sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = globalSDK.event.listen((e) => {
|
const unsub = serverSDK.event.listen((e) => {
|
||||||
if (e.details?.type === "worktree.ready") {
|
if (e.details?.type === "worktree.ready") {
|
||||||
setBusy(e.name, false)
|
setBusy(e.name, false)
|
||||||
WorktreeState.ready(e.name)
|
WorktreeState.ready(e.name)
|
||||||
@@ -464,7 +444,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const props = e.details.properties
|
const props = e.details.properties
|
||||||
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
|
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
|
||||||
|
|
||||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||||
const session = store.session.find((s) => s.id === props.sessionID)
|
const session = store.session.find((s) => s.id === props.sessionID)
|
||||||
const sessionKey = `${directory}:${props.sessionID}`
|
const sessionKey = `${directory}:${props.sessionID}`
|
||||||
|
|
||||||
@@ -527,7 +507,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (!currentDir() || !currentSession) return
|
if (!currentDir() || !currentSession) return
|
||||||
const sessionKey = `${currentDir()}:${currentSession}`
|
const sessionKey = `${currentDir()}:${currentSession}`
|
||||||
dismissSessionAlert(sessionKey)
|
dismissSessionAlert(sessionKey)
|
||||||
const [store] = globalSync.child(currentDir(), { bootstrap: false })
|
const [store] = serverSync.child(currentDir(), { bootstrap: false })
|
||||||
const childSessions = store.session.filter((s) => s.parentID === currentSession)
|
const childSessions = store.session.filter((s) => s.parentID === currentSession)
|
||||||
for (const child of childSessions) {
|
for (const child of childSessions) {
|
||||||
dismissSessionAlert(`${currentDir()}:${child.id}`)
|
dismissSessionAlert(`${currentDir()}:${child.id}`)
|
||||||
@@ -535,7 +515,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
useUpdatePolling()
|
|
||||||
useSDKNotificationToasts()
|
useSDKNotificationToasts()
|
||||||
|
|
||||||
function scrollToSession(sessionId: string, sessionKey: string) {
|
function scrollToSession(sessionId: string, sessionKey: string) {
|
||||||
@@ -566,11 +545,11 @@ export default function Layout(props: ParentProps) {
|
|||||||
const direct = projects.find((p) => pathKey(p.worktree) === key)
|
const direct = projects.find((p) => pathKey(p.worktree) === key)
|
||||||
if (direct) return direct
|
if (direct) return direct
|
||||||
|
|
||||||
const [child] = globalSync.child(directory, { bootstrap: false })
|
const [child] = serverSync.child(directory, { bootstrap: false })
|
||||||
const id = child.project
|
const id = child.project
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
const meta = globalSync.data.project.find((p) => p.id === id)
|
const meta = serverSync.data.project.find((p) => p.id === id)
|
||||||
const root = meta?.worktree
|
const root = meta?.worktree
|
||||||
if (!root) return
|
if (!root) return
|
||||||
|
|
||||||
@@ -661,7 +640,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const result: Session[] = []
|
const result: Session[] = []
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const [dirStore] = globalSync.child(dir, { bootstrap: true })
|
const [dirStore] = serverSync.child(dir, { bootstrap: true })
|
||||||
const dirSessions = sortedRootSessions(dirStore, now)
|
const dirSessions = sortedRootSessions(dirStore, now)
|
||||||
result.push(...dirSessions)
|
result.push(...dirSessions)
|
||||||
}
|
}
|
||||||
@@ -713,7 +692,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
route()
|
route()
|
||||||
globalSDK.url
|
serverSDK.url
|
||||||
|
|
||||||
prefetchToken.value += 1
|
prefetchToken.value += 1
|
||||||
clearSessionPrefetchInflight()
|
clearSessionPrefetchInflight()
|
||||||
@@ -760,13 +739,13 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
||||||
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
|
const [store, setStore] = serverSync.child(directory, { bootstrap: false })
|
||||||
|
|
||||||
return runSessionPrefetch({
|
return runSessionPrefetch({
|
||||||
directory,
|
directory,
|
||||||
sessionID,
|
sessionID,
|
||||||
task: (rev) =>
|
task: (rev) =>
|
||||||
retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
retry(() => serverSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||||
.then((messages) => {
|
.then((messages) => {
|
||||||
if (prefetchToken.value !== token) return
|
if (prefetchToken.value !== token) return
|
||||||
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
|
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
|
||||||
@@ -786,7 +765,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (stale.length > 0) {
|
if (stale.length > 0) {
|
||||||
clearSessionPrefetch(directory, stale)
|
clearSessionPrefetch(directory, stale)
|
||||||
for (const id of stale) {
|
for (const id of stale) {
|
||||||
globalSync.todo.set(id, undefined)
|
serverSync.todo.set(id, undefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,7 +830,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const directory = session.directory
|
const directory = session.directory
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
|
|
||||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||||
const cached = untrack(() => {
|
const cached = untrack(() => {
|
||||||
const info = getSessionPrefetch(directory, session.id)
|
const info = getSessionPrefetch(directory, session.id)
|
||||||
return shouldSkipSessionPrefetch({
|
return shouldSkipSessionPrefetch({
|
||||||
@@ -956,7 +935,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (!target) return
|
if (!target) return
|
||||||
|
|
||||||
// warm up child store to prevent flicker
|
// warm up child store to prevent flicker
|
||||||
globalSync.child(target.worktree)
|
serverSync.child(target.worktree)
|
||||||
void openProject(target.worktree)
|
void openProject(target.worktree)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,7 +944,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const target = projects[index]
|
const target = projects[index]
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
|
||||||
globalSync.child(target.worktree)
|
serverSync.child(target.worktree)
|
||||||
void openProject(target.worktree)
|
void openProject(target.worktree)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,12 +973,12 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function archiveSession(session: Session) {
|
async function archiveSession(session: Session) {
|
||||||
const [store, setStore] = globalSync.child(session.directory)
|
const [store, setStore] = serverSync.child(session.directory)
|
||||||
const sessions = store.session ?? []
|
const sessions = store.session ?? []
|
||||||
const index = sessions.findIndex((s) => s.id === session.id)
|
const index = sessions.findIndex((s) => s.id === session.id)
|
||||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||||
|
|
||||||
await globalSDK.client.session.update({
|
await serverSDK.client.session.update({
|
||||||
directory: session.directory,
|
directory: session.directory,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
time: { archived: Date.now() },
|
time: { archived: Date.now() },
|
||||||
@@ -1049,19 +1028,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
keybind: "mod+alt+arrowdown",
|
keybind: "mod+alt+arrowdown",
|
||||||
onSelect: () => navigateProjectByOffset(1),
|
onSelect: () => navigateProjectByOffset(1),
|
||||||
},
|
},
|
||||||
...Array.from({ length: 9 }, (_, i) => {
|
|
||||||
const index = i
|
|
||||||
const number = index + 1
|
|
||||||
return {
|
|
||||||
id: `project.${number}`,
|
|
||||||
category: language.t("command.category.project"),
|
|
||||||
title: `Open Project {number}`,
|
|
||||||
keybind: `mod+${number}`,
|
|
||||||
disabled: layout.projects.list().length <= index,
|
|
||||||
hidden: true,
|
|
||||||
onSelect: () => navigateToProjectIndex(index),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
id: "provider.connect",
|
id: "provider.connect",
|
||||||
title: language.t("command.provider.connect"),
|
title: language.t("command.provider.connect"),
|
||||||
@@ -1081,6 +1047,18 @@ export default function Layout(props: ParentProps) {
|
|||||||
keybind: "mod+comma",
|
keybind: "mod+comma",
|
||||||
onSelect: () => openSettings(),
|
onSelect: () => openSettings(),
|
||||||
},
|
},
|
||||||
|
...(platform.platform === "desktop" && platform.exportDebugLogs
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "logs.export",
|
||||||
|
title: "Export logs",
|
||||||
|
category: language.t("command.category.settings"),
|
||||||
|
onSelect: () => {
|
||||||
|
void platform.exportDebugLogs?.()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: "session.previous",
|
id: "session.previous",
|
||||||
title: language.t("command.session.previous"),
|
title: language.t("command.session.previous"),
|
||||||
@@ -1164,6 +1142,21 @@ export default function Layout(props: ParentProps) {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (!USE_NEW_DESIGN)
|
||||||
|
Array.from({ length: 9 }, (_, i) => {
|
||||||
|
const index = i
|
||||||
|
const number = index + 1
|
||||||
|
commands.push({
|
||||||
|
id: `project.${number}`,
|
||||||
|
category: language.t("command.category.project"),
|
||||||
|
title: `Open Project {number}`,
|
||||||
|
keybind: `mod+${number}`,
|
||||||
|
disabled: layout.projects.list().length <= index,
|
||||||
|
hidden: true,
|
||||||
|
onSelect: () => navigateToProjectIndex(index),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
for (const [id] of availableThemeEntries()) {
|
for (const [id] of availableThemeEntries()) {
|
||||||
commands.push({
|
commands.push({
|
||||||
id: `theme.set.${id}`,
|
id: `theme.set.${id}`,
|
||||||
@@ -1253,11 +1246,11 @@ export default function Layout(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
if (known) return known[0]
|
if (known) return known[0]
|
||||||
|
|
||||||
const [child] = globalSync.child(directory, { bootstrap: false })
|
const [child] = serverSync.child(directory, { bootstrap: false })
|
||||||
const id = child.project
|
const id = child.project
|
||||||
if (!id) return directory
|
if (!id) return directory
|
||||||
|
|
||||||
const meta = globalSync.data.project.find((item) => item.id === id)
|
const meta = serverSync.data.project.find((item) => item.id === id)
|
||||||
return meta?.worktree ?? directory
|
return meta?.worktree ?? directory
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1305,7 +1298,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
const refreshDirs = async (target?: string) => {
|
const refreshDirs = async (target?: string) => {
|
||||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||||
const listed = await globalSDK.client.worktree
|
const listed = await serverSDK.client.worktree
|
||||||
.list({ directory: root })
|
.list({ directory: root })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [] as string[])
|
.catch(() => [] as string[])
|
||||||
@@ -1314,13 +1307,13 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
const openSession = async (target: { directory: string; id: string }) => {
|
const openSession = async (target: { directory: string; id: string }) => {
|
||||||
if (!canOpen(target.directory)) return false
|
if (!canOpen(target.directory)) return false
|
||||||
const [data] = globalSync.child(target.directory, { bootstrap: false })
|
const [data] = serverSync.child(target.directory, { bootstrap: false })
|
||||||
if (data.session.some((item) => item.id === target.id)) {
|
if (data.session.some((item) => item.id === target.id)) {
|
||||||
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
|
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
|
||||||
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
|
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const resolved = await globalSDK.client.session
|
const resolved = await serverSDK.client.session
|
||||||
.get({ sessionID: target.id })
|
.get({ sessionID: target.id })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
@@ -1340,7 +1333,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latest = latestRootSession(
|
const latest = latestRootSession(
|
||||||
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
|
dirs.map((item) => serverSync.child(item, { bootstrap: false })[0]),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
)
|
)
|
||||||
if (latest && (await openSession(latest))) {
|
if (latest && (await openSession(latest))) {
|
||||||
@@ -1351,7 +1344,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
dirs.map(async (item) => ({
|
dirs.map(async (item) => ({
|
||||||
path: { directory: item },
|
path: { directory: item },
|
||||||
session: await globalSDK.client.session
|
session: await serverSDK.client.session
|
||||||
.list({ directory: item })
|
.list({ directory: item })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
@@ -1412,11 +1405,11 @@ export default function Layout(props: ParentProps) {
|
|||||||
const name = next === getFilename(project.worktree) ? "" : next
|
const name = next === getFilename(project.worktree) ? "" : next
|
||||||
|
|
||||||
if (project.id && project.id !== "global") {
|
if (project.id && project.id !== "global") {
|
||||||
await globalSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
|
await serverSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
globalSync.project.meta(project.worktree, { name })
|
serverSync.project.meta(project.worktree, { name })
|
||||||
}
|
}
|
||||||
|
|
||||||
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
|
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||||
@@ -1513,7 +1506,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
setBusy(directory, true)
|
setBusy(directory, true)
|
||||||
|
|
||||||
const result = await globalSDK.client.worktree
|
const result = await serverSDK.client.worktree
|
||||||
.remove({ directory: root, worktreeRemoveInput: { directory } })
|
.remove({ directory: root, worktreeRemoveInput: { directory } })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -1532,7 +1525,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
clearLastProjectSession(root)
|
clearLastProjectSession(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
globalSync.set(
|
serverSync.set(
|
||||||
"project",
|
"project",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const project = draft.find((item) => item.worktree === root)
|
const project = draft.find((item) => item.worktree === root)
|
||||||
@@ -1571,7 +1564,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
const dismiss = () => toaster.dismiss(progress)
|
const dismiss = () => toaster.dismiss(progress)
|
||||||
|
|
||||||
const sessions: Session[] = await globalSDK.client.session
|
const sessions: Session[] = await serverSDK.client.session
|
||||||
.list({ directory })
|
.list({ directory })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
@@ -1582,9 +1575,9 @@ export default function Layout(props: ParentProps) {
|
|||||||
platform,
|
platform,
|
||||||
getTerminalServerScope(server.current, server.key),
|
getTerminalServerScope(server.current, server.key),
|
||||||
)
|
)
|
||||||
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
await serverSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||||
|
|
||||||
const result = await globalSDK.client.worktree
|
const result = await serverSDK.client.worktree
|
||||||
.reset({ directory: root, worktreeResetInput: { directory } })
|
.reset({ directory: root, worktreeResetInput: { directory } })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -1606,7 +1599,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
sessions
|
sessions
|
||||||
.filter((session) => session.time.archived === undefined)
|
.filter((session) => session.time.archived === undefined)
|
||||||
.map((session) =>
|
.map((session) =>
|
||||||
globalSDK.client.session
|
serverSDK.client.session
|
||||||
.update({
|
.update({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
directory: session.directory,
|
directory: session.directory,
|
||||||
@@ -1647,7 +1640,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
globalSDK.client.file
|
serverSDK.client.file
|
||||||
.status({ directory: props.directory })
|
.status({ directory: props.directory })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const files = x.data ?? []
|
const files = x.data ?? []
|
||||||
@@ -1706,7 +1699,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const sessions = await globalSDK.client.session
|
const sessions = await serverSDK.client.session
|
||||||
.list({ directory: props.directory })
|
.list({ directory: props.directory })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
@@ -1715,7 +1708,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
globalSDK.client.file
|
serverSDK.client.file
|
||||||
.status({ directory: props.directory })
|
.status({ directory: props.directory })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const files = x.data ?? []
|
const files = x.data ?? []
|
||||||
@@ -1826,8 +1819,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
|
document.documentElement.style.setProperty(
|
||||||
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
"--dialog-left-margin",
|
||||||
|
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
|
const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
|
||||||
@@ -1847,7 +1842,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const next = new Set(dirs)
|
const next = new Set(dirs)
|
||||||
for (const directory of next) {
|
for (const directory of next) {
|
||||||
if (loadedSessionDirs.has(directory)) continue
|
if (loadedSessionDirs.has(directory)) continue
|
||||||
void globalSync.project.loadSessions(directory)
|
void serverSync.project.loadSessions(directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedSessionDirs.clear()
|
loadedSessionDirs.clear()
|
||||||
@@ -1944,7 +1939,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const createWorkspace = async (project: LocalProject) => {
|
const createWorkspace = async (project: LocalProject) => {
|
||||||
clearSidebarHoverState()
|
clearSidebarHoverState()
|
||||||
const created = await globalSDK.client.worktree
|
const created = await serverSDK.client.worktree
|
||||||
.create({ directory: project.worktree })
|
.create({ directory: project.worktree })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -1978,7 +1973,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
return [created.directory, ...next]
|
return [created.directory, ...next]
|
||||||
})
|
})
|
||||||
|
|
||||||
globalSync.child(created.directory)
|
serverSync.child(created.directory)
|
||||||
navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
|
navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2083,7 +2078,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (!item) return false
|
if (!item) return false
|
||||||
return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
|
return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
|
||||||
})
|
})
|
||||||
const homedir = createMemo(() => globalSync.data.path.home)
|
const homedir = createMemo(() => serverSync.data.path.home)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -2310,7 +2305,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
<div
|
<div
|
||||||
class="shrink-0 px-3 py-3"
|
class="shrink-0 px-3 py-3"
|
||||||
classList={{
|
classList={{
|
||||||
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
|
hidden: store.gettingStartedDismissed || !(providers.all().size > 0 && providers.paid().length === 0),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
|
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
|
||||||
@@ -2368,10 +2363,34 @@ export default function Layout(props: ParentProps) {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (USE_NEW_DESIGN) {
|
||||||
|
return (
|
||||||
|
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||||
|
{autoselecting() ?? ""}
|
||||||
|
<Titlebar update={titlebarUpdate} />
|
||||||
|
<main
|
||||||
|
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
|
||||||
|
classList={{
|
||||||
|
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
|
</main>
|
||||||
|
{import.meta.env.DEV && <DebugBar />}
|
||||||
|
<Toast.Region />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||||
{autoselecting() ?? ""}
|
{autoselecting() ?? ""}
|
||||||
<Titlebar />
|
<Titlebar update={titlebarUpdate} />
|
||||||
|
<Show when={updateVersion() !== undefined}>
|
||||||
|
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||||
|
</Show>
|
||||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||||
<div class="flex-1 min-h-0 relative">
|
<div class="flex-1 min-h-0 relative">
|
||||||
<div class="size-full relative overflow-x-hidden">
|
<div class="size-full relative overflow-x-hidden">
|
||||||
@@ -2519,3 +2538,37 @@ export default function Layout(props: ParentProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UpdateAvailableToast(props: {
|
||||||
|
version: string
|
||||||
|
install: () => void
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
let toastId: number | undefined
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
toastId = showToast({
|
||||||
|
persistent: true,
|
||||||
|
icon: "download",
|
||||||
|
title: props.language.t("toast.update.title"),
|
||||||
|
description: props.language.t("toast.update.description", { version: props.version }),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: props.language.t("toast.update.action.installRestart"),
|
||||||
|
onClick: props.install,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: props.language.t("toast.update.action.notYet"),
|
||||||
|
onClick: "dismiss",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (toastId === undefined) return
|
||||||
|
toaster.dismiss(toastId)
|
||||||
|
})
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,29 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
|
|||||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||||
project.name || getFilename(project.worktree)
|
project.name || getFilename(project.worktree)
|
||||||
|
|
||||||
|
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||||
|
|
||||||
|
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
|
||||||
|
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
|
||||||
|
if (icon?.override) return icon.override
|
||||||
|
if (icon?.color) return undefined
|
||||||
|
return icon?.url
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
|
||||||
|
session: Session,
|
||||||
|
projects: T[],
|
||||||
|
byID: Map<string, T>,
|
||||||
|
) {
|
||||||
|
const direct = byID.get(session.projectID)
|
||||||
|
if (direct) return direct
|
||||||
|
const directory = pathKey(session.directory)
|
||||||
|
return projects.find(
|
||||||
|
(project) =>
|
||||||
|
pathKey(project.worktree) === directory || project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const errorMessage = (err: unknown, fallback: string) => {
|
export const errorMessage = (err: unknown, fallback: string) => {
|
||||||
if (err && typeof err === "object" && "data" in err) {
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
const data = (err as { data?: { message?: string } }).data
|
const data = (err as { data?: { message?: string } }).data
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { A, useParams } from "@solidjs/router"
|
import { A, useParams } from "@solidjs/router"
|
||||||
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
@@ -15,16 +15,7 @@ import { usePermission } from "@/context/permission"
|
|||||||
import { messageAgentColor } from "@/utils/agent"
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
import { sessionTitle } from "@/utils/session-title"
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||||
import { childSessionOnPath, hasProjectPermissions } from "./helpers"
|
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
||||||
|
|
||||||
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
|
||||||
|
|
||||||
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
|
|
||||||
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
|
|
||||||
if (icon?.override) return icon?.override
|
|
||||||
if (icon?.color) return undefined
|
|
||||||
return icon?.url
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectIcon = (props: {
|
export const ProjectIcon = (props: {
|
||||||
project: LocalProject
|
project: LocalProject
|
||||||
@@ -32,7 +23,7 @@ export const ProjectIcon = (props: {
|
|||||||
notify?: boolean
|
notify?: boolean
|
||||||
working?: boolean
|
working?: boolean
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
|
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
|
||||||
@@ -42,7 +33,7 @@ export const ProjectIcon = (props: {
|
|||||||
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
|
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
|
||||||
const hasPermissions = createMemo(() =>
|
const hasPermissions = createMemo(() =>
|
||||||
dirs().some((directory) => {
|
dirs().some((directory) => {
|
||||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||||
return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory))
|
return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -155,10 +146,10 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
|
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
|
||||||
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
|
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
|
||||||
const [sessionStore] = globalSync.child(props.session.directory)
|
const [sessionStore] = serverSync.child(props.session.directory)
|
||||||
const hasPermissions = createMemo(() => {
|
const hasPermissions = createMemo(() => {
|
||||||
return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => {
|
return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => {
|
||||||
return !permission.autoResponds(item, props.session.directory)
|
return !permission.autoResponds(item, props.session.directory)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { HoverCard } from "@opencode-ai/ui/hover-card"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||||
import { useLayout, type LocalProject } from "@/context/layout"
|
import { useLayout, type LocalProject } from "@/context/layout"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
||||||
@@ -274,7 +274,7 @@ export const SortableProject = (props: {
|
|||||||
ctx: ProjectSidebarContext
|
ctx: ProjectSidebarContext
|
||||||
sortNow: Accessor<number>
|
sortNow: Accessor<number>
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sortable = createSortable(props.project.worktree)
|
const sortable = createSortable(props.project.worktree)
|
||||||
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
|
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
|
||||||
@@ -294,23 +294,23 @@ export const SortableProject = (props: {
|
|||||||
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
||||||
|
|
||||||
const label = (directory: string) => {
|
const label = (directory: string) => {
|
||||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
const [data] = serverSync.child(directory, { bootstrap: false })
|
||||||
const kind =
|
const kind =
|
||||||
directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
||||||
const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
|
const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
|
||||||
return `${kind} : ${name}`
|
return `${kind} : ${name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
|
const projectStore = createMemo(() => serverSync.child(props.project.worktree, { bootstrap: false })[0])
|
||||||
const isWorking = createMemo(() =>
|
const isWorking = createMemo(() =>
|
||||||
dirs().some((directory) => {
|
dirs().some((directory) => {
|
||||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||||
return Object.keys(store.session_status).some((id) => store.session_working(id))
|
return Object.keys(store.session_status).some((id) => store.session_working(id))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
||||||
const workspaceSessions = (directory: string) => {
|
const workspaceSessions = (directory: string) => {
|
||||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
const [data] = serverSync.child(directory, { bootstrap: false })
|
||||||
return sortedRootSessions(data, props.sortNow())
|
return sortedRootSessions(data, props.sortNow())
|
||||||
}
|
}
|
||||||
const tile = () => (
|
const tile = () => (
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { type LocalProject } from "@/context/layout"
|
import { type LocalProject } from "@/context/layout"
|
||||||
import { useGlobalSync, useQueryOptions } from "@/context/global-sync"
|
import { useServerSync, useQueryOptions } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
||||||
@@ -60,7 +60,7 @@ export const WorkspaceDragOverlay = (props: {
|
|||||||
activeWorkspace: Accessor<string | undefined>
|
activeWorkspace: Accessor<string | undefined>
|
||||||
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const label = createMemo(() => {
|
const label = createMemo(() => {
|
||||||
const project = props.sidebarProject()
|
const project = props.sidebarProject()
|
||||||
@@ -68,7 +68,7 @@ export const WorkspaceDragOverlay = (props: {
|
|||||||
const directory = props.activeWorkspace()
|
const directory = props.activeWorkspace()
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
|
|
||||||
const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
|
const [workspaceStore] = serverSync.child(directory, { bootstrap: false })
|
||||||
const kind =
|
const kind =
|
||||||
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
||||||
const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
|
const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
|
||||||
@@ -299,11 +299,11 @@ export const SortableWorkspace = (props: {
|
|||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const queryOptions = useQueryOptions()
|
const queryOptions = useQueryOptions()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sortable = createSortable(props.directory)
|
const sortable = createSortable(props.directory)
|
||||||
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
|
const [workspaceStore, setWorkspaceStore] = serverSync.child(props.directory, { bootstrap: false })
|
||||||
const [menu, setMenu] = createStore({
|
const [menu, setMenu] = createStore({
|
||||||
open: false,
|
open: false,
|
||||||
pendingRename: false,
|
pendingRename: false,
|
||||||
@@ -328,7 +328,7 @@ export const SortableWorkspace = (props: {
|
|||||||
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
||||||
await globalSync.project.loadSessions(props.directory)
|
await serverSync.project.loadSessions(props.directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
|
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
|
||||||
@@ -357,7 +357,7 @@ export const SortableWorkspace = (props: {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!boot()) return
|
if (!boot()) return
|
||||||
globalSync.child(props.directory, { bootstrap: true })
|
serverSync.child(props.directory, { bootstrap: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -446,11 +446,11 @@ export const LocalWorkspace = (props: {
|
|||||||
sortNow: Accessor<number>
|
sortNow: Accessor<number>
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const queryOptions = useQueryOptions()
|
const queryOptions = useQueryOptions()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const workspace = createMemo(() => {
|
const workspace = createMemo(() => {
|
||||||
const [store, setStore] = globalSync.child(props.project.worktree)
|
const [store, setStore] = serverSync.child(props.project.worktree)
|
||||||
return { store, setStore }
|
return { store, setStore }
|
||||||
})
|
})
|
||||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||||
@@ -461,7 +461,7 @@ export const LocalWorkspace = (props: {
|
|||||||
const loading = () => fetching() > 0 && count() === 0
|
const loading = () => fetching() > 0 && count() === 0
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||||
await globalSync.project.loadSessions(props.project.worktree)
|
await serverSync.project.loadSessions(props.project.worktree)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
import { debounce } from "@solid-primitives/scheduled"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
@@ -30,10 +31,10 @@ import { Button } from "@opencode-ai/ui/button"
|
|||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { checksum } from "@opencode-ai/core/util/encode"
|
import { checksum } from "@opencode-ai/core/util/encode"
|
||||||
import { useLocation, useSearchParams } from "@solidjs/router"
|
import { useLocation, useSearchParams } from "@solidjs/router"
|
||||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
|
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } 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 { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
@@ -58,6 +59,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
|||||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||||
|
import { shouldUseV2NewSessionPage } from "@/pages/session/new-session-layout"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { diffs as list } from "@/utils/diffs"
|
import { diffs as list } from "@/utils/diffs"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -73,6 +75,7 @@ const emptyFollowups: FollowupItem[] = []
|
|||||||
|
|
||||||
type ChangeMode = "git" | "branch" | "turn"
|
type ChangeMode = "git" | "branch" | "turn"
|
||||||
type VcsMode = "git" | "branch"
|
type VcsMode = "git" | "branch"
|
||||||
|
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
|
|
||||||
type SessionHistoryWindowInput = {
|
type SessionHistoryWindowInput = {
|
||||||
sessionID: () => string | undefined
|
sessionID: () => string | undefined
|
||||||
@@ -179,7 +182,7 @@ function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
@@ -261,8 +264,10 @@ export default function Page() {
|
|||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
const size = createSizing()
|
const size = createSizing()
|
||||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
const isV2NewSessionPage = () =>
|
||||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
|
||||||
|
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
|
||||||
|
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
|
||||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||||
const sessionPanelWidth = createMemo(() => {
|
const sessionPanelWidth = createMemo(() => {
|
||||||
if (!desktopSidePanelOpen()) return "100%"
|
if (!desktopSidePanelOpen()) return "100%"
|
||||||
@@ -476,7 +481,7 @@ export default function Page() {
|
|||||||
: skipToken,
|
: skipToken,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() })
|
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
||||||
const reviewDiffs = () => {
|
const reviewDiffs = () => {
|
||||||
if (store.changes === "git" || store.changes === "branch")
|
if (store.changes === "git" || store.changes === "branch")
|
||||||
// avoids suspense
|
// avoids suspense
|
||||||
@@ -555,11 +560,11 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function upsert(next: Project) {
|
function upsert(next: Project) {
|
||||||
const list = globalSync.data.project
|
const list = serverSync.data.project
|
||||||
sync.set("project", next.id)
|
sync.set("project", next.id)
|
||||||
const idx = list.findIndex((item) => item.id === next.id)
|
const idx = list.findIndex((item) => item.id === next.id)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
globalSync.set(
|
serverSync.set(
|
||||||
"project",
|
"project",
|
||||||
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
|
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
|
||||||
)
|
)
|
||||||
@@ -567,10 +572,10 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
const at = list.findIndex((item) => item.id > next.id)
|
const at = list.findIndex((item) => item.id > next.id)
|
||||||
if (at >= 0) {
|
if (at >= 0) {
|
||||||
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
|
serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
globalSync.set("project", [...list, next])
|
serverSync.set("project", [...list, next])
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitMutation = useMutation(() => ({
|
const gitMutation = useMutation(() => ({
|
||||||
@@ -668,7 +673,7 @@ export default function Page() {
|
|||||||
todoTimer = undefined
|
todoTimer = undefined
|
||||||
if (!id) return
|
if (!id) return
|
||||||
if (status === "idle" && !blocked) return
|
if (status === "idle" && !blocked) return
|
||||||
const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
|
const cached = untrack(() => sync.data.todo[id] !== undefined || serverSync.data.session_todo[id] !== undefined)
|
||||||
|
|
||||||
todoFrame = requestAnimationFrame(() => {
|
todoFrame = requestAnimationFrame(() => {
|
||||||
todoFrame = undefined
|
todoFrame = undefined
|
||||||
@@ -1381,7 +1386,7 @@ export default function Page() {
|
|||||||
const ok = await sendFollowupDraft({
|
const ok = await sendFollowupDraft({
|
||||||
client: sdk.client,
|
client: sdk.client,
|
||||||
sync,
|
sync,
|
||||||
globalSync,
|
serverSync,
|
||||||
draft: item,
|
draft: item,
|
||||||
optimisticBusy: item.sessionDirectory === sdk.directory,
|
optimisticBusy: item.sessionDirectory === sdk.directory,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
@@ -1648,6 +1653,60 @@ export default function Page() {
|
|||||||
|
|
||||||
useUsageExceededDialogs()
|
useUsageExceededDialogs()
|
||||||
|
|
||||||
|
const composerRegion = (placement: "dock" | "inline") => (
|
||||||
|
<SessionComposerRegion
|
||||||
|
state={composer}
|
||||||
|
ready={!store.deferRender && messagesReady()}
|
||||||
|
centered={placement === "dock" && centered()}
|
||||||
|
placement={placement}
|
||||||
|
inputRef={(el) => {
|
||||||
|
inputRef = el
|
||||||
|
}}
|
||||||
|
newSessionWorktree={newSessionWorktree()}
|
||||||
|
onNewSessionWorktreeChange={(value) => setStore("newSessionWorktree", value)}
|
||||||
|
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||||
|
onSubmit={() => {
|
||||||
|
comments.clear()
|
||||||
|
resumeScroll()
|
||||||
|
}}
|
||||||
|
onResponseSubmit={resumeScroll}
|
||||||
|
followup={
|
||||||
|
params.id && !isChildSession()
|
||||||
|
? {
|
||||||
|
queue: queueEnabled,
|
||||||
|
items: followupDock(),
|
||||||
|
sending: sendingFollowup(),
|
||||||
|
edit: editingFollowup(),
|
||||||
|
onQueue: queueFollowup,
|
||||||
|
onAbort: () => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
setFollowup("paused", id, true)
|
||||||
|
},
|
||||||
|
onSend: (id) => {
|
||||||
|
void sendFollowup(params.id!, id, { manual: true })
|
||||||
|
},
|
||||||
|
onEdit: editFollowup,
|
||||||
|
onEditLoaded: clearFollowupEdit,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
revert={
|
||||||
|
rolled().length > 0
|
||||||
|
? {
|
||||||
|
items: rolled(),
|
||||||
|
restoring: restoring(),
|
||||||
|
disabled: reverting(),
|
||||||
|
onRestore: restore,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
setPromptDockRef={(el) => {
|
||||||
|
promptDock = el
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||||
{sessionSync() ?? ""}
|
{sessionSync() ?? ""}
|
||||||
@@ -1678,12 +1737,12 @@ export default function Page() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Session panel */}
|
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
|
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
|
||||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||||
!size.active() && !ui.reviewSnap,
|
!size.active() && !ui.reviewSnap,
|
||||||
|
"transition-[width]": !isV2NewSessionPage(),
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: sessionPanelWidth(),
|
width: sessionPanelWidth(),
|
||||||
@@ -1740,60 +1799,16 @@ export default function Page() {
|
|||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<NewSessionView worktree={newSessionWorktree()} />
|
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
|
||||||
|
<NewSessionDesignView worktree={newSessionWorktree()}>
|
||||||
|
{composerRegion("inline")}
|
||||||
|
</NewSessionDesignView>
|
||||||
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SessionComposerRegion
|
<Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show>
|
||||||
state={composer}
|
|
||||||
ready={!store.deferRender && messagesReady()}
|
|
||||||
centered={centered()}
|
|
||||||
inputRef={(el) => {
|
|
||||||
inputRef = el
|
|
||||||
}}
|
|
||||||
newSessionWorktree={newSessionWorktree()}
|
|
||||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
|
||||||
onSubmit={() => {
|
|
||||||
comments.clear()
|
|
||||||
resumeScroll()
|
|
||||||
}}
|
|
||||||
onResponseSubmit={resumeScroll}
|
|
||||||
followup={
|
|
||||||
params.id && !isChildSession()
|
|
||||||
? {
|
|
||||||
queue: queueEnabled,
|
|
||||||
items: followupDock(),
|
|
||||||
sending: sendingFollowup(),
|
|
||||||
edit: editingFollowup(),
|
|
||||||
onQueue: queueFollowup,
|
|
||||||
onAbort: () => {
|
|
||||||
const id = params.id
|
|
||||||
if (!id) return
|
|
||||||
setFollowup("paused", id, true)
|
|
||||||
},
|
|
||||||
onSend: (id) => {
|
|
||||||
void sendFollowup(params.id!, id, { manual: true })
|
|
||||||
},
|
|
||||||
onEdit: editFollowup,
|
|
||||||
onEditLoaded: clearFollowupEdit,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
revert={
|
|
||||||
rolled().length > 0
|
|
||||||
? {
|
|
||||||
items: rolled(),
|
|
||||||
restoring: restoring(),
|
|
||||||
disabled: reverting(),
|
|
||||||
onRestore: restore,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
setPromptDockRef={(el) => {
|
|
||||||
promptDock = el
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Show when={desktopReviewOpen()}>
|
<Show when={desktopReviewOpen()}>
|
||||||
<div onPointerDown={() => size.start()}>
|
<div onPointerDown={() => size.start()}>
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ export function SessionComposerRegion(props: {
|
|||||||
state: SessionComposerState
|
state: SessionComposerState
|
||||||
ready: boolean
|
ready: boolean
|
||||||
centered: boolean
|
centered: boolean
|
||||||
|
placement?: "dock" | "inline"
|
||||||
inputRef: (el: HTMLDivElement) => void
|
inputRef: (el: HTMLDivElement) => void
|
||||||
newSessionWorktree: string
|
newSessionWorktree: string
|
||||||
|
onNewSessionWorktreeChange?: (worktree: string) => void
|
||||||
onNewSessionWorktreeReset: () => void
|
onNewSessionWorktreeReset: () => void
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
onResponseSubmit: () => void
|
onResponseSubmit: () => void
|
||||||
@@ -142,11 +144,15 @@ export function SessionComposerRegion(props: {
|
|||||||
<div
|
<div
|
||||||
ref={props.setPromptDockRef}
|
ref={props.setPromptDockRef}
|
||||||
data-component="session-prompt-dock"
|
data-component="session-prompt-dock"
|
||||||
class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
|
classList={{
|
||||||
|
"w-full flex flex-col justify-center items-center pointer-events-none": true,
|
||||||
|
"shrink-0 pb-3 bg-background-stronger": props.placement !== "inline",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"w-full px-3 pointer-events-auto": true,
|
"w-full px-3 pointer-events-auto": true,
|
||||||
|
"max-w-[720px] px-0": props.placement === "inline",
|
||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -256,8 +262,10 @@ export function SessionComposerRegion(props: {
|
|||||||
fallback={
|
fallback={
|
||||||
<Show when={!props.state.blocked()}>
|
<Show when={!props.state.blocked()}>
|
||||||
<PromptInput
|
<PromptInput
|
||||||
|
variant={props.placement === "inline" ? "new-session" : undefined}
|
||||||
ref={props.inputRef}
|
ref={props.inputRef}
|
||||||
newSessionWorktree={props.newSessionWorktree}
|
newSessionWorktree={props.newSessionWorktree}
|
||||||
|
onNewSessionWorktreeChange={props.onNewSessionWorktreeChange}
|
||||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
||||||
edit={props.followup?.edit}
|
edit={props.followup?.edit}
|
||||||
onEditLoaded={props.followup?.onEditLoaded}
|
onEditLoaded={props.followup?.onEditLoaded}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
@@ -27,7 +27,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const globalSync = useGlobalSync()
|
const serverSync = useServerSync()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||||||
const todos = createMemo((): Todo[] => {
|
const todos = createMemo((): Todo[] => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return []
|
if (!id) return []
|
||||||
return globalSync.data.session_todo[id] ?? []
|
return serverSync.data.session_todo[id] ?? []
|
||||||
})
|
})
|
||||||
|
|
||||||
const done = createMemo(
|
const done = createMemo(
|
||||||
@@ -111,7 +111,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||||||
const clear = () => {
|
const clear = () => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
globalSync.todo.set(id, [])
|
serverSync.todo.set(id, [])
|
||||||
sync.set("todo", id, [])
|
sync.set("todo", id, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export type TimelineRowMap = {
|
|||||||
userMessageID: string
|
userMessageID: string
|
||||||
group: PartGroup
|
group: PartGroup
|
||||||
previousAssistantPart: boolean
|
previousAssistantPart: boolean
|
||||||
lastAssistantPart: boolean
|
|
||||||
}
|
}
|
||||||
Thinking: { userMessageID: string; reasoningHeading?: string }
|
Thinking: { userMessageID: string; reasoningHeading?: string }
|
||||||
Retry: { userMessageID: string }
|
Retry: { userMessageID: string }
|
||||||
@@ -50,7 +49,6 @@ export namespace TimelineRow {
|
|||||||
userMessageID: string
|
userMessageID: string
|
||||||
group: PartGroup
|
group: PartGroup
|
||||||
previousAssistantPart: boolean
|
previousAssistantPart: boolean
|
||||||
lastAssistantPart: boolean
|
|
||||||
}> {}
|
}> {}
|
||||||
export class Thinking extends Data.TaggedClass("Thinking")<{
|
export class Thinking extends Data.TaggedClass("Thinking")<{
|
||||||
userMessageID: string
|
userMessageID: string
|
||||||
@@ -151,8 +149,6 @@ export namespace Timeline {
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
|
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
|
||||||
const assistantGroupCount = assistantItems.filter((item) => item.type === "part").length
|
|
||||||
|
|
||||||
if (comments.length > 0)
|
if (comments.length > 0)
|
||||||
rows.push(
|
rows.push(
|
||||||
new TimelineRow.CommentStrip({
|
new TimelineRow.CommentStrip({
|
||||||
@@ -195,7 +191,6 @@ export namespace Timeline {
|
|||||||
userMessageID: userMessage.id,
|
userMessageID: userMessage.id,
|
||||||
group: item.group,
|
group: item.group,
|
||||||
previousAssistantPart: assistantGroupIndex > 0,
|
previousAssistantPart: assistantGroupIndex > 0,
|
||||||
lastAssistantPart: assistantGroupIndex === assistantGroupCount - 1,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
assistantGroupIndex += 1
|
assistantGroupIndex += 1
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, mapArray, type JSX } from "solid-js"
|
import {
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
Index,
|
||||||
|
on,
|
||||||
|
onCleanup,
|
||||||
|
Show,
|
||||||
|
mapArray,
|
||||||
|
type Accessor,
|
||||||
|
type JSX,
|
||||||
|
} from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
@@ -48,7 +60,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
@@ -245,7 +257,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||||
<Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
|
<Dynamic component={fileComponent} mode="diff" virtualize={false} fileDiff={view.fileDiff} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -273,7 +285,7 @@ export function MessageTimeline(props: {
|
|||||||
let touchGesture: number | undefined
|
let touchGesture: number | undefined
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const globalSDK = useGlobalSDK()
|
const serverSDK = useServerSDK()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -415,8 +427,7 @@ export function MessageTimeline(props: {
|
|||||||
if (rows.length === 0) return rows
|
if (rows.length === 0) return rows
|
||||||
return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
|
return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
|
||||||
})
|
})
|
||||||
const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)))
|
const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys })
|
||||||
const timelineRowKeys = createMemo(() => [...timelineRowByKey().keys()], [] as string[], { equals: sameKeys })
|
|
||||||
const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
|
const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
|
||||||
const messageRowIndex = createMemo(() => {
|
const messageRowIndex = createMemo(() => {
|
||||||
const result = new Map<string, number>()
|
const result = new Map<string, number>()
|
||||||
@@ -427,6 +438,14 @@ export function MessageTimeline(props: {
|
|||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
const lastAssistantGroupKey = createMemo(() => {
|
||||||
|
const result = new Map<string, string>()
|
||||||
|
timelineRows().forEach((row) => {
|
||||||
|
if (row._tag !== "AssistantPart") return
|
||||||
|
result.set(row.userMessageID, row.group.key)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
})
|
||||||
const keepMounted = createMemo(() => {
|
const keepMounted = createMemo(() => {
|
||||||
const id = activeMessageID()
|
const id = activeMessageID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@@ -540,6 +559,7 @@ export function MessageTimeline(props: {
|
|||||||
const [bar, setBar] = createStore({
|
const [bar, setBar] = createStore({
|
||||||
ms: pace(640),
|
ms: pace(640),
|
||||||
})
|
})
|
||||||
|
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
|
||||||
|
|
||||||
let more: HTMLButtonElement | undefined
|
let more: HTMLButtonElement | undefined
|
||||||
let head: HTMLDivElement | undefined
|
let head: HTMLDivElement | undefined
|
||||||
@@ -560,6 +580,11 @@ export function MessageTimeline(props: {
|
|||||||
|
|
||||||
const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
|
const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
|
||||||
|
|
||||||
|
const measureTimeline = () => {
|
||||||
|
virtualizer?.measure()
|
||||||
|
anchorMeasuredBottom()
|
||||||
|
}
|
||||||
|
|
||||||
function anchorMeasuredBottom() {
|
function anchorMeasuredBottom() {
|
||||||
if (!listRoot) return false
|
if (!listRoot) return false
|
||||||
if (!measuredBottomAnchored) return false
|
if (!measuredBottomAnchored) return false
|
||||||
@@ -704,14 +729,14 @@ export function MessageTimeline(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shareMutation = useMutation(() => ({
|
const shareMutation = useMutation(() => ({
|
||||||
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
|
mutationFn: (id: string) => serverSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error("Failed to share session", err)
|
console.error("Failed to share session", err)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const unshareMutation = useMutation(() => ({
|
const unshareMutation = useMutation(() => ({
|
||||||
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
|
mutationFn: (id: string) => serverSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error("Failed to unshare session", err)
|
console.error("Failed to unshare session", err)
|
||||||
},
|
},
|
||||||
@@ -980,29 +1005,42 @@ export function MessageTimeline(props: {
|
|||||||
|
|
||||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||||
|
|
||||||
const renderAssistantPartGroup = (row: TimelineRowMap["AssistantPart"]) => {
|
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
|
||||||
if (row.group.type === "context") {
|
if (row().group.type === "context") {
|
||||||
const parts = createMemo(() => {
|
const parts = createMemo(() => {
|
||||||
const group = row.group
|
const group = row().group
|
||||||
if (group.type !== "context") return emptyTools
|
if (group.type !== "context") return emptyTools
|
||||||
return group.refs
|
return group.refs
|
||||||
.map((ref) => getMsgPart(ref.messageID, ref.partID))
|
.map((ref) => getMsgPart(ref.messageID, ref.partID))
|
||||||
.filter((part): part is ToolPart => part?.type === "tool")
|
.filter((part): part is ToolPart => part?.type === "tool")
|
||||||
})
|
})
|
||||||
|
|
||||||
return <ContextToolGroup parts={parts()} busy={workingTurn(row.userMessageID) && row.lastAssistantPart} />
|
return (
|
||||||
|
<ContextToolGroup
|
||||||
|
parts={parts()}
|
||||||
|
busy={
|
||||||
|
workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
|
||||||
|
}
|
||||||
|
onSizeChange={measureTimeline}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = createMemo(() => {
|
const message = createMemo(() => {
|
||||||
const group = row.group
|
const group = row().group
|
||||||
if (group.type !== "part") return
|
if (group.type !== "part") return
|
||||||
return messageByID().get(group.ref.messageID)
|
return messageByID().get(group.ref.messageID)
|
||||||
})
|
})
|
||||||
const part = createMemo(() => {
|
const part = createMemo(() => {
|
||||||
const group = row.group
|
const group = row().group
|
||||||
if (group.type !== "part") return
|
if (group.type !== "part") return
|
||||||
return getMsgPart(group.ref.messageID, group.ref.partID)
|
return getMsgPart(group.ref.messageID, group.ref.partID)
|
||||||
})
|
})
|
||||||
|
const defaultOpen = createMemo(() => {
|
||||||
|
const item = part()
|
||||||
|
if (!item) return
|
||||||
|
return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={message()}>
|
<Show when={message()}>
|
||||||
@@ -1012,14 +1050,13 @@ export function MessageTimeline(props: {
|
|||||||
<MessagePart
|
<MessagePart
|
||||||
part={part()}
|
part={part()}
|
||||||
message={message()}
|
message={message()}
|
||||||
showAssistantCopyPartID={assistantCopyPartID(row.userMessageID)}
|
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
||||||
turnDurationMs={turnDurationMs(row.userMessageID)}
|
turnDurationMs={turnDurationMs(row().userMessageID)}
|
||||||
defaultOpen={partDefaultOpen(
|
defaultOpen={defaultOpen()}
|
||||||
part(),
|
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
||||||
settings.general.shellToolPartsExpanded(),
|
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
||||||
settings.general.editToolPartsExpanded(),
|
|
||||||
)}
|
|
||||||
deferToolContent={false}
|
deferToolContent={false}
|
||||||
|
virtualizeDiff={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1028,25 +1065,25 @@ export function MessageTimeline(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) {
|
function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) {
|
||||||
const anchor = () => {
|
const anchor = () => {
|
||||||
const row = input.row
|
const row = input.row()
|
||||||
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
|
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
|
||||||
}
|
}
|
||||||
const previousUserMessage = () => {
|
const previousUserMessage = () => {
|
||||||
const row = input.row
|
const row = input.row()
|
||||||
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
|
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
|
||||||
}
|
}
|
||||||
const previousAssistantPart = () => {
|
const previousAssistantPart = () => {
|
||||||
const row = input.row
|
const row = input.row()
|
||||||
return row._tag === "AssistantPart" && row.previousAssistantPart
|
return row._tag === "AssistantPart" && row.previousAssistantPart
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={anchor() ? props.anchor(input.row.userMessageID) : undefined}
|
id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
|
||||||
data-message-id={input.row.userMessageID}
|
data-message-id={input.row().userMessageID}
|
||||||
data-timeline-row={input.row._tag}
|
data-timeline-row={input.row()._tag}
|
||||||
classList={{
|
classList={{
|
||||||
"min-w-0 w-full max-w-full": true,
|
"min-w-0 w-full max-w-full": true,
|
||||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||||
@@ -1062,14 +1099,15 @@ export function MessageTimeline(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderTimelineRow = (row: TimelineRow.TimelineRow) => {
|
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
|
||||||
switch (row._tag) {
|
switch (row()._tag) {
|
||||||
case "CommentStrip": {
|
case "CommentStrip": {
|
||||||
|
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
|
||||||
const comments = createMemo(() =>
|
const comments = createMemo(() =>
|
||||||
getMsgParts(row.userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
|
getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={commentStripRow}>
|
||||||
<div class="w-full px-4 md:px-5 pb-2">
|
<div class="w-full px-4 md:px-5 pb-2">
|
||||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||||
<div class="flex w-max min-w-full justify-end gap-2">
|
<div class="flex w-max min-w-full justify-end gap-2">
|
||||||
@@ -1102,17 +1140,22 @@ export function MessageTimeline(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "UserMessage": {
|
case "UserMessage": {
|
||||||
|
const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
|
||||||
const message = createMemo(() => {
|
const message = createMemo(() => {
|
||||||
const m = messageByID().get(row.userMessageID)
|
const m = messageByID().get(userMessageRow().userMessageID)
|
||||||
if (m?.role === "user") return m
|
if (m?.role === "user") return m
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={userMessageRow}>
|
||||||
<Show when={message()}>
|
<Show when={message()}>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<div data-slot="session-turn-message-content" aria-live="off">
|
<div data-slot="session-turn-message-content" aria-live="off">
|
||||||
<Message message={message()} parts={getMsgParts(row.userMessageID)} actions={props.actions} />
|
<Message
|
||||||
|
message={message()}
|
||||||
|
parts={getMsgParts(userMessageRow().userMessageID)}
|
||||||
|
actions={props.actions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1121,13 +1164,14 @@ export function MessageTimeline(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "TurnDivider": {
|
case "TurnDivider": {
|
||||||
|
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={turnDividerRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<div data-slot="session-turn-compaction">
|
<div data-slot="session-turn-compaction">
|
||||||
<MessageDivider
|
<MessageDivider
|
||||||
label={language.t(
|
label={language.t(
|
||||||
row.label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
|
turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1136,22 +1180,27 @@ export function MessageTimeline(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "AssistantPart": {
|
case "AssistantPart": {
|
||||||
|
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={assistantPartRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(row.userMessageID)}>
|
<div
|
||||||
{renderAssistantPartGroup(row)}
|
data-slot="session-turn-assistant-content"
|
||||||
|
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
|
||||||
|
>
|
||||||
|
{renderAssistantPartGroup(assistantPartRow)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "Thinking": {
|
case "Thinking": {
|
||||||
|
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={thinkingRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<TimelineThinkingRow
|
<TimelineThinkingRow
|
||||||
reasoningHeading={row.reasoningHeading}
|
reasoningHeading={thinkingRow().reasoningHeading}
|
||||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1159,29 +1208,32 @@ export function MessageTimeline(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "Retry": {
|
case "Retry": {
|
||||||
|
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={retryRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<SessionRetry status={sessionStatus()} show={activeMessageID() === row.userMessageID} />
|
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
||||||
</div>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "DiffSummary": {
|
case "DiffSummary": {
|
||||||
|
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={diffSummaryRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<TimelineDiffSummaryRow diffs={row.diffs} />
|
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
|
||||||
</div>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "Error": {
|
case "Error": {
|
||||||
|
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={errorRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<Card variant="error" class="error-card">
|
<Card variant="error" class="error-card">
|
||||||
{row.text}
|
{errorRow().text}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
@@ -1192,12 +1244,8 @@ export function MessageTimeline(props: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineRowView(props: { rowKey: string }) {
|
function TimelineRowView(props: { row: TimelineRow.TimelineRow }) {
|
||||||
return (
|
return renderTimelineRow(() => props.row)
|
||||||
<Show when={timelineRowByKey().get(props.rowKey)} keyed>
|
|
||||||
{(item) => renderTimelineRow(item)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1236,7 +1284,6 @@ export function MessageTimeline(props: {
|
|||||||
onClick={props.onAutoScrollInteraction}
|
onClick={props.onAutoScrollInteraction}
|
||||||
class="relative min-w-0 w-full h-full"
|
class="relative min-w-0 w-full h-full"
|
||||||
style={{
|
style={{
|
||||||
"--session-title-height": showHeader() ? "40px" : "0px",
|
|
||||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1256,16 +1303,14 @@ export function MessageTimeline(props: {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={workingStatus() !== "hidden" && settings.general.showSessionProgressBar()}>
|
<Show when={workingStatus() !== "hidden" && settings.general.showSessionProgressBar()}>
|
||||||
|
<div data-component="session-progress" data-state={workingStatus()} aria-hidden="true">
|
||||||
<div
|
<div
|
||||||
data-component="session-progress"
|
data-component="session-progress-bar"
|
||||||
data-state={workingStatus()}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
style={{
|
||||||
"--session-progress-color": tint() ?? "var(--icon-interactive-base)",
|
background: tint() ?? "var(--icon-interactive-base)",
|
||||||
"--session-progress-ms": `${bar.ms}ms`,
|
animation: `session-progress-whip ${bar.ms}ms infinite`,
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<div data-component="session-progress-bar" />
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||||
@@ -1532,7 +1577,7 @@ export function MessageTimeline(props: {
|
|||||||
<Show when={scrollRoot()}>
|
<Show when={scrollRoot()}>
|
||||||
{(root) => (
|
{(root) => (
|
||||||
<Virtualizer
|
<Virtualizer
|
||||||
data={timelineRowKeys()}
|
data={timelineRows()}
|
||||||
cache={virtualCache()}
|
cache={virtualCache()}
|
||||||
itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
|
itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
|
||||||
scrollRef={root()}
|
scrollRef={root()}
|
||||||
@@ -1552,7 +1597,7 @@ export function MessageTimeline(props: {
|
|||||||
scheduleContentRoot(root())
|
scheduleContentRoot(root())
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(key) => <TimelineRowView rowKey={key} />}
|
{(row) => <TimelineRowView row={row} />}
|
||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { shouldUseV2NewSessionPage } from "./new-session-layout"
|
||||||
|
|
||||||
|
describe("shouldUseV2NewSessionPage", () => {
|
||||||
|
test("keeps prod session pages on the legacy layout", () => {
|
||||||
|
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
|
||||||
|
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the v2 layout only for non-prod new-session pages", () => {
|
||||||
|
expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
|
||||||
|
expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
|
||||||
|
return input.channel !== "prod" && !input.sessionID
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user