Skip to content

Commit d0170f2

Browse files
committed
🤖 refactor: Integrate init hooks into DisplayedMessage pattern and centralize bash execution
- Added workspace-init DisplayedMessage type with status tracking - Extended StreamingMessageAggregator to convert init events to DisplayedMessage - Created InitMessage component to render init banners in message stream - Removed local init state management from AIView (eliminated parallel infrastructure) - Removed legacy WorkspaceMetaEvent type (no longer used) - Created BashExecutionService to centralize all bash execution - Provides single abstraction point for future host migration (containers, remote, etc.) - Eliminates duplicate environment setup across init hooks and bash tool - executeStreaming() mode for line-by-line output (init hooks) - Updated IpcMain to use BashExecutionService for init hook execution Benefits: - Init events flow through same path as other workspace events - Centralized state management (no local component state) - Single source of truth for bash environment setup - Easier to abstract workspace hosts in future Tests: - Added unit tests for aggregator init handling (2 tests) - All integration tests passing (3/3 init hook tests) - Typecheck passing for both renderer and main processes
1 parent fd4bfa9 commit d0170f2

File tree

12 files changed

+485
-216
lines changed

12 files changed

+485
-216
lines changed

‎src/components/AIView.tsx‎

Lines changed: 14 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ import { useGitStatus } from "@/stores/GitStatusStore";
2929
import { TooltipWrapper, Tooltip } from "./Tooltip";
3030
import type { DisplayedMessage } from "@/types/message";
3131
import { useAIViewKeybinds } from "@/hooks/useAIViewKeybinds";
32-
import type { WorkspaceChatMessage } from "@/types/ipc";
33-
import { isInitStart, isInitOutput, isInitEnd } from "@/types/ipc";
3432

3533
const ViewContainer = styled.div`
3634
flex: 1;
@@ -61,37 +59,6 @@ const ViewHeader = styled.div`
6159
align-items: center;
6260
`;
6361

64-
// Inline banner that streams workspace init hook output without blocking usage
65-
const InitHookBanner = styled.div<{ error?: boolean }>`
66-
background: ${(p) => (p.error ? "#3a1e1e" : "#1e2a3a")};
67-
border-bottom: 1px solid ${(p) => (p.error ? "#653737" : "#2f3f52")};
68-
color: #ddd;
69-
font-family: var(--font-monospace);
70-
font-size: 12px;
71-
padding: 8px 12px;
72-
display: flex;
73-
flex-direction: column;
74-
gap: 6px;
75-
`;
76-
77-
const InitHookHeader = styled.div`
78-
display: flex;
79-
align-items: center;
80-
gap: 8px;
81-
color: #ccc;
82-
`;
83-
84-
const InitHookLog = styled.pre`
85-
margin: 0;
86-
max-height: 120px;
87-
overflow: auto;
88-
white-space: pre-wrap;
89-
background: rgba(0, 0, 0, 0.15);
90-
padding: 6px 8px;
91-
border: 1px solid rgba(255, 255, 255, 0.08);
92-
border-radius: 4px;
93-
`;
94-
9562
const WorkspaceTitle = styled.div`
9663
font-weight: 600;
9764
color: #cccccc;
@@ -271,37 +238,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
271238
// Get git status for this workspace
272239
const gitStatus = useGitStatus(workspaceId);
273240

274-
// Workspace init hook streaming state
275-
const [initLines, setInitLines] = useState<string[]>([]);
276-
const [initExitCode, setInitExitCode] = useState<number | null>(null);
277-
const [showInit, setShowInit] = useState<boolean>(false);
278-
279-
// Subscribe to init hook events from chat stream
280-
useEffect(() => {
281-
const handleMessage = (msg: WorkspaceChatMessage) => {
282-
if (isInitStart(msg)) {
283-
setShowInit(true);
284-
setInitLines([]);
285-
setInitExitCode(null);
286-
} else if (isInitOutput(msg)) {
287-
setShowInit(true);
288-
const line = msg.isError ? `ERROR: ${msg.line}` : msg.line;
289-
setInitLines((prev) => [...prev, line.trimEnd()]);
290-
} else if (isInitEnd(msg)) {
291-
const code = msg.exitCode;
292-
setInitExitCode(code);
293-
if (code === 0) {
294-
setTimeout(() => setShowInit(false), 800);
295-
} else {
296-
setShowInit(true);
297-
}
298-
}
299-
};
300-
301-
const unsubscribe = window.api.workspace.onChat(workspaceId, handleMessage);
302-
return unsubscribe;
303-
}, [workspaceId]);
304-
305241
const [editingMessage, setEditingMessage] = useState<{ id: string; content: string } | undefined>(
306242
undefined
307243
);
@@ -458,8 +394,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
458394

459395
const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
460396
const editCutoffHistoryId = mergedMessages.find(
461-
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" }> =>
462-
msg.type !== "history-hidden" && msg.historyId === editingMessage.id
397+
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" | "workspace-init" }> =>
398+
msg.type !== "history-hidden" &&
399+
msg.type !== "workspace-init" &&
400+
msg.historyId === editingMessage.id
463401
)?.historyId;
464402

