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
153 changes: 116 additions & 37 deletions apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { useTranslation } from "react-i18next"
import { useEffect, useState, useMemo, useCallback } from "react"
import {
AlertTriangle,
Check,
CheckCircle2,
ChevronDown,
ChevronRight,
ChevronUp,
CircleAlert,
Copy,
ExternalLink,
Info,
X,
Expand Down Expand Up @@ -2329,6 +2331,113 @@ function ErrorMessage({ message, onOpenUrl, sessionId, onRetry }: { message: Mes
)
}

/**
* AssistantMessage - Separate component for assistant/agent responses so it can
* own the copy-button's `copied` state (a hook can't live in the role-switch of
* MessageBubble). Renders the markdown bubble plus hover actions: copy the
* response as Markdown (parity with Claude/ChatGPT/Codex desktop, which all
* offer a per-message copy) and the existing pop-out control.
*/
function AssistantMessage({
message,
renderMode,
onOpenUrl,
onOpenFile,
onPopOut,
}: {
message: Message
renderMode: RenderMode
onOpenUrl?: (url: string) => void
onOpenFile?: (path: string) => void
onPopOut?: (message: Message) => void
}) {
const { t } = useTranslation()
const [copied, setCopied] = React.useState(false)
const copyResetRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)

React.useEffect(() => {
return () => {
if (copyResetRef.current) clearTimeout(copyResetRef.current)
}
}, [])

const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(message.content)
setCopied(true)
if (copyResetRef.current) clearTimeout(copyResetRef.current)
copyResetRef.current = setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error('[ChatDisplay] Failed to copy assistant message:', error)
toast.error(t('toast.copyFailed'))
}
}, [message.content, t])

// Copy/pop-out only make sense on a settled (non-streaming) response.
const showActions = !message.isStreaming

return (
<div className="flex justify-start group">
<div className="relative max-w-[90%] bg-background shadow-minimal rounded-[8px] pl-6 pr-4 py-3 break-words min-w-0 select-text">
{showActions && (
<div className="absolute top-2 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{/* Copy response as Markdown */}
<button
type="button"
onClick={handleCopy}
data-testid="assistant-copy"
data-copied={copied ? 'true' : 'false'}
className="p-1.5 rounded-md hover:bg-foreground/5"
aria-label={copied ? t('common.copied') : t('common.copy')}
title={copied ? t('common.copied') : t('common.copy')}
>
{copied ? (
<Check className="w-4 h-4 text-success" />
) : (
<Copy className="w-4 h-4 text-muted-foreground hover:text-foreground" />
)}
</button>
{/* Pop-out button */}
{onPopOut && (
<button
type="button"
onClick={() => onPopOut(message)}
className="p-1.5 rounded-md hover:bg-foreground/5"
title={t("sidebarMenu.openInNewWindow")}
>
<ExternalLink className="w-4 h-4 text-muted-foreground hover:text-foreground" />
</button>
)}
</div>
)}
{/* Use StreamingMarkdown for block-level memoization during streaming */}
{message.isStreaming ? (
<StreamingMarkdown
content={message.content}
isStreaming={true}
mode={renderMode}
onUrlClick={onOpenUrl}
onFileClick={onOpenFile}
/>
) : (
<CollapsibleMarkdownProvider>
<Markdown
mode={renderMode}
onUrlClick={onOpenUrl}
onFileClick={onOpenFile}
id={message.id}
className="text-sm"
collapsible
>
{message.content}
</Markdown>
</CollapsibleMarkdownProvider>
)}
</div>
</div>
)
}

