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
176 changes: 158 additions & 18 deletions desktop/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } f
import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand";
import {
SquarePen,
Brain,
Blocks,
Download,
SquarePen,
CircleGauge,
FileText,
FileJson,
GitBranch,
History,
Settings as SettingsIcon,
Pencil,
MoreHorizontal,
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
Expand All @@ -21,7 +21,7 @@ import {
import logoWordmark from "./assets/logo-wordmark.svg";
import { asArray } from "./lib/array";
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, t, useI18n, useT } from "./lib/i18n";
import { useController } from "./lib/useController";
import { useController, type Item, type LiveStream } from "./lib/useController";
import { app, onProjectTreeChanged } from "./lib/bridge";
import { Transcript } from "./components/Transcript";
import { Composer } from "./components/Composer";
Expand All @@ -40,6 +40,7 @@ import { Tooltip } from "./components/Tooltip";
import { OnboardingOverlay } from "./components/OnboardingOverlay";
import { TabBar } from "./components/TabBar";
import { ProjectTree } from "./components/ProjectTree";
import { CopyButton } from "./components/CopyButton";
import { parseTodos } from "./lib/tools";
import { shouldShowTodoPanel } from "./lib/todoVisibility";
import type { ComposerInsertRequest, MemoryView, Meta, Mode, SessionMeta, TabMeta } from "./lib/types";
Expand Down Expand Up @@ -208,6 +209,95 @@ function workspaceDisplayName(path?: string): string {
return parts.length > 0 ? parts[parts.length - 1] : path;
}

function materializeLiveItems(items: Item[], live?: LiveStream): Item[] {
if (!live) return items;
return items.map((item) => {
if (item.kind !== "assistant" || item.id !== live.id) return item;
return { ...item, text: live.text, reasoning: live.reasoning, streaming: true };
});
}

function fence(label: string, value: string): string {
if (!value.trim()) return "";
const fenceToken = value.includes("```") ? "````" : "```";
return `${label}\n${fenceToken}\n${value.trim()}\n${fenceToken}`;
}

function sessionItemsToMarkdown(title: string, items: Item[], live?: LiveStream): string {
const lines: string[] = [`# ${title.trim() || "Reasonix session"}`, ""];
for (const item of materializeLiveItems(items, live)) {
switch (item.kind) {
case "user":
lines.push("## User", "", item.text.trim(), "");
break;
case "assistant":
lines.push("## Assistant");
if (item.reasoning.trim()) {
lines.push("", "### Reasoning", "", item.reasoning.trim());
}
if (item.text.trim()) {
lines.push("", item.text.trim());
}
lines.push("");
break;
case "tool":
lines.push(`### Tool: ${item.name}`);
if (item.args.trim()) lines.push("", fence("Args", item.args));
if (item.output?.trim()) lines.push("", fence("Output", item.output));
if (item.error?.trim()) lines.push("", fence("Error", item.error));
lines.push("");
break;
case "phase":
lines.push(`### Phase`, "", item.text.trim(), "");
break;
case "notice":
lines.push(`### ${item.level === "warn" ? "Warning" : "Notice"}`, "", item.text.trim(), "");
break;
case "compaction":
lines.push("### Context Compaction", "");
if (item.pending) {
lines.push("Compaction pending.");
} else {
lines.push(`Messages: ${item.messages}`);
if (item.trigger) lines.push(`Trigger: ${item.trigger}`);
if (item.summary.trim()) lines.push("", item.summary.trim());
}
lines.push("");
break;
}
}
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
}

function sessionItemsToJson(title: string, items: Item[], live?: LiveStream): string {
return JSON.stringify(
{
title,
exportedAt: new Date().toISOString(),
items: materializeLiveItems(items, live),
},
null,
2,
);
}

function safeFilename(name: string): string {
const cleaned = name.trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 80);
return cleaned || "reasonix-session";
}

function downloadTextFile(filename: string, text: string, mime: string): void {
const blob = new Blob([text], { type: `${mime};charset=utf-8` });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}


