Add session web preview mode (#430)

## Summary
- Add ephemeral per-session web preview mode for opening arbitrary
HTTP(S) URLs inside the session workspace without persisting preview
state across reloads.
- Reuse the SideCar iframe shell by extracting a shared BrowserFrame
with navigation, refresh, path entry, viewport presets, and no-injection
element comment targeting.
- Add authenticated preview proxy routes for HTTP and WebSocket traffic,
sharing lower-level proxy forwarding with SideCars to avoid duplicating
proxy code.
- Localize all new preview and viewport strings across English, Spanish,
French, Hebrew, Japanese, Russian, and Simplified Chinese.

## User-facing behavior
- Users can open a web preview from the session toolbar and toggle back
and forth between chat and preview mode.
- Preview mode replaces only the message stream/timeline area; the
prompt input and attachments stay available below the preview.
- Comment mode lets users select elements in the same-origin proxied
iframe and append structured page/element references to the prompt
draft.
- The generated prompt quotes the page/element reference while leaving
the user's actual comment as normal prompt text.
- Viewport selection supports responsive, desktop, tablet
portrait/landscape, and mobile portrait/landscape canvases, with fixed
sizes isolated inside the iframe scroll container.

## Implementation notes
- Preview sessions are in-memory and keyed by short-lived tokens exposed
through `/previews/:token`.
- Preview proxy responses strip frame-blocking headers and rewrite
same-origin redirect locations back under the preview route.
- SideCarView now composes the shared BrowserFrame, preserving existing
SideCar behavior while gaining shared viewport controls.
- Parent-side iframe DOM access powers hover highlighting and element
metadata extraction, avoiding HTML/script injection for the first
implementation.

## Validation
- `npm run typecheck --workspace @codenomad/ui`
- `npm run typecheck --workspace @neuralnomads/codenomad`

## Notes
- Existing unrelated local changes to `package.json` and
`package-lock.json` were intentionally left out of this PR.
- The initial proxy does not yet rewrite all HTML/CSS asset references;
pages with root-relative assets may need follow-up URL rewriting.
This commit is contained in:
Shantur Rathore 2026-05-10 21:46:10 +01:00 committed by GitHub
parent a3231c3da5
commit efe3f5055f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1529 additions and 242 deletions

View file

@ -256,6 +256,14 @@ export interface SideCar {
updatedAt: string
}
export interface PreviewSession {
token: string
sessionId: string
targetUrl: string
proxyUrl: string
createdAt: string
}
export interface BinaryRecord {
id: string
path: string

View file

@ -26,6 +26,7 @@ import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/networ
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
import { SideCarManager } from "./sidecars/manager"
import { PreviewManager } from "./previews/manager"
import { ClientConnectionManager } from "./clients/connection-manager"
import { PluginChannelManager } from "./plugins/channel"
import { VoiceModeManager } from "./plugins/voice-mode"
@ -340,6 +341,7 @@ async function main() {
eventBus,
logger: logger.child({ component: "sidecars" }),
})
const previewManager = new PreviewManager()
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
@ -435,6 +437,7 @@ async function main() {
instanceStore,
speechService,
sidecarManager,
previewManager,
authManager,
clientConnectionManager,
pluginChannel,
@ -461,6 +464,7 @@ async function main() {
instanceStore,
speechService,
sidecarManager,
previewManager,
authManager,
clientConnectionManager,
pluginChannel,

View file

@ -0,0 +1,77 @@
import { randomUUID } from "crypto"
import type { PreviewSession } from "../api-types"
interface PreviewRecord {
token: string
sessionId: string
target: URL
createdAt: string
}
export class PreviewManager {
private readonly previews = new Map<string, PreviewRecord>()
create(sessionId: string, rawUrl: string): PreviewSession {
const target = this.normalizeTargetUrl(rawUrl)
const token = randomUUID()
const record: PreviewRecord = {
token,
sessionId,
target,
createdAt: new Date().toISOString(),
}
this.previews.set(token, record)
return this.toPreviewSession(record)
}
get(token: string): PreviewSession | undefined {
const record = this.previews.get(token)
return record ? this.toPreviewSession(record) : undefined
}
delete(token: string): boolean {
return this.previews.delete(token)
}
buildTargetUrl(token: string, incomingPath: string, search = ""): URL | undefined {
const record = this.previews.get(token)
if (!record) return undefined
const publicBase = this.buildProxyBasePath(token)
let targetPath = incomingPath.startsWith(publicBase) ? incomingPath.slice(publicBase.length) : incomingPath
if (!targetPath || targetPath === "/") {
targetPath = record.target.pathname || "/"
} else if (!targetPath.startsWith("/")) {
targetPath = `/${targetPath}`
}
return new URL(`${targetPath}${search}`, record.target.origin)
}
buildProxyBasePath(token: string): string {
return `/previews/${encodeURIComponent(token)}`
}
private normalizeTargetUrl(rawUrl: string): URL {
const trimmed = rawUrl.trim()
const withProtocol = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`
const target = new URL(withProtocol)
if (target.protocol !== "http:" && target.protocol !== "https:") {
throw new Error("Preview URL must use HTTP or HTTPS")
}
if (target.username || target.password) {
throw new Error("Preview URL cannot include credentials")
}
return target
}
private toPreviewSession(record: PreviewRecord): PreviewSession {
return {
token: record.token,
sessionId: record.sessionId,
targetUrl: record.target.toString(),
proxyUrl: `${this.buildProxyBasePath(record.token)}${record.target.pathname}${record.target.search}${record.target.hash}`,
createdAt: record.createdAt,
}
}
}

View file

@ -6,7 +6,7 @@ import fs from "fs"
import { connect as connectTcp, type Socket } from "net"
import path from "path"
import { connect as connectTls, type TLSSocket } from "tls"
import { fetch } from "undici"
import { fetch, type Headers } from "undici"
import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
@ -28,6 +28,7 @@ import { registerSpeechRoutes } from "./routes/speech"
import { registerRemoteServerRoutes } from "./routes/remote-servers"
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
import { registerSideCarRoutes } from "./routes/sidecars"
import { registerPreviewRoutes } from "./routes/previews"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
@ -39,6 +40,7 @@ import { ClientConnectionManager } from "../clients/connection-manager"
import { PluginChannelManager } from "../plugins/channel"
import { VoiceModeManager } from "../plugins/voice-mode"
import type { SideCarManager } from "../sidecars/manager"
import type { PreviewManager } from "../previews/manager"
import type { RemoteProxySessionManager } from "./remote-proxy"
interface HttpServerDeps {
@ -56,6 +58,7 @@ interface HttpServerDeps {
instanceStore: InstanceStore
speechService: SpeechService
sidecarManager: SideCarManager
previewManager: PreviewManager
authManager: AuthManager
clientConnectionManager: ClientConnectionManager
pluginChannel: PluginChannelManager
@ -214,7 +217,7 @@ export function createHttpServer(deps: HttpServerDeps) {
const session = deps.authManager.getSessionFromRequest(request)
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/") || pathname.startsWith("/previews/")
if (requiresAuthForApi && !session) {
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
@ -285,12 +288,19 @@ export function createHttpServer(deps: HttpServerDeps) {
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
registerSpeechRoutes(app, { speechService: deps.speechService })
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
registerPreviewRoutes(app, { previewManager: deps.previewManager })
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger })
setupSideCarWebSocketProxy(app, {
sidecarManager: deps.sidecarManager,
authManager: deps.authManager,
logger: proxyLogger,
})
setupPreviewWebSocketProxy(app, {
previewManager: deps.previewManager,
authManager: deps.authManager,
logger: proxyLogger,
})
registerPluginRoutes(app, {
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
@ -303,9 +313,9 @@ export function createHttpServer(deps: HttpServerDeps) {
if (deps.uiDevServerUrl) {
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager, deps.previewManager, proxyLogger)
} else {
setupStaticUi(app, deps.uiStaticDir, deps.authManager)
setupStaticUi(app, deps.uiStaticDir, deps.authManager, deps.previewManager, proxyLogger)
}
return {
@ -381,6 +391,15 @@ interface SideCarWebSocketProxyDeps extends SideCarProxyDeps {
authManager: AuthManager
}
interface PreviewProxyDeps {
previewManager: PreviewManager
logger: Logger
}
interface PreviewWebSocketProxyDeps extends PreviewProxyDeps {
authManager: AuthManager
}
function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) {
const proxyBaseHandler = async (
request: FastifyRequest<{ Params: { id: string } }>,
@ -412,6 +431,37 @@ function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps
app.all("/sidecars/:id/*", proxyWildcardHandler)
}
function registerPreviewProxyRoutes(app: FastifyInstance, deps: PreviewProxyDeps) {
const proxyBaseHandler = async (
request: FastifyRequest<{ Params: { token: string } }>,
reply: FastifyReply,
) => {
await proxyPreviewRequest({
request,
reply,
previewManager: deps.previewManager,
logger: deps.logger,
pathSuffix: "",
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { token: string; "*": string } }>,
reply: FastifyReply,
) => {
await proxyPreviewRequest({
request,
reply,
previewManager: deps.previewManager,
logger: deps.logger,
pathSuffix: request.params["*"] ?? "",
})
}
app.all("/previews/:token", proxyBaseHandler)
app.all("/previews/:token/*", proxyWildcardHandler)
}
function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) {
app.server.on("upgrade", (request, socket, head) => {
const rawUrl = request.url ?? "/"
@ -434,6 +484,28 @@ function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocket
})
}
function setupPreviewWebSocketProxy(app: FastifyInstance, deps: PreviewWebSocketProxyDeps) {
app.server.on("upgrade", (request, socket, head) => {
const rawUrl = request.url ?? "/"
const parsed = parsePreviewUpgradePath(rawUrl)
if (!parsed) {
return
}
void proxyPreviewWebSocketUpgrade({
request,
socket: socket as Socket,
head,
token: parsed.token,
incomingPath: parsed.pathname,
search: parsed.search,
previewManager: deps.previewManager,
authManager: deps.authManager,
logger: deps.logger,
})
})
}
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
app.register(async (instance) => {
instance.removeAllContentTypeParsers()
@ -770,7 +842,13 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}`
}
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
function setupStaticUi(
app: FastifyInstance,
uiDir: string,
authManager: AuthManager,
previewManager: PreviewManager,
logger: Logger,
) {
if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only")
return
@ -781,6 +859,14 @@ function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthMan
return
}
app.addHook("preHandler", (request, reply, done) => {
const session = authManager.getSessionFromRequest(request)
if (session && proxyPreviewFallbackFromReferer(request, reply, previewManager, logger)) {
return
}
done()
})
app.register(fastifyStatic, {
root: uiDir,
prefix: "/",
@ -797,6 +883,10 @@ function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthMan
}
const session = authManager.getSessionFromRequest(request)
if (session && proxyPreviewFallbackFromReferer(request, reply, previewManager, logger)) {
return
}
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
@ -810,7 +900,13 @@ function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthMan
})
}
function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
function setupDevProxy(
app: FastifyInstance,
upstreamBase: string,
authManager: AuthManager,
previewManager: PreviewManager,
logger: Logger,
) {
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
const url = request.raw.url ?? ""
@ -820,6 +916,10 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager:
}
const session = authManager.getSessionFromRequest(request)
if (session && proxyPreviewFallbackFromReferer(request, reply, previewManager, logger)) {
return
}
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
@ -829,6 +929,49 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager:
})
}
function proxyPreviewFallbackFromReferer(
request: FastifyRequest,
reply: FastifyReply,
previewManager: PreviewManager,
logger: Logger,
): boolean {
const rawUrl = request.raw.url ?? request.url ?? ""
const pathname = rawUrl.split("?")[0] ?? ""
if (!isPreviewFallbackPath(pathname)) {
return false
}
const refererHeader = request.headers.referer ?? request.headers.referrer
const referer = Array.isArray(refererHeader) ? refererHeader[0] : refererHeader
if (!referer) {
return false
}
const parsed = parsePreviewUpgradePath(referer)
if (!parsed) {
return false
}
void proxyPreviewAssetRequest({
request,
reply,
previewManager,
logger,
token: parsed.token,
})
return true
}
function isPreviewFallbackPath(pathname: string): boolean {
if (!pathname || pathname === "/") return false
if (pathname.startsWith("/api/") || pathname === "/api") return false
if (pathname.startsWith("/workspaces/")) return false
if (pathname.startsWith("/sidecars/")) return false
if (pathname.startsWith("/previews/")) return false
if (pathname.startsWith("/auth/") || pathname === "/login") return false
return true
}
async function proxyToDevServer(request: FastifyRequest, reply: FastifyReply, upstreamBase: string) {
try {
const targetUrl = new URL(request.raw.url ?? "/", upstreamBase)
@ -873,6 +1016,82 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, s
return result
}
function buildFetchProxyHeaders(headers: FastifyRequest["headers"], targetOrigin: string): Record<string, string> {
const sanitized = sanitizeSideCarProxyRequestHeaders(
headers as Record<string, string | string[] | undefined>,
targetOrigin,
)
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(sanitized)) {
if (!value) continue
if (key.toLowerCase() === "cookie") continue
result[key] = Array.isArray(value) ? value.join(",") : value
}
return result
}
function headersToRecord(headers: Headers): Record<string, string | string[] | undefined> {
const result: Record<string, string> = {}
headers.forEach((value, key) => {
result[key.toLowerCase()] = value
})
return result
}
function getHeaderValue(headers: Record<string, string | string[] | undefined>, key: string): string | undefined {
const value = headers[key.toLowerCase()]
return Array.isArray(value) ? value[0] : value
}
function shouldForwardRequestBody(method: string): boolean {
const normalized = method.toUpperCase()
return normalized !== "GET" && normalized !== "HEAD"
}
function isHtmlContentType(contentType: string): boolean {
const normalized = contentType.toLowerCase()
return normalized.includes("text/html") || normalized.includes("application/xhtml+xml")
}
function isCssContentType(contentType: string): boolean {
return contentType.toLowerCase().includes("text/css")
}
function rewritePreviewBodyUrls(body: string, publicBase: string, kind: "html" | "css"): string {
if (kind === "css") {
return rewriteCssPreviewUrls(body, publicBase)
}
return rewriteCssPreviewUrls(
body
.replace(/\b(src|href|action|poster|data)=(["'])\/(?!\/)([^"']*)\2/gi, (_match, attr: string, quote: string, pathValue: string) => {
return `${attr}=${quote}${publicBase}/${pathValue}${quote}`
})
.replace(/\bsrcset=(["'])([^"']*)\1/gi, (_match, quote: string, value: string) => {
return `srcset=${quote}${rewriteSrcsetPreviewUrls(value, publicBase)}${quote}`
}),
publicBase,
)
}
function rewriteCssPreviewUrls(body: string, publicBase: string): string {
return body.replace(/url\((\s*)(["']?)\/(?!\/)([^"')]+)\2(\s*)\)/gi, (_match, before: string, quote: string, pathValue: string, after: string) => {
return `url(${before}${quote}${publicBase}/${pathValue}${quote}${after})`
})
}
function rewriteSrcsetPreviewUrls(value: string, publicBase: string): string {
return value
.split(",")
.map((entry) => {
const trimmed = entry.trimStart()
if (!trimmed.startsWith("/") || trimmed.startsWith("//")) return entry
const leading = entry.slice(0, entry.length - trimmed.length)
return `${leading}${publicBase}${trimmed}`
})
.join(",")
}
async function proxySideCarRequest(args: {
request: FastifyRequest
reply: FastifyReply
@ -897,14 +1116,154 @@ async function proxySideCarRequest(args: {
const targetUrl = `${targetOrigin}${targetPath}`
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar")
await args.reply.from(targetUrl, {
rewriteRequestHeaders: (_originalRequest, headers) =>
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, targetOrigin),
await proxyTargetRequest({
reply: args.reply,
logger: args.logger,
targetUrl,
targetOrigin,
logContext: { sidecarId: sidecar.id },
errorMessage: "SideCar proxy failed",
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
})
}
async function proxyPreviewRequest(args: {
request: FastifyRequest
reply: FastifyReply
previewManager: PreviewManager
logger: Logger
pathSuffix?: string
}) {
const token = (args.request.params as { token?: string }).token ?? ""
const preview = args.previewManager.get(token)
if (!preview) {
args.reply.code(404).send({ error: "Preview not found" })
return
}
const rawUrl = args.request.raw.url ?? args.request.url ?? ""
const queryIndex = rawUrl.indexOf("?")
const search = queryIndex >= 0 ? rawUrl.slice(queryIndex) : ""
const pathSuffix = args.pathSuffix ?? ""
const requestPath = pathSuffix ? `${args.previewManager.buildProxyBasePath(token)}/${pathSuffix.replace(/^\/+/, "")}` : args.previewManager.buildProxyBasePath(token)
const targetUrl = args.previewManager.buildTargetUrl(token, requestPath, search)
if (!targetUrl) {
args.reply.code(404).send({ error: "Preview not found" })
return
}
args.logger.debug({ previewToken: token, targetUrl: targetUrl.toString() }, "Proxying request to preview")
await proxyPreviewTargetRequest({
request: args.request,
reply: args.reply,
logger: args.logger,
targetUrl: targetUrl.toString(),
targetOrigin: targetUrl.origin,
publicBase: args.previewManager.buildProxyBasePath(token),
logContext: { previewToken: token },
errorMessage: "Preview proxy failed",
rewriteHeaders: (headers) => rewritePreviewResponseHeaders(headers, token, targetUrl.origin),
})
}
async function proxyPreviewAssetRequest(args: {
request: FastifyRequest
reply: FastifyReply
previewManager: PreviewManager
logger: Logger
token: string
}) {
const rawUrl = args.request.raw.url ?? args.request.url ?? ""
const queryIndex = rawUrl.indexOf("?")
const search = queryIndex >= 0 ? rawUrl.slice(queryIndex) : ""
const pathname = rawUrl.split("?")[0] ?? "/"
const targetUrl = args.previewManager.buildTargetUrl(args.token, pathname, search)
if (!targetUrl) {
args.reply.code(404).send({ error: "Preview not found" })
return
}
args.logger.debug({ previewToken: args.token, targetUrl: targetUrl.toString() }, "Proxying preview fallback asset")
await proxyPreviewTargetRequest({
request: args.request,
reply: args.reply,
logger: args.logger,
targetUrl: targetUrl.toString(),
targetOrigin: targetUrl.origin,
publicBase: args.previewManager.buildProxyBasePath(args.token),
logContext: { previewToken: args.token, previewFallback: true },
errorMessage: "Preview proxy failed",
rewriteHeaders: (headers) => rewritePreviewResponseHeaders(headers, args.token, targetUrl.origin),
})
}
async function proxyPreviewTargetRequest(args: {
request: FastifyRequest
reply: FastifyReply
logger: Logger
targetUrl: string
targetOrigin: string
publicBase: string
logContext: Record<string, unknown>
errorMessage: string
rewriteHeaders: (headers: Record<string, string | string[] | undefined>) => Record<string, string | string[] | undefined>
}) {
try {
const response = await fetch(args.targetUrl, {
method: args.request.method,
headers: buildFetchProxyHeaders(args.request.headers, args.targetOrigin),
body: shouldForwardRequestBody(args.request.method) ? (args.request.raw as any) : undefined,
duplex: shouldForwardRequestBody(args.request.method) ? "half" : undefined,
redirect: "manual",
} as any)
const headers = args.rewriteHeaders(headersToRecord(response.headers))
const contentType = getHeaderValue(headers, "content-type") ?? response.headers.get("content-type") ?? ""
delete headers["content-length"]
delete headers["content-encoding"]
for (const [key, value] of Object.entries(headers)) {
if (value !== undefined) args.reply.header(key, value)
}
args.reply.code(response.status)
if (!response.body || args.request.method === "HEAD") {
args.reply.send()
return
}
if (isHtmlContentType(contentType) || isCssContentType(contentType)) {
const text = await response.text()
args.reply.send(rewritePreviewBodyUrls(text, args.publicBase, isCssContentType(contentType) ? "css" : "html"))
return
}
args.reply.send(Buffer.from(await response.arrayBuffer()))
} catch (error) {
args.logger.error({ ...args.logContext, err: error, targetUrl: args.targetUrl }, args.errorMessage)
if (!args.reply.sent) {
args.reply.code(502).send({ error: args.errorMessage })
}
}
}
async function proxyTargetRequest(args: {
reply: FastifyReply
logger: Logger
targetUrl: string
targetOrigin: string
logContext: Record<string, unknown>
errorMessage: string
rewriteHeaders: (headers: Record<string, string | string[] | undefined>) => Record<string, string | string[] | undefined>
}) {
await args.reply.from(args.targetUrl, {
rewriteRequestHeaders: (_originalRequest, headers) =>
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, args.targetOrigin),
rewriteHeaders: args.rewriteHeaders,
onError: (reply, { error }) => {
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request")
args.logger.error({ ...args.logContext, err: error, targetUrl: args.targetUrl }, args.errorMessage)
if (!reply.sent) {
reply.code(502).send({ error: "SideCar proxy failed" })
reply.code(502).send({ error: args.errorMessage })
}
},
})
@ -934,6 +1293,30 @@ function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname:
}
}
function parsePreviewUpgradePath(rawUrl: string): { token: string; pathname: string; search: string } | null {
let parsed: URL
try {
parsed = new URL(rawUrl, "http://localhost")
} catch {
return null
}
const match = parsed.pathname.match(/^\/previews\/([^/]+)(?:\/.*)?$/)
if (!match) {
return null
}
try {
return {
token: decodeURIComponent(match[1] ?? ""),
pathname: parsed.pathname,
search: parsed.search,
}
} catch {
return null
}
}
async function proxySideCarWebSocketUpgrade(args: {
request: import("http").IncomingMessage
socket: Socket
@ -969,6 +1352,71 @@ async function proxySideCarWebSocketUpgrade(args: {
const targetUrl = new URL(`${targetOrigin}${targetPath}`)
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar")
proxyTargetWebSocketUpgrade({
request,
socket,
head,
targetUrl,
logger,
logContext: { sidecarId },
proxyLabel: "SideCar",
})
}
async function proxyPreviewWebSocketUpgrade(args: {
request: import("http").IncomingMessage
socket: Socket
head: Buffer
token: string
incomingPath: string
search: string
previewManager: PreviewManager
authManager: AuthManager
logger: Logger
}) {
const { request, socket, head, token, incomingPath, search, previewManager, authManager, logger } = args
if (!isWebSocketUpgradeRequest(request)) {
rejectUpgrade(socket, 400, "Bad Request")
return
}
const session = authManager.getSessionFromHeaders(request.headers)
if (!session) {
rejectUpgrade(socket, 401, "Unauthorized")
return
}
const targetUrl = previewManager.buildTargetUrl(token, incomingPath, search)
if (!targetUrl) {
rejectUpgrade(socket, 404, "Not Found")
return
}
logger.debug({ previewToken: token, targetUrl: targetUrl.toString() }, "Proxying websocket to preview")
proxyTargetWebSocketUpgrade({
request,
socket,
head,
targetUrl,
logger,
logContext: { previewToken: token },
proxyLabel: "preview",
stripCookies: true,
})
}
function proxyTargetWebSocketUpgrade(args: {
request: import("http").IncomingMessage
socket: Socket
head: Buffer
targetUrl: URL
logger: Logger
logContext: Record<string, unknown>
proxyLabel: string
stripCookies?: boolean
}) {
const { request, socket, head, targetUrl, logger, logContext, proxyLabel, stripCookies } = args
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl)
const closeBoth = () => {
@ -981,7 +1429,7 @@ async function proxySideCarWebSocketUpgrade(args: {
}
upstream.once("error", (error) => {
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket")
logger.error({ ...logContext, err: error, targetUrl: targetUrl.toString() }, `Failed to proxy ${proxyLabel} websocket`)
rejectUpgrade(socket, 502, "Bad Gateway")
if (!upstream.destroyed) {
upstream.destroy()
@ -989,7 +1437,7 @@ async function proxySideCarWebSocketUpgrade(args: {
})
socket.once("error", (error) => {
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored")
logger.debug({ ...logContext, err: error }, `${proxyLabel} websocket client socket errored`)
if (!upstream.destroyed) {
upstream.destroy()
}
@ -997,14 +1445,14 @@ async function proxySideCarWebSocketUpgrade(args: {
upstream.once(readyEvent, () => {
try {
upstream.write(buildSideCarWebSocketRequest(request, targetUrl))
upstream.write(buildSideCarWebSocketRequest(request, targetUrl, { stripCookies }))
if (head.length > 0) {
upstream.write(head)
}
upstream.pipe(socket)
socket.pipe(upstream)
} catch (error) {
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade")
logger.error({ ...logContext, err: error, targetUrl: targetUrl.toString() }, `Failed to forward ${proxyLabel} websocket upgrade`)
closeBoth()
}
})
@ -1040,7 +1488,11 @@ function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSock
}
}
function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string {
function buildSideCarWebSocketRequest(
request: import("http").IncomingMessage,
targetUrl: URL,
options?: { stripCookies?: boolean },
): string {
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`
const headerLines: string[] = []
@ -1053,6 +1505,7 @@ function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, t
if (!key || value === undefined) continue
const lower = key.toLowerCase()
if (blockedHeaders.has(lower)) continue
if (options?.stripCookies && lower === "cookie") continue
if (lower === "origin") {
headerLines.push(`Origin: ${targetUrl.origin}\r\n`)
continue
@ -1121,6 +1574,42 @@ function rewriteSideCarResponseHeaders(
return next
}
function rewritePreviewResponseHeaders(
headers: Record<string, string | string[] | undefined>,
token: string,
targetOrigin: string,
) {
const next = { ...headers }
delete next["x-frame-options"]
delete next["content-security-policy"]
delete next["content-security-policy-report-only"]
delete next["set-cookie"]
delete next["set-cookie2"]
const locationHeader = next.location
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader
if (!location) {
return next
}
const publicBase = `/previews/${encodeURIComponent(token)}`
if (location.startsWith("/")) {
next.location = `${publicBase}${location}`
return next
}
try {
const parsed = new URL(location)
if (parsed.origin === targetOrigin) {
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`
}
} catch {
// Relative redirects should continue to resolve against the current preview path.
}
return next
}
function sanitizeSideCarProxyRequestHeaders(
headers: Record<string, string | string[] | undefined>,
targetOrigin: string,

View file

@ -0,0 +1,34 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { PreviewSession } from "../../api-types"
import type { PreviewManager } from "../../previews/manager"
interface RouteDeps {
previewManager: PreviewManager
}
const PreviewCreateSchema = z.object({
sessionId: z.string().trim().min(1),
url: z.string().trim().min(1),
})
export function registerPreviewRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/previews", async (request, reply): Promise<PreviewSession | { error: string }> => {
try {
const body = PreviewCreateSchema.parse(request.body ?? {})
return deps.previewManager.create(body.sessionId, body.url)
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to create preview" }
}
})
app.delete<{ Params: { token: string } }>("/api/previews/:token", async (request, reply) => {
const removed = deps.previewManager.delete(request.params.token)
if (!removed) {
reply.code(404)
return { error: "Preview not found" }
}
reply.code(204)
})
}

View file

@ -0,0 +1,381 @@
import { ArrowLeft, ArrowRight, ChevronDown, Expand, MessageSquarePlus, Monitor, RefreshCw, RotateCw, Smartphone, Tablet } from "lucide-solid"
import { Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
export interface BrowserFrameElementTarget {
pagePath: string
tagName: string
text?: string
role?: string
ariaLabel?: string
selector?: string
rect: { x: number; y: number; width: number; height: number }
}
interface BrowserFrameLabels {
back: string
refresh: string
path: string
go: string
commentMode?: string
viewport?: string
viewportResponsive?: string
viewportDesktop?: string
viewportTablet?: string
viewportTabletLandscape?: string
viewportMobile?: string
viewportMobileLandscape?: string
}
type BrowserViewportPreset = "responsive" | "desktop" | "tablet" | "tabletLandscape" | "mobile" | "mobileLandscape"
const VIEWPORT_PRESETS: Record<BrowserViewportPreset, { width: number | null; height: number | null }> = {
responsive: { width: null, height: null },
desktop: { width: 1440, height: 900 },
tablet: { width: 768, height: 1024 },
tabletLandscape: { width: 1024, height: 768 },
mobile: { width: 390, height: 844 },
mobileLandscape: { width: 844, height: 390 },
}
const VIEWPORT_OPTIONS = [
{ id: "responsive" as const, icon: Expand, getLabel: (labels: BrowserFrameLabels) => labels.viewportResponsive },
{ id: "desktop" as const, icon: Monitor, getLabel: (labels: BrowserFrameLabels) => labels.viewportDesktop },
{ id: "tablet" as const, icon: Tablet, getLabel: (labels: BrowserFrameLabels) => labels.viewportTablet },
{ id: "tabletLandscape" as const, icon: RotateCw, getLabel: (labels: BrowserFrameLabels) => labels.viewportTabletLandscape },
{ id: "mobile" as const, icon: Smartphone, getLabel: (labels: BrowserFrameLabels) => labels.viewportMobile },
{ id: "mobileLandscape" as const, icon: RotateCw, getLabel: (labels: BrowserFrameLabels) => labels.viewportMobileLandscape },
]
interface BrowserFrameProps {
title: string
initialUrl: string
proxyBasePath: string
lockedBaseLabel: string
labels: BrowserFrameLabels
commentMode?: boolean
onToggleCommentMode?: () => void
onCommentTarget?: (target: BrowserFrameElementTarget) => void
}
function getElementText(element: Element): string | undefined {
const text = (element.textContent ?? "").replace(/\s+/g, " ").trim()
return text ? text.slice(0, 120) : undefined
}
function getElementSelector(element: Element): string {
const parts: string[] = []
let current: Element | null = element
while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 5) {
const tag = current.tagName.toLowerCase()
const id = current.getAttribute("id")
if (id) {
parts.unshift(`${tag}#${CSS.escape(id)}`)
break
}
const className = Array.from(current.classList).slice(0, 2).map((item) => `.${CSS.escape(item)}`).join("")
let part = `${tag}${className}`
const parentElement: Element | null = current.parentElement
if (parentElement) {
const siblings = Array.from(parentElement.children as HTMLCollectionOf<Element>).filter((child) => child.tagName === current?.tagName)
if (siblings.length > 1) {
part = `${part}:nth-of-type(${siblings.indexOf(current) + 1})`
}
}
parts.unshift(part)
current = parentElement
}
return parts.join(" > ")
}
export const BrowserFrame: Component<BrowserFrameProps> = (props) => {
const [frameSrc, setFrameSrc] = createSignal(props.initialUrl)
const [pathInput, setPathInput] = createSignal("/")
const [viewportPreset, setViewportPreset] = createSignal<BrowserViewportPreset>("responsive")
const [viewportMenuOpen, setViewportMenuOpen] = createSignal(false)
const [highlight, setHighlight] = createSignal<{ x: number; y: number; width: number; height: number } | null>(null)
let iframeRef: HTMLIFrameElement | undefined
let frameWrapRef: HTMLDivElement | undefined
let cleanupFrameListeners: (() => void) | null = null
const canComment = createMemo(() => Boolean(props.onToggleCommentMode && props.onCommentTarget))
const viewport = createMemo(() => VIEWPORT_PRESETS[viewportPreset()])
const isResponsiveViewport = createMemo(() => viewportPreset() === "responsive")
const selectedViewportOption = createMemo(() => VIEWPORT_OPTIONS.find((option) => option.id === viewportPreset()) ?? VIEWPORT_OPTIONS[0])
const getEditablePathFromUrl = (url: string): string => {
try {
const parsed = new URL(url, window.location.origin)
const basePath = props.proxyBasePath
let pathname = parsed.pathname
if (basePath && pathname.startsWith(basePath)) {
pathname = pathname.slice(basePath.length) || "/"
}
if (!pathname.startsWith("/")) {
pathname = `/${pathname}`
}
return `${pathname}${parsed.search}${parsed.hash}`
} catch {
return "/"
}
}
const buildNormalizedTargetUrl = (rawInput: string): string => {
const trimmed = rawInput.trim()
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`
const parsed = new URL(withLeadingSlash || "/", window.location.origin)
const safeSegments: string[] = []
for (const segment of parsed.pathname.split("/")) {
if (!segment || segment === ".") continue
if (segment === "..") {
if (safeSegments.length > 0) safeSegments.pop()
continue
}
safeSegments.push(segment)
}
const normalizedPath = `/${safeSegments.join("/")}` || "/"
return `${props.proxyBasePath}${normalizedPath}${parsed.search}${parsed.hash}`
}
const buildElementTarget = (element: Element): BrowserFrameElementTarget => {
const rect = element.getBoundingClientRect()
const pagePath = getEditablePathFromUrl(iframeRef?.contentWindow?.location.href ?? frameSrc())
return {
pagePath,
tagName: element.tagName.toLowerCase(),
text: getElementText(element),
role: element.getAttribute("role") ?? undefined,
ariaLabel: element.getAttribute("aria-label") ?? undefined,
selector: getElementSelector(element),
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
}
}
const attachCommentListeners = () => {
cleanupFrameListeners?.()
cleanupFrameListeners = null
setHighlight(null)
if (!props.commentMode || !iframeRef?.contentDocument || !iframeRef.contentWindow || !frameWrapRef) return
const doc = iframeRef.contentDocument
const frameWindow = iframeRef.contentWindow
const handleMove = (event: MouseEvent) => {
const target = event.target
if (!target || !(target instanceof (frameWindow as any).Element)) return
const element = target as Element
const rect = element.getBoundingClientRect()
const frameRect = iframeRef?.getBoundingClientRect()
const wrapRect = frameWrapRef?.getBoundingClientRect()
if (!frameRect || !wrapRect) return
setHighlight({
x: frameRect.left - wrapRect.left + rect.x,
y: frameRect.top - wrapRect.top + rect.y,
width: rect.width,
height: rect.height,
})
}
const handleLeave = () => setHighlight(null)
const handleClick = (event: MouseEvent) => {
const target = event.target
if (!target || !(target instanceof (frameWindow as any).Element)) return
event.preventDefault()
event.stopPropagation()
props.onCommentTarget?.(buildElementTarget(target as Element))
}
doc.addEventListener("mousemove", handleMove, true)
doc.addEventListener("mouseleave", handleLeave, true)
doc.addEventListener("click", handleClick, true)
cleanupFrameListeners = () => {
doc.removeEventListener("mousemove", handleMove, true)
doc.removeEventListener("mouseleave", handleLeave, true)
doc.removeEventListener("click", handleClick, true)
}
}
const syncPathInputFromFrame = () => {
try {
const currentHref = iframeRef?.contentWindow?.location.href
if (currentHref) setPathInput(getEditablePathFromUrl(currentHref))
} catch {
setPathInput(getEditablePathFromUrl(frameSrc()))
}
attachCommentListeners()
}
createEffect(() => {
setFrameSrc(props.initialUrl)
setPathInput(getEditablePathFromUrl(props.initialUrl))
})
createEffect(() => {
props.commentMode
attachCommentListeners()
})
onCleanup(() => cleanupFrameListeners?.())
const handleBack = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
try {
iframeRef?.contentWindow?.history.go(-1)
} catch {
// Ignore navigation errors from pages that do not expose history access.
}
}
const handleRefresh = () => {
try {
iframeRef?.contentWindow?.location.reload()
return
} catch {
// Fall back to resetting the iframe source if the frame cannot be reloaded directly.
}
setFrameSrc("about:blank")
requestAnimationFrame(() => setFrameSrc(props.initialUrl))
}
const handleGo = (event?: Event) => {
event?.preventDefault()
const nextUrl = buildNormalizedTargetUrl(pathInput())
setFrameSrc(nextUrl)
setPathInput(getEditablePathFromUrl(nextUrl))
}
return (
<div class="flex h-full min-h-0 w-full flex-col bg-surface">
<div class="flex shrink-0 items-center gap-2 px-3 py-2" style={{ "border-bottom": "1px solid var(--border-base)" }}>
<button type="button" class="new-tab-button" onClick={handleBack} title={props.labels.back} aria-label={props.labels.back}>
<ArrowLeft class="h-4 w-4" />
</button>
<button type="button" class="new-tab-button" onClick={handleRefresh} title={props.labels.refresh} aria-label={props.labels.refresh}>
<RefreshCw class="h-4 w-4" />
</button>
<div class="shrink-0 rounded-md px-3 py-1.5 text-sm" style={{ background: "var(--surface-secondary)", color: "var(--text-secondary)", border: "1px solid var(--border-base)" }}>
{props.lockedBaseLabel}
</div>
<form class="flex min-w-0 flex-1 items-center gap-2" onSubmit={(event) => handleGo(event)}>
<input
type="text"
class="min-w-0 flex-1 rounded-md px-3 py-1.5 text-sm outline-none"
style={{ background: "var(--surface-secondary)", color: "var(--text-primary)", border: "1px solid var(--border-base)" }}
value={pathInput()}
onInput={(event) => setPathInput(event.currentTarget.value)}
spellcheck={false}
autocomplete="off"
autocorrect="off"
autocapitalize="off"
aria-label={props.labels.path}
/>
<button type="submit" class="new-tab-button" title={props.labels.go} aria-label={props.labels.go}>
<ArrowRight class="h-4 w-4" />
</button>
</form>
<div class="relative shrink-0">
<button
type="button"
class="selector-button selector-button-secondary px-2 py-1.5 text-sm"
aria-label={props.labels.viewport}
title={props.labels.viewport}
aria-haspopup="menu"
aria-expanded={viewportMenuOpen() ? "true" : "false"}
onClick={() => setViewportMenuOpen((open) => !open)}
>
{(() => {
const Icon = selectedViewportOption().icon
return <Icon class="h-4 w-4" />
})()}
<ChevronDown class="h-3.5 w-3.5" />
</button>
<Show when={viewportMenuOpen()}>
<div
class="absolute right-0 top-full z-20 mt-1 min-w-[13rem] overflow-hidden rounded-md border border-base shadow-xl"
style={{ background: "var(--surface-base)", color: "var(--text-primary)" }}
role="menu"
>
{VIEWPORT_OPTIONS.map((option) => {
const Icon = option.icon
return (
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-surface-secondary"
style={viewportPreset() === option.id ? { color: "var(--accent-primary)" } : undefined}
role="menuitemradio"
aria-checked={viewportPreset() === option.id ? "true" : "false"}
aria-label={option.getLabel(props.labels)}
title={option.getLabel(props.labels)}
onClick={() => {
setViewportPreset(option.id)
setViewportMenuOpen(false)
}}
>
<Icon class="h-4 w-4" />
<span>{option.getLabel(props.labels)}</span>
</button>
)
})}
</div>
</Show>
</div>
<Show when={canComment()}>
<button
type="button"
class="new-tab-button"
style={props.commentMode ? { color: "var(--accent-primary)" } : undefined}
title={props.labels.commentMode}
aria-label={props.labels.commentMode}
aria-pressed={props.commentMode ? "true" : "false"}
onClick={props.onToggleCommentMode}
>
<MessageSquarePlus class="h-4 w-4" />
</button>
</Show>
</div>
<div ref={frameWrapRef} class="relative min-h-0 min-w-0 flex-1 overflow-hidden">
<div
class={isResponsiveViewport()
? "absolute inset-0 overflow-hidden bg-surface"
: "absolute inset-0 overflow-auto bg-surface-secondary p-4"}
>
<iframe
ref={iframeRef}
src={frameSrc()}
title={props.title}
class={isResponsiveViewport() ? "block border-0 bg-surface" : "block border-0 bg-surface shadow-xl"}
style={{
width: viewport().width ? `${viewport().width}px` : "100%",
height: viewport().height ? `${viewport().height}px` : "100%",
margin: viewport().width ? "0 auto" : "0",
}}
referrerPolicy="same-origin"
onLoad={syncPathInputFromFrame}
/>
</div>
<Show when={props.commentMode && highlight()}>
{(rect) => (
<div
class="pointer-events-none absolute rounded-md"
style={{
left: `${rect().x}px`,
top: `${rect().y}px`,
width: `${rect().width}px`,
height: `${rect().height}px`,
border: "2px solid var(--accent-primary)",
"box-shadow": "0 0 0 9999px rgba(0, 0, 0, 0.08)",
}}
/>
)}
</Show>
</div>
</div>
)
}

View file

@ -42,9 +42,11 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getRetrySeconds, getSessionIdleFadeClass, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../../stores/session-status"
import { Maximize2, Search, ShieldAlert } from "lucide-solid"
import { Eye, Maximize2, MessageSquareText, Search, ShieldAlert } from "lucide-solid"
import type { PromptInputApi } from "../prompt-input/types"
import { useConfig } from "../../stores/preferences"
import { showPromptDialog } from "../../stores/alerts"
import { openSessionPreview, sessionPreviews, showSessionChat, showSessionPreview } from "../../stores/session-previews"
import type { LayoutMode } from "./shell/types"
import {
@ -271,6 +273,11 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return sessionPromptApis()[sessionId] ?? null
})
const activeSessionPreview = createMemo(() => {
const sessionId = activeSessionIdForInstance()
return sessionId ? sessionPreviews().get(sessionId) ?? null : null
})
const registerSessionPromptApi = (sessionId: string, api: PromptInputApi | null) => {
setSessionPromptApis((current) => ({
...current,
@ -278,6 +285,52 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}))
}
async function handleOpenPreview() {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return
const url = await showPromptDialog(t("sessionPreview.open.prompt"), {
title: t("sessionPreview.open.title"),
inputLabel: t("sessionPreview.open.label"),
inputPlaceholder: t("sessionPreview.open.placeholder"),
confirmLabel: t("sessionPreview.open.confirm"),
cancelLabel: t("sessionPreview.open.cancel"),
})
const normalized = url?.trim()
if (!normalized) return
await openSessionPreview(sessionId, normalized)
}
function handleShowPreview() {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return
showSessionPreview(sessionId)
}
function handlePreviewButtonClick() {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return
const preview = activeSessionPreview()
if (preview?.mode === "preview") {
showSessionChat(sessionId)
return
}
if (preview) {
showSessionPreview(sessionId)
return
}
void handleOpenPreview()
}
const previewToggleLabel = createMemo(() => {
const preview = activeSessionPreview()
return preview?.mode === "preview" ? t("sessionPreview.chat.button") : t("sessionPreview.open.button")
})
const PreviewToggleIcon = createMemo(() => activeSessionPreview()?.mode === "preview" ? MessageSquareText : Eye)
const yoloModeEnabled = createMemo(() => {
const session = activeSessionForInstance()
if (!session) return false
@ -728,6 +781,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
>
<Search class="w-5 h-5" aria-hidden="true" />
</IconButton>
<IconButton
color="inherit"
onClick={handlePreviewButtonClick}
aria-label={previewToggleLabel()}
title={previewToggleLabel()}
size="small"
>
{(() => {
const Icon = PreviewToggleIcon()
return <Icon class="w-5 h-5" aria-hidden="true" />
})()}
</IconButton>
</Show>
<button
type="button"
@ -850,6 +915,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
>
<Search class="w-5 h-5" aria-hidden="true" />
</IconButton>
<IconButton
color="inherit"
onClick={handlePreviewButtonClick}
aria-label={previewToggleLabel()}
title={previewToggleLabel()}
size="small"
>
{(() => {
const Icon = PreviewToggleIcon()
return <Icon class="w-5 h-5" aria-hidden="true" />
})()}
</IconButton>
</Show>
<Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected">

View file

@ -0,0 +1,91 @@
import { createSignal, type Component } from "solid-js"
import { X } from "lucide-solid"
import { useI18n } from "../lib/i18n"
import { showPromptDialog } from "../stores/alerts"
import type { SessionPreviewRecord } from "../stores/session-previews"
import { BrowserFrame, type BrowserFrameElementTarget } from "./browser-frame"
interface SessionPreviewViewProps {
preview: SessionPreviewRecord
onBackToChat: () => void
onClose: () => void
onInsertComment: (markdown: string) => void
}
function describeElement(target: BrowserFrameElementTarget): string {
const label = target.ariaLabel || target.text
const role = target.role ? ` role="${target.role}"` : ""
return label ? `${target.tagName}${role} "${label}"` : `${target.tagName}${role}`
}
function buildCommentMarkdown(target: BrowserFrameElementTarget, comment: string): string {
const lines = [
"> Web preview comment",
`> Page: \`${target.pagePath}\``,
`> Element: \`${describeElement(target)}\``,
]
if (target.selector) {
lines.push(`> Selector: \`${target.selector}\``)
}
return `${lines.join("\n")}\n\n${comment}\n\n`
}
export const SessionPreviewView: Component<SessionPreviewViewProps> = (props) => {
const { t } = useI18n()
const [commentMode, setCommentMode] = createSignal(false)
const target = () => new URL(props.preview.targetUrl)
async function handleCommentTarget(elementTarget: BrowserFrameElementTarget) {
const comment = await showPromptDialog(t("sessionPreview.comment.prompt"), {
title: t("sessionPreview.comment.title"),
inputLabel: t("sessionPreview.comment.label"),
confirmLabel: t("sessionPreview.comment.add"),
cancelLabel: t("sessionPreview.comment.cancel"),
})
const normalized = comment?.trim()
if (!normalized) return
props.onInsertComment(buildCommentMarkdown(elementTarget, normalized))
}
return (
<div class="flex h-full min-h-0 flex-col bg-surface">
<div class="flex shrink-0 items-center justify-between gap-3 px-3 py-2" style={{ "border-bottom": "1px solid var(--border-base)" }}>
<div class="min-w-0">
<div class="text-sm font-medium text-primary truncate">{t("sessionPreview.title")}</div>
<div class="text-xs text-muted truncate">{props.preview.targetUrl}</div>
</div>
<div class="flex items-center gap-2">
<button type="button" class="selector-button selector-button-secondary" onClick={props.onBackToChat}>
{t("sessionPreview.backToChat")}
</button>
<button type="button" class="new-tab-button" onClick={props.onClose} aria-label={t("sessionPreview.close")} title={t("sessionPreview.close")}>
<X class="h-4 w-4" />
</button>
</div>
</div>
<BrowserFrame
title={t("sessionPreview.title")}
initialUrl={props.preview.proxyUrl}
proxyBasePath={`/previews/${encodeURIComponent(props.preview.token)}`}
lockedBaseLabel={target().host}
labels={{
back: t("sidecars.back"),
refresh: t("sidecars.refresh"),
path: t("sidecars.path"),
go: t("sidecars.go"),
viewport: t("browserFrame.viewport"),
viewportResponsive: t("browserFrame.viewport.responsive"),
viewportDesktop: t("browserFrame.viewport.desktop"),
viewportTablet: t("browserFrame.viewport.tablet"),
viewportTabletLandscape: t("browserFrame.viewport.tabletLandscape"),
viewportMobile: t("browserFrame.viewport.mobile"),
viewportMobileLandscape: t("browserFrame.viewport.mobileLandscape"),
commentMode: t("sessionPreview.comment.mode"),
}}
commentMode={commentMode()}
onToggleCommentMode={() => setCommentMode((value) => !value)}
onCommentTarget={(target) => void handleCommentTarget(target)}
/>
</div>
)
}

View file

@ -18,6 +18,8 @@ import { useI18n } from "../../lib/i18n"
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
import { clearConversationPlaybackForSession } from "../../stores/conversation-speech"
import { useConfig } from "../../stores/preferences"
import { closeSessionPreview, getSessionPreview, showSessionChat } from "../../stores/session-previews"
import { SessionPreviewView } from "../session-preview-view"
const log = getLogger("session")
@ -59,12 +61,14 @@ export const SessionView: Component<SessionViewProps> = (props) => {
})
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
const preview = createMemo(() => getSessionPreview(props.sessionId))
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
let promptInputApi: PromptInputApi | null = null
let pendingPromptText: string | null = null
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
let pendingCommentText: string | null = null
let scrollToBottomHandle: (() => void) | undefined
let rootRef: HTMLDivElement | undefined
@ -207,6 +211,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
pendingSelectionInsert = null
}
if (pendingCommentText) {
api.insertComment(pendingCommentText)
pendingCommentText = null
}
return () => {
if (promptInputApi === api) {
promptInputApi = null
@ -222,6 +231,14 @@ export const SessionView: Component<SessionViewProps> = (props) => {
pendingSelectionInsert = { text, mode }
}
}
function handleInsertPreviewComment(markdown: string) {
if (promptInputApi) {
promptInputApi.insertComment(markdown)
} else {
pendingCommentText = `${pendingCommentText ?? ""}${markdown}`
}
}
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
scheduleScrollToBottom()
@ -380,56 +397,65 @@ export const SessionView: Component<SessionViewProps> = (props) => {
if (!activeSession) return null
return (
<div ref={rootRef} class="session-view">
<MessageSection
instanceId={props.instanceId}
sessionId={activeSession.id}
loading={messagesLoading()}
onRevert={handleRevert}
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
onFork={handleFork}
isActive={props.isActive}
registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn
}}
showSidebarToggle={props.showSidebarToggle}
onSidebarToggle={props.onSidebarToggle}
forceCompactStatusLayout={props.forceCompactStatusLayout}
onQuoteSelection={handleQuoteSelection}
/>
<Show when={attachments().length > 0}>
<PromptAttachmentsBar
attachments={attachments()}
onRemoveAttachment={(attachmentId) => {
if (promptInputApi) {
promptInputApi.removeAttachment(attachmentId)
return
}
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}}
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
/>
</Show>
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={activeSession.id}
isActive={props.isActive}
compactLayout={props.compactPromptLayout}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
isSessionBusy={sessionBusy()}
disabled={sessionNeedsInput()}
onAbortSession={handleAbortSession}
registerPromptInputApi={registerPromptInputApi}
<Show
when={preview()?.mode === "preview" && preview()}
fallback={
<MessageSection
instanceId={props.instanceId}
sessionId={activeSession.id}
loading={messagesLoading()}
onRevert={handleRevert}
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
onFork={handleFork}
isActive={props.isActive}
registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn
}}
showSidebarToggle={props.showSidebarToggle}
onSidebarToggle={props.onSidebarToggle}
forceCompactStatusLayout={props.forceCompactStatusLayout}
onQuoteSelection={handleQuoteSelection}
/>
}
>
{(activePreview) => (
<SessionPreviewView
preview={activePreview()}
onBackToChat={() => showSessionChat(props.sessionId)}
onClose={() => void closeSessionPreview(props.sessionId)}
onInsertComment={handleInsertPreviewComment}
/>
)}
</Show>
<Show when={attachments().length > 0}>
<PromptAttachmentsBar
attachments={attachments()}
onRemoveAttachment={(attachmentId) => {
if (promptInputApi) {
promptInputApi.removeAttachment(attachmentId)
return
}
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}}
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
/>
</Show>
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={activeSession.id}
isActive={props.isActive}
compactLayout={props.compactPromptLayout}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
isSessionBusy={sessionBusy()}
disabled={sessionNeedsInput()}
onAbortSession={handleAbortSession}
registerPromptInputApi={registerPromptInputApi}
/>
</div>
)
}}

View file

@ -1,7 +1,7 @@
import { ArrowLeft, ArrowRight, RefreshCw } from "lucide-solid"
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
import { createMemo, type Component } from "solid-js"
import type { SideCarTabRecord } from "../stores/sidecars"
import { useI18n } from "../lib/i18n"
import { BrowserFrame } from "./browser-frame"
interface SideCarViewProps {
tab: SideCarTabRecord
@ -9,9 +9,6 @@ interface SideCarViewProps {
export const SideCarView: Component<SideCarViewProps> = (props) => {
const { t } = useI18n()
const [frameSrc, setFrameSrc] = createSignal(props.tab.shellUrl)
const [pathInput, setPathInput] = createSignal("/")
let iframeRef: HTMLIFrameElement | undefined
const lockedBaseLabel = createMemo(() => {
const hostLabel = props.tab.port ? `${props.tab.name}:${props.tab.port}` : props.tab.name
@ -21,177 +18,25 @@ export const SideCarView: Component<SideCarViewProps> = (props) => {
return hostLabel
})
const getEditablePathFromUrl = (url: string): string => {
try {
const parsed = new URL(url, window.location.origin)
const basePath = props.tab.proxyBasePath
let pathname = parsed.pathname
if (basePath && pathname.startsWith(basePath)) {
pathname = pathname.slice(basePath.length) || "/"
}
if (!pathname.startsWith("/")) {
pathname = `/${pathname}`
}
return `${pathname}${parsed.search}${parsed.hash}`
} catch {
return "/"
}
}
const buildNormalizedTargetUrl = (rawInput: string): string => {
const trimmed = rawInput.trim()
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`
const parsed = new URL(withLeadingSlash || "/", window.location.origin)
const safeSegments: string[] = []
for (const segment of parsed.pathname.split("/")) {
if (!segment || segment === ".") {
continue
}
if (segment === "..") {
if (safeSegments.length > 0) {
safeSegments.pop()
}
continue
}
safeSegments.push(segment)
}
const normalizedPath = `/${safeSegments.join("/")}` || "/"
const basePath = props.tab.proxyBasePath
return `${basePath}${normalizedPath}${parsed.search}${parsed.hash}`
}
const syncPathInputFromFrame = () => {
try {
const currentHref = iframeRef?.contentWindow?.location.href
if (!currentHref) {
return
}
setPathInput(getEditablePathFromUrl(currentHref))
} catch {
setPathInput(getEditablePathFromUrl(frameSrc()))
}
}
createEffect(() => {
setFrameSrc(props.tab.shellUrl)
setPathInput(getEditablePathFromUrl(props.tab.shellUrl))
})
const handleBack = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
try {
const frameWindow = iframeRef?.contentWindow
if (!frameWindow) {
return
}
if (frameWindow.history.length <= 1) {
return
}
frameWindow.focus()
frameWindow.history.go(-1)
} catch {
// Ignore navigation errors from pages that do not expose history access.
}
}
const handleRefresh = () => {
try {
iframeRef?.contentWindow?.location.reload()
return
} catch {
// Fall back to resetting the iframe source if the frame cannot be reloaded directly.
}
setFrameSrc("about:blank")
requestAnimationFrame(() => setFrameSrc(props.tab.shellUrl))
}
const handleGo = (event?: Event) => {
event?.preventDefault()
const nextUrl = buildNormalizedTargetUrl(pathInput())
setFrameSrc(nextUrl)
setPathInput(getEditablePathFromUrl(nextUrl))
}
return (
<div class="flex h-full min-h-0 w-full flex-col bg-surface">
<div
class="flex shrink-0 items-center gap-2 px-3 py-2"
style={{ "border-bottom": "1px solid var(--border-base)" }}
>
<button
type="button"
class="new-tab-button"
onClick={handleBack}
title={t("sidecars.back")}
aria-label={t("sidecars.back")}
>
<ArrowLeft class="h-4 w-4" />
</button>
<button
type="button"
class="new-tab-button"
onClick={handleRefresh}
title={t("sidecars.refresh")}
aria-label={t("sidecars.refresh")}
>
<RefreshCw class="h-4 w-4" />
</button>
<div
class="shrink-0 rounded-md px-3 py-1.5 text-sm"
style={{
background: "var(--surface-secondary)",
color: "var(--text-secondary)",
border: "1px solid var(--border-base)",
}}
>
{lockedBaseLabel()}
</div>
<form class="flex min-w-0 flex-1 items-center gap-2" onSubmit={(event) => handleGo(event)}>
<input
type="text"
class="min-w-0 flex-1 rounded-md px-3 py-1.5 text-sm outline-none"
style={{
background: "var(--surface-secondary)",
color: "var(--text-primary)",
border: "1px solid var(--border-base)",
}}
value={pathInput()}
onInput={(event) => setPathInput(event.currentTarget.value)}
spellcheck={false}
autocomplete="off"
autocorrect="off"
autocapitalize="off"
aria-label={t("sidecars.path")}
/>
<button
type="submit"
class="new-tab-button"
title={t("sidecars.go")}
aria-label={t("sidecars.go")}
>
<ArrowRight class="h-4 w-4" />
</button>
</form>
</div>
<iframe
ref={iframeRef}
src={frameSrc()}
title={props.tab.name}
class="min-h-0 flex-1 w-full border-0 bg-surface"
referrerPolicy="same-origin"
onLoad={syncPathInputFromFrame}
/>
</div>
<BrowserFrame
title={props.tab.name}
initialUrl={props.tab.shellUrl}
proxyBasePath={props.tab.proxyBasePath}
lockedBaseLabel={lockedBaseLabel()}
labels={{
back: t("sidecars.back"),
refresh: t("sidecars.refresh"),
path: t("sidecars.path"),
go: t("sidecars.go"),
viewport: t("browserFrame.viewport"),
viewportResponsive: t("browserFrame.viewport.responsive"),
viewportDesktop: t("browserFrame.viewport.desktop"),
viewportTablet: t("browserFrame.viewport.tablet"),
viewportTabletLandscape: t("browserFrame.viewport.tabletLandscape"),
viewportMobile: t("browserFrame.viewport.mobile"),
viewportMobileLandscape: t("browserFrame.viewport.mobileLandscape"),
}}
/>
)
}

View file

@ -11,6 +11,7 @@ import type {
SpeechSynthesisResponse,
SpeechTranscriptionResponse,
SideCar,
PreviewSession,
ServerMeta,
RemoteProxySessionCreateRequest,
RemoteProxySessionCreateResponse,
@ -252,6 +253,15 @@ export const serverApi = {
deleteSidecar(id: string): Promise<void> {
return request(`/api/sidecars/${encodeURIComponent(id)}`, { method: "DELETE" })
},
createPreview(payload: { sessionId: string; url: string }): Promise<PreviewSession> {
return request<PreviewSession>("/api/previews", {
method: "POST",
body: JSON.stringify(payload),
})
},
deletePreview(token: string): Promise<void> {
return request(`/api/previews/${encodeURIComponent(token)}`, { method: "DELETE" })
},
fetchServerMeta(): Promise<ServerMeta> {
return request<ServerMeta>("/api/meta")
},

View file

@ -82,6 +82,31 @@ export const sessionMessages = {
"sessionView.attachments.insertPastedTextTitle": "Insert pasted text",
"sessionView.attachments.removeAriaLabel": "Remove attachment",
"sessionPreview.title": "Web preview",
"sessionPreview.backToChat": "Back to chat",
"sessionPreview.close": "Close preview",
"sessionPreview.chat.button": "Back to chat",
"sessionPreview.open.button": "Open web preview",
"sessionPreview.open.title": "Open web preview",
"sessionPreview.open.prompt": "Enter a trusted local or staging URL to preview in this session.",
"sessionPreview.open.label": "Preview URL",
"sessionPreview.open.placeholder": "https://staging.example.com",
"sessionPreview.open.confirm": "Open preview",
"sessionPreview.open.cancel": "Cancel",
"sessionPreview.comment.mode": "Comment mode",
"sessionPreview.comment.title": "Add preview comment",
"sessionPreview.comment.prompt": "Describe what should change for the selected element.",
"sessionPreview.comment.label": "Comment",
"sessionPreview.comment.add": "Add comment",
"sessionPreview.comment.cancel": "Cancel",
"browserFrame.viewport": "Viewport size",
"browserFrame.viewport.responsive": "Responsive viewport",
"browserFrame.viewport.desktop": "Desktop viewport (1440 x 900)",
"browserFrame.viewport.tablet": "Tablet viewport (768 x 1024)",
"browserFrame.viewport.tabletLandscape": "Tablet landscape (1024 x 768)",
"browserFrame.viewport.mobile": "Mobile viewport (390 x 844)",
"browserFrame.viewport.mobileLandscape": "Mobile landscape (844 x 390)",
"sessionEvents.sessionCompactedToast": "Session {label} was compacted",
"sessionEvents.sessionError.unknown": "Unknown error",
"sessionEvents.sessionError.title": "Session error",

View file

@ -82,6 +82,31 @@ export const sessionMessages = {
"sessionView.attachments.insertPastedTextTitle": "Insertar texto pegado",
"sessionView.attachments.removeAriaLabel": "Quitar adjunto",
"sessionPreview.title": "Vista previa web",
"sessionPreview.backToChat": "Volver al chat",
"sessionPreview.close": "Cerrar vista previa",
"sessionPreview.chat.button": "Volver al chat",
"sessionPreview.open.button": "Abrir vista previa web",
"sessionPreview.open.title": "Abrir vista previa web",
"sessionPreview.open.prompt": "Introduce una URL local o de staging de confianza para previsualizarla en esta sesión.",
"sessionPreview.open.label": "URL de vista previa",
"sessionPreview.open.placeholder": "https://staging.example.com",
"sessionPreview.open.confirm": "Abrir vista previa",
"sessionPreview.open.cancel": "Cancelar",
"sessionPreview.comment.mode": "Modo de comentarios",
"sessionPreview.comment.title": "Agregar comentario de vista previa",
"sessionPreview.comment.prompt": "Describe qué debe cambiar en el elemento seleccionado.",
"sessionPreview.comment.label": "Comentario",
"sessionPreview.comment.add": "Agregar comentario",
"sessionPreview.comment.cancel": "Cancelar",
"browserFrame.viewport": "Tamaño del viewport",
"browserFrame.viewport.responsive": "Viewport adaptable",
"browserFrame.viewport.desktop": "Viewport de escritorio (1440 x 900)",
"browserFrame.viewport.tablet": "Viewport de tablet (768 x 1024)",
"browserFrame.viewport.tabletLandscape": "Tablet horizontal (1024 x 768)",
"browserFrame.viewport.mobile": "Viewport móvil (390 x 844)",
"browserFrame.viewport.mobileLandscape": "Móvil horizontal (844 x 390)",
"sessionEvents.sessionCompactedToast": "La sesión {label} fue compactada",
"sessionEvents.sessionError.unknown": "Error desconocido",
"sessionEvents.sessionError.title": "Error de sesión",

View file

@ -82,6 +82,31 @@ export const sessionMessages = {
"sessionView.attachments.insertPastedTextTitle": "Insérer le texte collé",
"sessionView.attachments.removeAriaLabel": "Retirer la pièce jointe",
"sessionPreview.title": "Aperçu web",
"sessionPreview.backToChat": "Retour au chat",
"sessionPreview.close": "Fermer l'aperçu",
"sessionPreview.chat.button": "Retour au chat",
"sessionPreview.open.button": "Ouvrir l'aperçu web",
"sessionPreview.open.title": "Ouvrir l'aperçu web",
"sessionPreview.open.prompt": "Saisissez une URL locale ou de staging de confiance à prévisualiser dans cette session.",
"sessionPreview.open.label": "URL de l'aperçu",
"sessionPreview.open.placeholder": "https://staging.example.com",
"sessionPreview.open.confirm": "Ouvrir l'aperçu",
"sessionPreview.open.cancel": "Annuler",
"sessionPreview.comment.mode": "Mode commentaire",
"sessionPreview.comment.title": "Ajouter un commentaire d'aperçu",
"sessionPreview.comment.prompt": "Décrivez ce qui doit changer pour l'élément sélectionné.",
"sessionPreview.comment.label": "Commentaire",
"sessionPreview.comment.add": "Ajouter le commentaire",
"sessionPreview.comment.cancel": "Annuler",
"browserFrame.viewport": "Taille du viewport",
"browserFrame.viewport.responsive": "Viewport responsive",
"browserFrame.viewport.desktop": "Viewport bureau (1440 x 900)",
"browserFrame.viewport.tablet": "Viewport tablette (768 x 1024)",
"browserFrame.viewport.tabletLandscape": "Tablette paysage (1024 x 768)",
"browserFrame.viewport.mobile": "Viewport mobile (390 x 844)",
"browserFrame.viewport.mobileLandscape": "Mobile paysage (844 x 390)",
"sessionEvents.sessionCompactedToast": "La session {label} a été compactée",
"sessionEvents.sessionError.unknown": "Erreur inconnue",
"sessionEvents.sessionError.title": "Erreur de session",

View file

@ -82,6 +82,31 @@ export const sessionMessages = {
"sessionView.attachments.insertPastedTextTitle": "הכנס טקסט שהודבק",
"sessionView.attachments.removeAriaLabel": "הסר קובץ מצורף",
"sessionPreview.title": "תצוגה מקדימה של אתר",
"sessionPreview.backToChat": "חזרה לצ'אט",
"sessionPreview.close": "סגור תצוגה מקדימה",
"sessionPreview.chat.button": "חזרה לצ'אט",
"sessionPreview.open.button": "פתח תצוגה מקדימה של אתר",
"sessionPreview.open.title": "פתח תצוגה מקדימה של אתר",
"sessionPreview.open.prompt": "הזן כתובת URL מקומית או סביבת staging שאתה נותן בה אמון לתצוגה מקדימה בסשן הזה.",
"sessionPreview.open.label": "כתובת URL לתצוגה מקדימה",
"sessionPreview.open.placeholder": "https://staging.example.com",
"sessionPreview.open.confirm": "פתח תצוגה מקדימה",
"sessionPreview.open.cancel": "ביטול",
"sessionPreview.comment.mode": "מצב תגובות",
"sessionPreview.comment.title": "הוסף תגובה לתצוגה המקדימה",
"sessionPreview.comment.prompt": "תאר מה צריך להשתנות באלמנט שנבחר.",
"sessionPreview.comment.label": "תגובה",
"sessionPreview.comment.add": "הוסף תגובה",
"sessionPreview.comment.cancel": "ביטול",
"browserFrame.viewport": "גודל viewport",
"browserFrame.viewport.responsive": "Viewport רספונסיבי",
"browserFrame.viewport.desktop": "Viewport שולחני (1440 x 900)",
"browserFrame.viewport.tablet": "Viewport טאבלט (768 x 1024)",
"browserFrame.viewport.tabletLandscape": "טאבלט לרוחב (1024 x 768)",
"browserFrame.viewport.mobile": "Viewport נייד (390 x 844)",
"browserFrame.viewport.mobileLandscape": "נייד לרוחב (844 x 390)",
"sessionEvents.sessionCompactedToast": "הסשן {label} סוכם",
"sessionEvents.sessionError.unknown": "שגיאה לא ידועה",
"sessionEvents.sessionError.title": "שגיאת סשן",

View file

@ -82,6 +82,31 @@ export const sessionMessages = {
"sessionView.attachments.insertPastedTextTitle": "貼り付けたテキストを挿入",
"sessionView.attachments.removeAriaLabel": "添付を削除",
"sessionPreview.title": "Web プレビュー",
"sessionPreview.backToChat": "チャットに戻る",
"sessionPreview.close": "プレビューを閉じる",
"sessionPreview.chat.button": "チャットに戻る",
"sessionPreview.open.button": "Web プレビューを開く",
"sessionPreview.open.title": "Web プレビューを開く",
"sessionPreview.open.prompt": "このセッションでプレビューする信頼済みのローカルまたはステージング URL を入力してください。",
"sessionPreview.open.label": "プレビュー URL",
"sessionPreview.open.placeholder": "https://staging.example.com",
"sessionPreview.open.confirm": "プレビューを開く",
"sessionPreview.open.cancel": "キャンセル",
"sessionPreview.comment.mode": "コメントモード",
"sessionPreview.comment.title": "プレビューコメントを追加",
"sessionPreview.comment.prompt": "選択した要素で変更すべき内容を説明してください。",
"sessionPreview.comment.label": "コメント",
"sessionPreview.comment.add": "コメントを追加",
"sessionPreview.comment.cancel": "キャンセル",
"browserFrame.viewport": "ビューポートサイズ",
"browserFrame.viewport.responsive": "レスポンシブビューポート",
"browserFrame.viewport.desktop": "デスクトップビューポート (1440 x 900)",
"browserFrame.viewport.tablet": "タブレットビューポート (768 x 1024)",
"browserFrame.viewport.tabletLandscape": "タブレット横向き (1024 x 768)",
"browserFrame.viewport.mobile": "モバイルビューポート (390 x 844)",
"browserFrame.viewport.mobileLandscape": "モバイル横向き (844 x 390)",
"sessionEvents.sessionCompactedToast": "セッション {label} をコンパクト化しました",
"sessionEvents.sessionError.unknown": "不明なエラー",
"sessionEvents.sessionError.title": "セッションエラー",

View file

@ -82,6 +82,31 @@ export const sessionMessages = {
"sessionView.attachments.insertPastedTextTitle": "Вставить вставленный текст",
"sessionView.attachments.removeAriaLabel": "Удалить вложение",
"sessionPreview.title": "Веб-предпросмотр",
"sessionPreview.backToChat": "Вернуться в чат",
"sessionPreview.close": "Закрыть предпросмотр",
"sessionPreview.chat.button": "Вернуться в чат",
"sessionPreview.open.button": "Открыть веб-предпросмотр",
"sessionPreview.open.title": "Открыть веб-предпросмотр",
"sessionPreview.open.prompt": "Введите доверенный локальный или staging URL для предпросмотра в этой сессии.",
"sessionPreview.open.label": "URL предпросмотра",
"sessionPreview.open.placeholder": "https://staging.example.com",
"sessionPreview.open.confirm": "Открыть предпросмотр",
"sessionPreview.open.cancel": "Отмена",
"sessionPreview.comment.mode": "Режим комментариев",
"sessionPreview.comment.title": "Добавить комментарий к предпросмотру",
"sessionPreview.comment.prompt": "Опишите, что нужно изменить для выбранного элемента.",
"sessionPreview.comment.label": "Комментарий",
"sessionPreview.comment.add": "Добавить комментарий",
"sessionPreview.comment.cancel": "Отмена",
"browserFrame.viewport": "Размер viewport",
"browserFrame.viewport.responsive": "Адаптивный viewport",
"browserFrame.viewport.desktop": "Десктопный viewport (1440 x 900)",
"browserFrame.viewport.tablet": "Планшетный viewport (768 x 1024)",
"browserFrame.viewport.tabletLandscape": "Планшет горизонтально (1024 x 768)",
"browserFrame.viewport.mobile": "Мобильный viewport (390 x 844)",
"browserFrame.viewport.mobileLandscape": "Мобильный горизонтально (844 x 390)",
"sessionEvents.sessionCompactedToast": "Сессия {label} была компактирована",
"sessionEvents.sessionError.unknown": "Неизвестная ошибка",
"sessionEvents.sessionError.title": "Ошибка сессии",

View file

@ -82,6 +82,31 @@ export const sessionMessages = {
"sessionView.attachments.insertPastedTextTitle": "插入粘贴的文本",
"sessionView.attachments.removeAriaLabel": "移除附件",
"sessionPreview.title": "网页预览",
"sessionPreview.backToChat": "返回聊天",
"sessionPreview.close": "关闭预览",
"sessionPreview.chat.button": "返回聊天",
"sessionPreview.open.button": "打开网页预览",
"sessionPreview.open.title": "打开网页预览",
"sessionPreview.open.prompt": "输入可信的本地或预发布 URL以便在此会话中预览。",
"sessionPreview.open.label": "预览 URL",
"sessionPreview.open.placeholder": "https://staging.example.com",
"sessionPreview.open.confirm": "打开预览",
"sessionPreview.open.cancel": "取消",
"sessionPreview.comment.mode": "评论模式",
"sessionPreview.comment.title": "添加预览评论",
"sessionPreview.comment.prompt": "描述所选元素需要更改的内容。",
"sessionPreview.comment.label": "评论",
"sessionPreview.comment.add": "添加评论",
"sessionPreview.comment.cancel": "取消",
"browserFrame.viewport": "视口大小",
"browserFrame.viewport.responsive": "响应式视口",
"browserFrame.viewport.desktop": "桌面视口 (1440 x 900)",
"browserFrame.viewport.tablet": "平板视口 (768 x 1024)",
"browserFrame.viewport.tabletLandscape": "平板横屏 (1024 x 768)",
"browserFrame.viewport.mobile": "移动视口 (390 x 844)",
"browserFrame.viewport.mobileLandscape": "移动横屏 (844 x 390)",
"sessionEvents.sessionCompactedToast": "会话 {label} 已被压缩",
"sessionEvents.sessionError.unknown": "未知错误",
"sessionEvents.sessionError.title": "会话错误",

View file

@ -0,0 +1,70 @@
import { createSignal } from "solid-js"
import { serverApi } from "../lib/api-client"
import type { PreviewSession } from "../../../server/src/api-types"
interface SessionPreviewRecord extends PreviewSession {
mode: "preview" | "chat"
}
const [sessionPreviews, setSessionPreviews] = createSignal<Map<string, SessionPreviewRecord>>(new Map())
function getSessionPreview(sessionId: string): SessionPreviewRecord | null {
return sessionPreviews().get(sessionId) ?? null
}
async function openSessionPreview(sessionId: string, url: string): Promise<SessionPreviewRecord> {
const existing = sessionPreviews().get(sessionId)
if (existing) {
void serverApi.deletePreview(existing.token).catch(() => undefined)
}
const preview = await serverApi.createPreview({ sessionId, url })
const record: SessionPreviewRecord = { ...preview, mode: "preview" }
setSessionPreviews((prev) => {
const next = new Map(prev)
next.set(sessionId, record)
return next
})
return record
}
function showSessionPreview(sessionId: string) {
setSessionPreviews((prev) => {
const current = prev.get(sessionId)
if (!current) return prev
const next = new Map(prev)
next.set(sessionId, { ...current, mode: "preview" })
return next
})
}
function showSessionChat(sessionId: string) {
setSessionPreviews((prev) => {
const current = prev.get(sessionId)
if (!current) return prev
const next = new Map(prev)
next.set(sessionId, { ...current, mode: "chat" })
return next
})
}
async function closeSessionPreview(sessionId: string) {
const current = sessionPreviews().get(sessionId)
if (!current) return
setSessionPreviews((prev) => {
const next = new Map(prev)
next.delete(sessionId)
return next
})
await serverApi.deletePreview(current.token).catch(() => undefined)
}
export {
sessionPreviews,
getSessionPreview,
openSessionPreview,
showSessionPreview,
showSessionChat,
closeSessionPreview,
}
export type { SessionPreviewRecord }