Skip to content
Open
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
33 changes: 32 additions & 1 deletion desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,22 @@ type HistoryMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Reasoning string `json:"reasoning,omitempty"`
// ToolCalls carries the tool calls an assistant message requested. Each
// entry mirrors a provider.ToolCall so the frontend can reconstruct tool
// cards in historical sessions.
ToolCalls []HistoryToolCall `json:"toolCalls,omitempty"`
// ToolCallID is set on tool-result messages (role:"tool") so the frontend
// can match results back to their dispatch cards.
ToolCallID string `json:"toolCallId,omitempty"`
// ToolName is set on tool-result messages for display.
ToolName string `json:"toolName,omitempty"`
}

// HistoryToolCall is one tool call from a historical assistant message.
type HistoryToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
}

// History returns the session's message log.
Expand All @@ -640,7 +656,22 @@ func historyMessages(msgs []provider.Message, resolveUserContent func(string) st
if m.Role == provider.RoleAssistant {
reasoning = m.ReasoningContent
}
out = append(out, HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning})
hm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}
// Preserve tool calls from assistant messages so the frontend can
// reconstruct tool cards.
if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {
hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))
for i, tc := range m.ToolCalls {
hm.ToolCalls[i] = HistoryToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
}
}
// Preserve tool result linkage so the frontend can fill in tool card
// output/errors.
if m.Role == provider.RoleTool {
hm.ToolCallID = m.ToolCallID
hm.ToolName = m.Name
}
out = append(out, hm)
}
return out
}
Expand Down
60 changes: 49 additions & 11 deletions desktop/frontend/src/components/HistoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,15 +285,53 @@ function sessionMetaLine(s: SessionMeta, tr: ReturnType<typeof useT>): string {
}

function previewMessagesToItems(messages: HistoryMessage[]): Item[] {
return messages
.filter(
(m) =>
(m.role === "user" && m.content.trim() !== "") ||
(m.role === "assistant" && (m.content.trim() !== "" || (m.reasoning ?? "").trim() !== "")),
)
.map((m, i) =>
m.role === "user"
? { kind: "user", id: `hp${i}`, text: m.content }
: { kind: "assistant", id: `hp${i}`, text: m.content, reasoning: m.reasoning ?? "", streaming: false },
);
// Build a lookup: toolCallId → tool result content for filling in tool cards.
const resultMap = new Map<string, string>();
for (const m of messages) {
if (m.role === "tool" && m.toolCallId && !resultMap.has(m.toolCallId)) {
resultMap.set(m.toolCallId, m.content);
}
}

const items: Item[] = [];
let seq = 0;
for (const m of messages) {
if (m.role === "system" || m.role === "tool") continue;
if (m.role === "user") {
if (m.content.trim() === "") continue;
items.push({ kind: "user", id: `hp${seq}`, text: m.content });
seq++;
continue;
}
if (m.role === "assistant") {
const hasText = m.content.trim() !== "" || (m.reasoning ?? "").trim() !== "";
const toolCalls = m.toolCalls ?? [];
if (hasText) {
items.push({
kind: "assistant",
id: `hp${seq}`,
text: m.content,
reasoning: m.reasoning ?? "",
streaming: false,
});
seq++;
}
for (const tc of toolCalls) {
const output = resultMap.get(tc.id) ?? "";
const hasError = output.startsWith("[error") || output.startsWith("Error:");
items.push({
kind: "tool",
id: tc.id || `hpt${seq}`,
name: tc.name,
args: tc.arguments ?? "",
readOnly: false,
status: hasError ? "error" : "done",
output,
error: hasError ? output : undefined,
});
seq++;
}
}
}
return items;
}
16 changes: 10 additions & 6 deletions desktop/frontend/src/components/Transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,16 @@ export function Transcript({
}, [openTurn]);

