mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 14:36:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00daa5ce52 |
@@ -54,7 +54,6 @@
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.15",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
@@ -969,7 +968,6 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -979,7 +977,6 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/pacote": "11.1.8",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -1645,10 +1642,6 @@
|
||||
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
|
||||
|
||||
"@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="],
|
||||
|
||||
"@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="],
|
||||
|
||||
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
@@ -2883,8 +2876,6 @@
|
||||
|
||||
"@solid-primitives/media": ["@solid-primitives/media@2.3.6", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.6", "@solid-primitives/rootless": "^1.5.4", "@solid-primitives/static-store": "^0.1.4", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-pk49gPOq/UMRUJ+pTSrOfBiR8xJjRYHXIf1iR/jSnyQ/KroU+ZXhkZzavC7hvfp2vJeOTW5k2/HN0r3Q1VJ7Pw=="],
|
||||
|
||||
"@solid-primitives/memo": ["@solid-primitives/memo@1.5.1", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-VDPrkl9epp0tbby9MvsqphGFCYCtDRC5J8FKzTqHbQiG5hhR8n6xv4MfjhTW231IaBxxPHLxS43EE8c5Q23mSQ=="],
|
||||
|
||||
"@solid-primitives/props": ["@solid-primitives/props@3.2.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-MXXdvi2TSB6d+0N6ueA/HP1j/Kh9SEc4WdF1ZDMLwPagxW6pIHHSS9xbRO0RjqSVpNP4j0nxCWT1hx3CkJNhNA=="],
|
||||
|
||||
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A=="],
|
||||
@@ -5311,10 +5302,6 @@
|
||||
|
||||
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||
|
||||
"solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="],
|
||||
|
||||
"solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="],
|
||||
|
||||
"solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
|
||||
|
||||
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
|
||||
@@ -5329,8 +5316,6 @@
|
||||
|
||||
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
||||
|
||||
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
|
||||
|
||||
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
|
||||
|
||||
"sort-keys": ["sort-keys@1.1.2", "", { "dependencies": { "is-plain-obj": "^1.0.0" } }, "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg=="],
|
||||
@@ -6645,8 +6630,6 @@
|
||||
|
||||
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
||||
|
||||
"solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="],
|
||||
|
||||
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
|
||||
|
||||
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
|
||||
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
|
||||
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
|
||||
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
|
||||
"x86_64-linux": "sha256-fG6VYtNC0pce4VM9po7vVucPuJul42yuuijTjNSr7rk=",
|
||||
"aarch64-linux": "sha256-3TznrmNqdt25cOxia6vcdi/5qKaeyLPIsNXGYBSJNrs=",
|
||||
"aarch64-darwin": "sha256-8Kmagb5tfECSWZNsIJgrRP1d3X5tuEoWLEWkV3UENZo=",
|
||||
"x86_64-darwin": "sha256-mIV+mDwIGD02BNYZVi37sY4ls1T01N6z76eBtH0sKiA="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -5,10 +5,10 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.0",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
|
||||
@@ -510,10 +510,9 @@ interface ParserState {
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly finishedTools: ReadonlySet<number>
|
||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
|
||||
// one finish after both chunks have had a chance to arrive.
|
||||
readonly finishReason: FinishReasonDetails | undefined
|
||||
readonly usage: Usage | undefined
|
||||
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
||||
// can emit exactly one finish after both chunks have had a chance to arrive.
|
||||
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
|
||||
readonly hasToolCalls: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
@@ -693,9 +692,12 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
finishReason: {
|
||||
normalized: mapFinishReason(event.messageStop.stopReason),
|
||||
raw: event.messageStop.stopReason,
|
||||
pendingFinish: {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.messageStop.stopReason),
|
||||
raw: event.messageStop.stopReason,
|
||||
},
|
||||
usage: state.pendingFinish?.usage,
|
||||
},
|
||||
},
|
||||
[],
|
||||
@@ -703,11 +705,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.metadata) {
|
||||
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.usage
|
||||
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
usage,
|
||||
pendingFinish: {
|
||||
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
|
||||
usage,
|
||||
},
|
||||
},
|
||||
[],
|
||||
] as const
|
||||
@@ -731,18 +736,18 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const framing = BedrockEventStream.framing(ADAPTER)
|
||||
|
||||
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
if (!state.finishReason) return []
|
||||
if (!state.pendingFinish) return []
|
||||
const normalized = (() => {
|
||||
if (state.finishReason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
|
||||
return state.finishReason.normalized
|
||||
if (state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
|
||||
return state.pendingFinish.reason.normalized
|
||||
})()
|
||||
const events: LLMEvent[] = []
|
||||
Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: {
|
||||
...state.finishReason,
|
||||
...state.pendingFinish.reason,
|
||||
normalized,
|
||||
},
|
||||
usage: state.usage,
|
||||
usage: state.pendingFinish.usage,
|
||||
})
|
||||
return events
|
||||
}
|
||||
@@ -766,8 +771,7 @@ export const protocol = Protocol.make({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
finishedTools: new Set<number>(),
|
||||
finishReason: undefined,
|
||||
usage: undefined,
|
||||
pendingFinish: undefined,
|
||||
hasToolCalls: false,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
reasoningSignatures: {},
|
||||
|
||||
@@ -609,27 +609,18 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: GeminiEvent) => {
|
||||
if (ProviderShared.isRecord(event.error)) {
|
||||
if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") {
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return Effect.fail(
|
||||
new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message:
|
||||
typeof event.error.message === "string" && event.error.message.length > 0
|
||||
? event.error.message
|
||||
: typeof event.error.status === "string" && event.error.status.length > 0
|
||||
? event.error.status
|
||||
: "Gemini provider error",
|
||||
message: event.error.message,
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
rawBody: body,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
if ("error" in event)
|
||||
return Effect.fail(
|
||||
ProviderShared.eventError(state.route, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
|
||||
)
|
||||
const nextState = {
|
||||
...state,
|
||||
promptFeedback: event.promptFeedback ?? state.promptFeedback,
|
||||
|
||||
@@ -176,14 +176,14 @@ export const InputItem = Schema.Union([
|
||||
HostedToolItem,
|
||||
])
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
export type HostedToolReplayItem = {
|
||||
export type ExtendedHostedToolItem = {
|
||||
readonly type: string
|
||||
readonly id: string
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| HostedToolReplayItem
|
||||
| ExtendedHostedToolItem
|
||||
| {
|
||||
readonly type: "message"
|
||||
readonly id?: string
|
||||
@@ -373,7 +373,7 @@ export const Event = Schema.StructWithRest(
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
|
||||
export interface ProviderAdapter {
|
||||
export interface Extension {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly lowerMedia?: (input: {
|
||||
@@ -381,10 +381,10 @@ export interface ProviderAdapter {
|
||||
readonly media: ProviderShared.NormalizedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
|
||||
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
|
||||
}
|
||||
|
||||
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
|
||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||
|
||||
export interface ParserState {
|
||||
readonly id: string
|
||||
@@ -397,9 +397,6 @@ export interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly outputItems: Readonly<Record<number, string>>
|
||||
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
|
||||
// Item ids are response-scoped identities. Keep completed ids tombstoned so
|
||||
// reconnect replay cannot reopen fragments already emitted downstream.
|
||||
readonly completedMessages: ReadonlySet<string>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
}
|
||||
|
||||
@@ -485,12 +482,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
|
||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
part: MediaPart,
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
extension: Extension,
|
||||
target: "message" | "tool-result",
|
||||
) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const providerMedia = adapter.lowerMedia?.({ part, media, request })
|
||||
if (providerMedia) return providerMedia
|
||||
const extended = extension.lowerMedia?.({ part, media, request })
|
||||
if (extended) return extended
|
||||
const url =
|
||||
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
|
||||
? part.data
|
||||
@@ -510,17 +507,17 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
const lowerUserContent = Effect.fnUntraced(function* (
|
||||
part: LLMRequest["messages"][number]["content"][number],
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
extension: Extension,
|
||||
) {
|
||||
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
||||
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
|
||||
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
|
||||
})
|
||||
|
||||
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
|
||||
const lowered = yield* lowerMedia(part, request, adapter, "message")
|
||||
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
|
||||
const lowered = yield* lowerMedia(part, request, extension, "message")
|
||||
if (lowered.type === "input_video")
|
||||
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
|
||||
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
|
||||
return lowered
|
||||
})
|
||||
|
||||
@@ -529,13 +526,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
|
||||
const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
||||
item: Content,
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
extension: Extension,
|
||||
) {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||
return yield* lowerMedia(
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
request,
|
||||
adapter,
|
||||
extension,
|
||||
"tool-result",
|
||||
)
|
||||
})
|
||||
@@ -543,33 +540,30 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
||||
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
|
||||
item: Content,
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
extension: Extension,
|
||||
) {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||
return yield* lowerMessageMedia(
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
request,
|
||||
adapter,
|
||||
extension,
|
||||
)
|
||||
})
|
||||
|
||||
const lowerToolResultOutput = Effect.fnUntraced(function* (
|
||||
part: ToolResultPart,
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
extension: Extension,
|
||||
) {
|
||||
// Text/json/error results are encoded as a plain string for backward
|
||||
// compatibility with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
|
||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
||||
const input: LoweredInputItem[] = []
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
@@ -577,13 +571,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
if (message.role === "system") {
|
||||
input.push({
|
||||
role: "developer",
|
||||
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
|
||||
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
|
||||
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
|
||||
if (content.length > 0) input.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
@@ -650,7 +644,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
? undefined
|
||||
: Schema.is(HostedToolItem)(part.result.value)
|
||||
? part.result.value
|
||||
: adapter.restoreHostedToolItem?.(part.result.value)
|
||||
: extension.lowerHostedToolItem?.(part.result.value)
|
||||
if (id !== undefined && hosted?.id === id) {
|
||||
if (!hostedToolItems.has(id)) {
|
||||
input.push(hosted)
|
||||
@@ -664,11 +658,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
|
||||
content: yield* Effect.forEach(content, (item) =>
|
||||
lowerHostedToolResultContentItem(item, request, extension),
|
||||
),
|
||||
})
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||
"text",
|
||||
"reasoning",
|
||||
"tool-call",
|
||||
@@ -681,11 +677,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: part.id,
|
||||
output: yield* lowerToolResultOutput(part, request, adapter),
|
||||
output: yield* lowerToolResultOutput(part, request, extension),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -737,28 +733,28 @@ const allowedToolChoice = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
|
||||
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
extension: Extension,
|
||||
) {
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return {
|
||||
model: request.model.id,
|
||||
input: yield* lowerMessages(request, adapter),
|
||||
input: yield* lowerMessages(request, extension),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(request.tools, (tool) =>
|
||||
lowerTool(
|
||||
adapter.name,
|
||||
extension.name,
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
),
|
||||
),
|
||||
tool_choice:
|
||||
allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
|
||||
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
@@ -772,7 +768,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
|
||||
|
||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
|
||||
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
@@ -955,16 +951,12 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id !== undefined) {
|
||||
const itemID = item.id
|
||||
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
|
||||
const phase = messagePhase(item.phase)
|
||||
const completedMessages = new Set(state.completedMessages)
|
||||
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
|
||||
// A new message closes earlier messages, including ones that never streamed.
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = [...state.lifecycle.text]
|
||||
.filter((id) => id !== itemID)
|
||||
.reduce((lifecycle, id) => {
|
||||
completedMessages.add(id)
|
||||
const openPhase = state.message?.id === id ? state.message.phase : undefined
|
||||
return Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
@@ -977,7 +969,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
completedMessages,
|
||||
message: {
|
||||
id: itemID,
|
||||
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
|
||||
@@ -1094,12 +1085,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id !== undefined) {
|
||||
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const completedMessages = new Set(state.completedMessages)
|
||||
completedMessages.add(item.id)
|
||||
if (state.message !== undefined && state.message.id !== item.id)
|
||||
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
|
||||
const message = state.message
|
||||
const message = state.message?.id === item.id ? state.message : undefined
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? message?.phase : itemPhase
|
||||
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
@@ -1112,13 +1098,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const text = content.length > 0 ? content.join("") : undefined
|
||||
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
const lifecycle =
|
||||
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
|
||||
completedMessages,
|
||||
message: undefined,
|
||||
message: message ? undefined : state.message,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
@@ -1422,9 +1408,9 @@ export const step = (state: ParserState, input: Event) => {
|
||||
* The provider-neutral Open Responses protocol. Provider-specific Responses
|
||||
* implementations compose this baseline with their own tools and event variants.
|
||||
*/
|
||||
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
|
||||
id: adapter.id,
|
||||
name: adapter.name,
|
||||
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
|
||||
id: extension.id,
|
||||
name: extension.name,
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
@@ -1432,7 +1418,6 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
|
||||
lifecycle: Lifecycle.initial(),
|
||||
outputItems: {},
|
||||
message: undefined,
|
||||
completedMessages: new Set<string>(),
|
||||
reasoningItems: {},
|
||||
})
|
||||
|
||||
|
||||
@@ -86,11 +86,11 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
const adapter = {
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.Extension
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
const native = tool.native?.openai
|
||||
@@ -125,9 +125,9 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* OpenResponses.fromRequestWithAdapter(
|
||||
const body = yield* OpenResponses.fromRequestWithExtension(
|
||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||
adapter,
|
||||
extension,
|
||||
)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
|
||||
@@ -204,7 +204,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request) => OpenResponses.initial(request, adapter),
|
||||
initial: (request) => OpenResponses.initial(request, extension),
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { MediaPart } from "../../schema/index.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
@@ -57,16 +57,6 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
|
||||
},
|
||||
})
|
||||
|
||||
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.invalidRequest("Bedrock Converse media data must be valid base64", cause),
|
||||
),
|
||||
)
|
||||
return Encoding.encodeBase64(bytes)
|
||||
})
|
||||
|
||||
// Route by MIME. Known image/document formats lower into a typed block; anything
|
||||
// else fails with a clear error instead of silently degrading to a malformed
|
||||
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
|
||||
@@ -76,7 +66,8 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
||||
const mime = part.mediaType.toLowerCase()
|
||||
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
|
||||
if (imageFormat) {
|
||||
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
return { image: { format: imageFormat, source: { bytes: media.base64 } } } satisfies ImageBlock
|
||||
}
|
||||
if (mime.startsWith("image/"))
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
|
||||
@@ -84,7 +75,8 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
||||
if (documentFormat) {
|
||||
if (!part.filename)
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
|
||||
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
return documentBlock(part.filename, documentFormat, media.base64)
|
||||
}
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
||||
})
|
||||
|
||||
@@ -36,15 +36,15 @@ const XAIResponsesBody = Schema.Struct({
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
|
||||
const adapter = {
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.Extension
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
|
||||
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
|
||||
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
|
||||
})
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
@@ -78,7 +78,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request) => OpenResponses.initial(request, adapter),
|
||||
initial: (request) => OpenResponses.initial(request, extension),
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LanguageModelSchema,
|
||||
ProviderOptions,
|
||||
} from "./options.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
|
||||
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
|
||||
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
|
||||
@@ -55,6 +56,11 @@ export const MediaPart = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Content.Media" })
|
||||
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
||||
|
||||
const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
||||
isRecord(value) &&
|
||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
||||
"value" in value
|
||||
|
||||
const toolResultValueSchema = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("json"),
|
||||
@@ -74,7 +80,6 @@ const toolResultValueSchema = Schema.Union([
|
||||
}),
|
||||
]).annotate({ identifier: "LLM.ToolResult" })
|
||||
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
|
||||
const isToolResultValue = Schema.is(toolResultValueSchema)
|
||||
|
||||
export const ToolResultValue = Object.assign(toolResultValueSchema, {
|
||||
is: isToolResultValue,
|
||||
|
||||
@@ -267,7 +267,7 @@ export const CachePolicyObject = Schema.Struct({
|
||||
Schema.Union([
|
||||
Schema.Literal("latest-user-message"),
|
||||
Schema.Literal("latest-assistant"),
|
||||
Schema.Struct({ tail: Schema.Natural }),
|
||||
Schema.Struct({ tail: Schema.Number }),
|
||||
]),
|
||||
),
|
||||
ttlSeconds: Schema.optional(Schema.Number),
|
||||
|
||||
@@ -631,45 +631,10 @@ describe("Bedrock Converse route", () => {
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains metadata usage that arrives before messageStop", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects metadata-only streams as incomplete with HTTP context", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(eventStreamBody(["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }])),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
http: {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.amazon.eventstream" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed tool call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
@@ -1464,20 +1429,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects image media that is not valid base64", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user({ type: "media", mediaType: "image/png", data: "https://example.test/image.png" })],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
|
||||
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers document media into Bedrock document blocks with format and name", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1601,37 +1552,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects remote media URLs in tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{
|
||||
type: "file",
|
||||
uri: "https://example.test/report.pdf",
|
||||
mime: "application/pdf",
|
||||
name: "report.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
|
||||
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported image media types", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
|
||||
@@ -62,62 +62,6 @@ describe("provider error retention", () => {
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("classifies a message-less Gemini 429 and retains its event and HTTP context", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({
|
||||
error: { code: 429, status: "RESOURCE_EXHAUSTED", details: { opaque: [1, 2] } },
|
||||
trace: { opaque: "outer" },
|
||||
})
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents(body), {
|
||||
headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" },
|
||||
}),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toBe("RESOURCE_EXHAUSTED")
|
||||
expect(error.reason._tag).toBe("RateLimit")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
|
||||
expect(error.reason.http?.url).toStartWith("https://provider.test/")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a malformed non-record Gemini error", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({ error: "RESOURCE_EXHAUSTED", trace: { opaque: "outer" } })
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
|
||||
).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("Invalid google/gemini stream event")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects and retains an explicit null Gemini error", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({ error: null, trace: { opaque: "outer" } })
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
|
||||
).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "trace-null" } })),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-null" } })
|
||||
expect(error.reason.http?.url).toStartWith("https://provider.test/")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains malformed provider frames and the original decode cause", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}'
|
||||
|
||||
@@ -82,32 +82,6 @@ describe("Open Responses completed item text", () => {
|
||||
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles a done-only message once across replayed item events", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
content: [{ type: "output_text", text: "Recovered" }],
|
||||
}
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
completed,
|
||||
)
|
||||
expect(response.text).toBe("Recovered")
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Recovered",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Open Responses completed item reasoning", () => {
|
||||
|
||||
@@ -216,63 +216,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
|
||||
Effect.gen(function* () {
|
||||
const text = {
|
||||
type: "message",
|
||||
id: "msg_text",
|
||||
content: [{ type: "output_text", text: "Done-only text." }],
|
||||
}
|
||||
const refusal = {
|
||||
type: "message",
|
||||
id: "msg_refusal",
|
||||
content: [{ type: "refusal", refusal: "Done-only refusal." }],
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item: text },
|
||||
{ type: "response.output_item.done", item: text },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
|
||||
},
|
||||
{ type: "response.output_item.done", item: refusal },
|
||||
{ type: "response.output_item.done", item: refusal },
|
||||
completed,
|
||||
)
|
||||
|
||||
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_text",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
|
||||
},
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_text",
|
||||
text: "Done-only text.",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
|
||||
},
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_refusal",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
|
||||
},
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_refusal",
|
||||
text: "Done-only refusal.",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats a repeated message lifecycle as replay", () =>
|
||||
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
@@ -289,44 +233,9 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores a stale done-only message while another message is active", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-delta", id: "msg_1", text: "Draft" },
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_1",
|
||||
text: "Final",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
|
||||
}),
|
||||
)
|
||||
;[undefined, "fc_1"].forEach((id) => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
QuotaExceededError,
|
||||
RateLimitError,
|
||||
RouteID,
|
||||
ToolResultValue,
|
||||
TransportError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
@@ -84,37 +83,6 @@ describe("llm schema", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("ToolResultValue", () => {
|
||||
test("uses the canonical schema guard", () => {
|
||||
const cases: ReadonlyArray<{ readonly value: unknown; readonly expected: boolean }> = [
|
||||
{ value: { type: "json", value: { ok: true } }, expected: true },
|
||||
{ value: { type: "text", value: "done" }, expected: true },
|
||||
{ value: { type: "error", value: "failed" }, expected: true },
|
||||
{ value: { type: "content", value: [{ type: "text", text: "done" }] }, expected: true },
|
||||
{ value: { type: "content", value: [{ type: "text" }] }, expected: false },
|
||||
{ value: { type: "content", value: "done" }, expected: false },
|
||||
{ value: { type: "json" }, expected: false },
|
||||
{ value: { type: "unknown", value: "done" }, expected: false },
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
expect(Schema.is(ToolResultValue)(item.value)).toBe(item.expected)
|
||||
expect(ToolResultValue.is(item.value)).toBe(item.expected)
|
||||
}
|
||||
})
|
||||
|
||||
test("accepts canonical results with extra fields", () => {
|
||||
expect(ToolResultValue.is({ type: "json", value: { ok: true }, metadata: { source: "tool" } })).toBe(true)
|
||||
expect(
|
||||
ToolResultValue.is({
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "https://example.test/result.txt", mime: "text/plain", checksum: "abc" }],
|
||||
metadata: { source: "tool" },
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AI.Usage", () => {
|
||||
test("subtractTokens clamps non-sensical breakdowns to zero", () => {
|
||||
// Defense against a provider reporting cached_tokens > prompt_tokens or
|
||||
|
||||
@@ -442,33 +442,6 @@ describe("LLMClient tools", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects malformed tagged dynamic output as opaque JSON", () =>
|
||||
Effect.gen(function* () {
|
||||
const malformed = { type: "content", value: [{ type: "text" }] }
|
||||
const dynamic = Tool.make({
|
||||
description: "Return caller-defined JSON.",
|
||||
jsonSchema: { type: "object", properties: {} },
|
||||
execute: () => Effect.succeed(malformed),
|
||||
})
|
||||
|
||||
const dispatched = yield* ToolRuntime.dispatch(
|
||||
{ dynamic },
|
||||
LLMEvent.toolCall({ id: "call_1", name: "dynamic", input: {} }),
|
||||
)
|
||||
|
||||
expect(dispatched.result).toEqual({ type: "json", value: malformed })
|
||||
expect(dispatched.output).toEqual({ structured: malformed, content: [] })
|
||||
expect(dispatched.events).toEqual([
|
||||
LLMEvent.toolResult({
|
||||
id: "call_1",
|
||||
name: "dynamic",
|
||||
result: { type: "json", value: malformed },
|
||||
output: { structured: malformed, content: [] },
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes tool calls for one step without looping by default", () =>
|
||||
Effect.gen(function* () {
|
||||
const layer = scriptedResponses([
|
||||
|
||||
@@ -1,45 +1,5 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
const composer = component.locator('[data-component="composer"]')
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
|
||||
})
|
||||
|
||||
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
|
||||
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
|
||||
const component = await mount(`opencode-composer-flow--${draft}`)
|
||||
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const text = await input.textContent()
|
||||
|
||||
for (let count = 0; count < 2; count++) {
|
||||
await input.press("ControlOrMeta+a")
|
||||
expect(
|
||||
await input.evaluate((editor) => {
|
||||
const selection = window.getSelection()
|
||||
return {
|
||||
text: selection?.toString(),
|
||||
inside: editor.contains(selection?.anchorNode ?? null) && editor.contains(selection?.focusNode ?? null),
|
||||
}
|
||||
}),
|
||||
).toEqual({ text, inside: true })
|
||||
}
|
||||
|
||||
await page.keyboard.type("Replacement draft")
|
||||
await expect(input).toHaveText("Replacement draft")
|
||||
await expect(component.getByRole("status")).toHaveText("Ready")
|
||||
if (draft === "mixed-attachments") {
|
||||
await expect(component.getByAltText("layout.png")).toBeVisible()
|
||||
await expect(component.getByText("Keep the normal flow flat", { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const replace = Element.prototype.replaceChildren
|
||||
|
||||
@@ -8,30 +8,6 @@ story.beforeEach(async ({ mount }) => {
|
||||
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("spaces the first mobile message without changing desktop spacing", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
mountTimelineVirtualizer({ count: 1, rowHeight: 60, immediate: true })
|
||||
}, fixture)
|
||||
const root = page.getByTestId("timeline-virtualizer-fixture")
|
||||
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
|
||||
const content = root.locator("[data-timeline-virtual-content]")
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
const gap = () =>
|
||||
root.locator('[data-timeline-key="user-message:message-0"]').evaluate((element) => {
|
||||
const viewport = element.closest("[data-scrollable]")!
|
||||
return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
|
||||
})
|
||||
await expect.poll(gap).toBe(16)
|
||||
await root.evaluate((element) => element.setAttribute("dir", "rtl"))
|
||||
await expect.poll(gap).toBe(16)
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect.poll(gap).toBe(0)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect.poll(gap).toBe(16)
|
||||
})
|
||||
|
||||
story("bounds the cheap suffix and reveals only ready measured rows", async ({ page }) => {
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Locator } from "@playwright/test"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
@@ -65,64 +65,11 @@ for (const lines of [6000, 25000]) {
|
||||
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
|
||||
expect(await events.evaluate((events) => events.count)).toBe(1)
|
||||
await expect(input).toBeFocused()
|
||||
await expectCaretVisible(input)
|
||||
const scroll = page.locator('[data-component="composer-scroll"]')
|
||||
await expect(scroll.locator(".scroll-view__viewport")).toHaveCSS("scrollbar-width", "none")
|
||||
await expect(scroll.locator(".scroll-view__thumb")).toBeVisible()
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(async () => (await input.innerText()) === text + "!").toBe(true)
|
||||
await expectCaretVisible(input)
|
||||
const thumb = await scroll.locator(".scroll-view__thumb").boundingBox()
|
||||
const bounds = await scroll.boundingBox()
|
||||
if (!thumb || !bounds) throw new Error("Missing composer scrollbar bounds")
|
||||
await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(thumb.x + thumb.width / 2, bounds.y + 8 + thumb.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect(scroll.locator(".scroll-view__viewport")).toHaveJSProperty("scrollTop", 0)
|
||||
await expect(input).toBeFocused()
|
||||
await page.keyboard.press("ControlOrMeta+Home")
|
||||
await page.keyboard.press("ControlOrMeta+End")
|
||||
await expectCaretVisible(input)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectCaretVisible(input: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
input.evaluate((element) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.isCollapsed || !selection.rangeCount || !element.contains(selection.anchorNode)) return false
|
||||
const caret = selection.getRangeAt(0).getBoundingClientRect()
|
||||
const viewport = (element.closest("[data-scrollable]") ?? element).getBoundingClientRect()
|
||||
return caret.height > 0 && caret.top >= viewport.top - 1 && caret.bottom <= viewport.bottom + 1
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
for (const width of [390, 1280]) {
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`reveals a multiline paste in the middle at ${width}px in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 })
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const suffix = "\nExisting trailing content".repeat(100)
|
||||
await input.fill("Before " + suffix)
|
||||
await input.press("ControlOrMeta+Home")
|
||||
await input.press("ArrowRight")
|
||||
const text = "Pasted line /tmp/example.ts 123 \u0645\u0631\u062d\u0628\u0627\n".repeat(100) + "End of paste"
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
await expect.poll(() => input.innerText()).toBe("B" + text + "efore " + suffix)
|
||||
await expectCaretVisible(input)
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(() => input.innerText()).toBe("B" + text + "!efore " + suffix)
|
||||
await expectCaretVisible(input)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const text of [
|
||||
"single line <b> &",
|
||||
"first\nsecond",
|
||||
|
||||
@@ -123,7 +123,7 @@ async function setup(page: Page) {
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
({ directory, server, sessionID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -131,8 +131,10 @@ async function setup(page: Page) {
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
@@ -142,6 +144,6 @@ async function setup(page: Page) {
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
{ directory, server, sessionID },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`mobile files browse, search, switch, and close shared file tabs in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
fileList: (path) =>
|
||||
path
|
||||
? []
|
||||
: ["first.ts", "second.ts"].map((name) => ({
|
||||
name,
|
||||
path: name,
|
||||
absolute: `${fixture.directory}/${name}`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
})),
|
||||
fileContent: (path) => `contents:${path}`,
|
||||
findFiles: ({ query }) => ["first.ts", "second.ts"].filter((path) => path.includes(query)),
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await expect(files.getByRole("button", { name: "first.ts", exact: true })).toBeVisible()
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
await expect(files.locator('[data-slot="session-mobile-files-header"]')).toHaveCSS("border-bottom-width", "0px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
files.getByRole("tablist", { name: "Open files", exact: true }).evaluate((element) => {
|
||||
const header = element.closest('[data-slot="session-mobile-files-header"]')!
|
||||
const separator = getComputedStyle(element, "::before")
|
||||
return {
|
||||
height: separator.height,
|
||||
fullWidth: parseFloat(separator.width) === header.clientWidth,
|
||||
start: separator.insetInlineStart,
|
||||
bottom: separator.bottom,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.toEqual({ height: "1px", fullWidth: true, start: "0px", bottom: "0px" })
|
||||
await files.getByRole("button", { name: "first.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await files.locator('[data-column-number="1"]').click()
|
||||
const editor = files.locator('[data-component="line-comment-v2"][data-variant="editor"]')
|
||||
await expect(editor.getByRole("textbox")).toBeVisible()
|
||||
for (const width of [390, 700]) {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const panel = await files.boundingBox()
|
||||
const comment = await editor.boundingBox()
|
||||
if (!panel || !comment) return false
|
||||
return Math.abs(comment.x - panel.x - 12) < 2 && Math.abs(comment.width - panel.width + 24) < 2
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
await editor.getByRole("textbox").fill("Full-width file comment")
|
||||
await editor.getByRole("button", { name: "Comment", exact: true }).click()
|
||||
const comment = files.locator('[data-component="line-comment-v2"][data-variant="display"]')
|
||||
await expect(comment).toContainText("Full-width file comment")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const panel = await files.boundingBox()
|
||||
const card = await comment.boundingBox()
|
||||
if (!panel || !card) return false
|
||||
return Math.abs(card.x - panel.x - 12) < 2 && Math.abs(card.width - panel.width + 24) < 2
|
||||
})
|
||||
.toBe(true)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeHidden()
|
||||
await files.getByRole("button", { name: "All files", exact: true }).click()
|
||||
await files.getByRole("combobox", { name: "Filter files", exact: true }).fill("second")
|
||||
await files.getByRole("option", { name: "second.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
|
||||
const openTabs = files.getByRole("tablist", { name: "Open files", exact: true })
|
||||
await expect(openTabs.getByRole("tab")).toHaveText(["first.ts", "second.ts"])
|
||||
await expect
|
||||
.poll(() =>
|
||||
openTabs.getByRole("tab", { name: "second.ts", exact: true }).evaluate((element) => {
|
||||
const tab = element.closest('[data-slot="tabs-v2-trigger-wrapper"]')!
|
||||
const header = element.closest('[data-slot="session-mobile-files-header"]')!
|
||||
return Math.abs(tab.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom) < 1
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await files
|
||||
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
|
||||
.filter({ has: page.getByRole("tab", { name: "first.ts", exact: true }) })
|
||||
.getByRole("button", { name: "Close tab", exact: true })
|
||||
.click()
|
||||
await expect(openTabs.getByRole("tab")).toHaveText(["second.ts"])
|
||||
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
|
||||
await files.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
|
||||
await expect(openTabs.getByRole("tab")).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("opening changed files selects the requested tab before file loading completes", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const loading = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
fileContent: async (path) => {
|
||||
if (path === "second.ts") await loading.promise
|
||||
return `contents:${path}`
|
||||
},
|
||||
vcsDiff: ["first.ts", "second.ts"].map((file) => ({
|
||||
file,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-before\n+after\n`,
|
||||
})),
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
for (const file of ["first.ts", "second.ts", "first.ts", "second.ts"]) {
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
const diff = page.locator(`[data-component="session-review"] [data-file="${file}"]`)
|
||||
const header = diff.getByRole("button", { name: file, exact: true })
|
||||
await expect(header).toBeEnabled()
|
||||
if ((await header.getAttribute("aria-expanded")) === "false") await header.click()
|
||||
await diff.getByRole("button", { name: "Open file", exact: true }).click()
|
||||
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(files.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
if (file === "second.ts") loading.resolve()
|
||||
await expect(files.getByText(`contents:${file}`, { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`mobile change summaries appear only for expanded diffs in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page, {
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "added.ts",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch:
|
||||
"diff --git a/added.ts b/added.ts\n--- /dev/null\n+++ b/added.ts\n@@ -0,0 +1 @@\n+export const added = 1\n",
|
||||
},
|
||||
{
|
||||
file: "removed.ts",
|
||||
status: "deleted",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
patch:
|
||||
"diff --git a/removed.ts b/removed.ts\n--- a/removed.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-export const removed = 1\n",
|
||||
},
|
||||
{
|
||||
file: "modified.ts",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `diff --git a/modified.ts b/modified.ts\n--- a/modified.ts\n+++ b/modified.ts\n@@ -1 +1 @@\n-export const value = 1\n+export const value = "${"long content ".repeat(30)}"\n`,
|
||||
},
|
||||
],
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expect(review.getByRole("button", { name: "Expand all", exact: true })).toBeVisible()
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
|
||||
await expect(review.locator('[data-file="added.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
|
||||
"border-top-width",
|
||||
"0px",
|
||||
)
|
||||
await expect(review.locator('[data-file="modified.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
|
||||
"border-bottom-width",
|
||||
"0px",
|
||||
)
|
||||
|
||||
for (const change of [
|
||||
{ file: "added.ts", status: "Added", additions: "+1", deletions: "-0" },
|
||||
{ file: "removed.ts", status: "Removed", additions: "+0", deletions: "-1" },
|
||||
{ file: "modified.ts", status: undefined, additions: "+1", deletions: "-1" },
|
||||
]) {
|
||||
const item = review.locator(`[data-file="${change.file}"]`)
|
||||
const trigger = item.getByRole("button", { name: change.file, exact: true })
|
||||
const summary = item.locator('[data-slot="session-review-change-summary"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(trigger.locator('[data-component="diff-changes"]')).toHaveCount(0)
|
||||
await expect(trigger.locator('[data-slot="session-review-change"]')).toHaveCount(0)
|
||||
await expect(summary).toHaveCount(0)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
if (change.file === "modified.ts") {
|
||||
await expect(item.locator('[data-slot="accordion-content"]')).toHaveCSS("border-bottom-width", "0px")
|
||||
await expect(trigger).not.toHaveCSS("border-bottom-width", "0px")
|
||||
}
|
||||
await expect(summary).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Open file", exact: true })).toBeVisible()
|
||||
await expect(summary.locator('[data-slot="diff-changes-additions"]')).toHaveText(change.additions)
|
||||
await expect(summary.locator('[data-slot="diff-changes-deletions"]')).toHaveText(change.deletions)
|
||||
if (change.status) await expect(summary.getByText(change.status, { exact: true })).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCount(0)
|
||||
}
|
||||
await expect(review.locator('[data-slot="session-review-view-button"]')).toHaveCount(0)
|
||||
const modified = review.locator('[data-file="modified.ts"]')
|
||||
await modified.getByRole("button", { name: "modified.ts", exact: true }).click()
|
||||
await expect(modified.locator("[data-line-number-content]")).toHaveText(["1", "1"])
|
||||
await expect(modified.locator("[data-diff]")).not.toHaveAttribute("data-disable-line-numbers")
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
|
||||
await expect(review.getByRole("button", { name: "Diff options", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const wrap = settings.getByRole("switch", { name: "Wrap lines", exact: true })
|
||||
const wrapControl = settings.locator('[data-action="settings-mobile-diff-wrap"] [data-slot="switch-control"]')
|
||||
await expect(wrap).toBeChecked()
|
||||
await wrapControl.click()
|
||||
await expect(wrap).not.toBeChecked()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
|
||||
.toBe(false)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
|
||||
await expect
|
||||
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
.toBe(true)
|
||||
await modified.locator("[data-code]").evaluate((element) => {
|
||||
element.scrollLeft = 100
|
||||
})
|
||||
await expect
|
||||
.poll(() => modified.locator("[data-code]").evaluate((element) => Math.abs(element.scrollLeft)))
|
||||
.toBeGreaterThan(0)
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(wrap).not.toBeChecked()
|
||||
await wrapControl.click()
|
||||
await expect(wrap).toBeChecked()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
|
||||
.toBe(true)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
|
||||
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
|
||||
await expect(openFile).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await openFile.boundingBox()
|
||||
const summary = await modified.locator('[data-slot="session-review-change-summary"]').boundingBox()
|
||||
if (!button || !summary) return false
|
||||
return direction === "ltr"
|
||||
? button.x > summary.x + summary.width / 2
|
||||
: button.x + button.width < summary.x + summary.width / 2
|
||||
})
|
||||
.toBe(true)
|
||||
await openFile.click()
|
||||
await expect(
|
||||
page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "Files", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true")
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await expect(files.getByRole("tab", { name: "modified.ts", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(files).toHaveAttribute("data-browsing", "false")
|
||||
})
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const position of ["top", "bottom"] as const) {
|
||||
test(`mobile session tabs switch views and keep the terminal cached with ${position} navigation`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(
|
||||
(position) =>
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: position } })),
|
||||
position,
|
||||
)
|
||||
await page.route("**/api/pty*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
|
||||
data: {
|
||||
id: "pty_mobile_views",
|
||||
title: "Terminal 1",
|
||||
command: "sh",
|
||||
args: [],
|
||||
cwd: fixture.directory,
|
||||
status: "running",
|
||||
pid: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/api/pty/pty_mobile_views/connect", () => undefined)
|
||||
await page.route("**/api/pty/pty_mobile_views/connect-token*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
|
||||
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
const more = navigation.getByRole("button", { name: "More options", exact: true })
|
||||
const picker = tabs.getByRole("tab", { selected: true })
|
||||
const message = page.locator(
|
||||
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
|
||||
)
|
||||
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(message).toBeVisible()
|
||||
await expect(composer).toBeVisible()
|
||||
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
|
||||
await expect(tabs).toHaveCSS("padding-left", "0px")
|
||||
await expect(tabs).toHaveCSS("padding-right", "0px")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await navigation.boundingBox()
|
||||
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bar = await tabs.boundingBox()
|
||||
const input = await composer.boundingBox()
|
||||
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
|
||||
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.locator("[data-session-title]")).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.targetTitle)
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
const drawer = page.getByRole("dialog", { name: "Tabs", exact: true })
|
||||
await expect(drawer).toHaveAttribute("data-open", "")
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
|
||||
await drawer.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await page.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(drawer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(picker).toHaveCount(0)
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
const usage = page.locator('[data-slot="session-usage-content"]')
|
||||
await expect(usage).toHaveCSS("padding-top", "16px")
|
||||
await expect(usage).toHaveCSS("padding-inline-start", "16px")
|
||||
await expect(usage).toHaveCSS("padding-inline-end", "16px")
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
const status = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await status.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
await expect(status.getByText("opencode.json", { exact: true })).toBeVisible()
|
||||
await status.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(status).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await details.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details).not.toHaveAttribute("data-transitioning")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await details.getByRole("button", { name: "No changes", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(picker).toHaveText("Changes")
|
||||
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("height", "40px")
|
||||
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(picker).toHaveText("Files")
|
||||
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
|
||||
const panel = page.locator("#terminal-panel")
|
||||
await expect(panel).toHaveAttribute("data-opened", "true")
|
||||
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
|
||||
await expect(panel.locator('[data-component="terminal"]')).toBeVisible()
|
||||
await expect(panel.locator("textarea")).toBeEditable()
|
||||
await expect(panel).toHaveCount(1)
|
||||
await panel.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await expect(message).toBeVisible()
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(panel).toHaveAttribute("inert", "")
|
||||
await expect(panel).toHaveAttribute("data-cache-probe", "original")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Terminal")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel).toHaveAttribute("data-cache-probe", "original")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Session")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Terminal")
|
||||
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(panel).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.sourceTitle)
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect(picker).toBeHidden()
|
||||
await expect(page.locator("[data-session-title]")).toBeVisible()
|
||||
})
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
|
||||
)
|
||||
},
|
||||
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
|
||||
)
|
||||
await page.goto("/")
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
|
||||
})
|
||||
|
||||
test("mobile project selection and drawer navigation preserve session identity", async ({ page }) => {
|
||||
const projects = page.getByRole("button", { name: "Projects", exact: true })
|
||||
await projects.click()
|
||||
const picker = page.getByRole("dialog", { name: "Projects", exact: true })
|
||||
await expect(picker.getByRole("button", { name: "All projects", exact: true })).toBeVisible()
|
||||
await picker.getByRole("button", { name: new RegExp(` ${fixture.project.name}$`) }).click()
|
||||
await expect(picker).toBeHidden()
|
||||
await expect(projects).toContainText(fixture.project.name)
|
||||
|
||||
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveText([fixture.project.name, fixture.project.name])
|
||||
const settings = drawer.getByRole("button", { name: "Settings", exact: true })
|
||||
const help = drawer.getByRole("button", { name: "Help", exact: true })
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(help).toBeVisible()
|
||||
expect((await settings.boundingBox())?.width).toBeGreaterThan((await help.boundingBox())?.width ?? 0)
|
||||
|
||||
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(trigger).toContainText(fixture.expected.sourceTitle)
|
||||
await trigger.click()
|
||||
await drawer.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
|
||||
})
|
||||
|
||||
test("mobile settings section menu stays above a full-width panel", async ({ page }) => {
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await page.locator('[data-slot="mobile-tabs-drawer"]').getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
|
||||
const panel = settings.getByRole("tabpanel")
|
||||
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.click()
|
||||
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeHidden()
|
||||
await expect(settings).toBeVisible()
|
||||
await menu.click()
|
||||
await page.getByRole("menuitemradio", { name: "Models", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Models", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Models", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("switch", { name: "Claude Opus 4.6", exact: true })).toBeAttached()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
|
||||
const content = await panel.boundingBox()
|
||||
return !!navigation && !!content && navigation.y + navigation.height <= content.y
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const more = page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
if (dismissal === "escape") await page.keyboard.press("Escape")
|
||||
if (dismissal === "drag") {
|
||||
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
|
||||
const bounds = await handle.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 + 1)
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, 843)
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect(drawer, `dismissal: ${dismissal}`).toBeHidden()
|
||||
await expect(overlay).toHaveCount(0)
|
||||
await expect(more).toBeFocused()
|
||||
}
|
||||
})
|
||||
@@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
({ directory, server, sessionID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -88,8 +88,10 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
@@ -99,7 +101,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
{ directory, server, sessionID },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
|
||||
@@ -64,7 +64,7 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
|
||||
location: project ? { directory: project } : {},
|
||||
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
|
||||
id,
|
||||
source: { type: "package", target: id },
|
||||
source: { type: "package", package: id },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
})),
|
||||
|
||||
@@ -89,7 +89,7 @@ async function openReview(page: Page) {
|
||||
)
|
||||
await changes.click()
|
||||
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
||||
await expect(changes).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
|
||||
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expectAppVisible(review)
|
||||
|
||||
@@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
({ directory, server, sessionID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -69,8 +69,10 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
@@ -80,7 +82,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
{ directory, server, sessionID },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
|
||||
@@ -22,7 +22,6 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
||||
await selectFile(page, "alpha.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await selectFile(page, "gamma.ts")
|
||||
|
||||
|
||||
@@ -18,72 +18,37 @@ const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// The review pane's data is workspace-scoped, but visibility belongs to each
|
||||
// session tab. Switching tabs must keep the pane mounted without opening it.
|
||||
test("keeps review visibility per tab and the pane mounted across tab switches", async ({ page }) => {
|
||||
// The v2 review pane's diff data is workspace-scoped: switching between session
|
||||
// tabs in the same workspace must update its parameters reactively instead of
|
||||
// tearing the pane down and remounting it (which flickers).
|
||||
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
|
||||
await setup(page)
|
||||
|
||||
await page.goto(sessionHref(sessionA))
|
||||
await expectSessionTitle(page, titleA)
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('[data-slot="session-chat-panel"]')
|
||||
.evaluate((element) => getComputedStyle(element).transitionDuration),
|
||||
)
|
||||
.toContain("0.24s")
|
||||
const reviewTab = page.locator("#session-side-panel-review-tab")
|
||||
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
|
||||
const chatPanel = page.locator('[data-slot="session-chat-panel"]')
|
||||
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
||||
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||
await expectAppVisible(review)
|
||||
await expect(chatPanel).toHaveCSS("width", "580px")
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
await writeProbe(page)
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expect(review).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('[data-slot="session-chat-panel"]')
|
||||
.evaluate((element) => getComputedStyle(element).transitionDuration),
|
||||
)
|
||||
.toBe("0s")
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectAppVisible(review)
|
||||
await expect(chatPanel).toHaveCSS("width", "520px")
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
await switchTab(page, titleA)
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expectAppVisible(review)
|
||||
await expect(chatPanel).toHaveCSS("width", "580px")
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
({ key }) => {
|
||||
const panes = JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")
|
||||
return panes[key]?.review
|
||||
},
|
||||
{ key: `${server}\n${sessionHref(sessionA)}` },
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expectAppVisible(review)
|
||||
await expect(chatPanel).toHaveCSS("width", "580px")
|
||||
|
||||
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
await viewport.hover()
|
||||
await page.mouse.wheel(0, 100_000)
|
||||
@@ -137,7 +102,7 @@ async function setup(page: Page) {
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions, panes }) => {
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -149,19 +114,8 @@ async function setup(page: Page) {
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
|
||||
}
|
||||
},
|
||||
{
|
||||
directory,
|
||||
server,
|
||||
sessions: [sessionA, sessionB],
|
||||
panes: {
|
||||
[`${server}\n${sessionHref(sessionA)}`]: { sessionWidth: 580 },
|
||||
[`${server}\n${sessionHref(sessionB)}`]: { sessionWidth: 520 },
|
||||
},
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -123,17 +123,12 @@ test("uses side placement by default and supports the terminal across the bottom
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.addInitScript(
|
||||
({ tabKey, server, sessionID }) => {
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`, server, sessionID },
|
||||
)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
})
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
|
||||
@@ -32,13 +32,12 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
|
||||
|
||||
const editor = page.getByRole("dialog", { name: "Add server" })
|
||||
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
|
||||
await expect(editor.getByPlaceholder("username")).toHaveCount(0)
|
||||
const name = editor.getByPlaceholder("Localhost", { exact: true })
|
||||
const username = editor.getByPlaceholder("username")
|
||||
const password = editor.getByPlaceholder("password")
|
||||
await name.click()
|
||||
await expect(name).toBeFocused()
|
||||
await name.fill("Remote")
|
||||
await expect(name).toHaveValue("Remote")
|
||||
await username.click()
|
||||
await expect(username).toBeFocused()
|
||||
await username.fill("kit")
|
||||
await expect(username).toHaveValue("kit")
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(password).toBeFocused()
|
||||
await password.fill("secret")
|
||||
|
||||
@@ -126,10 +126,6 @@ for (const create of [false, true]) {
|
||||
json: [
|
||||
{ directory: canonical, strategy: null },
|
||||
{ directory: "/projects/existing-worktree", strategy: "git" },
|
||||
...Array.from({ length: 20 }, (_, index) => ({
|
||||
directory: `/projects/worktree-${index}`,
|
||||
strategy: "git",
|
||||
})),
|
||||
],
|
||||
headers,
|
||||
})
|
||||
@@ -165,9 +161,7 @@ for (const create of [false, true]) {
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toBeVisible()
|
||||
listing.resolve()
|
||||
const existing = page.getByRole("menuitem", { name: "existing-worktree", exact: true })
|
||||
await expect(existing).toBeVisible()
|
||||
await expect(existing).toHaveCSS("height", "28px")
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menu")).toHaveCount(0)
|
||||
listing = Promise.withResolvers<void>()
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const sessions = fixture.sessions.map((session) => ({ ...session }))
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.route("**/api/session/*/rename", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const id = new URL(route.request().url()).pathname.split("/").at(-2)
|
||||
const session = sessions.find((item) => item.id === id)
|
||||
const payload: unknown = route.request().postDataJSON()
|
||||
if (
|
||||
!session ||
|
||||
!payload ||
|
||||
typeof payload !== "object" ||
|
||||
!("title" in payload) ||
|
||||
typeof payload.title !== "string"
|
||||
)
|
||||
throw new Error("Invalid rename request")
|
||||
session.title = payload.title
|
||||
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.goto("/")
|
||||
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle }).click()
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const commit of ["Enter", "blur", "click outside"]) {
|
||||
test(`saves the session heading on ${commit}`, async ({ page }) => {
|
||||
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill("Renamed session")
|
||||
if (commit === "Enter") await input.press("Enter")
|
||||
if (commit === "blur") await input.press("Tab")
|
||||
if (commit === "click outside") await page.locator('[data-component="composer-editor"]').click()
|
||||
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed session" })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("cancels the session heading with Escape", async ({ page }) => {
|
||||
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await input.fill("Discard this title")
|
||||
await input.press("Escape")
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("keeps the draft when saving the session heading fails", async ({ page }) => {
|
||||
await page.route("**/api/session/*/rename", (route) =>
|
||||
route.fulfill({ status: 500, headers: { "access-control-allow-origin": "*" } }),
|
||||
)
|
||||
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await input.fill("Retry this title")
|
||||
await input.press("Tab")
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await expect(input).toBeEnabled()
|
||||
await expect(input).toHaveValue("Retry this title")
|
||||
await expect(
|
||||
page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("does not save an empty session heading", async ({ page }) => {
|
||||
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await input.fill(" ")
|
||||
await input.press("Tab")
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("renames and closes the session tab from its context menu", async ({ page }) => {
|
||||
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
|
||||
await tab.click({ button: "right" })
|
||||
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeHidden()
|
||||
await expect(tab).toBeFocused()
|
||||
await tab.press("Shift+F10")
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill("Renamed from tab")
|
||||
await input.press("Enter")
|
||||
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
|
||||
const renamed = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from tab" })
|
||||
await renamed.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Close tab", exact: true }).click()
|
||||
await expect(renamed).toBeHidden()
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(
|
||||
page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from tab" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("renames an inactive tab without switching sessions", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click()
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
|
||||
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
|
||||
await tab.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill("Inactive tab renamed")
|
||||
await input.press("Tab")
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
|
||||
await page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Inactive tab renamed" }).click()
|
||||
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -85,7 +85,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
|
||||
data: [
|
||||
{
|
||||
id: "demo-plugin",
|
||||
source: { type: "package", target: "demo-plugin" },
|
||||
source: { type: "package", package: "demo-plugin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
|
||||
@@ -141,7 +141,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
|
||||
})
|
||||
|
||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Close Tab", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
})
|
||||
|
||||
test("mobile drawer exposes close controls and navigates between tabs", async ({ page }) => {
|
||||
test("cramped tabs only show the close button for the active tab", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 360, height: 720 })
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
@@ -89,31 +89,26 @@ test("mobile drawer exposes close controls and navigates between tabs", async ({
|
||||
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
await page.goto(hrefA)
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
|
||||
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
|
||||
await expect(tabA).toHaveAttribute("data-active", "true")
|
||||
await expect(tabB).toBeVisible()
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
|
||||
await tabB.locator(`a[href="${hrefB}"]`).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
await page.setViewportSize({ width: 450, height: 720 })
|
||||
await expect(tabA).toBeVisible()
|
||||
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "true")
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "false")
|
||||
await page.setViewportSize({ width: 450, height: 720 })
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -207,28 +202,28 @@ test("appearance experimental setting switches tab orientation", async ({ page }
|
||||
|
||||
await page.setViewportSize({ width: 920, height: 720 })
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
|
||||
await expect(settings.getByRole("tablist")).toBeHidden()
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
|
||||
|
||||
await page.setViewportSize({ width: 800, height: 720 })
|
||||
await expect(settings.getByRole("tablist")).toBeHidden()
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
|
||||
await expect(version).toBeInViewport()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 720 })
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(version).toBeInViewport()
|
||||
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
|
||||
await expect(version).toBeInViewport()
|
||||
await expect(version).toHaveCSS("direction", "ltr")
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 360 })
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
|
||||
await version.scrollIntoViewIfNeeded()
|
||||
await expect(version).toBeInViewport()
|
||||
|
||||
// Reload the UI-selected preference without seeding settings storage.
|
||||
await page.reload()
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
.locator('[data-slot="titlebar-tabs"]')
|
||||
.locator(`[data-titlebar-tab-link][href="${href}"]`)
|
||||
.getByText(sessionA.title, { exact: true }),
|
||||
).toBeVisible()
|
||||
@@ -247,7 +242,7 @@ test("appearance experimental setting switches tab orientation", async ({ page }
|
||||
await expect(layout).toContainText("Vertical")
|
||||
})
|
||||
|
||||
test("vertical tab preference uses the drawer on mobile", async ({ page }) => {
|
||||
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 720 })
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
@@ -264,8 +259,7 @@ test("vertical tab preference uses the drawer on mobile", async ({ page }) => {
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.goto(href)
|
||||
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
const tabs = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tabs = page.locator('[data-slot="titlebar-tabs"]')
|
||||
await expect(tabs.locator(`[data-titlebar-tab-link][href="${href}"]`)).toContainText(sessionA.title)
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -252,15 +252,8 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
|
||||
function seedCachedTerminal(page: Page) {
|
||||
return page.addInitScript(
|
||||
({ terminalKey, ptyID, tabKey, server, sessionID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs.panes",
|
||||
JSON.stringify({ [tabKey]: { terminal: true, terminalHeight: 320 } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
({ terminalKey, ptyID }) => {
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
|
||||
localStorage.setItem(
|
||||
terminalKey,
|
||||
JSON.stringify({
|
||||
@@ -269,13 +262,7 @@ function seedCachedTerminal(page: Page) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
terminalKey: terminalStorageKey(),
|
||||
ptyID,
|
||||
tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`,
|
||||
server,
|
||||
sessionID,
|
||||
},
|
||||
{ terminalKey: terminalStorageKey(), ptyID },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,10 @@ const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// Terminal processes are workspace-scoped, but panel visibility belongs to each
|
||||
// session tab. Switching tabs must keep the PTY alive without opening its panel.
|
||||
test("keeps terminal visibility per tab and the PTY alive across tab switches", async ({ page }) => {
|
||||
// Terminals are workspace-scoped: switching between session tabs in the same
|
||||
// workspace must keep the terminal mounted and its PTY connection open instead
|
||||
// of tearing it down and reconnecting.
|
||||
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
|
||||
const connections = await setup(page)
|
||||
|
||||
await page.goto(sessionHref(sessionA))
|
||||
@@ -26,10 +27,7 @@ test("keeps terminal visibility per tab and the PTY alive across tab switches",
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
const terminalPanel = page.locator('[data-component="terminal-panel"]')
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminalPanel).toHaveAttribute("data-size-animated", "true")
|
||||
await expect(terminalPanel).toHaveCSS("height", "300px")
|
||||
await expect.poll(() => connections.length).toBe(1)
|
||||
const connection = new URL(connections[0]!)
|
||||
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
||||
@@ -39,26 +37,15 @@ test("keeps terminal visibility per tab and the PTY alive across tab switches",
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expect(terminal).toBeHidden()
|
||||
await expect(terminalPanel).toHaveAttribute("data-size-animated", "false")
|
||||
await expect(terminal).toBeVisible()
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
expect(connections.length).toBe(1)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminalPanel).toHaveCSS("height", "180px")
|
||||
|
||||
await switchTab(page, titleA)
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminalPanel).toHaveCSS("height", "300px")
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
expect(connections.length).toBe(1)
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminalPanel).toHaveCSS("height", "300px")
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||
@@ -133,7 +120,7 @@ async function setup(page: Page) {
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions, panes }) => {
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -145,20 +132,8 @@ async function setup(page: Page) {
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
|
||||
}
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
|
||||
},
|
||||
{
|
||||
directory,
|
||||
server,
|
||||
sessions: [sessionA, sessionB],
|
||||
panes: {
|
||||
[`${server}\n${sessionHref(sessionA)}`]: { terminalHeight: 300 },
|
||||
[`${server}\n${sessionHref(sessionB)}`]: { terminalHeight: 180 },
|
||||
},
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
return connections
|
||||
}
|
||||
|
||||
@@ -75,12 +75,11 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expectBackground(view.send, "contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectToken(
|
||||
await expectBackground(
|
||||
message,
|
||||
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
|
||||
"background-color",
|
||||
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
|
||||
)
|
||||
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,16 +245,3 @@ async function expectBackground(element: Locator, token: string, property = "bac
|
||||
}, token)
|
||||
await expect(element).toHaveCSS(property, new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
|
||||
}
|
||||
|
||||
async function expectToken(element: Locator, property: string, token: string) {
|
||||
const color = await element.evaluate((element, token) => {
|
||||
const probe = document.createElement("span")
|
||||
probe.hidden = true
|
||||
probe.style.color = `var(${token})`
|
||||
element.append(probe)
|
||||
const color = getComputedStyle(probe).color
|
||||
probe.remove()
|
||||
return color
|
||||
}, token)
|
||||
await expect(element).toHaveCSS(property, color)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,6 @@ export async function expectAppVisible(locator: Locator) {
|
||||
}
|
||||
|
||||
export async function expectSessionTitle(page: Page, title: string) {
|
||||
if ((page.viewportSize()?.width ?? 1280) < 768) {
|
||||
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
|
||||
await expectAppVisible(trigger)
|
||||
await expect(trigger.locator('span[dir="auto"]')).toHaveText(title, { timeout: APP_READY_TIMEOUT })
|
||||
return
|
||||
}
|
||||
await expectAppVisible(page.getByRole("heading", { name: title }))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#080808",
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"vite-plugin-solid": "2.11.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
[data-component="composer-editor"]:empty::before {
|
||||
content: "\200B";
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { AttachmentCard } from "@opencode-ai/session-ui/attachment-card"
|
||||
import { CommentCard } from "@opencode-ai/session-ui/comment-card"
|
||||
import { typeLabel } from "@opencode-ai/session-ui/message-file"
|
||||
@@ -54,7 +53,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
const state = props.controller.state
|
||||
const view = props.controller.view
|
||||
let editor: HTMLDivElement | undefined
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let localInput = false
|
||||
const updateCursor = () => {
|
||||
if (!editor || !window.getSelection()?.isCollapsed) return
|
||||
@@ -147,14 +145,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<ScrollView
|
||||
data-component="composer-scroll"
|
||||
class="min-h-[60px] max-h-[180px]"
|
||||
viewportRef={(element) => {
|
||||
viewport = element
|
||||
element.tabIndex = -1
|
||||
}}
|
||||
>
|
||||
<div class="relative min-h-[60px]">
|
||||
<div
|
||||
ref={(element) => {
|
||||
editor = element
|
||||
@@ -171,7 +162,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
spellcheck={state.mode === "normal"}
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
class="relative z-10 block min-h-[60px] w-full whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
|
||||
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
|
||||
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
|
||||
style={{
|
||||
"unicode-bidi": state.mode === "normal" ? "plaintext" : undefined,
|
||||
@@ -199,20 +190,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
}}
|
||||
onKeyUp={updateCursor}
|
||||
onPointerUp={updateCursor}
|
||||
onPaste={(event) => {
|
||||
props.controller.onPaste(event)
|
||||
// Programmatic multiline insertion does not reliably reveal the caret.
|
||||
requestAnimationFrame(() => {
|
||||
const selection = window.getSelection()
|
||||
if (!editor || !viewport || !selection?.isCollapsed || !selection.rangeCount) return
|
||||
if (!editor.contains(selection.anchorNode)) return
|
||||
const caret = selection.getRangeAt(0).getBoundingClientRect()
|
||||
if (!caret.height) return
|
||||
const bounds = viewport.getBoundingClientRect()
|
||||
if (caret.bottom > bounds.bottom - 8) viewport.scrollTop += caret.bottom - bounds.bottom + 8
|
||||
if (caret.top < bounds.top + 8) viewport.scrollTop += caret.top - bounds.top - 8
|
||||
})
|
||||
}}
|
||||
onPaste={props.controller.onPaste}
|
||||
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
|
||||
/>
|
||||
<Show when={!props.controller.value()}>
|
||||
@@ -228,7 +206,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
: i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }))}
|
||||
</div>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
</div>
|
||||
|
||||
<div class="flex h-11 items-center px-2">
|
||||
<div
|
||||
|
||||
@@ -6,7 +6,6 @@ import { AppBaseProviders, AppInterface } from "@/app"
|
||||
import { loadInitialLocale } from "@/runtime/i18n/language"
|
||||
import { PlatformProvider } from "@/runtime/platform/platform"
|
||||
import { createWebPlatform } from "@/runtime/platform/web"
|
||||
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "@/runtime/platform/pwa"
|
||||
import en from "@/runtime/i18n/en"
|
||||
import zh from "@/runtime/i18n/zh"
|
||||
import { authFromToken } from "@/runtime/server/api"
|
||||
@@ -72,8 +71,6 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
void loadInitialLocale().then((locale) => {
|
||||
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
|
||||
clearAuthToken()
|
||||
const standalone = isStandalone()
|
||||
if (standalone) restorePwaRoute()
|
||||
const server: ServerConnection.Http = {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
@@ -90,9 +87,7 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
servers={[server]}
|
||||
>
|
||||
{standalone && <PwaRoutePersistence />}
|
||||
</AppInterface>
|
||||
/>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
|
||||
@@ -2,14 +2,9 @@ import type { HomeProjectsController } from "./controller"
|
||||
import { HomeProjectsView } from "./view"
|
||||
import type { HomeScrollController } from "../scroll"
|
||||
|
||||
export function HomeProjects(props: {
|
||||
projects: HomeProjectsController
|
||||
scroll: HomeScrollController
|
||||
dropdown?: boolean
|
||||
}) {
|
||||
export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
|
||||
return (
|
||||
<HomeProjectsView
|
||||
dropdown={props.dropdown}
|
||||
language={props.projects.copy.language}
|
||||
servers={props.projects.server.list()}
|
||||
projects={props.projects.project.list()}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||
@@ -29,7 +28,6 @@ const projectContextMenuID = (server: ServerConnection.Any, directory: string) =
|
||||
`project:${ServerConnection.key(server)}:${directory}`
|
||||
|
||||
export type HomeProjectsViewProps = {
|
||||
dropdown?: boolean
|
||||
language: ReturnType<typeof useLanguage>
|
||||
servers: ServerConnection.Any[]
|
||||
projects: LocalProject[]
|
||||
@@ -66,81 +64,6 @@ export type HomeProjectsViewProps = {
|
||||
}
|
||||
|
||||
export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
const [state, setState] = createStore({ open: false })
|
||||
const selected = createMemo(() => props.projects.find((project) => project.worktree === props.selection.directory))
|
||||
const server = createMemo(() =>
|
||||
props.servers.find((server) => ServerConnection.key(server) === props.selection.server),
|
||||
)
|
||||
return (
|
||||
<Show when={props.dropdown} fallback={<HomeProjectsPanel {...props} />}>
|
||||
<Popover
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
placement="bottom-start"
|
||||
sameWidth
|
||||
gutter={6}
|
||||
>
|
||||
<Popover.Trigger
|
||||
data-component="home-projects-dropdown"
|
||||
aria-label={props.language.t("home.projects")}
|
||||
class="flex h-10 w-full min-w-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-base px-1.5 text-start text-v2-text-text-base outline-none hover:bg-v2-background-bg-layer-01"
|
||||
>
|
||||
<Show when={selected()} fallback={<Icon name="folder" size="small" />}>
|
||||
{(project) => <HomeProjectAvatar project={project()} />}
|
||||
</Show>
|
||||
<span class="flex min-w-0 flex-1 flex-col leading-[var(--line-height-compact)]">
|
||||
<bdi class="truncate">
|
||||
<Show when={selected()} fallback={props.language.t("home.projects.all")}>
|
||||
{(project) => displayName(project())}
|
||||
</Show>
|
||||
</bdi>
|
||||
<Show when={props.servers.length > 1 && server()}>
|
||||
{(server) => (
|
||||
<bdi class="truncate text-v2-text-text-muted opacity-70">
|
||||
{server().displayName ?? new URL(server().http.url).host}
|
||||
</bdi>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
aria-label={props.language.t("home.projects")}
|
||||
dir={props.language.direction()}
|
||||
class="z-50 max-h-[min(70dvh,var(--kb-popper-content-available-height))] overflow-hidden rounded-[10px] bg-v2-background-bg-base p-1.5 shadow-[var(--v2-elevation-floating)] outline-none data-[expanded]:animate-in data-[expanded]:fade-in data-[expanded]:slide-in-from-top-2 duration-150 ease-out motion-reduce:animate-none"
|
||||
>
|
||||
<HomeProjectsPanel
|
||||
{...props}
|
||||
onSelectProject={(server, directory) => {
|
||||
props.onSelectProject(server, directory)
|
||||
setState("open", false)
|
||||
}}
|
||||
onFocusServer={(server) => {
|
||||
props.onFocusServer(server)
|
||||
setState("open", false)
|
||||
}}
|
||||
onChooseProject={(server) => {
|
||||
setState("open", false)
|
||||
props.onChooseProject(server)
|
||||
}}
|
||||
onAddProjects={(server, directories) => {
|
||||
setState("open", false)
|
||||
props.onAddProjects(server, directories)
|
||||
}}
|
||||
onOpenProjectNewSession={(server, directory) => {
|
||||
setState("open", false)
|
||||
props.onOpenProjectNewSession(server, directory)
|
||||
}}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectsPanel(props: HomeProjectsViewProps) {
|
||||
const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined })
|
||||
const contextMenuProps = {
|
||||
contextMenuOpen: (id: string) => contextMenu.open === id,
|
||||
@@ -148,77 +71,48 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
|
||||
}
|
||||
return (
|
||||
<aside
|
||||
class={
|
||||
props.dropdown
|
||||
? "flex max-h-[min(60dvh,calc(var(--kb-popper-content-available-height)-12px))] min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
: `
|
||||
class={`
|
||||
mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden
|
||||
lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]
|
||||
`
|
||||
}
|
||||
`}
|
||||
aria-label={props.language.t("home.projects")}
|
||||
onWheel={(event) => {
|
||||
if (event.target === event.currentTarget) return
|
||||
props.onWheel(event)
|
||||
}}
|
||||
>
|
||||
<Show when={!props.dropdown}>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
|
||||
<Tooltip placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButton
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<Icon name="folder-add-left" />}
|
||||
disabled={props.serverHealth(props.servers[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers[0])}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||
<Show when={props.dropdown && props.servers.length === 1}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
class="mb-1"
|
||||
data-selected={!props.selection.directory ? "" : undefined}
|
||||
onClick={() => props.onFocusServer(props.servers[0])}
|
||||
>
|
||||
<Icon name="folder" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.projects.all")}</span>
|
||||
</HomeProjectNavButton>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
|
||||
<Tooltip placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButton
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<Icon name="folder-add-left" />}
|
||||
disabled={props.serverHealth(props.servers[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers[0])}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||
<Show
|
||||
when={props.servers.length > 1}
|
||||
fallback={
|
||||
<div class={props.dropdown ? "" : "pr-3"}>
|
||||
<div class="pr-3">
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
|
||||
>
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
|
||||
<Show when={props.dropdown}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
class="mt-1 disabled:opacity-60"
|
||||
disabled={props.serverHealth(props.servers[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers[0])}
|
||||
>
|
||||
<Icon name="folder-add-left" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||
</HomeProjectNavButton>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class={`flex min-w-0 flex-col ${props.dropdown ? "gap-1" : "gap-4 pr-3"}`}>
|
||||
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||
<For each={props.servers}>
|
||||
{(item) => {
|
||||
const projects = () => props.projectsForServer(item)
|
||||
@@ -263,9 +157,7 @@ export function HomeUtilityNav(props: {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
class={`${props.class ?? ""} min-w-0 flex-row justify-between gap-1 lg:flex-col lg:justify-start lg:pr-3 [&>button]:w-auto lg:[&>button]:w-full`}
|
||||
>
|
||||
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
@@ -619,7 +511,6 @@ function HomeProjectRow(
|
||||
// does not focus that server and load its session index. Touch is
|
||||
// excluded so flick-scrolling the list cannot select rows.
|
||||
pointerDownSelected = undefined
|
||||
if (props.dropdown) return
|
||||
if (event.button !== 0 || event.pointerType === "touch") return
|
||||
if (!props.serverSelected) return
|
||||
pointerDownSelected = props.selected
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { Show } from "solid-js"
|
||||
import { createHomeController } from "./model"
|
||||
import { createHomeProjectsController } from "./projects/controller"
|
||||
import { HomeUtilityNav } from "./projects/view"
|
||||
@@ -11,7 +9,6 @@ import { createHomeSessionsController } from "./sessions/controller"
|
||||
import { HomeSessions } from "./sessions/region"
|
||||
|
||||
export function Home() {
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const home = createHomeController()
|
||||
const projects = createHomeProjectsController(home)
|
||||
const sessions = createHomeSessionsController(home)
|
||||
@@ -20,17 +17,12 @@ export function Home() {
|
||||
return (
|
||||
<div
|
||||
class={`
|
||||
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
|
||||
mx-2 mb-2 mt-[var(--shell-top-inset,8px)] min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
|
||||
`}
|
||||
>
|
||||
<Show when={mobile()}>
|
||||
<div class="relative z-40 -mb-3 shrink-0 px-3 pt-3">
|
||||
<HomeProjects projects={projects} scroll={scroll} dropdown />
|
||||
</div>
|
||||
</Show>
|
||||
<ScrollView
|
||||
class="min-h-0 flex-1 [container-type:size]"
|
||||
class="h-full [container-type:size]"
|
||||
thumbContainer={scroll.viewport.thumbTrack()}
|
||||
thumbHoverTarget={scroll.viewport.hoverTarget()}
|
||||
viewportRef={scroll.viewport.setViewport}
|
||||
@@ -39,24 +31,20 @@ export function Home() {
|
||||
>
|
||||
<div
|
||||
class={`
|
||||
mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)] gap-4 px-3
|
||||
max-md:grid-rows-[minmax(0,1fr)] lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
|
||||
mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3
|
||||
lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
|
||||
`}
|
||||
>
|
||||
<Show when={!mobile()}>
|
||||
<HomeProjects projects={projects} scroll={scroll} />
|
||||
</Show>
|
||||
<HomeProjects projects={projects} scroll={scroll} />
|
||||
<HomeSessions sessions={sessions} search={search} scroll={scroll} />
|
||||
<HomeUtilityNav
|
||||
class="flex lg:hidden"
|
||||
onOpenSettings={projects.utility.settings}
|
||||
onOpenHelp={projects.utility.help}
|
||||
language={projects.copy.language}
|
||||
/>
|
||||
</div>
|
||||
</ScrollView>
|
||||
<div class="hidden shrink-0 px-3 py-2 md:block lg:hidden">
|
||||
<HomeUtilityNav
|
||||
class="flex"
|
||||
onOpenSettings={projects.utility.settings}
|
||||
onOpenHelp={projects.utility.help}
|
||||
language={projects.copy.language}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
@media (max-width: 767px) {
|
||||
[data-component="home-session-row-container"][data-project-name="true"],
|
||||
[data-component="home-session-row-container"][data-project-name="true"] [data-component="home-session-row"],
|
||||
[data-component="home-session-row-container"][data-project-name="true"] [data-slot="home-session-editor"],
|
||||
[data-component="home-session-search-row"][data-project-name="true"] {
|
||||
height: 48px;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
[data-project-name="true"] [data-slot="home-session-labels"] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
[data-project-name="true"] [data-slot="home-session-labels"] > * {
|
||||
max-width: 100%;
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { shouldOpenSessionInBackground } from "./open"
|
||||
import "./view.css"
|
||||
import {
|
||||
HomeSessionStatusController,
|
||||
homeSessionSearchKey,
|
||||
@@ -97,13 +96,10 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col"
|
||||
aria-label={props.language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-1 pt-6 md:pb-3 lg:pt-12"
|
||||
onWheel={props.onWheel}
|
||||
>
|
||||
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
|
||||
<HomeSessionSearch {...props} />
|
||||
<Show when={props.groups.length > 0 && props.canCreateSession}>
|
||||
<div class="pointer-events-none absolute right-0 top-[68px] z-20 flex md:top-[84px] lg:top-[108px]">
|
||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||
<Button
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
@@ -117,18 +113,18 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pointer-events-none sticky top-[68px] z-40 h-0 -mr-3 md:top-[84px] lg:top-[108px]">
|
||||
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
||||
<div
|
||||
ref={props.onSetThumbTrack}
|
||||
data-component="home-session-scroll-track"
|
||||
class="relative ml-auto h-[calc(100cqh-68px)] w-3 md:h-[calc(100cqh-84px)] lg:h-[calc(100cqh-108px)]"
|
||||
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
|
||||
/>
|
||||
</div>
|
||||
<div class="-mr-3 min-h-[calc(100cqh-64px)] md:min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
||||
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
||||
<Show
|
||||
when={!props.loading}
|
||||
fallback={
|
||||
<div class="pt-1 md:pt-3">
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={props.language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
@@ -142,7 +138,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div ref={props.onSetContent} class="flex flex-col pt-1 pr-3 pb-16 md:pt-3">
|
||||
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
{/* Index keeps group subtrees mounted when the group arrays are
|
||||
rebuilt, so store updates cannot recreate rows mid-gesture. */}
|
||||
<Index each={props.groups}>
|
||||
@@ -154,9 +150,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
onSetRef={(element) => props.onSetHeader(group().id, element)}
|
||||
elevated={index === 0}
|
||||
/>
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-2 md:pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<div class={`flex min-w-0 flex-col gap-px pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}>
|
||||
{/* Rows key by session ID: session.sync replaces the
|
||||
stored session object wholesale, so reference-keyed
|
||||
rows would be disposed mid-interaction whenever a
|
||||
@@ -241,11 +235,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
absolute flex flex-col overflow-hidden rounded-[12px]
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]
|
||||
`}
|
||||
style={{
|
||||
top: "-6px",
|
||||
"inset-inline-start": "-6px",
|
||||
width: "calc(100% + 12px)",
|
||||
}}
|
||||
style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
|
||||
>
|
||||
<div class="flex flex-col pt-9">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
||||
@@ -279,7 +269,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
>
|
||||
{props.language.t("home.sessions.search.sessions")}
|
||||
</p>
|
||||
<ScrollView class="max-h-[min(20rem,40dvh)]" viewportRef={props.onSetSearchList}>
|
||||
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
|
||||
<div class="flex flex-col gap-px pb-2">
|
||||
<For each={props.searchResults}>
|
||||
{(record) => (
|
||||
@@ -301,7 +291,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
</Show>
|
||||
<label
|
||||
class={`
|
||||
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 ps-3 pe-2
|
||||
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2
|
||||
bg-v2-background-bg-layer-02/60 text-v2-icon-icon-muted transition-[background-color,box-shadow]
|
||||
duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02
|
||||
`}
|
||||
@@ -385,7 +375,6 @@ function HomeSessionSearchResultRow(
|
||||
id={`home-session-search-option-${key()}`}
|
||||
data-key={key()}
|
||||
data-component="home-session-search-row"
|
||||
data-project-name={!!showProjectName()}
|
||||
role="option"
|
||||
aria-selected={props.selected}
|
||||
class={`
|
||||
@@ -414,7 +403,7 @@ function HomeSessionSearchResultRow(
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<div data-slot="home-session-labels" class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} search />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} search />
|
||||
@@ -434,8 +423,8 @@ function HomeSessionGroupHeader(props: {
|
||||
<div
|
||||
ref={props.onSetRef}
|
||||
class={`
|
||||
pointer-events-none sticky top-[68px] flex h-7 min-w-0 items-center justify-between
|
||||
bg-v2-background-bg-base ps-1.5 md:ps-3 md:top-[84px] lg:top-[108px]
|
||||
pointer-events-none sticky top-[84px] flex h-7 min-w-0 items-center justify-between
|
||||
bg-v2-background-bg-base pl-3 lg:top-[108px]
|
||||
`}
|
||||
classList={{ "home-session-group-header z-[5]": !!props.elevated, "z-10": !props.elevated }}
|
||||
>
|
||||
@@ -521,7 +510,6 @@ function HomeSessionRow(
|
||||
return (
|
||||
<div
|
||||
data-component="home-session-row-container"
|
||||
data-project-name={!!showProjectName()}
|
||||
data-session-id={props.record.session.id}
|
||||
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px] outline-none focus:outline-none focus-visible:outline-none"
|
||||
classList={{ group: !!showProjectName() }}
|
||||
@@ -535,56 +523,51 @@ function HomeSessionRow(
|
||||
<Show
|
||||
when={!editor()}
|
||||
fallback={
|
||||
<div
|
||||
data-slot="home-session-editor"
|
||||
class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 ps-1.5 pe-3 md:ps-3 md:pe-10"
|
||||
>
|
||||
<div class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 pl-3 pr-10">
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={false}
|
||||
/>
|
||||
<div data-slot="home-session-labels" class="contents">
|
||||
<InlineInput
|
||||
data-component="home-session-rename"
|
||||
aria-label={props.language.t("common.rename")}
|
||||
dir="auto"
|
||||
value={editor()?.draft ?? ""}
|
||||
disabled={editor()?.renaming ?? false}
|
||||
class={`
|
||||
<InlineInput
|
||||
data-component="home-session-rename"
|
||||
aria-label={props.language.t("common.rename")}
|
||||
dir="auto"
|
||||
value={editor()?.draft ?? ""}
|
||||
disabled={editor()?.renaming ?? false}
|
||||
class={`
|
||||
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
|
||||
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
|
||||
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
|
||||
`}
|
||||
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
|
||||
onInput={(event) => {
|
||||
const draft = event.currentTarget.value
|
||||
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
// Enter and Escape during IME composition commit or cancel
|
||||
// the composition, not the rename. Safari can report the
|
||||
// composition-confirming keydown with isComposing false but
|
||||
// keyCode 229.
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveEditor()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
|
||||
onInput={(event) => {
|
||||
const draft = event.currentTarget.value
|
||||
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
// Enter and Escape during IME composition commit or cancel
|
||||
// the composition, not the rename. Safari can report the
|
||||
// composition-confirming keydown with isComposing false but
|
||||
// keyCode 229.
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
closeEditor()
|
||||
requestAnimationFrame(() => rowButton()?.focus())
|
||||
}}
|
||||
onBlur={closeEditor}
|
||||
/>
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</div>
|
||||
void saveEditor()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
closeEditor()
|
||||
requestAnimationFrame(() => rowButton()?.focus())
|
||||
}}
|
||||
onBlur={closeEditor}
|
||||
/>
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -595,7 +578,7 @@ function HomeSessionRow(
|
||||
aria-expanded={!!menu()}
|
||||
class={`
|
||||
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
|
||||
bg-transparent py-3 ps-1.5 pe-3 md:ps-3 md:pe-10 text-start text-v2-text-text-muted [font-weight:530]
|
||||
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
|
||||
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
|
||||
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
|
||||
`}
|
||||
@@ -658,12 +641,10 @@ function HomeSessionRow(
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<div data-slot="home-session-labels" class="contents">
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</div>
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Menu
|
||||
@@ -742,7 +723,6 @@ function HomeSessionTitle(props: { title: string; showProjectName: boolean; sear
|
||||
return (
|
||||
<span
|
||||
data-component="home-session-title"
|
||||
dir="auto"
|
||||
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
|
||||
classList={{
|
||||
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
|
||||
@@ -758,8 +738,6 @@ function HomeSessionTitle(props: { title: string; showProjectName: boolean; sear
|
||||
function HomeSessionProjectName(props: { name: string; search?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
data-component="home-session-project-name"
|
||||
dir="auto"
|
||||
class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
|
||||
classList={{ "text-[13px] leading-4 tracking-[-0.04px]": !!props.search }}
|
||||
>
|
||||
@@ -801,7 +779,7 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT
|
||||
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 ps-1.5 pe-4 md:ps-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">
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<NewSessionStatus visible={settings.visibility.status()} />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("pluginLabels", () => {
|
||||
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
|
||||
{
|
||||
id: "package-plugin",
|
||||
source: { type: "package", target: "example" },
|
||||
source: { type: "package", package: "example" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
|
||||
|
||||
export function pluginLabel(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.target
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import createPresence from "solid-presence"
|
||||
|
||||
export function createAnimatedPresence<T>(
|
||||
value: Accessor<T | undefined>,
|
||||
element: Accessor<HTMLElement | null>,
|
||||
identity?: Accessor<unknown>,
|
||||
) {
|
||||
const animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>(
|
||||
(previous) => {
|
||||
const currentIdentity = identity?.()
|
||||
const current = value()
|
||||
const show = current !== undefined
|
||||
const same = !identity || previous?.identity === currentIdentity
|
||||
return {
|
||||
identity: currentIdentity,
|
||||
show,
|
||||
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
|
||||
value: current ?? (same ? previous?.value : undefined),
|
||||
}
|
||||
},
|
||||
)
|
||||
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
|
||||
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
|
||||
const current = value()
|
||||
const show = current !== undefined
|
||||
return {
|
||||
show,
|
||||
animate: previous !== undefined && (previous.animate || previous.show !== show),
|
||||
value: current ?? previous?.value,
|
||||
}
|
||||
})
|
||||
const presence = createPresence({ show: () => animation().show, element })
|
||||
return {
|
||||
...presence,
|
||||
|
||||
@@ -414,6 +414,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "አገልጋይ አክል",
|
||||
"dialog.server.add.name": "የአገልጋይ ስም (አማራጭ)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "የተጠቃሚ ስም (አማራጭ)",
|
||||
"dialog.server.add.usernamePlaceholder": "የተጠቃሚ ስም",
|
||||
"dialog.server.add.password": "የይለፍ ቃል (አማራጭ)",
|
||||
"dialog.server.add.passwordPlaceholder": "የይለፍ ቃል",
|
||||
"dialog.server.edit.title": "አገልጋይ አርትዕ",
|
||||
@@ -496,6 +498,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "ዴስክቶፕ {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "የተጫነው ሥሪት ከዴስክቶፕ መተግበሪያ ሥሪት ጋር አይዛመድም።",
|
||||
"wsl.onboarding.adding": "በማከል ላይ...",
|
||||
"server.row.noUsername": "የተጠቃሚ ስም የለም",
|
||||
"dialog.project.edit.title": "ፕሮጀክት አርትዕ",
|
||||
"dialog.project.edit.name": "ስም",
|
||||
"dialog.project.edit.icon": "አዶ",
|
||||
|
||||
@@ -420,6 +420,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "إضافة خادم",
|
||||
"dialog.server.add.name": "اسم الخادم (اختياري)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "اسم المستخدم (اختياري)",
|
||||
"dialog.server.add.password": "كلمة المرور (اختياري)",
|
||||
"dialog.server.edit.title": "تحرير الخادم",
|
||||
"dialog.server.default.title": "الخادم الافتراضي",
|
||||
@@ -1023,7 +1024,9 @@ export const dict = {
|
||||
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
|
||||
"app.server.retrying": "جارٍ إعادة المحاولة تلقائيًا...",
|
||||
"app.server.otherServers": "خوادم أخرى",
|
||||
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
|
||||
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
|
||||
"server.row.noUsername": "لا يوجد اسم مستخدم",
|
||||
"session.review.noVcs.createGit.title": "إنشاء مستودع Git",
|
||||
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
|
||||
"session.review.noVcs.createGit.actionLoading": "جارٍ إنشاء مستودع Git...",
|
||||
|
||||
@@ -421,6 +421,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Server əlavə et",
|
||||
"dialog.server.add.name": "Server adı (istəyə bağlı)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "İstifadəçi adı (istəyə bağlı)",
|
||||
"dialog.server.add.usernamePlaceholder": "istifadəçi adı",
|
||||
"dialog.server.add.password": "Şifrə (istəyə bağlı)",
|
||||
"dialog.server.add.passwordPlaceholder": "parol",
|
||||
"dialog.server.edit.title": "Serveri redaktə et",
|
||||
@@ -507,6 +509,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "masaüstü {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Quraşdırılmış versiya masaüstü proqram versiyasına uyğun gəlmir.",
|
||||
"wsl.onboarding.adding": "Əlavə edilir...",
|
||||
"server.row.noUsername": "istifadəçi adı yoxdur",
|
||||
"dialog.project.edit.title": "Layihəni redaktə et",
|
||||
"dialog.project.edit.name": "Ad",
|
||||
"dialog.project.edit.icon": "İkon",
|
||||
|
||||
@@ -421,6 +421,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Добавете сървър",
|
||||
"dialog.server.add.name": "Име на сървъра (по избор)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "потребителско име (по избор)",
|
||||
"dialog.server.add.usernamePlaceholder": "потребителско име",
|
||||
"dialog.server.add.password": "Парола (по избор)",
|
||||
"dialog.server.add.passwordPlaceholder": "парола",
|
||||
"dialog.server.edit.title": "Редактиране на сървъра",
|
||||
@@ -506,6 +508,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "работен плот {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Инсталираната версия не съответства на версията на настолното приложение.",
|
||||
"wsl.onboarding.adding": "Добавяне...",
|
||||
"server.row.noUsername": "няма потребителско име",
|
||||
"dialog.project.edit.title": "Редактиране на проекта",
|
||||
"dialog.project.edit.name": "Име",
|
||||
"dialog.project.edit.icon": "Икона",
|
||||
|
||||
@@ -418,6 +418,8 @@ export const dict: Record<string, string> = {
|
||||
"dialog.server.add.button": "সার্ভার যোগ করুন",
|
||||
"dialog.server.add.name": "সার্ভারের নাম (ঐচ্ছিক)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "ব্যবহারকারীর নাম (ঐচ্ছিক)",
|
||||
"dialog.server.add.usernamePlaceholder": "ব্যবহারকারীর নাম",
|
||||
"dialog.server.add.password": "পাসওয়ার্ড (ঐচ্ছিক)",
|
||||
"dialog.server.add.passwordPlaceholder": "পাসওয়ার্ড",
|
||||
"dialog.server.edit.title": "সার্ভার সম্পাদনা করুন",
|
||||
@@ -503,6 +505,7 @@ export const dict: Record<string, string> = {
|
||||
"wsl.onboarding.desktopVersion": "ডেস্কটপ {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "ইনস্টল করা সংস্করণ ডেস্কটপ অ্যাপ সংস্করণের সাথে মেলে না।",
|
||||
"wsl.onboarding.adding": "যোগ করা হচ্ছে...",
|
||||
"server.row.noUsername": "ব্যবহারকারীর নাম নেই",
|
||||
"dialog.project.edit.title": "প্রকল্প সম্পাদনা করুন",
|
||||
"dialog.project.edit.name": "নাম",
|
||||
"dialog.project.edit.icon": "আইকন",
|
||||
|
||||
@@ -422,6 +422,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "Adicionar servidor",
|
||||
"dialog.server.add.name": "Nome do servidor (opcional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nome de usuário (opcional)",
|
||||
"dialog.server.add.password": "Senha (opcional)",
|
||||
"dialog.server.edit.title": "Editar servidor",
|
||||
"dialog.server.default.title": "Servidor padrão",
|
||||
@@ -1034,7 +1035,9 @@ export const dict = {
|
||||
"app.server.unreachable": "Não foi possível conectar a {{server}}",
|
||||
"app.server.retrying": "Tentando novamente automaticamente...",
|
||||
"app.server.otherServers": "Outros servidores",
|
||||
"dialog.server.add.usernamePlaceholder": "nome de usuário",
|
||||
"dialog.server.add.passwordPlaceholder": "senha",
|
||||
"server.row.noUsername": "sem nome de usuário",
|
||||
"session.review.noVcs.createGit.title": "Criar um repositório Git",
|
||||
"session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto",
|
||||
"session.review.noVcs.createGit.actionLoading": "Criando repositório Git...",
|
||||
|
||||
@@ -449,6 +449,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "Dodaj server",
|
||||
"dialog.server.add.name": "Ime servera (opcionalno)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Korisničko ime (opcionalno)",
|
||||
"dialog.server.add.password": "Lozinka (opcionalno)",
|
||||
"dialog.server.edit.title": "Uredi server",
|
||||
"dialog.server.default.title": "Podrazumijevani server",
|
||||
@@ -1112,7 +1113,9 @@ export const dict = {
|
||||
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
|
||||
"app.server.retrying": "Automatski ponovni pokušaj...",
|
||||
"app.server.otherServers": "Drugi serveri",
|
||||
"dialog.server.add.usernamePlaceholder": "korisničko ime",
|
||||
"dialog.server.add.passwordPlaceholder": "lozinka",
|
||||
"server.row.noUsername": "nema korisničkog imena",
|
||||
"session.review.noVcs.createGit.title": "Kreiraj Git repozitorij",
|
||||
"session.review.noVcs.createGit.description": "Prati, pregledaj i poništi promjene u ovom projektu",
|
||||
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
|
||||
|
||||
@@ -420,6 +420,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Afegeix servidor",
|
||||
"dialog.server.add.name": "Nom del servidor (opcional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nom d'usuari (opcional)",
|
||||
"dialog.server.add.usernamePlaceholder": "nom d'usuari",
|
||||
"dialog.server.add.password": "Contrasenya (opcional)",
|
||||
"dialog.server.add.passwordPlaceholder": "contrasenya",
|
||||
"dialog.server.edit.title": "Edita el servidor",
|
||||
@@ -505,6 +507,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "escriptori {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "La versió instal·lada no coincideix amb la versió de l'aplicació d'escriptori.",
|
||||
"wsl.onboarding.adding": "S'està afegint...",
|
||||
"server.row.noUsername": "sense nom d'usuari",
|
||||
"dialog.project.edit.title": "Edita el projecte",
|
||||
"dialog.project.edit.name": "Nom",
|
||||
"dialog.project.edit.icon": "Icona",
|
||||
|
||||
@@ -418,6 +418,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Přidat server",
|
||||
"dialog.server.add.name": "Název serveru (volitelné)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "uživatelské jméno (volitelné)",
|
||||
"dialog.server.add.usernamePlaceholder": "uživatelské jméno",
|
||||
"dialog.server.add.password": "Heslo (volitelné)",
|
||||
"dialog.server.add.passwordPlaceholder": "heslo",
|
||||
"dialog.server.edit.title": "Upravit server",
|
||||
@@ -503,6 +505,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Nainstalovaná verze neodpovídá verzi aplikace pro stolní počítače.",
|
||||
"wsl.onboarding.adding": "Přidávání...",
|
||||
"server.row.noUsername": "žádné uživatelské jméno",
|
||||
"dialog.project.edit.title": "Upravit projekt",
|
||||
"dialog.project.edit.name": "Jméno",
|
||||
"dialog.project.edit.icon": "ikona",
|
||||
|
||||
@@ -346,6 +346,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "Tilføj server",
|
||||
"dialog.server.add.name": "Servernavn (valgfrit)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Brugernavn (valgfrit)",
|
||||
"dialog.server.add.password": "Adgangskode (valgfrit)",
|
||||
"dialog.server.edit.title": "Rediger server",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
@@ -1002,7 +1003,9 @@ export const dict = {
|
||||
"app.server.unreachable": "Kunne ikke nå {{server}}",
|
||||
"app.server.retrying": "Prøver igen automatisk...",
|
||||
"app.server.otherServers": "Andre servere",
|
||||
"dialog.server.add.usernamePlaceholder": "brugernavn",
|
||||
"dialog.server.add.passwordPlaceholder": "adgangskode",
|
||||
"server.row.noUsername": "intet brugernavn",
|
||||
"session.review.noVcs.createGit.title": "Opret et Git-repository",
|
||||
"session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt",
|
||||
"session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...",
|
||||
|
||||
@@ -327,6 +327,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "Server hinzufügen",
|
||||
"dialog.server.add.name": "Servername (optional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Benutzername (optional)",
|
||||
"dialog.server.add.password": "Passwort (optional)",
|
||||
"dialog.server.edit.title": "Server bearbeiten",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
@@ -944,7 +945,9 @@ export const dict = {
|
||||
"app.server.unreachable": "Konnte {{server}} nicht erreichen",
|
||||
"app.server.retrying": "Verbindung wird automatisch erneut hergestellt…",
|
||||
"app.server.otherServers": "Andere Server",
|
||||
"dialog.server.add.usernamePlaceholder": "Benutzername",
|
||||
"dialog.server.add.passwordPlaceholder": "Passwort",
|
||||
"server.row.noUsername": "Kein Benutzername",
|
||||
"session.review.noVcs.createGit.title": "Git-Repository erstellen",
|
||||
"session.review.noVcs.createGit.description":
|
||||
"Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen",
|
||||
|
||||
@@ -424,6 +424,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "ސަރވަރ އިތުރުކުރުން",
|
||||
"dialog.server.add.name": "ސަރވަރ ނަން (އިޚްތިޔާރީ)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost އެވެ",
|
||||
"dialog.server.add.username": "ޔޫޒަރނޭމް (އިޚްތިޔާރީ)",
|
||||
"dialog.server.add.usernamePlaceholder": "ޔޫޒަރނޭމް",
|
||||
"dialog.server.add.password": "ޕާސްވޯޑް (އިޚްތިޔާރީ)",
|
||||
"dialog.server.add.passwordPlaceholder": "ޕާސްވަރޑް",
|
||||
"dialog.server.edit.title": "އެޑިޓް ސަރވަރ",
|
||||
@@ -509,6 +511,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "ޑެސްކްޓޮޕް {{version}} އެވެ",
|
||||
"wsl.onboarding.versionMismatch": "އިންސްޓޯލް ކޮށްފައިވާ ވަރޝަން ޑެސްކްޓޮޕް އެޕް ވަރޝަން އާއި އެއްގޮތެއް ނުވެއެވެ.",
|
||||
"wsl.onboarding.adding": "އިތުރުކުރަނީ...",
|
||||
"server.row.noUsername": "ޔޫޒަރނޭމް އެއް ނެތެވެ",
|
||||
"dialog.project.edit.title": "އެޑިޓް ޕްރޮޖެކްޓް",
|
||||
"dialog.project.edit.name": "ނަން",
|
||||
"dialog.project.edit.icon": "އައިކޮން",
|
||||
|
||||
@@ -423,6 +423,8 @@ export const dict: Record<string, string> = {
|
||||
"dialog.server.add.button": "སར་བར་ཁ་སྐོང་འབད།",
|
||||
"dialog.server.add.name": "སར་བར་གྱི་མིང་ (གདམ་ཁ་ཅན།)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost།",
|
||||
"dialog.server.add.username": "ལག་ལེན་པའི་མིང་ (གདམ་ཁ་ཅན་)།",
|
||||
"dialog.server.add.usernamePlaceholder": "ལག་ལེན་པའི་མིང་།",
|
||||
"dialog.server.add.password": "ཆོག་ཡིག་ (གདམ་ཁ་ཅན།)",
|
||||
"dialog.server.add.passwordPlaceholder": "གསང༌ཨང",
|
||||
"dialog.server.edit.title": "སར་བར་ཞུན་དག་འབད།",
|
||||
@@ -508,6 +510,7 @@ export const dict: Record<string, string> = {
|
||||
"wsl.onboarding.desktopVersion": "ཌེཀསི་ཊོཔ་ {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "གཞི་བཙུགས་འབད་ཡོད་པའི་ཐོན་རིམ་འདི་ ཌེཀསི་ཊོཔ་ཨེཔ་ཐོན་རིམ་དང་མཐུན་སྒྲིག་མི་འབད།",
|
||||
"wsl.onboarding.adding": "ཁ་སྐོང་བརྐྱབ་དོ།",
|
||||
"server.row.noUsername": "སྤྱོད་མིང་མེད།",
|
||||
"dialog.project.edit.title": "ཞུན་དག་ལས་གཞི།",
|
||||
"dialog.project.edit.name": "མིང",
|
||||
"dialog.project.edit.icon": "ངོས་དཔར།",
|
||||
|
||||
@@ -420,6 +420,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Προσθήκη διακομιστή",
|
||||
"dialog.server.add.name": "Όνομα διακομιστή (προαιρετικό)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Όνομα χρήστη (προαιρετικό)",
|
||||
"dialog.server.add.usernamePlaceholder": "όνομα χρήστη",
|
||||
"dialog.server.add.password": "Κωδικός πρόσβασης (προαιρετικό)",
|
||||
"dialog.server.add.passwordPlaceholder": "κωδικός πρόσβασης",
|
||||
"dialog.server.edit.title": "Επεξεργασία διακομιστή",
|
||||
@@ -505,6 +507,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "επιτραπέζιος υπολογιστής {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Η εγκατεστημένη έκδοση δεν ταιριάζει με την έκδοση της εφαρμογής για υπολογιστές.",
|
||||
"wsl.onboarding.adding": "Προσθήκη...",
|
||||
"server.row.noUsername": "χωρίς όνομα χρήστη",
|
||||
"dialog.project.edit.title": "Επεξεργασία έργου",
|
||||
"dialog.project.edit.name": "Όνομα",
|
||||
"dialog.project.edit.icon": "Εικονίδιο",
|
||||
|
||||
@@ -363,7 +363,9 @@ export const dict = {
|
||||
"dialog.server.add.button": "Add server",
|
||||
"dialog.server.add.name": "Server name (optional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.password": "Password",
|
||||
"dialog.server.add.username": "Username (optional)",
|
||||
"dialog.server.add.usernamePlaceholder": "username",
|
||||
"dialog.server.add.password": "Password (optional)",
|
||||
"dialog.server.add.passwordPlaceholder": "password",
|
||||
"dialog.server.edit.title": "Edit server",
|
||||
"dialog.server.default.title": "Default server",
|
||||
@@ -451,6 +453,7 @@ export const dict = {
|
||||
"wsl.onboarding.versionMismatch": "Installed version does not match the desktop app version.",
|
||||
"wsl.onboarding.adding": "Adding...",
|
||||
|
||||
"server.row.noUsername": "no username",
|
||||
"server.row.incompatible":
|
||||
"This server is running OpenCode {{version}}, which isn't compatible with this app. Upgrade it to OpenCode V2 to continue.",
|
||||
|
||||
@@ -643,7 +646,6 @@ export const dict = {
|
||||
"home.empty.description": "Get started by opening a local project",
|
||||
"home.title": "Home",
|
||||
"home.projects": "Projects",
|
||||
"home.projects.all": "All projects",
|
||||
"home.project.add": "Add project",
|
||||
"home.recentlyClosed": "Recently closed",
|
||||
"home.server.collapse": "Collapse server projects",
|
||||
@@ -660,10 +662,6 @@ export const dict = {
|
||||
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
|
||||
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.files": "Files",
|
||||
"session.files.openTabs": "Open files",
|
||||
"session.tab.usage": "Usage",
|
||||
"session.view.select": "Session view",
|
||||
"session.tab.review": "Review",
|
||||
"session.tab.context": "Context",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
@@ -702,7 +700,6 @@ export const dict = {
|
||||
"session.review.change.one": "Change",
|
||||
"session.review.change.other": "Changes",
|
||||
"session.review.loadingChanges": "Loading changes...",
|
||||
"session.review.wrapLines": "Wrap lines",
|
||||
"session.review.empty": "No changes in this session yet",
|
||||
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
|
||||
"session.review.noVcs.createGit.title": "Create a Git repository",
|
||||
@@ -805,7 +802,6 @@ export const dict = {
|
||||
"terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
"titlebar.updateVersion": "Update {{version}}",
|
||||
|
||||
"common.closeTab": "Close tab",
|
||||
@@ -983,9 +979,7 @@ export const dict = {
|
||||
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Place the title bar at the bottom of the screen on mobile",
|
||||
"settings.general.row.mobileDiffWrap.description":
|
||||
"Wrap long lines in mobile diffs instead of scrolling horizontally",
|
||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||
"settings.general.row.showCustomAgents.title": "Show agent",
|
||||
"settings.general.row.showCustomAgents.description":
|
||||
"Switch between agents in the composer. When hidden, defaults to Build agent.",
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "Añadir servidor",
|
||||
"dialog.server.add.name": "Nombre del servidor (opcional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nombre de usuario (opcional)",
|
||||
"dialog.server.add.password": "Contraseña (opcional)",
|
||||
"dialog.server.edit.title": "Editar servidor",
|
||||
"dialog.server.default.title": "Servidor predeterminado",
|
||||
@@ -1117,7 +1118,9 @@ export const dict = {
|
||||
"app.server.unreachable": "No se pudo conectar con {{server}}",
|
||||
"app.server.retrying": "Reintentando automáticamente...",
|
||||
"app.server.otherServers": "Otros servidores",
|
||||
"dialog.server.add.usernamePlaceholder": "usuario",
|
||||
"dialog.server.add.passwordPlaceholder": "contraseña",
|
||||
"server.row.noUsername": "sin usuario",
|
||||
"session.review.noVcs.createGit.title": "Crear repositorio Git",
|
||||
"session.review.noVcs.createGit.description": "Rastrea, revisa y deshaz cambios en este proyecto",
|
||||
"session.review.noVcs.createGit.actionLoading": "Creando repositorio Git...",
|
||||
|
||||
@@ -417,6 +417,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Lisa server",
|
||||
"dialog.server.add.name": "Serveri nimi (valikuline)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Kasutajanimi (valikuline)",
|
||||
"dialog.server.add.usernamePlaceholder": "kasutajanimi",
|
||||
"dialog.server.add.password": "Parool (valikuline)",
|
||||
"dialog.server.add.passwordPlaceholder": "parool",
|
||||
"dialog.server.edit.title": "Muuda serverit",
|
||||
@@ -501,6 +503,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "töölaud {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Installitud versioon ei ühti töölauarakenduse versiooniga.",
|
||||
"wsl.onboarding.adding": "Lisamine...",
|
||||
"server.row.noUsername": "kasutajanime pole",
|
||||
"dialog.project.edit.title": "Redigeeri projekti",
|
||||
"dialog.project.edit.name": "Nimi",
|
||||
"dialog.project.edit.icon": "Ikoon",
|
||||
|
||||
@@ -418,6 +418,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "سرور اضافه کنید",
|
||||
"dialog.server.add.name": "نام سرور (اختیاری)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "نام کاربری (اختیاری)",
|
||||
"dialog.server.add.usernamePlaceholder": "نام کاربری",
|
||||
"dialog.server.add.password": "رمز عبور (اختیاری)",
|
||||
"dialog.server.add.passwordPlaceholder": "رمز عبور",
|
||||
"dialog.server.edit.title": "ویرایش سرور",
|
||||
@@ -503,6 +505,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "دسکتاپ {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "نسخه نصب شده با نسخه برنامه دسکتاپ مطابقت ندارد.",
|
||||
"wsl.onboarding.adding": "در حال افزودن...",
|
||||
"server.row.noUsername": "بدون نام کاربری",
|
||||
"dialog.project.edit.title": "ویرایش پروژه",
|
||||
"dialog.project.edit.name": "نام",
|
||||
"dialog.project.edit.icon": "نماد",
|
||||
|
||||
@@ -325,6 +325,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Lisää palvelin",
|
||||
"dialog.server.add.name": "Palvelimen nimi (valinnainen)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Käyttäjätunnus (valinnainen)",
|
||||
"dialog.server.add.usernamePlaceholder": "käyttäjätunnus",
|
||||
"dialog.server.add.password": "Salasana (valinnainen)",
|
||||
"dialog.server.add.passwordPlaceholder": "salasana",
|
||||
"dialog.server.edit.title": "Muokkaa palvelinta",
|
||||
@@ -410,6 +412,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "Desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Asennettu versio ei vastaa työpöytäsovelluksen versiota.",
|
||||
"wsl.onboarding.adding": "Lisätään...",
|
||||
"server.row.noUsername": "ei käyttäjätunnusta",
|
||||
"dialog.project.edit.title": "Muokkaa projektia",
|
||||
"dialog.project.edit.name": "Nimi",
|
||||
"dialog.project.edit.icon": "Kuvake",
|
||||
|
||||
@@ -417,6 +417,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Legg ambætara til",
|
||||
"dialog.server.add.name": "Ambætaranavn (valfrítt)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Brúkaranavn (valfrítt)",
|
||||
"dialog.server.add.usernamePlaceholder": "brúkaranavn",
|
||||
"dialog.server.add.password": "Loyniorð (valfrítt)",
|
||||
"dialog.server.add.passwordPlaceholder": "loyniorð",
|
||||
"dialog.server.edit.title": "Rætta ambætara",
|
||||
@@ -502,6 +504,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "skriviborð {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Innsetta útgávan passar ikki til skriviborðsappiútgávuna.",
|
||||
"wsl.onboarding.adding": "Leggi til...",
|
||||
"server.row.noUsername": "einki brúkaranavn",
|
||||
"dialog.project.edit.title": "Rætta verkætlan",
|
||||
"dialog.project.edit.name": "Navn",
|
||||
"dialog.project.edit.icon": "Ikon",
|
||||
|
||||
@@ -426,6 +426,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "Ajouter un serveur",
|
||||
"dialog.server.add.name": "Nom du serveur (optionnel)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nom d'utilisateur (optionnel)",
|
||||
"dialog.server.add.password": "Mot de passe (optionnel)",
|
||||
"dialog.server.edit.title": "Modifier le serveur",
|
||||
"dialog.server.default.title": "Serveur par défaut",
|
||||
@@ -1043,7 +1044,9 @@ export const dict = {
|
||||
"app.server.unreachable": "Impossible de joindre {{server}}",
|
||||
"app.server.retrying": "Nouvelle tentative automatique...",
|
||||
"app.server.otherServers": "Autres serveurs",
|
||||
"dialog.server.add.usernamePlaceholder": "nom d'utilisateur",
|
||||
"dialog.server.add.passwordPlaceholder": "mot de passe",
|
||||
"server.row.noUsername": "aucun nom d'utilisateur",
|
||||
"session.review.noVcs.createGit.title": "Créer un dépôt Git",
|
||||
"session.review.noVcs.createGit.description": "Suivre, examiner et annuler les modifications dans ce projet",
|
||||
"session.review.noVcs.createGit.actionLoading": "Création du dépôt Git...",
|
||||
|
||||
@@ -416,6 +416,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "הוסף שרת",
|
||||
"dialog.server.add.name": "שם שרת (אופציונלי)",
|
||||
"dialog.server.add.namePlaceholder": "מארח מקומי",
|
||||
"dialog.server.add.username": "שם משתמש (אופציונלי)",
|
||||
"dialog.server.add.usernamePlaceholder": "שם משתמש",
|
||||
"dialog.server.add.password": "סיסמה (אופציונלי)",
|
||||
"dialog.server.add.passwordPlaceholder": "סיסמה",
|
||||
"dialog.server.edit.title": "ערוך שרת",
|
||||
@@ -499,6 +501,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "גרסת שולחן העבודה: {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "הגרסה המותקנת אינה תואמת את גרסת האפליקציה לשולחן העבודה.",
|
||||
"wsl.onboarding.adding": "מוסיף...",
|
||||
"server.row.noUsername": "ללא שם משתמש",
|
||||
"dialog.project.edit.title": "ערוך פרויקט",
|
||||
"dialog.project.edit.name": "שם",
|
||||
"dialog.project.edit.icon": "סמל",
|
||||
|
||||
@@ -425,6 +425,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "सर्वर जोड़ें",
|
||||
"dialog.server.add.name": "सर्वर नाम (वैकल्पिक)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "उपयोगकर्ता नाम (वैकल्पिक)",
|
||||
"dialog.server.add.usernamePlaceholder": "उपयोक्तानाम",
|
||||
"dialog.server.add.password": "पासवर्ड (वैकल्पिक)",
|
||||
"dialog.server.add.passwordPlaceholder": "पासवर्ड",
|
||||
"dialog.server.edit.title": "सर्वर संपादित करें",
|
||||
@@ -510,6 +512,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "डेस्कटॉप {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "इंस्टॉल किया गया संस्करण डेस्कटॉप ऐप संस्करण से मेल नहीं खाता।",
|
||||
"wsl.onboarding.adding": "जोड़ा जा रहा है...",
|
||||
"server.row.noUsername": "कोई उपयोगकर्ता नाम नहीं",
|
||||
"dialog.project.edit.title": "प्रोजेक्ट संपादित करें",
|
||||
"dialog.project.edit.name": "नाम",
|
||||
"dialog.project.edit.icon": "आइकन",
|
||||
|
||||
@@ -422,6 +422,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Dodaj poslužitelj",
|
||||
"dialog.server.add.name": "Naziv poslužitelja (neobavezno)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "korisničko ime (nije obavezno)",
|
||||
"dialog.server.add.usernamePlaceholder": "korisničko ime",
|
||||
"dialog.server.add.password": "Lozinka (nije obavezno)",
|
||||
"dialog.server.add.passwordPlaceholder": "lozinka",
|
||||
"dialog.server.edit.title": "Uredi poslužitelj",
|
||||
@@ -507,6 +509,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "radna površina {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Instalirana verzija ne odgovara verziji aplikacije za stolna računala.",
|
||||
"wsl.onboarding.adding": "Dodavanje...",
|
||||
"server.row.noUsername": "nema korisničkog imena",
|
||||
"dialog.project.edit.title": "Uredi projekt",
|
||||
"dialog.project.edit.name": "Ime",
|
||||
"dialog.project.edit.icon": "Ikona",
|
||||
|
||||
@@ -422,6 +422,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Szerver hozzáadása",
|
||||
"dialog.server.add.name": "Szerver neve (nem kötelező)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Felhasználónév (nem kötelező)",
|
||||
"dialog.server.add.usernamePlaceholder": "felhasználónév",
|
||||
"dialog.server.add.password": "Jelszó (nem kötelező)",
|
||||
"dialog.server.add.passwordPlaceholder": "jelszó",
|
||||
"dialog.server.edit.title": "Szerver szerkesztése",
|
||||
@@ -507,6 +509,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "asztali {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "A telepített verzió nem egyezik az asztali alkalmazás verziójával.",
|
||||
"wsl.onboarding.adding": "Hozzáadás...",
|
||||
"server.row.noUsername": "nincs felhasználónév",
|
||||
"dialog.project.edit.title": "Projekt szerkesztése",
|
||||
"dialog.project.edit.name": "Név",
|
||||
"dialog.project.edit.icon": "Ikon",
|
||||
|
||||
@@ -420,6 +420,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Ավելացնել սերվեր",
|
||||
"dialog.server.add.name": "Սերվերի անունը (ըստ ցանկության)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Օգտագործողի անուն (ըստ ցանկության)",
|
||||
"dialog.server.add.usernamePlaceholder": "օգտվողի անուն",
|
||||
"dialog.server.add.password": "Գաղտնաբառ (ըստ ցանկության)",
|
||||
"dialog.server.add.passwordPlaceholder": "գաղտնաբառ",
|
||||
"dialog.server.edit.title": "Խմբագրել սերվերը",
|
||||
@@ -505,6 +507,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "աշխատասեղանի {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Տեղադրված տարբերակը չի համապատասխանում աշխատասեղանի հավելվածի տարբերակին։",
|
||||
"wsl.onboarding.adding": "Ավելացվում է...",
|
||||
"server.row.noUsername": "առանց օգտվողի անուն",
|
||||
"dialog.project.edit.title": "Խմբագրել նախագիծը",
|
||||
"dialog.project.edit.name": "Անուն",
|
||||
"dialog.project.edit.icon": "Պատկերակ",
|
||||
|
||||
@@ -453,6 +453,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Tambah server",
|
||||
"dialog.server.add.name": "Nama server (opsional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nama pengguna (opsional)",
|
||||
"dialog.server.add.usernamePlaceholder": "nama pengguna",
|
||||
"dialog.server.add.password": "Kata sandi (opsional)",
|
||||
"dialog.server.add.passwordPlaceholder": "kata sandi",
|
||||
"dialog.server.edit.title": "Sunting server",
|
||||
@@ -540,6 +542,7 @@ export const dict = {
|
||||
"wsl.onboarding.versionMismatch": "Versi terinstal tidak cocok dengan versi aplikasi desktop.",
|
||||
"wsl.onboarding.adding": "Menambahkan...",
|
||||
|
||||
"server.row.noUsername": "tanpa nama pengguna",
|
||||
|
||||
"dialog.project.edit.title": "Sunting proyek",
|
||||
"dialog.project.edit.name": "Nama",
|
||||
|
||||
@@ -422,6 +422,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Bæta við netþjóni",
|
||||
"dialog.server.add.name": "Nafn netþjóns (valfrjálst)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Notandanafn (valfrjálst)",
|
||||
"dialog.server.add.usernamePlaceholder": "notendanafn",
|
||||
"dialog.server.add.password": "Lykilorð (valfrjálst)",
|
||||
"dialog.server.add.passwordPlaceholder": "lykilorð",
|
||||
"dialog.server.edit.title": "Breyta miðlara",
|
||||
@@ -507,6 +509,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "skrifborð {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Uppsett útgáfa passar ekki við útgáfu skrifborðsforritsins.",
|
||||
"wsl.onboarding.adding": "Bætir við...",
|
||||
"server.row.noUsername": "ekkert notendanafn",
|
||||
"dialog.project.edit.title": "Breyta verkefni",
|
||||
"dialog.project.edit.name": "Nafn",
|
||||
"dialog.project.edit.icon": "Táknmynd",
|
||||
|
||||
@@ -327,6 +327,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Aggiungi server",
|
||||
"dialog.server.add.name": "Nome del server (facoltativo)",
|
||||
"dialog.server.add.namePlaceholder": "Host locale",
|
||||
"dialog.server.add.username": "Nome utente (facoltativo)",
|
||||
"dialog.server.add.usernamePlaceholder": "nomeutente",
|
||||
"dialog.server.add.password": "Password (facoltativa)",
|
||||
"dialog.server.add.passwordPlaceholder": "password",
|
||||
"dialog.server.edit.title": "Modifica server",
|
||||
@@ -412,6 +414,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "La versione installata non corrisponde alla versione dell'app desktop.",
|
||||
"wsl.onboarding.adding": "Aggiunta...",
|
||||
"server.row.noUsername": "nessun nome utente",
|
||||
"dialog.project.edit.title": "Modifica progetto",
|
||||
"dialog.project.edit.name": "Nome",
|
||||
"dialog.project.edit.icon": "Icona",
|
||||
|
||||
@@ -419,6 +419,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "サーバーを追加",
|
||||
"dialog.server.add.name": "サーバー名 (オプション)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "ユーザー名 (オプション)",
|
||||
"dialog.server.add.password": "パスワード (オプション)",
|
||||
"dialog.server.edit.title": "サーバーを編集",
|
||||
"dialog.server.default.title": "デフォルトサーバー",
|
||||
@@ -1018,7 +1019,9 @@ export const dict = {
|
||||
"app.server.unreachable": "{{server}} に到達できませんでした",
|
||||
"app.server.retrying": "自動的に再試行中...",
|
||||
"app.server.otherServers": "その他のサーバー",
|
||||
"dialog.server.add.usernamePlaceholder": "ユーザー名",
|
||||
"dialog.server.add.passwordPlaceholder": "パスワード",
|
||||
"server.row.noUsername": "ユーザー名なし",
|
||||
"session.review.noVcs.createGit.title": "Git リポジトリを作成",
|
||||
"session.review.noVcs.createGit.description": "このプロジェクトの変更を追跡、レビュー、元に戻す",
|
||||
"session.review.noVcs.createGit.actionLoading": "Git リポジトリを作成中...",
|
||||
|
||||
@@ -418,6 +418,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "სერვერის დამატება",
|
||||
"dialog.server.add.name": "სერვერის სახელი (არასავალდებულო)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "მომხმარებლის სახელი (არასავალდებულო)",
|
||||
"dialog.server.add.usernamePlaceholder": "მომხმარებლის სახელი",
|
||||
"dialog.server.add.password": "პაროლი (არასავალდებულო)",
|
||||
"dialog.server.add.passwordPlaceholder": "პაროლი",
|
||||
"dialog.server.edit.title": "სერვერის რედაქტირება",
|
||||
@@ -503,6 +505,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "დესკტოპის {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "დაინსტალირებული ვერსია არ ემთხვევა დესკტოპის აპის ვერსიას.",
|
||||
"wsl.onboarding.adding": "დამატება...",
|
||||
"server.row.noUsername": "მომხმარებლის სახელის გარეშე",
|
||||
"dialog.project.edit.title": "პროექტის რედაქტირება",
|
||||
"dialog.project.edit.name": "სახელი",
|
||||
"dialog.project.edit.icon": "ხატულა",
|
||||
|
||||
@@ -417,6 +417,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "បន្ថែមម៉ាស៊ីនមេ",
|
||||
"dialog.server.add.name": "ឈ្មោះម៉ាស៊ីនមេ (ជាជម្រើស)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "ឈ្មោះអ្នកប្រើប្រាស់ (ជាជម្រើស)",
|
||||
"dialog.server.add.usernamePlaceholder": "ឈ្មោះអ្នកប្រើ",
|
||||
"dialog.server.add.password": "ពាក្យសម្ងាត់ (ជាជម្រើស)\nពាក្យសម្ងាត់",
|
||||
"dialog.server.add.passwordPlaceholder": "ពាក្យសម្ងាត់",
|
||||
"dialog.server.edit.title": "កែសម្រួលម៉ាស៊ីនមេ",
|
||||
@@ -502,6 +504,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "ផ្ទៃតុ {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "កំណែដែលបានដំឡើងមិនត្រូវគ្នានឹងកំណែកម្មវិធីកុំព្យូទ័រលើតុទេ។",
|
||||
"wsl.onboarding.adding": "កំពុងបន្ថែម...",
|
||||
"server.row.noUsername": "គ្មានឈ្មោះអ្នកប្រើប្រាស់",
|
||||
"dialog.project.edit.title": "កែសម្រួលគម្រោង",
|
||||
"dialog.project.edit.name": "ឈ្មោះ",
|
||||
"dialog.project.edit.icon": "រូបតំណាង",
|
||||
|
||||
@@ -308,6 +308,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "서버 추가",
|
||||
"dialog.server.add.name": "서버 이름 (선택 사항)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "사용자 이름 (선택 사항)",
|
||||
"dialog.server.add.password": "비밀번호 (선택 사항)",
|
||||
"dialog.server.edit.title": "서버 편집",
|
||||
"dialog.server.default.title": "기본 서버",
|
||||
@@ -767,7 +768,9 @@ export const dict = {
|
||||
"app.server.unreachable": "{{server}}에 연결할 수 없습니다",
|
||||
"app.server.retrying": "자동으로 재시도 중...",
|
||||
"app.server.otherServers": "다른 서버",
|
||||
"dialog.server.add.usernamePlaceholder": "사용자 이름",
|
||||
"dialog.server.add.passwordPlaceholder": "비밀번호",
|
||||
"server.row.noUsername": "사용자 이름 없음",
|
||||
"session.review.noVcs.createGit.title": "Git 저장소 생성",
|
||||
"session.review.noVcs.createGit.description": "이 프로젝트의 변경 사항을 추적, 검토 및 실행 취소",
|
||||
"session.review.noVcs.createGit.actionLoading": "Git 저장소 생성 중...",
|
||||
|
||||
@@ -417,6 +417,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "ເພີ່ມເຊີບເວີ",
|
||||
"dialog.server.add.name": "ຊື່ເຊີບເວີ (ທາງເລືອກ)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "ຊື່ຜູ້ໃຊ້ (ທາງເລືອກ)",
|
||||
"dialog.server.add.usernamePlaceholder": "ຊື່ຜູ້ໃຊ້",
|
||||
"dialog.server.add.password": "ລະຫັດຜ່ານ (ທາງເລືອກ)",
|
||||
"dialog.server.add.passwordPlaceholder": "ລະຫັດຜ່ານ",
|
||||
"dialog.server.edit.title": "ແກ້ໄຂເຊີບເວີ",
|
||||
@@ -501,6 +503,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "ເດັສທັອບ {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "ເວີຊັນທີ່ຕິດຕັ້ງບໍ່ກົງກັບເວີຊັນຂອງແອັບ desktop.",
|
||||
"wsl.onboarding.adding": "ກຳລັງເພີ່ມ...",
|
||||
"server.row.noUsername": "ບໍ່ມີຊື່ຜູ້ໃຊ້",
|
||||
"dialog.project.edit.title": "ແກ້ໄຂໂຄງການ",
|
||||
"dialog.project.edit.name": "ຊື່",
|
||||
"dialog.project.edit.icon": "ໄອຄອນ",
|
||||
|
||||
@@ -423,6 +423,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Pridėti serverį",
|
||||
"dialog.server.add.name": "Serverio pavadinimas (neprivaloma)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Vartotojo vardas (neprivaloma)",
|
||||
"dialog.server.add.usernamePlaceholder": "vartotojo vardas",
|
||||
"dialog.server.add.password": "Slaptažodis (neprivaloma)",
|
||||
"dialog.server.add.passwordPlaceholder": "slaptažodis",
|
||||
"dialog.server.edit.title": "Redaguoti serverį",
|
||||
@@ -508,6 +510,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "darbalaukis {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Įdiegta versija neatitinka darbalaukio programos versijos.",
|
||||
"wsl.onboarding.adding": "Pridedama...",
|
||||
"server.row.noUsername": "jokio vartotojo vardo",
|
||||
"dialog.project.edit.title": "Redaguoti projektą",
|
||||
"dialog.project.edit.name": "Vardas",
|
||||
"dialog.project.edit.icon": "Piktograma",
|
||||
|
||||
@@ -418,6 +418,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Pievienot serveri",
|
||||
"dialog.server.add.name": "Servera nosaukums (nav obligāti)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Lietotājvārds (nav obligāti)",
|
||||
"dialog.server.add.usernamePlaceholder": "lietotājvārds",
|
||||
"dialog.server.add.password": "Parole (nav obligāti)",
|
||||
"dialog.server.add.passwordPlaceholder": "parole",
|
||||
"dialog.server.edit.title": "Rediģēt serveri",
|
||||
@@ -503,6 +505,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "darbvirsmas {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Instalētā versija neatbilst darbvirsmas lietotnes versijai.",
|
||||
"wsl.onboarding.adding": "Pievieno...",
|
||||
"server.row.noUsername": "nav lietotājvārda",
|
||||
"dialog.project.edit.title": "Rediģēt projektu",
|
||||
"dialog.project.edit.name": "Nosaukums",
|
||||
"dialog.project.edit.icon": "Ikona",
|
||||
|
||||
@@ -419,6 +419,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Додај сервер",
|
||||
"dialog.server.add.name": "Име на сервер (изборно)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Корисничко име (изборно)",
|
||||
"dialog.server.add.usernamePlaceholder": "корисничко име",
|
||||
"dialog.server.add.password": "Лозинка (изборно)",
|
||||
"dialog.server.add.passwordPlaceholder": "лозинка",
|
||||
"dialog.server.edit.title": "Уреди сервер",
|
||||
@@ -504,6 +506,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "работна површина {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Инсталираната верзија не се совпаѓа со верзијата на апликацијата за десктоп.",
|
||||
"wsl.onboarding.adding": "Се додава...",
|
||||
"server.row.noUsername": "нема корисничко име",
|
||||
"dialog.project.edit.title": "Уреди проект",
|
||||
"dialog.project.edit.name": "Име",
|
||||
"dialog.project.edit.icon": "Икона",
|
||||
|
||||
@@ -421,6 +421,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Сервер нэмэх",
|
||||
"dialog.server.add.name": "Серверийн нэр (заавал биш)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Хэрэглэгчийн нэр (заавал биш)",
|
||||
"dialog.server.add.usernamePlaceholder": "хэрэглэгчийн нэр",
|
||||
"dialog.server.add.password": "Нууц үг (заавал биш)",
|
||||
"dialog.server.add.passwordPlaceholder": "нууц үг",
|
||||
"dialog.server.edit.title": "Сервер засах",
|
||||
@@ -506,6 +508,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "ширээний компьютер {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Суулгасан хувилбар нь ширээний програмын хувилбартай таарахгүй байна.",
|
||||
"wsl.onboarding.adding": "Нэмэж байна...",
|
||||
"server.row.noUsername": "хэрэглэгчийн нэр байхгүй",
|
||||
"dialog.project.edit.title": "Төслийг засварлах",
|
||||
"dialog.project.edit.name": "Нэр",
|
||||
"dialog.project.edit.icon": "Дүрс",
|
||||
|
||||
@@ -418,6 +418,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Tambah pelayan",
|
||||
"dialog.server.add.name": "Nama pelayan (pilihan)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nama pengguna (pilihan)",
|
||||
"dialog.server.add.usernamePlaceholder": "nama pengguna",
|
||||
"dialog.server.add.password": "Kata laluan (pilihan)",
|
||||
"dialog.server.add.passwordPlaceholder": "kata laluan",
|
||||
"dialog.server.edit.title": "Edit pelayan",
|
||||
@@ -503,6 +505,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Versi yang dipasang tidak sepadan dengan versi aplikasi desktop.",
|
||||
"wsl.onboarding.adding": "Sedang menambah...",
|
||||
"server.row.noUsername": "tiada nama pengguna",
|
||||
"dialog.project.edit.title": "Edit projek",
|
||||
"dialog.project.edit.name": "Nama",
|
||||
"dialog.project.edit.icon": "Ikon",
|
||||
|
||||
@@ -422,6 +422,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "ဆာဗာထည့်ပါ။",
|
||||
"dialog.server.add.name": "ဆာဗာအမည် (ချန်လှပ်ထားနိုင်သည်)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "အသုံးပြုသူအမည် (ချန်လှပ်ထားနိုင်သည်)",
|
||||
"dialog.server.add.usernamePlaceholder": "အသုံးပြုသူအမည်",
|
||||
"dialog.server.add.password": "စကားဝှက် (ချန်လှပ်ထားနိုင်သည်)",
|
||||
"dialog.server.add.passwordPlaceholder": "စကားဝှက်",
|
||||
"dialog.server.edit.title": "ဆာဗာကို တည်းဖြတ်ပါ။",
|
||||
@@ -507,6 +509,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "ဒက်စ်တော့ {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "ထည့်သွင်းထားသောဗားရှင်းသည် ဒက်စ်တော့အက်ပ်ဗားရှင်းနှင့် မကိုက်ညီပါ။",
|
||||
"wsl.onboarding.adding": "ထည့်နေသည်...",
|
||||
"server.row.noUsername": "အသုံးပြုသူအမည်မရှိပါ။",
|
||||
"dialog.project.edit.title": "ပရောဂျက်ကို တည်းဖြတ်ပါ။",
|
||||
"dialog.project.edit.name": "အမည်",
|
||||
"dialog.project.edit.icon": "သင်္ကေတ",
|
||||
|
||||
@@ -419,6 +419,8 @@ export const dict: Record<string, string> = {
|
||||
"dialog.server.add.button": "सर्भर थप्नुहोस्",
|
||||
"dialog.server.add.name": "सर्भर नाम (वैकल्पिक)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "प्रयोगकर्ता नाम (वैकल्पिक)",
|
||||
"dialog.server.add.usernamePlaceholder": "प्रयोगकर्ता नाम",
|
||||
"dialog.server.add.password": "पासवर्ड (वैकल्पिक)",
|
||||
"dialog.server.add.passwordPlaceholder": "पासवर्ड",
|
||||
"dialog.server.edit.title": "सर्भर सम्पादन गर्नुहोस्",
|
||||
@@ -504,6 +506,7 @@ export const dict: Record<string, string> = {
|
||||
"wsl.onboarding.desktopVersion": "डेस्कटप {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "स्थापना गरिएको संस्करण डेस्कटप एप संस्करणसँग मेल खाँदैन।",
|
||||
"wsl.onboarding.adding": "थप्दै...",
|
||||
"server.row.noUsername": "प्रयोगकर्ता नाम छैन",
|
||||
"dialog.project.edit.title": "परियोजना सम्पादन गर्नुहोस्",
|
||||
"dialog.project.edit.name": "नाम",
|
||||
"dialog.project.edit.icon": "आइकन",
|
||||
|
||||
@@ -419,6 +419,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Server toevoegen",
|
||||
"dialog.server.add.name": "Servernaam (optioneel)",
|
||||
"dialog.server.add.namePlaceholder": "Lokale host",
|
||||
"dialog.server.add.username": "Gebruikersnaam (optioneel)",
|
||||
"dialog.server.add.usernamePlaceholder": "gebruikersnaam",
|
||||
"dialog.server.add.password": "Wachtwoord (optioneel)",
|
||||
"dialog.server.add.passwordPlaceholder": "wachtwoord",
|
||||
"dialog.server.edit.title": "Server bewerken",
|
||||
@@ -504,6 +506,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "De geïnstalleerde versie komt niet overeen met de versie van de desktop-app.",
|
||||
"wsl.onboarding.adding": "Toevoegen...",
|
||||
"server.row.noUsername": "geen gebruikersnaam",
|
||||
"dialog.project.edit.title": "Project bewerken",
|
||||
"dialog.project.edit.name": "Naam",
|
||||
"dialog.project.edit.icon": "Pictogram",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user