function MessageBubble({
message,
onOpenFile,
Expand Down Expand Up @@ -2367,43 +2476,13 @@ function MessageBubble({
// === ASSISTANT MESSAGE: Left-aligned gray bubble with markdown rendering ===
if (message.role === 'assistant') {
return (
<div className="flex justify-start group">
<div className="relative max-w-[90%] bg-background shadow-minimal rounded-[8px] pl-6 pr-4 py-3 break-words min-w-0 select-text">
{/* Pop-out button - visible on hover */}
{onPopOut && !message.isStreaming && (
<button
onClick={() => onPopOut(message)}
className="absolute top-2 right-2 p-1.5 rounded-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-foreground/5"
title={t("sidebarMenu.openInNewWindow")}
>
<ExternalLink className="w-4 h-4 text-muted-foreground hover:text-foreground" />
</button>
)}
{/* Use StreamingMarkdown for block-level memoization during streaming */}
{message.isStreaming ? (
<StreamingMarkdown
content={message.content}
isStreaming={true}
mode={renderMode}
onUrlClick={onOpenUrl}
onFileClick={onOpenFile}
/>
) : (
<CollapsibleMarkdownProvider>
<Markdown
mode={renderMode}
onUrlClick={onOpenUrl}
onFileClick={onOpenFile}
id={message.id}
className="text-sm"
collapsible
>
{message.content}
</Markdown>
</CollapsibleMarkdownProvider>
)}
</div>
</div>
<AssistantMessage
message={message}
renderMode={renderMode}
onOpenUrl={onOpenUrl}
onOpenFile={onOpenFile}
onPopOut={onPopOut}
/>
)
}

Expand Down
3 changes: 2 additions & 1 deletion docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | pr-open | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-03 | Renderer-only pref (localStorage) applied app-wide via `<MotionConfig reducedMotion>` + `data-reduce-motion` on `<html>` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; i18n parity ✅. CDP assertion included; **could not run locally** (egress 403s Electron binary download). |
| copy-message | "Copy" (copy-as-Markdown) action on assistant/agent responses | Codex desktop "Copy as Markdown" (openai/codex #2880, #17241) + Claude/ChatGPT desktop per-message copy | frontend-only | pr-open | [#70](https://github.com/modelstudioai/openwork/issues/70) | [#71](https://github.com/modelstudioai/openwork/pull/71) | loop/copy-message | 2026-07-08 | User messages + code blocks were already copyable, but full assistant responses had only a pop-out button — no copy. Extracted `AssistantMessage` component in `ChatDisplay.tsx` (owns `copied` state, mirrors the existing `ErrorMessage` extraction) with a hover copy button next to pop-out; copies raw `message.content` via `navigator.clipboard.writeText`, 2s "copied" check state, `toast.copyFailed` on error. **Zero new i18n keys** (reuses `common.copy`/`common.copied`/`toast.copyFailed`). Added a general `seed` hook to the e2e harness (`app.ts`/`runner.ts`) so assertions can pre-seed an on-disk session the embedded `SessionManager` loads on boot (no backend). typecheck:all zero-delta; `bun test` 56-failure set byte-identical to main; renderer build ✅; i18n parity ✅ (1544 keys); eslint 0 errors. CDP assertion (`copy-message.assert.ts`) seeds a user+assistant session, opens it, clicks the copy button, and asserts the exact response markdown reaches the (stubbed) clipboard + the button enters its copied state; **could not run locally** (org egress policy 403s the Electron binary download — same block as prior rounds). |
| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | merged | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-08 | **Merged** into `main` (2026-07-06). Renderer-only pref (localStorage) applied app-wide via `<MotionConfig reducedMotion>` + `data-reduce-motion` on `<html>` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. |
| composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude/ChatGPT/Codex desktop composer maximize | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | loop/composer-expand | 2026-07-03 | Opened by a prior run. Adds `isComposerExpanded` toggle in `FreeFormInput`; 2 new i18n keys. Awaiting review. |
| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code / ChatGPT / Codex desktop | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-02 | Opened by a prior run. Floating jump button in `ChatDisplay` + `seed()` harness hook. Awaiting review. |
| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-03 | **Merged** into `main` (2026-07-02). `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). |
Expand Down
21 changes: 21 additions & 0 deletions e2e/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,29 @@ const E2E_DIR = join(ROOT_DIR, '.e2e');
const MAIN_BUNDLE = join(ELECTRON_DIR, 'dist/main.cjs');
const RENDERER_HTML = join(ELECTRON_DIR, 'dist/renderer/index.html');

/** The isolated per-launch profile directories, handed to a {@link LaunchOptions.seed} hook. */
export interface ProfileDirs {
/** Electron userData (cache, cookies, local storage, single-instance lock). */
userDataDir: string;
/** App config + workspace registry (CRAFT_CONFIG_DIR / ~/.craft-agent). */
configDir: string;
/** Root of the default conversation workspace (QWEN_DEFAULT_WORKSPACE_DIR). */
workspaceDir: string;
}

export interface LaunchOptions {
/** Override the remote-debugging port (default: an ephemeral free port). */
port?: number;
/** Force a rebuild of the Electron bundles before launching. */
rebuild?: boolean;
/** Milliseconds to wait for the main renderer target to appear. */
startupTimeoutMs?: number;
/**
* Runs after the isolated profile dirs are created but BEFORE Electron starts,
* so an assertion can pre-seed on-disk state (e.g. a workspace + a session
* with messages) that the app will load on launch. Backend-independent.
*/
seed?: (dirs: ProfileDirs) => void | Promise<void>;
}

export interface LaunchedApp {
Expand Down Expand Up @@ -134,6 +150,11 @@ export async function launchApp(options: LaunchOptions = {}): Promise<LaunchedAp
mkdirSync(configDir, { recursive: true });
mkdirSync(workspaceDir, { recursive: true });

// Pre-seed on-disk state (workspaces/sessions) before the app boots.
if (options.seed) {
await options.seed({ userDataDir, configDir, workspaceDir });
}

const electronArgs = [
'apps/electron',
`--remote-debugging-port=${port}`,
Expand Down
161 changes: 161 additions & 0 deletions e2e/assertions/copy-message.assert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* Feature assertion: the "Copy" (copy-as-Markdown) action on assistant/agent
* responses.
*
* Drives the real built app over CDP through the full path:
* seed a 1-user + 1-assistant session on disk → open it → the assistant bubble
* renders → its copy button is present and reports the un-copied state → click
* it → the exact response Markdown is written to the clipboard AND the button
* flips to its "copied" state.
*
* The clipboard-content check proves the button copies the real response text
* (not merely that it renders or toggles an icon). `navigator.clipboard.writeText`
* is stubbed in-page before the click so the assertion is deterministic on a
* headless host where the OS clipboard/permission may be unavailable.
*
* A backend is NOT required: the session is pre-seeded as a plain `session.jsonl`
* under the isolated profile's default-workspace root before the app boots, so
* the transcript is real and local without invoking qwen-code.
*/

import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Assertion } from '../runner';
import type { ProfileDirs } from '../app';

const SESSION_ID = 'e2e-copy-seeded';
const SESSION_NAME = 'Copy seed';

/** The exact response text the copy button must place on the clipboard. */
const ASSISTANT_CONTENT =
'# Heading\n\nHere is the **assistant** response with a list:\n\n' +
'- one\n- two\n\n```ts\nconst x = 1\n```\n';

const ROW = `[data-session-id="${SESSION_ID}"]`;
const COPY_BTN = '[data-testid="assistant-copy"]';

/**
* Pre-seed a session with a user prompt and an assistant response so the
* transcript renders an assistant bubble (which owns the copy button). Written
* before Electron launches; the app lists and loads it on boot.
*/
function seed({ workspaceDir }: ProfileDirs): void {
const sessionDir = join(workspaceDir, 'sessions', SESSION_ID);
mkdirSync(sessionDir, { recursive: true });

const base = 1700000000000;
const header = {
id: SESSION_ID,
workspaceRootPath: workspaceDir,
name: SESSION_NAME,
createdAt: base,
lastUsedAt: base + 2,
lastMessageAt: base + 2,
sessionStatus: 'todo',
messageCount: 2,
lastMessageRole: 'assistant',
preview: 'Seeded conversation for copy-message testing',
};

const lines = [
JSON.stringify(header),
JSON.stringify({ id: 'm1', type: 'user', content: 'Please answer.', timestamp: base + 1 }),
JSON.stringify({ id: 'm2', type: 'assistant', content: ASSISTANT_CONTENT, timestamp: base + 2, turnId: 't2' }),
];
writeFileSync(join(sessionDir, 'session.jsonl'), lines.join('\n') + '\n', 'utf-8');
}

const assertion: Assertion = {
name: 'assistant message copy button copies the response markdown to the clipboard',
seed,
async run(app) {
const { session } = app;

// App fully mounted and at the ready AppShell (same anchors other assertions use).
await session.waitForFunction(
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
{ timeoutMs: 30000, message: 'React UI did not mount' },
);
await session.waitForSelector('[aria-label="Craft menu"]', {
timeoutMs: 30000,
message: 'app did not reach the ready AppShell state',
});

// 1. The seeded session appears in the sidebar list.
await session.waitForSelector(ROW, {
timeoutMs: 20000,
message: 'seeded session row did not appear in the session list',
});

// 2. Open it. The row selects on `mousedown` (not click), so dispatch a real
// left-button press + release on the row's button.
const opened = await session.evaluate<boolean>(`(() => {
const btn = document.querySelector(${JSON.stringify(ROW)} + ' button.entity-row-btn')
|| document.querySelector(${JSON.stringify(ROW)} + ' button')
|| document.querySelector(${JSON.stringify(ROW)});
if (!btn) return false;
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }));
return true;
})()`);
if (!opened) throw new Error('could not find/press the seeded session row');

// 3. The assistant bubble's copy button renders (proof the seeded response
// loaded, not the empty/draft state).
await session.waitForSelector(COPY_BTN, {
timeoutMs: 20000,
message: 'assistant copy button did not render for the seeded response',
});

// 4. It starts in the un-copied state.
const initialCopied = await session.evaluate<string | null>(
`document.querySelector(${JSON.stringify(COPY_BTN)})?.getAttribute('data-copied') ?? null`,
);
if (initialCopied !== 'false') {
throw new Error(`copy button should start un-copied, saw data-copied=${JSON.stringify(initialCopied)}`);
}

// 5. Stub clipboard.writeText so we can capture the copied text deterministically
// on a headless host, then click the button.
await session.evaluate(`(() => {
window.__copiedText = undefined;
try {
navigator.clipboard.writeText = (text) => { window.__copiedText = String(text); return Promise.resolve(); };
} catch {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: (text) => { window.__copiedText = String(text); return Promise.resolve(); } },
});
}
return true;
})()`);

const clicked = await session.evaluate<boolean>(`(() => {
const el = document.querySelector(${JSON.stringify(COPY_BTN)});
if (!el) return false;
el.click();
return true;
})()`);
if (!clicked) throw new Error('could not click the assistant copy button');

// 6. The exact response markdown was placed on the clipboard.
await session.waitForFunction('window.__copiedText !== undefined', {
timeoutMs: 5000,
message: 'clipboard.writeText was never called by the copy button',
});
const copiedText = await session.evaluate<string | null>('window.__copiedText ?? null');
if (copiedText !== ASSISTANT_CONTENT) {
throw new Error(
`copy button wrote the wrong text to the clipboard.\n expected: ${JSON.stringify(ASSISTANT_CONTENT)}\n actual: ${JSON.stringify(copiedText)}`,
);
}

// 7. The button reflects the copied state (proves it's a real, wired action).
await session.waitForFunction(
`document.querySelector(${JSON.stringify(COPY_BTN)})?.getAttribute('data-copied') === 'true'`,
{ timeoutMs: 5000, message: 'copy button did not enter its "copied" state after clicking' },
);
},
};

export default assertion;
Loading