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
@@ -1021,8 +1021,38 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[currentSessionId, newSessionDraftOpen]
|
||||
)
|
||||
);
|
||||
const draftSourceKey = useInlineCommentDraftStore(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||
const drafts = sessionKey ? (state.drafts[sessionKey] ?? []) : [];
|
||||
let previewConsole = 0;
|
||||
let previewAnnotation = 0;
|
||||
let review = 0;
|
||||
for (const draft of drafts) {
|
||||
if (draft.source === 'preview-console') previewConsole += 1;
|
||||
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
|
||||
else review += 1;
|
||||
}
|
||||
return `${previewConsole}:${previewAnnotation}:${review}`;
|
||||
},
|
||||
[currentSessionId, newSessionDraftOpen]
|
||||
)
|
||||
);
|
||||
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const hasDrafts = draftCount > 0;
|
||||
const [previewConsoleCount, previewAnnotationCount, reviewCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
|
||||
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||
if (!sessionKey) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? [];
|
||||
for (const draft of drafts) {
|
||||
if (draft.source === source) {
|
||||
removeInlineCommentDraft(sessionKey, draft.id);
|
||||
}
|
||||
}
|
||||
}, [currentSessionId, newSessionDraftOpen, removeInlineCommentDraft]);
|
||||
|
||||
// User message history for up/down arrow navigation.
|
||||
// Keep this on a narrow hook instead of full session message records.
|
||||
@@ -3270,19 +3300,61 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
onEditMessage={handleQueuedMessageEdit}
|
||||
/>
|
||||
{hasDrafts && (
|
||||
<div className="pb-2">
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl border"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.reviewComments')}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>
|
||||
{draftCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{reviewCount > 0 ? (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.reviewComments')}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>{reviewCount}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{previewConsoleCount > 0 ? (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.devServerLogs')}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>{previewConsoleCount}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
|
||||
onClick={() => removePreviewDrafts('preview-console')}
|
||||
aria-label={t('chat.chatInput.devServerLogsRemove')}
|
||||
title={t('chat.chatInput.devServerLogsRemove')}
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{previewAnnotationCount > 0 ? (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
|
||||
style={{
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.previewAnnotations')}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>{previewAnnotationCount}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
|
||||
onClick={() => removePreviewDrafts('preview-annotation')}
|
||||
aria-label={t('chat.chatInput.previewContextRemove')}
|
||||
title={t('chat.chatInput.previewContextRemove')}
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ import remend from 'remend';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
|
||||
import { RiFileCopyLine, RiCheckLine, RiDownloadLine, RiEyeLine, RiCodeLine } from '@remixicon/react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
|
||||
import { isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||
@@ -694,6 +694,26 @@ const CODE_SHARED_STYLE: React.CSSProperties = {
|
||||
lineHeight: 'var(--markdown-code-block-line-height)',
|
||||
};
|
||||
|
||||
const downloadTextFile = (content: string, filename: string, mimeType: string) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Best-effort; callers can optionally toast.
|
||||
}
|
||||
};
|
||||
|
||||
const MarkdownCodeBlock: React.FC<{
|
||||
code: string;
|
||||
language: string;
|
||||
@@ -701,9 +721,18 @@ const MarkdownCodeBlock: React.FC<{
|
||||
}> = ({ code, language, syntaxTheme }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [highlight, setHighlight] = React.useState(true);
|
||||
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code');
|
||||
const prevCodeRef = React.useRef<string>(code);
|
||||
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const canPreview = language === 'html' || language === 'htm';
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!canPreview && viewMode !== 'code') {
|
||||
setViewMode('code');
|
||||
}
|
||||
}, [canPreview, viewMode]);
|
||||
|
||||
// Defer Prism highlighting while code is actively streaming.
|
||||
// Initial mount renders highlighted immediately (plays nice with finalized blocks).
|
||||
React.useEffect(() => {
|
||||
@@ -732,46 +761,96 @@ const MarkdownCodeBlock: React.FC<{
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
}, [code]);
|
||||
|
||||
const handleDownload = React.useCallback(() => {
|
||||
if (!canPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeSuffix = Date.now().toString(36);
|
||||
downloadTextFile(code, `preview-${safeSuffix}.html`, 'text/html;charset=utf-8');
|
||||
}, [canPreview, code]);
|
||||
|
||||
return (
|
||||
<div data-component="markdown-code" className="my-4 group overflow-hidden rounded-2xl border border-border/80 bg-[var(--surface-elevated)]">
|
||||
<div className="flex items-center justify-between border-b border-border/70 px-3 py-1.5">
|
||||
<span className="font-mono text-[13px] text-muted-foreground">{language}</span>
|
||||
<div className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="flex items-center gap-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
||||
{canPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode((mode) => (mode === 'preview' ? 'code' : 'preview'))}
|
||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={viewMode === 'preview' ? 'Show code' : 'Preview'}
|
||||
aria-pressed={viewMode === 'preview'}
|
||||
aria-label={viewMode === 'preview' ? 'Show code' : 'Preview HTML'}
|
||||
>
|
||||
{viewMode === 'preview' ? <RiCodeLine className="size-3.5" /> : <RiEyeLine className="size-3.5" />}
|
||||
</button>
|
||||
) : null}
|
||||
{canPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Download HTML"
|
||||
aria-label="Download HTML"
|
||||
>
|
||||
<RiDownloadLine className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleCopy(); }}
|
||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={copied ? 'Copied' : 'Copy code'}
|
||||
aria-label={copied ? 'Copied' : 'Copy code'}
|
||||
>
|
||||
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2.5">
|
||||
{highlight ? (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={syntaxTheme}
|
||||
customStyle={CODE_SHARED_STYLE}
|
||||
codeTagProps={{ style: CODE_SHARED_STYLE }}
|
||||
PreTag="pre"
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<pre style={CODE_SHARED_STYLE}>
|
||||
<code style={CODE_SHARED_STYLE}>{code}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
{canPreview && viewMode === 'preview' ? (
|
||||
<div className="h-[320px] md:h-[420px] bg-background">
|
||||
<iframe
|
||||
srcDoc={code}
|
||||
title="HTML preview"
|
||||
className="h-full w-full border-0"
|
||||
sandbox="allow-scripts allow-forms"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2.5">
|
||||
{highlight ? (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={syntaxTheme}
|
||||
customStyle={CODE_SHARED_STYLE}
|
||||
codeTagProps={{ style: CODE_SHARED_STYLE }}
|
||||
PreTag="pre"
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<pre style={CODE_SHARED_STYLE}>
|
||||
<code style={CODE_SHARED_STYLE}>{code}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const buildMarkdownComponents = ({
|
||||
syntaxTheme,
|
||||
onPreviewLoopback,
|
||||
previewLabel,
|
||||
previewTitle,
|
||||
}: {
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
onPreviewLoopback?: (url: string) => void;
|
||||
previewLabel?: string;
|
||||
previewTitle?: string;
|
||||
}): Components => ({
|
||||
table({ children, ...props }) {
|
||||
return <TableWrapper className={props.className}>{children}</TableWrapper>;
|
||||
@@ -846,15 +925,36 @@ const buildMarkdownComponents = ({
|
||||
);
|
||||
},
|
||||
a({ href, children, ...props }) {
|
||||
const targetHref = href ?? '';
|
||||
const isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false;
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
href={href}
|
||||
target={isExternalHttpUrl(href ?? '') ? '_blank' : undefined}
|
||||
rel={isExternalHttpUrl(href ?? '') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
<>
|
||||
<a
|
||||
{...props}
|
||||
href={href}
|
||||
target={isExternalHttpUrl(targetHref) ? '_blank' : undefined}
|
||||
rel={isExternalHttpUrl(targetHref) ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
{isLoopback && onPreviewLoopback ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onPreviewLoopback(targetHref);
|
||||
}}
|
||||
className="ml-1 inline-flex h-5 items-center gap-0.5 rounded border border-[var(--border)] bg-[var(--surface-background)] px-1.5 align-middle text-[11px] leading-none text-[var(--muted-foreground)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--foreground)]"
|
||||
aria-label={previewTitle ?? previewLabel ?? 'Open preview pane'}
|
||||
title={previewTitle ?? previewLabel ?? 'Open preview pane'}
|
||||
data-loopback-preview-trigger="true"
|
||||
>
|
||||
<RiEyeLine className="size-3" aria-hidden="true" />
|
||||
<span className="font-medium">{previewLabel ?? 'Preview'}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1436,8 +1536,24 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
});
|
||||
useExternalLinkInteractions({ containerRef });
|
||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
||||
const { t } = useI18n();
|
||||
const handlePreviewLoopback = React.useCallback((url: string) => {
|
||||
if (!effectiveDirectory) return;
|
||||
openContextPreview(effectiveDirectory, url);
|
||||
}, [effectiveDirectory, openContextPreview]);
|
||||
const previewLabel = t('terminalView.preview.open');
|
||||
const previewTitle = t('terminalView.preview.openTitle');
|
||||
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||
const markdownComponents = React.useMemo(() => buildMarkdownComponents({ syntaxTheme }), [syntaxTheme]);
|
||||
const markdownComponents = React.useMemo(
|
||||
() => buildMarkdownComponents({
|
||||
syntaxTheme,
|
||||
onPreviewLoopback: effectiveDirectory ? handlePreviewLoopback : undefined,
|
||||
previewLabel,
|
||||
previewTitle,
|
||||
}),
|
||||
[syntaxTheme, effectiveDirectory, handlePreviewLoopback, previewLabel, previewTitle],
|
||||
);
|
||||
const componentKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
const markdownBlocks = useStableMarkdownBlocks(content, isStreaming && !disableStreamAnimation, componentKey);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { FadeInOnReveal } from './FadeInOnReveal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line, RiErrorWarningLine, RiBookletLine } from '@remixicon/react';
|
||||
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line, RiErrorWarningLine, RiBookletLine, RiGlobalLine } from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
|
||||
@@ -43,6 +43,7 @@ import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useSessions } from '@/sync/sync-context';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { extractLoopbackUrls } from '@/lib/url';
|
||||
|
||||
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
|
||||
const MESSAGE_FOOTER_CONTAINER_STYLE = { containerType: 'inline-size' as const, containerName: 'message-footer' };
|
||||
@@ -960,6 +961,36 @@ const AssistantMessageBody = React.memo(({
|
||||
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
|
||||
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
|
||||
|
||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
||||
|
||||
const messagePreviewUrl = React.useMemo(() => {
|
||||
for (const part of assistantTextParts) {
|
||||
const text = (part as { text?: unknown }).text;
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const url = extractLoopbackUrls(text)[0];
|
||||
if (!url) {
|
||||
continue;
|
||||
}
|
||||
return url.includes('0.0.0.0') ? url.replace('0.0.0.0', '127.0.0.1') : url;
|
||||
}
|
||||
for (const part of toolParts) {
|
||||
const state = (part as unknown as { state?: unknown }).state as Record<string, unknown> | undefined;
|
||||
const output = state && typeof state.output === 'string' ? state.output : null;
|
||||
if (!output) {
|
||||
continue;
|
||||
}
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const url = extractLoopbackUrls(output.replace(/\x1b\[[0-9;]*m/g, ''))[0];
|
||||
if (!url) {
|
||||
continue;
|
||||
}
|
||||
return url.includes('0.0.0.0') ? url.replace('0.0.0.0', '127.0.0.1') : url;
|
||||
}
|
||||
return null;
|
||||
}, [assistantTextParts, toolParts]);
|
||||
|
||||
const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
|
||||
@@ -1658,9 +1689,35 @@ const AssistantMessageBody = React.memo(({
|
||||
}, [messageCompletedAt, messageCreatedAt]);
|
||||
|
||||
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
|
||||
const canOpenMessagePreview = !isMobile && !isVSCodeRuntime();
|
||||
|
||||
const finalTurnActionButtons = (
|
||||
<>
|
||||
{canOpenMessagePreview && messagePreviewUrl ? (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('chat.messageBody.actions.openPreviewAria')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={() => {
|
||||
const directory = effectiveDirectory
|
||||
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : null);
|
||||
if (!directory) {
|
||||
return;
|
||||
}
|
||||
openContextPreview(directory, messagePreviewUrl);
|
||||
}}
|
||||
>
|
||||
<RiGlobalLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.openPreview')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isVSCodeRuntime() ? (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -20,13 +19,11 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
const isFullscreen = useUIStore((state) => state.isBottomTerminalExpanded);
|
||||
const setBottomTerminalHeight = useUIStore((state) => state.setBottomTerminalHeight);
|
||||
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
|
||||
const setBottomTerminalExpanded = useUIStore((state) => state.setBottomTerminalExpanded);
|
||||
const [fullscreenHeight, setFullscreenHeight] = React.useState<number | null>(null);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const dockRef = React.useRef<HTMLElement | null>(null);
|
||||
const startYRef = React.useRef(0);
|
||||
const startHeightRef = React.useRef(bottomTerminalHeight || 300);
|
||||
const previousHeightRef = React.useRef(bottomTerminalHeight || 300);
|
||||
|
||||
const standardHeight = React.useMemo(
|
||||
() => Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, bottomTerminalHeight || 300)),
|
||||
@@ -116,30 +113,18 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (isFullscreen) {
|
||||
setBottomTerminalExpanded(false);
|
||||
const restoreHeight = Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, previousHeightRef.current));
|
||||
setBottomTerminalHeight(restoreHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
previousHeightRef.current = standardHeight;
|
||||
setBottomTerminalExpanded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={dockRef}
|
||||
className={cn(
|
||||
'flex overflow-hidden border-t border-border bg-sidebar',
|
||||
isFullscreen ? 'absolute inset-0 z-40' : 'relative',
|
||||
isFullscreen ? 'absolute inset-x-0 bottom-0 z-40' : 'relative',
|
||||
isResizing ? 'transition-none' : 'transition-[height] duration-300 ease-in-out',
|
||||
!isOpen && 'border-t-0'
|
||||
)}
|
||||
style={isFullscreen ? undefined : {
|
||||
style={isFullscreen ? {
|
||||
top: 'var(--oc-header-height, 48px)',
|
||||
} : {
|
||||
height: `${appliedHeight}px`,
|
||||
minHeight: `${appliedHeight}px`,
|
||||
maxHeight: `${appliedHeight}px`,
|
||||
@@ -159,29 +144,6 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
/>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-2 top-2 z-30 inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
title={isFullscreen ? t('terminalView.bottomDock.restoreTitle') : t('terminalView.bottomDock.expandTitle')}
|
||||
aria-label={isFullscreen ? t('terminalView.bottomDock.restoreAria') : t('terminalView.bottomDock.expandAria')}
|
||||
>
|
||||
{isFullscreen ? <RiFullscreenExitLine className="h-5 w-5" /> : <RiFullscreenLine className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBottomTerminalOpen(false)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
title={t('terminalView.bottomDock.closeTitle')}
|
||||
aria-label={t('terminalView.bottomDock.closeAria')}
|
||||
>
|
||||
<RiCloseLine className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex h-full min-h-0 w-full flex-col transition-opacity duration-300 ease-in-out',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,8 @@ import type { GitHubAuthStatus } from '@/lib/api/types';
|
||||
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
|
||||
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
|
||||
import { forceKillTerminal } from '@/lib/terminalApi';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
|
||||
import { isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop';
|
||||
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||
@@ -259,6 +261,9 @@ type DesktopServicesMenuProps = {
|
||||
expandedFamilies: Record<string, string[]>;
|
||||
toggleFamilyExpanded: (providerId: string, familyId: string) => void;
|
||||
shortcutLabel: (actionId: string) => string;
|
||||
showDevShutdown: boolean;
|
||||
isDevShutdownInFlight: boolean;
|
||||
onDevShutdown: () => Promise<void>;
|
||||
};
|
||||
|
||||
const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
||||
@@ -285,6 +290,9 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
||||
expandedFamilies,
|
||||
toggleFamilyExpanded,
|
||||
shortcutLabel,
|
||||
showDevShutdown,
|
||||
isDevShutdownInFlight,
|
||||
onDevShutdown,
|
||||
}: DesktopServicesMenuProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -521,6 +529,22 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showDevShutdown ? (
|
||||
<>
|
||||
<div className="mx-4 my-2 border-t border-[var(--interactive-border)]" />
|
||||
<div className="px-2 pb-2">
|
||||
<DropdownMenuItem
|
||||
disabled={isDevShutdownInFlight}
|
||||
onSelect={() => {
|
||||
void onDevShutdown();
|
||||
}}
|
||||
>
|
||||
{t('header.services.shutdownDev')}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
@@ -584,8 +608,8 @@ const normalize = (value: string): string => {
|
||||
const getActiveContextMode = (panelState: {
|
||||
isOpen: boolean;
|
||||
activeTabId: string | null;
|
||||
tabs: Array<{ id: string; mode: 'diff' | 'file' | 'context' | 'plan' | 'chat' }>;
|
||||
} | undefined): 'diff' | 'file' | 'context' | 'plan' | 'chat' | null => {
|
||||
tabs: Array<{ id: string; mode: 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' }>;
|
||||
} | undefined): 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | null => {
|
||||
if (!panelState?.isOpen || !Array.isArray(panelState.tabs) || panelState.tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -646,6 +670,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
||||
const runtimeApis = useRuntimeAPIs();
|
||||
const [isDevShutdownInFlight, setIsDevShutdownInFlight] = React.useState(false);
|
||||
|
||||
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||
@@ -1501,6 +1526,63 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}));
|
||||
}, [servicesTabs]);
|
||||
|
||||
const showDevShutdown = React.useMemo(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (isDesktopApp) return false;
|
||||
if (isVSCode) return false;
|
||||
const host = window.location.hostname;
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
}, [isDesktopApp, isVSCode]);
|
||||
|
||||
const handleDevShutdown = React.useCallback(async () => {
|
||||
if (isDevShutdownInFlight) return;
|
||||
setIsDevShutdownInFlight(true);
|
||||
setIsDesktopServicesOpen(false);
|
||||
|
||||
const previewUrls: string[] = [];
|
||||
let shutdownRequested = false;
|
||||
try {
|
||||
try {
|
||||
for (const [, dirState] of useTerminalStore.getState().sessions.entries()) {
|
||||
for (const tab of dirState.tabs) {
|
||||
if (tab.previewUrl) {
|
||||
previewUrls.push(tab.previewUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure preview/dev terminals don't linger.
|
||||
await forceKillTerminal({});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const devRes = await fetch('/api/system/dev-shutdown', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ previewUrls }),
|
||||
});
|
||||
if (devRes.ok) {
|
||||
shutdownRequested = true;
|
||||
} else {
|
||||
const shutdownRes = await fetch('/api/system/shutdown', { method: 'POST' });
|
||||
shutdownRequested = shutdownRes.ok;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} finally {
|
||||
if (!shutdownRequested) {
|
||||
setIsDevShutdownInFlight(false);
|
||||
}
|
||||
}
|
||||
}, [isDevShutdownInFlight, setIsDesktopServicesOpen]);
|
||||
|
||||
const quotaDisplayTabs = React.useMemo(() => {
|
||||
return [
|
||||
{ value: 'usage' as const, label: t('header.services.used') },
|
||||
@@ -1685,6 +1767,9 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
expandedFamilies={expandedFamilies}
|
||||
toggleFamilyExpanded={toggleFamilyExpanded}
|
||||
shortcutLabel={shortcutLabel}
|
||||
showDevShutdown={showDevShutdown}
|
||||
isDevShutdownInFlight={isDevShutdownInFlight}
|
||||
onDevShutdown={handleDevShutdown}
|
||||
/>
|
||||
<HeaderIconActionButton
|
||||
title={t('header.actions.terminalPanelWithShortcut', { shortcut: shortcutLabel('toggle_terminal') })}
|
||||
|
||||
@@ -2,8 +2,10 @@ import React from 'react';
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
RiGlobalLine,
|
||||
RiLoader4Line,
|
||||
RiPlayLine,
|
||||
RiSearchLine,
|
||||
RiStopLine,
|
||||
} from '@remixicon/react';
|
||||
import {
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
@@ -35,20 +38,14 @@ import {
|
||||
resolveProjectActionDesktopForwardUrl,
|
||||
toProjectActionRunKey,
|
||||
} from '@/lib/projectActions';
|
||||
|
||||
type RunningEntry = {
|
||||
key: string;
|
||||
directory: string;
|
||||
actionId: string;
|
||||
tabId: string;
|
||||
sessionId: string;
|
||||
status: 'running' | 'stopping';
|
||||
};
|
||||
import { detectDevServerCommand, readPackageJsonScripts } from '@/lib/detectDevServer';
|
||||
import { connectTerminalStream } from '@/lib/terminalApi';
|
||||
|
||||
type UrlWatchEntry = {
|
||||
lastSeenChunkId: number | null;
|
||||
openedUrl: boolean;
|
||||
tail: string;
|
||||
openInPreview: boolean;
|
||||
};
|
||||
|
||||
const sleep = (ms: number): Promise<void> => {
|
||||
@@ -68,6 +65,8 @@ interface ProjectActionsButtonProps {
|
||||
const ANSI_ESCAPE_PREFIX = String.fromCharCode(27);
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_ESCAPE_PREFIX}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
|
||||
const URL_GLOBAL_PATTERN = /https?:\/\/[^\s<>'"`]+/gi;
|
||||
const AUTO_DISCOVER_ACTION_ID = '__openchamber_auto_discover_preview__';
|
||||
const AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS = 15_000;
|
||||
|
||||
const stripControlChars = (value: string): string => {
|
||||
let next = '';
|
||||
@@ -194,20 +193,28 @@ export const ProjectActionsButton = ({
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsProjectsSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
|
||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
||||
|
||||
const terminalSessions = useTerminalStore((state) => state.sessions);
|
||||
const ensureDirectory = useTerminalStore((state) => state.ensureDirectory);
|
||||
const setTabLabel = useTerminalStore((state) => state.setTabLabel);
|
||||
const setTabIconKey = useTerminalStore((state) => state.setTabIconKey);
|
||||
const setActiveTab = useTerminalStore((state) => state.setActiveTab);
|
||||
const setConnecting = useTerminalStore((state) => state.setConnecting);
|
||||
const setTabSessionId = useTerminalStore((state) => state.setTabSessionId);
|
||||
const setTabPreviewUrl = useTerminalStore((state) => state.setTabPreviewUrl);
|
||||
const projectActionRuns = useTerminalStore((state) => state.projectActionRuns);
|
||||
const setProjectActionRun = useTerminalStore((state) => state.setProjectActionRun);
|
||||
const updateProjectActionRunStatus = useTerminalStore((state) => state.updateProjectActionRunStatus);
|
||||
const removeProjectActionRun = useTerminalStore((state) => state.removeProjectActionRun);
|
||||
|
||||
const [actions, setActions] = React.useState<OpenChamberProjectAction[]>([]);
|
||||
const [selectedActionId, setSelectedActionId] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [runningByKey, setRunningByKey] = React.useState<Record<string, RunningEntry>>({});
|
||||
const tabByKeyRef = React.useRef<Record<string, string>>({});
|
||||
const urlWatchByRunKeyRef = React.useRef<Record<string, UrlWatchEntry>>({});
|
||||
const streamCleanupByRunKeyRef = React.useRef<Record<string, () => void>>({});
|
||||
const previewWaitTimeoutByRunKeyRef = React.useRef<Record<string, number>>({});
|
||||
const loadRequestIdRef = React.useRef(0);
|
||||
|
||||
const projectId = projectRef?.id ?? null;
|
||||
@@ -248,13 +255,13 @@ export const ProjectActionsButton = ({
|
||||
const filtered = state.actions;
|
||||
setActions(filtered);
|
||||
setSelectedActionId((current) => {
|
||||
if (filtered.length === 0) {
|
||||
return null;
|
||||
if (current === AUTO_DISCOVER_ACTION_ID) {
|
||||
return current;
|
||||
}
|
||||
if (current && filtered.some((entry) => entry.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return filtered[0]?.id ?? null;
|
||||
return null;
|
||||
});
|
||||
} catch {
|
||||
if (loadRequestIdRef.current !== requestId) {
|
||||
@@ -268,6 +275,31 @@ export const ProjectActionsButton = ({
|
||||
}
|
||||
}, [stableProjectRef]);
|
||||
|
||||
const normalizedDirectory = React.useMemo(() => {
|
||||
return normalizeProjectActionDirectory(directory || stableProjectRef?.path || '');
|
||||
}, [directory, stableProjectRef?.path]);
|
||||
|
||||
const selectedAction = React.useMemo(() => {
|
||||
if (!selectedActionId) {
|
||||
return null;
|
||||
}
|
||||
return actions.find((entry) => entry.id === selectedActionId) ?? null;
|
||||
}, [actions, selectedActionId]);
|
||||
|
||||
const autoDiscoverAction = React.useMemo<OpenChamberProjectAction>(() => ({
|
||||
id: AUTO_DISCOVER_ACTION_ID,
|
||||
name: t('projectActions.actions.autoDiscover'),
|
||||
command: '',
|
||||
icon: 'search',
|
||||
autoOpenUrl: true,
|
||||
}), [t]);
|
||||
|
||||
const canUseAutoDiscover = !isMobile;
|
||||
const displayActions = React.useMemo(
|
||||
() => canUseAutoDiscover ? [autoDiscoverAction, ...actions] : actions,
|
||||
[actions, autoDiscoverAction, canUseAutoDiscover]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadActions();
|
||||
}, [loadActions]);
|
||||
@@ -298,35 +330,29 @@ export const ProjectActionsButton = ({
|
||||
if (!selectedActionId) {
|
||||
return;
|
||||
}
|
||||
if (!actions.some((entry) => entry.id === selectedActionId)) {
|
||||
setSelectedActionId(actions[0]?.id ?? null);
|
||||
if (selectedActionId === AUTO_DISCOVER_ACTION_ID && canUseAutoDiscover) {
|
||||
return;
|
||||
}
|
||||
}, [actions, selectedActionId]);
|
||||
if (!actions.some((entry) => entry.id === selectedActionId)) {
|
||||
setSelectedActionId(null);
|
||||
}
|
||||
}, [actions, canUseAutoDiscover, selectedActionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setRunningByKey((prev) => {
|
||||
let changed = false;
|
||||
const next: Record<string, RunningEntry> = {};
|
||||
|
||||
for (const [key, entry] of Object.entries(prev)) {
|
||||
const directoryState = terminalSessions.get(entry.directory);
|
||||
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
|
||||
if (!tab || tab.terminalSessionId !== entry.sessionId) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[key] = entry;
|
||||
for (const [key, entry] of Object.entries(projectActionRuns)) {
|
||||
const directoryState = terminalSessions.get(entry.directory);
|
||||
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
|
||||
if (!tab || tab.terminalSessionId !== entry.sessionId) {
|
||||
removeProjectActionRun(key);
|
||||
}
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [terminalSessions]);
|
||||
}
|
||||
}, [projectActionRuns, removeProjectActionRun, terminalSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
for (const [runKey, entry] of Object.entries(runningByKey)) {
|
||||
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '' };
|
||||
for (const [runKey, entry] of Object.entries(projectActionRuns)) {
|
||||
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false };
|
||||
urlWatchByRunKeyRef.current[runKey] = watch;
|
||||
const action = actions.find((item) => item.id === entry.actionId);
|
||||
const action = displayActions.find((item) => item.id === entry.actionId);
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
@@ -358,32 +384,36 @@ export const ProjectActionsButton = ({
|
||||
|
||||
if (maybeUrl) {
|
||||
watch.openedUrl = true;
|
||||
void openExternal(maybeUrl);
|
||||
toast.success(t('projectActions.toast.openedUrlFromOutput'));
|
||||
if (watch.openInPreview) {
|
||||
const run = projectActionRuns[runKey];
|
||||
if (run) {
|
||||
setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false });
|
||||
if (run.status === 'waiting-for-preview') {
|
||||
updateProjectActionRunStatus(runKey, 'running');
|
||||
}
|
||||
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
|
||||
delete previewWaitTimeoutByRunKeyRef.current[runKey];
|
||||
openContextPreview(run.directory, maybeUrl);
|
||||
}
|
||||
} else {
|
||||
void openExternal(maybeUrl);
|
||||
toast.success(t('projectActions.toast.openedUrlFromOutput'));
|
||||
}
|
||||
}
|
||||
urlWatchByRunKeyRef.current[runKey] = watch;
|
||||
}
|
||||
|
||||
for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) {
|
||||
if (!runningByKey[runKey]) {
|
||||
if (!projectActionRuns[runKey]) {
|
||||
delete urlWatchByRunKeyRef.current[runKey];
|
||||
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
|
||||
delete previewWaitTimeoutByRunKeyRef.current[runKey];
|
||||
}
|
||||
}
|
||||
|
||||
}, [actions, openExternal, runningByKey, t, terminalSessions]);
|
||||
}, [displayActions, openContextPreview, openExternal, projectActionRuns, setTabPreviewUrl, t, terminalSessions, updateProjectActionRunStatus]);
|
||||
|
||||
const normalizedDirectory = React.useMemo(() => {
|
||||
return normalizeProjectActionDirectory(directory || stableProjectRef?.path || '');
|
||||
}, [directory, stableProjectRef?.path]);
|
||||
|
||||
const selectedAction = React.useMemo(() => {
|
||||
if (!selectedActionId) {
|
||||
return actions[0] ?? null;
|
||||
}
|
||||
return actions.find((entry) => entry.id === selectedActionId) ?? actions[0] ?? null;
|
||||
}, [actions, selectedActionId]);
|
||||
|
||||
const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction) => {
|
||||
const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction, options: { revealTerminal?: boolean } = {}) => {
|
||||
if (!normalizedDirectory) {
|
||||
throw new Error(t('projectActions.error.noActiveDirectory'));
|
||||
}
|
||||
@@ -405,10 +435,12 @@ export const ProjectActionsButton = ({
|
||||
}
|
||||
|
||||
setTabLabel(normalizedDirectory, tabId, `Action: ${action.name}`);
|
||||
setActiveTab(normalizedDirectory, tabId);
|
||||
|
||||
setBottomTerminalOpen(true);
|
||||
setActiveMainTab('terminal');
|
||||
setTabIconKey(normalizedDirectory, tabId, action.icon || 'play');
|
||||
if (options.revealTerminal !== false) {
|
||||
setActiveTab(normalizedDirectory, tabId);
|
||||
setBottomTerminalOpen(true);
|
||||
setActiveMainTab('terminal');
|
||||
}
|
||||
|
||||
const stateAfterTab = useTerminalStore.getState().getDirectoryState(normalizedDirectory);
|
||||
const tab = stateAfterTab?.tabs.find((entry) => entry.id === tabId);
|
||||
@@ -423,6 +455,7 @@ export const ProjectActionsButton = ({
|
||||
setActiveMainTab,
|
||||
setActiveTab,
|
||||
setBottomTerminalOpen,
|
||||
setTabIconKey,
|
||||
setTabLabel,
|
||||
t,
|
||||
]);
|
||||
@@ -438,13 +471,35 @@ export const ProjectActionsButton = ({
|
||||
}
|
||||
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
|
||||
const existingRun = runningByKey[runKey];
|
||||
const existingRun = projectActionRuns[runKey];
|
||||
if (existingRun && existingRun.status === 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { key, tabId, sessionId } = await getOrCreateActionTab(action);
|
||||
const discovered = action.id === AUTO_DISCOVER_ACTION_ID
|
||||
? await (async (): Promise<OpenChamberProjectAction> => {
|
||||
const [actionsState, scripts] = await Promise.all([
|
||||
getProjectActionsState({ id: stableProjectRef?.id ?? '', path: normalizedDirectory }),
|
||||
readPackageJsonScripts(normalizedDirectory),
|
||||
]);
|
||||
const devServer = await detectDevServerCommand(normalizedDirectory, actionsState.actions, scripts);
|
||||
if (!devServer) {
|
||||
throw new Error(t('contextPanel.preview.noDevServer'));
|
||||
}
|
||||
return {
|
||||
id: AUTO_DISCOVER_ACTION_ID,
|
||||
name: t('projectActions.actions.autoDiscover'),
|
||||
command: devServer.command,
|
||||
icon: 'search',
|
||||
autoOpenUrl: true,
|
||||
openUrl: devServer.previewUrlHint || '',
|
||||
};
|
||||
})()
|
||||
: action;
|
||||
|
||||
const hasCustomOpenUrl = discovered.autoOpenUrl === true && (discovered.openUrl || '').trim().length > 0;
|
||||
const { key, tabId, sessionId } = await getOrCreateActionTab(discovered, { revealTerminal: !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID });
|
||||
let activeSessionId = sessionId;
|
||||
let createdSession = false;
|
||||
|
||||
@@ -468,54 +523,92 @@ export const ProjectActionsButton = ({
|
||||
await sleep(350);
|
||||
}
|
||||
|
||||
setRunningByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
key,
|
||||
directory: normalizedDirectory,
|
||||
actionId: action.id,
|
||||
tabId,
|
||||
sessionId: activeSessionId,
|
||||
status: 'running',
|
||||
},
|
||||
}));
|
||||
if (discovered.id === AUTO_DISCOVER_ACTION_ID) {
|
||||
streamCleanupByRunKeyRef.current[key]?.();
|
||||
setConnecting(normalizedDirectory, tabId, true);
|
||||
streamCleanupByRunKeyRef.current[key] = connectTerminalStream(
|
||||
activeSessionId,
|
||||
(event) => {
|
||||
if (event.type === 'data' && typeof event.data === 'string' && event.data.length > 0) {
|
||||
useTerminalStore.getState().appendToBuffer(normalizedDirectory, tabId, event.data);
|
||||
}
|
||||
if (event.type === 'exit') {
|
||||
useTerminalStore.getState().setTabLifecycle(normalizedDirectory, tabId, 'exited');
|
||||
useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false);
|
||||
useTerminalStore.getState().removeProjectActionRun(key);
|
||||
delete urlWatchByRunKeyRef.current[key];
|
||||
streamCleanupByRunKeyRef.current[key]?.();
|
||||
delete streamCleanupByRunKeyRef.current[key];
|
||||
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[key]);
|
||||
delete previewWaitTimeoutByRunKeyRef.current[key];
|
||||
}
|
||||
},
|
||||
() => {
|
||||
useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false);
|
||||
},
|
||||
{ maxRetries: 60, initialRetryDelay: 250, maxRetryDelay: 2000, connectionTimeout: 5000 },
|
||||
);
|
||||
}
|
||||
|
||||
const hasCustomOpenUrl = action.autoOpenUrl === true && (action.openUrl || '').trim().length > 0;
|
||||
const hasDesktopForwardSelection = action.autoOpenUrl === true
|
||||
const hasDesktopForwardSelection = discovered.autoOpenUrl === true
|
||||
&& isDesktopShellApp
|
||||
&& (action.desktopOpenSshForward || '').trim().length > 0;
|
||||
const manualOpenUrl = action.autoOpenUrl ? normalizeManualOpenUrl(action.openUrl) : null;
|
||||
const desktopForwardUrl = action.autoOpenUrl && isDesktopShellApp
|
||||
? resolveProjectActionDesktopForwardUrl(action.desktopOpenSshForward, desktopSshInstances)
|
||||
&& (discovered.desktopOpenSshForward || '').trim().length > 0;
|
||||
const manualOpenUrl = discovered.autoOpenUrl ? normalizeManualOpenUrl(discovered.openUrl) : null;
|
||||
const desktopForwardUrl = discovered.autoOpenUrl && isDesktopShellApp
|
||||
? resolveProjectActionDesktopForwardUrl(discovered.desktopOpenSshForward, desktopSshInstances)
|
||||
: null;
|
||||
|
||||
setProjectActionRun({
|
||||
key,
|
||||
directory: normalizedDirectory,
|
||||
actionId: discovered.id,
|
||||
tabId,
|
||||
sessionId: activeSessionId,
|
||||
status: discovered.id === AUTO_DISCOVER_ACTION_ID && !manualOpenUrl ? 'waiting-for-preview' : 'running',
|
||||
});
|
||||
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[key]);
|
||||
delete previewWaitTimeoutByRunKeyRef.current[key];
|
||||
if (discovered.id === AUTO_DISCOVER_ACTION_ID && !manualOpenUrl) {
|
||||
previewWaitTimeoutByRunKeyRef.current[key] = window.setTimeout(() => {
|
||||
useTerminalStore.getState().updateProjectActionRunStatus(key, 'running');
|
||||
delete previewWaitTimeoutByRunKeyRef.current[key];
|
||||
}, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
if (desktopForwardUrl) {
|
||||
setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true });
|
||||
void openExternal(desktopForwardUrl);
|
||||
toast.success(t('projectActions.toast.openedForwardedUrl'));
|
||||
} else if (manualOpenUrl) {
|
||||
void openExternal(manualOpenUrl);
|
||||
setTabPreviewUrl(normalizedDirectory, tabId, manualOpenUrl, { locked: true, autoOpened: true });
|
||||
openContextPreview(normalizedDirectory, manualOpenUrl);
|
||||
toast.success(t('projectActions.toast.openedActionUrl'));
|
||||
} else if (hasCustomOpenUrl) {
|
||||
setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true });
|
||||
toast.error(t('projectActions.error.invalidCustomUrlFormat'));
|
||||
} else if (hasDesktopForwardSelection) {
|
||||
setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true });
|
||||
toast.error(t('projectActions.error.selectedDesktopSshForwardUnavailable'));
|
||||
} else {
|
||||
setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: false, autoOpened: false });
|
||||
}
|
||||
|
||||
urlWatchByRunKeyRef.current[key] = {
|
||||
lastSeenChunkId: null,
|
||||
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
|
||||
tail: '',
|
||||
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
|
||||
};
|
||||
|
||||
const normalizedCommand = stripControlChars(action.command.trim().replace(/\r\n|\r/g, '\n'));
|
||||
const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n'));
|
||||
await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`);
|
||||
} catch (error) {
|
||||
setRunningByKey((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[runKey];
|
||||
return next;
|
||||
});
|
||||
removeProjectActionRun(runKey);
|
||||
delete urlWatchByRunKeyRef.current[runKey];
|
||||
streamCleanupByRunKeyRef.current[runKey]?.();
|
||||
delete streamCleanupByRunKeyRef.current[runKey];
|
||||
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
|
||||
delete previewWaitTimeoutByRunKeyRef.current[runKey];
|
||||
toast.error(error instanceof Error ? error.message : t('projectActions.error.failedToRunAction'));
|
||||
}
|
||||
}, [
|
||||
@@ -526,28 +619,27 @@ export const ProjectActionsButton = ({
|
||||
isDesktopShellApp,
|
||||
normalizedDirectory,
|
||||
openExternal,
|
||||
runningByKey,
|
||||
openContextPreview,
|
||||
projectActionRuns,
|
||||
runtime.isVSCode,
|
||||
removeProjectActionRun,
|
||||
setConnecting,
|
||||
setProjectActionRun,
|
||||
setTabPreviewUrl,
|
||||
setTabSessionId,
|
||||
stableProjectRef?.id,
|
||||
t,
|
||||
terminal,
|
||||
]);
|
||||
|
||||
const stopAction = React.useCallback(async (action: OpenChamberProjectAction) => {
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
|
||||
const activeRun = runningByKey[runKey];
|
||||
const activeRun = projectActionRuns[runKey];
|
||||
if (!activeRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningByKey((prev) => ({
|
||||
...prev,
|
||||
[runKey]: {
|
||||
...activeRun,
|
||||
status: 'stopping',
|
||||
},
|
||||
}));
|
||||
updateProjectActionRunStatus(runKey, 'stopping');
|
||||
|
||||
try {
|
||||
await terminal.sendInput(activeRun.sessionId, '\x03');
|
||||
@@ -581,29 +673,30 @@ export const ProjectActionsButton = ({
|
||||
setTabSessionId(activeRun.directory, activeRun.tabId, null);
|
||||
}
|
||||
|
||||
setRunningByKey((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[runKey];
|
||||
return next;
|
||||
});
|
||||
removeProjectActionRun(runKey);
|
||||
delete urlWatchByRunKeyRef.current[runKey];
|
||||
}, [normalizedDirectory, runningByKey, setTabSessionId, terminal]);
|
||||
streamCleanupByRunKeyRef.current[runKey]?.();
|
||||
delete streamCleanupByRunKeyRef.current[runKey];
|
||||
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
|
||||
delete previewWaitTimeoutByRunKeyRef.current[runKey];
|
||||
}, [normalizedDirectory, projectActionRuns, removeProjectActionRun, setTabSessionId, terminal, updateProjectActionRunStatus]);
|
||||
|
||||
const handlePrimaryClick = React.useCallback(() => {
|
||||
if (!selectedAction) {
|
||||
const action = selectedAction ?? displayActions[0];
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, selectedAction.id);
|
||||
const runningEntry = runningByKey[runKey];
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
|
||||
const runningEntry = projectActionRuns[runKey];
|
||||
if (runningEntry?.status === 'stopping') {
|
||||
return;
|
||||
}
|
||||
if (runningEntry) {
|
||||
void stopAction(selectedAction);
|
||||
void stopAction(action);
|
||||
return;
|
||||
}
|
||||
void runAction(selectedAction);
|
||||
}, [normalizedDirectory, runAction, runningByKey, selectedAction, stopAction]);
|
||||
void runAction(action);
|
||||
}, [displayActions, normalizedDirectory, runAction, projectActionRuns, selectedAction, stopAction]);
|
||||
|
||||
const handleSelectAction = React.useCallback((action: OpenChamberProjectAction, toggleStopIfRunning = false) => {
|
||||
setSelectedActionId(action.id);
|
||||
@@ -614,7 +707,7 @@ export const ProjectActionsButton = ({
|
||||
}
|
||||
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
|
||||
const runningEntry = runningByKey[runKey];
|
||||
const runningEntry = projectActionRuns[runKey];
|
||||
if (runningEntry?.status === 'stopping') {
|
||||
return;
|
||||
}
|
||||
@@ -623,7 +716,7 @@ export const ProjectActionsButton = ({
|
||||
return;
|
||||
}
|
||||
void runAction(action);
|
||||
}, [normalizedDirectory, runAction, runningByKey, stopAction]);
|
||||
}, [normalizedDirectory, runAction, projectActionRuns, stopAction]);
|
||||
|
||||
const openProjectActionsSettings = React.useCallback(() => {
|
||||
if (!stableProjectRef?.id) {
|
||||
@@ -638,117 +731,120 @@ export const ProjectActionsButton = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (actions.length === 0) {
|
||||
if (compact) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] p-2',
|
||||
'typography-ui-label font-medium text-muted-foreground hover:bg-interactive-hover hover:text-foreground transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
className
|
||||
)}
|
||||
aria-label={t('projectActions.actions.addActionAria')}
|
||||
onClick={openProjectActionsSettings}
|
||||
>
|
||||
<RiAddLine className="h-5 w-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-7 shrink-0 items-center gap-2 self-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px]',
|
||||
'bg-[var(--surface-elevated)] px-3 typography-ui-label font-medium text-foreground hover:bg-interactive-hover transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
'border border-border/60',
|
||||
className
|
||||
)}
|
||||
onClick={openProjectActionsSettings}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="header-open-label whitespace-nowrap">{t('projectActions.actions.addAction')}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSelected = selectedAction ?? actions[0] ?? null;
|
||||
const resolvedSelected = selectedAction ?? displayActions[0] ?? null;
|
||||
if (!resolvedSelected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedIconKey = (resolvedSelected.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
|
||||
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
|
||||
const SelectedIcon = resolvedSelected.id === AUTO_DISCOVER_ACTION_ID
|
||||
? RiSearchLine
|
||||
: PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
|
||||
const selectedButtonLabel = formatActionButtonLabel(
|
||||
resolvedSelected.name,
|
||||
t('projectActions.label.fallbackAction'),
|
||||
);
|
||||
const selectedRunKey = toProjectActionRunKey(normalizedDirectory, resolvedSelected.id);
|
||||
const selectedRunning = runningByKey[selectedRunKey];
|
||||
const selectedRunning = projectActionRuns[selectedRunKey];
|
||||
const isStoppingSelected = selectedRunning?.status === 'stopping';
|
||||
const isWaitingForSelectedPreview = selectedRunning?.status === 'waiting-for-preview';
|
||||
const selectedRunPreviewUrl = selectedRunning
|
||||
? terminalSessions.get(selectedRunning.directory)?.tabs.find((tab) => tab.id === selectedRunning.tabId)?.previewUrl ?? null
|
||||
: null;
|
||||
const showSelectedPreviewButton = Boolean(selectedRunning && selectedRunPreviewUrl);
|
||||
const handleOpenSelectedPreview = () => {
|
||||
if (!selectedRunning || !selectedRunPreviewUrl) {
|
||||
return;
|
||||
}
|
||||
openContextPreview(selectedRunning.directory, selectedRunPreviewUrl);
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading || isStoppingSelected}
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] p-2',
|
||||
'typography-ui-label font-medium text-muted-foreground hover:bg-interactive-hover hover:text-foreground transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
'disabled:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
aria-label={selectedRunning
|
||||
? t('projectActions.actions.stopNamedAria', { name: resolvedSelected.name })
|
||||
: t('projectActions.actions.runNamedAria', { name: resolvedSelected.name })}
|
||||
>
|
||||
{isStoppingSelected
|
||||
? <RiLoader4Line className="h-5 w-5 animate-spin text-[var(--status-warning)]" />
|
||||
: selectedRunning
|
||||
? <RiStopLine className="h-5 w-5 text-[var(--status-warning)]" />
|
||||
: <SelectedIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52 max-h-[70vh] overflow-y-auto">
|
||||
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground">{t('projectActions.actions.addNewAction')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{actions.map((entry) => {
|
||||
const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
|
||||
const Icon = PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, entry.id);
|
||||
const runState = runningByKey[runKey];
|
||||
const isRunning = Boolean(runState);
|
||||
const isStopping = runState?.status === 'stopping';
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entry.id}
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleSelectAction(entry, true);
|
||||
}}
|
||||
<div className="inline-flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading || isStoppingSelected}
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] p-2',
|
||||
'typography-ui-label font-medium text-muted-foreground hover:bg-interactive-hover hover:text-foreground transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
'disabled:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
onClick={handlePrimaryClick}
|
||||
aria-label={selectedRunning
|
||||
? t('projectActions.actions.stopNamedAria', { name: resolvedSelected.name })
|
||||
: t('projectActions.actions.runNamedAria', { name: resolvedSelected.name })}
|
||||
>
|
||||
{isStoppingSelected || isWaitingForSelectedPreview
|
||||
? <RiLoader4Line className="h-5 w-5 animate-spin text-[var(--status-warning)]" />
|
||||
: selectedRunning
|
||||
? <RiStopLine className="h-5 w-5 text-[var(--status-warning)]" />
|
||||
: <SelectedIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
{showSelectedPreviewButton ? (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="app-region-no-drag -ml-1 inline-flex h-9 w-7 items-center justify-center rounded-[10px] text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('projectActions.actions.openPreview')}
|
||||
onClick={handleOpenSelectedPreview}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
|
||||
{isStopping
|
||||
? <RiLoader4Line className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
|
||||
: isRunning
|
||||
? <RiStopLine className="ml-auto h-4 w-4 text-[var(--status-warning)]" />
|
||||
: null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<RiGlobalLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('projectActions.actions.openPreview')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="app-region-no-drag -ml-1 inline-flex h-9 w-5 items-center justify-center rounded-[10px] text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('projectActions.actions.chooseActionAria')}
|
||||
>
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52 max-h-[70vh] overflow-y-auto">
|
||||
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground">{t('projectActions.actions.addNewAction')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{displayActions.map((entry) => {
|
||||
const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
|
||||
const Icon = entry.id === AUTO_DISCOVER_ACTION_ID
|
||||
? RiSearchLine
|
||||
: PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, entry.id);
|
||||
const runState = projectActionRuns[runKey];
|
||||
const isRunning = Boolean(runState);
|
||||
const isStopping = runState?.status === 'stopping';
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entry.id}
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
handleSelectAction(entry, true);
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
|
||||
{isStopping || runState?.status === 'waiting-for-preview'
|
||||
? <RiLoader4Line className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
|
||||
: isRunning
|
||||
? <RiStopLine className="ml-auto h-4 w-4 text-[var(--status-warning)]" />
|
||||
: null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -776,7 +872,7 @@ export const ProjectActionsButton = ({
|
||||
: t('projectActions.actions.runNamedAria', { name: resolvedSelected.name })}
|
||||
>
|
||||
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{isStoppingSelected
|
||||
{isStoppingSelected || isWaitingForSelectedPreview
|
||||
? <RiLoader4Line className="h-4 w-4 animate-spin text-[var(--status-warning)]" />
|
||||
: selectedRunning
|
||||
? <RiStopLine className="h-4 w-4 text-[var(--status-warning)]" />
|
||||
@@ -785,6 +881,26 @@ export const ProjectActionsButton = ({
|
||||
{!compact ? <span className="header-open-label whitespace-nowrap">{selectedButtonLabel}</span> : null}
|
||||
</button>
|
||||
|
||||
{showSelectedPreviewButton ? (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenSelectedPreview}
|
||||
className={cn(
|
||||
compact ? 'inline-flex h-full w-8 items-center justify-center' : 'inline-flex h-full w-7 items-center justify-center',
|
||||
'border-l border-[var(--interactive-border)] text-foreground',
|
||||
'hover:bg-interactive-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
||||
)}
|
||||
aria-label={t('projectActions.actions.openPreview')}
|
||||
>
|
||||
<RiGlobalLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('projectActions.actions.openPreview')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@@ -805,11 +921,13 @@ export const ProjectActionsButton = ({
|
||||
<span className="typography-ui-label text-foreground">{t('projectActions.actions.addNewAction')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{actions.map((entry) => {
|
||||
{displayActions.map((entry) => {
|
||||
const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
|
||||
const Icon = PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
|
||||
const Icon = entry.id === AUTO_DISCOVER_ACTION_ID
|
||||
? RiSearchLine
|
||||
: PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
|
||||
const runKey = toProjectActionRunKey(normalizedDirectory, entry.id);
|
||||
const runState = runningByKey[runKey];
|
||||
const runState = projectActionRuns[runKey];
|
||||
const isRunning = Boolean(runState);
|
||||
const isStopping = runState?.status === 'stopping';
|
||||
|
||||
@@ -823,7 +941,7 @@ export const ProjectActionsButton = ({
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
|
||||
{isStopping
|
||||
{isStopping || runState?.status === 'waiting-for-preview'
|
||||
? <RiLoader4Line className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
|
||||
: isRunning
|
||||
? <RiStopLine className="ml-auto h-4 w-4 text-[var(--status-warning)]" />
|
||||
|
||||
@@ -435,12 +435,12 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
<>
|
||||
{item.icon ? (
|
||||
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className={cn('flex items-center justify-center transition-opacity', closeReplacesIcon && 'group-hover:opacity-0')}>{item.icon}</span>
|
||||
<span className={cn('flex items-center justify-center transition-opacity', closeReplacesIcon && (isMobile ? 'opacity-0' : 'group-hover:opacity-0'))}>{item.icon}</span>
|
||||
{closeReplacesIcon ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className="absolute inset-0 z-20 flex items-center justify-center rounded-sm text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
className={cn('absolute inset-0 z-20 flex items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
@@ -467,12 +467,12 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
isActive ? 'text-[var(--primary-base)]' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex items-center justify-center transition-opacity', closeReplacesIcon && 'group-hover:opacity-0')}>{item.icon}</span>
|
||||
<span className={cn('flex items-center justify-center transition-opacity', closeReplacesIcon && (isMobile ? 'opacity-0' : 'group-hover:opacity-0'))}>{item.icon}</span>
|
||||
{closeReplacesIcon ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className="absolute inset-0 z-20 flex items-center justify-center rounded-sm text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
className={cn('absolute inset-0 z-20 flex items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
|
||||
@@ -1223,7 +1223,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
};
|
||||
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
|
||||
|
||||
const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean }): Promise<string> => {
|
||||
const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; optional?: boolean }): Promise<string> => {
|
||||
if (files.readFile) {
|
||||
const result = await files.readFile(path, options);
|
||||
return result.content ?? '';
|
||||
@@ -1233,7 +1233,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
params.set('allowOutsideWorkspace', 'true');
|
||||
}
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`);
|
||||
if (options?.optional) {
|
||||
params.set('optional', 'true');
|
||||
}
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`, {
|
||||
// Avoid conditional requests (304 + empty body).
|
||||
cache: options?.optional ? 'no-store' : 'default',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed'));
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
@@ -365,7 +366,16 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
|
||||
return result?.content ?? '';
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
|
||||
const runtimeFiles = getRegisteredRuntimeAPIs()?.files;
|
||||
if (runtimeFiles?.readFile) {
|
||||
const result = await runtimeFiles.readFile(path, { optional: true });
|
||||
return result?.content ?? '';
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, {
|
||||
// Avoid conditional requests (304 + empty body).
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read plan file (${response.status})`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine } from '@remixicon/react';
|
||||
import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine, RiFullscreenExitLine, RiFullscreenLine, RiGlobalLine, RiTerminalLine } from '@remixicon/react';
|
||||
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
@@ -13,10 +13,12 @@ import { TerminalViewport, type TerminalController } from '@/components/terminal
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { primeTerminalInputTransport } from '@/lib/terminalApi';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions';
|
||||
|
||||
type Modifier = 'ctrl' | 'cmd';
|
||||
type MobileKey =
|
||||
@@ -111,6 +113,8 @@ export const TerminalView: React.FC = () => {
|
||||
const setConnecting = useTerminalStore((s) => s.setConnecting);
|
||||
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
|
||||
|
||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
||||
|
||||
const directoryTerminalState = React.useMemo(() => {
|
||||
if (!effectiveDirectory) return undefined;
|
||||
return terminalSessions.get(effectiveDirectory);
|
||||
@@ -133,10 +137,24 @@ export const TerminalView: React.FC = () => {
|
||||
);
|
||||
}, [directoryTerminalState, activeTabId]);
|
||||
|
||||
const terminalTabItems = React.useMemo(() => {
|
||||
return (directoryTerminalState?.tabs ?? []).map((tab) => ({
|
||||
icon: (() => {
|
||||
const Icon = tab.iconKey ? PROJECT_ACTION_ICON_MAP[tab.iconKey as ProjectActionIconKey] ?? RiTerminalLine : RiTerminalLine;
|
||||
return <Icon className="h-4 w-4" />;
|
||||
})(),
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
title: tab.label,
|
||||
closeLabel: t('terminalView.tabs.closeTabTitle'),
|
||||
}));
|
||||
}, [directoryTerminalState?.tabs, t]);
|
||||
|
||||
const terminalSessionId = activeTab?.terminalSessionId ?? null;
|
||||
const terminalLifecycle = activeTab?.lifecycle ?? 'idle';
|
||||
const bufferChunks = activeTab?.bufferChunks ?? [];
|
||||
const isConnecting = activeTab?.isConnecting ?? false;
|
||||
const previewUrl = activeTab?.previewUrl ?? null;
|
||||
|
||||
const [connectionError, setConnectionError] = React.useState<string | null>(null);
|
||||
const [isFatalError, setIsFatalError] = React.useState(false);
|
||||
@@ -187,6 +205,8 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
||||
const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen);
|
||||
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
|
||||
const setBottomTerminalExpanded = useUIStore((state) => state.setBottomTerminalExpanded);
|
||||
const isTerminalActive = activeMainTab === 'terminal';
|
||||
const isTerminalVisible = isTerminalActive || isBottomTerminalOpen;
|
||||
const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible);
|
||||
@@ -905,6 +925,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending;
|
||||
const shouldRenderViewport = isMobile ? isTerminalVisible : hasOpenedTerminalViewport;
|
||||
const showBottomDockControls = !isMobile && isBottomTerminalOpen && !isTerminalActive;
|
||||
const quickKeysControls = (
|
||||
<>
|
||||
<Button
|
||||
@@ -1012,73 +1033,82 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-background)]">
|
||||
<div className={cn('sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] text-xs', isMobile ? 'px-4 py-1.5' : runtime.platform === 'desktop' ? 'pl-5 pr-20 py-2' : 'px-5 py-2')}>
|
||||
<div className={cn('app-region-no-drag sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] text-xs', isMobile ? 'pl-3 pr-1.5 py-1' : 'pl-3 pr-1.5 py-1')}>
|
||||
{enableTabs && directoryTerminalState ? (
|
||||
<div className={cn('pl-1 pr-1 flex items-center gap-2', isMobile ? 'mt-1' : 'mt-2')}>
|
||||
<div className={cn('min-w-0 flex-1 overflow-x-auto', isMobile ? 'pb-0.5' : 'pb-1')}>
|
||||
<div className={cn('flex w-max items-center pr-1', isMobile ? 'gap-1' : 'gap-1')}>
|
||||
{directoryTerminalState.tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'group flex items-center rounded-md border whitespace-nowrap',
|
||||
isMobile ? 'h-8 gap-0.5 pl-2 pr-1.5 text-sm leading-none' : 'gap-1 pl-2 pr-1 py-1 text-xs',
|
||||
isActive
|
||||
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
|
||||
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectTab(tab.id)}
|
||||
className={cn(
|
||||
'truncate text-left',
|
||||
isMobile ? '!min-h-0 !min-w-0 max-w-[9.5rem]' : 'max-w-[10rem]'
|
||||
)}
|
||||
title={tab.label}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-sm text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
|
||||
isMobile ? '!min-h-0 !min-w-0 h-3.5 w-3.5 p-0 leading-none' : 'h-4 w-4 p-0 leading-none',
|
||||
!isMobile && !isActive && 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCloseTab(tab.id);
|
||||
}}
|
||||
title={t('terminalView.tabs.closeTabTitle')}
|
||||
>
|
||||
{isMobile ? <span aria-hidden>×</span> : <RiCloseLine size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateTab}
|
||||
className={cn(
|
||||
'ml-1 flex items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]',
|
||||
isMobile ? '!min-h-0 !min-w-0 h-8 w-8' : 'h-6.5 w-6.5'
|
||||
)}
|
||||
title={t('terminalView.tabs.newTabTitle')}
|
||||
>
|
||||
<RiAddLine size={isMobile ? 18 : 16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-1 pr-1">
|
||||
<div className={cn('min-w-0 flex-1', isMobile ? 'h-8' : 'h-7')}>
|
||||
<SortableTabsStrip
|
||||
items={terminalTabItems}
|
||||
activeId={activeTabId}
|
||||
onSelect={handleSelectTab}
|
||||
onClose={handleCloseTab}
|
||||
layoutMode="scrollable"
|
||||
variant="default"
|
||||
className="h-full bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isMobile && showQuickKeys ? (
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
||||
{quickKeysControls}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className={cn('shrink-0', isMobile ? 'h-8 w-8 p-0' : 'h-7 w-7 p-0')}
|
||||
onClick={handleCreateTab}
|
||||
title={t('terminalView.tabs.newTabTitle')}
|
||||
>
|
||||
<RiAddLine size={isMobile ? 18 : 16} />
|
||||
</Button>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 overflow-visible">
|
||||
{previewUrl ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="h-6 shrink-0 gap-1 px-2"
|
||||
onClick={() => {
|
||||
if (!effectiveDirectory) return;
|
||||
openContextPreview(effectiveDirectory, previewUrl);
|
||||
}}
|
||||
title={t('terminalView.preview.openTitle')}
|
||||
>
|
||||
<RiGlobalLine className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="whitespace-nowrap">{t('terminalView.preview.open')}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{showBottomDockControls ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => setBottomTerminalExpanded(!isBottomTerminalExpanded)}
|
||||
className={cn('shrink-0 p-0', isMobile ? 'h-8 w-8' : 'h-7 w-7')}
|
||||
title={isBottomTerminalExpanded ? t('terminalView.bottomDock.restoreTitle') : t('terminalView.bottomDock.expandTitle')}
|
||||
aria-label={isBottomTerminalExpanded ? t('terminalView.bottomDock.restoreAria') : t('terminalView.bottomDock.expandAria')}
|
||||
>
|
||||
{isBottomTerminalExpanded ? <RiFullscreenExitLine className="h-4 w-4" /> : <RiFullscreenLine className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => setBottomTerminalOpen(false)}
|
||||
className={cn('shrink-0 p-0', isMobile ? 'h-8 w-8' : 'h-7 w-7')}
|
||||
title={t('terminalView.bottomDock.closeTitle')}
|
||||
aria-label={t('terminalView.bottomDock.closeAria')}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isMobile && showQuickKeys && enableTabs && directoryTerminalState ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 pl-1 pr-1">
|
||||
{quickKeysControls}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1093,7 +1123,7 @@ export const TerminalView: React.FC = () => {
|
||||
className="relative flex-1 overflow-hidden"
|
||||
style={{ backgroundColor: xtermTheme.background }}
|
||||
>
|
||||
<div className="h-full w-full box-border pl-7 pr-5 pt-3 pb-4">
|
||||
<div className="h-full w-full box-border pl-4 pr-1.5 pt-3 pb-4">
|
||||
{shouldRenderViewport ? (
|
||||
<TerminalViewport
|
||||
key={viewportSessionKey}
|
||||
|
||||
@@ -511,6 +511,7 @@ export interface ListDirectoryOptions {
|
||||
|
||||
export interface FileReadOptions {
|
||||
allowOutsideWorkspace?: boolean;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export interface FilesAPI {
|
||||
|
||||
@@ -26,12 +26,15 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
|
||||
|
||||
const readFileContent = async (files: FilesAPI, path: string): Promise<string> => {
|
||||
if (files.readFile) {
|
||||
const result = await files.readFile(path, { allowOutsideWorkspace: true });
|
||||
const result = await files.readFile(path, { allowOutsideWorkspace: true, optional: true });
|
||||
return result.content ?? '';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ path, allowOutsideWorkspace: 'true' });
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`);
|
||||
const params = new URLSearchParams({ path, allowOutsideWorkspace: 'true', optional: 'true' });
|
||||
const response = await fetch(`/api/fs/read?${params.toString()}`, {
|
||||
// Avoid conditional requests (304 + empty body).
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((errorPayload as { error?: string }).error || 'Failed to read file');
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { OpenChamberProjectAction } from './openchamberConfig';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
type DevServerInfo = {
|
||||
command: string;
|
||||
label: string;
|
||||
actionId?: string;
|
||||
previewUrlHint?: string;
|
||||
};
|
||||
|
||||
type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun';
|
||||
|
||||
const DEV_COMMAND_PATTERNS = [
|
||||
{ pattern: /^dev(:.*)?$/i },
|
||||
{ pattern: /^start(:.*)?$/i },
|
||||
{ pattern: /^preview(:.*)?$/i },
|
||||
{ pattern: /^serve(:.*)?$/i },
|
||||
{ pattern: /^develop(:.*)?$/i },
|
||||
];
|
||||
|
||||
const COMMON_DEV_COMMANDS = [
|
||||
'dev',
|
||||
'start',
|
||||
'preview',
|
||||
'serve',
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect the dev server command from project actions or package.json scripts
|
||||
*/
|
||||
export async function detectDevServerCommand(
|
||||
directory: string,
|
||||
projectActions: OpenChamberProjectAction[],
|
||||
packageJsonScripts: Record<string, string> | null,
|
||||
): Promise<DevServerInfo | null> {
|
||||
if (!directory) return null;
|
||||
|
||||
// First, check if there's a project action that looks like a dev server
|
||||
const devAction = findDevServerAction(projectActions);
|
||||
if (devAction) {
|
||||
return {
|
||||
command: devAction.command,
|
||||
label: devAction.name || 'Start Preview',
|
||||
actionId: devAction.id,
|
||||
};
|
||||
}
|
||||
|
||||
// Then, check package.json scripts
|
||||
if (packageJsonScripts) {
|
||||
const devScript = findDevScript(packageJsonScripts);
|
||||
if (devScript) {
|
||||
// Determine the package manager command
|
||||
const pm = await detectPackageManager(directory);
|
||||
const pmCommand = pm === 'npm' ? 'npm run' : pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : pm === 'bun' ? 'bun' : 'npm run';
|
||||
return {
|
||||
command: `${pmCommand} ${devScript}`,
|
||||
label: `Start (${devScript})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: static sites (no package.json) can be previewed via a simple file server.
|
||||
// This keeps Start Preview usable for non-Node projects.
|
||||
if (await hasStaticIndexHtml(directory)) {
|
||||
const port = await allocatePreviewPort();
|
||||
const resolvedPort = typeof port === 'number' && Number.isFinite(port) && port > 0 ? port : 8000;
|
||||
return {
|
||||
command: `python3 -m http.server ${resolvedPort}`,
|
||||
label: 'Static preview',
|
||||
previewUrlHint: `http://127.0.0.1:${resolvedPort}/`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function hasStaticIndexHtml(directory: string): Promise<boolean> {
|
||||
const target = `${directory}/index.html`;
|
||||
const content = await readOptionalTextFile(target);
|
||||
return typeof content === 'string' && content.trim().length > 0;
|
||||
}
|
||||
|
||||
async function allocatePreviewPort(): Promise<number | null> {
|
||||
try {
|
||||
const response = await fetch('/api/system/free-port', { cache: 'no-store' });
|
||||
if (!response.ok) return null;
|
||||
const body = await response.json().catch(() => null) as { port?: unknown } | null;
|
||||
const port = typeof body?.port === 'number' ? body.port : null;
|
||||
return port && Number.isFinite(port) ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a project action that looks like a dev server
|
||||
*/
|
||||
function findDevServerAction(actions: OpenChamberProjectAction[]): OpenChamberProjectAction | null {
|
||||
// Look for actions with "dev", "preview", "start" in the name or command
|
||||
for (const action of actions) {
|
||||
const nameAndCommand = `${action.name} ${action.command}`.toLowerCase();
|
||||
|
||||
// Check if it's likely a dev server action
|
||||
const isDevAction = COMMON_DEV_COMMANDS.some(cmd =>
|
||||
nameAndCommand.includes(cmd)
|
||||
);
|
||||
|
||||
if (isDevAction) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return the first action if there's only one
|
||||
if (actions.length === 1) {
|
||||
return actions[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a dev script in package.json scripts
|
||||
*/
|
||||
function findDevScript(scripts: Record<string, string>): string | null {
|
||||
for (const { pattern } of DEV_COMMAND_PATTERNS) {
|
||||
for (const scriptName of Object.keys(scripts)) {
|
||||
if (pattern.test(scriptName)) {
|
||||
return scriptName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple package manager detection based on lock files
|
||||
* Note: This is intentionally a simple client-side check.
|
||||
* For server-side operations, the server's package-manager.js is used.
|
||||
*/
|
||||
async function detectPackageManager(directory: string): Promise<PackageManager> {
|
||||
const packageJsonContent = await readOptionalTextFile(`${directory}/package.json`);
|
||||
if (packageJsonContent) {
|
||||
try {
|
||||
const pkg = JSON.parse(packageJsonContent) as { packageManager?: unknown };
|
||||
const packageManager = typeof pkg.packageManager === 'string' ? pkg.packageManager.toLowerCase() : '';
|
||||
if (packageManager.startsWith('bun@')) return 'bun';
|
||||
if (packageManager.startsWith('pnpm@')) return 'pnpm';
|
||||
if (packageManager.startsWith('yarn@')) return 'yarn';
|
||||
if (packageManager.startsWith('npm@')) return 'npm';
|
||||
} catch {
|
||||
// Ignore malformed package.json here; readPackageJsonScripts handles it separately.
|
||||
}
|
||||
}
|
||||
|
||||
const lockfiles: Array<[string, PackageManager]> = [
|
||||
['bun.lock', 'bun'],
|
||||
['bun.lockb', 'bun'],
|
||||
['pnpm-lock.yaml', 'pnpm'],
|
||||
['yarn.lock', 'yarn'],
|
||||
['package-lock.json', 'npm'],
|
||||
];
|
||||
|
||||
for (const [fileName, packageManager] of lockfiles) {
|
||||
const content = await readOptionalTextFile(`${directory}/${fileName}`);
|
||||
if (typeof content === 'string' && content.trim().length > 0) {
|
||||
return packageManager;
|
||||
}
|
||||
}
|
||||
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
async function readOptionalTextFile(path: string): Promise<string | null> {
|
||||
const runtimeFiles = getRegisteredRuntimeAPIs()?.files;
|
||||
if (runtimeFiles?.readFile) {
|
||||
try {
|
||||
const result = await runtimeFiles.readFile(path, { optional: true });
|
||||
return typeof result?.content === 'string' ? result.content : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return response.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read package.json scripts from a directory
|
||||
*/
|
||||
export async function readPackageJsonScripts(directory: string): Promise<Record<string, string> | null> {
|
||||
try {
|
||||
const content = await readOptionalTextFile(`${directory}/package.json`);
|
||||
|
||||
if (content == null) return null;
|
||||
const pkg = JSON.parse(content);
|
||||
|
||||
return pkg.scripts || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -695,12 +695,61 @@ export const dict = {
|
||||
'contextPanel.mode.diff': 'Diff',
|
||||
'contextPanel.mode.plan': 'Plan',
|
||||
'contextPanel.mode.context': 'Context',
|
||||
'contextPanel.mode.preview': 'Preview',
|
||||
'contextPanel.tab.closeTabAria': 'Close {label} tab',
|
||||
'contextPanel.actions.collapsePanel': 'Collapse panel',
|
||||
'contextPanel.actions.expandPanel': 'Expand panel',
|
||||
'contextPanel.actions.closePanel': 'Close panel',
|
||||
'contextPanel.actions.resizePanelAria': 'Resize context panel',
|
||||
'contextPanel.iframe.sessionChatTitle': 'Session chat {sessionID}',
|
||||
'contextPanel.preview.actions.reload': 'Reload preview',
|
||||
'contextPanel.preview.actions.openExternal': 'Open in browser',
|
||||
'contextPanel.preview.actions.retry': 'Retry',
|
||||
'contextPanel.preview.iframeTitle': 'Preview',
|
||||
'contextPanel.preview.invalidUrl': 'Preview needs a valid http(s) URL.',
|
||||
'contextPanel.preview.empty': 'No preview URL',
|
||||
'contextPanel.preview.loading': 'Connecting preview proxy...',
|
||||
'contextPanel.preview.proxyError': 'Could not start preview proxy.',
|
||||
'contextPanel.preview.upstreamUnreachable': 'Dev server is not responding.',
|
||||
'contextPanel.preview.upstreamUnreachableHint': 'Make sure your dev server is still running, then retry.',
|
||||
'contextPanel.preview.startingServer': 'Starting dev server...',
|
||||
'contextPanel.preview.startingServerHint': 'Waiting for the server to accept connections.',
|
||||
'contextPanel.preview.title': 'Preview',
|
||||
'contextPanel.preview.description': 'Use Project Actions or a terminal Preview button to open a preview.',
|
||||
'contextPanel.preview.startPreview': 'Start Preview',
|
||||
'contextPanel.preview.starting': 'Starting...',
|
||||
'contextPanel.preview.noDevServer': 'No dev server command found. Configure a project action or add a "dev" script to package.json.',
|
||||
'contextPanel.preview.startFailed': 'Failed to start preview server.',
|
||||
'contextPanel.preview.serverExited': 'Dev server exited unexpectedly.',
|
||||
'contextPanel.preview.noUrlDetected': 'Dev server started, but no URL was detected in its output. Open the Preview terminal tab to check logs.',
|
||||
'contextPanel.preview.serverExitedWithLog': 'Dev server exited unexpectedly. Last output:\n\n{log}',
|
||||
'contextPanel.preview.noUrlDetectedWithLog': 'Dev server did not become reachable. Last output:\n\n{log}',
|
||||
'contextPanel.preview.console.open': 'Open preview console',
|
||||
'contextPanel.preview.console.waiting': 'Waiting for preview console',
|
||||
'contextPanel.preview.console.title': 'Preview console',
|
||||
'contextPanel.preview.console.attach': 'Attach',
|
||||
'contextPanel.preview.console.copy': 'Copy',
|
||||
'contextPanel.preview.console.clear': 'Clear',
|
||||
'contextPanel.preview.console.empty': 'No preview console events yet.',
|
||||
'contextPanel.preview.console.noFilteredEvents': 'No events match this filter.',
|
||||
'contextPanel.preview.console.runtimeError': 'Runtime error',
|
||||
'contextPanel.preview.console.copied': 'Preview console copied',
|
||||
'contextPanel.preview.console.copyFailed': 'Failed to copy preview console',
|
||||
'contextPanel.preview.console.attached': 'Preview console attached to chat',
|
||||
'contextPanel.preview.console.attachNoSession': 'Open a chat session before attaching preview logs',
|
||||
'contextPanel.preview.console.attachAnnotation': 'These are browser console logs from the dev server running for this project.',
|
||||
'contextPanel.preview.inspect.toggle': 'Inspect preview element',
|
||||
'contextPanel.preview.inspect.attached': 'Preview annotation attached to chat',
|
||||
'contextPanel.preview.inspect.attachNoSession': 'Open a chat session before attaching preview annotations',
|
||||
'contextPanel.preview.inspect.attachAnnotation': 'This is a selected DOM element from the in-app preview.',
|
||||
'contextPanel.preview.inspect.attachAnnotationWithScreenshot': 'This is a selected DOM element from the in-app preview. A screenshot of the visible preview area with the selected element highlighted is attached.',
|
||||
'contextPanel.preview.console.filter.all': 'All',
|
||||
'contextPanel.preview.console.filter.errors': 'Errors',
|
||||
'contextPanel.preview.console.filter.warnings': 'Warnings',
|
||||
'contextPanel.preview.console.filter.logs': 'Logs',
|
||||
|
||||
'terminalView.preview.open': 'Preview',
|
||||
'terminalView.preview.openTitle': 'Open preview pane',
|
||||
'sidebarFilesTree.menu.rename': 'Rename',
|
||||
'sidebarFilesTree.menu.copyPath': 'Copy Path',
|
||||
'sidebarFilesTree.menu.save': 'Save',
|
||||
@@ -958,6 +1007,7 @@ export const dict = {
|
||||
'header.services.used': 'Used',
|
||||
'header.services.remaining': 'Remaining',
|
||||
'header.services.modelFamily.other': 'Other',
|
||||
'header.services.shutdownDev': 'Stop OpenChamber',
|
||||
'header.actions.openPlanAria': 'Open plan',
|
||||
'header.actions.planWithShortcut': 'Plan ({shortcut})',
|
||||
'header.actions.terminalPanelWithShortcut': 'Terminal panel ({shortcut})',
|
||||
@@ -1329,6 +1379,8 @@ export const dict = {
|
||||
'chat.messageBody.actions.fork': 'Fork from here',
|
||||
'chat.messageBody.actions.copyMessageAria': 'Copy message text',
|
||||
'chat.messageBody.actions.copyMessage': 'Copy message',
|
||||
'chat.messageBody.actions.openPreviewAria': 'Open preview',
|
||||
'chat.messageBody.actions.openPreview': 'Open preview',
|
||||
'chat.messageBody.actions.copyAnswer': 'Copy answer',
|
||||
'chat.messageBody.actions.savingImage': 'Saving image...',
|
||||
'chat.messageBody.actions.saveAsImage': 'Save as image',
|
||||
@@ -1382,6 +1434,11 @@ export const dict = {
|
||||
'chat.chatInput.toast.openSessionFirst': 'Open a session first',
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': 'Failed to toggle permission auto-accept',
|
||||
'chat.chatInput.reviewComments': 'Review comments:',
|
||||
'chat.chatInput.devServerLogs': 'Dev Server logs:',
|
||||
'chat.chatInput.devServerLogsRemove': 'Remove Dev Server logs',
|
||||
'chat.chatInput.previewAnnotations': 'Preview annotations:',
|
||||
'chat.chatInput.previewContext': 'Preview context:',
|
||||
'chat.chatInput.previewContextRemove': 'Remove preview context',
|
||||
'chat.chatInput.projectRoot': 'Project root',
|
||||
'chat.chatInput.branch': 'Branch',
|
||||
'chat.chatInput.worktrees': 'Worktrees',
|
||||
@@ -1762,7 +1819,9 @@ export const dict = {
|
||||
'projectActions.actions.addActionAria': 'Add action',
|
||||
'projectActions.actions.addAction': 'Add action',
|
||||
'projectActions.actions.addNewAction': 'Add new action',
|
||||
'projectActions.actions.autoDiscover': 'Auto-discover',
|
||||
'projectActions.actions.chooseActionAria': 'Choose project action',
|
||||
'projectActions.actions.openPreview': 'Open Preview',
|
||||
'projectActions.actions.runNamedAria': 'Run {name}',
|
||||
'projectActions.actions.stopNamedAria': 'Stop {name}',
|
||||
'projectActions.label.fallbackAction': 'Action',
|
||||
|
||||
@@ -696,12 +696,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.mode.diff": "Diff",
|
||||
"contextPanel.mode.plan": "Plan",
|
||||
"contextPanel.mode.context": "Contexto",
|
||||
"contextPanel.mode.preview": "Vista previa",
|
||||
"contextPanel.tab.closeTabAria": "Cerrar pestaña {label}",
|
||||
"contextPanel.actions.collapsePanel": "Colapsar panel",
|
||||
"contextPanel.actions.expandPanel": "Expandir panel",
|
||||
"contextPanel.actions.closePanel": "Cerrar panel",
|
||||
"contextPanel.actions.resizePanelAria": "Ajustar tamaño del panel de contexto",
|
||||
"contextPanel.iframe.sessionChatTitle": "Chat de sesión {sessionID}",
|
||||
"contextPanel.preview.actions.reload": "Recargar vista previa",
|
||||
"contextPanel.preview.actions.openExternal": "Abrir en el navegador",
|
||||
"contextPanel.preview.actions.retry": "Reintentar",
|
||||
"contextPanel.preview.iframeTitle": "Vista previa",
|
||||
"contextPanel.preview.invalidUrl": "La vista previa necesita una URL http(s) válida.",
|
||||
"contextPanel.preview.empty": "Sin URL de vista previa",
|
||||
"contextPanel.preview.loading": "Conectando proxy de vista previa...",
|
||||
"contextPanel.preview.proxyError": "No se pudo iniciar el proxy de vista previa.",
|
||||
"contextPanel.preview.upstreamUnreachable": "El servidor de desarrollo no responde.",
|
||||
"contextPanel.preview.upstreamUnreachableHint": "Verifica que tu servidor de desarrollo siga en ejecución y vuelve a intentarlo.",
|
||||
|
||||
"terminalView.preview.open": "Vista previa",
|
||||
"terminalView.preview.openTitle": "Abrir panel de vista previa",
|
||||
"sidebarFilesTree.menu.rename": "Cambiar nombre",
|
||||
"sidebarFilesTree.menu.copyPath": "Copiar ruta",
|
||||
"sidebarFilesTree.menu.save": "Guardar",
|
||||
@@ -959,6 +973,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"header.services.used": "Usado",
|
||||
"header.services.remaining": "Restante",
|
||||
"header.services.modelFamily.other": "Otro",
|
||||
"header.services.shutdownDev": "Detener OpenChamber",
|
||||
"header.actions.openPlanAria": "Abrir plan",
|
||||
"header.actions.planWithShortcut": "Plan ({shortcut})",
|
||||
"header.actions.terminalPanelWithShortcut": "Panel de terminal ({shortcut})",
|
||||
@@ -1330,6 +1345,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.fork": "Bifurcar desde aquí",
|
||||
"chat.messageBody.actions.copyMessageAria": "Copiar texto del mensaje",
|
||||
"chat.messageBody.actions.copyMessage": "Copiar mensaje",
|
||||
"chat.messageBody.actions.openPreviewAria": "Abrir vista previa",
|
||||
"chat.messageBody.actions.openPreview": "Abrir vista previa",
|
||||
"chat.messageBody.actions.copyAnswer": "Copiar respuesta",
|
||||
"chat.messageBody.actions.savingImage": "Guardando imagen...",
|
||||
"chat.messageBody.actions.saveAsImage": "Guardar como imagen",
|
||||
@@ -1383,6 +1400,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.toast.openSessionFirst": "Abre una sesión primero",
|
||||
"chat.chatInput.toast.togglePermissionAutoAcceptFailed": "No se pudo cambiar la aceptación automática de permisos",
|
||||
"chat.chatInput.reviewComments": "Comentarios de revisión:",
|
||||
"chat.chatInput.devServerLogs": "Logs del Dev Server:",
|
||||
"chat.chatInput.devServerLogsRemove": "Quitar logs del Dev Server",
|
||||
"chat.chatInput.previewAnnotations": "Anotaciones de vista previa:",
|
||||
"chat.chatInput.previewContext": "Contexto de vista previa:",
|
||||
"chat.chatInput.previewContextRemove": "Quitar contexto de vista previa",
|
||||
"chat.chatInput.projectRoot": "Raíz del proyecto",
|
||||
"chat.chatInput.branch": "Rama",
|
||||
"chat.chatInput.worktrees": "Worktrees",
|
||||
@@ -1763,7 +1785,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"projectActions.actions.addActionAria": "Añadir acción",
|
||||
"projectActions.actions.addAction": "Añadir acción",
|
||||
"projectActions.actions.addNewAction": "Añadir nueva acción",
|
||||
"projectActions.actions.autoDiscover": "Autodetectar",
|
||||
"projectActions.actions.chooseActionAria": "Elegir acción del proyecto",
|
||||
"projectActions.actions.openPreview": "Abrir Preview",
|
||||
"projectActions.actions.runNamedAria": "Ejecutar {name}",
|
||||
"projectActions.actions.stopNamedAria": "Detener {name}",
|
||||
"projectActions.label.fallbackAction": "Acción",
|
||||
@@ -2092,4 +2116,39 @@ export const dict: Record<I18nKey, string> = {
|
||||
"markdownRenderer.mermaid.actions.copySourceTitle": "Copiar fuente",
|
||||
"markdownRenderer.mermaid.actions.downloadSvgTitle": "Descargar SVG",
|
||||
"markdownRenderer.mermaid.toast.downloadFailed": "No se pudo descargar el diagrama",
|
||||
"contextPanel.preview.title": "Vista previa",
|
||||
"contextPanel.preview.description": "Usa Acciones del proyecto o el botón Preview del terminal para abrir una vista previa.",
|
||||
"contextPanel.preview.startPreview": "Iniciar vista previa",
|
||||
"contextPanel.preview.starting": "Iniciando...",
|
||||
"contextPanel.preview.noDevServer": "No se encontro un comando de servidor de desarrollo. Configura una accion del proyecto o anade un script \"dev\" a package.json.",
|
||||
"contextPanel.preview.startFailed": "Fallo al iniciar el servidor de vista previa.",
|
||||
"contextPanel.preview.serverExited": "El servidor de desarrollo se cerro inesperadamente.",
|
||||
"contextPanel.preview.noUrlDetected": "El servidor de desarrollo se inicio, pero no se detecto ningun URL en su salida. Abre la pestaña de terminal de vista previa para ver los registros.",
|
||||
"contextPanel.preview.serverExitedWithLog": "El servidor de desarrollo terminó inesperadamente. Última salida:\n\n{log}",
|
||||
"contextPanel.preview.noUrlDetectedWithLog": "El servidor de desarrollo no respondió. Última salida:\n\n{log}",
|
||||
"contextPanel.preview.startingServer": "Iniciando servidor de desarrollo...",
|
||||
"contextPanel.preview.startingServerHint": "Esperando a que el servidor acepte conexiones.",
|
||||
"contextPanel.preview.console.open": "Abrir consola de vista previa",
|
||||
"contextPanel.preview.console.waiting": "Esperando la consola de vista previa",
|
||||
"contextPanel.preview.console.title": "Consola de vista previa",
|
||||
"contextPanel.preview.console.attach": "Adjuntar",
|
||||
"contextPanel.preview.console.copy": "Copiar",
|
||||
"contextPanel.preview.console.clear": "Limpiar",
|
||||
"contextPanel.preview.console.empty": "Aún no hay eventos de consola de vista previa.",
|
||||
"contextPanel.preview.console.noFilteredEvents": "Ningún evento coincide con este filtro.",
|
||||
"contextPanel.preview.console.runtimeError": "Error de ejecución",
|
||||
"contextPanel.preview.console.copied": "Consola de vista previa copiada",
|
||||
"contextPanel.preview.console.copyFailed": "No se pudo copiar la consola de vista previa",
|
||||
"contextPanel.preview.console.attached": "Consola de vista previa adjuntada al chat",
|
||||
"contextPanel.preview.console.attachNoSession": "Abre una sesión de chat antes de adjuntar logs de vista previa",
|
||||
"contextPanel.preview.console.attachAnnotation": "Estos son logs de la consola del navegador del servidor de desarrollo que se ejecuta para este proyecto.",
|
||||
"contextPanel.preview.inspect.toggle": "Inspeccionar elemento de vista previa",
|
||||
"contextPanel.preview.inspect.attached": "Anotación de vista previa adjuntada al chat",
|
||||
"contextPanel.preview.inspect.attachNoSession": "Abre una sesión de chat antes de adjuntar anotaciones de vista previa",
|
||||
"contextPanel.preview.inspect.attachAnnotation": "Este es un elemento DOM seleccionado de la vista previa integrada.",
|
||||
"contextPanel.preview.inspect.attachAnnotationWithScreenshot": "Este es un elemento DOM seleccionado de la vista previa integrada. Se adjunta una captura del área visible de la vista previa con el elemento resaltado.",
|
||||
"contextPanel.preview.console.filter.all": "Todo",
|
||||
"contextPanel.preview.console.filter.errors": "Errores",
|
||||
"contextPanel.preview.console.filter.warnings": "Advertencias",
|
||||
"contextPanel.preview.console.filter.logs": "Registros",
|
||||
};
|
||||
|
||||
@@ -696,6 +696,57 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.mode.diff': 'diff',
|
||||
'contextPanel.mode.plan': '플랜',
|
||||
'contextPanel.mode.context': '컨텍스트',
|
||||
'contextPanel.mode.preview': '미리보기',
|
||||
'contextPanel.preview.actions.reload': '미리보기 새로고침',
|
||||
'contextPanel.preview.actions.openExternal': '브라우저에서 열기',
|
||||
'contextPanel.preview.actions.retry': '다시 시도',
|
||||
'contextPanel.preview.iframeTitle': '미리보기',
|
||||
'contextPanel.preview.invalidUrl': '미리보기에는 유효한 http(s) URL이 필요합니다.',
|
||||
'contextPanel.preview.empty': '미리보기 URL이 없습니다',
|
||||
'contextPanel.preview.loading': '미리보기 프록시에 연결 중...',
|
||||
'contextPanel.preview.proxyError': '미리보기 프록시를 시작할 수 없습니다.',
|
||||
'contextPanel.preview.upstreamUnreachable': '개발 서버가 응답하지 않습니다.',
|
||||
'contextPanel.preview.upstreamUnreachableHint': '개발 서버가 여전히 실행 중인지 확인한 후 다시 시도하세요.',
|
||||
'contextPanel.preview.startingServer': '개발 서버를 시작하는 중...',
|
||||
'contextPanel.preview.startingServerHint': '서버가 연결을 수락할 때까지 기다리는 중입니다.',
|
||||
'contextPanel.preview.title': '미리보기',
|
||||
'contextPanel.preview.description': '프로젝트 작업 또는 터미널 Preview 버튼으로 미리보기를 여세요.',
|
||||
'contextPanel.preview.startPreview': '미리보기 시작',
|
||||
'contextPanel.preview.starting': '시작 중...',
|
||||
'contextPanel.preview.noDevServer': '개발 서버 명령을 찾을 수 없습니다. 프로젝트 동작을 구성하거나 package.json에 "dev" 스크립트를 추가하세요.',
|
||||
'contextPanel.preview.startFailed': '미리보기 서버를 시작하지 못했습니다.',
|
||||
'contextPanel.preview.serverExited': '개발 서버가 예기치 않게 종료되었습니다.',
|
||||
'contextPanel.preview.noUrlDetected': '개발 서버가 시작되었지만 출력에서 URL이 감지되지 않았습니다. 미리보기 터미널 탭에서 로그를 확인하세요.',
|
||||
'contextPanel.preview.serverExitedWithLog': '개발 서버가 예기치 않게 종료되었습니다. 마지막 출력:\n\n{log}',
|
||||
'contextPanel.preview.noUrlDetectedWithLog': '개발 서버가 응답할 수 있는 상태가 되지 못했습니다. 마지막 출력:\n\n{log}',
|
||||
'contextPanel.preview.console.open': '미리보기 콘솔 열기',
|
||||
'contextPanel.preview.console.waiting': '미리보기 콘솔 대기 중',
|
||||
'contextPanel.preview.console.title': '미리보기 콘솔',
|
||||
'contextPanel.preview.console.attach': '첨부',
|
||||
'contextPanel.preview.console.copy': '복사',
|
||||
'contextPanel.preview.console.clear': '지우기',
|
||||
'contextPanel.preview.console.empty': '아직 미리보기 콘솔 이벤트가 없습니다.',
|
||||
'contextPanel.preview.console.noFilteredEvents': '이 필터와 일치하는 이벤트가 없습니다.',
|
||||
'contextPanel.preview.console.runtimeError': '런타임 오류',
|
||||
'contextPanel.preview.console.copied': '미리보기 콘솔 복사됨',
|
||||
'contextPanel.preview.console.copyFailed': '미리보기 콘솔 복사 실패',
|
||||
'contextPanel.preview.console.attached': '미리보기 콘솔이 채팅에 첨부되었습니다',
|
||||
'contextPanel.preview.console.attachNoSession': '미리보기 로그를 첨부하기 전에 채팅 세션을 여세요',
|
||||
'contextPanel.preview.console.attachAnnotation': '이것은 이 프로젝트에서 실행 중인 개발 서버의 브라우저 콘솔 로그입니다.',
|
||||
'contextPanel.preview.inspect.toggle': '미리보기 요소 검사',
|
||||
'contextPanel.preview.inspect.attached': '미리보기 주석이 채팅에 첨부되었습니다',
|
||||
'contextPanel.preview.inspect.attachNoSession': '미리보기 주석을 첨부하기 전에 채팅 세션을 여세요',
|
||||
'contextPanel.preview.inspect.attachAnnotation': '인앱 미리보기에서 선택한 DOM 요소입니다.',
|
||||
'contextPanel.preview.inspect.attachAnnotationWithScreenshot': '인앱 미리보기에서 선택한 DOM 요소입니다. 선택한 요소가 강조된 보이는 미리보기 영역 스크린샷이 첨부되었습니다.',
|
||||
'contextPanel.preview.console.filter.all': '전체',
|
||||
'contextPanel.preview.console.filter.errors': '오류',
|
||||
'contextPanel.preview.console.filter.warnings': '경고',
|
||||
'contextPanel.preview.console.filter.logs': '로그',
|
||||
'terminalView.preview.open': '미리보기',
|
||||
'terminalView.preview.openTitle': '미리보기 패널 열기',
|
||||
'header.services.shutdownDev': 'OpenChamber 종료',
|
||||
'chat.messageBody.actions.openPreviewAria': '미리보기 열기',
|
||||
'chat.messageBody.actions.openPreview': '미리보기 열기',
|
||||
'contextPanel.tab.closeTabAria': '{label} tab 닫기',
|
||||
'contextPanel.actions.collapsePanel': '접기 패널',
|
||||
'contextPanel.actions.expandPanel': '펼치기 패널',
|
||||
@@ -1383,6 +1434,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.openSessionFirst': '먼저 세션을 여세요',
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': 'toggle permission auto-accept 실패',
|
||||
'chat.chatInput.reviewComments': 'Review 댓글:',
|
||||
'chat.chatInput.devServerLogs': 'Dev Server 로그:',
|
||||
'chat.chatInput.devServerLogsRemove': 'Dev Server 로그 제거',
|
||||
'chat.chatInput.previewAnnotations': '미리보기 주석:',
|
||||
'chat.chatInput.previewContext': '미리보기 컨텍스트:',
|
||||
'chat.chatInput.previewContextRemove': '미리보기 컨텍스트 제거',
|
||||
'chat.chatInput.projectRoot': '프로젝트 root',
|
||||
'chat.chatInput.branch': '브랜치',
|
||||
'chat.chatInput.worktrees': '워크트리',
|
||||
@@ -1763,7 +1819,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'projectActions.actions.addActionAria': 'action 추가',
|
||||
'projectActions.actions.addAction': 'action 추가',
|
||||
'projectActions.actions.addNewAction': 'new action 추가',
|
||||
'projectActions.actions.autoDiscover': '자동 검색',
|
||||
'projectActions.actions.chooseActionAria': '선택 프로젝트 작업',
|
||||
'projectActions.actions.openPreview': 'Preview 열기',
|
||||
'projectActions.actions.runNamedAria': '실행 {name}',
|
||||
'projectActions.actions.stopNamedAria': '중지 {name}',
|
||||
'projectActions.label.fallbackAction': '작업',
|
||||
|
||||
@@ -696,12 +696,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.mode.diff": "Diff",
|
||||
"contextPanel.mode.plan": "Plano",
|
||||
"contextPanel.mode.context": "Contexto",
|
||||
"contextPanel.mode.preview": "Prévia",
|
||||
"contextPanel.tab.closeTabAria": "Fechar aba {label}",
|
||||
"contextPanel.actions.collapsePanel": "Recolher painel",
|
||||
"contextPanel.actions.expandPanel": "Expandir painel",
|
||||
"contextPanel.actions.closePanel": "Fechar painel",
|
||||
"contextPanel.actions.resizePanelAria": "Ajustar tamanho do painel de contexto",
|
||||
"contextPanel.iframe.sessionChatTitle": "Chat de sessão {sessionID}",
|
||||
"contextPanel.preview.actions.reload": "Recarregar prévia",
|
||||
"contextPanel.preview.actions.openExternal": "Abrir no navegador",
|
||||
"contextPanel.preview.actions.retry": "Tentar novamente",
|
||||
"contextPanel.preview.iframeTitle": "Prévia",
|
||||
"contextPanel.preview.invalidUrl": "A prévia precisa de uma URL http(s) válida.",
|
||||
"contextPanel.preview.empty": "Sem URL de prévia",
|
||||
"contextPanel.preview.loading": "Conectando proxy de prévia...",
|
||||
"contextPanel.preview.proxyError": "Não foi possível iniciar o proxy de prévia.",
|
||||
"contextPanel.preview.upstreamUnreachable": "O servidor de desenvolvimento não está respondendo.",
|
||||
"contextPanel.preview.upstreamUnreachableHint": "Verifique se o servidor de desenvolvimento ainda está em execução e tente novamente.",
|
||||
|
||||
"terminalView.preview.open": "Prévia",
|
||||
"terminalView.preview.openTitle": "Abrir painel de prévia",
|
||||
"sidebarFilesTree.menu.rename": "Renomear",
|
||||
"sidebarFilesTree.menu.copyPath": "Copiar caminho",
|
||||
"sidebarFilesTree.menu.save": "Salvar",
|
||||
@@ -959,6 +973,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"header.services.used": "Usado",
|
||||
"header.services.remaining": "Restante",
|
||||
"header.services.modelFamily.other": "Outro",
|
||||
"header.services.shutdownDev": "Parar OpenChamber",
|
||||
"header.actions.openPlanAria": "Abrir plano",
|
||||
"header.actions.planWithShortcut": "Plano ({shortcut})",
|
||||
"header.actions.terminalPanelWithShortcut": "Painel de terminal ({shortcut})",
|
||||
@@ -1330,6 +1345,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.fork": "Bifurcar daqui",
|
||||
"chat.messageBody.actions.copyMessageAria": "Copiar texto da mensagem",
|
||||
"chat.messageBody.actions.copyMessage": "Copiar mensagem",
|
||||
"chat.messageBody.actions.openPreviewAria": "Abrir visualização",
|
||||
"chat.messageBody.actions.openPreview": "Abrir visualização",
|
||||
"chat.messageBody.actions.copyAnswer": "Copiar resposta",
|
||||
"chat.messageBody.actions.savingImage": "Salvando imagem...",
|
||||
"chat.messageBody.actions.saveAsImage": "Salvar como imagem",
|
||||
@@ -1383,6 +1400,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.toast.openSessionFirst": "Abra uma sessão primeiro",
|
||||
"chat.chatInput.toast.togglePermissionAutoAcceptFailed": "Não foi possível alterar a aceitação automática de permissões",
|
||||
"chat.chatInput.reviewComments": "Comentários de revisão:",
|
||||
"chat.chatInput.devServerLogs": "Logs do Dev Server:",
|
||||
"chat.chatInput.devServerLogsRemove": "Remover logs do Dev Server",
|
||||
"chat.chatInput.previewAnnotations": "Anotações da visualização:",
|
||||
"chat.chatInput.previewContext": "Contexto da visualização:",
|
||||
"chat.chatInput.previewContextRemove": "Remover contexto da visualização",
|
||||
"chat.chatInput.projectRoot": "Raiz do projeto",
|
||||
"chat.chatInput.branch": "Branch",
|
||||
"chat.chatInput.worktrees": "Worktrees",
|
||||
@@ -1763,7 +1785,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"projectActions.actions.addActionAria": "Adicionar ação",
|
||||
"projectActions.actions.addAction": "Adicionar ação",
|
||||
"projectActions.actions.addNewAction": "Adicionar nova ação",
|
||||
"projectActions.actions.autoDiscover": "Detectar automaticamente",
|
||||
"projectActions.actions.chooseActionAria": "Escolher ação do projeto",
|
||||
"projectActions.actions.openPreview": "Abrir Preview",
|
||||
"projectActions.actions.runNamedAria": "Executar {name}",
|
||||
"projectActions.actions.stopNamedAria": "Parar {name}",
|
||||
"projectActions.label.fallbackAction": "Ação",
|
||||
@@ -2092,4 +2116,39 @@ export const dict: Record<I18nKey, string> = {
|
||||
"markdownRenderer.mermaid.actions.copySourceTitle": "Copiar origem",
|
||||
"markdownRenderer.mermaid.actions.downloadSvgTitle": "Baixar SVG",
|
||||
"markdownRenderer.mermaid.toast.downloadFailed": "Não foi possível baixar o diagrama",
|
||||
"contextPanel.preview.title": "Visualização",
|
||||
"contextPanel.preview.description": "Use Ações do projeto ou o botão Preview do terminal para abrir uma visualização.",
|
||||
"contextPanel.preview.startPreview": "Iniciar visualização",
|
||||
"contextPanel.preview.starting": "Iniciando...",
|
||||
"contextPanel.preview.noDevServer": "Nenhum comando de servidor de desenvolvimento encontrado. Configure uma ação do projeto ou adicione um script \"dev\" ao package.json.",
|
||||
"contextPanel.preview.startFailed": "Falha ao iniciar o servidor de visualização.",
|
||||
"contextPanel.preview.serverExited": "Servidor de desenvolvimento encerrou inesperadamente.",
|
||||
"contextPanel.preview.noUrlDetected": "O servidor de desenvolvimento foi iniciado, mas nenhum URL foi detectado na saída. Abra a aba de terminal da visualização para ver os logs.",
|
||||
"contextPanel.preview.serverExitedWithLog": "O servidor de desenvolvimento encerrou inesperadamente. Última saída:\n\n{log}",
|
||||
"contextPanel.preview.noUrlDetectedWithLog": "O servidor de desenvolvimento não respondeu. Última saída:\n\n{log}",
|
||||
"contextPanel.preview.startingServer": "Iniciando servidor de desenvolvimento...",
|
||||
"contextPanel.preview.startingServerHint": "Aguardando o servidor aceitar conexões.",
|
||||
"contextPanel.preview.console.open": "Abrir console da visualização",
|
||||
"contextPanel.preview.console.waiting": "Aguardando console da visualização",
|
||||
"contextPanel.preview.console.title": "Console da visualização",
|
||||
"contextPanel.preview.console.attach": "Anexar",
|
||||
"contextPanel.preview.console.copy": "Copiar",
|
||||
"contextPanel.preview.console.clear": "Limpar",
|
||||
"contextPanel.preview.console.empty": "Ainda não há eventos do console da visualização.",
|
||||
"contextPanel.preview.console.noFilteredEvents": "Nenhum evento corresponde a este filtro.",
|
||||
"contextPanel.preview.console.runtimeError": "Erro de execução",
|
||||
"contextPanel.preview.console.copied": "Console da visualização copiado",
|
||||
"contextPanel.preview.console.copyFailed": "Não foi possível copiar o console da visualização",
|
||||
"contextPanel.preview.console.attached": "Console da visualização anexado ao chat",
|
||||
"contextPanel.preview.console.attachNoSession": "Abra uma sessão de chat antes de anexar logs da visualização",
|
||||
"contextPanel.preview.console.attachAnnotation": "Estes são logs do console do navegador do servidor de desenvolvimento em execução para este projeto.",
|
||||
"contextPanel.preview.inspect.toggle": "Inspecionar elemento da visualização",
|
||||
"contextPanel.preview.inspect.attached": "Anotação da visualização anexada ao chat",
|
||||
"contextPanel.preview.inspect.attachNoSession": "Abra uma sessão de chat antes de anexar anotações da visualização",
|
||||
"contextPanel.preview.inspect.attachAnnotation": "Este é um elemento DOM selecionado da visualização integrada.",
|
||||
"contextPanel.preview.inspect.attachAnnotationWithScreenshot": "Este é um elemento DOM selecionado da visualização integrada. Uma captura da área visível da visualização com o elemento destacado foi anexada.",
|
||||
"contextPanel.preview.console.filter.all": "Tudo",
|
||||
"contextPanel.preview.console.filter.errors": "Erros",
|
||||
"contextPanel.preview.console.filter.warnings": "Avisos",
|
||||
"contextPanel.preview.console.filter.logs": "Logs",
|
||||
};
|
||||
|
||||
@@ -696,12 +696,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.mode.diff": "Diff",
|
||||
"contextPanel.mode.plan": "План",
|
||||
"contextPanel.mode.context": "Контекст",
|
||||
"contextPanel.mode.preview": "Перегляд",
|
||||
"contextPanel.tab.closeTabAria": "Закрити вкладку {label}",
|
||||
"contextPanel.actions.collapsePanel": "Згорнути панель",
|
||||
"contextPanel.actions.expandPanel": "Розгорнути панель",
|
||||
"contextPanel.actions.closePanel": "Закрити панель",
|
||||
"contextPanel.actions.resizePanelAria": "Змінити розмір контекстної панелі",
|
||||
"contextPanel.iframe.sessionChatTitle": "Сесійний чат {sessionID}",
|
||||
"contextPanel.preview.actions.reload": "Перезавантажити перегляд",
|
||||
"contextPanel.preview.actions.openExternal": "Відкрити в браузері",
|
||||
"contextPanel.preview.actions.retry": "Повторити",
|
||||
"contextPanel.preview.iframeTitle": "Перегляд",
|
||||
"contextPanel.preview.invalidUrl": "Для перегляду потрібна коректна URL http(s).",
|
||||
"contextPanel.preview.empty": "Немає URL для перегляду",
|
||||
"contextPanel.preview.loading": "Підключення проксі перегляду...",
|
||||
"contextPanel.preview.proxyError": "Не вдалося запустити проксі перегляду.",
|
||||
"contextPanel.preview.upstreamUnreachable": "Сервер розробки не відповідає.",
|
||||
"contextPanel.preview.upstreamUnreachableHint": "Переконайтеся, що сервер розробки все ще працює, і повторіть спробу.",
|
||||
|
||||
"terminalView.preview.open": "Перегляд",
|
||||
"terminalView.preview.openTitle": "Відкрити панель перегляду",
|
||||
"sidebarFilesTree.menu.rename": "Перейменувати",
|
||||
"sidebarFilesTree.menu.copyPath": "Копіювати шлях",
|
||||
"sidebarFilesTree.menu.save": "Зберегти",
|
||||
@@ -958,6 +972,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"header.services.noRateLimitsReported": "Ліміти запитів не надходять.",
|
||||
"header.services.used": "Використано",
|
||||
"header.services.remaining": "Залишилося",
|
||||
"header.services.shutdownDev": "Зупинити OpenChamber",
|
||||
"header.services.modelFamily.other": "інше",
|
||||
"header.actions.openPlanAria": "Відкрити план",
|
||||
"header.actions.planWithShortcut": "План ({shortcut})",
|
||||
@@ -1330,6 +1345,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.fork": "Відгалузити звідси",
|
||||
"chat.messageBody.actions.copyMessageAria": "Копіювати текст повідомлення",
|
||||
"chat.messageBody.actions.copyMessage": "Копіювати повідомлення",
|
||||
"chat.messageBody.actions.openPreviewAria": "Відкрити попередній перегляд",
|
||||
"chat.messageBody.actions.openPreview": "Відкрити попередній перегляд",
|
||||
"chat.messageBody.actions.copyAnswer": "Скопіювати відповідь",
|
||||
"chat.messageBody.actions.savingImage": "Збереження зображення...",
|
||||
"chat.messageBody.actions.saveAsImage": "Зберегти як зображення",
|
||||
@@ -1383,6 +1400,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.toast.openSessionFirst": "Спочатку відкрийте сесію",
|
||||
"chat.chatInput.toast.togglePermissionAutoAcceptFailed": "Не вдалося ввімкнути автоматичне прийняття дозволів",
|
||||
"chat.chatInput.reviewComments": "Коментарі рев’ю:",
|
||||
"chat.chatInput.devServerLogs": "Логи Dev Server:",
|
||||
"chat.chatInput.devServerLogsRemove": "Прибрати логи Dev Server",
|
||||
"chat.chatInput.previewAnnotations": "Анотації перегляду:",
|
||||
"chat.chatInput.previewContext": "Контекст перегляду:",
|
||||
"chat.chatInput.previewContextRemove": "Прибрати контекст перегляду",
|
||||
"chat.chatInput.projectRoot": "Корінь проєкту",
|
||||
"chat.chatInput.branch": "гілка",
|
||||
"chat.chatInput.worktrees": "Worktree",
|
||||
@@ -1763,7 +1785,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"projectActions.actions.addActionAria": "Додати дію",
|
||||
"projectActions.actions.addAction": "Додати дію",
|
||||
"projectActions.actions.addNewAction": "Додати нову дію",
|
||||
"projectActions.actions.autoDiscover": "Автовиявлення",
|
||||
"projectActions.actions.chooseActionAria": "Вибрати дію проєкту",
|
||||
"projectActions.actions.openPreview": "Відкрити Preview",
|
||||
"projectActions.actions.runNamedAria": "Запустити {name}",
|
||||
"projectActions.actions.stopNamedAria": "Зупинити {name}",
|
||||
"projectActions.label.fallbackAction": "Дія",
|
||||
@@ -2092,4 +2116,39 @@ export const dict: Record<I18nKey, string> = {
|
||||
"markdownRenderer.mermaid.actions.copySourceTitle": "Копіювати джерело",
|
||||
"markdownRenderer.mermaid.actions.downloadSvgTitle": "Завантажити SVG",
|
||||
"markdownRenderer.mermaid.toast.downloadFailed": "Не вдалося завантажити діаграму",
|
||||
"contextPanel.preview.title": "Попередній перегляд",
|
||||
"contextPanel.preview.description": "Використайте дії проєкту або кнопку Preview у терміналі, щоб відкрити перегляд.",
|
||||
"contextPanel.preview.startPreview": "Почати перегляд",
|
||||
"contextPanel.preview.starting": "Запуск...",
|
||||
"contextPanel.preview.noDevServer": "Команду сервера розробки не знайдено. Налаштуйте дію проекту або додайте скрипт \"dev\" у package.json.",
|
||||
"contextPanel.preview.startFailed": "Не вдалося запустити сервер перегляду.",
|
||||
"contextPanel.preview.serverExited": "Сервер розробки несподівано завершив роботу.",
|
||||
"contextPanel.preview.noUrlDetected": "Сервер розробки запущено, але URL не виявлено у його виводі. Відкрийте вкладку термінала попереднього перегляду, щоб переглянути журнали.",
|
||||
"contextPanel.preview.serverExitedWithLog": "Сервер розробки несподівано завершив роботу. Останній вивід:\n\n{log}",
|
||||
"contextPanel.preview.noUrlDetectedWithLog": "Сервер розробки не став доступним. Останній вивід:\n\n{log}",
|
||||
"contextPanel.preview.startingServer": "Запуск сервера розробки...",
|
||||
"contextPanel.preview.startingServerHint": "Очікуємо, поки сервер почне приймати з'єднання.",
|
||||
"contextPanel.preview.console.open": "Відкрити консоль перегляду",
|
||||
"contextPanel.preview.console.waiting": "Очікування консолі перегляду",
|
||||
"contextPanel.preview.console.title": "Консоль перегляду",
|
||||
"contextPanel.preview.console.attach": "Додати",
|
||||
"contextPanel.preview.console.copy": "Копіювати",
|
||||
"contextPanel.preview.console.clear": "Очистити",
|
||||
"contextPanel.preview.console.empty": "Подій консолі перегляду ще немає.",
|
||||
"contextPanel.preview.console.noFilteredEvents": "Немає подій для цього фільтра.",
|
||||
"contextPanel.preview.console.runtimeError": "Помилка виконання",
|
||||
"contextPanel.preview.console.copied": "Консоль перегляду скопійовано",
|
||||
"contextPanel.preview.console.copyFailed": "Не вдалося скопіювати консоль перегляду",
|
||||
"contextPanel.preview.console.attached": "Консоль перегляду додано до чату",
|
||||
"contextPanel.preview.console.attachNoSession": "Відкрийте чат-сесію перед додаванням логів перегляду",
|
||||
"contextPanel.preview.console.attachAnnotation": "Це браузерні console logs із dev server, що запущений для цього проєкту.",
|
||||
"contextPanel.preview.inspect.toggle": "Інспектувати елемент перегляду",
|
||||
"contextPanel.preview.inspect.attached": "Анотацію перегляду додано до чату",
|
||||
"contextPanel.preview.inspect.attachNoSession": "Відкрийте чат-сесію перед додаванням анотацій перегляду",
|
||||
"contextPanel.preview.inspect.attachAnnotation": "Це вибраний DOM-елемент із вбудованого перегляду.",
|
||||
"contextPanel.preview.inspect.attachAnnotationWithScreenshot": "Це вибраний DOM-елемент із вбудованого перегляду. Скріншот видимої області перегляду з підсвіченим елементом додано як вкладення.",
|
||||
"contextPanel.preview.console.filter.all": "Усі",
|
||||
"contextPanel.preview.console.filter.errors": "Помилки",
|
||||
"contextPanel.preview.console.filter.warnings": "Попередження",
|
||||
"contextPanel.preview.console.filter.logs": "Логи",
|
||||
};
|
||||
|
||||
@@ -696,12 +696,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.mode.diff': '对比',
|
||||
'contextPanel.mode.plan': '计划',
|
||||
'contextPanel.mode.context': '上下文',
|
||||
'contextPanel.mode.preview': '预览',
|
||||
'contextPanel.tab.closeTabAria': '关闭 {label} 标签',
|
||||
'contextPanel.actions.collapsePanel': '折叠面板',
|
||||
'contextPanel.actions.expandPanel': '展开面板',
|
||||
'contextPanel.actions.closePanel': '关闭面板',
|
||||
'contextPanel.actions.resizePanelAria': '调整上下文面板大小',
|
||||
'contextPanel.iframe.sessionChatTitle': '会话聊天 {sessionID}',
|
||||
'contextPanel.preview.actions.reload': '刷新预览',
|
||||
'contextPanel.preview.actions.openExternal': '在浏览器中打开',
|
||||
'contextPanel.preview.actions.retry': '重试',
|
||||
'contextPanel.preview.iframeTitle': '预览',
|
||||
'contextPanel.preview.invalidUrl': '预览需要有效的 http(s) URL。',
|
||||
'contextPanel.preview.empty': '没有预览 URL',
|
||||
'contextPanel.preview.loading': '正在连接预览代理...',
|
||||
'contextPanel.preview.proxyError': '无法启动预览代理。',
|
||||
'contextPanel.preview.upstreamUnreachable': '开发服务器无响应。',
|
||||
'contextPanel.preview.upstreamUnreachableHint': '请确认开发服务器仍在运行,然后重试。',
|
||||
|
||||
'terminalView.preview.open': '预览',
|
||||
'terminalView.preview.openTitle': '打开预览面板',
|
||||
'sidebarFilesTree.menu.rename': '重命名',
|
||||
'sidebarFilesTree.menu.copyPath': '复制路径',
|
||||
'sidebarFilesTree.menu.save': '保存',
|
||||
@@ -958,6 +972,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'header.services.noRateLimitsReported': '未上报速率限制。',
|
||||
'header.services.used': '已用',
|
||||
'header.services.remaining': '剩余',
|
||||
'header.services.shutdownDev': '停止 OpenChamber',
|
||||
'header.services.modelFamily.other': '其他',
|
||||
'header.actions.openPlanAria': '打开计划',
|
||||
'header.actions.planWithShortcut': '计划({shortcut})',
|
||||
@@ -1330,6 +1345,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.fork': '从此处分叉',
|
||||
'chat.messageBody.actions.copyMessageAria': '复制消息文本',
|
||||
'chat.messageBody.actions.copyMessage': '复制消息',
|
||||
'chat.messageBody.actions.openPreviewAria': '打开预览',
|
||||
'chat.messageBody.actions.openPreview': '打开预览',
|
||||
'chat.messageBody.actions.copyAnswer': '复制回答',
|
||||
'chat.messageBody.actions.savingImage': '正在保存图片...',
|
||||
'chat.messageBody.actions.saveAsImage': '保存为图片',
|
||||
@@ -1383,6 +1400,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.openSessionFirst': '请先打开一个会话',
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '切换权限自动接受失败',
|
||||
'chat.chatInput.reviewComments': '审查评论:',
|
||||
'chat.chatInput.devServerLogs': 'Dev Server 日志:',
|
||||
'chat.chatInput.devServerLogsRemove': '移除 Dev Server 日志',
|
||||
'chat.chatInput.previewAnnotations': '预览注释:',
|
||||
'chat.chatInput.previewContext': '预览上下文:',
|
||||
'chat.chatInput.previewContextRemove': '移除预览上下文',
|
||||
'chat.chatInput.projectRoot': '项目根目录',
|
||||
'chat.chatInput.branch': '分支',
|
||||
'chat.chatInput.worktrees': '工作树',
|
||||
@@ -1763,7 +1785,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'projectActions.actions.addActionAria': '添加操作',
|
||||
'projectActions.actions.addAction': '添加操作',
|
||||
'projectActions.actions.addNewAction': '添加新操作',
|
||||
'projectActions.actions.autoDiscover': '自动发现',
|
||||
'projectActions.actions.chooseActionAria': '选择项目操作',
|
||||
'projectActions.actions.openPreview': '打开 Preview',
|
||||
'projectActions.actions.runNamedAria': '运行 {name}',
|
||||
'projectActions.actions.stopNamedAria': '停止 {name}',
|
||||
'projectActions.label.fallbackAction': '操作',
|
||||
@@ -2092,4 +2116,39 @@ export const dict: Record<I18nKey, string> = {
|
||||
'markdownRenderer.mermaid.actions.copySourceTitle': '复制源码',
|
||||
'markdownRenderer.mermaid.actions.downloadSvgTitle': '下载 SVG',
|
||||
'markdownRenderer.mermaid.toast.downloadFailed': '下载图表失败',
|
||||
'contextPanel.preview.title': '预览',
|
||||
'contextPanel.preview.description': '使用项目操作或终端 Preview 按钮打开预览。',
|
||||
'contextPanel.preview.startPreview': '启动预览',
|
||||
'contextPanel.preview.starting': '正在启动...',
|
||||
'contextPanel.preview.noDevServer': '未找到开发服务器命令。请配置项目操作或添加 "dev" 脚本到 package.json。',
|
||||
'contextPanel.preview.startFailed': '启动预览服务器失败。',
|
||||
'contextPanel.preview.serverExited': '开发服务器意外退出。',
|
||||
'contextPanel.preview.noUrlDetected': '开发服务器已启动,但未在输出中检测到 URL。请打开预览终端标签页查看日志。',
|
||||
'contextPanel.preview.serverExitedWithLog': '开发服务器意外退出。最后输出:\n\n{log}',
|
||||
'contextPanel.preview.noUrlDetectedWithLog': '开发服务器未能响应。最后输出:\n\n{log}',
|
||||
'contextPanel.preview.startingServer': '正在启动开发服务器...',
|
||||
'contextPanel.preview.startingServerHint': '正在等待服务器开始接受连接。',
|
||||
'contextPanel.preview.console.open': '打开预览控制台',
|
||||
'contextPanel.preview.console.waiting': '正在等待预览控制台',
|
||||
'contextPanel.preview.console.title': '预览控制台',
|
||||
'contextPanel.preview.console.attach': '附加',
|
||||
'contextPanel.preview.console.copy': '复制',
|
||||
'contextPanel.preview.console.clear': '清除',
|
||||
'contextPanel.preview.console.empty': '还没有预览控制台事件。',
|
||||
'contextPanel.preview.console.noFilteredEvents': '没有匹配此筛选器的事件。',
|
||||
'contextPanel.preview.console.runtimeError': '运行时错误',
|
||||
'contextPanel.preview.console.copied': '预览控制台已复制',
|
||||
'contextPanel.preview.console.copyFailed': '无法复制预览控制台',
|
||||
'contextPanel.preview.console.attached': '预览控制台已附加到聊天',
|
||||
'contextPanel.preview.console.attachNoSession': '请先打开聊天会话,再附加预览日志',
|
||||
'contextPanel.preview.console.attachAnnotation': '这些是此项目开发服务器的浏览器控制台日志。',
|
||||
'contextPanel.preview.inspect.toggle': '检查预览元素',
|
||||
'contextPanel.preview.inspect.attached': '预览注释已附加到聊天',
|
||||
'contextPanel.preview.inspect.attachNoSession': '请先打开聊天会话,再附加预览注释',
|
||||
'contextPanel.preview.inspect.attachAnnotation': '这是内置预览中选中的 DOM 元素。',
|
||||
'contextPanel.preview.inspect.attachAnnotationWithScreenshot': '这是内置预览中选中的 DOM 元素。已附加带有高亮选中元素的可见预览区域截图。',
|
||||
'contextPanel.preview.console.filter.all': '全部',
|
||||
'contextPanel.preview.console.filter.errors': '错误',
|
||||
'contextPanel.preview.console.filter.warnings': '警告',
|
||||
'contextPanel.preview.console.filter.logs': '日志',
|
||||
};
|
||||
|
||||
@@ -11,6 +11,14 @@ export function formatInlineCommentDraft(draft: InlineCommentDraft): string {
|
||||
if (draft.source === 'diff' && side) {
|
||||
return `Comment on \`${fileLabel}\` lines ${startLine}-${endLine} (${side}):\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
|
||||
}
|
||||
|
||||
if (draft.source === 'preview-console') {
|
||||
return `Attached preview context from \`${fileLabel}\`:\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
|
||||
}
|
||||
|
||||
if (draft.source === 'preview-annotation') {
|
||||
return text ? `${code}\n\n${text}` : code;
|
||||
}
|
||||
|
||||
// Plan and file format (no side)
|
||||
return `Comment on \`${fileLabel}\` lines ${startLine}-${endLine}:\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
|
||||
@@ -22,7 +30,11 @@ export function formatInlineCommentDraft(draft: InlineCommentDraft): string {
|
||||
*/
|
||||
export function formatInlineCommentDrafts(drafts: InlineCommentDraft[]): string {
|
||||
if (drafts.length === 0) return '';
|
||||
|
||||
|
||||
if (drafts.every((draft) => draft.source === 'preview-annotation')) {
|
||||
return drafts.map(formatInlineCommentDraft).join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
return drafts.map(formatInlineCommentDraft).join('\n\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,12 @@ const readTextFile = async (path: string): Promise<string | null> => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(path)}`);
|
||||
const response = await fetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(path)}`,
|
||||
{
|
||||
// Avoid conditional requests (304 + empty body).
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
@@ -201,7 +206,10 @@ const resolveHomeDirectory = async (): Promise<string | null> => {
|
||||
// In some runtimes, window.__OPENCHAMBER_HOME__ can be workspace/project-root
|
||||
// scoped, which would incorrectly route writes into the project directory.
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/fs/home`);
|
||||
const response = await fetch(`${getBaseUrl()}/fs/home`, {
|
||||
// Avoid conditional requests (304 + empty body).
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to resolve home directory from API');
|
||||
}
|
||||
|
||||
@@ -28,6 +28,57 @@ export const isExternalHttpUrl = (url: string): boolean => {
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
};
|
||||
|
||||
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']);
|
||||
|
||||
/**
|
||||
* Returns true when the URL is an http(s) URL pointing at a loopback host
|
||||
* (localhost, 127.0.0.1, 0.0.0.0, ::1). Used to decide whether to offer an in-app
|
||||
* preview pane instead of opening the system browser.
|
||||
*/
|
||||
export const isLoopbackHttpUrl = (url: string): boolean => {
|
||||
const parsed = parseUrlSafely(url.trim());
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
return LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase());
|
||||
};
|
||||
|
||||
const LOOPBACK_URL_PATTERN
|
||||
// eslint-disable-next-line no-control-regex
|
||||
= /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d{2,5})?(?:\/[^\s<>"'`\u0000-\u001f]*)?/gi;
|
||||
|
||||
/**
|
||||
* Extracts loopback http(s) URLs from a free-text string. Returns unique URLs
|
||||
* in order of first appearance. Trailing punctuation that is unlikely to be
|
||||
* part of a real URL is stripped.
|
||||
*/
|
||||
export const extractLoopbackUrls = (text: string): string[] => {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const matches = text.match(LOOPBACK_URL_PATTERN);
|
||||
if (!matches || matches.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of matches) {
|
||||
const cleaned = raw.replace(/[),.;:!?'"`]+$/g, '');
|
||||
if (!cleaned || !isLoopbackHttpUrl(cleaned)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(cleaned)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(cleaned);
|
||||
out.push(cleaned);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens an external URL in the system browser.
|
||||
* In Tauri desktop runtime, uses tauri.shell.open() for proper handling.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -268,6 +268,7 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||
let activeTransport: "ws" | "sse" = transport === "ws" ? "ws" : "sse"
|
||||
let attemptAbortReason: AttemptAbortReason = null
|
||||
let consecutiveFailures = 0
|
||||
|
||||
const notifyDisconnected = (reason: string) => {
|
||||
if (disconnected) {
|
||||
@@ -279,6 +280,7 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
|
||||
const markConnected = () => {
|
||||
disconnected = false
|
||||
consecutiveFailures = 0
|
||||
// Fire onReconnect on every successful connect — including the very
|
||||
// first one. Consumer state (isConnected) starts at false and needs
|
||||
// to be flipped positively; without this the send button throws
|
||||
@@ -382,9 +384,10 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
let opened = false
|
||||
let readyAt = 0
|
||||
const socket = new WebSocket(buildGlobalEventWsUrl(lastEventId))
|
||||
const setFallbackCode = (error: Error) => {
|
||||
if (!opened && transport === "auto") {
|
||||
const setFallbackCode = (error: Error, force = false) => {
|
||||
if ((force || !opened) && transport === "auto") {
|
||||
wsFallbackUntil = Date.now() + WS_FALLBACK_WINDOW_MS
|
||||
;(error as Error & { code?: string }).code = "WS_FALLBACK"
|
||||
}
|
||||
@@ -441,7 +444,8 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
signal.addEventListener("abort", handleAbort, { once: true })
|
||||
|
||||
socket.onopen = () => {
|
||||
streamErrorLogged = false
|
||||
// Don't clear streamErrorLogged here. If the socket immediately closes
|
||||
// before sending the ready frame, clearing would cause log spam.
|
||||
}
|
||||
|
||||
socket.onmessage = (messageEvent) => {
|
||||
@@ -462,10 +466,12 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
|
||||
if (frame.type === "ready") {
|
||||
opened = true
|
||||
readyAt = Date.now()
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer)
|
||||
readyTimer = undefined
|
||||
}
|
||||
streamErrorLogged = false
|
||||
markConnected()
|
||||
return
|
||||
}
|
||||
@@ -517,7 +523,12 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
;(error as Error & { reason?: string }).reason = opened
|
||||
? `ws_closed:code=${event?.code ?? "?"}`
|
||||
: "ws_closed_before_ready"
|
||||
setFallbackCode(error)
|
||||
|
||||
// If the WS stream connects (ready) but then drops quickly, prefer SSE for a while.
|
||||
// This avoids tight reconnect loops with repeated console spam.
|
||||
const livedMs = readyAt > 0 ? Date.now() - readyAt : 0
|
||||
const unstableAfterReady = opened && livedMs > 0 && livedMs < 2_000
|
||||
setFallbackCode(error, unstableAfterReady)
|
||||
settleReject(error)
|
||||
}
|
||||
})
|
||||
@@ -567,6 +578,7 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
// a full directory resync.
|
||||
onTransportSwitch?.()
|
||||
} else if (!isAbortError(error)) {
|
||||
consecutiveFailures += 1
|
||||
if (!streamErrorLogged) {
|
||||
streamErrorLogged = true
|
||||
console.error("[event-pipeline] stream failed", error)
|
||||
@@ -587,6 +599,10 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
? `${currentTransport}_error:${message.slice(0, 80)}`
|
||||
: `${currentTransport}_error:unknown`
|
||||
notifyDisconnected(reason)
|
||||
|
||||
// Backoff so a hard-down server doesn't spin the browser event loop.
|
||||
// Cap at 5s; reset occurs in markConnected().
|
||||
retryDelayMs = Math.min(5_000, Math.max(retryDelayMs, 250) * (consecutiveFailures <= 1 ? 1 : 2))
|
||||
}
|
||||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
|
||||
Reference in New Issue
Block a user