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:
Bohdan Triapitsyn
2026-07-17 13:17:21 +03:00
committed by GitHub
parent f5b4a267c0
commit d4a8c4d2e1
103 changed files with 4085 additions and 4496 deletions
+25 -3
View File
@@ -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>
);
};