Skip to content

Commit c97d1b9

Browse files
ytahdnytahdn
andauthored
feat(web-shell): polish embedded terminal interactions (#4759)
Co-authored-by: ytahdn <ytahdn@gmail.com>
1 parent 79b94ce commit c97d1b9

16 files changed

Lines changed: 620 additions & 131 deletions

packages/web-shell/client/App.tsx

Lines changed: 154 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import { StreamingStatus } from './components/StreamingStatus';
2727
import { TodoPanel } from './components/panels/TodoPanel';
2828
import { ActiveAgentsPanel } from './components/panels/ActiveAgentsPanel';
2929
import { WelcomeHeader } from './components/WelcomeHeader';
30-
import { ApprovalModeDialog } from './components/dialogs/ApprovalModeDialog';
30+
import {
31+
APPROVAL_MODE_ACTIVE_EVENT,
32+
ApprovalModeMessage,
33+
} from './components/messages/ApprovalModeMessage';
3134
import { ResumeDialog } from './components/dialogs/ResumeDialog';
3235
import {
3336
AGENTS_ACTIVE_EVENT,
@@ -97,13 +100,42 @@ import type {
97100
} from './adapters/types';
98101
import { extractTodosFromToolCall, hasActiveTodos } from './utils/todos';
99102
import { ThemeProvider } from './themeContext';
103+
import {
104+
WebShellCustomizationProvider,
105+
type WebShellMarkdownCustomization,
106+
type ToolHeaderExtraRenderer,
107+
} from './customization';
100108
import styles from './App.module.css';
101109

102110
export const CompactModeContext = createContext(false);
103111

104112
const MODES_CYCLE = DAEMON_APPROVAL_MODES;
105113
const MAX_DISPLAYED_QUEUED_PROMPTS = 3;
106114
const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 240;
115+
const COMPACT_MODE_STORAGE_KEY = 'web-shell:compact-mode';
116+
117+
function loadCompactMode(): boolean {
118+
try {
119+
return window.localStorage.getItem(COMPACT_MODE_STORAGE_KEY) === 'true';
120+
} catch {
121+
return false;
122+
}
123+
}
124+
125+
function saveCompactMode(enabled: boolean) {
126+
try {
127+
window.localStorage.setItem(
128+
COMPACT_MODE_STORAGE_KEY,
129+
enabled ? 'true' : 'false',
130+
);
131+
} catch {
132+
// Ignore storage failures in private browsing or restricted contexts.
133+
}
134+
}
135+
136+
function normalizeHiddenCommand(command: string): string {
137+
return command.trim().replace(/^\/+/, '').toLowerCase();
138+
}
107139

108140
interface QueuedPrompt {
109141
id: number;
@@ -153,6 +185,12 @@ export interface WebShellProps {
153185
onError?: (error: Error) => void;
154186
/** Called when `/bug` is invoked. Receives system info. If omitted, web-shell opens the report URL itself. */
155187
onBugReport?: (info: BugReportInfo) => void;
188+
/** Slash command names to hide from completion/help, for example `['approval-mode']`. */
189+
hiddenSlashCommands?: string[];
190+
/** Custom renderer for the tool-card header content after the status icon and tool name. */
191+
renderToolHeaderExtra?: ToolHeaderExtraRenderer;
192+
/** Custom Markdown behavior for assistant content only. */
193+
markdown?: WebShellMarkdownCustomization;
156194
}
157195

158196
function replaceSessionUrl(sessionId: string): void {
@@ -438,6 +476,9 @@ export function App({
438476
onStreamingStateChange,
439477
onError,
440478
onBugReport,
479+
hiddenSlashCommands,
480+
renderToolHeaderExtra,
481+
markdown,
441482
}: WebShellProps = {}) {
442483
const [selectedLanguage, setSelectedLanguage] = useState<WebShellLanguage>(
443484
() =>
@@ -446,6 +487,10 @@ export function App({
446487
: normalizeLanguage(providedLanguage),
447488
);
448489
const t = useMemo(() => getTranslator(selectedLanguage), [selectedLanguage]);
490+
const customization = useMemo(
491+
() => ({ renderToolHeaderExtra, markdown }),
492+
[renderToolHeaderExtra, markdown],
493+
);
449494
const store = useTranscriptStore();
450495
const blocks = useTranscriptBlocks();
451496
const connection = useConnection();
@@ -568,7 +613,7 @@ export function App({
568613

569614
const [modelInlineMode, setModelInlineMode] =
570615
useState<ModelInlineMode | null>(null);
571-
const [showModeDialog, setShowModeDialog] = useState(false);
616+
const [approvalModeInlineOpen, setApprovalModeInlineOpen] = useState(false);
572617
const [showResumeDialog, setShowResumeDialog] = useState(false);
573618
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
574619
const [showReleaseDialog, setShowReleaseDialog] = useState(false);
@@ -586,6 +631,7 @@ export function App({
586631
const [memoryPortalHost, setMemoryPortalHost] =
587632
useState<HTMLDivElement | null>(null);
588633
const [showShortcuts, setShowShortcuts] = useState(false);
634+
const approvalModePanelActive = usePanelActive(APPROVAL_MODE_ACTIVE_EVENT);
589635
const mcpPanelActive = usePanelActive(MCP_STATUS_ACTIVE_EVENT);
590636
const agentsPanelActive = usePanelActive(AGENTS_ACTIVE_EVENT);
591637
const memoryPanelActive = usePanelActive(MEMORY_ACTIVE_EVENT);
@@ -603,7 +649,6 @@ export function App({
603649
const nextQueuedPromptIdRef = useRef(1);
604650
const drainingQueueRef = useRef(false);
605651
const dialogOpen =
606-
showModeDialog ||
607652
showResumeDialog ||
608653
showDeleteDialog ||
609654
showReleaseDialog ||
@@ -612,6 +657,7 @@ export function App({
612657
showToolsDialog;
613658
const bottomHidden =
614659
dialogOpen ||
660+
approvalModePanelActive ||
615661
mcpPanelActive ||
616662
agentsPanelActive ||
617663
memoryPanelActive ||
@@ -854,7 +900,7 @@ export function App({
854900
setShowShortcuts((prev) => !prev);
855901
}, []);
856902

857-
const [compactMode, setCompactMode] = useState(false);
903+
const [compactMode, setCompactMode] = useState(loadCompactMode);
858904
const compactModeRef = useRef(compactMode);
859905
compactModeRef.current = compactMode;
860906

@@ -869,13 +915,8 @@ export function App({
869915
const handleToggleCompact = useCallback(() => {
870916
const next = !compactModeRef.current;
871917
setCompactMode(next);
872-
store.dispatch([
873-
{
874-
type: 'status',
875-
text: next ? t('compact.enabled') : t('compact.disabled'),
876-
},
877-
]);
878-
}, [store, t]);
918+
saveCompactMode(next);
919+
}, []);
879920

880921
const handleSetMode = useCallback(
881922
(modeId: string) => {
@@ -1192,12 +1233,13 @@ export function App({
11921233
});
11931234
return true;
11941235
}
1195-
if (cmd === 'approval-mode' || cmd === 'mode') {
1236+
if (cmd === 'approval-mode') {
11961237
const modeArg = text.slice(match[0].length).trim();
11971238
if (modeArg) {
11981239
handleSetMode(modeArg);
11991240
} else {
1200-
setShowModeDialog(true);
1241+
store.appendLocalUserMessage(text);
1242+
setApprovalModeInlineOpen(true);
12011243
}
12021244
return true;
12031245
}
@@ -1374,7 +1416,9 @@ export function App({
13741416
return true;
13751417
}
13761418
if (cmd === 'clear') {
1377-
store.reset();
1419+
sessionActions.newSession().catch((error: unknown) => {
1420+
reportError(error, 'Failed to create a new session');
1421+
});
13781422
return true;
13791423
}
13801424
if (cmd === 'new' || cmd === 'reset') {
@@ -1791,13 +1835,17 @@ export function App({
17911835

17921836
const commands = useMemo(() => {
17931837
const skillNames = new Set(connection.skills ?? []);
1794-
return mergeCommands(connection.commands ?? [], getLocalCommands(t)).map(
1795-
(command) =>
1838+
const hidden = new Set(
1839+
(hiddenSlashCommands ?? []).map(normalizeHiddenCommand).filter(Boolean),
1840+
);
1841+
return mergeCommands(connection.commands ?? [], getLocalCommands(t))
1842+
.filter((command) => !hidden.has(normalizeHiddenCommand(command.name)))
1843+
.map((command) =>
17961844
skillNames.has(command.name)
17971845
? { ...command, description: t('skills.run') }
17981846
: command,
1799-
);
1800-
}, [connection.commands, connection.skills, t]);
1847+
);
1848+
}, [connection.commands, connection.skills, hiddenSlashCommands, t]);
18011849

18021850
const welcomeHeader = useMemo(
18031851
() => (
@@ -1893,13 +1941,6 @@ export function App({
18931941
onClose={() => setShowReleaseDialog(false)}
18941942
/>
18951943
)}
1896-
{showModeDialog && (
1897-
<ApprovalModeDialog
1898-
currentMode={currentMode}
1899-
onSelect={handleSetMode}
1900-
onClose={() => setShowModeDialog(false)}
1901-
/>
1902-
)}
19031944
{showHelpDialog && (
19041945
<HelpDialog
19051946
commands={commands}
@@ -1919,81 +1960,96 @@ export function App({
19191960
</div>
19201961
)}
19211962

1922-
<CompactModeContext.Provider value={compactMode}>
1923-
<div
1924-
className={
1925-
floatingTodos.length > 0 || floatingAgents.length > 0
1926-
? `${styles.content} ${styles.contentHasMessages}`
1927-
: styles.content
1928-
}
1929-
style={dialogOpen ? { visibility: 'hidden' } : undefined}
1930-
>
1931-
<MessageList
1932-
messages={displayMessages}
1933-
pendingApproval={pendingApproval}
1934-
onConfirm={handleConfirm}
1935-
catchingUp={connection.catchingUp}
1936-
workspaceCwd={connection.workspaceCwd || ''}
1937-
welcomeHeader={welcomeHeader}
1938-
tailContent={
1939-
agentsInlineMode || memoryInlineOpen || modelInlineMode ? (
1940-
<>
1941-
{modelInlineMode && (
1942-
<ModelMessage
1943-
mode={modelInlineMode}
1944-
onSelect={
1945-
modelInlineMode === 'fast'
1946-
? handleFastModelSelect
1947-
: handleModelSelect
1948-
}
1949-
onClose={() => setModelInlineMode(null)}
1950-
/>
1951-
)}
1952-
{agentsInlineMode && (
1953-
<AgentsMessage
1954-
mode={agentsInlineMode}
1955-
onMessage={(text) =>
1956-
store.dispatch([{ type: 'status', text }])
1957-
}
1958-
onClose={() => setAgentsInlineMode(null)}
1959-
/>
1960-
)}
1961-
{memoryInlineOpen && (
1962-
<MemoryMessage
1963-
refreshSignal={memoryRefreshSignal}
1964-
addSignal={memoryAddSignal}
1965-
addScope={memoryAddScope}
1966-
portalHost={memoryPortalHost}
1967-
onMessage={(text, type = 'status') => {
1968-
store.dispatch([{ type, text }]);
1969-
}}
1970-
onClose={() => setMemoryInlineOpen(false)}
1971-
/>
1972-
)}
1973-
</>
1974-
) : undefined
1975-
}
1976-
tailKey={
1977-
agentsInlineMode || memoryInlineOpen || modelInlineMode
1978-
? `inline-${modelInlineMode ?? 'none'}-${agentsInlineMode ?? 'none'}-${memoryInlineOpen ? 'memory' : 'none'}`
1979-
: undefined
1963+
<WebShellCustomizationProvider value={customization}>
1964+
<CompactModeContext.Provider value={compactMode}>
1965+
<div
1966+
className={
1967+
floatingTodos.length > 0 || floatingAgents.length > 0
1968+
? `${styles.content} ${styles.contentHasMessages}`
1969+
: styles.content
19801970
}
1981-
/>
1982-
1983-
{btwMessage?.role === 'btw' && (
1984-
<div className={styles.btwPanel}>
1985-
<BtwMessage
1986-
question={btwMessage.question}
1987-
answer={btwMessage.answer}
1988-
isPending={btwMessage.isPending}
1989-
/>
1990-
</div>
1991-
)}
1971+
style={dialogOpen ? { visibility: 'hidden' } : undefined}
1972+
>
1973+
<MessageList
1974+
messages={displayMessages}
1975+
pendingApproval={pendingApproval}
1976+
onConfirm={handleConfirm}
1977+
catchingUp={connection.catchingUp}
1978+
workspaceCwd={connection.workspaceCwd || ''}
1979+
welcomeHeader={welcomeHeader}
1980+
tailContent={
1981+
agentsInlineMode ||
1982+
memoryInlineOpen ||
1983+
modelInlineMode ||
1984+
approvalModeInlineOpen ? (
1985+
<>
1986+
{approvalModeInlineOpen && (
1987+
<ApprovalModeMessage
1988+
currentMode={currentMode}
1989+
onSelect={handleSetMode}
1990+
onClose={() => setApprovalModeInlineOpen(false)}
1991+
/>
1992+
)}
1993+
{modelInlineMode && (
1994+
<ModelMessage
1995+
mode={modelInlineMode}
1996+
onSelect={
1997+
modelInlineMode === 'fast'
1998+
? handleFastModelSelect
1999+
: handleModelSelect
2000+
}
2001+
onClose={() => setModelInlineMode(null)}
2002+
/>
2003+
)}
2004+
{agentsInlineMode && (
2005+
<AgentsMessage
2006+
mode={agentsInlineMode}
2007+
onMessage={(text) =>
2008+
store.dispatch([{ type: 'status', text }])
2009+
}
2010+
onClose={() => setAgentsInlineMode(null)}
2011+
/>
2012+
)}
2013+
{memoryInlineOpen && (
2014+
<MemoryMessage
2015+
refreshSignal={memoryRefreshSignal}
2016+
addSignal={memoryAddSignal}
2017+
addScope={memoryAddScope}
2018+
portalHost={memoryPortalHost}
2019+
onMessage={(text, type = 'status') => {
2020+
store.dispatch([{ type, text }]);
2021+
}}
2022+
onClose={() => setMemoryInlineOpen(false)}
2023+
/>
2024+
)}
2025+
</>
2026+
) : undefined
2027+
}
2028+
tailKey={
2029+
agentsInlineMode ||
2030+
memoryInlineOpen ||
2031+
modelInlineMode ||
2032+
approvalModeInlineOpen
2033+
? `inline-${modelInlineMode ?? 'none'}-${agentsInlineMode ?? 'none'}-${memoryInlineOpen ? 'memory' : 'none'}-${approvalModeInlineOpen ? 'approval' : 'none'}`
2034+
: undefined
2035+
}
2036+
/>
19922037

1993-
<StreamingStatus />
1994-
</div>
1995-
<div ref={setMemoryPortalHost} data-web-shell-overlay-root />
1996-
</CompactModeContext.Provider>
2038+
{btwMessage?.role === 'btw' && (
2039+
<div className={styles.btwPanel}>
2040+
<BtwMessage
2041+
question={btwMessage.question}
2042+
answer={btwMessage.answer}
2043+
isPending={btwMessage.isPending}
2044+
/>
2045+
</div>
2046+
)}
2047+
2048+
<StreamingStatus />
2049+
</div>
2050+
<div ref={setMemoryPortalHost} data-web-shell-overlay-root />
2051+
</CompactModeContext.Provider>
2052+
</WebShellCustomizationProvider>
19972053

19982054
<div
19992055
className={

0 commit comments

Comments
 (0)