mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-10 00:14:27 +00:00
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:
parent
a3231c3da5
commit
efe3f5055f
19 changed files with 1529 additions and 242 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
77
packages/server/src/previews/manager.ts
Normal file
77
packages/server/src/previews/manager.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
34
packages/server/src/server/routes/previews.ts
Normal file
34
packages/server/src/server/routes/previews.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
381
packages/ui/src/components/browser-frame.tsx
Normal file
381
packages/ui/src/components/browser-frame.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
91
packages/ui/src/components/session-preview-view.tsx
Normal file
91
packages/ui/src/components/session-preview-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "שגיאת סשן",
|
||||
|
|
|
|||
|
|
@ -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": "セッションエラー",
|
||||
|
|
|
|||
|
|
@ -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": "Ошибка сессии",
|
||||
|
|
|
|||
|
|
@ -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": "会话错误",
|
||||
|
|
|
|||
70
packages/ui/src/stores/session-previews.ts
Normal file
70
packages/ui/src/stores/session-previews.ts
Normal 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue