feat(preview): embedded dev-server preview pane + dev shutdown controls (#1062)
* feat: embedded preview proxy for local dev servers Add a same-origin server proxy under /api/preview/proxy/:id and matching UI surfaces so local dev servers (Vite, Next, etc.) can be embedded inside OpenChamber. Server (packages/web/server): - New lib/preview/proxy-runtime.js: cookie-gated HTTP+WebSocket proxy to loopback hosts only, with TTL'd targets and SSRF allowlist. - index.js wires the runtime alongside terminal/event-stream. UI (packages/ui): - ContextPanel preview tab with iframe, reload, and open-in-browser. - Inline html code-block preview in MarkdownRenderer. - Terminal auto-detects loopback URLs and offers to open them. - i18n keys across en, es, pt-BR, uk, zh-CN. * perf(preview): cache proxy targets across PreviewPane remounts Module-scoped Map keyed by upstream URL so tab switches and component remounts within the same page session reuse the existing proxy registration instead of POSTing a fresh target each time. In-memory only by design: the server holds the target map in memory and the auth cookie is HttpOnly + scoped to the proxy id, so a stale persisted entry would 404 after a server restart. Entries are evicted on registration error and on a 30s safety margin before TTL expiry. * feat(preview): surface dev-server-down state with retry overlay Iframes don't expose HTTP status to the parent, so when the proxy returns a 502 (upstream dev server is offline) the iframe just renders the raw JSON error body. Probe the proxy URL out-of-band with HEAD (falling back to GET on 404/405) and replace the iframe with a friendly 'Dev server is not responding' overlay + retry button when the upstream is unreachable. Re-probes on reload, on URL change, and on proxy re-registration. * feat(preview): strip frame-busting response headers Many dev servers (Next.js, others) send X-Frame-Options: SAMEORIGIN and/or a CSP with frame-ancestors that block embedding inside the OpenChamber iframe. The proxy is same-origin and already authenticated per-target, so embedding is otherwise safe. - Drop X-Frame-Options outright on proxied responses. - Surgically remove only the frame-ancestors directive from Content-Security-Policy and Content-Security-Policy-Report-Only, preserving every other directive. Drops the header entirely if no directives remain. - Verified end-to-end: upstream sending both headers comes through with X-Frame-Options removed, CSP retaining default-src/script-src but no frame-ancestors, and unrelated headers untouched. * docs(preview): design for remote-host relay agent Design-only doc for the next phase of the embedded preview feature: when OpenChamber runs remotely (cloud/shared/tunnel) and the user's dev server runs on their local machine. Covers architecture (local agent + outbound control WebSocket + server dispatch), pairing flow, wire protocol, security model, failure modes, open questions, and implementation milestones. No code changes. * feat(preview): auto-open preview pane for loopback URLs in chat Detect http(s) loopback URLs in incoming assistant messages and open the preview pane automatically, deduped per (session, url) pair so re-renders or repeated mentions do not steal focus. Add an inline Preview button next to loopback links in chat markdown as a manual fallback when the auto-open was dismissed or the URL appeared in an older message. - url.ts: isLoopbackHttpUrl / extractLoopbackUrls helpers - ChatContainer: module-level dedupe Set + effect on active session tail - MarkdownRendererImpl: optional onPreviewLoopback in main renderer only (SimpleMarkdownRenderer for tool diffs is intentionally untouched) - Reuses existing terminalView.preview.open i18n keys * feat: preview enhancements, dev shutdown, and reliability fixes Add preview start/stop UI in ContextPanel/Header, improve URL detection (Python HTTP server logs, trailing punctuation, IPv6 loopback), fix proxy path filtering to avoid disrupting non-preview WebSockets. Add dev-only /api/system/dev-shutdown endpoint and Header button to terminate local dev processes and orphaned preview servers. Improve terminal cleanup with process group killing, event pipeline reconnect backoff. Update file read APIs with optional flag and cache control. Add /api/system/free-port endpoint, detectDevServer.ts utility, and preview/shutdown i18n strings for 5 languages. * fix: harden preview support * fix: keep terminal toolbar interactive * fix: keep expanded terminal below header * fix: keep preview iframe under proxy path * fix: respect project action preview urls * fix: rewrite preview asset urls * feat: capture preview console logs * feat: annotate preview elements * feat: attach preview annotation screenshots * fix: improve proxied preview hmr * feat: refine preview action UX * fix: address preview review feedback * fix: show auto-discover preview wait state --------- Co-authored-by: William Biggers <will@Williams-MacBook-Pro.local> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
William Biggers
Bohdan Triapitsyn
parent
67d05a23fc
commit
bd9a91335c
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { getSafeStorage } from './utils/safeStorage';
|
||||
|
||||
export type InlineCommentSource = 'diff' | 'plan' | 'file';
|
||||
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation';
|
||||
|
||||
export interface InlineCommentDraft {
|
||||
id: string;
|
||||
@@ -36,7 +36,7 @@ interface InlineCommentDraftActions {
|
||||
type InlineCommentDraftStore = InlineCommentDraftState & InlineCommentDraftActions;
|
||||
|
||||
const isValidSource = (value: unknown): value is InlineCommentSource =>
|
||||
value === 'diff' || value === 'plan' || value === 'file';
|
||||
value === 'diff' || value === 'plan' || value === 'file' || value === 'preview-console' || value === 'preview-annotation';
|
||||
|
||||
const isValidSide = (value: unknown): value is 'original' | 'modified' =>
|
||||
value === 'original' || value === 'modified';
|
||||
|
||||
@@ -16,10 +16,14 @@ export type TerminalTab = {
|
||||
terminalSessionId: string | null;
|
||||
lifecycle: TerminalTabLifecycle;
|
||||
label: string;
|
||||
iconKey: string | null;
|
||||
bufferChunks: TerminalChunk[];
|
||||
bufferLength: number;
|
||||
isConnecting: boolean;
|
||||
createdAt: number;
|
||||
previewUrl: string | null;
|
||||
previewAutoOpened: boolean;
|
||||
previewUrlLocked: boolean;
|
||||
};
|
||||
|
||||
export type DirectoryTerminalState = {
|
||||
@@ -27,8 +31,18 @@ export type DirectoryTerminalState = {
|
||||
activeTabId: string | null;
|
||||
};
|
||||
|
||||
export type TerminalProjectActionRun = {
|
||||
key: string;
|
||||
directory: string;
|
||||
actionId: string;
|
||||
tabId: string;
|
||||
sessionId: string;
|
||||
status: 'running' | 'waiting-for-preview' | 'stopping';
|
||||
};
|
||||
|
||||
interface TerminalStore {
|
||||
sessions: Map<string, DirectoryTerminalState>;
|
||||
projectActionRuns: Record<string, TerminalProjectActionRun>;
|
||||
nextChunkId: number;
|
||||
nextTabId: number;
|
||||
hasHydrated: boolean;
|
||||
@@ -40,6 +54,7 @@ interface TerminalStore {
|
||||
createTab: (directory: string) => string;
|
||||
setActiveTab: (directory: string, tabId: string) => void;
|
||||
setTabLabel: (directory: string, tabId: string, label: string) => void;
|
||||
setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void;
|
||||
closeTab: (directory: string, tabId: string) => Promise<void>;
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
|
||||
@@ -47,6 +62,11 @@ interface TerminalStore {
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => void;
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string) => void;
|
||||
clearBuffer: (directory: string, tabId: string) => void;
|
||||
setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options?: { locked?: boolean; autoOpened?: boolean }) => void;
|
||||
markPreviewAutoOpened: (directory: string, tabId: string) => void;
|
||||
setProjectActionRun: (run: TerminalProjectActionRun) => void;
|
||||
updateProjectActionRunStatus: (runKey: string, status: TerminalProjectActionRun['status']) => void;
|
||||
removeProjectActionRun: (runKey: string) => void;
|
||||
|
||||
removeDirectory: (directory: string) => void;
|
||||
clearAll: () => void;
|
||||
@@ -56,7 +76,7 @@ const TERMINAL_BUFFER_LIMIT = 1_000_000;
|
||||
const TERMINAL_STORE_NAME = 'terminal-store';
|
||||
let hydrationListenerAttached = false;
|
||||
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'terminalSessionId' | 'lifecycle' | 'createdAt'>;
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'iconKey' | 'terminalSessionId' | 'lifecycle' | 'createdAt'>;
|
||||
|
||||
type PersistedDirectoryTerminalState = {
|
||||
tabs: PersistedTerminalTab[];
|
||||
@@ -91,12 +111,77 @@ const createEmptyTab = (id: string, label: string): TerminalTab => ({
|
||||
terminalSessionId: null,
|
||||
lifecycle: 'idle',
|
||||
label,
|
||||
iconKey: null,
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
isConnecting: false,
|
||||
createdAt: Date.now(),
|
||||
previewUrl: null,
|
||||
previewAutoOpened: false,
|
||||
previewUrlLocked: false,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
|
||||
// Many dev servers print loopback as 0.0.0.0, localhost, or IPv6 ([::]/[::1]).
|
||||
const URL_PATTERN = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[(?:::1|::)\])(?::\d{2,5})?(?:\/[\w\-./~%!$&'()*+,;=:@?#[\]]*)?)/i;
|
||||
|
||||
// Dev server logs frequently wrap URLs in punctuation, e.g.
|
||||
// "Local: http://localhost:5173/ (press h to show help)"
|
||||
// "Serving on (http://127.0.0.1:50028/)."
|
||||
// The URL_PATTERN above intentionally allows sub-delim characters like `()`
|
||||
// in the path (RFC 3986), which means greedy capture can swallow trailing
|
||||
// closing brackets that were really part of the surrounding sentence.
|
||||
// Peel off any trailing closer that has no matching opener inside the URL,
|
||||
// plus common trailing sentence punctuation.
|
||||
const TRAILING_PUNCT = new Set(['.', ',', ';', ':', '!', '?']);
|
||||
const trimUrlTrailingPunctuation = (url: string): string => {
|
||||
let result = url;
|
||||
while (result.length > 0) {
|
||||
const last = result[result.length - 1];
|
||||
if (last === ')' || last === ']' || last === '}' || last === '>') {
|
||||
const opener = last === ')' ? '(' : last === ']' ? '[' : last === '}' ? '{' : '<';
|
||||
// Count matched pairs in the rest of the URL; if there's no unmatched
|
||||
// opener, the closer is from surrounding text — strip it.
|
||||
const head = result.slice(0, -1);
|
||||
const opens = (head.match(new RegExp(`\\${opener}`, 'g')) || []).length;
|
||||
const closes = (head.match(new RegExp(`\\${last}`, 'g')) || []).length;
|
||||
if (opens > closes) break;
|
||||
result = head;
|
||||
continue;
|
||||
}
|
||||
if (TRAILING_PUNCT.has(last)) {
|
||||
result = result.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const extractPreviewUrl = (chunk: string): string | null => {
|
||||
if (!chunk) return null;
|
||||
const cleaned = chunk.replace(ANSI_ESCAPE_PATTERN, '');
|
||||
const match = cleaned.match(URL_PATTERN);
|
||||
if (!match?.[1]) return null;
|
||||
let url = trimUrlTrailingPunctuation(match[1]);
|
||||
// Normalize common loopback hostnames to a stable value so the iframe can load.
|
||||
url = url.replace('0.0.0.0', '127.0.0.1');
|
||||
url = url.replace('[::1]', '127.0.0.1');
|
||||
url = url.replace('[::]', '127.0.0.1');
|
||||
return url;
|
||||
};
|
||||
|
||||
const extractPythonHttpServerUrl = (chunk: string): string | null => {
|
||||
if (!chunk) return null;
|
||||
const cleaned = chunk.replace(ANSI_ESCAPE_PATTERN, '');
|
||||
const match = cleaned.match(/Serving HTTP on .*? port (\d{2,5})/i);
|
||||
if (!match?.[1]) return null;
|
||||
const port = Number.parseInt(match[1], 10);
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) return null;
|
||||
return `http://127.0.0.1:${port}/`;
|
||||
};
|
||||
|
||||
const createEmptyDirectoryState = (firstTab: TerminalTab): DirectoryTerminalState => ({
|
||||
tabs: [firstTab],
|
||||
activeTabId: firstTab.id,
|
||||
@@ -110,6 +195,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sessions: new Map(),
|
||||
projectActionRuns: {},
|
||||
nextChunkId: 1,
|
||||
nextTabId: 1,
|
||||
hasHydrated: typeof window === 'undefined',
|
||||
@@ -235,6 +321,39 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const normalizedIconKey = iconKey?.trim() || null;
|
||||
if (existing.tabs[idx]?.iconKey === normalizedIconKey) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...nextTabs[idx],
|
||||
iconKey: normalizedIconKey,
|
||||
};
|
||||
|
||||
newSessions.set(key, {
|
||||
...existing,
|
||||
tabs: nextTabs,
|
||||
});
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
closeTab: async (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
const entry = get().sessions.get(key);
|
||||
@@ -262,12 +381,20 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
}
|
||||
|
||||
const nextTabs = existing.tabs.filter((t) => t.id !== tabId);
|
||||
const nextRuns = Object.fromEntries(
|
||||
Object.entries(state.projectActionRuns).filter(([, run]) => !(run.directory === key && run.tabId === tabId))
|
||||
);
|
||||
const runsChanged = Object.keys(nextRuns).length !== Object.keys(state.projectActionRuns).length;
|
||||
|
||||
if (nextTabs.length === 0) {
|
||||
const newTabId = `tab-${state.nextTabId}`;
|
||||
const newTab = createEmptyTab(newTabId, 'Terminal');
|
||||
newSessions.set(key, createEmptyDirectoryState(newTab));
|
||||
return { sessions: newSessions, nextTabId: state.nextTabId + 1 };
|
||||
return {
|
||||
sessions: newSessions,
|
||||
nextTabId: state.nextTabId + 1,
|
||||
...(runsChanged ? { projectActionRuns: nextRuns } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
let nextActive = existing.activeTabId;
|
||||
@@ -282,7 +409,10 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
activeTabId: nextActive,
|
||||
});
|
||||
|
||||
return { sessions: newSessions };
|
||||
return {
|
||||
sessions: newSessions,
|
||||
...(runsChanged ? { projectActionRuns: nextRuns } : {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -397,11 +527,17 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
bufferLength -= removed.data.length;
|
||||
}
|
||||
|
||||
const maybePreviewUrl = tab.previewUrlLocked ? null : extractPreviewUrl(chunk) ?? extractPythonHttpServerUrl(chunk);
|
||||
const shouldUpdatePreview = Boolean(maybePreviewUrl && maybePreviewUrl !== tab.previewUrl);
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...tab,
|
||||
bufferChunks,
|
||||
bufferLength,
|
||||
...(shouldUpdatePreview
|
||||
? { previewUrl: maybePreviewUrl, previewAutoOpened: false }
|
||||
: null),
|
||||
};
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
|
||||
@@ -409,6 +545,106 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options = {}) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const tab = existing.tabs[idx];
|
||||
const nextPreviewAutoOpened = options.autoOpened ?? tab.previewAutoOpened;
|
||||
const nextPreviewUrlLocked = options.locked ?? tab.previewUrlLocked;
|
||||
if (tab.previewUrl === url && tab.previewAutoOpened === nextPreviewAutoOpened && tab.previewUrlLocked === nextPreviewUrlLocked) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...tab,
|
||||
previewUrl: url,
|
||||
previewAutoOpened: nextPreviewAutoOpened,
|
||||
previewUrlLocked: nextPreviewUrlLocked,
|
||||
};
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
markPreviewAutoOpened: (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const tab = existing.tabs[idx];
|
||||
if (!tab.previewUrl || tab.previewAutoOpened) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = { ...tab, previewAutoOpened: true };
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
setProjectActionRun: (run: TerminalProjectActionRun) => {
|
||||
set((state) => {
|
||||
const existing = state.projectActionRuns[run.key];
|
||||
if (existing
|
||||
&& existing.directory === run.directory
|
||||
&& existing.actionId === run.actionId
|
||||
&& existing.tabId === run.tabId
|
||||
&& existing.sessionId === run.sessionId
|
||||
&& existing.status === run.status) {
|
||||
return state;
|
||||
}
|
||||
return { projectActionRuns: { ...state.projectActionRuns, [run.key]: run } };
|
||||
});
|
||||
},
|
||||
|
||||
updateProjectActionRunStatus: (runKey: string, status: TerminalProjectActionRun['status']) => {
|
||||
set((state) => {
|
||||
const existing = state.projectActionRuns[runKey];
|
||||
if (!existing || existing.status === status) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
projectActionRuns: {
|
||||
...state.projectActionRuns,
|
||||
[runKey]: { ...existing, status },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeProjectActionRun: (runKey: string) => {
|
||||
set((state) => {
|
||||
if (!state.projectActionRuns[runKey]) {
|
||||
return state;
|
||||
}
|
||||
const next = { ...state.projectActionRuns };
|
||||
delete next[runKey];
|
||||
return { projectActionRuns: next };
|
||||
});
|
||||
},
|
||||
|
||||
clearBuffer: (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
@@ -439,12 +675,15 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
newSessions.delete(key);
|
||||
return { sessions: newSessions };
|
||||
const nextRuns = Object.fromEntries(
|
||||
Object.entries(state.projectActionRuns).filter(([, run]) => run.directory !== key)
|
||||
);
|
||||
return { sessions: newSessions, projectActionRuns: nextRuns };
|
||||
});
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ sessions: new Map(), nextChunkId: 1, nextTabId: 1 });
|
||||
set({ sessions: new Map(), projectActionRuns: {}, nextChunkId: 1, nextTabId: 1 });
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -458,6 +697,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
tabs: dirState.tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
iconKey: tab.iconKey,
|
||||
terminalSessionId: tab.terminalSessionId,
|
||||
lifecycle: tab.lifecycle,
|
||||
createdAt: tab.createdAt,
|
||||
@@ -519,12 +759,16 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
tabs.push({
|
||||
id,
|
||||
label: typeof rawTab.label === 'string' ? rawTab.label : 'Terminal',
|
||||
iconKey: typeof rawTab.iconKey === 'string' ? rawTab.iconKey : null,
|
||||
terminalSessionId,
|
||||
lifecycle,
|
||||
createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(),
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
isConnecting: false,
|
||||
previewUrl: null,
|
||||
previewAutoOpened: false,
|
||||
previewUrlLocked: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOpt
|
||||
|
||||
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
|
||||
export type RightSidebarTab = 'git' | 'files' | 'context';
|
||||
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat';
|
||||
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview';
|
||||
export type MermaidRenderingMode = 'svg' | 'ascii';
|
||||
export type UserMessageRenderingMode = 'markdown' | 'plain';
|
||||
export type ChatRenderMode = 'sorted' | 'live';
|
||||
@@ -161,6 +161,10 @@ const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath
|
||||
return targetPath || mode;
|
||||
}
|
||||
|
||||
if (mode === 'preview') {
|
||||
return targetPath || mode;
|
||||
}
|
||||
|
||||
return mode;
|
||||
};
|
||||
|
||||
@@ -237,7 +241,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
touchedAt?: unknown;
|
||||
};
|
||||
|
||||
if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat') {
|
||||
if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -588,6 +592,7 @@ interface UIStore {
|
||||
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
|
||||
openContextOverview: (directory: string) => void;
|
||||
openContextPlan: (directory: string) => void;
|
||||
openContextPreview: (directory: string, url: string) => void;
|
||||
setActiveContextPanelTab: (directory: string, tabID: string) => void;
|
||||
reorderContextPanelTabs: (directory: string, activeTabID: string, overTabID: string) => void;
|
||||
closeContextPanelTab: (directory: string, tabID: string) => void;
|
||||
@@ -991,6 +996,31 @@ export const useUIStore = create<UIStore>()(
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'plan' });
|
||||
},
|
||||
|
||||
openContextPreview: (directory, url) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedUrl = (url || '').trim();
|
||||
if (!normalizedDirectory || !normalizedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let label: string | null = null;
|
||||
try {
|
||||
const parsed = new URL(normalizedUrl);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
label = parsed.host || parsed.hostname || 'Preview';
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid URL
|
||||
}
|
||||
|
||||
get().openContextPanelTab(normalizedDirectory, {
|
||||
mode: 'preview',
|
||||
targetPath: normalizedUrl,
|
||||
dedupeKey: normalizedUrl,
|
||||
label,
|
||||
});
|
||||
},
|
||||
|
||||
setActiveContextPanelTab: (directory, tabID) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedTabID = (tabID || '').trim();
|
||||
|
||||
Reference in New Issue
Block a user