// Each user message's turn = its ordinal among user messages, so a rewind
// targets the matching checkpoint.
const userTurn = new Map<string, number>();
let nt = 0;
for (const it of items) {
if (it.kind === "user") userTurn.set(it.id, nt++);
}
// targets the matching checkpoint. Memoized so it isn't rebuilt on every
// streaming re-render.
const userTurn = useMemo(() => {
const m = new Map<string, number>();
let nt = 0;
for (const it of items) {
if (it.kind === "user") m.set(it.id, nt++);
}
return m;
}, [items]);

return (
<div className="transcript" ref={scrollRef} onScroll={onScroll}>
Expand Down
44 changes: 41 additions & 3 deletions desktop/frontend/src/components/WorkspacePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ const WORKSPACE_PREVIEW_MIN_WIDTH = 420;
const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 92;
const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 48;

// Encoding options for the file encoding override selector.
const ENCODING_OPTIONS = [
{ value: "", label: "Auto" },
{ value: "UTF-8", label: "UTF-8" },
{ value: "UTF-8 BOM", label: "UTF-8 BOM" },
{ value: "GB18030", label: "GB18030" },
{ value: "UTF-16 LE", label: "UTF-16 LE" },
{ value: "UTF-16 BE", label: "UTF-16 BE" },
{ value: "UTF-16 LE (no BOM)", label: "UTF-16 LE (no BOM)" },
{ value: "UTF-16 BE (no BOM)", label: "UTF-16 BE (no BOM)" },
] as const;

function clampWorkspaceTreeWidth(width: number, panelWidth?: number): number {
const maxForPanel =
typeof panelWidth === "number" && Number.isFinite(panelWidth)
Expand Down Expand Up @@ -187,6 +199,18 @@ export function WorkspacePanel({
const [treeVisible, setTreeVisible] = useState(true);
const [treeWidth, setTreeWidth] = useState(loadWorkspaceTreeWidth);
const [treeResizing, setTreeResizing] = useState(false);
const [projectEncoding, setProjectEncoding] = useState<string>("");

// Load project encoding from settings on panel open / workspace change.
useEffect(() => {
if (!open) return;
app.Settings().then((s) => setProjectEncoding(s.fileEncoding || "")).catch(() => {});
}, [cwd, open]);

const changeProjectEncoding = useCallback(async (enc: string) => {
setProjectEncoding(enc);
await app.SetFileEncoding(enc).catch(() => {});
}, []);

const loadDir = useCallback(async (dir: string) => {
const entries = await app.ListDir(dir).catch(() => []);
Expand Down Expand Up @@ -267,7 +291,7 @@ export function WorkspacePanel({
let live = true;
setLoadingPreview(true);
app
.ReadFile(selectedPath)
.ReadFile(selectedPath, projectEncoding || "")
.then((next) => {
if (live) setPreview(next);
})
Expand All @@ -289,7 +313,7 @@ export function WorkspacePanel({
return () => {
live = false;
};
}, [selectedPath]);
}, [selectedPath, projectEncoding]);

useEffect(() => {
if (!open || !selectedPath) return;
Expand Down Expand Up @@ -496,7 +520,7 @@ export function WorkspacePanel({
const target = treeMenu;
setTreeMenu(null);
try {
const file = await app.ReadFile(target.path);
const file = await app.ReadFile(target.path, "");
if (file.err || file.binary) {
onAddToChat?.(formatWorkspaceReference(target.path, false));
return;
Expand Down Expand Up @@ -776,6 +800,20 @@ export function WorkspacePanel({
<RefreshCw size={14} />
</button>
</Tooltip>
<Tooltip label={t("workspace.encodingOverride")}>
<select
className="workspace-encoding-select"
value={projectEncoding}
onChange={(e) => void changeProjectEncoding(e.target.value)}
title={projectEncoding ? t("workspace.encodingTitle").replace("{enc}", projectEncoding) : t("workspace.encoding")}
>
{ENCODING_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.value === "" ? t("workspace.encodingAuto") : opt.label}
</option>
))}
</select>
</Tooltip>
</div>

<div className="workspace-search">
Expand Down
10 changes: 8 additions & 2 deletions desktop/frontend/src/lib/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export interface AppBindings {
SlashArgs(input: string): Promise<SlashArgsResult>;
ListDir(rel: string): Promise<DirEntry[]>;
SearchFileRefs(query: string): Promise<DirEntry[]>;
ReadFile(rel: string): Promise<FilePreview>;
ReadFile(rel: string, enc?: string): Promise<FilePreview>;
WorkspaceChanges(): Promise<WorkspaceChangesView>;
OpenWorkspacePath(rel: string): Promise<void>;
RevealWorkspacePath(rel: string): Promise<void>;
Expand Down Expand Up @@ -139,6 +139,8 @@ export interface AppBindings {
// SetBypass toggles YOLO mode (auto-approve every tool call this session; deny
// rules still apply). Runtime-only — not written to config.
SetBypass(on: boolean): Promise<void>;
// SetFileEncoding sets the project-level file encoding for read/write tools.
SetFileEncoding(encoding: string): Promise<void>;
// Auto-updater (desktop/updater_app.go): the injected build version, a manifest
// check, applying an update (win/linux self-update; macOS opens the download
// page), and opening that page directly. Progress streams on "updater:progress".
Expand Down Expand Up @@ -392,6 +394,7 @@ function makeMockApp(): AppBindings {
configPath: "~/projects/reasonix/reasonix.toml",
providerKinds: ["openai"],
bypass: false,
fileEncoding: "UTF-8",
};
return {
async Platform() {
Expand Down Expand Up @@ -789,7 +792,7 @@ function makeMockApp(): AppBindings {
.filter((path) => path.split("/").pop()?.toLowerCase().includes(q))
.map((name) => ({ name, isDir: false }));
},
async ReadFile(rel: string) {
async ReadFile(rel: string, _enc?: string) {
const samples: Record<string, string> = {
"README.md": "# Reasonix\n\nBrowser-dev workspace preview.\n\n- Chat in the center\n- Browse files on the right\n- Keep sessions on the left\n",
"go.mod": "module reasonix\n\ngo 1.23\n",
Expand Down Expand Up @@ -940,6 +943,9 @@ function makeMockApp(): AppBindings {
async SetBypass(on: boolean) {
settings.bypass = on;
},
async SetFileEncoding(encoding: string) {
settings.fileEncoding = encoding;
},
async Version() {
return "v1.0.0 (browser dev)";
},
Expand Down
23 changes: 10 additions & 13 deletions desktop/frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,26 +45,13 @@ export interface WireUsage {
cacheHitTokens: number;
cacheMissTokens: number;
reasoningTokens?: number;
cacheDiagnostics?: WireCacheDiagnostics;
// Session-cumulative cache tokens — the status bar shows the aggregate
// hit-rate (Σhit/Σ(hit+miss)), steadier than the single-turn cacheHitTokens.
sessionCacheHitTokens: number;
sessionCacheMissTokens: number;
costUsd?: number;
}

export interface WireCacheDiagnostics {
prefixHash: string;
prefixChanged: boolean;
prefixChangeReasons?: string[];
systemHash: string;
toolsHash: string;
logRewriteVersion: number;
toolSchemaTokens: number;
cacheMissTokens: number;
cacheHitTokens: number;
}

export interface WireApproval {
id: string;
tool: string;
Expand Down Expand Up @@ -115,6 +102,15 @@ export interface HistoryMessage {
role: string;
content: string;
reasoning?: string;
toolCalls?: HistoryToolCall[];
toolCallId?: string;
toolName?: string;
}

export interface HistoryToolCall {
id: string;
name: string;
arguments: string;
}

// CheckpointMeta is one rewind point (a user turn) for the rewind UI.
Expand Down Expand Up @@ -406,6 +402,7 @@ export interface SettingsView {
configPath: string;
providerKinds: string[]; // provider implementations the kernel registered (for the kind picker)
bypass: boolean; // live YOLO state (runtime-only) — whether approvals are skipped this session
fileEncoding: string; // project-level file encoding (e.g. "UTF-8", "GB18030")
}

// Auto-updater payloads (desktop/updater.go). UpdateInfo drives the update banner;
Expand Down
Loading