465403
if (!editCutoffHistoryId) {
@@ -499,8 +437,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
499437
// When editing, find the cutoff point
500438
const editCutoffHistoryId = editingMessage
501439
? mergedMessages.find(
502-
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" }> =>
503-
msg.type !== "history-hidden" && msg.historyId === editingMessage.id
440+
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" | "workspace-init" }> =>
441+
msg.type !== "history-hidden" &&
442+
msg.type !== "workspace-init" &&
443+
msg.historyId === editingMessage.id
504444
)?.historyId
505445
: undefined;
506446

@@ -528,28 +468,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
528468
return (
529469
<ViewContainer className={className}>
530470
<ChatArea ref={chatAreaRef}>
531-
{showInit && (
532-
<InitHookBanner
533-
error={initExitCode !== null && initExitCode !== 0}
534-
role="status"
535-
aria-live="polite"
536-
>
537-
<InitHookHeader>
538-
{initExitCode === null ? (
539-
<span>Running project init hook (.cmux/init)…</span>
540-
) : initExitCode === 0 ? (
541-
<span>Init hook completed successfully.</span>
542-
) : (
543-
<span>
544-
Init hook exited with code {initExitCode}. Workspace is ready, but some setup
545-
may have failed.
546-
</span>
547-
)}
548-
</InitHookHeader>
549-
{initLines.length > 0 && <InitHookLog>{initLines.join("\n")}</InitHookLog>}
550-
</InitHookBanner>
551-
)}
552-
553471
<ViewHeader>
554472
<WorkspaceTitle>
555473
<StatusIndicator
@@ -603,12 +521,17 @@ const AIViewInner: React.FC<AIViewProps> = ({
603521
const isAtCutoff =
604522
editCutoffHistoryId !== undefined &&
605523
msg.type !== "history-hidden" &&
524+
msg.type !== "workspace-init" &&
606525
msg.historyId === editCutoffHistoryId;
607526

608527
return (
609528
<React.Fragment key={msg.id}>
610529
<div
611-
data-message-id={msg.type !== "history-hidden" ? msg.historyId : undefined}
530+
data-message-id={
531+
msg.type !== "history-hidden" && msg.type !== "workspace-init"
532+
? msg.historyId
533+
: undefined
534+
}
612535
>
613536
<MessageRenderer
614537
message={msg}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import React from "react";
2+
import styled from "@emotion/styled";
3+
import type { DisplayedMessage } from "@/types/message";
4+
5+
interface InitMessageProps {
6+
message: Extract<DisplayedMessage, { type: "workspace-init" }>;
7+
className?: string;
8+
}
9+
10+
const InitHookBanner = styled.div<{ error?: boolean }>`
11+
background: ${(p) => (p.error ? "#3a1e1e" : "#1e2a3a")};
12+
border-bottom: 1px solid ${(p) => (p.error ? "#653737" : "#2f3f52")};
13+
color: #ddd;
14+
font-family: var(--font-monospace);
15+
font-size: 12px;
16+
padding: 8px 12px;
17+
display: flex;
18+
flex-direction: column;
19+
gap: 6px;
20+
`;
21+
22+
const InitHookHeader = styled.div`
23+
display: flex;
24+
align-items: center;
25+
gap: 8px;
26+
color: #ccc;
27+
`;
28+
29+
const InitHookLog = styled.pre`
30+
margin: 0;
31+
max-height: 120px;
32+
overflow: auto;
33+
white-space: pre-wrap;
34+
background: rgba(0, 0, 0, 0.15);
35+
padding: 6px 8px;
36+
border: 1px solid rgba(255, 255, 255, 0.08);
37+
border-radius: 4px;
38+
`;
39+
40+
export const InitMessage = React.memo<InitMessageProps>(({ message, className }) => {
41+
const isError = message.status === "error";
42+
43+
return (
44+
<InitHookBanner error={isError} className={className}>
45+
<InitHookHeader>
46+
<span>🔧</span>
47+
{message.status === "running" ? (
48+
<span>Running init hook...</span>
49+
) : message.status === "success" ? (
50+
<span>✅ Init hook completed successfully</span>
51+
) : (
52+
<span>
53+
Init hook exited with code {message.exitCode}. Workspace is ready, but some setup
54+
failed.
55+
</span>
56+
)}
57+
</InitHookHeader>
58+
{message.lines.length > 0 && <InitHookLog>{message.lines.join("\n")}</InitHookLog>}
59+
</InitHookBanner>
60+
);
61+
});
62+
63+
InitMessage.displayName = "InitMessage";

‎src/components/Messages/MessageRenderer.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ToolMessage } from "./ToolMessage";
66
import { ReasoningMessage } from "./ReasoningMessage";
77
import { StreamErrorMessage } from "./StreamErrorMessage";
88
import { HistoryHiddenMessage } from "./HistoryHiddenMessage";
9+
import { InitMessage } from "./InitMessage";
910

1011
interface MessageRendererProps {
1112
message: DisplayedMessage;
@@ -46,6 +47,8 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
4647
return <StreamErrorMessage message={message} className={className} />;
4748
case "history-hidden":
4849
return <HistoryHiddenMessage message={message} className={className} />;
50+
case "workspace-init":
51+
return <InitMessage message={message} className={className} />;
4952
default:
5053
console.error("don't know how to render message", message);
5154
return null;

0 commit comments

Comments
 (0)