feat(terminal): refactor runtime and add mobile workspace (#2280)
Replace the legacy terminal flow with a shared authenticated WebSocket runtime used across web, desktop, relay, and mobile surfaces. - introduce the v3 terminal protocol with scoped attachments, snapshots, ordered output, bounded replay history, reconnects, and explicit lifecycle - harden PTY creation, restart, resize, close, force-kill, idle cleanup, shell selection, login mode, environment sanitization, and appearance sync - add runtime-aware terminal APIs with relay authentication and Electron parity - add a fullscreen mobile terminal workspace with touch scrolling, long-press selection, safe-area controls, quick keys, and Ctrl/Alt input - add terminal selection attachments, preview detection, project actions, shell settings, and localized UI - harden Ghostty rendering, resize recovery, Unicode handling, block characters, line height, and stale-row behavior - remove the obsolete terminal SSE path and update reverse-proxy guidance - expand terminal runtime, transport, input, selection, and store coverage - avoid duplicate web builds when preparing mobile assets in root CI builds
This commit is contained in:
committed by
GitHub
parent
f5b4a267c0
commit
d4a8c4d2e1
@@ -1520,12 +1520,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
let previewConsole = 0;
|
||||
let previewAnnotation = 0;
|
||||
let review = 0;
|
||||
let terminal = 0;
|
||||
for (const draft of drafts) {
|
||||
if (draft.source === 'preview-console') previewConsole += 1;
|
||||
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
|
||||
else if (draft.source === 'terminal') terminal += 1;
|
||||
else review += 1;
|
||||
}
|
||||
return `${previewConsole}:${previewAnnotation}:${review}`;
|
||||
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}`;
|
||||
},
|
||||
[currentSessionId, newSessionDraftOpen]
|
||||
)
|
||||
@@ -1533,7 +1535,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const hasDrafts = draftCount > 0;
|
||||
const [previewConsoleCount, previewAnnotationCount, reviewCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
|
||||
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
|
||||
const terminalContextDrafts = terminalContextCount > 0
|
||||
? (useInlineCommentDraftStore.getState().drafts[currentSessionId ?? (newSessionDraftOpen ? 'draft' : '')] ?? []).filter((draft) => draft.source === 'terminal')
|
||||
: [];
|
||||
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||
if (!sessionKey) return;
|
||||
@@ -1550,7 +1555,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
if (!sessionKey) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? [];
|
||||
for (const draft of drafts) {
|
||||
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation') {
|
||||
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal') {
|
||||
removeInlineCommentDraft(sessionKey, draft.id);
|
||||
}
|
||||
}
|
||||
@@ -2327,6 +2332,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
inputMode,
|
||||
sendMessageOptions,
|
||||
);
|
||||
const restoreConsumedDrafts = () => {
|
||||
if (sessionKey && drafts.length > 0) {
|
||||
useInlineCommentDraftStore.getState().restoreDrafts(sessionKey, drafts);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
scrollToBottom?.();
|
||||
@@ -2354,6 +2364,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const normalized = rawMessage.toLowerCase();
|
||||
|
||||
console.error('Message send failed:', rawMessage || error);
|
||||
restoreConsumedDrafts();
|
||||
|
||||
const currentInput = textareaRef.current?.value ?? messageRef.current;
|
||||
if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) {
|
||||
@@ -4650,6 +4661,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
<AutoReviewBanner />
|
||||
{hasDrafts && (
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{terminalContextDrafts.map((draft) => (
|
||||
<div key={draft.id} className="inline-flex max-w-full items-center gap-1.5 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1" title={draft.code}>
|
||||
<Icon name="terminal" className="h-3.5 w-3.5" />
|
||||
<span className="truncate text-xs font-medium text-[var(--surface-mutedForeground)]">
|
||||
{t('chat.chatInput.terminalContext', { terminal: draft.fileLabel, start: draft.startLine, end: draft.endLine })}
|
||||
</span>
|
||||
<button type="button" className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]" onClick={() => removeInlineCommentDraft(draft.sessionKey, draft.id)} aria-label={t('chat.chatInput.terminalContextRemove')} title={t('chat.chatInput.terminalContextRemove')}>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{reviewCount > 0 ? (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
parseSkillHref,
|
||||
} from '@/lib/messages/inlineMessageLinks';
|
||||
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
|
||||
import { extractTerminalContexts } from '@/lib/messages/terminalContext';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -31,7 +32,9 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text;
|
||||
const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||
const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||
const terminalContextState = React.useMemo(() => extractTerminalContexts(serializedText), [serializedText]);
|
||||
const textContent = terminalContextState.visibleText;
|
||||
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [isTruncated, setIsTruncated] = React.useState(false);
|
||||
@@ -190,7 +193,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
});
|
||||
}, [agentMention, openSkill, skillByName, textContent]);
|
||||
|
||||
if (!textContent || textContent.trim().length === 0) {
|
||||
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -243,6 +246,18 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
plainTextContent
|
||||
)}
|
||||
</div>
|
||||
{terminalContextState.contexts.length > 0 ? (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{terminalContextState.contexts.map((context, index) => (
|
||||
<details key={`${context.terminalLabel}-${context.startLine}-${index}`} className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-1.5 text-xs">
|
||||
<summary className="cursor-pointer text-[var(--surface-mutedForeground)]">
|
||||
{t('chat.message.terminalContext', { terminal: context.terminalLabel, start: context.startLine, end: context.endLine })}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[var(--surface-foreground)]">{context.text}</pre>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user