Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -11,7 +11,6 @@ import type {
|
||||
AgentsResponse,
|
||||
GitMeta,
|
||||
Skill,
|
||||
AskUserAnswer,
|
||||
ToolCostStat,
|
||||
ProviderSnapshotEntry,
|
||||
CoderProvidersFile,
|
||||
@@ -284,17 +283,6 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
|
||||
}),
|
||||
// Batch 9.7: submit answers for a paused ask_user_input call. Server
|
||||
// validates against the question shape, UPDATEs the pending tool row,
|
||||
// publishes the deferred tool_result frame, and enqueues the next turn.
|
||||
answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
|
||||
request<{ tool_message_id: string; assistant_message_id: string }>(
|
||||
`/api/chats/${chatId}/answer_user_input`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
|
||||
},
|
||||
),
|
||||
// v1.13.17-cross-repo-reads: resume a paused request_read_access. On
|
||||
// 'allow' the server re-resolves the grant root and appends it to
|
||||
// sessions.allowed_read_paths; the returned list reflects the post-
|
||||
|
||||
@@ -303,36 +303,6 @@ export interface ViewFileResult {
|
||||
bytes_returned: number;
|
||||
}
|
||||
|
||||
export type PaneKind = 'chat' | 'file_browser';
|
||||
|
||||
export interface FileBrowserPaneState {
|
||||
open_file?: string | null;
|
||||
filter?: string;
|
||||
expanded_dirs?: string[];
|
||||
}
|
||||
export type ChatPaneState = Record<string, never>;
|
||||
export type PaneState = ChatPaneState | FileBrowserPaneState;
|
||||
|
||||
interface PaneBase {
|
||||
id: string;
|
||||
session_id: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
}
|
||||
export type Pane = PaneBase & (
|
||||
| { kind: 'chat'; state: ChatPaneState }
|
||||
| { kind: 'file_browser'; state: FileBrowserPaneState }
|
||||
);
|
||||
|
||||
export interface PaneCreateRequest {
|
||||
kind: PaneKind;
|
||||
position?: number;
|
||||
}
|
||||
export interface PaneUpdateRequest {
|
||||
state?: PaneState;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
// v1.8 mobile-tabs: shape returned by GET /api/projects/:id/git. Mirrors
|
||||
// services/git_meta.ts on the server. branch=null means "not a git repo".
|
||||
export interface GitMeta {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { formatAgo } from '@/lib/format';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
@@ -191,12 +192,3 @@ function formatK(n: number): string {
|
||||
return `${Math.round(n / 1000)}k`;
|
||||
}
|
||||
|
||||
function formatAgo(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '—';
|
||||
const diff = Date.now() - then;
|
||||
if (diff < 60_000) return 'just now';
|
||||
if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`;
|
||||
return `${Math.round(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
63
apps/web/src/components/ArtifactPaneHeader.tsx
Normal file
63
apps/web/src/components/ArtifactPaneHeader.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Check, Copy, Download, X } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
defaultTitle: string;
|
||||
onDownload: () => void;
|
||||
downloadDisabled: boolean;
|
||||
onClose: () => void;
|
||||
// Optional copy button (Markdown pane only; HTML pane omits it).
|
||||
onCopy?: () => void;
|
||||
justCopied?: boolean;
|
||||
copyDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ArtifactPaneHeader({
|
||||
title,
|
||||
defaultTitle,
|
||||
onDownload,
|
||||
downloadDisabled,
|
||||
onClose,
|
||||
onCopy,
|
||||
justCopied,
|
||||
copyDisabled,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||
<span className="text-xs text-muted-foreground truncate flex-1" title={title}>
|
||||
{title || defaultTitle}
|
||||
</span>
|
||||
{onCopy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
disabled={copyDisabled}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Copy source"
|
||||
title="Copy"
|
||||
>
|
||||
{justCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownload}
|
||||
disabled={downloadDisabled}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Download"
|
||||
title="Download"
|
||||
>
|
||||
<Download size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Close artifact pane"
|
||||
title="Close"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,7 @@ interface Props {
|
||||
path: string;
|
||||
content: string;
|
||||
lang: string | null;
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
onNavigate: (path: string) => void;
|
||||
}
|
||||
|
||||
const SHIKI_THEME = 'github-dark';
|
||||
@@ -21,7 +19,11 @@ function splitShikiLines(html: string): string[] {
|
||||
if (!match) return [];
|
||||
const inner = match[1]!;
|
||||
const lines = inner.split(/(?=<span class="line">)/);
|
||||
return lines.filter(l => l.trim().length > 0);
|
||||
// Keep every line segment (including blank `<span class="line"></span>`
|
||||
// entries) so lineHtmls.length matches the raw line count and line-number
|
||||
// indexing stays in sync. The first element before the first line span is
|
||||
// discarded (it's always empty or whitespace).
|
||||
return lines.filter(l => l.startsWith('<span class="line">'));
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
// sessions.workspace_panes jsonb stays small and message_parts.payload is the
|
||||
// single source of truth.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { HtmlArtifactState } from '@/api/types';
|
||||
import { ArtifactPaneHeader } from './ArtifactPaneHeader';
|
||||
import { useArtifactDownload } from '@/hooks/useArtifactDownload';
|
||||
|
||||
interface Props {
|
||||
chatId: string;
|
||||
@@ -22,9 +22,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export function HtmlArtifactPane({ chatId, state, onClose }: Props) {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [htmlContent, setHtmlContent] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const { downloading, download } = useArtifactDownload(chatId, state.message_id, 'html');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -45,53 +45,15 @@ export function HtmlArtifactPane({ chatId, state, onClose }: Props) {
|
||||
};
|
||||
}, [chatId, state.message_id]);
|
||||
|
||||
async function download() {
|
||||
if (downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { url, path } = await api.messages.downloadArtifact(
|
||||
chatId,
|
||||
state.message_id,
|
||||
'html',
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.rel = 'noopener';
|
||||
a.click();
|
||||
toast.success(`Saved to ${path}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'download failed');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
|
||||
{state.title || 'HTML artifact'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void download()}
|
||||
disabled={downloading || htmlContent === null}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Download HTML"
|
||||
title="Download"
|
||||
>
|
||||
<Download size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Close artifact pane"
|
||||
title="Close"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<ArtifactPaneHeader
|
||||
title={state.title}
|
||||
defaultTitle="HTML artifact"
|
||||
onDownload={() => void download()}
|
||||
downloadDisabled={downloading || htmlContent === null}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 overflow-hidden bg-background">
|
||||
{loadError ? (
|
||||
<div className="p-4 text-sm text-destructive">Failed to load: {loadError}</div>
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
// the matching message_id. This keeps sessions.workspace_panes jsonb small
|
||||
// and the assistant message row remains the single source of truth.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, Copy, Download, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { MarkdownArtifactState } from '@/api/types';
|
||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||
import { ArtifactPaneHeader } from './ArtifactPaneHeader';
|
||||
import { useArtifactDownload } from '@/hooks/useArtifactDownload';
|
||||
|
||||
interface Props {
|
||||
chatId: string;
|
||||
@@ -22,9 +23,9 @@ interface Props {
|
||||
|
||||
export function MarkdownArtifactPane({ chatId, state, onClose }: Props) {
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const { downloading, download } = useArtifactDownload(chatId, state.message_id, 'md');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -64,65 +65,18 @@ export function MarkdownArtifactPane({ chatId, state, onClose }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { url, path } = await api.messages.downloadArtifact(
|
||||
chatId,
|
||||
state.message_id,
|
||||
'md',
|
||||
);
|
||||
// Trigger browser download from the returned URL. The endpoint stamps
|
||||
// Content-Disposition: attachment so the click lands as a save.
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.rel = 'noopener';
|
||||
a.click();
|
||||
toast.success(`Saved to ${path}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'download failed');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
|
||||
{state.title || 'Markdown artifact'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy()}
|
||||
disabled={content === null}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Copy markdown source"
|
||||
title="Copy"
|
||||
>
|
||||
{justCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void download()}
|
||||
disabled={downloading || content === null}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Download markdown"
|
||||
title="Download"
|
||||
>
|
||||
<Download size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Close artifact pane"
|
||||
title="Close"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<ArtifactPaneHeader
|
||||
title={state.title}
|
||||
defaultTitle="Markdown artifact"
|
||||
onDownload={() => void download()}
|
||||
downloadDisabled={downloading || content === null}
|
||||
onClose={onClose}
|
||||
onCopy={() => void copy()}
|
||||
justCopied={justCopied}
|
||||
copyDisabled={content === null}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 overflow-auto px-4 py-3 text-sm">
|
||||
{loadError ? (
|
||||
<div className="text-destructive">Failed to load: {loadError}</div>
|
||||
|
||||
@@ -2,53 +2,13 @@
|
||||
// in-chat bubble renderer and the MarkdownArtifactPane share the same Shiki +
|
||||
// remark-gfm + path-linkifier pipeline. Behavior preserved byte-for-byte from
|
||||
// the original MessageBubble.MarkdownBody helper (and its linkify helpers).
|
||||
import { Children, cloneElement, isValidElement } from 'react';
|
||||
import { memo, Children, cloneElement, isValidElement } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
|
||||
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
|
||||
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
|
||||
// match, but `src/foo.ts` will). False positives at the edges are accepted
|
||||
// per Sam's design decision (2026-05-14).
|
||||
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
|
||||
|
||||
function isPathLike(s: string): boolean {
|
||||
return s.includes('/');
|
||||
}
|
||||
|
||||
function emitOpenFile(path: string): void {
|
||||
sessionEvents.emit({ type: 'open_file_in_browser', path });
|
||||
}
|
||||
|
||||
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
|
||||
const out: ReactNode[] = [];
|
||||
let lastIdx = 0;
|
||||
let idx = 0;
|
||||
for (const match of text.matchAll(PATH_REGEX)) {
|
||||
const matchedText = match[0];
|
||||
const start = match.index ?? 0;
|
||||
if (!isPathLike(matchedText)) continue;
|
||||
if (start > lastIdx) out.push(text.slice(lastIdx, start));
|
||||
out.push(
|
||||
<button
|
||||
key={`${keyPrefix}-${idx}`}
|
||||
type="button"
|
||||
onClick={() => emitOpenFile(matchedText)}
|
||||
className="text-primary underline cursor-pointer hover:text-primary/80"
|
||||
>
|
||||
{matchedText}
|
||||
</button>
|
||||
);
|
||||
lastIdx = start + matchedText.length;
|
||||
idx += 1;
|
||||
}
|
||||
if (out.length === 0) return text;
|
||||
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
||||
return out;
|
||||
}
|
||||
import { linkifyPaths } from '@/lib/linkify-paths';
|
||||
|
||||
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
|
||||
const arr = Children.toArray(children);
|
||||
@@ -92,57 +52,58 @@ const codeRenderer = (props: { children?: unknown; className?: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export function MarkdownRenderer({ content }: { content: string }) {
|
||||
return (
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
code: codeRenderer,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-5 space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
|
||||
p: ({ children }) => (
|
||||
<p className="leading-relaxed">{linkifyChildren(children)}</p>
|
||||
),
|
||||
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-border px-2 py-1">
|
||||
{linkifyChildren(children)}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
// Hoisted to module level — closes over nothing render-scoped, so a stable
|
||||
// reference avoids react-markdown reconstructing its vdom on every token delta.
|
||||
const MARKDOWN_COMPONENTS: Components = {
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
code: codeRenderer,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-5 space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
|
||||
p: ({ children }) => (
|
||||
<p className="leading-relaxed">{linkifyChildren(children)}</p>
|
||||
),
|
||||
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-border px-2 py-1">
|
||||
{linkifyChildren(children)}
|
||||
</td>
|
||||
),
|
||||
};
|
||||
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) {
|
||||
return (
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
|
||||
{content}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain, History, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -708,7 +708,7 @@ function MistakeRecoverySentinel({ message }: { message: Message }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
export const MessageBubble = memo(function MessageBubble({
|
||||
message,
|
||||
sessionChats,
|
||||
capHitInfo,
|
||||
@@ -717,7 +717,7 @@ export function MessageBubble({
|
||||
hasCheckpoint,
|
||||
restoreDisabled,
|
||||
}: Props) {
|
||||
const hiddenSet = new Set(hideActions ?? []);
|
||||
const hiddenSet = useMemo(() => new Set(hideActions ?? []), [hideActions]);
|
||||
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
|
||||
// branch because summary=true never coexists with kind='compact' (new
|
||||
// compactions emit role='assistant' rows with kind='message'+summary=true).
|
||||
@@ -846,4 +846,4 @@ export function MessageBubble({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
||||
import type { SidebarProject, WorktreeRiskReport } from '@/api/types';
|
||||
import { giteaUrlFor } from '@/lib/projectUrls';
|
||||
import { isCoderSessionName } from '@/lib/coder-session';
|
||||
import { relTime } from '@/lib/format';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const EXPANDED_KEY = 'boocode.sidebar.expanded';
|
||||
@@ -54,22 +55,6 @@ function writeExpanded(ids: Set<string>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function relTime(iso: string): string {
|
||||
const now = Date.now();
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return '';
|
||||
const sec = Math.max(0, Math.floor((now - t) / 1000));
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day < 30) return `${day}d`;
|
||||
const mo = Math.floor(day / 30);
|
||||
if (mo < 12) return `${mo}mo`;
|
||||
return `${Math.floor(mo / 12)}y`;
|
||||
}
|
||||
|
||||
function activeProjectId(
|
||||
pathname: string,
|
||||
|
||||
@@ -274,9 +274,7 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
path={viewerFile.path}
|
||||
content={viewerFile.content}
|
||||
lang={inferLanguage(viewerFile.path)}
|
||||
projectId={projectId}
|
||||
onClose={() => setViewerFile(null)}
|
||||
onNavigate={(path) => void openFile(path)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { api } from '@/api/client';
|
||||
import type { Chat } from '@/api/types';
|
||||
import { formatRelative } from '@/lib/format';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
@@ -34,19 +35,6 @@ interface Props {
|
||||
onDeleteChat: (chatId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const s = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||
if (s < 60) return 'just now';
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.round(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function byRecent(a: Chat, b: Chat): number {
|
||||
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import type { TouchEvent } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onTap: () => void;
|
||||
onClose: () => void;
|
||||
canClose: boolean;
|
||||
}
|
||||
|
||||
const CLOSE_THRESHOLD = 60;
|
||||
const MAX_TRAVEL = 120;
|
||||
const VERTICAL_BAIL = 30;
|
||||
|
||||
// Pane tab with horizontal swipe-to-close (mobile only). Tracks horizontal
|
||||
// finger movement; if vertical exceeds VERTICAL_BAIL the gesture is cancelled
|
||||
// (so vertical scroll still works). On release past CLOSE_THRESHOLD, the
|
||||
// onClose callback fires. Otherwise the tab snaps back. Hand-rolled per spec.
|
||||
export function SwipeablePaneTab({ label, isActive, onTap, onClose, canClose }: Props) {
|
||||
const [translateX, setTranslateX] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const startRef = useRef<{ x: number; y: number; bailed: boolean } | null>(null);
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (!canClose) return;
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
startRef.current = { x: t.clientX, y: t.clientY, bailed: false };
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
const start = startRef.current;
|
||||
if (!start || start.bailed) return;
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
const dx = t.clientX - start.x;
|
||||
const dy = t.clientY - start.y;
|
||||
if (Math.abs(dy) > VERTICAL_BAIL) {
|
||||
start.bailed = true;
|
||||
setTranslateX(0);
|
||||
setDragging(false);
|
||||
return;
|
||||
}
|
||||
if (dx < 0) {
|
||||
setTranslateX(Math.max(dx, -MAX_TRAVEL));
|
||||
} else {
|
||||
setTranslateX(0);
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
const start = startRef.current;
|
||||
startRef.current = null;
|
||||
setDragging(false);
|
||||
if (!start || start.bailed) {
|
||||
setTranslateX(0);
|
||||
return;
|
||||
}
|
||||
const tx = translateX;
|
||||
if (tx <= -CLOSE_THRESHOLD) {
|
||||
onClose();
|
||||
// Don't reset translateX; the parent will unmount this tab.
|
||||
} else {
|
||||
setTranslateX(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Opacity fades from 1 -> 0.4 as the tab approaches the close threshold.
|
||||
const opacity =
|
||||
translateX < 0
|
||||
? Math.max(0.4, 1 - (Math.abs(translateX) / CLOSE_THRESHOLD) * 0.6)
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTap}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchCancel={onTouchEnd}
|
||||
style={{
|
||||
transform: `translateX(${translateX}px)`,
|
||||
opacity,
|
||||
// Only animate when releasing (snap-back); during drag the transform
|
||||
// tracks the finger 1:1 for a tight feel.
|
||||
transition: dragging ? undefined : 'transform 0.15s ease, opacity 0.15s ease',
|
||||
}}
|
||||
className={cn(
|
||||
'shrink-0 px-3 py-2 text-xs rounded min-h-[44px] min-w-[44px]',
|
||||
isActive
|
||||
? 'bg-background text-foreground border'
|
||||
: 'text-muted-foreground hover:bg-muted/40',
|
||||
)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
>
|
||||
<span className="truncate max-w-[140px] inline-block">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check, ChevronRight, Loader2, X } from 'lucide-react';
|
||||
import type { ToolCall, ToolResult } from '@/api/types';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { linkifyPaths } from '@/lib/linkify-paths';
|
||||
|
||||
// v1.8.2: cap on the inline arg-summary length. Expanded view shows full
|
||||
// args + full result, so this is purely a single-line render budget.
|
||||
@@ -22,7 +21,7 @@ function truncate(s: string, n: number): string {
|
||||
// Per-tool argument summary mapping from the v1.8.2 spec. Goal is a single
|
||||
// scannable line that surfaces the *what* (path / pattern) without
|
||||
// overwhelming the chat with full JSON.
|
||||
export function formatToolArgs(name: string, args: Record<string, unknown>): string {
|
||||
function formatToolArgs(name: string, args: Record<string, unknown>): string {
|
||||
if (name === 'view_file') {
|
||||
const path = String(args.path ?? '');
|
||||
const start = args.start_line;
|
||||
@@ -98,36 +97,8 @@ export function runStatus(run: ToolRun): 'pending' | 'success' | 'error' {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
// Path-shaped paths in tool output text get a click handler so users can
|
||||
// jump to the file. Same heuristic as MessageBubble.linkifyPaths.
|
||||
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
|
||||
function linkifyOutput(text: string): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
let lastIdx = 0;
|
||||
let idx = 0;
|
||||
for (const match of text.matchAll(PATH_REGEX)) {
|
||||
const matchedText = match[0];
|
||||
const start = match.index ?? 0;
|
||||
if (!matchedText.includes('/')) continue;
|
||||
if (start > lastIdx) out.push(text.slice(lastIdx, start));
|
||||
out.push(
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
sessionEvents.emit({ type: 'open_file_in_browser', path: matchedText })
|
||||
}
|
||||
className="text-primary underline cursor-pointer hover:text-primary/80"
|
||||
>
|
||||
{matchedText}
|
||||
</button>
|
||||
);
|
||||
lastIdx = start + matchedText.length;
|
||||
idx += 1;
|
||||
}
|
||||
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
||||
return out.length > 0 ? out : [text];
|
||||
}
|
||||
// Path-shaped substrings in tool output get a click handler via the shared
|
||||
// linkifyPaths util (lib/linkify-paths.tsx).
|
||||
|
||||
interface Props {
|
||||
run: ToolRun;
|
||||
@@ -189,7 +160,7 @@ export function ToolCallLine({ run, insideGroup }: Props) {
|
||||
{run.result.error ? (
|
||||
<span className="text-destructive">{run.result.error}</span>
|
||||
) : (
|
||||
linkifyOutput(
|
||||
linkifyPaths(
|
||||
typeof run.result.output === 'string'
|
||||
? run.result.output
|
||||
: JSON.stringify(run.result.output, null, 2)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Terminal, Clipboard } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||
import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||
import { activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { terminalsRegistry } from '@/lib/events';
|
||||
@@ -32,7 +32,6 @@ interface Props {
|
||||
project: Project | null;
|
||||
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
|
||||
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||
onCoderConnectedChange?: (paneId: string, connected: boolean) => void;
|
||||
}
|
||||
|
||||
export function Workspace({
|
||||
@@ -44,7 +43,6 @@ export function Workspace({
|
||||
chatsHook,
|
||||
session,
|
||||
project,
|
||||
onCoderConnectedChange,
|
||||
onAddPane,
|
||||
}: Props) {
|
||||
const {
|
||||
@@ -141,10 +139,6 @@ export function Workspace({
|
||||
return out;
|
||||
}, [panes]);
|
||||
|
||||
// Per-coder-pane WS connection (status dot lives in the pane header).
|
||||
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
||||
const [coderLabels, setCoderLabels] = useState<Record<string, string>>({});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div
|
||||
@@ -195,41 +189,21 @@ export function Workspace({
|
||||
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||
>
|
||||
{/* Hidden on mobile per v1.8; settings + terminal panes own
|
||||
their own header (no chats, so no ChatTabBar). */}
|
||||
{!isMobile && !isChromeless && (
|
||||
{/* Hidden on mobile; settings/terminal/artifact panes own their
|
||||
own header. Chat and coder panes share this strip — tabKind
|
||||
and onNewTab differ between the two. */}
|
||||
{!isMobile && (isCoder || !isChromeless) && (
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={chatsForPane(pane)}
|
||||
tabKind={isCoder ? 'coder' : undefined}
|
||||
tabNumbers={tabNumbers}
|
||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
||||
onCloseAll={() => closeAllTabs(idx)}
|
||||
onNewTab={() => void createChat(idx)}
|
||||
onSplitPane={(kind) => onAddPane(kind)}
|
||||
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
||||
onShowHistory={() => showLandingPage(idx)}
|
||||
onRename={renameChat}
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
)}
|
||||
{/* Coder panes host BooCode tabs (one chat = one agent context,
|
||||
all sharing the session worktree). "+" adds a tab; the split
|
||||
button adds a pane. Same tab strip as chat panes (tabKind). */}
|
||||
{isCoder && !isMobile && (
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={chatsForPane(pane)}
|
||||
tabKind="coder"
|
||||
tabNumbers={tabNumbers}
|
||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
||||
onCloseAll={() => closeAllTabs(idx)}
|
||||
onNewTab={() => void createCoderTab(idx)}
|
||||
onNewTab={isCoder ? () => void createCoderTab(idx) : () => void createChat(idx)}
|
||||
onSplitPane={(kind) => onAddPane(kind)}
|
||||
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
||||
onShowHistory={() => showLandingPage(idx)}
|
||||
@@ -296,17 +270,6 @@ export function Workspace({
|
||||
chatId={activePaneChatId(pane)}
|
||||
chatPending={isPaneChatPending(pane.id)}
|
||||
projectPath={project?.path}
|
||||
onConnectedChange={(connected) => {
|
||||
setCoderConnected((prev) =>
|
||||
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
|
||||
);
|
||||
onCoderConnectedChange?.(pane.id, connected);
|
||||
}}
|
||||
onAgentLabelChange={(label) =>
|
||||
setCoderLabels((prev) =>
|
||||
prev[pane.id] === label ? prev : { ...prev, [pane.id]: label },
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
|
||||
<MarkdownArtifactPane
|
||||
|
||||
@@ -25,7 +25,8 @@ interface Props {
|
||||
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled }: Props) {
|
||||
const stream = useSessionStream(sessionId);
|
||||
const lastErrorRef = useRef<string | null>(null);
|
||||
const [queue, setQueue] = useState<string[]>([]);
|
||||
const [queue, setQueue] = useState<{ id: string; text: string }[]>([]);
|
||||
const queueIdRef = useRef(0);
|
||||
const processingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -84,7 +85,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
||||
processingRef.current = true;
|
||||
const next = queue[0]!;
|
||||
setQueue((prev) => prev.slice(1));
|
||||
api.messages.send(chatId, next)
|
||||
api.messages.send(chatId, next.text)
|
||||
.catch((err) => toast.error(err instanceof Error ? err.message : 'queue send failed'))
|
||||
.finally(() => { processingRef.current = false; });
|
||||
}, [streaming, queue, chatId]);
|
||||
@@ -101,7 +102,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
||||
return;
|
||||
}
|
||||
if (streaming) {
|
||||
setQueue((prev) => [...prev, trimmed]);
|
||||
setQueue((prev) => [...prev, { id: String(++queueIdRef.current), text: trimmed }]);
|
||||
return;
|
||||
}
|
||||
await api.messages.send(chatId, trimmed);
|
||||
@@ -185,18 +186,18 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
||||
// into ChatInput via sendToChat. ChatInput appends (or sets, if empty) and
|
||||
// focuses; user re-sends, which re-queues if streaming is still active.
|
||||
function editQueued(idx: number) {
|
||||
const msg = queue[idx];
|
||||
if (!msg) return;
|
||||
const item = queue[idx];
|
||||
if (!item) return;
|
||||
setQueue((prev) => prev.filter((_, i) => i !== idx));
|
||||
sendToChat.emit({ chat_id: chatId, text: msg });
|
||||
sendToChat.emit({ chat_id: chatId, text: item.text });
|
||||
}
|
||||
|
||||
async function forceSendQueued(idx: number) {
|
||||
const msg = queue[idx];
|
||||
if (!msg) return;
|
||||
const item = queue[idx];
|
||||
if (!item) return;
|
||||
setQueue((prev) => prev.filter((_, i) => i !== idx));
|
||||
try {
|
||||
await api.chats.forceSend(chatId, msg);
|
||||
await api.chats.forceSend(chatId, item.text);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'force send failed');
|
||||
}
|
||||
@@ -211,10 +212,10 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
||||
{queue.length > 0 && (
|
||||
<div className="border-t">
|
||||
<div className="max-w-[1000px] mx-auto w-full px-4 py-1 space-y-1">
|
||||
{queue.map((msg, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/30 rounded px-2 py-1">
|
||||
{queue.map((item, i) => (
|
||||
<div key={item.id} className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/30 rounded px-2 py-1">
|
||||
<span className="font-medium shrink-0">Queued:</span>
|
||||
<span className="truncate flex-1">{msg}</span>
|
||||
<span className="truncate flex-1">{item.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editQueued(i)}
|
||||
|
||||
@@ -266,9 +266,6 @@ function SessionSection({ session, project }: { session: Session; project: Proje
|
||||
Inherit project default ({project.default_web_search_enabled ? 'on' : 'off'})
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Plumbed for Batch 8 (web_search tool). No effect yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AllowedReadPathsSection session={session} />
|
||||
@@ -532,7 +529,7 @@ function ProjectSection({ project }: { project: Project }) {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Applies to new sessions only. Plumbed for Batch 8.
|
||||
Applies to new sessions only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
155
apps/web/src/components/panes/terminal/FloatingMenu.tsx
Normal file
155
apps/web/src/components/panes/terminal/FloatingMenu.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { type ChatInputRegistration } from '@/lib/events';
|
||||
|
||||
// ============================================================
|
||||
// FloatingMenu — kept from v1.10.4 (mobile long-press + desktop right-click)
|
||||
// ============================================================
|
||||
interface FloatingMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
hasSelection: boolean;
|
||||
chatInputs: ChatInputRegistration[];
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onSelectAll: () => void;
|
||||
onSearch: () => void;
|
||||
onSendToChat: (chatId: string) => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
export function FloatingMenu({
|
||||
x,
|
||||
y,
|
||||
hasSelection,
|
||||
chatInputs,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onSelectAll,
|
||||
onSearch,
|
||||
onSendToChat,
|
||||
onDismiss,
|
||||
}: FloatingMenuProps) {
|
||||
const [submenu, setSubmenu] = useState(false);
|
||||
const MENU_W = 200;
|
||||
const MENU_H = 220;
|
||||
const left = Math.min(x, window.innerWidth - MENU_W - 8);
|
||||
const top = Math.min(y, window.innerHeight - MENU_H - 8);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(ev: KeyboardEvent): void {
|
||||
if (ev.key === 'Escape') onDismiss();
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onDismiss]);
|
||||
|
||||
const flatChat = chatInputs.length === 1 ? chatInputs[0] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-term-menu="1"
|
||||
role="menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top,
|
||||
left,
|
||||
background: '#1a1d24',
|
||||
border: '1px solid #2a2d34',
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
fontSize: 14,
|
||||
minWidth: MENU_W,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
zIndex: 50,
|
||||
color: '#d6deeb',
|
||||
}}
|
||||
>
|
||||
<MenuItem disabled={!hasSelection} onClick={onCopy}>Copy</MenuItem>
|
||||
<MenuItem onClick={onPaste}>Paste</MenuItem>
|
||||
<MenuItem onClick={onSelectAll}>Select All</MenuItem>
|
||||
<MenuItem onClick={onSearch}>Search</MenuItem>
|
||||
{flatChat && (
|
||||
<MenuItem disabled={!hasSelection} onClick={() => onSendToChat(flatChat.chatId)}>
|
||||
Send to {flatChat.label}
|
||||
</MenuItem>
|
||||
)}
|
||||
{chatInputs.length > 1 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasSelection}
|
||||
onClick={() => setSubmenu((v) => !v)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
minHeight: 44,
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
color: hasSelection ? '#d6deeb' : '#575656',
|
||||
cursor: hasSelection ? 'pointer' : 'not-allowed',
|
||||
borderRadius: 6,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span>Send to chat</span>
|
||||
{submenu ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
{submenu && hasSelection && (
|
||||
<div style={{ paddingLeft: 8 }}>
|
||||
{chatInputs.map((c) => (
|
||||
<MenuItem key={c.chatId} onClick={() => onSendToChat(c.chatId)}>
|
||||
{c.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: 1, background: '#2a2d34', margin: '4px 0' }} />
|
||||
<MenuItem onClick={onDismiss}>Dismiss</MenuItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
minHeight: 44,
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
color: disabled ? '#575656' : '#d6deeb',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
textAlign: 'left',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
}}
|
||||
onMouseEnter={(ev) => {
|
||||
if (!disabled) (ev.currentTarget as HTMLButtonElement).style.background = '#2a2d34';
|
||||
}}
|
||||
onMouseLeave={(ev) => {
|
||||
(ev.currentTarget as HTMLButtonElement).style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
163
apps/web/src/components/panes/terminal/SearchBar.tsx
Normal file
163
apps/web/src/components/panes/terminal/SearchBar.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { SearchAddon } from '@xterm/addon-search';
|
||||
import { ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||
import { type TermTheme } from './theme';
|
||||
|
||||
// ============================================================
|
||||
// SearchBar — kept from v1.10.4
|
||||
// ============================================================
|
||||
interface SearchBarProps {
|
||||
searchRef: React.MutableRefObject<SearchAddon | null>;
|
||||
theme: TermTheme;
|
||||
onClose: () => void;
|
||||
}
|
||||
export function SearchBar({ searchRef, theme, onClose }: SearchBarProps) {
|
||||
const [q, setQ] = useState('');
|
||||
const [counts, setCounts] = useState<{ idx: number; total: number }>({ idx: -1, total: 0 });
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const addon = searchRef.current;
|
||||
if (!addon) return;
|
||||
const sub = addon.onDidChangeResults(({ resultIndex, resultCount }) => {
|
||||
setCounts({ idx: resultIndex, total: resultCount });
|
||||
});
|
||||
return () => sub.dispose();
|
||||
}, [searchRef]);
|
||||
useEffect(() => {
|
||||
const addon = searchRef.current;
|
||||
if (!addon) return;
|
||||
if (q.length === 0) {
|
||||
addon.clearDecorations?.();
|
||||
setCounts({ idx: -1, total: 0 });
|
||||
return;
|
||||
}
|
||||
addon.findNext(q, {
|
||||
incremental: true,
|
||||
decorations: {
|
||||
matchBackground: theme.selectionBackground,
|
||||
matchOverviewRuler: theme.cursor,
|
||||
activeMatchBackground: theme.cursor,
|
||||
activeMatchColorOverviewRuler: theme.cursor,
|
||||
},
|
||||
});
|
||||
}, [q, searchRef, theme]);
|
||||
|
||||
function findNext(): void {
|
||||
if (!q) return;
|
||||
searchRef.current?.findNext(q);
|
||||
}
|
||||
function findPrev(): void {
|
||||
if (!q) return;
|
||||
searchRef.current?.findPrevious(q);
|
||||
}
|
||||
function onKey(ev: React.KeyboardEvent<HTMLInputElement>): void {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
if (ev.shiftKey) findPrev();
|
||||
else findNext();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: '#1a1d24',
|
||||
border: '1px solid #2a2d34',
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(ev) => setQ(ev.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder="Search…"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
outline: 'none',
|
||||
color: '#d6deeb',
|
||||
padding: '8px 8px',
|
||||
fontSize: 13,
|
||||
width: 160,
|
||||
minHeight: 36,
|
||||
}}
|
||||
/>
|
||||
{q.length > 0 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: counts.total === 0 ? '#ef5350' : '#575656',
|
||||
minWidth: 56,
|
||||
textAlign: 'right',
|
||||
padding: '0 4px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{counts.total === 0
|
||||
? 'No match'
|
||||
: counts.idx === -1
|
||||
? `${counts.total}+`
|
||||
: `${counts.idx + 1} of ${counts.total}`}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={findPrev}
|
||||
aria-label="Previous match"
|
||||
title="Previous (Shift+Enter)"
|
||||
style={iconBtnStyle}
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={findNext}
|
||||
aria-label="Next match"
|
||||
title="Next (Enter)"
|
||||
style={iconBtnStyle}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close search"
|
||||
title="Close (Esc)"
|
||||
style={iconBtnStyle}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const iconBtnStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 44,
|
||||
height: 44,
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
color: '#d6deeb',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 6,
|
||||
};
|
||||
115
apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx
Normal file
115
apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Maximize2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ============================================================
|
||||
// TerminalHotkeyBar — v1.10.8d port of boolab's TerminalHotkeyBar.jsx +
|
||||
// terminalHotkeysStore.js DEFAULT_BAR. The catalog is hardcoded inline (no
|
||||
// zustand store, no settings UI) — single-user homelab doesn't need either.
|
||||
// Add new entries by extending HOTKEY_BAR below.
|
||||
// ============================================================
|
||||
type Hotkey =
|
||||
| { id: string; label: string; bytes: string; sticky?: undefined }
|
||||
| { id: string; label: string; sticky: 'ctrl'; bytes?: undefined };
|
||||
|
||||
const HOTKEY_BAR: Hotkey[] = [
|
||||
{ id: 'esc', label: 'Esc', bytes: '\x1b' },
|
||||
{ id: 'shift-tab', label: '⇧Tab', bytes: '\x1b[Z' },
|
||||
{ id: 'tab', label: 'Tab', bytes: '\t' },
|
||||
{ id: 'ctrl', label: 'Ctrl', sticky: 'ctrl' },
|
||||
{ id: 'ctrl-c', label: 'Ctrl+C', bytes: '\x03' },
|
||||
{ id: 'arrow-up', label: '↑', bytes: '\x1b[A' },
|
||||
{ id: 'arrow-down', label: '↓', bytes: '\x1b[B' },
|
||||
{ id: 'arrow-left', label: '←', bytes: '\x1b[D' },
|
||||
{ id: 'arrow-right', label: '→', bytes: '\x1b[C' },
|
||||
];
|
||||
|
||||
interface TerminalHotkeyBarProps {
|
||||
ctrlArmed: boolean;
|
||||
onSendBytes: (bytes: string) => void;
|
||||
onArmCtrl: () => void;
|
||||
onFit: () => void;
|
||||
}
|
||||
|
||||
export function TerminalHotkeyBar({
|
||||
ctrlArmed,
|
||||
onSendBytes,
|
||||
onArmCtrl,
|
||||
onFit,
|
||||
}: TerminalHotkeyBarProps) {
|
||||
// Stop the touch from reaching the terminal pane below (which calls
|
||||
// preventDefault on touchmove to suppress page-scroll). Without this a
|
||||
// tap-and-drag on a hotkey button would also scroll the terminal buffer.
|
||||
const stopTouch = useCallback((e: React.TouchEvent) => e.stopPropagation(), []);
|
||||
const press = useCallback(
|
||||
(entry: Hotkey) => {
|
||||
if (entry.sticky === 'ctrl') {
|
||||
onArmCtrl();
|
||||
} else if (entry.bytes !== undefined) {
|
||||
onSendBytes(entry.bytes);
|
||||
}
|
||||
},
|
||||
[onArmCtrl, onSendBytes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Terminal hotkeys"
|
||||
className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-border bg-muted/30 px-2 py-1"
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
// Suppress iOS native swipe-back gesture starting on the bar; pinch
|
||||
// and other multi-touch gestures still pass through.
|
||||
touchAction: 'pan-x',
|
||||
}}
|
||||
onTouchStart={stopTouch}
|
||||
onTouchMove={stopTouch}
|
||||
>
|
||||
{HOTKEY_BAR.map((entry) => {
|
||||
const isCtrl = entry.sticky === 'ctrl';
|
||||
const armed = isCtrl && ctrlArmed;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => press(entry)}
|
||||
aria-pressed={isCtrl ? armed : undefined}
|
||||
aria-label={entry.label}
|
||||
className={cn(
|
||||
'shrink-0 rounded border px-2 py-0.5 text-xs font-mono transition-colors',
|
||||
armed
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border text-foreground hover:bg-muted',
|
||||
)}
|
||||
style={{
|
||||
minHeight: 28,
|
||||
minWidth: 36,
|
||||
WebkitTouchCallout: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFit}
|
||||
aria-label="Fit terminal"
|
||||
title="Fit terminal to container"
|
||||
className="shrink-0 inline-flex items-center justify-center rounded border border-border text-foreground hover:bg-muted"
|
||||
style={{
|
||||
minHeight: 28,
|
||||
minWidth: 36,
|
||||
paddingInline: 8,
|
||||
WebkitTouchCallout: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/components/panes/terminal/theme.ts
Normal file
32
apps/web/src/components/panes/terminal/theme.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// xterm color theme + background, shared by the Terminal construction
|
||||
// (TerminalPane) and the SearchBar decorations. Extracted verbatim from the
|
||||
// pre-Phase-9 TerminalPane.
|
||||
|
||||
// Terminal background matches the pane container's `bg-[#0b0f14]` so any
|
||||
// sub-cell rounding remainder is invisible. Update both if the theme changes.
|
||||
export const TERM_BG = '#0b0f14';
|
||||
|
||||
export const XTERM_THEME = {
|
||||
background: TERM_BG,
|
||||
foreground: '#d6deeb',
|
||||
cursor: '#82aaff',
|
||||
selectionBackground: '#1d3b53',
|
||||
black: '#011627',
|
||||
red: '#ef5350',
|
||||
green: '#22da6e',
|
||||
yellow: '#c5e478',
|
||||
blue: '#82aaff',
|
||||
magenta: '#c792ea',
|
||||
cyan: '#7fdbca',
|
||||
white: '#d6deeb',
|
||||
brightBlack: '#575656',
|
||||
brightRed: '#ef5350',
|
||||
brightGreen: '#22da6e',
|
||||
brightYellow: '#ffeb95',
|
||||
brightBlue: '#82aaff',
|
||||
brightMagenta: '#c792ea',
|
||||
brightCyan: '#7fdbca',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
export type TermTheme = typeof XTERM_THEME;
|
||||
179
apps/web/src/hooks/terminal/useTerminalFit.ts
Normal file
179
apps/web/src/hooks/terminal/useTerminalFit.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
|
||||
// useTerminalFit — terminal measurement + the refit triggers that are purely
|
||||
// about sizing (fonts.ready rAF, 150ms settle timeout, ResizeObserver,
|
||||
// visualViewport resize). The two refit triggers that are entangled with the
|
||||
// connection lifecycle — the init rAF (fit → /start → connect) and the
|
||||
// visibilitychange handler (reconnect-or-refit) — live in useTerminalSocket
|
||||
// and call this hook's `fit()`; that keeps each of those single-rAF / if-else
|
||||
// sequences intact rather than splitting one listener across two hooks.
|
||||
//
|
||||
// Exposes a stable `fit()` (the FitAddon-bypass measurement, with the iOS
|
||||
// keyboard clip) and `getSize()`/`setSize()` over the read-latest size mirror.
|
||||
|
||||
// xterm 5 ships no public dimensions API — `_core._renderService.dimensions`
|
||||
// is internal but stable across the package versions we ship. This is the ONE
|
||||
// place that reaches into the private cell metrics; everything else funnels
|
||||
// through `cellSize` or `readCellMetrics`.
|
||||
function readCellMetrics(term: Terminal): { w: number; h: number } | null {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dims = (term as any)._core?._renderService?.dimensions?.css?.cell;
|
||||
if (dims && dims.width > 0 && dims.height > 0) {
|
||||
return { w: dims.width, h: dims.height };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cell metrics with a getBoundingClientRect fallback for callers (touch
|
||||
// point→cell mapping) that need an estimate even before xterm has measured.
|
||||
// `fit()` deliberately does NOT use this fallback — it bails until real
|
||||
// metrics exist so it never resizes against a fallback estimate.
|
||||
export function cellSize(term: Terminal, container: HTMLElement): { w: number; h: number } {
|
||||
const m = readCellMetrics(term);
|
||||
if (m) return m;
|
||||
const rect = container.getBoundingClientRect();
|
||||
return { w: rect.width / Math.max(term.cols, 1), h: rect.height / Math.max(term.rows, 1) };
|
||||
}
|
||||
|
||||
interface FitDeps {
|
||||
termRef: React.MutableRefObject<Terminal | null>;
|
||||
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
export interface TerminalFit {
|
||||
fit: () => void;
|
||||
getSize: () => { cols: number; rows: number };
|
||||
setSize: (cols: number, rows: number) => void;
|
||||
}
|
||||
|
||||
export function useTerminalFit({ termRef, containerRef, sessionId, paneId }: FitDeps): TerminalFit {
|
||||
// Last size pushed through term.onResize. Mirrors xterm's authoritative
|
||||
// cols/rows; consulted on every WS open so the resize frame carries the
|
||||
// currently-measured size, not a stale lastSize from before the latest fit.
|
||||
const lastSizeRef = useRef<{ cols: number; rows: number }>({ cols: 80, rows: 24 });
|
||||
|
||||
const getSize = useCallback(() => lastSizeRef.current, []);
|
||||
const setSize = useCallback((cols: number, rows: number) => {
|
||||
lastSizeRef.current = { cols, rows };
|
||||
}, []);
|
||||
|
||||
// Bypass FitAddon's proposeDimensions(), which subtracts the native
|
||||
// scrollbar's reserved width even after CSS hides it (boolab fix). We
|
||||
// compute cols/rows directly from host.clientWidth/Height ÷ cell metrics.
|
||||
// Falls through (no-op) if cell metrics aren't measured yet — a later
|
||||
// refit (fonts.ready / setTimeout / ResizeObserver) will catch it.
|
||||
//
|
||||
// v1.10.8d: row count uses the VISIBLE height (clipped against the
|
||||
// visualViewport) rather than raw clientHeight. h-dvh on the root only
|
||||
// shrinks for the URL bar — not the iOS keyboard — so without the clip
|
||||
// we allocate rows for the area hidden behind the keyboard, and bash's
|
||||
// cursor (pushed down by MOTD on SSH login) ends up below the fold.
|
||||
const fit = useCallback((): void => {
|
||||
const t = termRef.current;
|
||||
if (!t) return;
|
||||
const host = containerRef.current;
|
||||
if (!host) return;
|
||||
if (!t.element || !(t.element as HTMLElement).offsetParent) return;
|
||||
const m = readCellMetrics(t);
|
||||
if (!m) return;
|
||||
const cellW = m.w;
|
||||
const cellH = m.h;
|
||||
let visibleHeight = host.clientHeight;
|
||||
const vp2 = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||
if (vp2) {
|
||||
const rect = host.getBoundingClientRect();
|
||||
// visualViewport.height is the layout viewport minus the keyboard.
|
||||
// rect.bottom is in layout-viewport coordinates. clip to whichever
|
||||
// is smaller. offsetTop/pageTop adjustments aren't needed at zoom=1.
|
||||
const visibleBottom = Math.min(rect.bottom, vp2.height);
|
||||
const clipped = Math.max(0, visibleBottom - rect.top);
|
||||
if (clipped > 0) visibleHeight = clipped;
|
||||
}
|
||||
const cols = Math.max(2, Math.floor(host.clientWidth / cellW));
|
||||
const rows = Math.max(1, Math.floor(visibleHeight / cellH));
|
||||
if (cols !== t.cols || rows !== t.rows) {
|
||||
try {
|
||||
t.resize(cols, rows);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [termRef, containerRef]);
|
||||
|
||||
// Pure-fit refit triggers. Re-run on session/pane change so a recreated
|
||||
// terminal gets the same fonts.ready / settle / ResizeObserver / vpResize
|
||||
// coverage the pre-split single effect gave it.
|
||||
useEffect(() => {
|
||||
const term = termRef.current;
|
||||
const ctr = containerRef.current;
|
||||
if (!term || !ctr) return;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
// Boolab pattern: term measures cell metrics on open() against whatever
|
||||
// font is registered at that instant; if the font hasn't loaded yet we
|
||||
// get a fallback measurement and the wrong cols. Refit when
|
||||
// document.fonts.ready resolves.
|
||||
let fontRaf: number | null = null;
|
||||
if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready
|
||||
.then(() => {
|
||||
if (disposed) return;
|
||||
fontRaf = requestAnimationFrame(() => fit());
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}
|
||||
|
||||
// Second-pass refit: mobile browsers can settle layout slightly late,
|
||||
// causing the rAF fit to measure a stale clientWidth.
|
||||
const delayedFit = setTimeout(() => fit(), 150);
|
||||
|
||||
// ResizeObserver: refit on any container size change. No debounce —
|
||||
// term.onResize itself is the throttle (no-op when cols/rows unchanged).
|
||||
const ro = new ResizeObserver(() => {
|
||||
fit();
|
||||
});
|
||||
ro.observe(ctr);
|
||||
|
||||
// v1.10.8d: visualViewport.resize fires when the iOS keyboard opens or
|
||||
// closes — the layout viewport stays full-height (so our h-dvh root
|
||||
// doesn't shrink), but the visual viewport contracts above the keyboard.
|
||||
// Refit so xterm's row count matches the visible area, then scroll to
|
||||
// bottom so the cursor stays above the keyboard fold (boolab pattern).
|
||||
let onVpResize: (() => void) | null = null;
|
||||
const vp = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||
if (vp) {
|
||||
onVpResize = (): void => {
|
||||
if (disposed) return;
|
||||
fit();
|
||||
const t = termRef.current;
|
||||
if (t) {
|
||||
try {
|
||||
t.scrollToBottom();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
vp.addEventListener('resize', onVpResize);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (fontRaf !== null) cancelAnimationFrame(fontRaf);
|
||||
clearTimeout(delayedFit);
|
||||
ro.disconnect();
|
||||
if (vp && onVpResize) vp.removeEventListener('resize', onVpResize);
|
||||
};
|
||||
// sessionId/paneId drive re-run on terminal recreation (term is rebuilt by
|
||||
// the orchestrator on those deps); fit is stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, paneId, fit]);
|
||||
|
||||
return { fit, getSize, setSize };
|
||||
}
|
||||
349
apps/web/src/hooks/terminal/useTerminalSelection.ts
Normal file
349
apps/web/src/hooks/terminal/useTerminalSelection.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
chatInputsRegistry,
|
||||
sendToChat,
|
||||
terminalsRegistry,
|
||||
type ChatInputRegistration,
|
||||
} from '@/lib/events';
|
||||
import { cellSize } from './useTerminalFit';
|
||||
|
||||
// useTerminalSelection — the touch long-press selection + word-range gesture
|
||||
// subsystem, the document pointer/contextmenu handlers, clipboard custom keys,
|
||||
// the terminal + chat-input registries, and the floating-menu actions. All of
|
||||
// it is independent of the WS/fit path and was kept verbatim from v1.10.4.
|
||||
|
||||
const LONG_PRESS_MS = 500;
|
||||
const LONG_PRESS_TOLERANCE_PX = 10;
|
||||
|
||||
function pointToCell(
|
||||
term: Terminal,
|
||||
container: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { col: number; bufferRow: number } {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const { w, h } = cellSize(term, container);
|
||||
const localX = Math.max(0, clientX - rect.left);
|
||||
const localY = Math.max(0, clientY - rect.top);
|
||||
const col = Math.min(term.cols - 1, Math.floor(localX / Math.max(w, 1)));
|
||||
const screenRow = Math.min(term.rows - 1, Math.floor(localY / Math.max(h, 1)));
|
||||
const bufferRow = term.buffer.active.viewportY + screenRow;
|
||||
return { col, bufferRow };
|
||||
}
|
||||
|
||||
const WORD_RE = /[\w.~$\-/]+/g;
|
||||
function wordRangeAt(line: string, col: number): { start: number; end: number } | null {
|
||||
for (const m of line.matchAll(WORD_RE)) {
|
||||
const start = m.index ?? 0;
|
||||
const end = start + m[0].length;
|
||||
if (col >= start && col < end) return { start, end };
|
||||
if (start > col) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SelectionDeps {
|
||||
termRef: React.MutableRefObject<Terminal | null>;
|
||||
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
label: string;
|
||||
send: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface TerminalSelectionActions {
|
||||
copy: () => void;
|
||||
paste: () => void;
|
||||
selectAll: () => void;
|
||||
search: () => void;
|
||||
sendToChat: (chatId: string) => void;
|
||||
}
|
||||
|
||||
export interface TerminalSelection {
|
||||
menu: { x: number; y: number } | null;
|
||||
setMenu: React.Dispatch<React.SetStateAction<{ x: number; y: number } | null>>;
|
||||
searchOpen: boolean;
|
||||
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
chatInputs: ChatInputRegistration[];
|
||||
hasSelection: boolean;
|
||||
actions: TerminalSelectionActions;
|
||||
}
|
||||
|
||||
export function useTerminalSelection({
|
||||
termRef,
|
||||
containerRef,
|
||||
sessionId,
|
||||
paneId,
|
||||
label,
|
||||
send,
|
||||
}: SelectionDeps): TerminalSelection {
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [chatInputs, setChatInputs] = useState<ChatInputRegistration[]>([]);
|
||||
|
||||
const pasteFromClipboard = useCallback((): void => {
|
||||
if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
|
||||
toast.error('Paste blocked — long-press input area instead');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
if (!text) return;
|
||||
send(text);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Paste blocked — long-press input area instead');
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
// ============================================================
|
||||
// v1.10.4 features (long-press menu, right-click, custom keys)
|
||||
// Kept verbatim — independent of the WS/fit path that v1.10.8c fixes.
|
||||
// Re-bound on session/pane change so the gesture closures reference the
|
||||
// recreated terminal.
|
||||
// ============================================================
|
||||
useEffect(() => {
|
||||
const termInit = termRef.current;
|
||||
const ctrInit = containerRef.current;
|
||||
if (!termInit || !ctrInit) return;
|
||||
// Non-null bindings so the gesture closures below keep the narrowing.
|
||||
const term: Terminal = termInit;
|
||||
const ctr: HTMLDivElement = ctrInit;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if (e.type !== 'keydown') return true;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod) return true;
|
||||
const isC = e.key === 'c' || e.key === 'C';
|
||||
const isV = e.key === 'v' || e.key === 'V';
|
||||
const isF = e.key === 'f' || e.key === 'F';
|
||||
if (isC) {
|
||||
if (term.hasSelection()) {
|
||||
const sel = term.getSelection();
|
||||
if (sel) {
|
||||
navigator.clipboard.writeText(sel).catch(() => {
|
||||
toast.error('Clipboard write failed');
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return !e.shiftKey;
|
||||
}
|
||||
if (isV) {
|
||||
pasteFromClipboard();
|
||||
return false;
|
||||
}
|
||||
if (isF) {
|
||||
setSearchOpen(true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Long-press selection + floating menu (touch).
|
||||
let lpTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lpStart: { x: number; y: number } | null = null;
|
||||
let lpAnchor: { col: number; bufferRow: number } | null = null;
|
||||
let inSelection = false;
|
||||
function clearLp(): void {
|
||||
if (lpTimer !== null) {
|
||||
clearTimeout(lpTimer);
|
||||
lpTimer = null;
|
||||
}
|
||||
lpStart = null;
|
||||
}
|
||||
function selectWord(col: number, bufferRow: number): boolean {
|
||||
const line = term.buffer.active.getLine(bufferRow);
|
||||
if (!line) return false;
|
||||
const text = line.translateToString(true);
|
||||
const range = wordRangeAt(text, col);
|
||||
if (!range) return false;
|
||||
term.select(range.start, bufferRow, range.end - range.start);
|
||||
return true;
|
||||
}
|
||||
function extendSelection(
|
||||
anchor: { col: number; bufferRow: number },
|
||||
to: { col: number; bufferRow: number },
|
||||
): void {
|
||||
const a = anchor;
|
||||
const b = to;
|
||||
let s: { col: number; row: number };
|
||||
let e: { col: number; row: number };
|
||||
if (a.bufferRow < b.bufferRow || (a.bufferRow === b.bufferRow && a.col <= b.col)) {
|
||||
s = { col: a.col, row: a.bufferRow };
|
||||
e = { col: b.col, row: b.bufferRow };
|
||||
} else {
|
||||
s = { col: b.col, row: b.bufferRow };
|
||||
e = { col: a.col, row: a.bufferRow };
|
||||
}
|
||||
const rowsBetween = e.row - s.row;
|
||||
const length = rowsBetween * term.cols + (e.col - s.col) + 1;
|
||||
term.select(s.col, s.row, length);
|
||||
}
|
||||
function onTouchStart(e: TouchEvent): void {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0]!;
|
||||
if ((e.target as Element | null)?.closest('[data-term-menu]')) return;
|
||||
lpStart = { x: t.clientX, y: t.clientY };
|
||||
lpAnchor = pointToCell(term, ctr, t.clientX, t.clientY);
|
||||
inSelection = false;
|
||||
lpTimer = setTimeout(() => {
|
||||
if (disposed || !lpAnchor) return;
|
||||
const ok = selectWord(lpAnchor.col, lpAnchor.bufferRow);
|
||||
if (!ok) return;
|
||||
inSelection = true;
|
||||
setMenu({ x: t.clientX, y: Math.max(t.clientY - 50, 8) });
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
function onTouchMove(e: TouchEvent): void {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0]!;
|
||||
if (inSelection && lpAnchor) {
|
||||
e.preventDefault();
|
||||
const to = pointToCell(term, ctr, t.clientX, t.clientY);
|
||||
extendSelection(lpAnchor, to);
|
||||
return;
|
||||
}
|
||||
if (!lpStart) return;
|
||||
const dx = t.clientX - lpStart.x;
|
||||
const dy = t.clientY - lpStart.y;
|
||||
if (Math.abs(dx) > LONG_PRESS_TOLERANCE_PX || Math.abs(dy) > LONG_PRESS_TOLERANCE_PX) {
|
||||
clearLp();
|
||||
}
|
||||
}
|
||||
function onTouchEnd(): void {
|
||||
if (!inSelection) clearLp();
|
||||
inSelection = false;
|
||||
}
|
||||
function onTouchCancel(): void {
|
||||
clearLp();
|
||||
inSelection = false;
|
||||
}
|
||||
ctr.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
ctr.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
ctr.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
ctr.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
||||
|
||||
function onContextMenu(e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
setMenu({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
ctr.addEventListener('contextmenu', onContextMenu);
|
||||
|
||||
function onDocPointerDown(e: PointerEvent): void {
|
||||
const t = e.target as Element | null;
|
||||
if (t && t.closest('[data-term-menu]')) return;
|
||||
setMenu(null);
|
||||
}
|
||||
document.addEventListener('pointerdown', onDocPointerDown);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (lpTimer !== null) clearTimeout(lpTimer);
|
||||
document.removeEventListener('pointerdown', onDocPointerDown);
|
||||
ctr.removeEventListener('touchstart', onTouchStart);
|
||||
ctr.removeEventListener('touchmove', onTouchMove);
|
||||
ctr.removeEventListener('touchend', onTouchEnd);
|
||||
ctr.removeEventListener('touchcancel', onTouchCancel);
|
||||
ctr.removeEventListener('contextmenu', onContextMenu);
|
||||
// attachCustomKeyEventHandler has no disposable; term.dispose() drops it.
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, paneId, send, pasteFromClipboard]);
|
||||
|
||||
// Terminal registry: focus / openSearch / paste hooks for keyboard shortcuts
|
||||
// (Session.tsx) and the pane-header paste button (Workspace.tsx). Keyed on
|
||||
// [paneId, label] ONLY — NOT on sessionId — so a positional `label` renumber
|
||||
// ("Terminal N" after a pane add/remove) refreshes the registry entry
|
||||
// WITHOUT tearing down + reconnecting the live terminal. The focus closure
|
||||
// reads termRef lazily, so it tracks a terminal recreated on a session change
|
||||
// without needing this effect to re-run. (v2 Phase 9 latent-bug fix: `label`
|
||||
// was previously in the xterm-init effect's dep array.)
|
||||
useEffect(() => {
|
||||
const unregister = terminalsRegistry.register(
|
||||
paneId,
|
||||
label,
|
||||
() => {
|
||||
try {
|
||||
termRef.current?.focus();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
() => setSearchOpen(true),
|
||||
() => pasteFromClipboard(),
|
||||
);
|
||||
return () => unregister();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paneId, label, pasteFromClipboard]);
|
||||
|
||||
// Chat-input registry: populates the floating menu's "Send to chat" submenu.
|
||||
// Independent of session/pane/term — subscribe once.
|
||||
useEffect(() => {
|
||||
setChatInputs(chatInputsRegistry.list());
|
||||
const unsubChats = chatInputsRegistry.subscribe(() => {
|
||||
setChatInputs(chatInputsRegistry.list());
|
||||
});
|
||||
return () => unsubChats();
|
||||
}, []);
|
||||
|
||||
function actCopy(): void {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const sel = term.getSelection();
|
||||
if (!sel) {
|
||||
setMenu(null);
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(sel).catch(() => toast.error('Clipboard write failed'));
|
||||
term.clearSelection();
|
||||
setMenu(null);
|
||||
}
|
||||
function actPaste(): void {
|
||||
const reg = terminalsRegistry.get(paneId);
|
||||
reg?.paste();
|
||||
setMenu(null);
|
||||
}
|
||||
function actSelectAll(): void {
|
||||
termRef.current?.selectAll();
|
||||
setMenu(null);
|
||||
}
|
||||
function actSearch(): void {
|
||||
setSearchOpen(true);
|
||||
setMenu(null);
|
||||
}
|
||||
function actSendToChat(chatId: string): void {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const sel = term.getSelection();
|
||||
if (!sel) {
|
||||
setMenu(null);
|
||||
return;
|
||||
}
|
||||
sendToChat.emit({ chat_id: chatId, text: sel });
|
||||
setMenu(null);
|
||||
}
|
||||
|
||||
const hasSelection = !!termRef.current?.getSelection();
|
||||
|
||||
return {
|
||||
menu,
|
||||
setMenu,
|
||||
searchOpen,
|
||||
setSearchOpen,
|
||||
chatInputs,
|
||||
hasSelection,
|
||||
actions: {
|
||||
copy: actCopy,
|
||||
paste: actPaste,
|
||||
selectAll: actSelectAll,
|
||||
search: actSearch,
|
||||
sendToChat: actSendToChat,
|
||||
},
|
||||
};
|
||||
}
|
||||
351
apps/web/src/hooks/terminal/useTerminalSocket.ts
Normal file
351
apps/web/src/hooks/terminal/useTerminalSocket.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { api } from '@/api/client';
|
||||
import { sendToTerminal } from '@/lib/events';
|
||||
import { encodeInput, encodeResize, parseServerFrame } from '@/lib/terminal-protocol';
|
||||
import type { TerminalFit } from './useTerminalFit';
|
||||
|
||||
// useTerminalSocket — owns the WebSocket lifecycle (connect / backoff
|
||||
// reconnect / manual reconnect), the terminal's onData + onResize handlers,
|
||||
// the bootstrap (init rAF: fit → /start → connect), the visibilitychange
|
||||
// reconnect-or-refit, the sendToTerminal subscription, and sticky Ctrl.
|
||||
//
|
||||
// It calls the fit hook's `getSize()` LAZILY (never captures a size value), so
|
||||
// every WS open / init / resize carries the currently-measured size, not a
|
||||
// stale one — preserving the read-latest contract.
|
||||
|
||||
export type ConnState = 'connecting' | 'open' | 'reconnecting' | 'disconnected';
|
||||
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000];
|
||||
|
||||
interface SocketDeps {
|
||||
termRef: React.MutableRefObject<Terminal | null>;
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
fit: TerminalFit['fit'];
|
||||
getSize: TerminalFit['getSize'];
|
||||
setSize: TerminalFit['setSize'];
|
||||
}
|
||||
|
||||
export interface TerminalSocket {
|
||||
connState: ConnState;
|
||||
send: (text: string) => void;
|
||||
reconnect: () => void;
|
||||
ctrlArmed: boolean;
|
||||
armCtrl: () => void;
|
||||
}
|
||||
|
||||
export function useTerminalSocket({
|
||||
termRef,
|
||||
sessionId,
|
||||
paneId,
|
||||
fit,
|
||||
getSize,
|
||||
setSize,
|
||||
}: SocketDeps): TerminalSocket {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttemptRef = useRef(0);
|
||||
const closedByUserRef = useRef(false);
|
||||
const reconnectFnRef = useRef<() => void>(() => {});
|
||||
|
||||
const [connState, setConnState] = useState<ConnState>('connecting');
|
||||
|
||||
// v1.10.8d: sticky Ctrl for the on-screen hotkey bar (boolab pattern).
|
||||
// ctrlArmedRef is read synchronously inside term.onData (per keystroke);
|
||||
// ctrlArmed state mirror exists so the Ctrl button can highlight in React.
|
||||
// Auto-disarms after 5s so a stray tap doesn't quietly mangle the next
|
||||
// keystroke minutes later.
|
||||
const [ctrlArmed, setCtrlArmed] = useState(false);
|
||||
const ctrlArmedRef = useRef(false);
|
||||
const ctrlTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const setCtrlArmedSync = useCallback((armed: boolean) => {
|
||||
ctrlArmedRef.current = armed;
|
||||
setCtrlArmed(armed);
|
||||
if (ctrlTimerRef.current) {
|
||||
clearTimeout(ctrlTimerRef.current);
|
||||
ctrlTimerRef.current = null;
|
||||
}
|
||||
if (armed) {
|
||||
ctrlTimerRef.current = setTimeout(() => {
|
||||
ctrlArmedRef.current = false;
|
||||
setCtrlArmed(false);
|
||||
ctrlTimerRef.current = null;
|
||||
}, 5000);
|
||||
}
|
||||
}, []);
|
||||
const armCtrl = useCallback(() => {
|
||||
setCtrlArmedSync(!ctrlArmedRef.current);
|
||||
}, [setCtrlArmedSync]);
|
||||
|
||||
// sendInput: write to the WS as a binary frame (server-side discriminator
|
||||
// routes binary to PTY, text to JSON control). Used by the hotkey bar and
|
||||
// the selection paste path.
|
||||
const send = useCallback((text: string) => {
|
||||
if (!text) return;
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
ws.send(encodeInput(text));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
reconnectFnRef.current();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
let disposed = false;
|
||||
closedByUserRef.current = false;
|
||||
reconnectAttemptRef.current = 0;
|
||||
|
||||
// boolab pattern: term.onResize fires synchronously inside term.resize()
|
||||
// and on init. We always update lastSizeRef so a fresh WS connect (or
|
||||
// reconnect) carries the current measured size; we send a resize frame
|
||||
// when the WS is open. No gating by an "is-started" flag — the WS itself
|
||||
// is the gate (it ignores writes when not open).
|
||||
term.onResize(({ cols, rows }) => {
|
||||
setSize(cols, rows);
|
||||
if (cols < 2 || rows < 1) return;
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
ws.send(encodeResize(cols, rows));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
// Keystrokes go out as a BINARY frame so the server can disambiguate them
|
||||
// from JSON control frames. TextEncoder is in every modern browser.
|
||||
//
|
||||
// Sticky Ctrl: when armed (via the hotkey bar), apply Ctrl to the next
|
||||
// single ASCII printable (0x40-0x7e — '@', A-Z, [, \, ], ^, _, `, a-z,
|
||||
// {, |, }, ~) and disarm. Multi-byte sequences (arrows, Esc, F-keys) pass
|
||||
// through untouched and disarm so a stuck arm doesn't poison them.
|
||||
term.onData((data) => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
let out = data;
|
||||
if (ctrlArmedRef.current) {
|
||||
if (data.length === 1) {
|
||||
const c = data.charCodeAt(0);
|
||||
if (c >= 0x40 && c <= 0x7e) {
|
||||
out = String.fromCharCode(c & 0x1f);
|
||||
}
|
||||
}
|
||||
setCtrlArmedSync(false);
|
||||
}
|
||||
try {
|
||||
ws.send(encodeInput(out));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
function buildWsUrl(): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const { cols, rows } = getSize();
|
||||
return `${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}?cols=${cols}&rows=${rows}`;
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (disposed || closedByUserRef.current) return;
|
||||
const attempt = reconnectAttemptRef.current;
|
||||
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
|
||||
setConnState('disconnected');
|
||||
return;
|
||||
}
|
||||
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)]!;
|
||||
reconnectAttemptRef.current = attempt + 1;
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
if (disposed || closedByUserRef.current) return;
|
||||
const existing = wsRef.current;
|
||||
if (existing && existing.readyState !== WebSocket.CLOSED) return;
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(buildWsUrl());
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
if (disposed) return;
|
||||
reconnectAttemptRef.current = 0;
|
||||
setConnState('open');
|
||||
// boolab pattern: send our currently-measured size as a resize frame
|
||||
// on every WS open. Server's PTY adopts it immediately; no race
|
||||
// window between attach and resize.
|
||||
const { cols, rows } = getSize();
|
||||
if (cols >= 2 && rows >= 1) {
|
||||
try {
|
||||
ws.send(encodeResize(cols, rows));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (e) => {
|
||||
const t = termRef.current;
|
||||
if (!t) return;
|
||||
if (typeof e.data === 'string') {
|
||||
const frame = parseServerFrame(e.data);
|
||||
if (frame?.type === 'init') {
|
||||
// boolab pattern: wipe any pre-attach buffer state, then the
|
||||
// server's next frame (a single binary capture-pane replay)
|
||||
// will paint the current tmux pane state in cleanly. Re-send
|
||||
// our measured size in case the server's init size (from the
|
||||
// WS query string) is stale relative to a fit that landed
|
||||
// between opening and now.
|
||||
try {
|
||||
t.clear();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const { cols, rows } = getSize();
|
||||
if (cols >= 2 && rows >= 1) {
|
||||
try {
|
||||
ws.send(encodeResize(cols, rows));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame?.type === 'exit') {
|
||||
t.write(`\r\n\x1b[2m[process exited with code ${frame.code}]\x1b[0m\r\n`);
|
||||
return;
|
||||
}
|
||||
t.write(e.data);
|
||||
} else {
|
||||
t.write(new Uint8Array(e.data as ArrayBuffer));
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (disposed) return;
|
||||
wsRef.current = null;
|
||||
if (closedByUserRef.current) return;
|
||||
setConnState('reconnecting');
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
// close fires after; let close own the retry.
|
||||
});
|
||||
}
|
||||
|
||||
function manualReconnect(): void {
|
||||
if (disposed) return;
|
||||
closedByUserRef.current = false;
|
||||
reconnectAttemptRef.current = 0;
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
try {
|
||||
wsRef.current?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setConnState('reconnecting');
|
||||
setTimeout(connect, 50);
|
||||
}
|
||||
reconnectFnRef.current = manualReconnect;
|
||||
|
||||
// Init: rAF-defer one frame so xterm's render service can populate
|
||||
// _renderService.dimensions, then fit → /start (idempotent, sizes
|
||||
// the tmux session) → connect WS. The WS handler also ensureSession's
|
||||
// as belt-and-suspenders, so a /start failure is non-fatal.
|
||||
const initRaf = requestAnimationFrame(() => {
|
||||
if (disposed) return;
|
||||
fit();
|
||||
const { cols, rows } = getSize();
|
||||
api.terminals
|
||||
.start(sessionId, paneId, cols, rows)
|
||||
.catch(() => {
|
||||
/* WS handler will ensureSession itself — non-fatal */
|
||||
})
|
||||
.finally(() => {
|
||||
if (disposed) return;
|
||||
connect();
|
||||
});
|
||||
});
|
||||
|
||||
const onVis = (): void => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
|
||||
manualReconnect();
|
||||
} else {
|
||||
// Refit in case the layout changed while hidden. Single rAF so the
|
||||
// measurement runs after the tab's first composited frame back in
|
||||
// the foreground.
|
||||
requestAnimationFrame(() => {
|
||||
if (disposed) return;
|
||||
fit();
|
||||
});
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
|
||||
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
|
||||
if (pane_id !== paneId) return;
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload = text.endsWith('\n') ? text : `${text}\n`;
|
||||
try {
|
||||
ws.send(encodeInput(payload));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
closedByUserRef.current = true;
|
||||
cancelAnimationFrame(initRaf);
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (ctrlTimerRef.current) {
|
||||
clearTimeout(ctrlTimerRef.current);
|
||||
ctrlTimerRef.current = null;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
unsubscribe();
|
||||
const ws = wsRef.current;
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close(1000, 'unmount');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
// term.onResize / term.onData disposables are cleaned up by term.dispose()
|
||||
// (in the orchestrator's spine effect), matching the pre-split teardown.
|
||||
reconnectFnRef.current = () => {};
|
||||
};
|
||||
// fit/getSize/setSize are stable; sessionId/paneId drive reconnect-on-change
|
||||
// and term-handler rebinding after the orchestrator recreates the terminal.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, paneId, fit, getSize, setSize]);
|
||||
|
||||
return { connState, send, reconnect, ctrlArmed, armCtrl };
|
||||
}
|
||||
26
apps/web/src/hooks/useArtifactDownload.ts
Normal file
26
apps/web/src/hooks/useArtifactDownload.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
|
||||
export function useArtifactDownload(chatId: string, messageId: string, format: 'md' | 'html') {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
async function download() {
|
||||
if (downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { url, path } = await api.messages.downloadArtifact(chatId, messageId, format);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.rel = 'noopener';
|
||||
a.click();
|
||||
toast.success(`Saved to ${path}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'download failed');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { downloading, download };
|
||||
}
|
||||
@@ -69,10 +69,6 @@ export function recordUsage(
|
||||
notify();
|
||||
}
|
||||
|
||||
export function clearThroughput(chatId: string): void {
|
||||
if (entries.delete(chatId)) notify();
|
||||
}
|
||||
|
||||
// Periodic sweep: re-notify so stale entries fall off the UI when the
|
||||
// stream ends without a follow-up frame. Light — one timer for the whole app.
|
||||
const G = globalThis as Record<string, unknown>;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project } from '@/api/types';
|
||||
|
||||
export function useProjects() {
|
||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const list = await api.projects.list();
|
||||
setProjects(list);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'failed to load projects');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const add = useCallback(
|
||||
async (body: { path: string; name?: string }) => {
|
||||
const created = await api.projects.add(body);
|
||||
await refresh();
|
||||
return created;
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await api.projects.remove(id);
|
||||
await refresh();
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
return { projects, error, refresh, add, remove };
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/** User messages are inserted atomically — never stream-append like assistant deltas. */
|
||||
export function applyMessageDelta(
|
||||
role: 'user' | 'assistant' | 'system' | 'tool',
|
||||
existingContent: string,
|
||||
chunk: string,
|
||||
): string {
|
||||
if (role === 'user') {
|
||||
return chunk || existingContent;
|
||||
}
|
||||
return existingContent + chunk;
|
||||
}
|
||||
@@ -7,12 +7,3 @@ export function isCoderSessionName(name: string | null | undefined): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Optimistic coder pane shell before scoped chat id arrives from the server. */
|
||||
export function defaultCoderWorkspacePane(id: string = crypto.randomUUID()) {
|
||||
return {
|
||||
id,
|
||||
kind: 'coder' as const,
|
||||
chatIds: [] as string[],
|
||||
activeChatIdx: -1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +63,3 @@ export function mergeWireToolCall(
|
||||
return [...list, entry];
|
||||
}
|
||||
|
||||
export function wireToolCallsToRuns(wires: CoderToolCallWire[] | undefined): ToolRun[] {
|
||||
return (wires ?? []).map(wireToolCallToRun);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
export function formatTokens(n: number | null | undefined): string | null {
|
||||
if (n === null || n === undefined) return null;
|
||||
if (n < 1000) return `${n} tok`;
|
||||
return `${(n / 1000).toFixed(1)}k tok`;
|
||||
// Short "Xs/m/h/d/mo/y" relative time without "ago" suffix (ProjectSidebar).
|
||||
export function relTime(iso: string): string {
|
||||
const now = Date.now();
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return '';
|
||||
const sec = Math.max(0, Math.floor((now - t) / 1000));
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day < 30) return `${day}d`;
|
||||
const mo = Math.floor(day / 30);
|
||||
if (mo < 12) return `${mo}mo`;
|
||||
return `${Math.floor(mo / 12)}y`;
|
||||
}
|
||||
|
||||
// "just now / Xm ago / Xh ago / Xd ago" with locale-date fallback for >7d
|
||||
// (SessionLandingPage).
|
||||
export function formatRelative(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const s = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||
if (s < 60) return 'just now';
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.round(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
// "just now / Xm ago / Xh ago / Xd ago" with no full-date fallback (AgentPicker).
|
||||
export function formatAgo(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '—';
|
||||
const diff = Date.now() - then;
|
||||
if (diff < 60_000) return 'just now';
|
||||
if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`;
|
||||
return `${Math.round(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
34
apps/web/src/lib/linkify-paths.tsx
Normal file
34
apps/web/src/lib/linkify-paths.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
|
||||
// Match path-shaped substrings ending in `.ext`. Requires a `/` in the match
|
||||
// to reduce false positives in prose (e.g. plain `foo.ts` won't match but
|
||||
// `src/foo.ts` will). False positives at the edges are accepted (2026-05-14).
|
||||
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
|
||||
|
||||
export function linkifyPaths(text: string, keyPrefix = 'p'): ReactNode {
|
||||
const out: ReactNode[] = [];
|
||||
let lastIdx = 0;
|
||||
let idx = 0;
|
||||
for (const match of text.matchAll(PATH_REGEX)) {
|
||||
const matchedText = match[0];
|
||||
const start = match.index ?? 0;
|
||||
if (!matchedText.includes('/')) continue;
|
||||
if (start > lastIdx) out.push(text.slice(lastIdx, start));
|
||||
out.push(
|
||||
<button
|
||||
key={`${keyPrefix}-${idx}`}
|
||||
type="button"
|
||||
onClick={() => sessionEvents.emit({ type: 'open_file_in_browser', path: matchedText })}
|
||||
className="text-primary underline cursor-pointer hover:text-primary/80"
|
||||
>
|
||||
{matchedText}
|
||||
</button>
|
||||
);
|
||||
lastIdx = start + matchedText.length;
|
||||
idx += 1;
|
||||
}
|
||||
if (out.length === 0) return text;
|
||||
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
||||
return out;
|
||||
}
|
||||
46
apps/web/src/lib/terminal-protocol.ts
Normal file
46
apps/web/src/lib/terminal-protocol.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Terminal WebSocket wire protocol (centralized; v2 Phase 9 extraction).
|
||||
//
|
||||
// The booterm WS multiplexes two directions on one socket with a binary/text
|
||||
// discriminator (mirrored server-side in apps/booterm):
|
||||
// - PTY input (keystrokes, paste, hotkey bytes) is sent as a BINARY frame.
|
||||
// - Control frames are JSON text: outbound {type:'resize',cols,rows};
|
||||
// inbound {type:'init'} and {type:'exit',code}.
|
||||
// This module is the single source of that encoding so a server-side protocol
|
||||
// change is mirrored in one place. Behavior is byte-identical to the prior
|
||||
// inline encoding scattered across TerminalPane.
|
||||
|
||||
// TextEncoder is stateless; a single shared instance is equivalent to the
|
||||
// per-call `new TextEncoder()` the inline sites used.
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
// Keystrokes / paste / hotkey bytes go out as a BINARY frame so the server can
|
||||
// disambiguate them from JSON control frames. TextEncoder is in every modern
|
||||
// browser.
|
||||
export function encodeInput(text: string): Uint8Array {
|
||||
return textEncoder.encode(text);
|
||||
}
|
||||
|
||||
// Resize is in-band on the WebSocket as a JSON text frame. The HTTP /resize
|
||||
// endpoint had a race with PTY-map registration; WS frames don't.
|
||||
export function encodeResize(cols: number, rows: number): string {
|
||||
return JSON.stringify({ type: 'resize', cols, rows });
|
||||
}
|
||||
|
||||
export type ServerControlFrame =
|
||||
| { type: 'init' }
|
||||
| { type: 'exit'; code: number };
|
||||
|
||||
// Parse an inbound text frame. Returns a recognized control frame, or `null`
|
||||
// when the text is not JSON or not a known control type — in which case the
|
||||
// caller writes it to the terminal as raw text. Preserves the original
|
||||
// try/catch fall-through: a parse error or an unknown `type` both yield null.
|
||||
export function parseServerFrame(data: string): ServerControlFrame | null {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as { type?: string; code?: number };
|
||||
if (parsed.type === 'init') return { type: 'init' };
|
||||
if (parsed.type === 'exit') return { type: 'exit', code: parsed.code ?? 0 };
|
||||
} catch {
|
||||
/* not JSON — caller writes as text */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -61,7 +61,6 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
initializeFirstChatIfEmpty,
|
||||
validatePanes,
|
||||
} = panesHook;
|
||||
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
||||
const activePane = panes[activePaneIdx];
|
||||
const activeIsCoder = activePane?.kind === 'coder';
|
||||
|
||||
@@ -522,11 +521,6 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
session={session}
|
||||
project={project}
|
||||
onAddPane={addPaneAndSwitch}
|
||||
onCoderConnectedChange={(paneId, connected) =>
|
||||
setCoderConnected((prev) =>
|
||||
prev[paneId] === connected ? prev : { ...prev, [paneId]: connected },
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user