Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function createWindow(): void {
}

app.whenReady().then(async () => {
app.setName("Agent Trace");
const userDataPath = app.getPath("userData");
const updateService = createUpdateService({
currentVersion: app.getVersion(),
Expand Down
12 changes: 12 additions & 0 deletions src/main/ipc/register-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ export function registerIpcHandlers(deps: IpcDependencies): () => void {
}

await deps.proxyManager.startProfile(profileId);

const profiles = deps.profileStore.getProfiles();
const updated = profiles.map((p) => p.id === profileId ? { ...p, autoStart: true } : p);
deps.profileStore.saveProfiles(updated);
Comment on lines +92 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sync profile state after persisting autoStart

Persisting autoStart here introduces a state divergence: the renderer's startProfile/stopProfile paths only refresh statuses and do not reload profiles, so the in-memory profile list can keep stale autoStart values. If the user then performs any operation that calls saveProfiles from that stale list (for example add/edit/delete a profile), the just-persisted autoStart flag is overwritten and the start/stop preference is silently lost for next app launch.

Useful? React with 👍 / 👎.


broadcast(deps.getMainWindow, IPC.PROFILES_CHANGED, { profiles: updated });
broadcast(deps.getMainWindow, IPC.PROFILE_STATUS_CHANGED, {
statuses: deps.proxyManager.getStatuses(),
});
Expand All @@ -98,6 +104,12 @@ export function registerIpcHandlers(deps: IpcDependencies): () => void {
}

await deps.proxyManager.stopProfile(profileId);

const profiles = deps.profileStore.getProfiles();
const updated = profiles.map((p) => p.id === profileId ? { ...p, autoStart: false } : p);
deps.profileStore.saveProfiles(updated);

broadcast(deps.getMainWindow, IPC.PROFILES_CHANGED, { profiles: updated });
broadcast(deps.getMainWindow, IPC.PROFILE_STATUS_CHANGED, {
statuses: deps.proxyManager.getStatuses(),
});
Expand Down
12 changes: 12 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ConnectionProfile,
ExchangeDetailVM,
ProfileStatusChangedEvent,
ProfilesChangedEvent,
SessionListFilter,
SessionListItemVM,
SessionTraceVM,
Expand Down Expand Up @@ -94,6 +95,17 @@ export const electronAPI: ElectronAPI = {
ipcRenderer.removeListener(IPC.PROFILE_STATUS_CHANGED, handler);
},

onProfilesChanged: (
cb: (payload: ProfilesChangedEvent) => void,
): (() => void) => {
const handler = (
_e: Electron.IpcRendererEvent,
payload: ProfilesChangedEvent,
) => cb(payload);
ipcRenderer.on(IPC.PROFILES_CHANGED, handler);
return () => ipcRenderer.removeListener(IPC.PROFILES_CHANGED, handler);
},

onUpdateStateChanged: (cb: (state: UpdateState) => void): (() => void) => {
const handler = (_e: Electron.IpcRendererEvent, state: UpdateState) =>
cb(state);
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/context-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function ContextChip({

return (
<div
className={cn("border-l-2 cursor-pointer", colors.border, colors.bg)}
className={cn("border-l-2 rounded-md cursor-pointer", colors.border, colors.bg)}
onClick={() => setExpanded(!expanded)}
>
<div className={cn("flex items-center gap-1.5 px-3 py-1.5", colors.text)}>
Expand Down
137 changes: 89 additions & 48 deletions src/renderer/src/components/conversation-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ArrowUp, ArrowDown } from "lucide-react";
import { cn } from "../lib/utils";
import type { SessionTimeline } from "../../../shared/contracts";
import { useTraceStore } from "../stores/trace-store";
import { useSessionStore } from "../stores/session-store";

const SCROLL_THRESHOLD = 120;

Expand All @@ -15,6 +16,7 @@ interface ConversationViewProps {
export function ConversationView({ timeline, rawMode }: ConversationViewProps) {
const storeTrace = useTraceStore((state) => state.trace);
const storeRawMode = useTraceStore((state) => state.rawMode);
const selectedSessionId = useSessionStore((s) => s.selectedSessionId);
const activeTimeline = timeline ?? storeTrace?.timeline ?? { messages: [] };
const activeRawMode = rawMode ?? storeRawMode;
const messages = activeTimeline.messages;
Expand All @@ -24,16 +26,68 @@ export function ConversationView({ timeline, rawMode }: ConversationViewProps) {
const [showBottom, setShowBottom] = useState(false);
const [hasNew, setHasNew] = useState(false);
const prevCountRef = useRef(messages.length);
const scrollCache = useRef<Map<string, number>>(new Map());
const prevSessionRef = useRef<string | null>(null);
const scrollListenerAttached = useRef(false);

const updateButtons = useCallback(() => {
const el = viewportRef.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
setShowTop(scrollTop > SCROLL_THRESHOLD);
const isScrollable = scrollHeight > clientHeight + 1;
setShowTop(isScrollable && scrollTop > SCROLL_THRESHOLD);
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
setShowBottom(distanceFromBottom > SCROLL_THRESHOLD);
setShowBottom(isScrollable && distanceFromBottom > SCROLL_THRESHOLD);
}, []);

// Ref callback to setup scroll listener once
const setViewportRef = useCallback((el: HTMLDivElement | null) => {
// Cleanup old listener if ref changes
if (viewportRef.current && scrollListenerAttached.current) {
viewportRef.current.removeEventListener("scroll", handleScroll);
scrollListenerAttached.current = false;
}

viewportRef.current = el;

if (el && !scrollListenerAttached.current) {
el.addEventListener("scroll", handleScroll, { passive: true });
scrollListenerAttached.current = true;
requestAnimationFrame(updateButtons);
}
}, [updateButtons]);

const handleScroll = useCallback(() => {
updateButtons();
const el = viewportRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom <= SCROLL_THRESHOLD) {
setHasNew(false);
}
}, [updateButtons]);

// Save/restore scroll position on session switch
useEffect(() => {
const el = viewportRef.current;
if (prevSessionRef.current && el) {
scrollCache.current.set(prevSessionRef.current, el.scrollTop);
}
if (selectedSessionId && el) {
const saved = scrollCache.current.get(selectedSessionId);
requestAnimationFrame(() => {
el.scrollTo({ top: saved ?? 0 });
updateButtons();
});
}
prevSessionRef.current = selectedSessionId ?? null;
}, [selectedSessionId, updateButtons]);

// Recalculate buttons when content changes
useEffect(() => {
requestAnimationFrame(updateButtons);
}, [messages.length, updateButtons]);

// Detect new messages while not at bottom
useEffect(() => {
if (messages.length > prevCountRef.current) {
Expand All @@ -48,41 +102,28 @@ export function ConversationView({ timeline, rawMode }: ConversationViewProps) {
prevCountRef.current = messages.length;
}, [messages.length]);

// Attach scroll listener to the viewport
// Watch for element size changes (visibility, content loading, etc.)
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const onScroll = () => {
const resizeObserver = new ResizeObserver(() => {
updateButtons();
// Clear new indicator when scrolled to bottom
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom <= SCROLL_THRESHOLD) {
setHasNew(false);
}
};
el.addEventListener("scroll", onScroll, { passive: true });
updateButtons();
return () => el.removeEventListener("scroll", onScroll);
}, [updateButtons]);

// Grab the viewport element from radix ScrollArea
const containerRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
const viewport = node.querySelector("[data-slot='scroll-area-viewport']");
viewportRef.current = viewport as HTMLDivElement | null;
updateButtons();
}
});
resizeObserver.observe(el);
return () => resizeObserver.disconnect();
}, [updateButtons]);

const scrollToTop = () => {
viewportRef.current?.scrollTo({ top: 0, behavior: "smooth" });
viewportRef.current?.scrollTo({ top: 0 });
requestAnimationFrame(updateButtons);
};

const scrollToBottom = () => {
const el = viewportRef.current;
if (el) {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
el.scrollTo({ top: el.scrollHeight });
setHasNew(false);
requestAnimationFrame(updateButtons);
}
};

Expand All @@ -95,8 +136,8 @@ export function ConversationView({ timeline, rawMode }: ConversationViewProps) {
}

return (
<div className="relative h-full" ref={containerRef}>
<div className="h-full overflow-auto" ref={(el) => { viewportRef.current = el; }}>
<div className="relative h-full">
<div className="h-full overflow-auto" ref={setViewportRef}>
<div className="space-y-3 p-6 max-w-4xl mx-auto">
{messages.map((msg, i) => (
<MessageBlock
Expand All @@ -108,29 +149,29 @@ export function ConversationView({ timeline, rawMode }: ConversationViewProps) {
</div>
</div>

{/* Scroll to top */}
{showTop && (
<button
className="absolute top-3 right-4 z-10 flex items-center gap-1 px-2.5 py-1.5 text-xs bg-card border border-border shadow-sm hover:bg-accent transition-colors"
onClick={scrollToTop}
>
<ArrowUp className="h-3 w-3" />
Top
</button>
)}

{/* Scroll to latest */}
{showBottom && (
<button
className="absolute bottom-3 right-4 z-10 flex items-center gap-1 px-2.5 py-1.5 text-xs bg-card border border-border shadow-sm hover:bg-accent transition-colors"
onClick={scrollToBottom}
>
<ArrowDown className="h-3 w-3" />
Latest
{hasNew && (
<span className="ml-1 h-1.5 w-1.5 rounded-full bg-accent-brand animate-pulse" />
{/* Scroll navigation */}
{(showTop || showBottom) && (
<div className="absolute bottom-3 right-4 z-10 flex flex-col gap-1">
{showTop && (
<button
className="flex items-center justify-center h-7 w-7 bg-card border border-border shadow-sm hover:bg-accent transition-colors rounded-sm"
onClick={scrollToTop}
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
)}
{showBottom && (
<button
className="relative flex items-center justify-center h-7 w-7 bg-card border border-border shadow-sm hover:bg-accent transition-colors rounded-sm"
onClick={scrollToBottom}
>
<ArrowDown className="h-3.5 w-3.5" />
{hasNew && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-accent-brand animate-pulse" />
)}
</button>
)}
</button>
</div>
)}
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/inspector-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function InspectorPanel({
<button
key={id}
className={cn(
"px-2 py-1 text-xs whitespace-nowrap transition-colors",
"px-2 py-1 text-xs whitespace-nowrap transition-colors rounded-md",
activeTab === id
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
Expand Down
17 changes: 9 additions & 8 deletions src/renderer/src/components/main-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useSessionStore } from "../stores/session-store";
import { useTraceStore } from "../stores/trace-store";
import { useProfileStore } from "../stores/profile-store";
import { PROVIDERS } from "../features/profiles/constants";
import { cn } from "../lib/utils";

function WaitingGuide() {
const profiles = useProfileStore((s) => s.profiles);
Expand Down Expand Up @@ -65,13 +66,13 @@ function WaitingGuide() {
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium mb-2">Set the environment variable</div>
<div className="border border-border bg-[#0a0a0a] p-2.5">
<div className="border border-border bg-[#0a0a0a] p-2.5 rounded-md">
<div className="flex items-center gap-2">
<code className="text-xs text-success font-mono flex-1 min-w-0 overflow-x-auto">
{exportCmd}
</code>
<button
className="shrink-0 flex items-center gap-1 text-[11px] text-neutral-400 hover:text-neutral-200 px-2 py-1 border border-neutral-700 bg-neutral-800 transition-colors"
className="shrink-0 flex items-center gap-1 text-[11px] text-neutral-400 hover:text-neutral-200 px-2 py-1 border border-neutral-700 bg-neutral-800 rounded-sm transition-colors"
onClick={handleCopy}
>
{copied ? (
Expand Down Expand Up @@ -136,10 +137,10 @@ export function MainContent() {

const contentArea = (
<>
{contentTab === "messages" && <ConversationView />}
{contentTab === "system" && <SystemView />}
{contentTab === "tools" && <ToolsView />}
{contentTab === "other" && <OtherView />}
<div className={cn("h-full", contentTab !== "messages" && "hidden")}><ConversationView /></div>
<div className={cn("h-full", contentTab !== "system" && "hidden")}><SystemView /></div>
<div className={cn("h-full", contentTab !== "tools" && "hidden")}><ToolsView /></div>
<div className={cn("h-full", contentTab !== "other" && "hidden")}><OtherView /></div>
</>
);

Expand All @@ -149,11 +150,11 @@ export function MainContent() {
<ConversationHeader />
<ContentTabBar />
<ResizablePanelGroup orientation="horizontal" className="flex-1">
<ResizablePanel defaultSize="50%" minSize="25%">
<ResizablePanel defaultSize="65%" minSize="40%">
{contentArea}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize="50%" minSize="25%">
<ResizablePanel defaultSize="35%" minSize="25%">
<InspectorPanel />
</ResizablePanel>
</ResizablePanelGroup>
Expand Down
8 changes: 4 additions & 4 deletions src/renderer/src/components/message-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ function CopyButton({ text }: { text: string }) {

return (
<button
className="p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
className="p-1 text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm transition-colors"
onClick={handleCopy}
title="Copy to clipboard"
>
Expand Down Expand Up @@ -107,7 +107,7 @@ export function MessageBlock({ message, rawMode }: MessageBlockProps) {

if (rawMode) {
return (
<div className="p-4 space-y-2 relative group bg-card border border-border">
<div className="p-4 space-y-2 relative group bg-card border border-border rounded-lg">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs">
<span className={cn("inline-block h-1.5 w-1.5 rounded-full", roleDotColor(message.role))} />
Expand Down Expand Up @@ -135,15 +135,15 @@ export function MessageBlock({ message, rawMode }: MessageBlockProps) {
return (
<div
className={cn(
"p-4 space-y-2 relative group bg-card border border-border transition-colors",
"p-4 space-y-2 relative group bg-card border border-border rounded-lg transition-colors",
!expanded && "cursor-pointer hover:bg-accent/30"
)}
onClick={!expanded ? () => setExpanded(true) : undefined}
>
<div
className={cn(
"flex items-center justify-between",
expanded && "sticky top-0 z-10 bg-card cursor-pointer -mx-4 -mt-4 px-4 pt-4 pb-2 border-b border-border/50 transition-colors hover:brightness-95 dark:hover:brightness-110"
expanded && "sticky top-0 z-10 bg-card cursor-pointer -mx-4 -mt-4 px-4 pt-4 pb-2 border-b border-border/50 transition-colors hover:brightness-95 dark:hover:brightness-110 rounded-t-lg"
)}
onClick={expanded ? () => setExpanded(false) : undefined}
>
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/src/components/other-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export function OtherView() {
{/* Injected context blocks from messages */}
{injectedItems.map((item, i) =>
rawMode ? (
<div key={`inj-${i}`} className="p-4 bg-card border border-border space-y-2">
<div key={`inj-${i}`} className="p-4 bg-card border border-border rounded-lg space-y-2">
<span className="text-xs text-muted-foreground font-medium">
{item.label}
</span>
Expand All @@ -119,7 +119,7 @@ export function OtherView() {

{/* Extra inspector sections */}
{inspectorSections.map((section, i) => (
<div key={`sec-${i}`} className="bg-card border border-border p-4 space-y-2">
<div key={`sec-${i}`} className="bg-card border border-border rounded-lg p-4 space-y-2">
<div className="text-xs font-medium text-muted-foreground">
{section.title}
</div>
Expand Down
Loading
Loading