Skip to content

Commit f692d80

Browse files
wilcorreaCopilot
andauthored
fix: ACP production build fixes — auth, binary path, UI polish (#29)
* fix(acp): allow configuring copilot binary path in settings Production builds on macOS don't inherit the shell PATH, causing 'Failed to spawn copilot: No such file or directory' when the binary lives in /opt/homebrew/bin or similar user-managed paths. - acp_connect now accepts optional binary_path parameter - falls back to $COPILOT_PATH env var then 'copilot' - useAcpConnection reads 'arandu-copilot-path' from localStorage - GeneralSettings exposes a text field to set the path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(acp): pass GH_TOKEN env var when spawning copilot process In production builds (GUI app), the macOS Keychain is not accessible to child processes without explicit user approval, causing 'Authentication required' even when the user is logged in via gh CLI. - acp_connect now accepts optional gh_token parameter - GH_TOKEN is set as env var on the spawned copilot process - useAcpConnection reads 'arandu-gh-token' from localStorage - GeneralSettings exposes a password field for the token - i18n keys added with hint to use 'gh auth token' to obtain it Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(sessions): show initial prompt in chat even when session has no messages When authentication fails or the agent hasn't responded yet, the chat area was empty and the user had no way to see what they originally sent. - TerminalChat now accepts optional initialPrompt prop - Displayed as a muted 'Brainstorm' card above messages (or in empty state) - ActiveSessionView passes session.initial_prompt to TerminalChat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ui): simplify ErrorConsole to compact single-line banner The previous collapsible panel with 'Errors (N)' header was visually heavy and felt like a debug console. Replaced with a minimal inline banner: icon + last error message + dismiss button. Multiple errors show a count suffix (+N). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(plan): approvePlan was silently returning when acpSessionId prop was null session.acp_session_id comes from the DB prop passed to ActiveSessionView. After doInit() saves the ACP session ID to the DB, the parent component prop is not updated, so acpSessionId stays null for the lifetime of the component, causing approvePlan() to exit early without sending anything. Fix: also check activeSessionIdRef (in-memory, always up-to-date) so the guard only blocks when both are null. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(sessions): delete plan file when deleting a session Call delete_plan from session_delete to clean up the orphaned plan .md file alongside the SQLite record. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c222b41 commit f692d80

11 files changed

Lines changed: 133 additions & 46 deletions

File tree

