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
4 changes: 0 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Local OpenCode config (user-specific model/MCP settings)
config.json

# Provider icon individual SVGs (intermediate build artifacts)
# The sprite.svg and types.ts are the actual outputs
src/components/provider-icons/svgs/

.worktrees/

# Local benchmark/debug output
Expand Down
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ Use Vite+ task runner and pnpm-managed deps.
- Prefer `vp lint` / `vp fmt` when narrowing issues
- Use `pnpm run` only when task is defined in `package.json`

## Translations

When adding or changing user-facing text, update i18n files in `src/i18n/locales/` at same time.

- Add or update keys in `en.json`, `de.json`, and `es.json`
- Do not leave hardcoded UI strings in React components when text should be translated
- Keep locale key structure aligned across all supported languages

## Project notes

- `vite.config.ts` uses `vite-plus`
Expand Down
10 changes: 6 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ExternalLink, GitMerge } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { MergeDialog } from "@/components/MergeDialog";
import { QueueList } from "@/components/QueueList";
import { UpdateDialog } from "@/components/UpdateDialog";
Expand Down Expand Up @@ -46,6 +47,7 @@ function AppContent({
detachedProject?: string;
suppressBootErrors?: boolean;
}) {
const { t } = useTranslation();
const client = useOpenGuiClient();
const [activeView, setActiveView] = useState<"chat" | "settings">("chat");
const leftSidebar = useSidebar();
Expand Down Expand Up @@ -200,8 +202,8 @@ function AppContent({
<Spinner className="size-4 shrink-0" />
<span>
{bootState === "checking-server"
? "Checking local server..."
: "Starting local server..."}
? t("startup.checkingLocalServer")
: t("startup.startingLocalServer")}
</span>
</div>
)}
Expand All @@ -220,7 +222,7 @@ function AppContent({
onClick={() => setMergeInfo(activeWorktreeInfo)}
>
<GitMerge className="size-4" />
Merge
{t("projectMenu.merge")}
</Button>
<Button
variant="outline"
Expand All @@ -229,7 +231,7 @@ function AppContent({
onClick={openPullRequest}
>
<ExternalLink className="size-4" />
Create PR
{t("projectMenu.createPullRequest")}
</Button>
</div>
</div>
Expand Down
21 changes: 12 additions & 9 deletions src/components/EmptyChatStates.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";

export function NoProjectConnected({
Expand All @@ -7,21 +8,23 @@ export function NoProjectConnected({
canStartChat: boolean;
onStartChat: () => void;
}) {
const { t } = useTranslation();

return (
<div className="flex-1 flex items-center justify-center px-6">
<div className="max-w-md text-center space-y-4">
<div className="space-y-1.5">
<h2 className="text-lg font-semibold tracking-tight">No project connected</h2>
<h2 className="text-lg font-semibold tracking-tight">
{t("emptyStates.noProjectTitle")}
</h2>
<p className="text-sm text-muted-foreground">
{canStartChat
? "Connect a project now or start a chat."
: "Connect a project to start chatting."}
{canStartChat ? t("emptyStates.noProjectCanStart") : t("emptyStates.noProjectConnect")}
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
{canStartChat && (
<Button type="button" onClick={onStartChat}>
Start a chat
{t("emptyStates.startChat")}
</Button>
)}
</div>
Expand All @@ -31,13 +34,13 @@ export function NoProjectConnected({
}

export function NoSessionSelected() {
const { t } = useTranslation();

return (
<div className="flex-1 flex items-center justify-center px-6">
<div className="max-w-md text-center space-y-1.5">
<h2 className="text-lg font-semibold tracking-tight">No session selected</h2>
<p className="text-sm text-muted-foreground">
Select a session or start a new one from a connected project.
</p>
<h2 className="text-lg font-semibold tracking-tight">{t("emptyStates.noSessionTitle")}</h2>
<p className="text-sm text-muted-foreground">{t("emptyStates.noSessionDescription")}</p>
</div>
</div>
);
Expand Down
6 changes: 5 additions & 1 deletion src/components/FileMentionPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { FileText, Folder } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";

interface FileMentionPopoverProps {
Expand All @@ -24,6 +25,7 @@ export function FileMentionPopover({
loading,
emptyMessage,
}: FileMentionPopoverProps) {
const { t } = useTranslation();
const listRef = React.useRef<HTMLDivElement>(null);

// Scroll the active item into view
Expand All @@ -42,7 +44,9 @@ export function FileMentionPopover({
className="absolute bottom-full left-0 right-0 z-50 mb-1 max-h-[240px] overflow-y-auto rounded-lg border bg-popover p-1 shadow-lg"
>
{loading && files.length === 0 && (
<div className="px-2.5 py-2 text-xs text-muted-foreground">Searching...</div>
<div className="px-2.5 py-2 text-xs text-muted-foreground">
{t("fileMention.searching")}
</div>
)}
{!loading && files.length === 0 && emptyMessage && (
<div className="px-2.5 py-2 text-xs text-muted-foreground">{emptyMessage}</div>
Expand Down
80 changes: 37 additions & 43 deletions src/components/MergeDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AlertTriangle, Check, GitBranch, GitMerge, Loader2 } from "lucide-react";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { DialogShell } from "@/components/ui/DialogShell";
import { ToggleSwitch } from "@/components/ui/ToggleSwitch";
Expand Down Expand Up @@ -34,9 +35,11 @@ export function MergeDialog({
onMerged,
onFixWithAI,
}: MergeDialogProps) {
const { t } = useTranslation();
const client = useOpenGuiClient();
const [mergeState, setMergeState] = useState<MergeState>({ step: "confirm" });
const [deleteWorktree, setDeleteWorktree] = useState(false);
const mergeInFlightRef = useRef(false);

const repoName = getProjectName(mainDirectory);

Expand All @@ -46,13 +49,16 @@ export function MergeDialog({
// Reset state on close
setMergeState({ step: "confirm" });
setDeleteWorktree(false);
mergeInFlightRef.current = false;
onOpenChange(false);
}
},
[onOpenChange],
);

const handleMerge = useCallback(async () => {
if (mergeInFlightRef.current) return;
mergeInFlightRef.current = true;
setMergeState({ step: "merging" });
try {
const res = await client.git.merge(mainDirectory, branch);
Expand All @@ -64,16 +70,18 @@ export function MergeDialog({
} else {
setMergeState({
step: "error",
message: res.error ?? "Merge failed",
message: res.error ?? t("mergeDialog.failed"),
});
}
} catch (err) {
setMergeState({
step: "error",
message: getErrorMessage(err, "Merge failed"),
message: getErrorMessage(err, t("mergeDialog.failed")),
});
} finally {
mergeInFlightRef.current = false;
}
}, [branch, client, deleteWorktree, mainDirectory, onMerged]);
}, [branch, client, deleteWorktree, mainDirectory, onMerged, t]);

const handleAbort = useCallback(async () => {
await client.git.mergeAbort(mainDirectory);
Expand All @@ -94,13 +102,8 @@ export function MergeDialog({
onOpenChange={handleClose}
className="sm:max-w-md"
icon={<Loader2 className="size-5 animate-spin" />}
title="Merging..."
description={
<>
Merging <span className="font-medium text-foreground">{branch}</span> into the current
branch
</>
}
title={t("mergeDialog.merging")}
description={t("mergeDialog.mergingDescription", { branch })}
/>
);
}
Expand All @@ -112,16 +115,15 @@ export function MergeDialog({
onOpenChange={handleClose}
className="sm:max-w-md"
icon={<Check className="size-5" />}
title="Merge successful"
title={t("mergeDialog.success")}
titleClassName="text-green-600"
description={
<>
<span className="font-medium text-foreground">{branch}</span> has been merged
successfully.
{deleteWorktree && " The worktree will be removed."}
</>
}
footer={<Button onClick={() => handleClose(false)}>Done</Button>}
description={t(
deleteWorktree
? "mergeDialog.successDescriptionDelete"
: "mergeDialog.successDescription",
{ branch },
)}
footer={<Button onClick={() => handleClose(false)}>{t("common.done")}</Button>}
/>
);
}
Expand All @@ -133,22 +135,19 @@ export function MergeDialog({
onOpenChange={handleClose}
className="sm:max-w-md"
icon={<AlertTriangle className="size-5" />}
title="Merge conflicts"
title={t("mergeDialog.conflicts")}
titleClassName="text-amber-500"
description={
<>
Merging <span className="font-medium text-foreground">{branch}</span> produced conflicts
in {mergeState.files.length} file
{mergeState.files.length !== 1 ? "s" : ""}:
</>
}
description={t("mergeDialog.conflictsDescription", {
branch,
count: mergeState.files.length,
})}
footerClassName="gap-2 sm:gap-0"
footer={
<>
<Button variant="outline" onClick={handleAbort}>
Abort merge
{t("mergeDialog.abort")}
</Button>
<Button onClick={handleFixWithAI}>Fix with AI</Button>
<Button onClick={handleFixWithAI}>{t("mergeDialog.fixWithAI")}</Button>
</>
}
>
Expand All @@ -174,12 +173,12 @@ export function MergeDialog({
onOpenChange={handleClose}
className="sm:max-w-md"
icon={<AlertTriangle className="size-5" />}
title="Merge failed"
title={t("mergeDialog.failed")}
titleClassName="text-destructive"
description={mergeState.message}
footer={
<Button variant="outline" onClick={() => handleClose(false)}>
Close
{t("common.close")}
</Button>
}
/>
Expand All @@ -192,28 +191,23 @@ export function MergeDialog({
onOpenChange={handleClose}
className="sm:max-w-md"
icon={<GitMerge className="size-5" />}
title="Merge branch"
description={
<>
Merge <span className="font-medium text-foreground">{branch}</span> into the current
branch of <span className="font-medium text-foreground">{repoName}</span>
</>
}
title={t("mergeDialog.title")}
description={t("mergeDialog.description", { branch, repoName })}
footer={
<>
<Button variant="outline" onClick={() => handleClose(false)}>
Cancel
{t("common.cancel")}
</Button>
<Button onClick={handleMerge}>Merge</Button>
<Button onClick={handleMerge}>{t("projectMenu.merge")}</Button>
</>
}
>
<div className="py-2">
<ToggleSwitch
checked={deleteWorktree}
onCheckedChange={setDeleteWorktree}
label="Delete worktree after successful merge"
description="Removes the worktree directory and disconnects it from the project"
label={t("mergeDialog.deleteWorktreeLabel")}
description={t("mergeDialog.deleteWorktreeDescription")}
/>
</div>
</DialogShell>
Expand Down
19 changes: 11 additions & 8 deletions src/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import type { Part, TextPart } from "@opencode-ai/sdk/v2/client";
import { ShieldAlert, Undo2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { QuestionPanel } from "@/components/message-list/QuestionPanel";
import { MessageBubble } from "@/components/message-list/MessageBubble";
import {
Expand Down Expand Up @@ -82,21 +83,20 @@ function RevertBanner({
revertedCount: number;
onRestore: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2 mt-4 select-none">
<div className="flex-1 h-px bg-orange-500/30" />
<div className="flex items-center gap-2 text-[11px] text-orange-500/80 font-mono">
<Undo2 className="size-3" />
<span>
{revertedCount} message{revertedCount !== 1 ? "s" : ""} reverted
</span>
<span>{t("revertBanner.reverted", { count: revertedCount })}</span>
<span className="text-orange-500/50">|</span>
<button
type="button"
onClick={onRestore}
className="hover:text-orange-500 transition-colors cursor-pointer"
>
Restore
{t("revertBanner.restore")}
</button>
</div>
<div className="flex-1 h-px bg-orange-500/30" />
Expand All @@ -105,6 +105,7 @@ function RevertBanner({
}

export function MessageList({ detachedProject: _detachedProject }: { detachedProject?: string }) {
const { t } = useTranslation();
const {
respondPermission,
replyQuestion,
Expand Down Expand Up @@ -419,7 +420,9 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro
<div className="flex items-start gap-2">
<ShieldAlert className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">Permission: {pendingPermission.permission}</p>
<p className="text-sm font-medium">
{t("permissionPanel.title", { permission: pendingPermission.permission })}
</p>
{pendingPermission.patterns.length > 0 && (
<p className="text-xs text-muted-foreground">
{pendingPermission.patterns.join(", ")}
Expand All @@ -429,13 +432,13 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro
</div>
<div className="flex gap-2">
<Button size="sm" variant="default" onClick={() => respondPermission("once")}>
Allow once
{t("permissionPanel.allowOnce")}
</Button>
<Button size="sm" variant="secondary" onClick={() => respondPermission("always")}>
Always allow
{t("permissionPanel.alwaysAllow")}
</Button>
<Button size="sm" variant="destructive" onClick={() => respondPermission("reject")}>
Reject
{t("permissionPanel.reject")}
</Button>
</div>
</div>
Expand Down
Loading
Loading