/** Global hotkey handler for shell-expand toggle (Ctrl/Cmd+B). */
function ShellHotkeys() {
Expand Down Expand Up @@ -291,6 +381,7 @@ export default function App() {
const [capsOpen, setCapsOpen] = useState(false);
const [renamingTopicId, setRenamingTopicId] = useState<string | null>(null);
const [topicTitleDraft, setTopicTitleDraft] = useState("");
const [topicExportOpen, setTopicExportOpen] = useState(false);
const topicRenameSkipCommitRef = useRef(false);
const topicRenameCommitHandledRef = useRef(false);

Expand Down Expand Up @@ -520,6 +611,30 @@ export default function App() {
// and a transcript update collide, the keystroke is processed immediately
// and the transcript re-render is deferred to idle time.
const deferredItems = useDeferredValue(state.items);
const sessionTitle = topicTitle(activeTab);
const sessionMarkdown = useMemo(() => sessionItemsToMarkdown(sessionTitle, state.items, state.live), [sessionTitle, state.items, state.live]);
const sessionJson = useMemo(() => sessionItemsToJson(sessionTitle, state.items, state.live), [sessionTitle, state.items, state.live]);
const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning);

useEffect(() => {
if (!topicExportOpen) return;
const onDown = (event: MouseEvent) => {
const target = event.target as Element | null;
if (!target?.closest(".topicbar__export")) setTopicExportOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [topicExportOpen]);

const exportSession = useCallback(
(format: "markdown" | "json") => {
const base = safeFilename(sessionTitle);
if (format === "json") downloadTextFile(`${base}.json`, sessionJson, "application/json");
else downloadTextFile(`${base}.md`, sessionMarkdown, "text/markdown");
setTopicExportOpen(false);
},
[sessionJson, sessionMarkdown, sessionTitle],
);

useEffect(() => {
if (!pendingPlanRevision || state.running) return;
Expand Down Expand Up @@ -1234,16 +1349,13 @@ export default function App() {
<span>{t("sidebar.trash")}</span>
</button>
</Tooltip>
<Tooltip label={t("topbar.memory")} fill side="right" disabled={sidebarNavTooltipDisabled}>
<button className="sidebar__navitem" onClick={() => void openMemory()}>
<Brain size={15} />
<span>{t("topbar.memory")}</span>
</button>
</Tooltip>
<Tooltip label={t("caps.title")} fill side="right" disabled={sidebarNavTooltipDisabled}>
<button className="sidebar__navitem" onClick={() => setCapsOpen(true)}>
<Tooltip label={t("sidebar.capabilities")} fill side="right" disabled={sidebarNavTooltipDisabled}>
<button
className="sidebar__navitem"
onClick={() => setCapsOpen(true)}
>
<Blocks size={15} />
<span>{t("caps.title")}</span>
<span>{t("sidebar.capabilities")}</span>
</button>
</Tooltip>
<Tooltip label={t("topbar.settings")} fill side="right" disabled={sidebarNavTooltipDisabled}>
Comment thread
SivanCola marked this conversation as resolved.
Expand Down Expand Up @@ -1350,11 +1462,39 @@ export default function App() {
</div>
<div className="topicbar__spacer" />
<div className="topicbar__actions">
<Tooltip label={t("topicBar.more")}>
<button className="topicbar__icon-btn">
<MoreHorizontal size={16} />
</button>
</Tooltip>
<CopyButton
text={sessionMarkdown}
label={t("topicBar.copyAll")}
showLabel={false}
className="topicbar__action-btn topicbar__action-btn--icon"
/>
<div className={`topicbar__export${topicExportOpen ? " topicbar__export--open" : ""}`}>
<Tooltip label={t("topicBar.export")}>
<button
className="topicbar__action-btn topicbar__action-btn--icon"
type="button"
disabled={!sessionHasContent}
aria-label={t("topicBar.export")}
aria-haspopup="menu"
aria-expanded={topicExportOpen}
onClick={() => setTopicExportOpen((open) => !open)}
>
<Download size={14} />
</button>
</Tooltip>
{topicExportOpen && (
<div className="topicbar__export-menu" role="menu">
Comment thread
SivanCola marked this conversation as resolved.
<button type="button" role="menuitem" onClick={() => exportSession("markdown")}>
<FileText size={13} />
<span>{t("topicBar.exportMarkdown")}</span>
</button>
<button type="button" role="menuitem" onClick={() => exportSession("json")}>
<FileJson size={13} />
<span>{t("topicBar.exportJson")}</span>
</button>
</div>
)}
</div>
</div>
</header>

Expand Down
9 changes: 6 additions & 3 deletions desktop/frontend/src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ export function CopyButton({
text,
className,
label,
showLabel = Boolean(label),
}: {
text: string;
className?: string;
label?: string;
showLabel?: boolean;
}) {
const t = useT();
const [copied, setCopied] = useState(false);
const actionLabel = label ?? t("msg.copy");
const copy = async () => {
try {
await navigator.clipboard.writeText(text);
Expand All @@ -27,15 +30,15 @@ export function CopyButton({
}
};
return (
<Tooltip label={t("msg.copy")}>
<Tooltip label={copied ? t("msg.copied") : actionLabel}>
<button
className={`copybtn ${className ?? ""}`}
onClick={copy}
aria-label={t("msg.copy")}
aria-label={actionLabel}
type="button"
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{label && <span className="copybtn__label">{copied ? t("msg.copied") : label}</span>}
{label && showLabel && <span className="copybtn__label">{copied ? t("msg.copied") : label}</span>}
</button>
</Tooltip>
);
Expand Down
5 changes: 5 additions & 0 deletions desktop/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const en = {
"sidebar.conversations": "Chats",
"sidebar.allHistory": "All history",
"sidebar.trash": "Trash",
"sidebar.capabilities": "MCP & Skills",
"sidebar.workspace": "Workspace",
"sidebar.changeWorkspace": "Change",
"sidebar.navigation": "Reasonix navigation",
Expand All @@ -59,6 +60,10 @@ export const en = {
// topic bar
"topicBar.renameSession": "Rename session",
"topicBar.more": "More",
"topicBar.copyAll": "Copy session",
"topicBar.export": "Export session",
"topicBar.exportMarkdown": "Export Markdown",
"topicBar.exportJson": "Export JSON",

// scope labels
"scope.global": "Scope: Global",
Expand Down
5 changes: 5 additions & 0 deletions desktop/frontend/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const zh: Record<DictKey, string> = {
"sidebar.conversations": "会话",
"sidebar.allHistory": "全部历史",
"sidebar.trash": "回收站",
"sidebar.capabilities": "MCP 与技能",
"sidebar.workspace": "工作区",
"sidebar.changeWorkspace": "更改",
"sidebar.navigation": "Reasonix 导航",
Expand All @@ -60,6 +61,10 @@ export const zh: Record<DictKey, string> = {
// 话题栏
"topicBar.renameSession": "重命名会话",
"topicBar.more": "更多",
"topicBar.copyAll": "复制会话",
"topicBar.export": "导出会话",
"topicBar.exportMarkdown": "导出 Markdown",
"topicBar.exportJson": "导出 JSON",

// 范围标签
"scope.global": "范围:全局",
Expand Down
75 changes: 75 additions & 0 deletions desktop/frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -6915,6 +6915,81 @@ a[href] {
background: transparent;
}

.topicbar__action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 30px;
height: 30px;
padding: 0 8px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--fg-faint);
font: inherit;
}

.topicbar__action-btn--icon {
width: 30px;
padding: 0;
}

.topicbar__action-btn:hover:not(:disabled),
.topicbar__action-btn:focus-visible:not(:disabled),
.topicbar__export--open .topicbar__action-btn {
color: var(--fg);
background: var(--button-bg);
outline: none;
}

.topicbar__action-btn:disabled {
opacity: 0.45;
cursor: default;
}

.topicbar__export {
position: relative;
display: inline-flex;
}

.topicbar__export-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 210;
min-width: 168px;
padding: 5px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-elev);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.36);
--wails-draggable: no-drag;
}

.topicbar__export-menu button {
width: 100%;
min-height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--fg-dim);
font: inherit;
font-size: 12px;
text-align: left;
}

.topicbar__export-menu button:hover,
.topicbar__export-menu button:focus-visible {
color: var(--fg);
background: var(--sidebar-hover);
outline: none;
}

.tabbar {
flex: 1 1 auto;
min-width: 0;
Expand Down
Loading