apps/tauri/src-tauri/src/acp/commands.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ impl Default for AcpState {
1717
pub async fn acp_connect(
1818
workspace_id: String,
1919
cwd: String,
20+
binary_path: Option<String>,
21+
gh_token: Option<String>,
2022
app_handle: AppHandle,
2123
state: State<'_, AcpState>,
2224
) -> Result<(), String> {
@@ -25,11 +27,14 @@ pub async fn acp_connect(
2527
return Ok(());
2628
}
2729

28-
let binary = std::env::var("COPILOT_PATH").unwrap_or_else(|_| "copilot".to_string());
30+
let binary = binary_path
31+
.filter(|s| !s.trim().is_empty())
32+
.unwrap_or_else(|| std::env::var("COPILOT_PATH").unwrap_or_else(|_| "copilot".to_string()));
2933
let conn = AcpConnection::spawn(
3034
&binary,
3135
&["--acp", "--stdio"],
3236
&cwd,
37+
gh_token,
3338
workspace_id.clone(),
3439
app_handle,
3540
)

apps/tauri/src-tauri/src/acp/connection.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,23 @@ impl AcpConnection {
2424
binary: &str,
2525
args: &[&str],
2626
cwd: &str,
27+
gh_token: Option<String>,
2728
workspace_id: String,
2829
app_handle: AppHandle,
2930
) -> Result<Self, String> {
30-
let mut child = Command::new(binary)
31-
.args(args)
31+
let mut cmd = Command::new(binary);
32+
cmd.args(args)
3233
.current_dir(cwd)
3334
.stdin(std::process::Stdio::piped())
3435
.stdout(std::process::Stdio::piped())
3536
.stderr(std::process::Stdio::inherit())
36-
.kill_on_drop(true)
37+
.kill_on_drop(true);
38+
39+
if let Some(token) = gh_token.filter(|s| !s.trim().is_empty()) {
40+
cmd.env("GH_TOKEN", token.trim());
41+
}
42+
43+
let mut child = cmd
3744
.spawn()
3845
.map_err(|e| format!("Failed to spawn {}: {}", binary, e))?;
3946

apps/tauri/src-tauri/src/sessions.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ pub fn delete_session(conn: &Connection, id: &str) -> Result<(), String> {
177177
// --- Tauri commands ---
178178

179179
use crate::comments::CommentsDb;
180+
use tauri::Manager;
180181

181182
#[tauri::command]
182183
pub fn session_list(
@@ -251,7 +252,11 @@ pub fn session_update_plan_file_path(
251252
pub fn session_delete(
252253
id: String,
253254
db: tauri::State<CommentsDb>,
255+
app: tauri::AppHandle,
254256
) -> Result<(), String> {
255257
let conn = db.0.lock().map_err(|e| e.to_string())?;
256-
delete_session(&conn, &id)
258+
delete_session(&conn, &id)?;
259+
let app_data = app.path().app_data_dir()
260+
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
261+
crate::plan_file::delete_plan(&app_data, &id)
257262
}

apps/tauri/src/components/ActiveSessionView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ export function ActiveSessionView({
317317
onCancel={acp.cancel}
318318
onClearErrors={acp.clearErrors}
319319
onReconnect={handleReconnect}
320+
initialPrompt={session.initial_prompt || undefined}
320321
/>
321322
</div>
322323
</ResizablePanel>
Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,31 @@
1-
import { useState } from "react";
2-
import { ChevronDown, ChevronRight, Trash2 } from "lucide-react";
3-
import { Button } from "@/components/ui/button";
1+
import { AlertCircle, X } from "lucide-react";
42

53
interface ErrorConsoleProps {
64
errors: string[];
75
onClear: () => void;
86
}
97

108
export function ErrorConsole({ errors, onClear }: ErrorConsoleProps) {
11-
const [expanded, setExpanded] = useState(true);
12-
139
if (errors.length === 0) return null;
1410

11+
const lastError = errors[errors.length - 1];
12+
1513
return (
16-
<div className="border-t border-border bg-destructive/[0.03]">
14+
<div className="border-t border-destructive/20 bg-destructive/5 px-3 py-2 flex items-start gap-2">
15+
<AlertCircle className="h-3.5 w-3.5 text-destructive/60 mt-0.5 shrink-0" />
16+
<p className="flex-1 font-mono text-xs text-destructive/70 break-words leading-relaxed">
17+
{lastError}
18+
{errors.length > 1 && (
19+
<span className="ml-2 text-destructive/40">(+{errors.length - 1})</span>
20+
)}
21+
</p>
1722
<button
18-
onClick={() => setExpanded(!expanded)}
19-
className="w-full flex items-center gap-2 px-4 py-1.5 text-xs font-mono text-destructive/70 hover:text-destructive hover:bg-destructive/10 transition-colors"
23+
onClick={onClear}
24+
className="shrink-0 text-muted-foreground/40 hover:text-destructive transition-colors"
25+
aria-label="Dismiss"
2026
>
21-
{expanded ? (
22-
<ChevronDown className="h-3 w-3" />
23-
) : (
24-
<ChevronRight className="h-3 w-3" />
25-
)}
26-
<span>Errors ({errors.length})</span>
27-
<Button
28-
variant="ghost"
29-
size="sm"
30-
className="h-5 w-5 p-0 ml-auto text-muted-foreground/40 hover:text-destructive"
31-
onClick={(e) => {
32-
e.stopPropagation();
33-
onClear();
34-
}}
35-
>
36-
<Trash2 className="h-3 w-3" />
37-
</Button>
27+
<X className="h-3.5 w-3.5" />
3828
</button>
39-
{expanded && (
40-
<div className="px-4 py-2 max-h-[120px] overflow-y-auto space-y-1">
41-
{errors.map((error, i) => (
42-
<div key={i} className="font-mono text-xs text-destructive/60 py-1 pl-3 border-l-2 border-destructive/20 break-words">
43-
{error}
44-
</div>
45-
))}
46-
</div>
47-
)}
4829
</div>
4930
);
5031
}

apps/tauri/src/components/TerminalChat.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface TerminalChatProps {
1717
onReconnect?: () => void;
1818
disabled?: boolean;
1919
placeholder?: string;
20+
initialPrompt?: string;
2021
}
2122

2223
export function TerminalChat({
@@ -29,6 +30,7 @@ export function TerminalChat({
2930
onReconnect,
3031
disabled,
3132
placeholder,
33+
initialPrompt,
3234
}: TerminalChatProps) {
3335
const { t } = useTranslation();
3436
const scrollRef = useRef<HTMLDivElement>(null);
@@ -45,9 +47,16 @@ export function TerminalChat({
4547
<div ref={scrollRef} className="absolute inset-0 overflow-auto py-4 terminal-scroll-fade">
4648
{messages.length === 0 ? (
4749
<div className="flex flex-col items-center justify-center h-full gap-3">
48-
<p className="text-muted-foreground/50 font-mono text-sm">
49-
{t("chat.emptyState")}
50-
</p>
50+
{initialPrompt ? (
51+
<div className="w-full px-4 py-3 mx-4 rounded border border-border bg-muted/30">
52+
<p className="text-xs text-muted-foreground/60 font-mono mb-1">{t("sessions.formPrompt")}</p>
53+
<p className="text-sm text-muted-foreground whitespace-pre-wrap font-mono">{initialPrompt}</p>
54+
</div>
55+
) : (
56+
<p className="text-muted-foreground/50 font-mono text-sm">
57+
{t("chat.emptyState")}
58+
</p>
59+
)}
5160
{onReconnect && (
5261
<Button variant="outline" size="sm" className="text-xs gap-1.5" onClick={onReconnect}>
5362
<RefreshCw className="h-3.5 w-3.5" />
@@ -57,6 +66,12 @@ export function TerminalChat({
5766
</div>
5867
) : (
5968
<div className="space-y-3">
69+
{initialPrompt && (
70+
<div className="px-4 py-3 mx-4 rounded border border-border bg-muted/30">
71+
<p className="text-xs text-muted-foreground/60 font-mono mb-1">{t("sessions.formPrompt")}</p>
72+
<p className="text-sm text-muted-foreground whitespace-pre-wrap font-mono">{initialPrompt}</p>
73+
</div>
74+
)}
6075
{messages.map((message, idx) => (
6176
<TerminalMessage
6277
key={message.id}

apps/tauri/src/components/settings/GeneralSettings.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Label } from "@/components/ui/label";
2+
import { Input } from "@/components/ui/input";
23
import {
34
Select,
45
SelectContent,
@@ -10,13 +11,66 @@ import { useTheme } from "next-themes";
1011
import { useTranslation } from "react-i18next";
1112
import { Monitor, Moon, Sun } from "lucide-react";
1213
import { updateTrayLabels } from "@/lib/tray-sync";
14+
import { useState } from "react";
15+
16+
const COPILOT_PATH_KEY = "arandu-copilot-path";
17+
const GH_TOKEN_KEY = "arandu-gh-token";
1318

1419
export function GeneralSettings() {
1520
const { theme, setTheme } = useTheme();
1621
const { t, i18n } = useTranslation();
22+
const [copilotPath, setCopilotPath] = useState(
23+
() => localStorage.getItem(COPILOT_PATH_KEY) ?? ""
24+
);
25+
const [ghToken, setGhToken] = useState(
26+
() => localStorage.getItem(GH_TOKEN_KEY) ?? ""
27+
);
28+
29+
function handleCopilotPathChange(value: string) {
30+
setCopilotPath(value);
31+
if (value.trim()) {
32+
localStorage.setItem(COPILOT_PATH_KEY, value.trim());
33+
} else {
34+
localStorage.removeItem(COPILOT_PATH_KEY);
35+
}
36+
}
37+
38+
function handleGhTokenChange(value: string) {
39+
setGhToken(value);
40+
if (value.trim()) {
41+
localStorage.setItem(GH_TOKEN_KEY, value.trim());
42+
} else {
43+
localStorage.removeItem(GH_TOKEN_KEY);
44+
}
45+
}
1746

1847
return (
1948
<div className="space-y-6">
49+
{/* GitHub Token */}
50+
<div className="space-y-2">
51+
<Label className="text-sm font-medium">{t("settings.ghToken")}</Label>
52+
<Input
53+
className="w-full font-mono text-sm"
54+
type="password"
55+
placeholder={t("settings.ghTokenPlaceholder")}
56+
value={ghToken}
57+
onChange={(e) => handleGhTokenChange(e.target.value)}
58+
/>
59+
<p className="text-xs text-muted-foreground">{t("settings.ghTokenHint")}</p>
60+
</div>
61+
62+
{/* Copilot Binary Path */}
63+
<div className="space-y-2">
64+
<Label className="text-sm font-medium">{t("settings.copilotPath")}</Label>
65+
<Input
66+
className="w-full font-mono text-sm"
67+
placeholder={t("settings.copilotPathPlaceholder")}
68+
value={copilotPath}
69+
onChange={(e) => handleCopilotPathChange(e.target.value)}
70+
/>
71+
<p className="text-xs text-muted-foreground">{t("settings.copilotPathHint")}</p>
72+
</div>
73+
2074
{/* Theme */}
2175
<div className="space-y-2">
2276
<Label className="text-sm font-medium">{t("settings.theme")}</Label>

apps/tauri/src/hooks/useAcpConnection.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,13 @@ export function useAcpConnection(
2323
setIsConnecting(true);
2424
setConnectionError(null);
2525
try {
26+
const binaryPath = localStorage.getItem("arandu-copilot-path") || undefined;
27+
const ghToken = localStorage.getItem("arandu-gh-token") || undefined;
2628
await invoke("acp_connect", {
2729
workspaceId,
2830
cwd: workspacePath,
31+
binaryPath,
32+
ghToken,
2933
});
3034
connectedRef.current = true;
3135
setIsConnected(true);

apps/tauri/src/hooks/usePlanWorkflow.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface UsePlanWorkflowParams {
2828

2929
export function usePlanWorkflow({
3030
workspaceId,
31+
activeSessionId,
3132
acpSessionId,
3233
localSessionId,
3334
initialPhase,
@@ -55,6 +56,8 @@ export function usePlanWorkflow({
5556
sendPromptRef.current = sendPrompt;
5657
const setModeRef = useRef(setMode);
5758
setModeRef.current = setMode;
59+
const activeSessionIdRef = useRef(activeSessionId);
60+
activeSessionIdRef.current = activeSessionId;
5861

5962
useEffect(() => {
6063
if (!agentPlanFilePath) return;
@@ -100,7 +103,7 @@ export function usePlanWorkflow({
100103

101104
const approvePlan = useCallback(
102105
async (reviewMarkdown?: string) => {
103-
if (!acpSessionId) return;
106+
if (!acpSessionId && !activeSessionIdRef.current) return;
104107

105108
const agentMode = findModeBySlug(availableModesRef.current, "agent");
106109
if (agentMode) {
@@ -121,7 +124,7 @@ export function usePlanWorkflow({
121124

122125
await sendPromptRef.current(prompt);
123126
},
124-
[workspaceId, acpSessionId, localSessionId]
127+
[workspaceId, acpSessionId, activeSessionId, localSessionId]
125128
);
126129

127130
const requestChanges = useCallback(

apps/tauri/src/locales/en.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,13 @@
129129
"whisper": "Voice-to-Text",
130130
"theme": "Theme",
131131
"language": "Language",
132-
"openSettings": "Settings"
132+
"openSettings": "Settings",
133+
"copilotPath": "Copilot binary path",
134+
"copilotPathPlaceholder": "e.g. /usr/local/bin/copilot",
135+
"copilotPathHint": "Leave blank to use default ($COPILOT_PATH or 'copilot')",
136+
"ghToken": "GitHub Token",
137+
"ghTokenPlaceholder": "ghp_... or gho_...",
138+
"ghTokenHint": "Required in production builds. Get it with: gh auth token"
133139
},
134140
"tray": {
135141
"show": "Show Window",

0 commit comments

Comments
 (0)