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
@@ -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>
);
};