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
1,673 changes: 844 additions & 829 deletions apps/qwen-cowork/bun.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions apps/qwen-cowork/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@
"better-sqlite3": "^12.6.0",
"dotenv": "^17.2.3",
"highlight.js": "^11.11.1",
"i18next": "^25.8.4",
"i18next-browser-languagedetector": "^8.2.0",
"os-utils": "^0.0.14",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-i18next": "^16.5.4",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
Expand Down
12 changes: 7 additions & 5 deletions apps/qwen-cowork/src/ui/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PermissionResult } from "@qwen-code/sdk";
import { useIPC } from "./hooks/useIPC";
import { useMessageWindow } from "./hooks/useMessageWindow";
Expand All @@ -14,6 +15,7 @@ import MDContent from "./render/markdown";
const SCROLL_THRESHOLD = 50;

function App() {
const { t } = useTranslation();
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const topSentinelRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -270,7 +272,7 @@ function App() {
<div className="flex items-center justify-center py-4 mb-4">
<div className="flex items-center gap-2 text-xs text-muted">
<div className="h-px w-12 bg-ink-900/10" />
<span>Beginning of conversation</span>
<span>{t('beginningOfConversation')}</span>
<div className="h-px w-12 bg-ink-900/10" />
</div>
</div>
Expand All @@ -283,15 +285,15 @@ function App() {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Loading...</span>
<span>{t('loading')}</span>
</div>
</div>
)}

{visibleMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="text-lg font-medium text-ink-700">No messages yet</div>
<p className="mt-2 text-sm text-muted">Start a conversation with agent cowork</p>
<div className="text-lg font-medium text-ink-700">{t('noMessagesYet')}</div>
<p className="mt-2 text-sm text-muted">{t('startConversation')}</p>
</div>
) : (
visibleMessages.map((item, idx) => (
Expand Down Expand Up @@ -344,7 +346,7 @@ function App() {
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12l7 7 7-7" />
</svg>
<span>New messages</span>
<span>{t('newMessages')}</span>
</button>
)}
</main>
Expand Down
26 changes: 14 additions & 12 deletions apps/qwen-cowork/src/ui/components/DecisionPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PermissionResult } from "@qwen-code/sdk";
import type { PermissionRequest } from "../store/useAppStore";

Expand All @@ -19,6 +20,7 @@ export function DecisionPanel({
request: PermissionRequest;
onSubmit: (result: PermissionResult) => void;
}) {
const { t } = useTranslation();
const input = request.input as AskUserQuestionInput | null;
const questions = input?.questions ?? [];
const [selectedOptions, setSelectedOptions] = useState<Record<number, string[]>>({});
Expand Down Expand Up @@ -69,7 +71,7 @@ export function DecisionPanel({
if (request.toolName === "AskUserQuestion" && questions.length > 0) {
return (
<div className="rounded-2xl border border-accent/20 bg-accent-subtle p-5">
<div className="text-xs font-semibold text-accent">Question from Claude</div>
<div className="text-xs font-semibold text-accent">{t('questionFromClaude')}</div>
{questions.map((q, qIndex) => (
<div key={qIndex} className="mt-4">
<p className="text-sm text-ink-700">{q.question}</p>
Expand Down Expand Up @@ -107,16 +109,16 @@ export function DecisionPanel({
})}
</div>
<div className="mt-3">
<label className="block text-xs font-medium text-muted">Other</label>
<label className="block text-xs font-medium text-muted">{t('other')}</label>
<input
type="text"
className="mt-1 w-full rounded-xl border border-ink-900/10 bg-surface px-3 py-2 text-sm text-ink-700 focus:border-info/50 focus:outline-none"
placeholder="Type your answer..."
placeholder={t('typeYourAnswer')}
value={otherInputs[qIndex] ?? ""}
onChange={(e) => setOtherInputs((prev) => ({ ...prev, [qIndex]: e.target.value }))}
/>
</div>
{q.multiSelect && <div className="mt-2 text-xs text-muted">Multiple selections allowed.</div>}
{q.multiSelect && <div className="mt-2 text-xs text-muted">{t('multipleSelectionsAllowed')}</div>}
</div>
))}
<div className="mt-5 flex flex-wrap gap-3">
Expand All @@ -130,13 +132,13 @@ export function DecisionPanel({
}}
disabled={!canSubmit}
>
Submit answers
{t('submitAnswers')}
</button>
<button
className="rounded-full border border-ink-900/10 bg-surface px-5 py-2 text-sm font-medium text-ink-700 hover:bg-surface-tertiary transition-colors"
onClick={() => onSubmit({ behavior: "deny", message: "User canceled the question" })}
onClick={() => onSubmit({ behavior: "deny", message: t('cancel') })}
>
Cancel
{t('cancel')}
</button>
</div>
</div>
Expand All @@ -145,9 +147,9 @@ export function DecisionPanel({

return (
<div className="rounded-2xl border border-accent/20 bg-accent-subtle p-5">
<div className="text-xs font-semibold text-accent">Permission Request</div>
<div className="text-xs font-semibold text-accent">{t('permissionRequest')}</div>
<p className="mt-2 text-sm text-ink-700">
Claude wants to use: <span className="font-medium">{request.toolName}</span>
{t('claudeWantsToUse')} <span className="font-medium">{request.toolName}</span>
</p>
<div className="mt-3 rounded-xl bg-surface-tertiary p-3">
<pre className="text-xs text-ink-600 font-mono whitespace-pre-wrap break-words max-h-40 overflow-auto">
Expand All @@ -159,13 +161,13 @@ export function DecisionPanel({
className="rounded-full bg-accent px-5 py-2 text-sm font-medium text-white shadow-soft hover:bg-accent-hover transition-colors"
onClick={() => onSubmit({ behavior: "allow", updatedInput: request.input as Record<string, unknown> })}
>
Allow
{t('allow')}
</button>
<button
className="rounded-full border border-ink-900/10 bg-surface px-5 py-2 text-sm font-medium text-ink-700 hover:bg-surface-tertiary transition-colors"
onClick={() => onSubmit({ behavior: "deny", message: "User denied the request" })}
onClick={() => onSubmit({ behavior: "deny", message: t('deny') })}
>
Deny
{t('deny')}
</button>
</div>
</div>
Expand Down
57 changes: 32 additions & 25 deletions apps/qwen-cowork/src/ui/components/EventCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
PermissionResult,
SDKAssistantMessage,
Expand Down Expand Up @@ -70,23 +71,24 @@ const StatusDot = ({ variant = "accent", isActive = false, isVisible = true }: {
};

const SessionResult = ({ message }: { message: SDKResultMessage }) => {
const { t } = useTranslation();
const formatMinutes = (ms: number | undefined) => typeof ms !== "number" ? "-" : `${(ms / 60000).toFixed(2)} min`;
const formatMillions = (tokens: number | undefined) => typeof tokens !== "number" ? "-" : `${(tokens / 1_000_000).toFixed(4)} M`;

return (
<div className="flex flex-col gap-2 mt-4">
<div className="header text-accent">Session Result</div>
<div className="header text-accent">{t('sessionResult')}</div>
<div className="flex flex-col rounded-xl px-4 py-3 border border-ink-900/10 bg-surface-secondary space-y-2">
<div className="flex flex-wrap items-center gap-2 text-[14px]">
<span className="font-normal">Duration</span>
<span className="font-normal">{t('duration')}</span>
<span className="inline-flex items-center rounded-full bg-surface-tertiary px-2.5 py-0.5 text-ink-700 text-[13px]">{formatMinutes(message.duration_ms)}</span>
<span className="font-normal">API</span>
<span className="font-normal">{t('api')}</span>
<span className="inline-flex items-center rounded-full bg-surface-tertiary px-2.5 py-0.5 text-ink-700 text-[13px]">{formatMinutes(message.duration_api_ms)}</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-[14px]">
<span className="font-normal">Usage</span>
<span className="inline-flex items-center rounded-full bg-surface-tertiary px-2.5 py-0.5 text-ink-700 text-[13px]">Input {formatMillions(message.usage?.input_tokens)}</span>
<span className="inline-flex items-center rounded-full bg-surface-tertiary px-2.5 py-0.5 text-ink-700 text-[13px]">Output {formatMillions(message.usage?.output_tokens)}</span>
<span className="font-normal">{t('usage')}</span>
<span className="inline-flex items-center rounded-full bg-surface-tertiary px-2.5 py-0.5 text-ink-700 text-[13px]">{t('input')} {formatMillions(message.usage?.input_tokens)}</span>
<span className="inline-flex items-center rounded-full bg-surface-tertiary px-2.5 py-0.5 text-ink-700 text-[13px]">{t('output')} {formatMillions(message.usage?.output_tokens)}</span>
</div>
</div>
</div>
Expand All @@ -105,11 +107,12 @@ function extractTagContent(input: string, tag: string): string | null {
}

const ToolResult = ({ messageContent }: { messageContent: ToolResultContent }) => {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const bottomRef = useRef<HTMLDivElement | null>(null);
const isFirstRender = useRef(true);
let lines: string[] = [];

const toolUseId = messageContent.tool_use_id;
const status: ToolStatus = messageContent.is_error ? "error" : "success";
const isError = messageContent.is_error;
Expand Down Expand Up @@ -145,15 +148,15 @@ const ToolResult = ({ messageContent }: { messageContent: ToolResultContent }) =

return (
<div className="flex flex-col mt-4">
<div className="header text-accent">Output</div>
<div className="header text-accent">{t('outputHeader')}</div>
<div className="mt-2 rounded-xl bg-surface-tertiary p-3">
<pre className={`text-sm whitespace-pre-wrap break-words font-mono ${isError ? "text-red-500" : "text-ink-700"}`}>
{isMarkdownContent ? <MDContent text={visibleContent} /> : visibleContent}
</pre>
{hasMoreLines && (
<button onClick={() => setIsExpanded(!isExpanded)} className="mt-2 text-sm text-accent hover:text-accent-hover transition-colors flex items-center gap-1">
<span>{isExpanded ? "▲" : "▼"}</span>
<span>{isExpanded ? "Collapse" : `Show ${lines.length - MAX_VISIBLE_LINES} more lines`}</span>
<span>{isExpanded ? t('collapse') : t('showMoreLines', { count: lines.length - MAX_VISIBLE_LINES })}</span>
</button>
)}
<div ref={bottomRef} />
Expand All @@ -162,15 +165,18 @@ const ToolResult = ({ messageContent }: { messageContent: ToolResultContent }) =
);
};

const AssistantBlockCard = ({ title, text, showIndicator = false }: { title: string; text: string; showIndicator?: boolean }) => (
<div className="flex flex-col mt-4">
<div className="header text-accent flex items-center gap-2">
<StatusDot variant="success" isActive={showIndicator} isVisible={showIndicator} />
{title}
const AssistantBlockCard = ({ title, text, showIndicator = false }: { title: string; text: string; showIndicator?: boolean }) => {
const { t } = useTranslation();
return (
<div className="flex flex-col mt-4">
<div className="header text-accent flex items-center gap-2">
<StatusDot variant="success" isActive={showIndicator} isVisible={showIndicator} />
{title === "Thinking" ? t('thinking') : title === "Assistant" ? t('assistant') : title}
</div>
<MDContent text={text} />
</div>
<MDContent text={text} />
</div>
);
);
};

const ToolUseCard = ({ messageContent, showIndicator = false }: { messageContent: MessageContent; showIndicator?: boolean }) => {
if (messageContent.type !== "tool_use") return null;
Expand Down Expand Up @@ -251,28 +257,29 @@ const AskUserQuestionCard = ({
};

const SystemInfoCard = ({ message, showIndicator = false }: { message: SDKMessage; showIndicator?: boolean }) => {
const { t } = useTranslation();
if (message.type !== "system" || !("subtype" in message) || message.subtype !== "init") return null;

const systemMsg = message as any;

const InfoItem = ({ name, value }: { name: string; value: string }) => (
<div className="text-[14px]">
<span className="mr-4 font-normal">{name}</span>
<span className="font-light">{value}</span>
</div>
);

return (
<div className="flex flex-col gap-2 mt-2">
<div className="header text-accent flex items-center gap-2">
<StatusDot variant="success" isActive={showIndicator} isVisible={showIndicator} />
System Init
{t('systemInit')}
</div>
<div className="flex flex-col rounded-xl px-4 py-2 border border-ink-900/10 bg-surface-secondary space-y-1">
<InfoItem name="Session ID" value={systemMsg.session_id || "-"} />
<InfoItem name="Model Name" value={systemMsg.model || "-"} />
<InfoItem name="Permission Mode" value={systemMsg.permissionMode || "-"} />
<InfoItem name="Working Directory" value={systemMsg.cwd || "-"} />
<InfoItem name={t('sessionId')} value={systemMsg.session_id || "-"} />
<InfoItem name={t('modelName')} value={systemMsg.model || "-"} />
<InfoItem name={t('permissionMode')} value={systemMsg.permissionMode || "-"} />
<InfoItem name={t('workingDirectory')} value={systemMsg.cwd || "-"} />
</div>
</div>
);
Expand Down
13 changes: 8 additions & 5 deletions apps/qwen-cowork/src/ui/components/PromptInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { ClientEvent } from "../types";
import { useAppStore } from "../store/useAppStore";

Expand All @@ -14,6 +15,7 @@ interface PromptInputProps {
}

export function usePromptActions(sendEvent: (event: ClientEvent) => void) {
const { t } = useTranslation();
const prompt = useAppStore((state) => state.prompt);
const cwd = useAppStore((state) => state.cwd);
const activeSessionId = useAppStore((state) => state.activeSessionId);
Expand All @@ -36,7 +38,7 @@ export function usePromptActions(sendEvent: (event: ClientEvent) => void) {
} catch (error) {
console.error(error);
setPendingStart(false);
setGlobalError("Failed to get session title.");
setGlobalError(t('failedToGetSessionTitle'));
return;
}
sendEvent({
Expand All @@ -45,7 +47,7 @@ export function usePromptActions(sendEvent: (event: ClientEvent) => void) {
});
} else {
if (activeSession?.status === "running") {
setGlobalError("Session is still running. Please wait for it to finish.");
setGlobalError(t('sessionIsStillRunning'));
return;
}
sendEvent({ type: "session.continue", payload: { sessionId: activeSessionId, prompt } });
Expand All @@ -60,7 +62,7 @@ export function usePromptActions(sendEvent: (event: ClientEvent) => void) {

const handleStartFromModal = useCallback(() => {
if (!cwd.trim()) {
setGlobalError("Working Directory is required to start a session.");
setGlobalError(t('workingDirectoryIsRequired'));
return;
}
handleSend();
Expand All @@ -70,6 +72,7 @@ export function usePromptActions(sendEvent: (event: ClientEvent) => void) {
}

export function PromptInput({ sendEvent, onSendMessage, disabled = false }: PromptInputProps) {
const { t } = useTranslation();
const { prompt, setPrompt, isRunning, handleSend, handleStop } = usePromptActions(sendEvent);
const promptRef = useRef<HTMLTextAreaElement | null>(null);

Expand Down Expand Up @@ -124,7 +127,7 @@ export function PromptInput({ sendEvent, onSendMessage, disabled = false }: Prom
<textarea
rows={1}
className="flex-1 resize-none bg-transparent py-1.5 text-sm text-ink-800 placeholder:text-muted focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
placeholder={disabled ? "Create/select a task to start..." : "Describe what you want agent to handle..."}
placeholder={disabled ? t('createSelectTask') : t('describeWhat')}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
Expand All @@ -135,7 +138,7 @@ export function PromptInput({ sendEvent, onSendMessage, disabled = false }: Prom
<button
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${isRunning ? "bg-error text-white hover:bg-error/90" : "bg-accent text-white hover:bg-accent-hover"}`}
onClick={handleButtonClick}
aria-label={isRunning ? "Stop session" : "Send prompt"}
aria-label={isRunning ? t('stopSession') : t('sendPrompt')}
disabled={disabled && !isRunning}
>
{isRunning ? (
Expand Down
Loading