Skip to content

Commit 5a405eb

Browse files
committed
merge: pull main, keep dark mode styling with non-virtualized list
2 parents 0d045ed + c38c5a6 commit 5a405eb

File tree

15 files changed

+282
-154
lines changed

15 files changed

+282
-154
lines changed

apps/array/src/main/index.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
declare const __BUILD_COMMIT__: string | undefined;
2+
declare const __BUILD_DATE__: string | undefined;
3+
14
import dns from "node:dns";
25
import { mkdirSync } from "node:fs";
6+
import os from "node:os";
37
import path from "node:path";
48
import { fileURLToPath } from "node:url";
59
import {
610
app,
711
BrowserWindow,
12+
clipboard,
13+
dialog,
814
ipcMain,
915
Menu,
1016
type MenuItemConstructorOptions,
@@ -116,7 +122,38 @@ function createWindow(): void {
116122
{
117123
label: "Array",
118124
submenu: [
119-
{ role: "about" },
125+
{
126+
label: "About Array",
127+
click: () => {
128+
const commit = __BUILD_COMMIT__ ?? "dev";
129+
const buildDate = __BUILD_DATE__ ?? "dev";
130+
const info = [
131+
`Version: ${app.getVersion()}`,
132+
`Commit: ${commit}`,
133+
`Date: ${buildDate}`,
134+
`Electron: ${process.versions.electron}`,
135+
`Chromium: ${process.versions.chrome}`,
136+
`Node.js: ${process.versions.node}`,
137+
`V8: ${process.versions.v8}`,
138+
`OS: ${process.platform} ${process.arch} ${os.release()}`,
139+
].join("\n");
140+
141+
dialog
142+
.showMessageBox({
143+
type: "info",
144+
title: "About Array",
145+
message: "Array",
146+
detail: info,
147+
buttons: ["Copy", "OK"],
148+
defaultId: 1,
149+
})
150+
.then((result) => {
151+
if (result.response === 0) {
152+
clipboard.writeText(info);
153+
}
154+
});
155+
},
156+
},
120157
{ type: "separator" },
121158
{
122159
label: "Check for Updates...",

apps/array/src/main/services/session-manager.ts

Lines changed: 100 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ import { logger } from "../lib/logger";
2323

2424
const log = logger.scope("session-manager");
2525

26+
function isAuthError(error: unknown): boolean {
27+
return (
28+
error instanceof Error &&
29+
error.message.startsWith("Authentication required")
30+
);
31+
}
32+
2633
type MessageCallback = (message: unknown) => void;
2734

2835
class NdJsonTap {
@@ -135,6 +142,7 @@ export interface ManagedSession {
135142
createdAt: number;
136143
lastActivityAt: number;
137144
mockNodeDir: string;
145+
config: SessionConfig;
138146
}
139147

140148
function getClaudeCliPath(): string {
@@ -146,7 +154,7 @@ function getClaudeCliPath(): string {
146154

147155
export class SessionManager {
148156
private sessions = new Map<string, ManagedSession>();
149-
private sessionTokens = new Map<string, string>();
157+
private currentToken: string | null = null;
150158
private getMainWindow: () => BrowserWindow | null;
151159
private onLog: OnLogCallback;
152160

@@ -155,23 +163,20 @@ export class SessionManager {
155163
this.onLog = onLog;
156164
}
157165

158-
public updateSessionToken(taskRunId: string, newToken: string): void {
159-
this.sessionTokens.set(taskRunId, newToken);
160-
log.info("Session token updated", { taskRunId });
166+
public updateToken(newToken: string): void {
167+
this.currentToken = newToken;
168+
log.info("Session token updated");
161169
}
162170

163-
private getSessionToken(taskRunId: string, fallback: string): string {
164-
return this.sessionTokens.get(taskRunId) || fallback;
171+
private getToken(fallback: string): string {
172+
return this.currentToken || fallback;
165173
}
166174

167-
private buildMcpServers(
168-
credentials: PostHogCredentials,
169-
taskRunId: string,
170-
): AcpMcpServer[] {
175+
private buildMcpServers(credentials: PostHogCredentials): AcpMcpServer[] {
171176
const servers: AcpMcpServer[] = [];
172177

173178
const mcpUrl = this.getPostHogMcpUrl(credentials.apiHost);
174-
const token = this.getSessionToken(taskRunId, credentials.apiKey);
179+
const token = this.getToken(credentials.apiKey);
175180

176181
servers.push({
177182
name: "posthog",
@@ -211,6 +216,7 @@ export class SessionManager {
211216
private async getOrCreateSession(
212217
config: SessionConfig,
213218
isReconnect: boolean,
219+
isRetry = false,
214220
): Promise<ManagedSession | null> {
215221
const {
216222
taskId,
@@ -222,9 +228,11 @@ export class SessionManager {
222228
model,
223229
} = config;
224230

225-
const existing = this.sessions.get(taskRunId);
226-
if (existing) {
227-
return existing;
231+
if (!isRetry) {
232+
const existing = this.sessions.get(taskRunId);
233+
if (existing) {
234+
return existing;
235+
}
228236
}
229237

230238
const channel = `agent-event:${taskRunId}`;
@@ -234,7 +242,7 @@ export class SessionManager {
234242
const agent = new Agent({
235243
workingDirectory: repoPath,
236244
posthogApiUrl: credentials.apiHost,
237-
posthogApiKey: credentials.apiKey,
245+
getPosthogApiKey: () => this.getToken(credentials.apiKey),
238246
posthogProjectId: credentials.projectId,
239247
debug: !app.isPackaged,
240248
onLog: this.onLog,
@@ -256,11 +264,9 @@ export class SessionManager {
256264
clientCapabilities: {},
257265
});
258266

259-
const mcpServers = this.buildMcpServers(credentials, taskRunId);
267+
const mcpServers = this.buildMcpServers(credentials);
260268

261269
if (isReconnect) {
262-
// Use our custom extension method to resume without replaying history.
263-
// Client fetches history from S3 directly.
264270
await connection.extMethod("_posthog/session/resume", {
265271
sessionId: taskRunId,
266272
cwd: repoPath,
@@ -290,38 +296,82 @@ export class SessionManager {
290296
createdAt: Date.now(),
291297
lastActivityAt: Date.now(),
292298
mockNodeDir,
299+
config,
293300
};
294301

295302
this.sessions.set(taskRunId, session);
303+
if (isRetry) {
304+
log.info("Session created after auth retry", { taskRunId });
305+
}
296306
return session;
297307
} catch (err) {
298308
this.cleanupMockNodeEnvironment(mockNodeDir);
309+
if (!isRetry && isAuthError(err)) {
310+
log.warn(
311+
`Auth error during ${isReconnect ? "reconnect" : "create"}, retrying`,
312+
{ taskRunId },
313+
);
314+
return this.getOrCreateSession(config, isReconnect, true);
315+
}
299316
log.error(
300-
`Failed to ${isReconnect ? "reconnect" : "create"} session`,
317+
`Failed to ${isReconnect ? "reconnect" : "create"} session${isRetry ? " after retry" : ""}`,
301318
err,
302319
);
303320
if (isReconnect) return null;
304321
throw err;
305322
}
306323
}
307324

325+
private async recreateSession(taskRunId: string): Promise<ManagedSession> {
326+
const existing = this.sessions.get(taskRunId);
327+
if (!existing) {
328+
throw new Error(`Session not found for recreation: ${taskRunId}`);
329+
}
330+
331+
log.info("Recreating session due to auth error", { taskRunId });
332+
333+
// Store config and cleanup old session
334+
const config = existing.config;
335+
this.cleanupSession(taskRunId);
336+
337+
// Reconnect to preserve Claude context via sdkSessionId
338+
const newSession = await this.getOrCreateSession(config, true);
339+
if (!newSession) {
340+
throw new Error(`Failed to recreate session: ${taskRunId}`);
341+
}
342+
343+
return newSession;
344+
}
345+
308346
async prompt(
309347
taskRunId: string,
310348
prompt: ContentBlock[],
311349
): Promise<{ stopReason: string }> {
312-
const session = this.sessions.get(taskRunId);
350+
let session = this.sessions.get(taskRunId);
313351
if (!session) {
314352
throw new Error(`Session not found: ${taskRunId}`);
315353
}
316354

317355
session.lastActivityAt = Date.now();
318356

319-
const result = await session.connection.prompt({
320-
sessionId: taskRunId, // Use taskRunId as ACP sessionId
321-
prompt,
322-
});
323-
324-
return { stopReason: result.stopReason };
357+
try {
358+
const result = await session.connection.prompt({
359+
sessionId: taskRunId,
360+
prompt,
361+
});
362+
return { stopReason: result.stopReason };
363+
} catch (err) {
364+
if (isAuthError(err)) {
365+
log.warn("Auth error during prompt, recreating session", { taskRunId });
366+
session = await this.recreateSession(taskRunId);
367+
const result = await session.connection.prompt({
368+
sessionId: taskRunId,
369+
prompt,
370+
});
371+
return { stopReason: result.stopReason };
372+
}
373+
throw err;
374+
}
325375
}
326376

327377
async cancelSession(taskRunId: string): Promise<boolean> {
@@ -398,11 +448,12 @@ export class SessionManager {
398448
credentials: PostHogCredentials,
399449
mockNodeDir: string,
400450
): void {
451+
const token = this.getToken(credentials.apiKey);
401452
const newPath = `${mockNodeDir}:${process.env.PATH || ""}`;
402453
process.env.PATH = newPath;
403-
process.env.POSTHOG_AUTH_HEADER = `Bearer ${credentials.apiKey}`;
404-
process.env.ANTHROPIC_API_KEY = credentials.apiKey;
405-
process.env.ANTHROPIC_AUTH_TOKEN = credentials.apiKey;
454+
process.env.POSTHOG_AUTH_HEADER = `Bearer ${token}`;
455+
process.env.ANTHROPIC_API_KEY = token;
456+
process.env.ANTHROPIC_AUTH_TOKEN = token;
406457

407458
const llmGatewayUrl =
408459
process.env.LLM_GATEWAY_URL ||
@@ -508,6 +559,24 @@ export class SessionManager {
508559
// No-op: session/update notifications are captured by the stream tap
509560
// and forwarded as acp_message events to avoid duplication
510561
},
562+
563+
extNotification: async (
564+
method: string,
565+
params: Record<string, unknown>,
566+
): Promise<void> => {
567+
if (method === "_posthog/sdk_session") {
568+
const { sessionId, sdkSessionId } = params as {
569+
sessionId: string;
570+
sdkSessionId: string;
571+
};
572+
// Store sdkSessionId in session config for recreation/reconnection
573+
const session = this.sessions.get(sessionId);
574+
if (session) {
575+
session.config.sdkSessionId = sdkSessionId;
576+
log.info("SDK session ID captured", { sessionId, sdkSessionId });
577+
}
578+
}
579+
},
511580
};
512581

513582
// Create client-side connection with tapped streams (bidirectional)
@@ -665,10 +734,10 @@ export function registerAgentIpc(
665734
"agent-token-refresh",
666735
async (
667736
_event: IpcMainInvokeEvent,
668-
taskRunId: string,
737+
_taskRunId: string,
669738
newToken: string,
670739
): Promise<void> => {
671-
sessionManager.updateSessionToken(taskRunId, newToken);
740+
sessionManager.updateToken(newToken);
672741
},
673742
);
674743

apps/array/src/renderer/features/auth/stores/authStore.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@ export const useAuthStore = create<AuthState>()(
202202
...(projectId && { projectId }),
203203
});
204204

205+
// Notify main process of token refresh for active agent sessions
206+
window.electronAPI
207+
.agentTokenRefresh("", tokenResponse.access_token)
208+
.catch((err) => {
209+
log.warn("Failed to update agent token:", err);
210+
});
211+
205212
get().scheduleTokenRefresh();
206213
},
207214

apps/array/src/renderer/features/code-editor/components/CodeEditorPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export function CodeEditorPanel({
6464
<CodeMirrorEditor
6565
content={fileContent}
6666
filePath={absolutePath}
67+
relativePath={filePath}
6768
readOnly
6869
/>
6970
</Box>

apps/array/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, Flex, SegmentedControl } from "@radix-ui/themes";
1+
import { Box, Flex, SegmentedControl, Text } from "@radix-ui/themes";
22
import { useMemo } from "react";
33
import { useCodeMirror } from "../hooks/useCodeMirror";
44
import { useEditorExtensions } from "../hooks/useEditorExtensions";
@@ -8,13 +8,15 @@ interface CodeMirrorDiffEditorProps {
88
originalContent: string;
99
modifiedContent: string;
1010
filePath?: string;
11+
relativePath?: string;
1112
onContentChange?: (content: string) => void;
1213
}
1314

1415
export function CodeMirrorDiffEditor({
1516
originalContent,
1617
modifiedContent,
1718
filePath,
19+
relativePath,
1820
onContentChange,
1921
}: CodeMirrorDiffEditorProps) {
2022
const { viewMode, setViewMode } = useDiffViewerStore();
@@ -41,11 +43,22 @@ export function CodeMirrorDiffEditor({
4143

4244
return (
4345
<Flex direction="column" height="100%">
44-
<Box
46+
<Flex
4547
px="3"
4648
py="2"
49+
align="center"
50+
justify="between"
4751
style={{ borderBottom: "1px solid var(--gray-6)", flexShrink: 0 }}
4852
>
53+
{relativePath && (
54+
<Text
55+
size="1"
56+
color="gray"
57+
style={{ fontFamily: "var(--code-font-family)" }}
58+
>
59+
{relativePath}
60+
</Text>
61+
)}
4962
<SegmentedControl.Root
5063
size="1"
5164
value={viewMode}
@@ -54,7 +67,7 @@ export function CodeMirrorDiffEditor({
5467
<SegmentedControl.Item value="split">Split</SegmentedControl.Item>
5568
<SegmentedControl.Item value="unified">Unified</SegmentedControl.Item>
5669
</SegmentedControl.Root>
57-
</Box>
70+
</Flex>
5871
<Box style={{ flex: 1, overflow: "auto" }}>
5972
<div ref={containerRef} style={{ height: "100%", width: "100%" }} />
6073
</Box>

0 commit comments

Comments
 (0)