diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs
index ae7cb815..4c7afad3 100644
--- a/internal/dev-entrypoint/cloud-web-runtime.mjs
+++ b/internal/dev-entrypoint/cloud-web-runtime.mjs
@@ -20,10 +20,15 @@ const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-hand
const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/opencode", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]);
const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u;
const OPENCODE_PROXY_PREFIX = "/_opencode";
+const OPENCODE_FRAME_URL_PATH = "/opencode/frame-url";
const OPENCODE_TICKET_QUERY = "hwlab_opencode_ticket";
const OPENCODE_TICKET_COOKIE = "hwlab_opencode_ticket";
const OPENCODE_TICKET_TTL_MS = 5 * 60 * 1000;
const OPENCODE_TICKET_MAX_AGE_SECONDS = Math.floor(OPENCODE_TICKET_TTL_MS / 1000);
+const ZERO_OTEL_TRACE_ID = "00000000000000000000000000000000";
+const ZERO_OTEL_SPAN_ID = "0000000000000000";
+const OTEL_TRACEPARENT_PATTERN = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/u;
+const SAFE_REQUEST_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,160}$/u;
const TRACE_EXPLORER_TEMPLATE_TOKEN = "{trace_id}";
const TRACE_EXPLORER_TEMPLATE_PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_:-]+)\}/gu;
const opencodeTickets = new Map();
@@ -68,6 +73,10 @@ export function createCloudWebServer({
sendJson(response, 200, healthPayload());
return;
}
+ if (url.pathname === OPENCODE_FRAME_URL_PATH && request.method === "GET") {
+ await handleOpencodeFrameUrlRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson });
+ return;
+ }
if (isOpencodeProxyRequest(request, url, opencodeProxyHost)) {
await proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, opencodeUpstreamUrl, opencodeProxyTimeoutMs, opencodeUsername, opencodePassword, serviceId, sendJson });
return;
@@ -164,12 +173,83 @@ async function opencodeRuntimeConfigFromEnv(options = {}) {
async function opencodeFrameUrl(value, { request = null, url = null, cloudApiBaseUrl = "", cloudApiProxyTimeoutMs = 0 } = {}) {
if (!request || normalizeCloudWebRoutePath(url?.pathname || "") !== "/opencode" || !cloudApiBaseUrl || !hasCookieHeader(request.headers.cookie)) return value;
- const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
- if (!session.ok || session.body?.authenticated !== true) return value;
+ const result = await mintOpencodeFrameUrl(value, { request, cloudApiBaseUrl, cloudApiProxyTimeoutMs, traceContext: cloudWebTraceContext(request) });
+ return result.ok ? result.url : value;
+}
+
+async function handleOpencodeFrameUrlRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson }) {
+ const traceContext = cloudWebTraceContext(request);
+ response.setHeader("cache-control", "no-store");
+ if (!cloudApiBaseUrl) {
+ sendJsonWithTraceContext(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson);
+ return;
+ }
+ const configuredUrl = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_URL");
+ if (!configuredUrl) {
+ sendJsonWithTraceContext(response, 503, { error: "opencode_url_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson);
+ return;
+ }
+ try {
+ validateOpencodeUrl(configuredUrl);
+ } catch (error) {
+ sendJsonWithTraceContext(response, 503, { error: "opencode_url_invalid", serviceId, path: url.pathname, message: error instanceof Error ? error.message : String(error), diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson);
+ return;
+ }
+
+ const result = await mintOpencodeFrameUrl(configuredUrl, { request, cloudApiBaseUrl, cloudApiProxyTimeoutMs, traceContext, serviceId });
+ if (!result.ok) {
+ logCloudWebRuntimeEvent("opencode_auth_required", {
+ serviceId,
+ request,
+ traceContext,
+ details: {
+ route: url.pathname,
+ sessionStatusCode: result.sessionStatusCode,
+ sessionAuthenticated: result.sessionAuthenticated,
+ ticketPresent: false,
+ ticketAccepted: false,
+ valuesPrinted: false
+ }
+ });
+ sendJsonWithTraceContext(response, result.statusCode, {
+ authenticated: false,
+ error: "opencode_auth_required",
+ serviceId,
+ path: url.pathname,
+ diagnostic: opencodeTraceDiagnostic(traceContext, { sessionStatusCode: result.sessionStatusCode })
+ }, traceContext, sendJson);
+ return;
+ }
+
+ sendJsonWithTraceContext(response, 200, {
+ authenticated: true,
+ url: result.url,
+ expiresInMs: OPENCODE_TICKET_TTL_MS,
+ diagnostic: opencodeTraceDiagnostic(traceContext)
+ }, traceContext, sendJson);
+}
+
+async function mintOpencodeFrameUrl(value, { request, cloudApiBaseUrl, cloudApiProxyTimeoutMs, traceContext = null, serviceId = "hwlab-cloud-web" } = {}) {
+ const session = await requestCloudApiJson({
+ request,
+ method: "GET",
+ pathname: "/auth/session",
+ headers: cloudWebTraceHeaders(traceContext, serviceId),
+ cloudApiBaseUrl,
+ cloudApiProxyTimeoutMs
+ });
+ if (!session.ok || session.body?.authenticated !== true) {
+ return {
+ ok: false,
+ statusCode: session.statusCode === 403 ? 403 : 401,
+ sessionStatusCode: session.statusCode || 0,
+ sessionAuthenticated: session.body?.authenticated === true
+ };
+ }
const ticket = issueOpencodeTicket(request.headers.cookie);
const target = new URL(value);
target.searchParams.set(OPENCODE_TICKET_QUERY, ticket);
- return target.toString();
+ return { ok: true, url: target.toString() };
}
function displayTimeConfigFromEnv() {
@@ -345,13 +425,15 @@ export function isClientDisconnectError(error) {
return CLIENT_DISCONNECT_ERROR_CODES.has(code) || CLIENT_DISCONNECT_ERROR_PATTERN.test(candidate.message || "");
}
-function logCloudWebRuntimeEvent(event, { serviceId, request = null, error = null, source = null } = {}) {
+function logCloudWebRuntimeEvent(event, { serviceId, request = null, error = null, source = null, traceContext = null, details = null } = {}) {
const payload = {
event,
serviceId: serviceId || "hwlab-cloud-web",
source,
method: request?.method || null,
path: request?.url || null,
+ traceId: traceContext?.traceId || null,
+ requestId: traceContext?.requestId || null,
error: error
? {
name: error.name || "Error",
@@ -359,6 +441,7 @@ function logCloudWebRuntimeEvent(event, { serviceId, request = null, error = nul
message: error.message || String(error)
}
: null,
+ details: details && typeof details === "object" ? details : null,
observedAt: new Date().toISOString()
};
process.stderr.write(JSON.stringify(payload) + "\n");
@@ -581,29 +664,51 @@ export async function proxyCloudApi({ request, response, url, cloudApiBaseUrl, c
}
async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, opencodeUpstreamUrl, opencodeProxyTimeoutMs, opencodeUsername, opencodePassword, serviceId, sendJson }) {
+ const traceContext = cloudWebTraceContext(request);
if (!cloudApiBaseUrl) {
- sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: url.pathname });
+ sendJsonWithTraceContext(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson);
return;
}
if (!opencodeUpstreamUrl) {
- sendJson(response, 503, { error: "opencode_upstream_url_missing", serviceId, path: url.pathname });
+ sendJsonWithTraceContext(response, 503, { error: "opencode_upstream_url_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson);
return;
}
const authorization = opencodeBasicAuthorization(opencodeUsername, opencodePassword);
if (!authorization) {
- sendJson(response, 503, { error: "opencode_credentials_missing", serviceId, path: url.pathname });
+ sendJsonWithTraceContext(response, 503, { error: "opencode_credentials_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson);
return;
}
const ticketAuth = opencodeTicketAuthRequest(request, url);
- const session = await requestCloudApiJson({ request: ticketAuth.request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
+ const session = await requestCloudApiJson({
+ request: ticketAuth.request,
+ method: "GET",
+ pathname: "/auth/session",
+ headers: cloudWebTraceHeaders(traceContext, serviceId),
+ cloudApiBaseUrl,
+ cloudApiProxyTimeoutMs
+ });
if (!session.ok || session.body?.authenticated !== true) {
- sendJson(response, session.statusCode === 403 ? 403 : 401, {
+ logCloudWebRuntimeEvent("opencode_auth_required", {
+ serviceId,
+ request,
+ traceContext,
+ details: {
+ route: url.pathname,
+ sessionStatusCode: session.statusCode || 0,
+ sessionAuthenticated: session.body?.authenticated === true,
+ ticketPresent: ticketAuth.ticketPresent,
+ ticketAccepted: Boolean(ticketAuth.ticket),
+ valuesPrinted: false
+ }
+ });
+ sendJsonWithTraceContext(response, session.statusCode === 403 ? 403 : 401, {
authenticated: false,
error: "opencode_auth_required",
serviceId,
- path: url.pathname
- });
+ path: url.pathname,
+ diagnostic: opencodeTraceDiagnostic(traceContext, { sessionStatusCode: session.statusCode || 0 })
+ }, traceContext, sendJson);
return;
}
@@ -613,10 +718,13 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c
}
const target = opencodeTargetUrl(url, opencodeUpstreamUrl);
- const extraResponseHeaders = ticketAuth.ticket ? { "set-cookie": opencodeTicketSetCookie(ticketAuth.ticket) } : {};
+ const extraResponseHeaders = {
+ ...cloudWebTraceHeaders(traceContext, serviceId),
+ ...(ticketAuth.ticket ? { "set-cookie": opencodeTicketSetCookie(ticketAuth.ticket) } : {})
+ };
const upstreamRequest = {
method: request.method,
- headers: opencodeUpstreamHeaders(request, body, authorization)
+ headers: opencodeUpstreamHeaders({ headers: { ...request.headers, ...cloudWebTraceHeaders(traceContext, serviceId) } }, body, authorization)
};
try {
await proxyCloudApiRequest({
@@ -632,13 +740,14 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c
if (!response.writableEnded) response.destroy(error);
return;
}
- sendJson(response, 502, {
+ sendJsonWithTraceContext(response, 502, {
status: "failed",
error: "opencode_proxy_unavailable",
serviceId,
path: url.pathname,
- reason: UPSTREAM_UNAVAILABLE_MESSAGE
- });
+ reason: UPSTREAM_UNAVAILABLE_MESSAGE,
+ diagnostic: opencodeTraceDiagnostic(traceContext)
+ }, traceContext, sendJson);
}
}
@@ -673,10 +782,11 @@ function issueOpencodeTicket(cookieHeader) {
function opencodeTicketAuthRequest(request, url) {
const ticket = opencodeTicketFromRequest(request, url);
const record = ticket ? opencodeTicketRecord(ticket) : null;
- if (!record) return { request, ticket: "" };
+ if (!record) return { request, ticket: "", ticketPresent: Boolean(ticket) };
return {
request: { headers: { ...request.headers, cookie: record.cookieHeader } },
- ticket
+ ticket,
+ ticketPresent: true
};
}
@@ -724,6 +834,86 @@ function opencodeBasicAuthorization(username, password) {
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
}
+function cloudWebTraceContext(request) {
+ const parsed = parseOtelTraceparent(request?.headers?.traceparent);
+ const explicitTraceId = normalizedOtelTraceId(request?.headers?.["x-hwlab-otel-trace-id"]);
+ const traceId = parsed?.traceId || explicitTraceId || randomOtelTraceId();
+ const spanId = randomOtelSpanId();
+ return {
+ traceId,
+ parentSpanId: parsed?.spanId || null,
+ spanId,
+ traceparent: `00-${traceId}-${spanId}-01`,
+ requestId: safeRequestId(request?.headers?.["x-request-id"]) || `req_${randomBytes(16).toString("hex")}`,
+ valuesPrinted: false
+ };
+}
+
+function cloudWebTraceHeaders(traceContext, serviceId = "hwlab-cloud-web") {
+ if (!traceContext?.traceId || !traceContext?.traceparent) return {};
+ return {
+ traceparent: traceContext.traceparent,
+ "x-hwlab-otel-trace-id": traceContext.traceId,
+ "x-request-id": traceContext.requestId,
+ "x-source-service-id": serviceId || "hwlab-cloud-web"
+ };
+}
+
+function sendJsonWithTraceContext(response, statusCode, body, traceContext, sendJson) {
+ for (const [name, value] of Object.entries(cloudWebTraceHeaders(traceContext))) {
+ if (value !== undefined && value !== null && value !== "") response.setHeader(name, value);
+ }
+ sendJson(response, statusCode, body);
+}
+
+function opencodeTraceDiagnostic(traceContext, extra = {}) {
+ return {
+ traceId: traceContext?.traceId || null,
+ requestId: traceContext?.requestId || null,
+ traceparent: traceContext?.traceparent || null,
+ valuesPrinted: false,
+ ...extra
+ };
+}
+
+function parseOtelTraceparent(value) {
+ const match = firstHeaderValue(value).trim().match(OTEL_TRACEPARENT_PATTERN);
+ if (!match) return null;
+ const traceId = normalizedOtelTraceId(match[1]);
+ const spanId = normalizedOtelSpanId(match[2]);
+ return traceId && spanId ? { traceId, spanId } : null;
+}
+
+function normalizedOtelTraceId(value) {
+ const text = firstHeaderValue(value).trim().toLowerCase();
+ return /^[0-9a-f]{32}$/u.test(text) && text !== ZERO_OTEL_TRACE_ID ? text : "";
+}
+
+function normalizedOtelSpanId(value) {
+ const text = firstHeaderValue(value).trim().toLowerCase();
+ return /^[0-9a-f]{16}$/u.test(text) && text !== ZERO_OTEL_SPAN_ID ? text : "";
+}
+
+function randomOtelTraceId() {
+ const value = randomBytes(16).toString("hex");
+ return value === ZERO_OTEL_TRACE_ID ? "00000000000000000000000000000001" : value;
+}
+
+function randomOtelSpanId() {
+ const value = randomBytes(8).toString("hex");
+ return value === ZERO_OTEL_SPAN_ID ? "0000000000000001" : value;
+}
+
+function safeRequestId(value) {
+ const text = firstHeaderValue(value).trim();
+ return SAFE_REQUEST_ID_PATTERN.test(text) ? text : "";
+}
+
+function firstHeaderValue(value) {
+ if (Array.isArray(value)) return String(value[0] ?? "");
+ return String(value ?? "");
+}
+
function requestHostName(request) {
return normalizeHostName(String(request.headers.host || "").split(":")[0]);
}
diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs
index 5128d3d4..cdb2c8e9 100644
--- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs
+++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs
@@ -500,10 +500,15 @@ test("cloud web injects trace explorer runtime config from env", async () => {
});
test("cloud web OpenCode proxy requires an authenticated HWLAB session", async () => {
- let authRequests = 0;
+ const authRequests = [];
let opencodeRequests = 0;
const cloudApi = createServer((request, response) => {
- authRequests += 1;
+ authRequests.push({
+ traceparent: request.headers.traceparent,
+ otelTraceId: request.headers["x-hwlab-otel-trace-id"],
+ requestId: request.headers["x-request-id"],
+ sourceServiceId: request.headers["x-source-service-id"]
+ });
request.resume();
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ authenticated: false }));
@@ -539,8 +544,19 @@ test("cloud web OpenCode proxy requires an authenticated HWLAB session", async (
try {
const response = await fetch(`${serverUrl(cloudWeb)}/`, { headers: { accept: "text/html" } });
assert.equal(response.status, 401);
- assert.equal((await response.json()).error, "opencode_auth_required");
- assert.equal(authRequests, 1);
+ const body = await response.json();
+ assert.equal(body.error, "opencode_auth_required");
+ assert.match(response.headers.get("traceparent") ?? "", /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/u);
+ assert.match(response.headers.get("x-hwlab-otel-trace-id") ?? "", /^[0-9a-f]{32}$/u);
+ assert.match(response.headers.get("x-request-id") ?? "", /^req_[0-9a-f]{32}$/u);
+ assert.equal(body.diagnostic.traceId, response.headers.get("x-hwlab-otel-trace-id"));
+ assert.equal(body.diagnostic.requestId, response.headers.get("x-request-id"));
+ assert.deepEqual(authRequests, [{
+ traceparent: response.headers.get("traceparent"),
+ otelTraceId: response.headers.get("x-hwlab-otel-trace-id"),
+ requestId: response.headers.get("x-request-id"),
+ sourceServiceId: "hwlab-cloud-web"
+ }]);
assert.equal(opencodeRequests, 0);
} finally {
await close(cloudWeb);
@@ -549,6 +565,89 @@ test("cloud web OpenCode proxy requires an authenticated HWLAB session", async (
}
});
+test("cloud web OpenCode frame-url endpoint mints traced tickets", async () => {
+ const restoreEnv = withEnv({
+ HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: "Asia/Shanghai",
+ HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: "zh-CN",
+ HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL: "北京时间",
+ HWLAB_CLOUD_WEB_OPENCODE_URL: "https://opencode.example.test"
+ });
+ const cloudApiRequests = [];
+ const opencodeRequests = [];
+ const cloudApi = createServer((request, response) => {
+ cloudApiRequests.push({
+ cookie: request.headers.cookie,
+ traceparent: request.headers.traceparent,
+ otelTraceId: request.headers["x-hwlab-otel-trace-id"],
+ requestId: request.headers["x-request-id"],
+ sourceServiceId: request.headers["x-source-service-id"]
+ });
+ request.resume();
+ response.writeHead(200, { "content-type": "application/json" });
+ response.end(JSON.stringify({ authenticated: request.headers.cookie === "hwlab_session=session-a" }));
+ });
+ const opencode = createServer((request, response) => {
+ opencodeRequests.push({ url: request.url, cookie: request.headers.cookie });
+ request.resume();
+ response.writeHead(200, { "content-type": "text/plain" });
+ response.end("opencode ok\n");
+ });
+ await listen(cloudApi);
+ await listen(opencode);
+
+ const cloudWeb = createCloudWebServer({
+ serviceId: "hwlab-cloud-web",
+ roots: [],
+ cloudApiBaseUrl: serverUrl(cloudApi),
+ cloudApiProxyTimeoutMs: 1000,
+ opencodeUpstreamUrl: serverUrl(opencode),
+ opencodeProxyHost: "",
+ opencodeProxyTimeoutMs: 1000,
+ opencodeUsername: "oc_user",
+ opencodePassword: "oc_password",
+ healthPayload: () => ({ status: "ok" }),
+ sendJson(response, statusCode, body) {
+ const payload = JSON.stringify(body);
+ response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
+ response.end(payload);
+ }
+ });
+ await listen(cloudWeb);
+
+ try {
+ const response = await fetch(`${serverUrl(cloudWeb)}/opencode/frame-url`, {
+ headers: { accept: "application/json", cookie: "hwlab_session=session-a", "x-request-id": "req_opencode_frame_url" }
+ });
+ assert.equal(response.status, 200);
+ assert.equal(response.headers.get("x-request-id"), "req_opencode_frame_url");
+ assert.match(response.headers.get("traceparent") ?? "", /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/u);
+ const body = await response.json();
+ const frameUrl = new URL(body.url);
+ const ticket = frameUrl.searchParams.get("hwlab_opencode_ticket");
+ assert.ok(ticket, "dynamic endpoint should append an OpenCode ticket");
+ assert.equal(body.diagnostic.traceId, response.headers.get("x-hwlab-otel-trace-id"));
+ assert.deepEqual(cloudApiRequests, [{
+ cookie: "hwlab_session=session-a",
+ traceparent: response.headers.get("traceparent"),
+ otelTraceId: response.headers.get("x-hwlab-otel-trace-id"),
+ requestId: "req_opencode_frame_url",
+ sourceServiceId: "hwlab-cloud-web"
+ }]);
+
+ const proxied = await fetch(`${serverUrl(cloudWeb)}/_opencode/?hwlab_opencode_ticket=${encodeURIComponent(ticket)}`, {
+ headers: { accept: "text/plain" }
+ });
+ assert.equal(proxied.status, 200);
+ assert.equal(await proxied.text(), "opencode ok\n");
+ assert.deepEqual(opencodeRequests, [{ url: "/", cookie: undefined }]);
+ } finally {
+ restoreEnv();
+ await close(cloudWeb);
+ await close(opencode);
+ await close(cloudApi);
+ }
+});
+
test("cloud web OpenCode proxy injects upstream Basic Auth without forwarding HWLAB cookie", async () => {
const opencodeRequests = [];
const cloudApi = createServer((request, response) => {
diff --git a/web/hwlab-cloud-web/src/views/opencode/OpenCodeFrameView.vue b/web/hwlab-cloud-web/src/views/opencode/OpenCodeFrameView.vue
index eeb8b454..a4d9d19d 100644
--- a/web/hwlab-cloud-web/src/views/opencode/OpenCodeFrameView.vue
+++ b/web/hwlab-cloud-web/src/views/opencode/OpenCodeFrameView.vue
@@ -1,14 +1,153 @@
- OpenCode 未配置
+
+
{{ statusText }}
+
+
{{ diagnosticText }}
+
@@ -34,10 +173,39 @@ const frameUrl = computed(() => opencodeFrameUrl());
.opencode-frame-empty {
display: grid;
flex: 1 1 auto;
+ gap: 12px;
place-items: center;
+ align-content: center;
min-height: 320px;
color: #dbe4f0;
font-size: 14px;
+ text-align: center;
+}
+
+.opencode-frame-empty p {
+ margin: 0;
+}
+
+.opencode-frame-empty small {
+ max-width: min(720px, 90vw);
+ overflow-wrap: anywhere;
+ color: #9fb1ca;
+ font-size: 12px;
+}
+
+.opencode-frame-retry {
+ min-width: 72px;
+ height: 32px;
+ border: 1px solid #94a3b8;
+ border-radius: 6px;
+ background: #ffffff;
+ color: #0f172a;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.opencode-frame-retry:hover {
+ background: #eef4ff;
}
@media (max-width: 720px) {