Skip to content
Draft
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,036 changes: 1,031 additions & 5 deletions cowork/package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions cowork/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@
"@mariozechner/pi-coding-agent": "^0.60.0",
"@mediapipe/tasks-vision": "^0.10.18",
"@modelcontextprotocol/sdk": "^1.26.0",
"@types/dompurify": "3.0.5",
"archiver": "^7.0.1",
"better-sqlite3": "^12.8.0",
"chokidar": "^4.0.1",
"dompurify": "3.4.3",
"dotenv": "^17.2.3",
"electron-store": "^11.0.2",
"electron-updater": "^6.3.0",
Expand All @@ -73,6 +75,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"katex": "^0.16.27",
"lucide-react": "^0.468.0",
"mermaid": "11.15.0",
"ngrok": "^5.0.0-beta.2",
"onnxruntime-node": "^1.20.0",
"openai": "^6.32.0",
Expand Down
6 changes: 6 additions & 0 deletions cowork/src/renderer/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../store/selectors';
import { useAppStore } from '../store';
import { useIPC } from '../hooks/useIPC';
import { useTextareaAutogrow } from '../hooks/use-textarea-autogrow';
import { MessageCard } from './MessageCard';
import { ModelSwitcher } from './ModelSwitcher';
import { PermissionModeSelector } from './PermissionModeSelector';
Expand Down Expand Up @@ -126,6 +127,10 @@ export function ChatView() {
const isUserAtBottomRef = useRef(true);
const isComposingRef = useRef(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-grow the textarea between 44 and 200 px as the user types or
// pastes multi-line content. Mirrors the chat-ui gitnexus-rs ChatInput
// pattern so multi-line drafts don't push the chat history out of view.
useTextareaAutogrow(textareaRef, prompt);
const prevMessageCountRef = useRef(0);
const prevPartialLengthRef = useRef(0);
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -1200,6 +1205,7 @@ export function ChatView() {
<textarea
ref={textareaRef}
value={prompt}
style={{ minHeight: 44 }}
onChange={(e) => {
const newValue = e.target.value;
setPrompt(newValue);
Expand Down
158 changes: 158 additions & 0 deletions cowork/src/renderer/components/HealthBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useBackendStatus, type BackendStatus } from '../hooks/use-backend-status';

/**
* Small permanent badge showing the Code Buddy backend status. Mirrors
* the chat-ui gitnexus-rs `BackendStatus` UX: a colored dot in a
* compact pill, click to open a tooltip-popup with the endpoint, last
* success time, and the latest error if any.
*
* Hidden entirely when the CodeBuddy integration is disabled in
* config (no need to occupy precious titlebar real estate for a
* never-going-to-light-up badge).
*/
export function HealthBadge() {
const { t } = useTranslation();
const status = useBackendStatus();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!open) return;
const onDocClick = (event: MouseEvent) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(event.target as Node)) setOpen(false);
};
const onEsc = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onEsc);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onEsc);
};
}, [open]);

if (status.status === 'disabled') return null;

const style = badgeStyleFor(status.status);
const labelKey = `healthBadge.status.${status.status}`;
const label = t(labelKey, defaultLabelFor(status.status));

