Skip to content
2 changes: 2 additions & 0 deletions desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,7 @@ func (a *App) shutdown(context.Context) {
_ = repair.RecordHealthyConfig(version)
}
}
killWebView2Processes()
}

// domReady is called (via OnDomReady) after the webview finishes loading its DOM
Expand Down Expand Up @@ -868,6 +869,7 @@ func (a *App) domReady(_ context.Context) {

runtime.WindowShow(a.ctx)
a.startupReady.Store(true)
killOrphanWebView2()
if a.startupTracker != nil {
_ = a.startupTracker.MarkReady()
tracker := a.startupTracker
Expand Down
182 changes: 108 additions & 74 deletions desktop/frontend/src/components/Composer.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion desktop/frontend/src/components/ComposerContextCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function ComposerContextCard({
type="button"
aria-label={removeLabel}
disabled={removeDisabled}
onClick={onRemove}
onClick={(e) => { e.stopPropagation(); onRemove(); }}
>
<X size={removeIconSize ?? (variant === "attachment" ? 14 : 13)} aria-hidden="true" />
</button>
Expand Down
15 changes: 7 additions & 8 deletions desktop/frontend/src/components/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Markdown } from "./Markdown";
import { CopyButton } from "./CopyButton";
import { ProcessBrainIcon } from "./ProcessCard";
import { ComposerContextCard } from "./ComposerContextCard";
import { formatAttachmentRefForDisplay, formatAttachmentRefForSubmit, parseAttachmentRefsForDisplay, sortDisplayAttachments } from "../lib/attachmentDisplay";
import { formatAttachmentRefForDisplay, formatAttachmentRefForSubmit, highlightRefsInText, parseAttachmentRefsForDisplay, sortDisplayAttachments } from "../lib/attachmentDisplay";
import type { DisplayAttachment } from "../lib/attachmentDisplay";
import { app } from "../lib/bridge";
import { replaySubmitText } from "../lib/editReplay";
Expand Down Expand Up @@ -204,6 +204,7 @@ export function UserMessage({
const invocationSegments = imSource ? [] : invocationSegmentsFromMessage(displayText, submitText, invocationMetadata);
const hasInvocationSegments = invocationSegments.some((segment) => segment.type === "invocation");
const orderedAttachments = sortDisplayAttachments(attachments);
const cardAttachments = orderedAttachments;
const sourceLabel = imSource ? imSourceLabel(imSource, t) : "";
const sentAt = createdAt === undefined ? null : messageDate(createdAt);
const canEdit = turn !== undefined && onEdit !== undefined && !editDisabled;
Expand Down Expand Up @@ -451,7 +452,7 @@ export function UserMessage({
{hasInvocationSegments && pasteBlocks.length === 0 ? (
<div className="msg__text msg__rich-text">
{invocationSegments.map((segment, index) => segment.type === "text"
? <span key={`text:${segment.start}:${index}`}>{segment.content}</span>
? <span key={`text:${segment.start}:${index}`}>{highlightRefsInText(segment.content).map((s, j) => s.isRef ? <span key={j} className="ref-highlight">{s.text}</span> : s.text)}</span>
: (
<InvocationBadge
key={`invocation:${segment.invocation.name}:${segment.offset}:${index}`}
Expand All @@ -463,7 +464,7 @@ export function UserMessage({
</div>
) : displaySegments.map((seg, i) => {
if (seg.type === "text") {
return seg.content ? <div className="msg__text" key={`s${i}`}>{seg.content}</div> : null;
return seg.content ? <div className="msg__text" key={`s${i}`}>{highlightRefsInText(seg.content).map((s, j) => s.isRef ? <span key={j} className="ref-highlight">{s.text}</span> : s.text)}</div> : null;
}
const expanded = Boolean(expandedPasteLabels[seg.block.label]);
return (
Expand All @@ -490,9 +491,9 @@ export function UserMessage({
</>
)}
{failed && <div className="msg__send-failed">{t("msg.sendFailed")}</div>}
{orderedAttachments.length > 0 && (
{cardAttachments.length > 0 && (
<div className="msg-attachments" aria-label={t("msg.attachments")}>
{orderedAttachments.map((attachment, index) => {
{cardAttachments.map((attachment, index) => {
const isImage = attachment.kind === "image";
const el = (
<div
Expand All @@ -510,9 +511,7 @@ export function UserMessage({
<span className="msg-attachment__main">
<span className="msg-attachment__name">{attachment.name}</span>
<span className="msg-attachment__meta">
{attachment.kind === "folder"
? t("msg.folderReference")
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
{attachment.path}
</span>
</span>
</div>
Expand Down
37 changes: 35 additions & 2 deletions desktop/frontend/src/components/RichComposerInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,39 @@ type RenderedComposerModel = ComposerModel & {

const CARET_SENTINEL = "\u00A0";

const CMD_HIGHLIGHT_RE = /(^|\s)(@(?!\[)[^\s]+|\/[A-Za-z][A-Za-z0-9_.:-]*|#[^\s]+|![^\s]+)/g;
const TRAILING_PUNCTUATION_RE = /[,.;:,。;:]+$/;

function renderHighlightedText(text: string, keyPrefix: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
CMD_HIGHLIGHT_RE.lastIndex = 0;
while ((match = CMD_HIGHLIGHT_RE.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
}
let token = match[2];
const punctMatch = TRAILING_PUNCTUATION_RE.exec(token);
const trailing = punctMatch ? punctMatch[0] : "";
const core = punctMatch ? token.slice(0, -trailing.length) : token;
if (match[1]) nodes.push(match[1]);
if (core) {
nodes.push(
<span key={`${keyPrefix}:hl:${match.index}`} className="composer-cmd-highlight">
{core}
</span>,
);
}
if (trailing) nodes.push(trailing);
lastIndex = match.index + match[0].length - trailing.length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}

function sameComposerModel(left: ComposerModel, right: ComposerModel | null): boolean {
if (!right || left.text !== right.text || left.invocations.length !== right.invocations.length) return false;
return left.invocations.every((item, index) => {
Expand Down Expand Up @@ -358,7 +391,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, {
let cursor = 0;
renderedOrdered.forEach((item) => {
const offset = Math.max(cursor, Math.min(renderedModel.text.length, item.offset));
if (offset > cursor) children.push(renderedModel.text.slice(cursor, offset));
if (offset > cursor) children.push(...renderHighlightedText(renderedModel.text.slice(cursor, offset), `seg:${item.id}`));
const invocation = invocationDisplayForCommand(item.command);
children.push(
<span
Expand Down Expand Up @@ -396,7 +429,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, {
);
cursor = offset;
});
if (cursor < renderedModel.text.length) children.push(renderedModel.text.slice(cursor));
if (cursor < renderedModel.text.length) children.push(...renderHighlightedText(renderedModel.text.slice(cursor), "tail"));

return (
<div
Expand Down
33 changes: 20 additions & 13 deletions desktop/frontend/src/lib/attachmentDisplay.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { escapeRefPath, refTokenRe, unescapeRefPath } from "./refToken";
import { escapeRefPath, refTokenRe } from "./refToken";

const attachmentRefRe = /@(\.reasonix\/attachments\/[^\s]+)/g;
const namedAttachmentRefRe = /(^|\s)@\[([^\]\r\n]+)\]\(([^)\s]+)\)/g;
Expand Down Expand Up @@ -107,18 +107,6 @@ export function parseAttachmentRefsForDisplay(text: string): { text: string; att
attachments.push(displayAttachment(core, name));
return lead + suffix;
})
.replace(refTokenRe(), (_full, lead: string, token: string) => {
const { core, suffix } = splitTrailingPunctuation(token);
const path = unescapeRefPath(core);
if (!path || !isDisplayReference(path)) return _full;
const name = baseName(path) || "attachment";
const attachment = displayAttachment(path, name);
attachments.push(attachment);
return lead + suffix;
})
.replace(/[ \t]+([.,;!?)\]},。;!?)】])/g, "$1")
.replace(/[ \t]{2,}/g, " ")
.trim();
return { text: cleaned, attachments };
}

Expand Down Expand Up @@ -158,3 +146,22 @@ function displayAttachment(path: string, name: string): DisplayAttachment {
ext: isDir ? "" : attachmentExt(path).replace(/^\./, "").toUpperCase(),
};
}

export function highlightRefsInText(text: string): Array<{ text: string; isRef: boolean }> {
const segments: Array<{ text: string; isRef: boolean }> = [];
const re = refTokenRe();
re.lastIndex = 0;
let match: RegExpExecArray | null;
let lastIndex = 0;
while ((match = re.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ text: text.slice(lastIndex, match.index), isRef: false });
}
segments.push({ text: match[0], isRef: true });
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
segments.push({ text: text.slice(lastIndex), isRef: false });
}
return segments;
}
1 change: 1 addition & 0 deletions desktop/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,7 @@ export const en = {
"imageViewer.title": "Image preview",
"composer.attachImageFailed": "Image paste failed",
"composer.attachFileFailed": "File attach failed",
"composer.attachDropEmptyFile": "File is empty (0 bytes), cannot attach",
"composer.attachDropFailed": "Dropped file attach failed",
"composer.pasteImageFailed": "Could not read clipboard image",
"composer.imageInputUnsupported": "Current model will not receive images directly. Use an image-capable model or an OCR/vision MCP tool to inspect the attached path.",
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1919,6 +1919,7 @@ export const zhTW: Record<DictKey, string> = {
"composer.shellModeOn": "Shell 模式已開:點選移除 !",
"composer.attachImageFailed": "圖片貼上失敗",
"composer.attachFileFailed": "檔案附加失敗",
"composer.attachDropEmptyFile": "文件為空(0位元組),無法附加",
"composer.attachDropFailed": "拖放檔案附加失敗",
"composer.pasteImageFailed": "未能讀取剪貼簿圖片",
"status.ctxLabel": "上下文",
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ export const zh: Record<DictKey, string> = {
"imageViewer.title": "图片预览",
"composer.attachImageFailed": "图片粘贴失败",
"composer.attachFileFailed": "文件附加失败",
"composer.attachDropEmptyFile": "文件为空(0字节),无法附加",
"composer.attachDropFailed": "拖放文件附加失败",
"composer.pasteImageFailed": "未能读取剪贴板图片",
"composer.imageInputUnsupported": "当前模型不会直接接收图片。可切换到支持图片的模型,或让 OCR/识图 MCP 工具读取附件路径。",
Expand Down
14 changes: 14 additions & 0 deletions desktop/frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -6018,6 +6018,20 @@ body > .mermaid-diagram--fullscreen {
display: block;
width: 6px;
}

/* ── command / ref highlighting ─────────────────────────────────── */
.composer-cmd-highlight {
color: var(--accent);
}
.ref-highlight {
color: var(--accent);
}
.ref-highlight--clickable {
cursor: pointer;
}
.ref-highlight--clickable:hover {
text-decoration: underline;
}
.composer__input::placeholder {
color: var(--fg-faint);
}
Expand Down
8 changes: 8 additions & 0 deletions desktop/kill_webview2_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//go:build !windows

package main

// WebView2 only exists on Windows. macOS uses WKWebView (in-process),
// Linux uses WebKitGTK (in-process). No cleanup needed.
func killWebView2Processes() {}
func killOrphanWebView2() {}
90 changes: 90 additions & 0 deletions desktop/kill_webview2_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//go:build windows

package main

import (
"log/slog"
"os"
"strings"
"unsafe"

"golang.org/x/sys/windows"
)

// killWebView2Processes terminates lingering msedgewebview2.exe child processes
// that may hold file locks on session data after the main window closes.
// Called from App.shutdown() — runs once, ~3-10 ms.
func killWebView2Processes() {
myPid := uint32(os.Getpid())

snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
slog.Warn("desktop: killWebView2Processes: CreateToolhelp32Snapshot failed", "err", err)
return
}
defer windows.CloseHandle(snap)

var pe windows.ProcessEntry32
pe.Size = uint32(unsafe.Sizeof(pe))

for err := windows.Process32First(snap, &pe); err == nil; err = windows.Process32Next(snap, &pe) {
name := windows.UTF16ToString(pe.ExeFile[:])
if !strings.EqualFold(name, "msedgewebview2.exe") {
continue
}
if pe.ParentProcessID != myPid {
continue
}

handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, pe.ProcessID)
if err != nil {
slog.Debug("desktop: killWebView2Processes: OpenProcess failed (already exited?)",
"pid", pe.ProcessID, "err", err)
continue
}

if err := windows.TerminateProcess(handle, 0); err != nil {
slog.Warn("desktop: killWebView2Processes: TerminateProcess failed",
"pid", pe.ProcessID, "err", err)
} else {
slog.Debug("desktop: killWebView2Processes: terminated",
"pid", pe.ProcessID, "name", name)
}
windows.CloseHandle(handle)
}
}

// killOrphanWebView2 cleans up WebView2 processes left behind by a previous
// crash (parent PID no longer exists). Called from App.startup().
func killOrphanWebView2() {
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return
}
defer windows.CloseHandle(snap)

var pe windows.ProcessEntry32
pe.Size = uint32(unsafe.Sizeof(pe))

for err := windows.Process32First(snap, &pe); err == nil; err = windows.Process32Next(snap, &pe) {
name := windows.UTF16ToString(pe.ExeFile[:])
if !strings.EqualFold(name, "msedgewebview2.exe") {
continue
}
parent := pe.ParentProcessID
if parent == 0 {
continue
}
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, parent)
if err == nil {
windows.CloseHandle(h)
continue
}
h2, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, pe.ProcessID)
if err != nil {
continue
}
windows.TerminateProcess(h2, 0)
windows.CloseHandle(h2)
}
}
15 changes: 11 additions & 4 deletions desktop/open_workspace_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@

package main

import "golang.org/x/sys/windows"
import (
"fmt"
"golang.org/x/sys/windows"
)

func openWorkspacePath(path string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
return fmt.Errorf("open %q: %w", path, err)
}
file, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
return fmt.Errorf("open %q: %w", path, err)
}
return windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
err = windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
if err != nil {
return fmt.Errorf("open %q: %w", path, err)
}
return nil
}
Loading
Loading