return (
<div ref={containerRef} className="relative" data-testid="health-badge">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full border text-[11px] transition-colors ${style.pill}`}
aria-label={label}
aria-expanded={open}
title={label}
>
<span className={`w-1.5 h-1.5 rounded-full ${style.dot}`} />
<span className="hidden sm:inline">{label}</span>
</button>
{open && <HealthPopup status={status} />}
</div>
);
}

function HealthPopup({ status }: { status: BackendStatus }) {
const { t } = useTranslation();
const lastSuccess = status.lastSuccessAt
? new Date(status.lastSuccessAt).toLocaleTimeString()
: t('healthBadge.never', 'jamais');

return (
<div
role="dialog"
className="absolute right-0 top-full mt-1 w-72 z-50 rounded-lg border border-border bg-surface shadow-lg p-3 text-[12px]"
>
<div className="font-medium text-text-primary mb-2">
{t('healthBadge.title', 'Backend Code Buddy')}
</div>
<div className="space-y-1 text-text-muted">
<Row label={t('healthBadge.endpoint', 'Endpoint')} value={status.endpoint ?? '—'} />
<Row
label={t('healthBadge.statusLabel', 'État')}
value={t(`healthBadge.status.${status.status}`, defaultLabelFor(status.status))}
/>
{status.version && (
<Row label={t('healthBadge.version', 'Version')} value={status.version} />
)}
<Row label={t('healthBadge.lastSuccess', 'Dernier OK')} value={lastSuccess} />
{status.lastError && (
<Row
label={t('healthBadge.lastError', 'Dernière erreur')}
value={status.lastError}
isError
/>
)}
</div>
</div>
);
}

function Row({ label, value, isError }: { label: string; value: string; isError?: boolean }) {
return (
<div className="flex justify-between gap-3">
<span className="text-text-muted/80">{label}</span>
<span
className={`text-right break-all ${isError ? 'text-error' : 'text-text-primary'}`}
style={{ maxWidth: '70%' }}
>
{value}
</span>
</div>
);
}

interface BadgeStyle {
pill: string;
dot: string;
}

function badgeStyleFor(status: BackendStatus['status']): BadgeStyle {
switch (status) {
case 'online':
return {
pill: 'border-success/40 bg-success/10 text-success hover:bg-success/15',
dot: 'bg-success animate-pulse',
};
case 'checking':
case 'unknown':
return {
pill: 'border-warning/40 bg-warning/10 text-warning hover:bg-warning/15',
dot: 'bg-warning animate-pulse',
};
case 'offline':
return {
pill: 'border-error/40 bg-error/10 text-error hover:bg-error/15',
dot: 'bg-error',
};
case 'disabled':
default:
return {
pill: 'border-border-subtle bg-surface text-text-muted',
dot: 'bg-text-muted/40',
};
}
}

function defaultLabelFor(status: BackendStatus['status']): string {
switch (status) {
case 'online':
return 'En ligne';
case 'checking':
return 'Vérification…';
case 'offline':
return 'Hors ligne';
case 'disabled':
return 'Désactivé';
case 'unknown':
default:
return 'Inconnu';
}
}
30 changes: 29 additions & 1 deletion cowork/src/renderer/components/MessageCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,44 @@
// Delegates block rendering to ContentBlockView and its sub-components.
import { useState, useCallback, memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Copy, Check, Clock, XCircle, Code2, Star } from 'lucide-react';
import { Copy, Check, Clock, XCircle, Code2, Star, RotateCcw } from 'lucide-react';
import type { Message, ContentBlock, ToolUseContent, ToolResultContent } from '../types';
import { ContentBlockView } from './message/ContentBlockView';
import { ToolBadgeStrip } from './message/ToolBadgeStrip';
import { detectArtifacts } from '../utils/artifact-detector';
import { useAppStore } from '../store';
import { useRegenerate } from '../hooks/use-regenerate';

interface MessageCardProps {
message: Message;
isStreaming?: boolean;
searchMatchState?: 'none' | 'match' | 'active';
}

/**
* Hover-only regenerate button for assistant messages. Pulled into a
* sub-component so the `useRegenerate` hook isn't called from inside
* the parent's conditional branch (assistant-only) — keeps hook order
* stable across renders. Returns null if regeneration isn't possible
* (no preceding user message) or while the session is currently
* streaming.
*/
function RegenerateAction({ message, isStreaming }: { message: Message; isStreaming?: boolean }) {
const { t } = useTranslation();
const { canRegenerate, handleRegenerate } = useRegenerate(message);
if (!canRegenerate || isStreaming) return null;
return (
<button
onClick={handleRegenerate}
className="absolute -left-8 top-7 w-6 h-6 flex items-center justify-center rounded-md bg-surface-muted hover:bg-surface-active transition-all opacity-0 group-hover/assistant:opacity-100"
title={t('messageCard.regenerate', 'Régénérer la réponse')}
aria-label={t('messageCard.regenerate', 'Régénérer la réponse')}
>
<RotateCcw className="w-3 h-3 text-text-muted" />
</button>
);
}

export const MessageCard = memo(function MessageCard({
message,
isStreaming,
Expand Down Expand Up @@ -188,6 +214,8 @@ export const MessageCard = memo(function MessageCard({
}`}
/>
</button>
<RegenerateAction message={message} isStreaming={isStreaming} />
<ToolBadgeStrip blocks={contentBlocks} message={message} />
{contentBlocks.map((block, index) => {
// Skip tool_result blocks that are merged into their tool_use card
if (
Expand Down
10 changes: 9 additions & 1 deletion cowork/src/renderer/components/Titlebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ServerDashboard } from './ServerDashboard';
import { RunnerBadge } from './RunnerBadge';
import { ClipboardSummaryPanel } from './ClipboardSummaryPanel';
import { VoiceChatOverlay } from './VoiceChatOverlay';
import { HealthBadge } from './HealthBadge';

const isMac = typeof window !== 'undefined' && window.electronAPI?.platform === 'darwin';

Expand Down Expand Up @@ -45,8 +46,15 @@ export function Titlebar() {
<TabBar />
</div>

{/* Presence indicator (face memory) — opens EnrollmentDialog on click. */}
{/* Backend health badge (Phase d.23 polish — chat-ui parity).
Auto-polls /api/health every 10s with backoff on failures.
Hidden when CodeBuddy integration is disabled. */}
<div className="titlebar-no-drag px-2 flex items-center ml-auto">
<HealthBadge />
</div>

{/* Presence indicator (face memory) — opens EnrollmentDialog on click. */}
<div className="titlebar-no-drag px-2 flex items-center">
<PresenceIndicator
onEnrollClicked={() => useAppStore.getState().setShowEnrollmentDialog(true)}
/>
Expand Down
8 changes: 8 additions & 0 deletions cowork/src/renderer/components/message/ContentBlockView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { normalizeLatexDelimiters } from '../../utils/latex-delimiters';
import type { ToolUseContent, ToolResultContent, FileAttachmentContent } from '../../types';
import { FileText } from 'lucide-react';
import { CodeBlock } from './CodeBlock';
import { MermaidBlock } from './MermaidBlock';
import { ThinkingBlock } from './ThinkingBlock';
import { ToolUseBlock } from './ToolUseBlock';
import { ToolResultBlock } from './ToolResultBlock';
Expand Down Expand Up @@ -197,6 +198,13 @@ export const ContentBlockView = memo(function ContentBlockView({
);
}

// Mermaid diagrams render inline (lazy mermaid + DOMPurify).
// The existing ArtifactPanel still picks them up for the "Open
// in panel" affordance — this just adds preview-in-flow.
if (match[1].toLowerCase() === 'mermaid') {
return <MermaidBlock text={String(children).replace(/\n$/, '')} />;
}

return <CodeBlock language={match[1]}>{String(children).replace(/\n$/, '')}</CodeBlock>;
},
p({ children }: { children?: React.ReactNode }) {
Expand Down
Loading
Loading