Merge branch 'main' of https://github.com/stablyai/orca into bug-regression-gitlab-merge-request-creation-opt

This commit is contained in:
Jinjing
2026-06-11 20:13:07 -07:00
88 changed files with 5777 additions and 2797 deletions
+13 -4
View File
@@ -235,10 +235,19 @@ export async function updateIssue(
if (updates.state) {
await acquire()
try {
const cmd = updates.state === 'closed' ? 'close' : 'reopen'
await ghExecFileAsync(['issue', cmd, String(issueNumber), '--repo', repo], {
...ghOptions
})
if (updates.state === 'closed') {
const closeArgs = ['issue', 'close', String(issueNumber), '--repo', repo]
if (updates.stateReason === 'completed') {
closeArgs.push('--reason', 'completed')
} else if (updates.stateReason === 'not_planned') {
closeArgs.push('--reason', 'not planned')
} else if (updates.stateReason === 'duplicate' && updates.duplicateOf) {
closeArgs.push('--duplicate-of', String(updates.duplicateOf))
}
await ghExecFileAsync(closeArgs, ghOptions)
} else {
await ghExecFileAsync(['issue', 'reopen', String(issueNumber), '--repo', repo], ghOptions)
}
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
// Treat "already closed/open" as a no-op
+23 -3
View File
@@ -171,15 +171,29 @@ export async function updateIssueBySlug(
if (!args.updates || typeof args.updates !== 'object') {
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
}
const { title, body, state, addLabels, removeLabels, addAssignees, removeAssignees } =
args.updates
const {
title,
body,
state,
stateReason,
duplicateOf,
addLabels,
removeLabels,
addAssignees,
removeAssignees
} = args.updates
// Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}.
// Labels/assignees go through their dedicated endpoints.
const base = `repos/${args.owner}/${args.repo}/issues/${args.number}`
// 1) PATCH body
if (title !== undefined || body !== undefined || state !== undefined) {
if (
title !== undefined ||
body !== undefined ||
state !== undefined ||
stateReason !== undefined
) {
const patchArgs: string[] = ['-X', 'PATCH', base]
if (title !== undefined) {
patchArgs.push('--raw-field', `title=${title}`)
@@ -190,6 +204,12 @@ export async function updateIssueBySlug(
if (state !== undefined) {
patchArgs.push('--raw-field', `state=${state}`)
}
if (stateReason !== undefined) {
patchArgs.push('--raw-field', `state_reason=${stateReason}`)
}
if (duplicateOf !== undefined) {
patchArgs.push('--raw-field', `duplicate_of=${duplicateOf}`)
}
const r = await runRest<unknown>(patchArgs)
if (!r.ok) {
return { ok: false, error: r.error }
@@ -96,6 +96,101 @@
background: color-mix(in srgb, var(--muted) 38%, var(--background));
}
.github-markdown-composer-tabbed {
border-radius: 8px;
}
.github-markdown-composer-tabbar {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 8px;
min-height: 40px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
background: color-mix(in srgb, var(--muted) 28%, var(--background));
}
.github-markdown-composer-tabs {
display: flex;
align-items: stretch;
gap: 0;
}
.github-markdown-composer-tab {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 0 14px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--muted-foreground);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.github-markdown-composer-tab:hover {
color: var(--foreground);
}
.github-markdown-composer-tab.is-active {
margin-bottom: -1px;
border-bottom-color: transparent;
background: var(--background);
color: var(--foreground);
}
.github-markdown-composer-tabbar-toolbar {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
justify-content: flex-end;
overflow-x: auto;
}
.github-markdown-composer-tabbar-toolbar .rich-markdown-editor-toolbar {
min-height: 38px;
padding: 4px 8px;
box-shadow: none;
background: transparent;
}
.github-markdown-composer-preview {
padding: 12px 14px 18px;
}
.github-markdown-composer-attachment {
display: flex;
width: 100%;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: none;
border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
background: transparent;
color: var(--muted-foreground);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.github-markdown-composer-attachment:hover:not(:disabled) {
color: var(--foreground);
background: color-mix(in srgb, var(--accent) 40%, transparent);
}
.github-markdown-composer-attachment:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.github-issue-comment-composer .github-markdown-composer-tabbed {
border-color: color-mix(in srgb, var(--border) 82%, transparent);
}
.orca-diff-comment-add-btn.rich-markdown-comment-add-btn {
display: flex;
width: 22px;
+54 -284
View File
@@ -37,8 +37,6 @@ import {
Pencil,
Plus,
RefreshCw,
Send,
Settings,
UndoDot,
Users,
Wrench,
@@ -134,6 +132,9 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl
import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata'
import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer'
import { GitHubWorkItemLabelPopoverContent } from '@/components/github/GitHubWorkItemLabelPopoverContent'
import { GitHubWorkItemAssigneePopoverContent } from '@/components/github/GitHubWorkItemAssigneePopoverContent'
import { GitHubIssueCommentComposer } from '@/components/github/GitHubIssueCommentComposer'
import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator'
import {
getGitHubPRReviewerRows,
@@ -2682,12 +2683,12 @@ function ConversationTab({
<div
key={comment.id}
className={cn(
'min-w-0 overflow-hidden rounded-lg border border-border/40 bg-card/50 shadow-xs',
isReply && 'ml-6 max-w-[calc(100%-1.5rem)]',
'min-w-0 overflow-hidden rounded-md border border-border/60 bg-card shadow-xs',
isReply && 'ml-8 max-w-[calc(100%-2rem)] border-l-2 border-l-border/80',
comment.isResolved && PR_COMMENT_RESOLVED_CONTAINER_CLASS
)}
>
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-2">
<div className="flex min-w-0 items-center gap-2 border-b border-border/50 bg-muted/20 px-3 py-2">
{comment.authorAvatarUrl ? (
<img
src={comment.authorAvatarUrl}
@@ -2965,9 +2966,9 @@ function ConversationTab({
{detailsLoaded ? (
<>
<div className="flex items-center gap-2 pt-1">
<div className="flex items-center gap-2 border-b border-border/40 pb-2 pt-1">
<MessageSquare className="size-4 text-muted-foreground" />
<span className="text-[13px] font-medium text-foreground">
<span className="text-[13px] font-semibold text-foreground">
{translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')}
</span>
{comments.length > 0 && (
@@ -3001,13 +3002,13 @@ function ConversationTab({
)}
{comments.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground">
<p className="px-1 py-2 text-[13px] text-muted-foreground">
{translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')}
</div>
</p>
) : visibleComments.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-center text-[13px] text-muted-foreground">
<p className="px-1 py-2 text-center text-[13px] text-muted-foreground">
{getPRCommentAudienceEmptyLabel(commentFilter)}
</div>
</p>
) : (
<div className="flex min-w-0 flex-col gap-3">
{visibleCommentGroups.map(renderCommentGroup)}
@@ -3017,13 +3018,19 @@ function ConversationTab({
) : null}
{detailsLoaded && repoPath && (
<GHCommentComposer
className="mt-1"
<GitHubIssueCommentComposer
className="mt-2"
repoPath={repoPath}
repoId={item.repoId}
issueNumber={item.number}
itemType={item.type}
itemState={localState}
itemId={item.id}
projectOrigin={projectOrigin}
previewGithubRepo={markdownGitHubRepo}
onCommentAdded={onCommentAdded}
onStateChange={onStateChange}
onMutated={onMutated}
/>
)}
</div>
@@ -4399,39 +4406,6 @@ async function runPullRequestStateUpdate(args: {
}
}
function GitHubLabelsSettingsLink({
url,
separated,
onOpen
}: {
url: string | null
separated?: boolean
onOpen?: () => void
}): React.JSX.Element | null {
if (!url) {
return null
}
return (
<div className={cn(separated && 'mt-1 border-t border-border/60 pt-1')}>
<button
type="button"
onClick={() => {
onOpen?.()
void window.api.shell.openUrl(url)
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<Settings className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 text-left">
{translate('auto.components.GitHubItemDialog.2aa9acdf34', 'Edit labels on GitHub')}
</span>
<ExternalLink className="size-3 shrink-0 opacity-70" />
</button>
</div>
)
}
function GHEditSection({
item,
repoPath,
@@ -4732,18 +4706,6 @@ function GHEditSection({
return null
}
const checkIcon = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
if (layout === 'sidebar') {
return (
<aside className="flex flex-col gap-5 text-[13px]">
@@ -4825,41 +4787,14 @@ function GHEditSection({
className="popover-scroll-content scrollbar-sleek w-60 p-1"
align="end"
>
{repoAssignees.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoAssignees.error}
</div>
) : (
<div>
{repoAssignees.data.map((user) => (
<button
key={user.login}
type="button"
onClick={() => handleAssigneeToggle(user.login)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localAssignees.includes(user.login)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localAssignees.includes(user.login) && checkIcon}
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block truncate">{user.login}</span>
{user.name && (
<span className="block truncate text-[11px] text-muted-foreground">
{user.name}
</span>
)}
</span>
</button>
))}
</div>
)}
<GitHubWorkItemAssigneePopoverContent
open={assigneePopoverOpen}
assignees={repoAssignees.data}
selectedLogins={localAssignees}
error={repoAssignees.error}
loading={repoAssignees.loading}
onToggleAssignee={handleAssigneeToggle}
/>
</PopoverContent>
</Popover>
</div>
@@ -4918,39 +4853,15 @@ function GHEditSection({
className="popover-scroll-content scrollbar-sleek w-60 p-1"
align="end"
>
{repoLabels.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoLabels.error}
</div>
) : null}
{!repoLabels.error ? (
<div>
{repoLabels.data.map((label) => (
<button
key={label}
type="button"
onClick={() => handleLabelToggle(label)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localLabels.includes(label)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localLabels.includes(label) && checkIcon}
</span>
{label}
</button>
))}
</div>
) : null}
<GitHubLabelsSettingsLink
url={repositoryLabelsUrl}
separated={!repoLabels.error && repoLabels.data.length > 0}
onOpen={() => setLabelPopoverOpen(false)}
<GitHubWorkItemLabelPopoverContent
open={labelPopoverOpen}
labels={repoLabels.data}
selectedLabels={localLabels}
error={repoLabels.error}
loading={repoLabels.loading}
repositoryLabelsUrl={repositoryLabelsUrl}
onToggleLabel={handleLabelToggle}
onOpenSettingsLink={() => setLabelPopoverOpen(false)}
/>
</PopoverContent>
</Popover>
@@ -5111,39 +5022,15 @@ function GHEditSection({
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{repoLabels.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoLabels.error}
</div>
) : null}
{!repoLabels.error ? (
<div>
{repoLabels.data.map((label) => (
<button
key={label}
type="button"
onClick={() => handleLabelToggle(label)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localLabels.includes(label)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localLabels.includes(label) && checkIcon}
</span>
{label}
</button>
))}
</div>
) : null}
<GitHubLabelsSettingsLink
url={repositoryLabelsUrl}
separated={!repoLabels.error && repoLabels.data.length > 0}
onOpen={() => setLabelPopoverOpen(false)}
<GitHubWorkItemLabelPopoverContent
open={labelPopoverOpen}
labels={repoLabels.data}
selectedLabels={localLabels}
error={repoLabels.error}
loading={repoLabels.loading}
repositoryLabelsUrl={repositoryLabelsUrl}
onToggleLabel={handleLabelToggle}
onOpenSettingsLink={() => setLabelPopoverOpen(false)}
/>
</PopoverContent>
</Popover>
@@ -5175,41 +5062,14 @@ function GHEditSection({
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{repoAssignees.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoAssignees.error}
</div>
) : (
<div>
{repoAssignees.data.map((user) => (
<button
key={user.login}
type="button"
onClick={() => handleAssigneeToggle(user.login)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localAssignees.includes(user.login)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localAssignees.includes(user.login) && checkIcon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{user.login}</span>
{user.name && (
<span className="block truncate text-[11px] text-muted-foreground">
{user.name}
</span>
)}
</span>
</button>
))}
</div>
)}
<GitHubWorkItemAssigneePopoverContent
open={assigneePopoverOpen}
assignees={repoAssignees.data}
selectedLogins={localAssignees}
error={repoAssignees.error}
loading={repoAssignees.loading}
onToggleAssignee={handleAssigneeToggle}
/>
</PopoverContent>
</Popover>
@@ -5276,96 +5136,6 @@ function GHEditSection({
)
}
function GHCommentComposer({
className,
repoPath,
repoId,
issueNumber,
itemType,
onCommentAdded
}: {
className?: string
repoPath: string
repoId?: string | null
issueNumber: number
itemType: 'issue' | 'pr'
onCommentAdded: (comment: PRComment) => void
}): React.JSX.Element {
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const mountedRef = useMountedRef()
const handleSubmit = useCallback(async () => {
const trimmed = body.trim()
if (!trimmed) {
return
}
setSubmitting(true)
try {
const result = await addIssueCommentForRepo({
repoPath,
repoId: repoId ?? undefined,
number: issueNumber,
body: trimmed,
type: itemType
})
if (!mountedRef.current) {
return
}
if (result.ok) {
setBody('')
// Why: use the comment returned by GitHub so the optimistic row shows
// the real login/avatar immediately instead of waiting for a reopen.
onCommentAdded(result.comment)
} else {
toast.error(
result.error ??
translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment')
)
}
} catch (err) {
if (mountedRef.current) {
toast.error(
err instanceof Error
? err.message
: translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment')
)
}
} finally {
if (mountedRef.current) {
setSubmitting(false)
}
}
}, [body, mountedRef, repoPath, repoId, issueNumber, itemType, onCommentAdded])
return (
<div className={cn('flex flex-col items-start gap-2', className)}>
<GitHubMarkdownComposer
value={body}
onChange={setBody}
placeholder={translate('auto.components.GitHubItemDialog.c5c117270e', 'Add a comment…')}
disabled={submitting}
minHeightClassName="min-h-28"
className="w-full"
onSubmitShortcut={() => void handleSubmit()}
/>
<Button
onClick={handleSubmit}
disabled={!body.trim() || submitting}
className="gap-2"
aria-label={translate('auto.components.GitHubItemDialog.0a73f59e85', 'Send comment')}
>
{submitting ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Send className="size-3.5" />
)}
{translate('auto.components.GitHubItemDialog.bf43425540', 'Comment')}
</Button>
</div>
)
}
// Why: the dialog doesn't carry the resolved PR-source slug the Tasks view's
// list cache carries, so we reach into workItemsCache to recover it. We scope
// the lookup to the dialog's own `repoPath` via the public
@@ -1,26 +1,13 @@
import React, { useCallback, useEffect, useRef } from 'react'
import React, { useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { EditorContent, useEditor } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
import Placeholder from '@tiptap/extension-placeholder'
import {
Bold,
Code,
Heading1,
Heading2,
Italic,
Link as LinkIcon,
List,
ListOrdered,
ListTodo,
LoaderCircle,
Pilcrow,
Quote,
Strikethrough
} from 'lucide-react'
import { LoaderCircle } from 'lucide-react'
import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions'
import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -34,258 +21,17 @@ type LinearIssueMarkdownDescriptionEditorProps = {
submitShortcutLabel: string
}
type LinearIssueMarkdownToolbarButtonProps = {
active?: boolean
disabled?: boolean
label: string
onClick: () => void
children: React.ReactNode
}
const linearIssueMarkdownExtensions = [
...createRichMarkdownExtensions(),
Placeholder.configure({
placeholder: translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.4f2fddc2b7',
'No description provided.'
)
})
]
function LinearIssueMarkdownToolbarButton({
active = false,
disabled = false,
label,
onClick,
children
}: LinearIssueMarkdownToolbarButtonProps): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
disabled={disabled}
className={cn('linear-issue-markdown-toolbar-button', active && 'is-active')}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
function LinearIssueMarkdownToolbarSeparator(): React.JSX.Element {
return <div className="linear-issue-markdown-toolbar-separator" />
}
function applyLinearIssueLink(editor: Editor | null): void {
if (!editor) {
return
}
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run()
return
}
const previousHref = editor.getAttributes('link').href as string | undefined
const href = window.prompt(
translate('auto.components.LinearIssueMarkdownDescriptionEditor.5c16ec8f14', 'Link URL'),
previousHref ?? ''
)
if (href === null) {
editor.chain().focus().run()
return
}
const trimmed = href.trim()
if (!trimmed) {
editor.chain().focus().unsetLink().run()
return
}
editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run()
}
function LinearIssueMarkdownToolbar({
editor,
disabled
}: {
editor: Editor | null
disabled: boolean
}): React.JSX.Element {
const runCommand = useCallback(
(command: (editor: Editor) => void) => {
if (!editor || disabled) {
return
}
command(editor)
},
[disabled, editor]
)
return (
<div
className="linear-issue-markdown-toolbar"
aria-label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.7c52151156',
'Issue description formatting'
)}
>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.68a41d5665',
'Body text'
)}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().setParagraph().run())}
>
<Pilcrow className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.e3f741d258',
'Heading 1'
)}
active={editor?.isActive('heading', { level: 1 }) ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 1 }).run())
}
>
<Heading1 className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.dddaa7a0a6',
'Heading 2'
)}
active={editor?.isActive('heading', { level: 2 }) ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 2 }).run())
}
>
<Heading2 className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarSeparator />
<LinearIssueMarkdownToolbarButton
label={translate('auto.components.LinearIssueMarkdownDescriptionEditor.caa88f50d0', 'Bold')}
active={editor?.isActive('bold') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleBold().run())}
>
<Bold className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.5666b4493d',
'Italic'
)}
active={editor?.isActive('italic') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleItalic().run())}
>
<Italic className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.28fd951b83',
'Strike'
)}
active={editor?.isActive('strike') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleStrike().run())}
>
<Strikethrough className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.ad1869bd54',
'Inline code'
)}
active={editor?.isActive('code') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleCode().run())}
>
<Code className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarSeparator />
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.c82917e06e',
'Bullet list'
)}
active={editor?.isActive('bulletList') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleBulletList().run())
}
>
<List className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.d6b2f3d35b',
'Numbered list'
)}
active={editor?.isActive('orderedList') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleOrderedList().run())
}
>
<ListOrdered className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.e2a0267c8c',
'Checklist'
)}
active={editor?.isActive('taskList') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleTaskList().run())
}
>
<ListTodo className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarSeparator />
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.9eaf02ac01',
'Quote'
)}
active={editor?.isActive('blockquote') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleBlockquote().run())
}
>
<Quote className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={
editor?.isActive('link')
? translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.340160f4e8',
'Remove link'
)
: translate('auto.components.LinearIssueMarkdownDescriptionEditor.632096eb1c', 'Link')
}
active={editor?.isActive('link') ?? false}
disabled={disabled}
onClick={() => runCommand(applyLinearIssueLink)}
>
<LinkIcon className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
</div>
)
function createLinearIssueMarkdownExtensions() {
const extensions = createRichMarkdownExtensions()
return [
...extensions,
Placeholder.configure({
placeholder: translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.4f2fddc2b7',
'No description provided.'
)
})
]
}
export function LinearIssueMarkdownDescriptionEditor({
@@ -296,46 +42,57 @@ export function LinearIssueMarkdownDescriptionEditor({
disabled,
submitShortcutLabel
}: LinearIssueMarkdownDescriptionEditorProps): React.JSX.Element {
const { i18n } = useTranslation()
const language = i18n.resolvedLanguage ?? i18n.language
const lastEditorMarkdownRef = useRef(value)
const editorRef = useRef<Editor | null>(null)
const linearIssueMarkdownExtensions = useMemo(() => {
// Why: Tiptap freezes extension options when the editor is created; the
// language value is the recreation key for translated extension options.
void language
return createLinearIssueMarkdownExtensions()
}, [language])
const editor = useEditor({
immediatelyRender: false,
extensions: linearIssueMarkdownExtensions,
content: encodeRawMarkdownHtmlForRichEditor(value),
contentType: 'markdown',
editable: !disabled,
editorProps: {
attributes: {
class: 'rich-markdown-editor',
spellcheck: 'true',
'aria-label': 'Issue description'
},
handleKeyDown: (_view, event) => {
if (!isScreenSubmitShortcut(event)) {
return false
const editor = useEditor(
{
immediatelyRender: false,
extensions: linearIssueMarkdownExtensions,
content: encodeRawMarkdownHtmlForRichEditor(value),
contentType: 'markdown',
editable: !disabled,
editorProps: {
attributes: {
class: 'rich-markdown-editor',
spellcheck: 'true',
'aria-label': 'Issue description'
},
handleKeyDown: (_view, event) => {
if (!isScreenSubmitShortcut(event)) {
return false
}
event.preventDefault()
editorRef.current?.commands.blur()
return true
}
event.preventDefault()
editorRef.current?.commands.blur()
return true
},
onFocus: () => {
window.api.ui.setMarkdownEditorFocused(true)
},
onBlur: ({ editor: nextEditor }) => {
window.api.ui.setMarkdownEditorFocused(false)
const nextValue = nextEditor.getMarkdown()
lastEditorMarkdownRef.current = nextValue
onChange(nextValue)
onSave(nextValue)
},
onUpdate: ({ editor: nextEditor }) => {
const nextValue = nextEditor.getMarkdown()
lastEditorMarkdownRef.current = nextValue
onChange(nextValue)
}
},
onFocus: () => {
window.api.ui.setMarkdownEditorFocused(true)
},
onBlur: ({ editor: nextEditor }) => {
window.api.ui.setMarkdownEditorFocused(false)
const nextValue = nextEditor.getMarkdown()
lastEditorMarkdownRef.current = nextValue
onChange(nextValue)
onSave(nextValue)
},
onUpdate: ({ editor: nextEditor }) => {
const nextValue = nextEditor.getMarkdown()
lastEditorMarkdownRef.current = nextValue
onChange(nextValue)
}
})
[language]
)
useEffect(() => {
editorRef.current = editor
@@ -0,0 +1,267 @@
import React, { useCallback } from 'react'
import type { Editor } from '@tiptap/react'
import { useTranslation } from 'react-i18next'
import {
Bold,
Code,
Heading1,
Heading2,
Italic,
Link as LinkIcon,
List,
ListOrdered,
ListTodo,
Pilcrow,
Quote,
Strikethrough
} from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
type LinearIssueMarkdownToolbarButtonProps = {
active?: boolean
disabled?: boolean
label: string
onClick: () => void
children: React.ReactNode
}
function LinearIssueMarkdownToolbarButton({
active = false,
disabled = false,
label,
onClick,
children
}: LinearIssueMarkdownToolbarButtonProps): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
disabled={disabled}
className={cn('linear-issue-markdown-toolbar-button', active && 'is-active')}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
function LinearIssueMarkdownToolbarSeparator(): React.JSX.Element {
return <div className="linear-issue-markdown-toolbar-separator" />
}
function applyLinearIssueLink(editor: Editor | null): void {
if (!editor) {
return
}
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run()
return
}
const previousHref = editor.getAttributes('link').href as string | undefined
const href = window.prompt(
translate('auto.components.LinearIssueMarkdownDescriptionEditor.5c16ec8f14', 'Link URL'),
previousHref ?? ''
)
if (href === null) {
editor.chain().focus().run()
return
}
const trimmed = href.trim()
if (!trimmed) {
editor.chain().focus().unsetLink().run()
return
}
editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run()
}
export function LinearIssueMarkdownToolbar({
editor,
disabled
}: {
editor: Editor | null
disabled: boolean
}): React.JSX.Element {
// Why: this toolbar can outlive editor recreation, so subscribe directly to language changes.
useTranslation()
const runCommand = useCallback(
(command: (editor: Editor) => void) => {
if (!editor || disabled) {
return
}
command(editor)
},
[disabled, editor]
)
return (
<div
className="linear-issue-markdown-toolbar"
aria-label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.7c52151156',
'Issue description formatting'
)}
>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.68a41d5665',
'Body text'
)}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().setParagraph().run())}
>
<Pilcrow className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.e3f741d258',
'Heading 1'
)}
active={editor?.isActive('heading', { level: 1 }) ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 1 }).run())
}
>
<Heading1 className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.dddaa7a0a6',
'Heading 2'
)}
active={editor?.isActive('heading', { level: 2 }) ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 2 }).run())
}
>
<Heading2 className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarSeparator />
<LinearIssueMarkdownToolbarButton
label={translate('auto.components.LinearIssueMarkdownDescriptionEditor.caa88f50d0', 'Bold')}
active={editor?.isActive('bold') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleBold().run())}
>
<Bold className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.5666b4493d',
'Italic'
)}
active={editor?.isActive('italic') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleItalic().run())}
>
<Italic className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.28fd951b83',
'Strike'
)}
active={editor?.isActive('strike') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleStrike().run())}
>
<Strikethrough className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.ad1869bd54',
'Inline code'
)}
active={editor?.isActive('code') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleCode().run())}
>
<Code className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarSeparator />
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.c82917e06e',
'Bullet list'
)}
active={editor?.isActive('bulletList') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleBulletList().run())
}
>
<List className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.d6b2f3d35b',
'Numbered list'
)}
active={editor?.isActive('orderedList') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleOrderedList().run())
}
>
<ListOrdered className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.e2a0267c8c',
'Checklist'
)}
active={editor?.isActive('taskList') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleTaskList().run())
}
>
<ListTodo className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarSeparator />
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.9eaf02ac01',
'Quote'
)}
active={editor?.isActive('blockquote') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleBlockquote().run())
}
>
<Quote className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={
editor?.isActive('link')
? translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.340160f4e8',
'Remove link'
)
: translate('auto.components.LinearIssueMarkdownDescriptionEditor.632096eb1c', 'Link')
}
active={editor?.isActive('link') ?? false}
disabled={disabled}
onClick={() => runCommand(applyLinearIssueLink)}
>
<LinkIcon className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
</div>
)
}
@@ -2,6 +2,7 @@
composer card markup together so the inline and modal variants share one UI
surface without splitting the controlled form into hard-to-follow fragments. */
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertTriangle,
Check,
@@ -99,15 +100,50 @@ type NewWorkspaceComposerCardProps = {
sparseControlsEnabled?: boolean
}
const SSH_STATUS_LABELS: Record<SshConnectionStatus, string> = {
disconnected: 'SSH not connected',
connecting: 'Connecting SSH...',
'auth-failed': 'SSH authentication failed',
'deploying-relay': 'Preparing SSH connection...',
connected: 'Connected',
reconnecting: 'Reconnecting SSH...',
'reconnection-failed': 'SSH reconnection failed',
error: translate('auto.components.NewWorkspaceComposerCard.a239038146', 'SSH connection error')
const SSH_STATUS_LABELS: Partial<Record<SshConnectionStatus, string>> = {
get disconnected() {
return translate(
'auto.components.NewWorkspaceComposerCard.sshNotConnected',
'SSH not connected'
)
},
get connecting() {
return translate('auto.components.NewWorkspaceComposerCard.connectingSsh', 'Connecting SSH...')
},
get 'auth-failed'() {
return translate(
'auto.components.NewWorkspaceComposerCard.sshAuthenticationFailed',
'SSH authentication failed'
)
},
get 'deploying-relay'() {
return translate(
'auto.components.NewWorkspaceComposerCard.preparingSshConnection',
'Preparing SSH connection...'
)
},
get connected() {
return translate('auto.components.NewWorkspaceComposerCard.connected', 'Connected')
},
get reconnecting() {
return translate(
'auto.components.NewWorkspaceComposerCard.reconnectingSsh',
'Reconnecting SSH...'
)
},
get 'reconnection-failed'() {
return translate(
'auto.components.NewWorkspaceComposerCard.sshReconnectionFailed',
'SSH reconnection failed'
)
},
get error() {
return translate('auto.components.NewWorkspaceComposerCard.a239038146', 'SSH connection error')
}
}
function getSshStatusLabel(status: SshConnectionStatus): string {
return SSH_STATUS_LABELS[status] ?? status
}
function SetupCommandPreview({
@@ -284,6 +320,9 @@ export default function NewWorkspaceComposerCard({
onSparseSelectPreset,
sparseControlsEnabled = true
}: NewWorkspaceComposerCardProps): React.JSX.Element {
// Why: this form uses the lightweight translate() helper directly; subscribe
// so an already-open create dialog repaints when the UI language changes.
useTranslation()
const { isFileDragOver, dragHandlers } = useComposerFileDragOver()
const openModal = useAppStore((s) => s.openModal)
const activeModal = useAppStore((s) => s.activeModal)
@@ -297,8 +336,8 @@ export default function NewWorkspaceComposerCard({
return repo?.displayName ?? repo?.path ?? 'This project'
}, [eligibleRepos, repoId])
const sshStatusLabel = selectedRepoSshStatus
? SSH_STATUS_LABELS[selectedRepoSshStatus]
: 'Not connected'
? getSshStatusLabel(selectedRepoSshStatus)
: translate('auto.components.NewWorkspaceComposerCard.notConnected', 'Not connected')
const connectButtonLabel =
selectedRepoSshStatus === 'disconnected' || selectedRepoSshStatus === null
? 'Connect'
+40 -75
View File
@@ -133,6 +133,8 @@ import { useAllWorktrees } from '@/store/selectors'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata'
import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
import { GitHubWorkItemLabelPopoverContent } from '@/components/github/GitHubWorkItemLabelPopoverContent'
import { GitHubWorkItemAssigneePopoverContent } from '@/components/github/GitHubWorkItemAssigneePopoverContent'
import {
getGitHubPRReviewerRows,
normalizeGitHubReviewerLogins
@@ -200,6 +202,25 @@ function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null {
}
}
function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null {
try {
const parsed = new URL(itemUrl)
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return null
}
const segments = parsed.pathname.split('/').filter(Boolean)
if (segments.length < 2) {
return null
}
parsed.pathname = `/${segments[0]}/${segments[1]}/labels`
parsed.search = ''
parsed.hash = ''
return parsed.toString()
} catch {
return null
}
}
const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt'))
export type ItemDialogTab = 'conversation' | 'checks' | 'files'
@@ -4948,6 +4969,7 @@ function GHEditSection({
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
const { isPending, run } = useImmediateMutation()
const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url])
// Why: when the dialog opens from a Project view, mutations route through
// *BySlug IPCs and we must keep `projectViewCache` in sync alongside
// `workItemsCache` — `patchWorkItem` only walks the latter, so without this
@@ -5188,18 +5210,6 @@ function GHEditSection({
return null
}
const checkIcon = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
{/* State */}
@@ -5269,34 +5279,16 @@ function GHEditSection({
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{repoLabels.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoLabels.error}
</div>
) : (
<div>
{repoLabels.data.map((label) => (
<button
key={label}
type="button"
onClick={() => handleLabelToggle(label)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localLabels.includes(label)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localLabels.includes(label) && checkIcon}
</span>
{label}
</button>
))}
</div>
)}
<GitHubWorkItemLabelPopoverContent
open={labelPopoverOpen}
labels={repoLabels.data}
selectedLabels={localLabels}
error={repoLabels.error}
loading={repoLabels.loading}
repositoryLabelsUrl={repositoryLabelsUrl}
onToggleLabel={handleLabelToggle}
onOpenSettingsLink={() => setLabelPopoverOpen(false)}
/>
</PopoverContent>
</Popover>
@@ -5327,41 +5319,14 @@ function GHEditSection({
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{repoAssignees.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoAssignees.error}
</div>
) : (
<div>
{repoAssignees.data.map((user) => (
<button
key={user.login}
type="button"
onClick={() => handleAssigneeToggle(user.login)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localAssignees.includes(user.login)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localAssignees.includes(user.login) && checkIcon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{user.login}</span>
{user.name && (
<span className="block truncate text-[11px] text-muted-foreground">
{user.name}
</span>
)}
</span>
</button>
))}
</div>
)}
<GitHubWorkItemAssigneePopoverContent
open={assigneePopoverOpen}
assignees={repoAssignees.data}
selectedLogins={localAssignees}
error={repoAssignees.error}
loading={repoAssignees.loading}
onToggleAssignee={handleAssigneeToggle}
/>
</PopoverContent>
</Popover>
@@ -188,12 +188,16 @@ type BrowserOverlayAnchor = {
const BROWSER_ANNOTATION_INTENT_OPTIONS = [
{
value: 'change',
label: translate('auto.components.browser.pane.BrowserPane.143204e423', 'Change'),
get label() {
return translate('auto.components.browser.pane.BrowserPane.143204e423', 'Change')
},
icon: PencilLine
},
{
value: 'question',
label: translate('auto.components.browser.pane.BrowserPane.b5ba6085de', 'Question'),
get label() {
return translate('auto.components.browser.pane.BrowserPane.b5ba6085de', 'Question')
},
icon: MessageCircleQuestionMark
}
] as const
@@ -1,4 +1,5 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
Code,
Eye,
@@ -27,29 +28,41 @@ type ViewModeMetadata = { label: string; icon: LucideIcon; title?: string }
const DEFAULT_VIEW_MODE_METADATA: Record<EditorToggleValue, ViewModeMetadata> = {
source: {
label: translate('auto.components.editor.EditorViewToggle.4d6ccb7ba6', 'Source'),
get label() {
return translate('auto.components.editor.EditorViewToggle.4d6ccb7ba6', 'Source')
},
icon: Code
},
rich: {
label: translate('auto.components.editor.EditorViewToggle.aff15f94f5', 'Rich Editor'),
get label() {
return translate('auto.components.editor.EditorViewToggle.aff15f94f5', 'Rich Editor')
},
icon: Pencil
},
preview: {
label: translate('auto.components.editor.EditorViewToggle.0d193dc03c', 'Preview'),
get label() {
return translate('auto.components.editor.EditorViewToggle.0d193dc03c', 'Preview')
},
icon: Eye
},
edit: {
label: translate('auto.components.editor.EditorViewToggle.ac3bb87913', 'Edit'),
get label() {
return translate('auto.components.editor.EditorViewToggle.ac3bb87913', 'Edit')
},
icon: FileText
},
changes: {
label: translate('auto.components.editor.EditorViewToggle.4837f3f578', 'Changes'),
get label() {
return translate('auto.components.editor.EditorViewToggle.4837f3f578', 'Changes')
},
icon: GitCompareArrows,
// Why: "Changes" collides with the Source Control sidebar's "Branch
// Changes" section, which diffs against the base ref. This toggle shows
// uncommitted changes (working tree vs HEAD), so disambiguate in the
// hover title without repeating the button label.
title: translate('auto.components.editor.EditorViewToggle.167f45888c', 'Uncommitted changes')
get title() {
return translate('auto.components.editor.EditorViewToggle.167f45888c', 'Uncommitted changes')
}
}
}
@@ -58,14 +71,18 @@ const DEFAULT_VIEW_MODE_METADATA: Record<EditorToggleValue, ViewModeMetadata> =
// which we don't offer, so callers can override the per-mode presentation.
export const CSV_VIEW_MODE_METADATA: Partial<Record<MarkdownViewMode, ViewModeMetadata>> = {
rich: {
label: translate('auto.components.editor.EditorViewToggle.e408aa9cd5', 'Table'),
get label() {
return translate('auto.components.editor.EditorViewToggle.e408aa9cd5', 'Table')
},
icon: TableIcon
}
}
export const NOTEBOOK_VIEW_MODE_METADATA: Partial<Record<MarkdownViewMode, ViewModeMetadata>> = {
rich: {
label: translate('auto.components.editor.EditorViewToggle.b3410cd5e0', 'Notebook'),
get label() {
return translate('auto.components.editor.EditorViewToggle.b3410cd5e0', 'Notebook')
},
icon: NotebookText
}
}
@@ -83,6 +100,9 @@ export default function EditorViewToggle({
onChange,
metadataOverride
}: EditorViewToggleProps): React.JSX.Element {
// Why: metadata labels are lightweight getters, so subscribe this compact
// control to repaint when the active language changes.
useTranslation()
return (
<TooltipProvider delayDuration={300}>
<ToggleGroup
@@ -1,4 +1,5 @@
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
import type { NodeViewProps } from '@tiptap/react'
import { Copy, Check } from 'lucide-react'
@@ -14,100 +15,148 @@ import { translate } from '@/i18n/i18n'
const LANGUAGES = [
{
value: '',
label: translate('auto.components.editor.RichMarkdownCodeBlock.13822cdfda', 'Plain text')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.13822cdfda', 'Plain text')
}
},
{
value: 'bash',
label: translate('auto.components.editor.RichMarkdownCodeBlock.4227cf50fe', 'Bash')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.4227cf50fe', 'Bash')
}
},
{ value: 'c', label: 'C' },
{
value: 'cpp',
label: translate('auto.components.editor.RichMarkdownCodeBlock.4daed43ae3', 'C++')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.4daed43ae3', 'C++')
}
},
{
value: 'css',
label: translate('auto.components.editor.RichMarkdownCodeBlock.026653f21f', 'CSS')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.026653f21f', 'CSS')
}
},
{
value: 'diff',
label: translate('auto.components.editor.RichMarkdownCodeBlock.bf6ee5caaa', 'Diff')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.bf6ee5caaa', 'Diff')
}
},
{
value: 'go',
label: translate('auto.components.editor.RichMarkdownCodeBlock.edfcc64182', 'Go')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.edfcc64182', 'Go')
}
},
{
value: 'graphql',
label: translate('auto.components.editor.RichMarkdownCodeBlock.706fd85738', 'GraphQL')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.706fd85738', 'GraphQL')
}
},
{
value: 'html',
label: translate('auto.components.editor.RichMarkdownCodeBlock.8c4a3fa02d', 'HTML')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.8c4a3fa02d', 'HTML')
}
},
{
value: 'java',
label: translate('auto.components.editor.RichMarkdownCodeBlock.36536ad539', 'Java')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.36536ad539', 'Java')
}
},
{
value: 'javascript',
label: translate('auto.components.editor.RichMarkdownCodeBlock.a209c57063', 'JavaScript')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.a209c57063', 'JavaScript')
}
},
{
value: 'json',
label: translate('auto.components.editor.RichMarkdownCodeBlock.78eba32de4', 'JSON')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.78eba32de4', 'JSON')
}
},
{
value: 'kotlin',
label: translate('auto.components.editor.RichMarkdownCodeBlock.bcb236e2d8', 'Kotlin')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.bcb236e2d8', 'Kotlin')
}
},
{
value: 'markdown',
label: translate('auto.components.editor.RichMarkdownCodeBlock.983b9576b4', 'Markdown')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.983b9576b4', 'Markdown')
}
},
{
value: 'mermaid',
label: translate('auto.components.editor.RichMarkdownCodeBlock.89d6cc14fb', 'Mermaid')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.89d6cc14fb', 'Mermaid')
}
},
{
value: 'python',
label: translate('auto.components.editor.RichMarkdownCodeBlock.2391f9cda9', 'Python')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.2391f9cda9', 'Python')
}
},
{
value: 'ruby',
label: translate('auto.components.editor.RichMarkdownCodeBlock.96182a2f64', 'Ruby')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.96182a2f64', 'Ruby')
}
},
{
value: 'rust',
label: translate('auto.components.editor.RichMarkdownCodeBlock.e72e6b03f4', 'Rust')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.e72e6b03f4', 'Rust')
}
},
{
value: 'scss',
label: translate('auto.components.editor.RichMarkdownCodeBlock.5af8251002', 'SCSS')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.5af8251002', 'SCSS')
}
},
{
value: 'shell',
label: translate('auto.components.editor.RichMarkdownCodeBlock.d01f55be57', 'Shell')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.d01f55be57', 'Shell')
}
},
{
value: 'sql',
label: translate('auto.components.editor.RichMarkdownCodeBlock.3009f722b9', 'SQL')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.3009f722b9', 'SQL')
}
},
{
value: 'swift',
label: translate('auto.components.editor.RichMarkdownCodeBlock.9e384d48dc', 'Swift')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.9e384d48dc', 'Swift')
}
},
{
value: 'typescript',
label: translate('auto.components.editor.RichMarkdownCodeBlock.88d777bc07', 'TypeScript')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.88d777bc07', 'TypeScript')
}
},
{
value: 'xml',
label: translate('auto.components.editor.RichMarkdownCodeBlock.5ef5605cb7', 'XML')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.5ef5605cb7', 'XML')
}
},
{
value: 'yaml',
label: translate('auto.components.editor.RichMarkdownCodeBlock.74eab1d9b2', 'YAML')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.74eab1d9b2', 'YAML')
}
}
]
@@ -115,6 +164,7 @@ export function RichMarkdownCodeBlock({
node,
updateAttributes
}: NodeViewProps): React.JSX.Element {
useTranslation()
const language = (node.attrs.language as string) || ''
const [copied, setCopied] = useState(false)
const copiedResetTimerRef = useRef<number | null>(null)
@@ -17,10 +17,12 @@ type UnsupportedMatch = {
const UNSUPPORTED_PATTERNS: UnsupportedMatch[] = [
{
reason: 'html-or-jsx',
message: translate(
'auto.components.editor.markdown.rich.mode.57128b73e1',
'Editable only in code mode because this file contains HTML, JSX, or MDX.'
),
get message() {
return translate(
'auto.components.editor.markdown.rich.mode.57128b73e1',
'Editable only in code mode because this file contains HTML, JSX, or MDX.'
)
},
// Why: the rich editor preserves common embedded markup via placeholder
// tokens before parsing, but any HTML shape that still fails round-trip
// must fall back instead of risking silent source corruption.
@@ -28,18 +30,22 @@ const UNSUPPORTED_PATTERNS: UnsupportedMatch[] = [
},
{
reason: 'reference-links',
message: translate(
'auto.components.editor.markdown.rich.mode.2fd2b44073',
'Editable only in code mode because this file contains reference-style links.'
),
get message() {
return translate(
'auto.components.editor.markdown.rich.mode.2fd2b44073',
'Editable only in code mode because this file contains reference-style links.'
)
},
pattern: /^\[[^\]]+\]:\s+\S+/m
},
{
reason: 'footnotes',
message: translate(
'auto.components.editor.markdown.rich.mode.7a8ce7c7da',
'Editable only in code mode because this file contains footnotes.'
),
get message() {
return translate(
'auto.components.editor.markdown.rich.mode.7a8ce7c7da',
'Editable only in code mode because this file contains footnotes.'
)
},
pattern: /^\[\^[^\]]+\]:\s+/m
}
]
@@ -0,0 +1,406 @@
import type {} from '@tiptap/extension-mathematics'
import {
ChevronRight,
Heading1,
Heading2,
Heading3,
ImageIcon,
List,
ListOrdered,
Quote,
Sigma,
Table2,
Workflow
} from 'lucide-react'
import { translate } from '@/i18n/i18n'
import {
icon,
insertCodeBlock,
insertTextWithSelection,
insertToggle,
textIcon,
type SlashCommand
} from './rich-markdown-slash-command-primitives'
export type {
SlashCommand,
SlashCommandGroup,
SlashCommandIcon,
SlashCommandId,
SlashMenuState
} from './rich-markdown-slash-command-primitives'
export const slashCommands: SlashCommand[] = [
{
id: 'heading-1',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.e66e7f04c6',
'Heading 1'
)
},
aliases: ['h1', 'title'],
icon: icon(Heading1),
group: 'Headings',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.570611864e',
'Large section heading.'
)
},
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
// invoking "/h1" on an existing H1 should keep it as H1, not revert to paragraph.
editor.chain().focus().setHeading({ level: 1 }).run()
}
},
{
id: 'toggle-h1',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.41482b15ce',
'Toggle Heading 1'
)
},
aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'],
icon: icon(ChevronRight),
group: 'Headings',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.3294a2c0cc',
'Create a collapsible section with a large heading summary.'
)
},
run: (editor) => {
insertToggle(editor, 'heading-1')
}
},
{
id: 'heading-2',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.c209a116b7',
'Heading 2'
)
},
aliases: ['h2'],
icon: icon(Heading2),
group: 'Headings',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.45cf7ceb3f',
'Medium section heading.'
)
},
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
// invoking "/h2" on an existing H2 should keep it as H2, not revert to paragraph.
editor.chain().focus().setHeading({ level: 2 }).run()
}
},
{
id: 'heading-3',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.30566ee962',
'Heading 3'
)
},
aliases: ['h3'],
icon: icon(Heading3),
group: 'Headings',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.4920740259',
'Small section heading.'
)
},
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
// invoking "/h3" on an existing H3 should keep it as H3, not revert to paragraph.
editor.chain().focus().setHeading({ level: 3 }).run()
}
},
{
id: 'blockquote',
get label() {
return translate('auto.components.editor.rich.markdown.slash.commands.c4c775778b', 'Quote')
},
aliases: ['quote', 'blockquote'],
icon: icon(Quote),
group: 'Basic blocks',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.6a3def14de',
'Insert a blockquote.'
)
},
run: (editor) => {
editor.chain().focus().toggleBlockquote().run()
}
},
{
id: 'ordered-list',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.ed4cf0ebce',
'Numbered List'
)
},
aliases: ['ordered', 'ol', 'numbered'],
icon: icon(ListOrdered),
group: 'Basic blocks',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.8e00aba296',
'Create an ordered list.'
)
},
run: (editor) => {
editor.chain().focus().toggleOrderedList().run()
}
},
{
id: 'bullet-list',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.56ff3237e7',
'Bullet List'
)
},
aliases: ['bullet', 'ul', 'list'],
icon: icon(List),
group: 'Basic blocks',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.c9b9e826b8',
'Create an unordered list.'
)
},
run: (editor) => {
editor.chain().focus().toggleBulletList().run()
}
},
{
id: 'task-list',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.d0d2cdfbdb',
'Check List'
)
},
aliases: ['todo', 'task', 'checkbox'],
icon: icon(List),
group: 'Basic blocks',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.d766f44867',
'Create a checklist.'
)
},
run: (editor) => {
editor.chain().focus().toggleTaskList().run()
}
},
{
id: 'text',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.58abdb9d41',
'Paragraph'
)
},
aliases: ['paragraph', 'plain'],
icon: icon(List),
group: 'Basic blocks',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.9a7fe896dc',
'Start a normal paragraph.'
)
},
run: (editor) => {
editor.chain().focus().setParagraph().run()
}
},
{
id: 'toggle-text',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.f82c78a2ee',
'Toggle Text'
)
},
aliases: ['toggle', 'details', 'collapse', 'toggle-text'],
icon: icon(ChevronRight),
group: 'Basic blocks',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.972ef9aeea',
'Create a collapsible text section.'
)
},
run: (editor) => {
insertToggle(editor)
}
},
{
id: 'code-block',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.624b50cf25',
'Code Block'
)
},
aliases: ['code', 'snippet'],
icon: icon(List),
group: 'Basic blocks',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.89e327e054',
'Insert a fenced code block.'
)
},
run: (editor) => {
editor.chain().focus().toggleCodeBlock().run()
}
},
{
id: 'divider',
get label() {
return translate('auto.components.editor.rich.markdown.slash.commands.ae8377cf6b', 'Divider')
},
aliases: ['divider', 'rule', 'hr'],
icon: icon(List),
group: 'Basic blocks',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.fae45ef4d3',
'Insert a horizontal rule.'
)
},
run: (editor) => {
editor.chain().focus().setHorizontalRule().run()
}
},
{
id: 'table',
get label() {
return translate('auto.components.editor.rich.markdown.slash.commands.19ea597868', 'Table')
},
aliases: ['grid', 'columns', 'rows'],
icon: icon(Table2),
group: 'Advanced',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.67faab829b',
'Insert a 3x3 markdown table.'
)
},
run: (editor) => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
}
},
{
id: 'mermaid',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.e516d3f6e3',
'Mermaid Diagram'
)
},
aliases: ['diagram', 'flowchart', 'chart', 'graph'],
icon: icon(Workflow),
group: 'Advanced',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.0ed9a7b38c',
'Insert a Mermaid fenced block.'
)
},
run: (editor) => {
insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]')
}
},
{
id: 'inline-math',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.2bf5544faf',
'Inline Math'
)
},
aliases: ['math', 'latex', 'equation', 'formula'],
icon: icon(Sigma),
group: 'Advanced',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.565907cf7a',
'Insert inline LaTeX math.'
)
},
run: (editor) => {
editor.commands.insertInlineMath({ latex: 'x' })
}
},
{
id: 'math-block',
get label() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.6993a38ad1',
'Math Block'
)
},
aliases: ['display math', 'latex block', 'equation block'],
icon: icon(Sigma),
group: 'Advanced',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.ae7d0f3f37',
'Insert display LaTeX math.'
)
},
run: (editor) => {
editor.commands.insertBlockMath({ latex: 'x' })
}
},
{
id: 'image',
get label() {
return translate('auto.components.editor.rich.markdown.slash.commands.572be8e524', 'Image')
},
aliases: ['image', 'img'],
icon: icon(ImageIcon),
group: 'Media',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.3324eb391a',
'Insert an image from your computer.'
)
},
// Why: window.prompt() is not supported in Electron's renderer process,
// so image URL input is handled by an inline input bar in RichMarkdownEditor.
run: (editor) => {
editor.chain().focus().run()
}
},
{
id: 'emoji',
get label() {
return translate('auto.components.editor.rich.markdown.slash.commands.8a30cbaeca', 'Emoji')
},
aliases: ['smile', 'reaction', 'icon'],
icon: textIcon('🙂'),
group: 'Others',
get description() {
return translate(
'auto.components.editor.rich.markdown.slash.commands.07e1b32396',
'Insert a plain Unicode emoji.'
)
},
run: (editor) => {
insertTextWithSelection(editor, '🙂')
}
}
]
@@ -0,0 +1,117 @@
import type React from 'react'
import type { Editor } from '@tiptap/react'
import { TextSelection } from '@tiptap/pm/state'
export type SlashMenuState = {
query: string
from: number
to: number
left: number
top: number
}
export type SlashCommandId =
| 'text'
| 'toggle-text'
| 'heading-1'
| 'toggle-h1'
| 'heading-2'
| 'heading-3'
| 'task-list'
| 'bullet-list'
| 'ordered-list'
| 'blockquote'
| 'code-block'
| 'divider'
| 'image'
| 'table'
| 'mermaid'
| 'inline-math'
| 'math-block'
| 'emoji'
export type SlashCommandIcon =
| { kind: 'component'; component: React.ComponentType<{ className?: string }> }
| { kind: 'text'; value: string }
export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others'
export type SlashCommand = {
id: SlashCommandId
label: string
aliases: string[]
icon: SlashCommandIcon
group: SlashCommandGroup
description: string
run: (editor: Editor) => void
}
export function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon {
return { kind: 'component', component }
}
export function textIcon(value: string): SlashCommandIcon {
return { kind: 'text', value }
}
export function insertTextWithSelection(
editor: Editor,
text: string,
selectionStartOffset?: number,
selectionEndOffset = selectionStartOffset
): void {
editor.commands.command(({ state, dispatch }) => {
const from = state.selection.from
const tr = state.tr.insertText(text, from, state.selection.to)
if (selectionStartOffset !== undefined) {
const selectionFrom = from + selectionStartOffset
const selectionTo = from + (selectionEndOffset ?? selectionStartOffset)
tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo))
}
dispatch?.(tr.scrollIntoView())
return true
})
}
export function insertCodeBlock(editor: Editor, language: string, text: string): void {
editor.commands.command(({ state, dispatch }) => {
const codeBlockType = state.schema.nodes.codeBlock
if (!codeBlockType) {
return false
}
const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined)
const tr = state.tr.replaceSelectionWith(node).scrollIntoView()
const cursor = tr.selection.from + 1
tr.setSelection(TextSelection.create(tr.doc, cursor, cursor))
dispatch?.(tr)
return true
})
}
export function insertToggle(editor: Editor, variant?: 'heading-1'): void {
const insertAt = editor.state.selection.from
editor
.chain()
.focus()
.insertContentAt(insertAt, {
type: 'details',
attrs: {
open: true,
...(variant ? { variant } : {})
},
content: [
{
type: 'detailsSummary'
},
{
type: 'detailsContent',
content: [{ type: 'paragraph' }]
}
]
})
.setTextSelection(insertAt + 1)
.run()
}
@@ -1,135 +1,15 @@
import type React from 'react'
import type { Editor } from '@tiptap/react'
import { TextSelection } from '@tiptap/pm/state'
import type {} from '@tiptap/extension-mathematics'
import {
ChevronRight,
Heading1,
Heading2,
Heading3,
ImageIcon,
List,
ListOrdered,
Quote,
Sigma,
Table2,
Workflow
} from 'lucide-react'
import { translate } from '@/i18n/i18n'
import type { SlashCommand, SlashMenuState } from './rich-markdown-slash-command-catalog'
export type SlashMenuState = {
query: string
from: number
to: number
left: number
top: number
}
export type SlashCommandId =
| 'text'
| 'toggle-text'
| 'heading-1'
| 'toggle-h1'
| 'heading-2'
| 'heading-3'
| 'task-list'
| 'bullet-list'
| 'ordered-list'
| 'blockquote'
| 'code-block'
| 'divider'
| 'image'
| 'table'
| 'mermaid'
| 'inline-math'
| 'math-block'
| 'emoji'
export type SlashCommandIcon =
| { kind: 'component'; component: React.ComponentType<{ className?: string }> }
| { kind: 'text'; value: string }
export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others'
export type SlashCommand = {
id: SlashCommandId
label: string
aliases: string[]
icon: SlashCommandIcon
group: SlashCommandGroup
description: string
run: (editor: Editor) => void
}
function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon {
return { kind: 'component', component }
}
function textIcon(value: string): SlashCommandIcon {
return { kind: 'text', value }
}
function insertTextWithSelection(
editor: Editor,
text: string,
selectionStartOffset?: number,
selectionEndOffset = selectionStartOffset
): void {
editor.commands.command(({ state, dispatch }) => {
const from = state.selection.from
const tr = state.tr.insertText(text, from, state.selection.to)
if (selectionStartOffset !== undefined) {
const selectionFrom = from + selectionStartOffset
const selectionTo = from + (selectionEndOffset ?? selectionStartOffset)
tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo))
}
dispatch?.(tr.scrollIntoView())
return true
})
}
function insertCodeBlock(editor: Editor, language: string, text: string): void {
editor.commands.command(({ state, dispatch }) => {
const codeBlockType = state.schema.nodes.codeBlock
if (!codeBlockType) {
return false
}
const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined)
const tr = state.tr.replaceSelectionWith(node).scrollIntoView()
const cursor = tr.selection.from + 1
tr.setSelection(TextSelection.create(tr.doc, cursor, cursor))
dispatch?.(tr)
return true
})
}
function insertToggle(editor: Editor, variant?: 'heading-1'): void {
const insertAt = editor.state.selection.from
editor
.chain()
.focus()
.insertContentAt(insertAt, {
type: 'details',
attrs: {
open: true,
...(variant ? { variant } : {})
},
content: [
{
type: 'detailsSummary'
},
{
type: 'detailsContent',
content: [{ type: 'paragraph' }]
}
]
})
.setTextSelection(insertAt + 1)
.run()
}
export { slashCommands } from './rich-markdown-slash-command-catalog'
export type {
SlashCommand,
SlashCommandGroup,
SlashCommandIcon,
SlashCommandId,
SlashMenuState
} from './rich-markdown-slash-command-catalog'
/**
* Executes a slash command by first deleting the typed slash text, then
@@ -157,215 +37,6 @@ export function runSlashCommand(
command.run(editor)
}
export const slashCommands: SlashCommand[] = [
{
id: 'heading-1',
label: translate("auto.components.editor.rich.markdown.slash.commands.e66e7f04c6", "Heading 1"),
aliases: ['h1', 'title'],
icon: icon(Heading1),
group: 'Headings',
description: translate("auto.components.editor.rich.markdown.slash.commands.570611864e", "Large section heading."),
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
// invoking "/h1" on an existing H1 should keep it as H1, not revert to paragraph.
editor.chain().focus().setHeading({ level: 1 }).run()
}
},
{
id: 'toggle-h1',
label: translate("auto.components.editor.rich.markdown.slash.commands.41482b15ce", "Toggle Heading 1"),
aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'],
icon: icon(ChevronRight),
group: 'Headings',
description: translate("auto.components.editor.rich.markdown.slash.commands.3294a2c0cc", "Create a collapsible section with a large heading summary."),
run: (editor) => {
insertToggle(editor, 'heading-1')
}
},
{
id: 'heading-2',
label: translate("auto.components.editor.rich.markdown.slash.commands.c209a116b7", "Heading 2"),
aliases: ['h2'],
icon: icon(Heading2),
group: 'Headings',
description: translate("auto.components.editor.rich.markdown.slash.commands.45cf7ceb3f", "Medium section heading."),
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
// invoking "/h2" on an existing H2 should keep it as H2, not revert to paragraph.
editor.chain().focus().setHeading({ level: 2 }).run()
}
},
{
id: 'heading-3',
label: translate("auto.components.editor.rich.markdown.slash.commands.30566ee962", "Heading 3"),
aliases: ['h3'],
icon: icon(Heading3),
group: 'Headings',
description: translate("auto.components.editor.rich.markdown.slash.commands.4920740259", "Small section heading."),
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
// invoking "/h3" on an existing H3 should keep it as H3, not revert to paragraph.
editor.chain().focus().setHeading({ level: 3 }).run()
}
},
{
id: 'blockquote',
label: translate("auto.components.editor.rich.markdown.slash.commands.c4c775778b", "Quote"),
aliases: ['quote', 'blockquote'],
icon: icon(Quote),
group: 'Basic blocks',
description: translate("auto.components.editor.rich.markdown.slash.commands.6a3def14de", "Insert a blockquote."),
run: (editor) => {
editor.chain().focus().toggleBlockquote().run()
}
},
{
id: 'ordered-list',
label: translate("auto.components.editor.rich.markdown.slash.commands.ed4cf0ebce", "Numbered List"),
aliases: ['ordered', 'ol', 'numbered'],
icon: icon(ListOrdered),
group: 'Basic blocks',
description: translate("auto.components.editor.rich.markdown.slash.commands.8e00aba296", "Create an ordered list."),
run: (editor) => {
editor.chain().focus().toggleOrderedList().run()
}
},
{
id: 'bullet-list',
label: translate("auto.components.editor.rich.markdown.slash.commands.56ff3237e7", "Bullet List"),
aliases: ['bullet', 'ul', 'list'],
icon: icon(List),
group: 'Basic blocks',
description: translate("auto.components.editor.rich.markdown.slash.commands.c9b9e826b8", "Create an unordered list."),
run: (editor) => {
editor.chain().focus().toggleBulletList().run()
}
},
{
id: 'task-list',
label: translate("auto.components.editor.rich.markdown.slash.commands.d0d2cdfbdb", "Check List"),
aliases: ['todo', 'task', 'checkbox'],
icon: icon(List),
group: 'Basic blocks',
description: translate("auto.components.editor.rich.markdown.slash.commands.d766f44867", "Create a checklist."),
run: (editor) => {
editor.chain().focus().toggleTaskList().run()
}
},
{
id: 'text',
label: translate("auto.components.editor.rich.markdown.slash.commands.58abdb9d41", "Paragraph"),
aliases: ['paragraph', 'plain'],
icon: icon(List),
group: 'Basic blocks',
description: translate("auto.components.editor.rich.markdown.slash.commands.9a7fe896dc", "Start a normal paragraph."),
run: (editor) => {
editor.chain().focus().setParagraph().run()
}
},
{
id: 'toggle-text',
label: translate("auto.components.editor.rich.markdown.slash.commands.f82c78a2ee", "Toggle Text"),
aliases: ['toggle', 'details', 'collapse', 'toggle-text'],
icon: icon(ChevronRight),
group: 'Basic blocks',
description: translate("auto.components.editor.rich.markdown.slash.commands.972ef9aeea", "Create a collapsible text section."),
run: (editor) => {
insertToggle(editor)
}
},
{
id: 'code-block',
label: translate("auto.components.editor.rich.markdown.slash.commands.624b50cf25", "Code Block"),
aliases: ['code', 'snippet'],
icon: icon(List),
group: 'Basic blocks',
description: translate("auto.components.editor.rich.markdown.slash.commands.89e327e054", "Insert a fenced code block."),
run: (editor) => {
editor.chain().focus().toggleCodeBlock().run()
}
},
{
id: 'divider',
label: translate("auto.components.editor.rich.markdown.slash.commands.ae8377cf6b", "Divider"),
aliases: ['divider', 'rule', 'hr'],
icon: icon(List),
group: 'Basic blocks',
description: translate("auto.components.editor.rich.markdown.slash.commands.fae45ef4d3", "Insert a horizontal rule."),
run: (editor) => {
editor.chain().focus().setHorizontalRule().run()
}
},
{
id: 'table',
label: translate("auto.components.editor.rich.markdown.slash.commands.19ea597868", "Table"),
aliases: ['grid', 'columns', 'rows'],
icon: icon(Table2),
group: 'Advanced',
description: translate("auto.components.editor.rich.markdown.slash.commands.67faab829b", "Insert a 3x3 markdown table."),
run: (editor) => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
}
},
{
id: 'mermaid',
label: translate("auto.components.editor.rich.markdown.slash.commands.e516d3f6e3", "Mermaid Diagram"),
aliases: ['diagram', 'flowchart', 'chart', 'graph'],
icon: icon(Workflow),
group: 'Advanced',
description: translate("auto.components.editor.rich.markdown.slash.commands.0ed9a7b38c", "Insert a Mermaid fenced block."),
run: (editor) => {
insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]')
}
},
{
id: 'inline-math',
label: translate("auto.components.editor.rich.markdown.slash.commands.2bf5544faf", "Inline Math"),
aliases: ['math', 'latex', 'equation', 'formula'],
icon: icon(Sigma),
group: 'Advanced',
description: translate("auto.components.editor.rich.markdown.slash.commands.565907cf7a", "Insert inline LaTeX math."),
run: (editor) => {
editor.commands.insertInlineMath({ latex: 'x' })
}
},
{
id: 'math-block',
label: translate("auto.components.editor.rich.markdown.slash.commands.6993a38ad1", "Math Block"),
aliases: ['display math', 'latex block', 'equation block'],
icon: icon(Sigma),
group: 'Advanced',
description: translate("auto.components.editor.rich.markdown.slash.commands.ae7d0f3f37", "Insert display LaTeX math."),
run: (editor) => {
editor.commands.insertBlockMath({ latex: 'x' })
}
},
{
id: 'image',
label: translate("auto.components.editor.rich.markdown.slash.commands.572be8e524", "Image"),
aliases: ['image', 'img'],
icon: icon(ImageIcon),
group: 'Media',
description: translate("auto.components.editor.rich.markdown.slash.commands.3324eb391a", "Insert an image from your computer."),
// Why: window.prompt() is not supported in Electron's renderer process,
// so image URL input is handled by an inline input bar in RichMarkdownEditor.
run: (editor) => {
editor.chain().focus().run()
}
},
{
id: 'emoji',
label: translate("auto.components.editor.rich.markdown.slash.commands.8a30cbaeca", "Emoji"),
aliases: ['smile', 'reaction', 'icon'],
icon: textIcon('🙂'),
group: 'Others',
description: translate("auto.components.editor.rich.markdown.slash.commands.07e1b32396", "Insert a plain Unicode emoji."),
run: (editor) => {
insertTextWithSelection(editor, '🙂')
}
}
]
/**
* Inspects the editor selection to decide whether the slash-command menu
* should be open (and where to position it), or dismissed.
@@ -142,38 +142,50 @@ type AgentCapabilitySetupRow = {
const AGENT_CAPABILITY_SETUP_ROWS: readonly AgentCapabilitySetupRow[] = [
{
id: 'orchestration',
title: translate(
'auto.components.feature.wall.AgentCapabilitiesSetupAction.ac07f8887f',
'Agent Orchestration'
),
description: translate(
'auto.components.feature.wall.AgentCapabilitiesSetupAction.c61c91e642',
'Let agents coordinate through Orca to keep large, multi-step tasks moving to completion.'
),
get title() {
return translate(
'auto.components.feature.wall.AgentCapabilitiesSetupAction.ac07f8887f',
'Agent Orchestration'
)
},
get description() {
return translate(
'auto.components.feature.wall.AgentCapabilitiesSetupAction.c61c91e642',
'Let agents coordinate through Orca to keep large, multi-step tasks moving to completion.'
)
},
icon: <Workflow className="size-4" />
},
{
id: 'browserUse',
title: translate(
'auto.components.feature.wall.AgentCapabilitiesSetupAction.e638da007a',
'Agent Browser Use'
),
description: translate(
'auto.components.feature.wall.AgentCapabilitiesSetupAction.5e8fe5a72d',
"Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see."
),
get title() {
return translate(
'auto.components.feature.wall.AgentCapabilitiesSetupAction.e638da007a',
'Agent Browser Use'
)
},
get description() {
return translate(
'auto.components.feature.wall.AgentCapabilitiesSetupAction.5e8fe5a72d',
"Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see."
)
},
icon: <Globe2 className="size-4" />
},
{
id: 'computerUse',
title: translate(
'auto.components.feature.wall.AgentCapabilitiesSetupAction.362a07517d',
'Computer Use'
),
description: translate(
'auto.components.feature.wall.AgentCapabilitiesSetupAction.1b51644c2d',
'Let agents control the desktop, moving the cursor, clicking, and typing in any app.'
),
get title() {
return translate(
'auto.components.feature.wall.AgentCapabilitiesSetupAction.362a07517d',
'Computer Use'
)
},
get description() {
return translate(
'auto.components.feature.wall.AgentCapabilitiesSetupAction.1b51644c2d',
'Let agents control the desktop, moving the cursor, clicking, and typing in any app.'
)
},
icon: <MonitorCog className="size-4" />
}
]
@@ -127,17 +127,22 @@ const TERM_ENTRIES: readonly { entry: TermEntry; minPhase: Phase }[] = [
{
entry: {
kind: 'ok',
html: (
<>
{translate('auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545', '✓ Updated')}{' '}
<code className="text-emerald-600 dark:text-emerald-400">
get html() {
return (
<>
{translate(
'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a',
'.pp-card[data-card="starter"] .pp-cta'
)}
</code>
</>
)
'auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545',
'✓ Updated'
)}{' '}
<code className="text-emerald-600 dark:text-emerald-400">
{translate(
'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a',
'.pp-card[data-card="starter"] .pp-cta'
)}
</code>
</>
)
}
},
minPhase: 'updated'
},
@@ -156,14 +161,16 @@ const TERM_ENTRIES: readonly { entry: TermEntry; minPhase: Phase }[] = [
{
entry: {
kind: 'ok',
html: (
<>
{translate(
'auto.components.feature.wall.BrowserAnimatedVisual.eb88125c6f',
'✓ Verified — Try free still works.'
)}
</>
)
get html() {
return (
<>
{translate(
'auto.components.feature.wall.BrowserAnimatedVisual.eb88125c6f',
'✓ Verified — Try free still works.'
)}
</>
)
}
},
minPhase: 'verified'
}
@@ -7,48 +7,25 @@ import {
MailGlyph,
WorkingSpinner
} from './feature-tour-preview-glyphs'
import {
FEATURE_TOUR_ORCHESTRATION_CHILDREN,
FEATURE_TOUR_PREVIEW_COPY
} from './feature-tour-preview-copy'
import { FeatureTourWorkspaceCard } from './FeatureTourWorkspaceCard'
import { translate } from '@/i18n/i18n'
type FrameId = 1 | 2 | 3 | 4
export type FeatureTourPreviewFrameCopy = {
id: FrameId
title: string
caption: string
}
export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] = [
{
id: 1,
title: translate("auto.components.feature.wall.FeatureTourPreview.56a0271428", "Isolated workspaces"),
caption:
'Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.'
},
{
id: 2,
title: translate("auto.components.feature.wall.FeatureTourPreview.e44269e97d", "Agent orchestration"),
caption: 'Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.'
},
{
id: 3,
title: translate("auto.components.feature.wall.FeatureTourPreview.ef737dcee1", "GitHub & Linear tasks"),
caption:
'Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.'
},
{
id: 4,
title: translate("auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a", "Splittable terminal"),
caption:
'Open any workspace to return to its terminal, then split panes for tests, logs, and agents.'
}
]
export { FEATURE_TOUR_PREVIEW_COPY } from './feature-tour-preview-copy'
export type { FeatureTourPreviewFrameCopy } from './feature-tour-preview-copy'
function WorkspaceFrame(): JSX.Element {
return (
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.56a0271428", "Isolated workspaces")}</div>
{translate(
'auto.components.feature.wall.FeatureTourPreview.56a0271428',
'Isolated workspaces'
)}
</div>
{/* Why: 3 cards in a row tells the "ship several at once" story by
composition; the wide preview aspect (~4.9:1) makes a vertical stack
read as wasted space. The grid auto-sizes (no flex-1) so the cards
@@ -57,7 +34,10 @@ function WorkspaceFrame(): JSX.Element {
<div className="grid grid-cols-3 gap-3 px-4">
<FeatureTourWorkspaceCard
status="working"
title={translate("auto.components.feature.wall.FeatureTourPreview.3c4adfd821", "fix login race condition")}
title={translate(
'auto.components.feature.wall.FeatureTourPreview.3c4adfd821',
'fix login race condition'
)}
agents={[
{ kind: 'claude', barWidth: '60%', state: 'working' },
{ kind: 'codex', barWidth: '52%', state: 'working' }
@@ -65,12 +45,18 @@ function WorkspaceFrame(): JSX.Element {
/>
<FeatureTourWorkspaceCard
status="done"
title={translate("auto.components.feature.wall.FeatureTourPreview.9c812e0d7c", "speed up CI pipeline")}
title={translate(
'auto.components.feature.wall.FeatureTourPreview.9c812e0d7c',
'speed up CI pipeline'
)}
agents={[{ kind: 'opencode-go', barWidth: '70%', state: 'done' }]}
/>
<FeatureTourWorkspaceCard
status="working"
title={translate("auto.components.feature.wall.FeatureTourPreview.e38112b289", "refactor billing webhook")}
title={translate(
'auto.components.feature.wall.FeatureTourPreview.e38112b289',
'refactor billing webhook'
)}
agents={[{ kind: 'claude', barWidth: '38%', state: 'working' }]}
/>
</div>
@@ -78,22 +64,6 @@ function WorkspaceFrame(): JSX.Element {
)
}
type OrchChildAgent = 'claude' | 'codex' | 'opencode-go'
const ORCH_CHILDREN: readonly {
key: 'top' | 'mid' | 'bot'
position: string
label: string
agent: OrchChildAgent
}[] = [
// Why: card vertical centers anchor to 18% / 50% / 82% — the same Y
// endpoints the dashed SVG paths terminate at — so the connectors land on
// each card's center regardless of card height.
{ key: 'top', position: 'top-[18%] -translate-y-1/2', label: translate("auto.components.feature.wall.FeatureTourPreview.b1f17bcc74", "PR 1/3"), agent: 'claude' },
{ key: 'mid', position: 'top-1/2 -translate-y-1/2', label: translate("auto.components.feature.wall.FeatureTourPreview.cfdfd4d6b4", "PR 2/3"), agent: 'codex' },
{ key: 'bot', position: 'top-[82%] -translate-y-1/2', label: translate("auto.components.feature.wall.FeatureTourPreview.ec4a73f5e6", "PR 3/3"), agent: 'opencode-go' }
]
function OrchestrationFrame(): JSX.Element {
// Why: a horizontal fan (root → 3 children L→R) reads naturally as
// "fans out and ships parallel PRs" at the wide aspect; the previous
@@ -104,7 +74,11 @@ function OrchestrationFrame(): JSX.Element {
return (
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.e44269e97d", "Agent orchestration")}</div>
{translate(
'auto.components.feature.wall.FeatureTourPreview.e44269e97d',
'Agent orchestration'
)}
</div>
<div className="relative w-full flex-1">
{/* Why: viewBox is percent-units (100×100, preserveAspectRatio="none")
so endpoints anchor to the same percentage anchors as the cards
@@ -158,20 +132,28 @@ function OrchestrationFrame(): JSX.Element {
<div className="flex items-center gap-2">
<WorkingSpinner />
<span className="truncate text-[15px] font-medium leading-none text-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.cebc7769cd", "redesign auth flow")}</span>
{translate(
'auto.components.feature.wall.FeatureTourPreview.cebc7769cd',
'redesign auth flow'
)}
</span>
</div>
<div className="mt-2 flex items-center gap-1.5 pl-3.5">
<WorkingSpinner size="xs" />
<ClaudeIcon size={13} />
<span className="truncate text-[12.5px] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.5171768676", "orchestrating 3 agents")}</span>
{translate(
'auto.components.feature.wall.FeatureTourPreview.5171768676',
'orchestrating 3 agents'
)}
</span>
</div>
</div>
{/* Why: children mirror the parent's WorkspaceCard composition so the
fan reads as "coordinator workspace dispatches to 3 child
workspaces, each running its own agent." */}
{ORCH_CHILDREN.map(({ key, position, label, agent }) => (
{FEATURE_TOUR_ORCHESTRATION_CHILDREN.map(({ key, position, label, agent }) => (
<div
key={key}
className={cn(
@@ -188,9 +170,9 @@ function OrchestrationFrame(): JSX.Element {
</div>
<div className="mt-2 flex items-center gap-1.5 pl-3.5">
<WorkingSpinner size="xs" />
{agent === "claude" ? (
{agent === 'claude' ? (
<ClaudeIcon size={12} />
) : agent === "codex" ? (
) : agent === 'codex' ? (
<CodexInlineIcon />
) : (
<OpenCodeGoIcon size={12} />
@@ -200,7 +182,7 @@ function OrchestrationFrame(): JSX.Element {
</div>
))}
{ORCH_CHILDREN.map(({ key }) => (
{FEATURE_TOUR_ORCHESTRATION_CHILDREN.map(({ key }) => (
<div key={`bubble-${key}`} className={cn('feature-tour-orch-bubble', key)}>
<MailGlyph />
</div>
@@ -218,12 +200,17 @@ function TasksFrame(): JSX.Element {
return (
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.bee6b4088d", "GitHub & Linear tasks")}</div>
{translate(
'auto.components.feature.wall.FeatureTourPreview.bee6b4088d',
'GitHub & Linear tasks'
)}
</div>
<div className="relative grid flex-1 grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center gap-4 px-4">
<div className="flex flex-col gap-2">
<div className="flex h-9 items-center gap-2.5 rounded-md border border-border bg-background px-3">
<span className="inline-flex h-5 items-center justify-center rounded-[3px] border border-border bg-muted px-1.5 font-mono text-[13px] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.0688842445", "GH #1799")}</span>
{translate('auto.components.feature.wall.FeatureTourPreview.0688842445', 'GH #1799')}
</span>
{/* Why: surrounding rows show only the issue number + a skeleton
so the user's eye is drawn to the row that has real text — the
one the cursor clicks on. */}
@@ -231,12 +218,18 @@ function TasksFrame(): JSX.Element {
</div>
<div className="feature-tour-tasks-row relative flex h-9 items-center gap-2.5 rounded-md border border-border bg-background px-3">
<span className="inline-flex h-5 items-center justify-center rounded-[3px] border border-border bg-muted px-1.5 font-mono text-[13px] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.fc0cc0b267", "GH #1842")}</span>
{translate('auto.components.feature.wall.FeatureTourPreview.fc0cc0b267', 'GH #1842')}
</span>
<span className="truncate text-[15px] font-medium leading-none text-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.c1f28c03b2", "Worktree picker truncates")}</span>
{translate(
'auto.components.feature.wall.FeatureTourPreview.c1f28c03b2',
'Worktree picker truncates'
)}
</span>
<span className="feature-tour-tasks-pill relative ml-auto flex h-6 items-center justify-center overflow-hidden rounded-full border border-emerald-500/30 bg-emerald-500/15">
<span className="feature-tour-tasks-pill-label flex items-center gap-1 whitespace-nowrap pl-3 pr-2.5 text-[13px] font-semibold leading-none tracking-[0.01em] text-primary-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.40bbd92ef4", "Start")}<svg
{translate('auto.components.feature.wall.FeatureTourPreview.40bbd92ef4', 'Start')}
<svg
width="11"
height="11"
viewBox="0 0 16 16"
@@ -262,7 +255,8 @@ function TasksFrame(): JSX.Element {
</div>
<div className="flex h-9 items-center gap-2.5 rounded-md border border-border bg-background px-3">
<span className="inline-flex h-5 items-center justify-center rounded-[3px] border border-border bg-muted px-1.5 font-mono text-[13px] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.d54aefe09e", "LIN-329")}</span>
{translate('auto.components.feature.wall.FeatureTourPreview.d54aefe09e', 'LIN-329')}
</span>
<span className="h-2 w-[45%] rounded-full bg-foreground/12" />
</div>
</div>
@@ -271,7 +265,11 @@ function TasksFrame(): JSX.Element {
<div className="flex items-center gap-2.5">
<WorkingSpinner />
<span className="truncate text-[15.5px] font-medium leading-none text-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.3822d8d14b", "fix/worktree-picker-truncates")}</span>
{translate(
'auto.components.feature.wall.FeatureTourPreview.3822d8d14b',
'fix/worktree-picker-truncates'
)}
</span>
<span className="ml-auto inline-flex">
<ClaudeIcon size={13} />
</span>
@@ -281,7 +279,12 @@ function TasksFrame(): JSX.Element {
<ClaudeIcon size={12} />
<span className="h-2 w-[55%] rounded-full bg-foreground/15" />
</div>
<div className="text-[13.5px] leading-none text-muted-foreground">{translate("auto.components.feature.wall.FeatureTourPreview.2a7cfc82c8", "Linked to GH #1842")}</div>
<div className="text-[13.5px] leading-none text-muted-foreground">
{translate(
'auto.components.feature.wall.FeatureTourPreview.2a7cfc82c8',
'Linked to GH #1842'
)}
</div>
</div>
</div>
</div>
@@ -292,48 +295,73 @@ function TerminalFrame(): JSX.Element {
return (
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a", "Splittable terminal")}</div>
{translate(
'auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a',
'Splittable terminal'
)}
</div>
<div className="mx-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-background">
<div className="flex items-center gap-1.5 border-b border-border bg-muted/40 px-2 py-1">
<span className="size-1.5 rounded-full bg-foreground/15" />
<span className="size-1.5 rounded-full bg-foreground/15" />
<span className="size-1.5 rounded-full bg-foreground/15" />
<span className="ml-2 font-mono text-[13.5px] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.04d54d50ec", "orca · zsh")}</span>
{translate('auto.components.feature.wall.FeatureTourPreview.04d54d50ec', 'orca · zsh')}
</span>
</div>
<div className="grid flex-1 grid-cols-2 divide-x divide-border font-mono text-[14.5px] leading-[1.4] text-foreground">
<div className="min-w-0 p-2">
<div className="flex items-center gap-1">
<span className="text-emerald-500">$</span>
<span className="feature-tour-terminal-line relative inline-block whitespace-nowrap text-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.6218a9014d", "pnpm playwright test")}</span>
{translate(
'auto.components.feature.wall.FeatureTourPreview.6218a9014d',
'pnpm playwright test'
)}
</span>
</div>
<div className="mt-1.5 flex flex-col gap-1">
<div
className="feature-tour-terminal-output truncate text-muted-foreground"
data-line="1"
>
{translate("auto.components.feature.wall.FeatureTourPreview.8279e9d95b", "Running 12 tests")}</div>
{translate(
'auto.components.feature.wall.FeatureTourPreview.8279e9d95b',
'Running 12 tests'
)}
</div>
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1.5"
data-line="2"
>
<span className="font-bold text-emerald-600">✓</span>
<span className="truncate">{translate("auto.components.feature.wall.FeatureTourPreview.24fedd5a52", "login.spec.ts")}</span>
<span className="truncate">
{translate(
'auto.components.feature.wall.FeatureTourPreview.24fedd5a52',
'login.spec.ts'
)}
</span>
</div>
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1.5"
data-line="3"
>
<span className="inline-block size-2 animate-spin rounded-full border-[1.5px] border-foreground/20 border-t-foreground" />
<span className="truncate">{translate("auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0", "dashboard.spec.ts")}</span>
<span className="truncate">
{translate(
'auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0',
'dashboard.spec.ts'
)}
</span>
</div>
</div>
</div>
<div className="min-w-0 p-2">
<div className="flex items-center gap-1">
<span className="text-emerald-500">$</span>
<span className="text-foreground">{translate("auto.components.feature.wall.FeatureTourPreview.771d8881c2", "claude")}</span>
<span className="text-foreground">
{translate('auto.components.feature.wall.FeatureTourPreview.771d8881c2', 'claude')}
</span>
</div>
<div className="mt-1.5 flex flex-col gap-1">
<div
@@ -341,21 +369,38 @@ function TerminalFrame(): JSX.Element {
data-line="1"
>
<ClaudeIcon size={12} />
<span className="truncate text-muted-foreground">{translate("auto.components.feature.wall.FeatureTourPreview.952d3ddd9a", "session started")}</span>
<span className="truncate text-muted-foreground">
{translate(
'auto.components.feature.wall.FeatureTourPreview.952d3ddd9a',
'session started'
)}
</span>
</div>
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1"
data-line="2"
>
<span className="text-amber-600">{translate("auto.components.feature.wall.FeatureTourPreview.1170621527", ">")}</span>
<span className="truncate">{translate("auto.components.feature.wall.FeatureTourPreview.ef8b164dd1", "review src/auth")}</span>
<span className="text-amber-600">
{translate('auto.components.feature.wall.FeatureTourPreview.1170621527', '>')}
</span>
<span className="truncate">
{translate(
'auto.components.feature.wall.FeatureTourPreview.ef8b164dd1',
'review src/auth'
)}
</span>
</div>
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1.5"
data-line="3"
>
<span className="inline-block size-2 animate-spin rounded-full border-[1.5px] border-amber-600/20 border-t-amber-600" />
<span className="truncate text-muted-foreground">{translate("auto.components.feature.wall.FeatureTourPreview.304ad0dfc1", "Thinking...")}</span>
<span className="truncate text-muted-foreground">
{translate(
'auto.components.feature.wall.FeatureTourPreview.304ad0dfc1',
'Thinking...'
)}
</span>
</div>
</div>
</div>
@@ -1,9 +1,10 @@
import { useEffect, useRef } from 'react'
import { useRef } from 'react'
import type { ComponentType, JSX, ReactNode } from 'react'
import { Files, GitBranch, ListChecks, MessageSquare, Search } from 'lucide-react'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { ReviewPRViewVisualStyles } from './review-animated-visual-pr-view-styles'
import { CheckTinyIcon, ChevDownIcon, CursorIcon } from './review-animated-visual-shared'
import { useReviewPrViewAnimation } from './review-pr-view-animation'
import { translate } from '@/i18n/i18n'
type SidebarTabId = 'explorer' | 'search' | 'source-control' | 'checks'
@@ -13,10 +14,46 @@ const SIDEBAR_TABS: readonly {
icon: ComponentType<{ className?: string; size?: number }>
label: string
}[] = [
{ id: 'explorer', icon: Files, label: translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5", "Explorer") },
{ id: 'search', icon: Search, label: translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.8e715588e4", "Search") },
{ id: 'source-control', icon: GitBranch, label: translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.d7f80060ca", "Source Control") },
{ id: 'checks', icon: ListChecks, label: translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.ab2901bce6", "Checks") }
{
id: 'explorer',
icon: Files,
get label() {
return translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5',
'Explorer'
)
}
},
{
id: 'search',
icon: Search,
get label() {
return translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.8e715588e4',
'Search'
)
}
},
{
id: 'source-control',
icon: GitBranch,
get label() {
return translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d7f80060ca',
'Source Control'
)
}
},
{
id: 'checks',
icon: ListChecks,
get label() {
return translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ab2901bce6',
'Checks'
)
}
}
]
function SidebarTabs(props: { active: SidebarTabId; interactiveChecks?: boolean }): JSX.Element {
@@ -85,20 +122,6 @@ function CommentCard(props: { index: number; path: string; children: ReactNode }
)
}
function moveCursor(
root: HTMLElement,
cursor: HTMLElement,
anchor: HTMLElement,
ox = 0,
oy = 0
): void {
const rootRect = root.getBoundingClientRect()
const anchorRect = anchor.getBoundingClientRect()
cursor.style.transform = `translate(${anchorRect.left - rootRect.left + ox}px, ${
anchorRect.top - rootRect.top + oy
}px)`
}
// Why: the Review PR visual follows the approved HTML mock beat-for-beat. The
// real app keeps Explorer / Checks in one right-sidebar surface, so the
// animation selects Checks before the PR status content appears.
@@ -106,205 +129,7 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
const { reducedMotion } = props
const rootRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const root = rootRef.current
if (!root) {
return
}
const sidebarPeek = root.querySelector<HTMLDivElement>('[data-checks-sidebar-peek]')
const prCard = root.querySelector<HTMLDivElement>('[data-pr-view-card]')
const cursor = root.querySelector<HTMLDivElement>('[data-cursor]')
const explorerTab = root.querySelector<HTMLSpanElement>('[data-explorer-tab]')
const checksTab = root.querySelector<HTMLSpanElement>('[data-checks-tab]')
const checksTooltip = root.querySelector<HTMLSpanElement>('[data-checks-tooltip]')
const checksBlock = root.querySelector<HTMLDivElement>('[data-checks-block]')
const commentsBlock = root.querySelector<HTMLDivElement>('[data-comments-block]')
const comments = Array.from(root.querySelectorAll<HTMLDivElement>('[data-comment-card]'))
const commentsCount = root.querySelector<HTMLSpanElement>('[data-comments-count]')
const checkSummary = root.querySelector<HTMLDivElement>('[data-check-summary]')
const checkSummaryLabel = root.querySelector<HTMLSpanElement>('[data-check-summary-label]')
const checkSummaryMeta = root.querySelector<HTMLSpanElement>('[data-check-summary-meta]')
const verifyRow = root.querySelector<HTMLDivElement>('[data-check-row="verify"]')
const verifyState = root.querySelector<HTMLSpanElement>('[data-check-verify-state]')
const mergeBtn = root.querySelector<HTMLButtonElement>('[data-merge-btn]')
if (
!sidebarPeek ||
!prCard ||
!cursor ||
!explorerTab ||
!checksTab ||
!checksTooltip ||
!checksBlock ||
!commentsBlock ||
!commentsCount ||
!checkSummary ||
!checkSummaryLabel ||
!checkSummaryMeta ||
!verifyRow ||
!verifyState ||
!mergeBtn
) {
return
}
const rootEl: HTMLDivElement = root
const sidebarPeekEl: HTMLDivElement = sidebarPeek
const prCardEl: HTMLDivElement = prCard
const cursorEl: HTMLDivElement = cursor
const explorerTabEl: HTMLSpanElement = explorerTab
const checksTabEl: HTMLSpanElement = checksTab
const checksTooltipEl: HTMLSpanElement = checksTooltip
const checksBlockEl: HTMLDivElement = checksBlock
const commentsBlockEl: HTMLDivElement = commentsBlock
const commentsCountEl: HTMLSpanElement = commentsCount
const checkSummaryEl: HTMLDivElement = checkSummary
const checkSummaryLabelEl: HTMLSpanElement = checkSummaryLabel
const checkSummaryMetaEl: HTMLSpanElement = checkSummaryMeta
const verifyRowEl: HTMLDivElement = verifyRow
const verifyStateEl: HTMLSpanElement = verifyState
const mergeBtnEl: HTMLButtonElement = mergeBtn
let cancelled = false
const timers: number[] = []
const wait = (ms: number): Promise<void> =>
new Promise((resolve) => {
const id = window.setTimeout(() => resolve(), ms)
timers.push(id)
})
function resetState(): void {
sidebarPeekEl.classList.add('is-visible')
sidebarPeekEl.classList.remove('is-hiding')
prCardEl.classList.remove('is-visible')
explorerTabEl.classList.add('is-active')
checksTabEl.classList.remove('is-active', 'is-hovered')
checksTooltipEl.classList.remove('is-visible')
cursorEl.classList.remove('is-visible', 'is-clicking')
cursorEl.style.transition = 'none'
cursorEl.style.transform = 'translate(-30px, 220px)'
void cursorEl.offsetWidth
cursorEl.style.transition = ''
checksBlockEl.classList.remove('is-visible')
commentsBlockEl.classList.remove('is-visible')
comments.forEach((el) => el.classList.remove('is-visible'))
commentsCountEl.textContent = '0'
checkSummaryEl.classList.remove('is-done')
checkSummaryLabelEl.textContent = '1 pending'
checkSummaryMetaEl.textContent = 'verify'
verifyRowEl.classList.remove('is-done')
verifyStateEl.textContent = 'Running'
mergeBtnEl.classList.remove('is-ready')
}
function showFinalState(): void {
resetState()
sidebarPeekEl.classList.add('is-hiding')
prCardEl.classList.add('is-visible')
checksBlockEl.classList.add('is-visible')
commentsBlockEl.classList.add('is-visible')
comments.forEach((el) => el.classList.add('is-visible'))
commentsCountEl.textContent = String(comments.length)
checkSummaryEl.classList.add('is-done')
checkSummaryLabelEl.textContent = 'Checks passed'
checkSummaryMetaEl.textContent = '3 checks'
verifyRowEl.classList.add('is-done')
verifyStateEl.textContent = 'Passed'
mergeBtnEl.classList.add('is-ready')
cursorEl.classList.remove('is-visible')
}
if (reducedMotion) {
showFinalState()
return
}
async function loop(): Promise<void> {
while (!cancelled) {
resetState()
await wait(420)
if (cancelled) {
return
}
cursorEl.classList.add('is-visible')
moveCursor(rootEl, cursorEl, checksTabEl, 5, 6)
checksTabEl.classList.add('is-hovered')
await wait(260)
if (cancelled) {
return
}
checksTooltipEl.classList.add('is-visible')
await wait(1300)
if (cancelled) {
return
}
cursorEl.classList.add('is-clicking')
await wait(220)
if (cancelled) {
return
}
cursorEl.classList.remove('is-clicking')
checksTooltipEl.classList.remove('is-visible')
checksTabEl.classList.remove('is-hovered')
explorerTabEl.classList.remove('is-active')
checksTabEl.classList.add('is-active')
await wait(420)
if (cancelled) {
return
}
sidebarPeekEl.classList.add('is-hiding')
prCardEl.classList.add('is-visible')
cursorEl.classList.remove('is-visible')
await wait(560)
if (cancelled) {
return
}
checksBlockEl.classList.add('is-visible')
await wait(1050)
if (cancelled) {
return
}
verifyRowEl.classList.add('is-done')
verifyStateEl.textContent = 'Passed'
checkSummaryEl.classList.add('is-done')
checkSummaryLabelEl.textContent = 'Checks passed'
checkSummaryMetaEl.textContent = '3 checks'
mergeBtnEl.classList.add('is-ready')
await wait(560)
if (cancelled) {
return
}
commentsBlockEl.classList.add('is-visible')
await wait(260)
if (cancelled) {
return
}
for (let i = 0; i < comments.length; i++) {
comments[i]?.classList.add('is-visible')
commentsCountEl.textContent = String(i + 1)
await wait(520)
if (cancelled) {
return
}
}
await wait(2900)
}
}
void loop()
return () => {
cancelled = true
timers.forEach((timer) => window.clearTimeout(timer))
}
}, [reducedMotion])
useReviewPrViewAnimation(rootRef, reducedMotion)
return (
<div ref={rootRef} className="ravpr-stage" data-page="pr-view">
@@ -312,7 +137,12 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
<div className="ravpr-sidebar is-visible" data-checks-sidebar-peek>
<SidebarTabs active="explorer" interactiveChecks />
<div className="ravpr-explorer">
<div className="ravpr-heading">{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5", "Explorer")}</div>
<div className="ravpr-heading">
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5',
'Explorer'
)}
</div>
<div className="ravpr-file-list">
<ExplorerSkeletonRow active width={190} />
<ExplorerSkeletonRow width={158} />
@@ -327,38 +157,89 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
<div className="ravpr-body">
<div className="ravpr-number-row">
<span className="ravpr-number">#2351</span>
<span className="ravpr-open">{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.dfe313e0c9", "OPEN")}</span>
<span className="ravpr-open">
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.dfe313e0c9',
'OPEN'
)}
</span>
</div>
<div className="ravpr-title">
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.0aab7ab84a',
'Add local diagnostics error tracking'
)}
</div>
<div className="ravpr-title">{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.0aab7ab84a", "Add local diagnostics error tracking")}</div>
<button className="ravpr-merge" data-merge-btn type="button">
<GitBranch className="size-3" />
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.2f37142229", "Squash and merge")}<ChevDownIcon />
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.2f37142229',
'Squash and merge'
)}
<ChevDownIcon />
</button>
<div className="ravpr-reveal" data-checks-block>
<div className="ravpr-section-row" data-check-summary>
<StatusCell />
<span className="ravpr-label" data-check-summary-label>
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.9a097cae12", "1 pending")}</span>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.9a097cae12',
'1 pending'
)}
</span>
<span className="ravpr-meta" data-check-summary-meta>
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb", "verify")}</span>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb',
'verify'
)}
</span>
</div>
<div className="ravpr-check-list">
<div className="ravpr-check-row" data-check-row="verify">
<StatusCell />
<span>{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb", "verify")}</span>
<span>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb',
'verify'
)}
</span>
<span className="ravpr-check-state" data-check-verify-state>
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.8ed213397c", "Running")}</span>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.8ed213397c',
'Running'
)}
</span>
</div>
<div className="ravpr-check-row is-done">
<StatusCell />
<span>{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.2ef0b97954", "typecheck")}</span>
<span className="ravpr-check-state">{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c", "Passed")}</span>
<span>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.2ef0b97954',
'typecheck'
)}
</span>
<span className="ravpr-check-state">
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c',
'Passed'
)}
</span>
</div>
<div className="ravpr-check-row is-done">
<StatusCell />
<span>{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.25f6838e43", "lint")}</span>
<span className="ravpr-check-state">{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c", "Passed")}</span>
<span>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.25f6838e43',
'lint'
)}
</span>
<span className="ravpr-check-state">
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c',
'Passed'
)}
</span>
</div>
</div>
</div>
@@ -366,15 +247,43 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
<div className="ravpr-reveal" data-comments-block>
<div className="ravpr-section-row">
<MessageSquare className="size-3.5" />
<span className="ravpr-label">{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.7a8b896e11", "Comments")}</span>
<span className="ravpr-label">
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.7a8b896e11',
'Comments'
)}
</span>
<span className="ravpr-meta">
<span data-comments-count>0</span> {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.fb1a856b6d", "open")}</span>
<span data-comments-count>0</span>{' '}
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.fb1a856b6d',
'open'
)}
</span>
</div>
<div className="ravpr-comment-list">
<CommentCard index={0} path="src/main/diagnostics.ts">
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.71828fba75", "Can we include the failing command in the diagnostic payload?")}</CommentCard>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.71828fba75',
'Can we include the failing command in the diagnostic payload?'
)}
</CommentCard>
<CommentCard index={1} path="tests/diagnostics.test.ts">
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.6f4c2d7cb7", "Add a coverage case for")}<code>{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.c2062da7ec", "stderr")}</code> {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.7c2808ecff", "truncation before merge.")}</CommentCard>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6f4c2d7cb7',
'Add a coverage case for'
)}
<code>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.c2062da7ec',
'stderr'
)}
</code>{' '}
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.7c2808ecff',
'truncation before merge.'
)}
</CommentCard>
</div>
</div>
</div>
@@ -15,10 +15,12 @@ type Issue = {
const ISSUES: readonly Issue[] = [
{
number: 1842,
title: translate(
'auto.components.feature.wall.TasksAnimatedVisual.b13375617e',
'Worktree picker truncates names'
)
get title() {
return translate(
'auto.components.feature.wall.TasksAnimatedVisual.b13375617e',
'Worktree picker truncates names'
)
}
}
]
@@ -0,0 +1,109 @@
import { translate } from '@/i18n/i18n'
type FrameId = 1 | 2 | 3 | 4
export type FeatureTourPreviewFrameCopy = {
id: FrameId
title: string
caption: string
}
export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] = [
{
id: 1,
get title() {
return translate(
'auto.components.feature.wall.FeatureTourPreview.56a0271428',
'Isolated workspaces'
)
},
get caption() {
return translate(
'auto.components.feature.wall.FeatureTourPreview.47f16ecf34',
'Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.'
)
}
},
{
id: 2,
get title() {
return translate(
'auto.components.feature.wall.FeatureTourPreview.e44269e97d',
'Agent orchestration'
)
},
get caption() {
return translate(
'auto.components.feature.wall.FeatureTourPreview.70aa182266',
'Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.'
)
}
},
{
id: 3,
get title() {
return translate(
'auto.components.feature.wall.FeatureTourPreview.ef737dcee1',
'GitHub & Linear tasks'
)
},
get caption() {
return translate(
'auto.components.feature.wall.FeatureTourPreview.f10c14dd9d',
'Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.'
)
}
},
{
id: 4,
get title() {
return translate(
'auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a',
'Splittable terminal'
)
},
get caption() {
return translate(
'auto.components.feature.wall.FeatureTourPreview.5d6ee181b6',
'Open any workspace to return to its terminal, then split panes for tests, logs, and agents.'
)
}
}
]
export type FeatureTourOrchestrationChildAgent = 'claude' | 'codex' | 'opencode-go'
export const FEATURE_TOUR_ORCHESTRATION_CHILDREN: readonly {
key: 'top' | 'mid' | 'bot'
position: string
label: string
agent: FeatureTourOrchestrationChildAgent
}[] = [
// Why: card vertical centers anchor to 18% / 50% / 82% — the same Y
// endpoints the dashed SVG paths terminate at — so the connectors land on
// each card's center regardless of card height.
{
key: 'top',
position: 'top-[18%] -translate-y-1/2',
get label() {
return translate('auto.components.feature.wall.FeatureTourPreview.b1f17bcc74', 'PR 1/3')
},
agent: 'claude'
},
{
key: 'mid',
position: 'top-1/2 -translate-y-1/2',
get label() {
return translate('auto.components.feature.wall.FeatureTourPreview.cfdfd4d6b4', 'PR 2/3')
},
agent: 'codex'
},
{
key: 'bot',
position: 'top-[82%] -translate-y-1/2',
get label() {
return translate('auto.components.feature.wall.FeatureTourPreview.ec4a73f5e6', 'PR 3/3')
},
agent: 'opencode-go'
}
]
@@ -0,0 +1,261 @@
import { useEffect } from 'react'
import type { RefObject } from 'react'
import { translate } from '@/i18n/i18n'
function getReviewPrAnimatedStatusCopy(): {
pendingLabel: string
verifyLabel: string
runningLabel: string
checksPassedLabel: string
checksCountLabel: string
passedLabel: string
} {
return {
pendingLabel: translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.9a097cae12',
'1 pending'
),
verifyLabel: translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb',
'verify'
),
runningLabel: translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.8ed213397c',
'Running'
),
checksPassedLabel: translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.a6c8b9e32f',
'Checks passed'
),
checksCountLabel: translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.f4d5e1a7b2',
'3 checks'
),
passedLabel: translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c',
'Passed'
)
}
}
function moveCursor(
root: HTMLElement,
cursor: HTMLElement,
anchor: HTMLElement,
ox = 0,
oy = 0
): void {
const rootRect = root.getBoundingClientRect()
const anchorRect = anchor.getBoundingClientRect()
cursor.style.transform = `translate(${anchorRect.left - rootRect.left + ox}px, ${
anchorRect.top - rootRect.top + oy
}px)`
}
export function useReviewPrViewAnimation(
rootRef: RefObject<HTMLDivElement | null>,
reducedMotion: boolean
): void {
useEffect(() => {
const root = rootRef.current
if (!root) {
return
}
const sidebarPeek = root.querySelector<HTMLDivElement>('[data-checks-sidebar-peek]')
const prCard = root.querySelector<HTMLDivElement>('[data-pr-view-card]')
const cursor = root.querySelector<HTMLDivElement>('[data-cursor]')
const explorerTab = root.querySelector<HTMLSpanElement>('[data-explorer-tab]')
const checksTab = root.querySelector<HTMLSpanElement>('[data-checks-tab]')
const checksTooltip = root.querySelector<HTMLSpanElement>('[data-checks-tooltip]')
const checksBlock = root.querySelector<HTMLDivElement>('[data-checks-block]')
const commentsBlock = root.querySelector<HTMLDivElement>('[data-comments-block]')
const comments = Array.from(root.querySelectorAll<HTMLDivElement>('[data-comment-card]'))
const commentsCount = root.querySelector<HTMLSpanElement>('[data-comments-count]')
const checkSummary = root.querySelector<HTMLDivElement>('[data-check-summary]')
const checkSummaryLabel = root.querySelector<HTMLSpanElement>('[data-check-summary-label]')
const checkSummaryMeta = root.querySelector<HTMLSpanElement>('[data-check-summary-meta]')
const verifyRow = root.querySelector<HTMLDivElement>('[data-check-row="verify"]')
const verifyState = root.querySelector<HTMLSpanElement>('[data-check-verify-state]')
const mergeBtn = root.querySelector<HTMLButtonElement>('[data-merge-btn]')
if (
!sidebarPeek ||
!prCard ||
!cursor ||
!explorerTab ||
!checksTab ||
!checksTooltip ||
!checksBlock ||
!commentsBlock ||
!commentsCount ||
!checkSummary ||
!checkSummaryLabel ||
!checkSummaryMeta ||
!verifyRow ||
!verifyState ||
!mergeBtn
) {
return
}
const rootEl: HTMLDivElement = root
const sidebarPeekEl: HTMLDivElement = sidebarPeek
const prCardEl: HTMLDivElement = prCard
const cursorEl: HTMLDivElement = cursor
const explorerTabEl: HTMLSpanElement = explorerTab
const checksTabEl: HTMLSpanElement = checksTab
const checksTooltipEl: HTMLSpanElement = checksTooltip
const checksBlockEl: HTMLDivElement = checksBlock
const commentsBlockEl: HTMLDivElement = commentsBlock
const commentsCountEl: HTMLSpanElement = commentsCount
const checkSummaryEl: HTMLDivElement = checkSummary
const checkSummaryLabelEl: HTMLSpanElement = checkSummaryLabel
const checkSummaryMetaEl: HTMLSpanElement = checkSummaryMeta
const verifyRowEl: HTMLDivElement = verifyRow
const verifyStateEl: HTMLSpanElement = verifyState
const mergeBtnEl: HTMLButtonElement = mergeBtn
let cancelled = false
const timers: number[] = []
const wait = (ms: number): Promise<void> =>
new Promise((resolve) => {
const id = window.setTimeout(() => resolve(), ms)
timers.push(id)
})
function resetState(): void {
sidebarPeekEl.classList.add('is-visible')
sidebarPeekEl.classList.remove('is-hiding')
prCardEl.classList.remove('is-visible')
explorerTabEl.classList.add('is-active')
checksTabEl.classList.remove('is-active', 'is-hovered')
checksTooltipEl.classList.remove('is-visible')
cursorEl.classList.remove('is-visible', 'is-clicking')
cursorEl.style.transition = 'none'
cursorEl.style.transform = 'translate(-30px, 220px)'
void cursorEl.offsetWidth
cursorEl.style.transition = ''
checksBlockEl.classList.remove('is-visible')
commentsBlockEl.classList.remove('is-visible')
comments.forEach((el) => el.classList.remove('is-visible'))
commentsCountEl.textContent = '0'
checkSummaryEl.classList.remove('is-done')
const copy = getReviewPrAnimatedStatusCopy()
checkSummaryLabelEl.textContent = copy.pendingLabel
checkSummaryMetaEl.textContent = copy.verifyLabel
verifyRowEl.classList.remove('is-done')
verifyStateEl.textContent = copy.runningLabel
mergeBtnEl.classList.remove('is-ready')
}
function showFinalState(): void {
resetState()
sidebarPeekEl.classList.add('is-hiding')
prCardEl.classList.add('is-visible')
checksBlockEl.classList.add('is-visible')
commentsBlockEl.classList.add('is-visible')
comments.forEach((el) => el.classList.add('is-visible'))
commentsCountEl.textContent = String(comments.length)
checkSummaryEl.classList.add('is-done')
const copy = getReviewPrAnimatedStatusCopy()
checkSummaryLabelEl.textContent = copy.checksPassedLabel
checkSummaryMetaEl.textContent = copy.checksCountLabel
verifyRowEl.classList.add('is-done')
verifyStateEl.textContent = copy.passedLabel
mergeBtnEl.classList.add('is-ready')
cursorEl.classList.remove('is-visible')
}
if (reducedMotion) {
showFinalState()
return
}
async function loop(): Promise<void> {
while (!cancelled) {
resetState()
await wait(420)
if (cancelled) {
return
}
cursorEl.classList.add('is-visible')
moveCursor(rootEl, cursorEl, checksTabEl, 5, 6)
checksTabEl.classList.add('is-hovered')
await wait(260)
if (cancelled) {
return
}
checksTooltipEl.classList.add('is-visible')
await wait(1300)
if (cancelled) {
return
}
cursorEl.classList.add('is-clicking')
await wait(220)
if (cancelled) {
return
}
cursorEl.classList.remove('is-clicking')
checksTooltipEl.classList.remove('is-visible')
checksTabEl.classList.remove('is-hovered')
explorerTabEl.classList.remove('is-active')
checksTabEl.classList.add('is-active')
await wait(420)
if (cancelled) {
return
}
sidebarPeekEl.classList.add('is-hiding')
prCardEl.classList.add('is-visible')
cursorEl.classList.remove('is-visible')
await wait(560)
if (cancelled) {
return
}
checksBlockEl.classList.add('is-visible')
await wait(1050)
if (cancelled) {
return
}
verifyRowEl.classList.add('is-done')
const copy = getReviewPrAnimatedStatusCopy()
verifyStateEl.textContent = copy.passedLabel
checkSummaryEl.classList.add('is-done')
checkSummaryLabelEl.textContent = copy.checksPassedLabel
checkSummaryMetaEl.textContent = copy.checksCountLabel
mergeBtnEl.classList.add('is-ready')
await wait(560)
if (cancelled) {
return
}
commentsBlockEl.classList.add('is-visible')
await wait(260)
if (cancelled) {
return
}
for (let i = 0; i < comments.length; i++) {
comments[i]?.classList.add('is-visible')
commentsCountEl.textContent = String(i + 1)
await wait(520)
if (cancelled) {
return
}
}
await wait(2900)
}
}
void loop()
return () => {
cancelled = true
timers.forEach((timer) => window.clearTimeout(timer))
}
}, [reducedMotion, rootRef])
}
@@ -0,0 +1,61 @@
import { Check, ChevronDown } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { CLOSE_ISSUE_REASONS } from './github-issue-close-reasons'
import type { GitHubIssueCloseReason } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export function CloseReasonDropdown({
closeReason,
disabled,
onCloseReasonChange
}: {
closeReason: GitHubIssueCloseReason
disabled: boolean
onCloseReasonChange: (reason: GitHubIssueCloseReason) => void
}): React.JSX.Element {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="secondary"
className="px-2"
disabled={disabled}
aria-label={translate(
'auto.components.github.CloseReasonDropdown.e1f2a3b4c5',
'Choose close reason'
)}
>
<ChevronDown className="size-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
{CLOSE_ISSUE_REASONS.map((option) => (
<DropdownMenuItem key={option.reason} onSelect={() => onCloseReasonChange(option.reason)}>
<div className="flex min-w-0 items-start gap-2">
{closeReason === option.reason ? (
<Check className="mt-0.5 size-4 shrink-0" />
) : (
<span className="size-4 shrink-0" />
)}
<div className="min-w-0">
<div className="flex items-center gap-2 text-[13px] font-medium">
{option.icon}
<span>{option.label}</span>
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">{option.description}</p>
</div>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,369 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { LoaderCircle } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer'
import { CLOSE_ISSUE_REASONS } from '@/components/github/github-issue-close-reasons'
import { CloseReasonDropdown } from '@/components/github/CloseReasonDropdown'
import {
addIssueCommentForRepo,
githubAvatarUrl,
runIssueStateUpdate,
type GitHubIssueCommentProjectOrigin
} from '@/components/github/github-issue-comment-helpers'
import { useMountedRef } from '@/hooks/useMountedRef'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import type {
GitHubIssueCloseReason,
GitHubOwnerRepo,
GitHubViewer,
GitHubWorkItem,
PRComment
} from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export function GitHubIssueCommentComposer({
className,
repoPath,
repoId,
issueNumber,
itemType,
itemState,
itemId,
projectOrigin,
previewGithubRepo,
onCommentAdded,
onStateChange,
onMutated
}: {
className?: string
repoPath: string
repoId?: string | null
issueNumber: number
itemType: 'issue' | 'pr'
itemState?: GitHubWorkItem['state']
itemId?: string
projectOrigin?: GitHubIssueCommentProjectOrigin
previewGithubRepo?: GitHubOwnerRepo | null
onCommentAdded: (comment: PRComment) => void
onStateChange?: (state: GitHubWorkItem['state']) => void
onMutated?: () => void
}): React.JSX.Element {
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const [statePending, setStatePending] = useState(false)
const [closeReason, setCloseReason] = useState<GitHubIssueCloseReason>('completed')
const [viewer, setViewer] = useState<GitHubViewer | null>(null)
const mountedRef = useMountedRef()
const viewerRequestIdRef = useRef(0)
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
useEffect(() => {
const requestId = ++viewerRequestIdRef.current
void window.api.gh
.viewer()
.then((nextViewer) => {
if (mountedRef.current && requestId === viewerRequestIdRef.current) {
setViewer(nextViewer)
}
})
.catch(() => {
if (mountedRef.current && requestId === viewerRequestIdRef.current) {
setViewer(null)
}
})
return () => {
viewerRequestIdRef.current += 1
}
}, [mountedRef])
const selectedCloseReason =
CLOSE_ISSUE_REASONS.find((option) => option.reason === closeReason) ?? CLOSE_ISSUE_REASONS[0]
const canMutateIssueState =
itemType === 'issue' &&
itemState !== undefined &&
itemState !== 'closed' &&
Boolean(onStateChange) &&
Boolean(repoPath || projectOrigin)
const canReopenIssue =
itemType === 'issue' &&
itemState === 'closed' &&
Boolean(onStateChange) &&
Boolean(repoPath || projectOrigin)
const patchProjectRowIfNeeded = useCallback(
(state: GitHubWorkItem['state']) => {
if (!projectOrigin) {
return
}
patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state })
},
[patchProjectRowContent, projectOrigin]
)
const applyStatePatch = useCallback(
(state: GitHubWorkItem['state']) => {
onStateChange?.(state)
if (itemId) {
patchWorkItem(itemId, { state }, repoId ?? undefined)
}
patchProjectRowIfNeeded(state)
},
[itemId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, repoId]
)
const handleSubmit = useCallback(async () => {
const trimmed = body.trim()
if (!trimmed) {
return
}
setSubmitting(true)
try {
const result = await addIssueCommentForRepo({
repoPath,
repoId: repoId ?? undefined,
number: issueNumber,
body: trimmed,
type: itemType
})
if (!mountedRef.current) {
return
}
if (result.ok) {
setBody('')
onCommentAdded(result.comment)
} else {
toast.error(
result.error ??
translate(
'auto.components.github.GitHubIssueCommentComposer.082515176a',
'Failed to add comment'
)
)
}
} catch (err) {
if (mountedRef.current) {
toast.error(
err instanceof Error
? err.message
: translate(
'auto.components.github.GitHubIssueCommentComposer.082515176a',
'Failed to add comment'
)
)
}
} finally {
if (mountedRef.current) {
setSubmitting(false)
}
}
}, [body, issueNumber, itemType, mountedRef, onCommentAdded, repoId, repoPath])
const handleCloseIssue = useCallback(
async (reason: GitHubIssueCloseReason = closeReason) => {
if (!canMutateIssueState || statePending) {
return
}
const previousState = itemState ?? 'open'
setStatePending(true)
applyStatePatch('closed')
try {
await runIssueStateUpdate({
repoPath,
repoId,
projectOrigin,
number: issueNumber,
updates: { state: 'closed', stateReason: reason }
})
useAppStore.getState().recordFeatureInteraction('github-tasks')
toast.success(
translate('auto.components.github.GitHubIssueCommentComposer.9f88657c4e', 'Issue closed')
)
onMutated?.()
} catch (err) {
applyStatePatch(previousState)
toast.error(
err instanceof Error
? err.message
: translate(
'auto.components.github.GitHubIssueCommentComposer.e9b7cb7d17',
'Failed to close issue'
)
)
} finally {
if (mountedRef.current) {
setStatePending(false)
}
}
},
[
applyStatePatch,
canMutateIssueState,
closeReason,
issueNumber,
itemState,
mountedRef,
onMutated,
projectOrigin,
repoId,
repoPath,
statePending
]
)
const handleReopenIssue = useCallback(async () => {
if (!canReopenIssue || statePending) {
return
}
const previousState = itemState ?? 'closed'
setStatePending(true)
applyStatePatch('open')
try {
await runIssueStateUpdate({
repoPath,
repoId,
projectOrigin,
number: issueNumber,
updates: { state: 'open' }
})
useAppStore.getState().recordFeatureInteraction('github-tasks')
toast.success(
translate('auto.components.github.GitHubIssueCommentComposer.bd3b4492a0', 'Issue reopened')
)
onMutated?.()
} catch (err) {
applyStatePatch(previousState)
toast.error(
err instanceof Error
? err.message
: translate(
'auto.components.github.GitHubIssueCommentComposer.f2a8c1d903',
'Failed to reopen issue'
)
)
} finally {
if (mountedRef.current) {
setStatePending(false)
}
}
}, [
applyStatePatch,
canReopenIssue,
issueNumber,
itemState,
mountedRef,
onMutated,
projectOrigin,
repoId,
repoPath,
statePending
])
const avatar = viewer?.login ? (
<img
src={githubAvatarUrl(viewer.login)}
alt={viewer.login}
className="size-8 shrink-0 rounded-full border border-border/50 bg-muted"
/>
) : (
<div className="size-8 shrink-0 rounded-full border border-border/50 bg-muted" />
)
return (
<div className={cn('github-issue-comment-composer', className)}>
<div className="flex items-start gap-3">
{avatar}
<div className="min-w-0 flex-1">
<h3 className="mb-2 text-[13px] font-semibold text-foreground">
{translate(
'auto.components.github.GitHubIssueCommentComposer.a1b2c3d4e5',
'Add a comment'
)}
</h3>
<GitHubMarkdownComposer
value={body}
onChange={setBody}
placeholder={translate(
'auto.components.github.GitHubIssueCommentComposer.c5c117270e',
'Add your comment here, be kind'
)}
disabled={submitting || statePending}
minHeightClassName="min-h-28"
className="w-full"
layout="tabbed"
previewGithubRepo={previewGithubRepo}
onSubmitShortcut={() => void handleSubmit()}
/>
<div className="mt-2 flex flex-wrap items-center justify-end gap-2">
{canMutateIssueState ? (
<ButtonGroup>
<Button
type="button"
size="sm"
variant="secondary"
className="gap-2"
disabled={statePending}
onClick={() => void handleCloseIssue(closeReason)}
>
{statePending ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
selectedCloseReason.icon
)}
{translate(
'auto.components.github.GitHubIssueCommentComposer.f6a7b8c9d0',
'Close issue'
)}
</Button>
<CloseReasonDropdown
closeReason={closeReason}
disabled={statePending}
onCloseReasonChange={(reason) => {
setCloseReason(reason)
void handleCloseIssue(reason)
}}
/>
</ButtonGroup>
) : null}
{canReopenIssue ? (
<Button
type="button"
size="sm"
variant="secondary"
disabled={statePending}
onClick={() => void handleReopenIssue()}
>
{statePending ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
translate(
'auto.components.github.GitHubIssueCommentComposer.b1c2d3e4f5',
'Reopen issue'
)
)}
</Button>
) : null}
<Button
onClick={() => void handleSubmit()}
disabled={!body.trim() || submitting || statePending}
size="sm"
className="gap-2 bg-emerald-600 text-white hover:bg-emerald-700 disabled:bg-emerald-600/50"
aria-label={translate(
'auto.components.github.GitHubIssueCommentComposer.0a73f59e85',
'Send comment'
)}
>
{submitting ? <LoaderCircle className="size-3.5 animate-spin" /> : null}
{translate('auto.components.github.GitHubIssueCommentComposer.bf43425540', 'Comment')}
</Button>
</div>
</div>
</div>
</div>
)
}
@@ -2,8 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
import Placeholder from '@tiptap/extension-placeholder'
import { ImageIcon } from 'lucide-react'
import { toast } from 'sonner'
import { ImageIcon, Paperclip } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
@@ -17,6 +16,13 @@ import {
} from '@/components/editor/RichMarkdownLinkBubble'
import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html'
import { normalizeSoftBreaks } from '@/components/editor/rich-markdown-normalize'
import { GitHubMarkdownComposerPreviewPane } from '@/components/github/github-markdown-composer-preview-pane'
import {
GitHubMarkdownComposerTabbar,
type ComposerTab
} from '@/components/github/github-markdown-composer-tabbar'
import { useImageInput } from '@/components/github/use-image-input'
import type { GitHubOwnerRepo } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
type GitHubMarkdownComposerProps = {
@@ -28,15 +34,8 @@ type GitHubMarkdownComposerProps = {
disabled?: boolean
autoFocus?: boolean
onSubmitShortcut?: () => void
}
function isHttpImageUrl(value: string): boolean {
try {
const parsed = new URL(value)
return parsed.protocol === 'https:' || parsed.protocol === 'http:'
} catch {
return false
}
layout?: 'stacked' | 'tabbed'
previewGithubRepo?: GitHubOwnerRepo | null
}
export function GitHubMarkdownComposer({
@@ -47,7 +46,9 @@ export function GitHubMarkdownComposer({
className,
disabled = false,
autoFocus = false,
onSubmitShortcut
onSubmitShortcut,
layout = 'stacked',
previewGithubRepo = null
}: GitHubMarkdownComposerProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<Editor | null>(null)
@@ -57,18 +58,25 @@ export function GitHubMarkdownComposer({
const onSubmitShortcutRef = useRef(onSubmitShortcut)
const disabledRef = useRef(disabled)
const isEditingLinkRef = useRef(false)
const imageInputOpenRef = useRef(false)
const [activeTab, setActiveTab] = useState<ComposerTab>('write')
const [linkBubble, setLinkBubble] = useState<LinkBubbleState | null>(null)
const [isEditingLink, setIsEditingLink] = useState(false)
const [imageInputOpen, setImageInputOpen] = useState(false)
const [imageUrl, setImageUrl] = useState('')
const imageInputRef = useRef<HTMLInputElement | null>(null)
const isTabbed = layout === 'tabbed'
const {
imageUrl,
imageInputOpen,
imageInputRef,
openImagePicker,
setImageUrl,
setImageInputOpen,
insertImageUrl
} = useImageInput(editorRef, disabledRef, () => setActiveTab('write'))
onChangeRef.current = onChange
onSubmitShortcutRef.current = onSubmitShortcut
disabledRef.current = disabled
isEditingLinkRef.current = isEditingLink
imageInputOpenRef.current = imageInputOpen
const extensions = useMemo(
() => [
@@ -125,7 +133,7 @@ export function GitHubMarkdownComposer({
openLinkEditor()
return true
}
if (event.key === 'Escape' && imageInputOpenRef.current) {
if (event.key === 'Escape' && imageInputOpen) {
event.preventDefault()
event.stopPropagation()
setImageInputOpen(false)
@@ -183,6 +191,23 @@ export function GitHubMarkdownComposer({
if (!editor) {
return
}
// Why: parent clears to '' after submit; always reset the editor so stale
// draft text never survives a successful comment post.
if (!value.trim()) {
if (editor.getMarkdown().trim()) {
applyingExternalValueRef.current = true
try {
editor.commands.clearContent(true)
normalizeSoftBreaks(editor)
lastSyncedMarkdownRef.current = ''
} finally {
applyingExternalValueRef.current = false
}
} else {
lastSyncedMarkdownRef.current = ''
}
return
}
if (value === lastSyncedMarkdownRef.current || value === editor.getMarkdown()) {
lastSyncedMarkdownRef.current = value
return
@@ -200,12 +225,6 @@ export function GitHubMarkdownComposer({
}
}, [editor, value])
useEffect(() => {
if (imageInputOpen) {
requestAnimationFrame(() => imageInputRef.current?.focus())
}
}, [imageInputOpen])
const handleLinkSave = useCallback((href: string) => {
const editor = editorRef.current
if (!editor) {
@@ -249,92 +268,107 @@ export function GitHubMarkdownComposer({
}
}, [linkBubble?.href])
const insertImageUrl = useCallback(() => {
const editor = editorRef.current
const trimmed = imageUrl.trim()
if (!editor || !trimmed) {
return
}
if (!isHttpImageUrl(trimmed)) {
toast.error(
translate(
'auto.components.github.GitHubMarkdownComposer.ec6310b731',
'Use an http:// or https:// image URL.'
)
)
return
}
editor
.chain()
.focus()
.insertContent({ type: 'image', attrs: { src: trimmed } })
.run()
setImageUrl('')
setImageInputOpen(false)
}, [imageUrl])
const toolbar = (
<RichMarkdownToolbar
editor={editor}
onToggleLink={openLinkEditor}
onImagePick={openImagePicker}
/>
)
const imageInputRow = imageInputOpen ? (
<form
className="github-markdown-composer-image-row"
onSubmit={(event) => {
event.preventDefault()
insertImageUrl()
}}
>
<ImageIcon className="size-3.5 shrink-0 text-muted-foreground" />
<Input
ref={imageInputRef}
value={imageUrl}
onChange={(event) => setImageUrl(event.target.value)}
onKeyDown={(event) => {
if (isScreenSubmitShortcut(event)) {
event.preventDefault()
event.stopPropagation()
insertImageUrl()
return
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
setImageInputOpen(false)
}
}}
placeholder={translate(
'auto.components.github.GitHubMarkdownComposer.f24783f470',
'https://...'
)}
disabled={disabled}
className="h-8 min-w-0 text-xs"
/>
<Button type="submit" size="xs" disabled={disabled || !imageUrl.trim()}>
{translate('auto.components.github.GitHubMarkdownComposer.e3bd59143c', 'Insert')}
</Button>
<Button type="button" variant="ghost" size="xs" onClick={() => setImageInputOpen(false)}>
{translate('auto.components.github.GitHubMarkdownComposer.015b4e607d', 'Cancel')}
</Button>
</form>
) : null
const editorPane = (
<div className="max-h-[360px] overflow-y-auto scrollbar-sleek">
<EditorContent editor={editor} />
</div>
)
const previewPane = (
<GitHubMarkdownComposerPreviewPane
value={value}
minHeightClassName={minHeightClassName}
previewGithubRepo={previewGithubRepo}
/>
)
const attachmentFooter = isTabbed ? (
<button
type="button"
className="github-markdown-composer-attachment"
disabled={disabled}
onClick={openImagePicker}
>
<Paperclip className="size-3.5 shrink-0" />
<span>
{translate(
'auto.components.github.GitHubMarkdownComposer.b7e4a1c902',
'Paste, drop, or click to add files'
)}
</span>
</button>
) : null
return (
<div
ref={rootRef}
className={cn(
'github-markdown-composer relative overflow-hidden rounded-md border border-input bg-background shadow-xs',
isTabbed && 'github-markdown-composer-tabbed',
disabled && 'opacity-60',
className
)}
>
<RichMarkdownToolbar
editor={editor}
onToggleLink={openLinkEditor}
onImagePick={() => {
if (!disabledRef.current) {
setImageInputOpen(true)
}
}}
/>
{imageInputOpen ? (
<form
className="github-markdown-composer-image-row"
onSubmit={(event) => {
event.preventDefault()
insertImageUrl()
}}
>
<ImageIcon className="size-3.5 shrink-0 text-muted-foreground" />
<Input
ref={imageInputRef}
value={imageUrl}
onChange={(event) => setImageUrl(event.target.value)}
onKeyDown={(event) => {
if (isScreenSubmitShortcut(event)) {
event.preventDefault()
event.stopPropagation()
insertImageUrl()
return
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
setImageInputOpen(false)
}
}}
placeholder={translate(
'auto.components.github.GitHubMarkdownComposer.f24783f470',
'https://...'
)}
disabled={disabled}
className="h-8 min-w-0 text-xs"
/>
<Button type="submit" size="xs" disabled={disabled || !imageUrl.trim()}>
{translate('auto.components.github.GitHubMarkdownComposer.e3bd59143c', 'Insert')}
</Button>
<Button type="button" variant="ghost" size="xs" onClick={() => setImageInputOpen(false)}>
{translate('auto.components.github.GitHubMarkdownComposer.015b4e607d', 'Cancel')}
</Button>
</form>
) : null}
<div className="max-h-[360px] overflow-y-auto scrollbar-sleek">
<EditorContent editor={editor} />
</div>
{isTabbed ? (
<GitHubMarkdownComposerTabbar activeTab={activeTab} onTabChange={setActiveTab}>
{toolbar}
</GitHubMarkdownComposerTabbar>
) : (
toolbar
)}
{imageInputRow}
{isTabbed ? (activeTab === 'write' ? editorPane : previewPane) : editorPane}
{attachmentFooter}
{linkBubble ? (
<RichMarkdownLinkBubble
linkBubble={linkBubble}
@@ -0,0 +1,116 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { GitHubAssignableUser } from '../../../../shared/types'
import { filterGitHubWorkItemAssignees } from './github-work-item-assignee-filter'
const assigneeCheckIcon = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
export function GitHubWorkItemAssigneePopoverContent({
open,
assignees,
selectedLogins,
error,
loading,
onToggleAssignee
}: {
open: boolean
assignees: readonly GitHubAssignableUser[]
selectedLogins: readonly string[]
error: string | null | undefined
loading?: boolean
onToggleAssignee: (login: string) => void
}): React.JSX.Element {
const [query, setQuery] = useState('')
const filteredAssignees = useMemo(
() => filterGitHubWorkItemAssignees(assignees, query),
[assignees, query]
)
const selectedSet = useMemo(() => new Set(selectedLogins), [selectedLogins])
useEffect(() => {
if (!open) {
setQuery('')
}
}, [open])
if (error) {
return <div className="px-2 py-3 text-center text-[12px] text-destructive">{error}</div>
}
const emptyText = loading
? translate(
'auto.components.github.GitHubWorkItemAssigneePopoverContent.cddd9b04a7',
'Loading assignees'
)
: translate(
'auto.components.github.GitHubWorkItemAssigneePopoverContent.a00830d3f7',
'No users'
)
return (
<Command shouldFilter={false} className="bg-transparent">
<CommandInput
placeholder={translate(
'auto.components.github.GitHubWorkItemAssigneePopoverContent.4f8b6f2c1d',
'Filter assignees...'
)}
value={query}
onValueChange={setQuery}
className="h-8 text-[12px]"
wrapperClassName="border-b border-border/60 px-2"
iconClassName="size-3.5"
/>
<CommandList className="max-h-60">
<CommandEmpty className="px-2 py-3 text-center text-[12px] text-muted-foreground">
{emptyText}
</CommandEmpty>
{filteredAssignees.map((user) => {
const isSelected = selectedSet.has(user.login)
return (
<CommandItem
key={user.login}
value={user.login}
onSelect={() => onToggleAssignee(user.login)}
className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[12px]"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
isSelected ? 'border-primary bg-primary text-primary-foreground' : 'border-input'
)}
>
{isSelected ? assigneeCheckIcon : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{user.login}</span>
{user.name ? (
<span className="block truncate text-[11px] text-muted-foreground">
{user.name}
</span>
) : null}
</span>
</CommandItem>
)
})}
</CommandList>
</Command>
)
}
@@ -0,0 +1,152 @@
import React, { useEffect, useMemo, useState } from 'react'
import { ExternalLink, Settings } from 'lucide-react'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { filterGitHubWorkItemLabels } from './github-work-item-label-filter'
const labelCheckIcon = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
function GitHubLabelsSettingsLink({
url,
separated,
onOpen
}: {
url: string | null
separated?: boolean
onOpen?: () => void
}): React.JSX.Element | null {
if (!url) {
return null
}
return (
<div className={cn(separated && 'mt-1 border-t border-border/60 pt-1')}>
<button
type="button"
onClick={() => {
onOpen?.()
void window.api.shell.openUrl(url)
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<Settings className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 text-left">
{translate(
'auto.components.github.GitHubWorkItemLabelPopoverContent.2aa9acdf34',
'Edit labels on GitHub'
)}
</span>
<ExternalLink className="size-3 shrink-0 opacity-70" />
</button>
</div>
)
}
export function GitHubWorkItemLabelPopoverContent({
open,
labels,
selectedLabels,
error,
loading,
repositoryLabelsUrl,
onToggleLabel,
onOpenSettingsLink
}: {
open: boolean
labels: readonly string[]
selectedLabels: readonly string[]
error: string | null | undefined
loading?: boolean
repositoryLabelsUrl?: string | null
onToggleLabel: (label: string) => void
onOpenSettingsLink?: () => void
}): React.JSX.Element {
const [query, setQuery] = useState('')
const filteredLabels = useMemo(() => filterGitHubWorkItemLabels(labels, query), [labels, query])
const selectedSet = useMemo(() => new Set(selectedLabels), [selectedLabels])
useEffect(() => {
if (!open) {
setQuery('')
}
}, [open])
if (error) {
return <div className="px-2 py-3 text-center text-[12px] text-destructive">{error}</div>
}
const emptyText = loading
? translate(
'auto.components.github.GitHubWorkItemLabelPopoverContent.cddd9b04a7',
'Loading labels'
)
: translate('auto.components.github.GitHubWorkItemLabelPopoverContent.de26e2eb06', 'No labels')
return (
<>
<Command shouldFilter={false} className="bg-transparent">
<CommandInput
placeholder={translate(
'auto.components.github.GitHubWorkItemLabelPopoverContent.8b0d52ee3a',
'Filter labels...'
)}
value={query}
onValueChange={setQuery}
className="h-8 text-[12px]"
wrapperClassName="border-b border-border/60 px-2"
iconClassName="size-3.5"
/>
<CommandList className="max-h-60">
<CommandEmpty className="px-2 py-3 text-center text-[12px] text-muted-foreground">
{emptyText}
</CommandEmpty>
{filteredLabels.map((label) => {
const isSelected = selectedSet.has(label)
return (
<CommandItem
key={label}
value={label}
onSelect={() => onToggleLabel(label)}
className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[12px]"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
isSelected
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{isSelected ? labelCheckIcon : null}
</span>
<span className="min-w-0 truncate">{label}</span>
</CommandItem>
)
})}
</CommandList>
</Command>
<GitHubLabelsSettingsLink
url={repositoryLabelsUrl ?? null}
separated={labels.length > 0}
onOpen={onOpenSettingsLink}
/>
</>
)
}
@@ -0,0 +1,49 @@
import { Ban, CheckCircle2, Copy } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import type { GitHubIssueCloseReason } from '../../../../shared/types'
export type CloseIssueReasonOption = {
reason: GitHubIssueCloseReason
label: string
description: string
icon: React.JSX.Element
}
export const CLOSE_ISSUE_REASONS: CloseIssueReasonOption[] = [
{
reason: 'completed',
label: translate(
'auto.components.github.githubIssueCloseReasons.completed.label',
'Close as completed'
),
description: translate(
'auto.components.github.githubIssueCloseReasons.completed.description',
'Done, closed, fixed, resolved'
),
icon: <CheckCircle2 className="size-4 text-violet-500" />
},
{
reason: 'not_planned',
label: translate(
'auto.components.github.githubIssueCloseReasons.notPlanned.label',
'Close as not planned'
),
description: translate(
'auto.components.github.githubIssueCloseReasons.notPlanned.description',
"Won't fix, can't repro, stale"
),
icon: <Ban className="size-4 text-muted-foreground" />
},
{
reason: 'duplicate',
label: translate(
'auto.components.github.githubIssueCloseReasons.duplicate.label',
'Close as duplicate'
),
description: translate(
'auto.components.github.githubIssueCloseReasons.duplicate.description',
'Duplicate of another issue'
),
icon: <Copy className="size-4 text-muted-foreground" />
}
]
@@ -0,0 +1,74 @@
import { useAppStore } from '@/store'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type { GitHubIssueCloseReason } from '../../../../shared/types'
export type GitHubIssueCommentProjectOrigin = {
owner: string
repo: string
cacheKey: string
projectItemId: string
}
export async function runIssueStateUpdate(args: {
repoPath: string
repoId?: string | null
projectOrigin: GitHubIssueCommentProjectOrigin | undefined
number: number
updates: {
state: 'open' | 'closed'
stateReason?: GitHubIssueCloseReason
duplicateOf?: number
}
}): Promise<void> {
if (args.projectOrigin) {
const target = getActiveRuntimeTarget(useAppStore.getState().settings)
const updateArgs = {
owner: args.projectOrigin.owner,
repo: args.projectOrigin.repo,
number: args.number,
updates: args.updates
}
const res =
target.kind === 'environment'
? await callRuntimeRpc<Awaited<ReturnType<typeof window.api.gh.updateIssueBySlug>>>(
target,
'github.project.updateIssueBySlug',
updateArgs,
{ timeoutMs: 30_000 }
)
: await window.api.gh.updateIssueBySlug(updateArgs)
if (!res.ok) {
throw new Error(res.error.message)
}
return
}
const res = await window.api.gh.updateIssue({
repoPath: args.repoPath,
repoId: args.repoId ?? undefined,
number: args.number,
updates: args.updates
})
if (!res.ok) {
throw new Error(res.error)
}
}
export async function addIssueCommentForRepo(args: {
repoId?: string
repoPath: string
number: number
body: string
type?: 'issue' | 'pr'
}): Promise<Awaited<ReturnType<typeof window.api.gh.addIssueComment>>> {
return window.api.gh.addIssueComment({
repoPath: args.repoPath,
repoId: args.repoId,
number: args.number,
body: args.body,
type: args.type
})
}
export function githubAvatarUrl(login: string): string {
return `https://github.com/${encodeURIComponent(login)}.png?size=64`
}
@@ -0,0 +1,44 @@
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import type { GitHubOwnerRepo } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export function isHttpImageUrl(value: string): boolean {
try {
const parsed = new URL(value)
return parsed.protocol === 'https:' || parsed.protocol === 'http:'
} catch {
return false
}
}
export function GitHubMarkdownComposerPreviewPane({
value,
minHeightClassName,
previewGithubRepo
}: {
value: string
minHeightClassName: string
previewGithubRepo: GitHubOwnerRepo | null
}): React.JSX.Element {
return (
<div
className={`github-markdown-composer-preview scrollbar-sleek max-h-[360px] overflow-y-auto ${minHeightClassName}`}
>
{value.trim() ? (
<CommentMarkdown
content={value}
variant="document"
githubRepo={previewGithubRepo}
className="min-w-0 max-w-full overflow-hidden break-words text-[13px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
/>
) : (
<p className="text-[13px] italic text-muted-foreground">
{translate(
'auto.components.github.GitHubMarkdownComposer.8f1c2d4e6a',
'Nothing to preview'
)}
</p>
)}
</div>
)
}
@@ -0,0 +1,43 @@
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { ReactNode } from 'react'
export type ComposerTab = 'write' | 'preview'
export function GitHubMarkdownComposerTabbar({
activeTab,
onTabChange,
children
}: {
activeTab: ComposerTab
onTabChange: (tab: ComposerTab) => void
children: ReactNode
}): React.JSX.Element {
return (
<div className="github-markdown-composer-tabbar">
<div className="github-markdown-composer-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={activeTab === 'write'}
className={cn('github-markdown-composer-tab', activeTab === 'write' && 'is-active')}
onClick={() => onTabChange('write')}
>
{translate('auto.components.github.GitHubMarkdownComposer.c91f0a2b14', 'Write')}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'preview'}
className={cn('github-markdown-composer-tab', activeTab === 'preview' && 'is-active')}
onClick={() => onTabChange('preview')}
>
{translate('auto.components.github.GitHubMarkdownComposer.d82b1e3f05', 'Preview')}
</button>
</div>
{activeTab === 'write' ? (
<div className="github-markdown-composer-tabbar-toolbar">{children}</div>
) : null}
</div>
)
}
@@ -20,27 +20,30 @@ type BucketMeta = {
const BUCKETS: BucketMeta[] = [
{
key: 'core',
label: translate('auto.components.github.github.rate.limit.display.bb227706a6', 'REST'),
description: translate(
'auto.components.github.github.rate.limit.display.c392c749a6',
'REST API'
)
get label() {
return translate('auto.components.github.github.rate.limit.display.bb227706a6', 'REST')
},
get description() {
return translate('auto.components.github.github.rate.limit.display.c392c749a6', 'REST API')
}
},
{
key: 'search',
label: translate('auto.components.github.github.rate.limit.display.c377a4f06a', 'Search'),
description: translate(
'auto.components.github.github.rate.limit.display.1f2f28a4de',
'Search API'
)
get label() {
return translate('auto.components.github.github.rate.limit.display.c377a4f06a', 'Search')
},
get description() {
return translate('auto.components.github.github.rate.limit.display.1f2f28a4de', 'Search API')
}
},
{
key: 'graphql',
label: translate('auto.components.github.github.rate.limit.display.1daf0f22a9', 'GraphQL'),
description: translate(
'auto.components.github.github.rate.limit.display.01f7323e58',
'GraphQL API'
)
get label() {
return translate('auto.components.github.github.rate.limit.display.1daf0f22a9', 'GraphQL')
},
get description() {
return translate('auto.components.github.github.rate.limit.display.01f7323e58', 'GraphQL API')
}
}
]
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { filterGitHubWorkItemAssignees } from './github-work-item-assignee-filter'
describe('filterGitHubWorkItemAssignees', () => {
const assignees = [
{ login: 'alice', name: 'Alice Smith', avatarUrl: 'https://example.com/alice.png' },
{ login: 'bob', name: 'Bob Jones', avatarUrl: 'https://example.com/bob.png' },
{ login: 'carol', name: null, avatarUrl: 'https://example.com/carol.png' }
]
it('returns all assignees when the query is empty', () => {
expect(filterGitHubWorkItemAssignees(assignees, '')).toEqual(assignees)
expect(filterGitHubWorkItemAssignees(assignees, ' ')).toEqual(assignees)
})
it('matches logins case-insensitively', () => {
expect(filterGitHubWorkItemAssignees(assignees, 'BOB')).toEqual([assignees[1]])
})
it('matches display names case-insensitively', () => {
expect(filterGitHubWorkItemAssignees(assignees, 'smith')).toEqual([assignees[0]])
expect(filterGitHubWorkItemAssignees(assignees, 'jones')).toEqual([assignees[1]])
})
})
@@ -0,0 +1,16 @@
import type { GitHubAssignableUser } from '../../../../shared/types'
export function filterGitHubWorkItemAssignees(
assignees: readonly GitHubAssignableUser[],
query: string
): GitHubAssignableUser[] {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery) {
return [...assignees]
}
return assignees.filter(
(user) =>
user.login.toLowerCase().includes(normalizedQuery) ||
(user.name ?? '').toLowerCase().includes(normalizedQuery)
)
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { filterGitHubWorkItemLabels } from './github-work-item-label-filter'
describe('filterGitHubWorkItemLabels', () => {
const labels = ['agent-workflow', 'bug', 'documentation', 'duplicate']
it('returns all labels when the query is empty', () => {
expect(filterGitHubWorkItemLabels(labels, '')).toEqual(labels)
expect(filterGitHubWorkItemLabels(labels, ' ')).toEqual(labels)
})
it('matches labels case-insensitively', () => {
expect(filterGitHubWorkItemLabels(labels, 'BUG')).toEqual(['bug'])
expect(filterGitHubWorkItemLabels(labels, 'Doc')).toEqual(['documentation'])
})
it('matches partial label names', () => {
expect(filterGitHubWorkItemLabels(labels, 'agent')).toEqual(['agent-workflow'])
expect(filterGitHubWorkItemLabels(labels, 'dup')).toEqual(['duplicate'])
})
})
@@ -0,0 +1,7 @@
export function filterGitHubWorkItemLabels(labels: readonly string[], query: string): string[] {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery) {
return [...labels]
}
return labels.filter((label) => label.toLowerCase().includes(normalizedQuery))
}
@@ -0,0 +1,70 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { Editor } from '@tiptap/react'
import { isHttpImageUrl } from './github-markdown-composer-preview-pane'
import { translate } from '@/i18n/i18n'
export function useImageInput(
editorRef: React.MutableRefObject<Editor | null>,
disabledRef: React.MutableRefObject<boolean>,
onOpen?: () => void
): {
imageUrl: string
imageInputOpen: boolean
imageInputRef: React.RefObject<HTMLInputElement | null>
openImagePicker: () => void
setImageUrl: (value: string) => void
setImageInputOpen: (open: boolean) => void
insertImageUrl: () => void
} {
const [imageInputOpen, setImageInputOpen] = useState(false)
const [imageUrl, setImageUrl] = useState('')
const imageInputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (imageInputOpen) {
requestAnimationFrame(() => imageInputRef.current?.focus())
}
}, [imageInputOpen])
const insertImageUrl = useCallback(() => {
const editor = editorRef.current
const trimmed = imageUrl.trim()
if (!editor || !trimmed) {
return
}
if (!isHttpImageUrl(trimmed)) {
toast.error(
translate(
'auto.components.github.GitHubMarkdownComposer.ec6310b731',
'Use an http:// or https:// image URL.'
)
)
return
}
editor
.chain()
.focus()
.insertContent({ type: 'image', attrs: { src: trimmed } })
.run()
setImageUrl('')
setImageInputOpen(false)
}, [imageUrl, editorRef])
const openImagePicker = useCallback(() => {
if (!disabledRef.current) {
setImageInputOpen(true)
onOpen?.()
}
}, [disabledRef, onOpen])
return {
imageUrl,
imageInputOpen,
imageInputRef,
openImagePicker,
setImageUrl,
setImageInputOpen,
insertImageUrl
}
}
@@ -6,18 +6,22 @@ export const PLATFORM_COPY: Record<
{ description: string; ctaLabel: string; url: string }
> = {
ios: {
description: translate(
'auto.components.mobile.mobile.platform.copy.432db52b73',
'Scan with your iPhone camera to open the App Store.'
),
get description() {
return translate(
'auto.components.mobile.mobile.platform.copy.432db52b73',
'Scan with your iPhone camera to open the App Store.'
)
},
ctaLabel: 'Open App Store',
url: 'https://apps.apple.com/app/orca-ide/id6766130217'
},
android: {
description: translate(
'auto.components.mobile.mobile.platform.copy.2a532d6fd7',
'Scan with your Android camera to download the latest APK from GitHub Releases.'
),
get description() {
return translate(
'auto.components.mobile.mobile.platform.copy.2a532d6fd7',
'Scan with your Android camera to download the latest APK from GitHub Releases.'
)
},
ctaLabel: 'Download APK',
url: 'https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk'
}
@@ -11,13 +11,11 @@ import {
GitBranchPlus,
GitMerge,
GitPullRequest,
Github,
Gitlab,
LoaderCircle,
Search,
Sparkles,
X
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { Button } from '@/components/ui/button'
@@ -63,29 +61,11 @@ import type {
} from '../../../../shared/types'
import { resolveSmartWorkspaceCommandValue } from './smart-workspace-command-value'
import { translate } from '@/i18n/i18n'
// Why: GitLab MR list filter — Open / Merged / Closed / All — replaces
// GitHub's search-DSL on the GitLab tab per the agreed scope.
type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all'
const MR_STATE_FILTERS: { id: MrStateFilter; label: string }[] = [
{
id: 'opened',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.622864b52a', 'Open')
},
{
id: 'merged',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.2319d87718', 'Merged')
},
{
id: 'closed',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.6fad211c66', 'Closed')
},
{
id: 'all',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.26824f60dd', 'All')
}
]
import {
getMrStateFilters,
getSmartWorkspaceNameModes,
type MrStateFilter
} from './smart-workspace-localized-options'
type RepoOption = ReturnType<typeof useAppStore.getState>['repos'][number]
@@ -120,47 +100,6 @@ export type SmartWorkspaceNameSelection = {
const SEARCH_DEBOUNCE_MS = 200
const RESULT_LIMIT = 12
const MODES: {
id: SmartNameMode
label: string
Icon: React.ComponentType<{ className?: string }>
}[] = [
{
id: 'smart',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.b3c60c2b7c', 'Smart'),
Icon: Sparkles
},
{
id: 'github',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.0a180280bd', 'GitHub'),
Icon: Github
},
{
id: 'linear',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.7a47af0565', 'Linear'),
Icon: ({ className }: { className?: string }) => (
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</svg>
)
},
{
id: 'gitlab',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.2cfc6be192', 'GitLab'),
Icon: Gitlab
},
{
id: 'branches',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.2e4c7c95fe', 'Branch'),
Icon: GitBranch
},
{
id: 'text',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.6f07a18604', 'Name'),
Icon: CaseSensitive
}
]
type RowEntry = SmartWorkspaceSourceRow
export default function SmartWorkspaceNameField({
@@ -182,6 +121,9 @@ export default function SmartWorkspaceNameField({
textOnly = false,
branchesEnabled = true
}: SmartWorkspaceNameFieldProps): React.JSX.Element {
// Why: tab/filter labels use the lightweight translate() helper; subscribing
// here makes them refresh even when language changes don't remount the field.
useTranslation()
const {
addRepo,
checkLinearConnection,
@@ -257,25 +199,22 @@ export default function SmartWorkspaceNameField({
)
const gitlabAvailable = availableTaskProviders.includes('gitlab')
const linearAvailable = availableTaskProviders.includes('linear')
const availableModes = useMemo(
() =>
MODES.filter((item) => {
if (textOnly) {
return item.id === 'text'
}
if (item.id === 'gitlab') {
return gitlabAvailable
}
if (item.id === 'linear') {
return linearAvailable
}
if (item.id === 'branches') {
return branchesEnabled
}
return true
}),
[branchesEnabled, gitlabAvailable, linearAvailable, textOnly]
)
const availableModes = getSmartWorkspaceNameModes().filter((item) => {
if (textOnly) {
return item.id === 'text'
}
if (item.id === 'gitlab') {
return gitlabAvailable
}
if (item.id === 'linear') {
return linearAvailable
}
if (item.id === 'branches') {
return branchesEnabled
}
return true
})
const mrStateFilters = getMrStateFilters()
const selectedSourceFocusKey = selectedSource
? `${selectedSource.kind}:${selectedSource.label}:${selectedSource.url ?? ''}`
@@ -1181,7 +1120,7 @@ export default function SmartWorkspaceNameField({
className="flex shrink-0 items-center gap-1 border-b border-border/40 px-2 py-1.5"
onMouseDown={(e) => e.preventDefault()}
>
{MR_STATE_FILTERS.map(({ id, label }) => (
{mrStateFilters.map(({ id, label }) => (
<Button
key={id}
type="button"
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { i18n } from '@/i18n/i18n'
import { getMrStateFilters, getSmartWorkspaceNameModes } from './smart-workspace-localized-options'
describe('smart-workspace-localized-options', () => {
beforeEach(async () => {
await i18n.changeLanguage('en')
})
it('refreshes create-workspace source tabs when the UI language changes', async () => {
expect(getSmartWorkspaceNameModes().map((mode) => mode.label)).toEqual([
'Smart',
'GitHub',
'Linear',
'GitLab',
'Branch',
'Name'
])
await i18n.changeLanguage('zh')
expect(getSmartWorkspaceNameModes().map((mode) => mode.label)).toEqual([
'聪明的',
'GitHub',
'Linear',
'GitLab',
'分支',
'姓名'
])
await i18n.changeLanguage('en')
expect(getSmartWorkspaceNameModes().map((mode) => mode.label)).toEqual([
'Smart',
'GitHub',
'Linear',
'GitLab',
'Branch',
'Name'
])
})
it('refreshes GitLab state filters when the UI language changes', async () => {
expect(getMrStateFilters().map((filter) => filter.label)).toEqual([
'Open',
'Merged',
'Closed',
'All'
])
await i18n.changeLanguage('zh')
expect(getMrStateFilters().map((filter) => filter.label)).toEqual([
'进行中',
'合并',
'已关闭',
'全部'
])
})
})
@@ -0,0 +1,89 @@
import type React from 'react'
import { CaseSensitive, GitBranch, Github, Gitlab, Sparkles } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import type { SmartNameMode } from './smart-workspace-source-results'
export type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all'
export type SmartWorkspaceNameModeOption = {
id: SmartNameMode
label: string
Icon: React.ComponentType<{ className?: string }>
}
function LinearModeIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</svg>
)
}
export function getMrStateFilters(): { id: MrStateFilter; label: string }[] {
return [
{
id: 'opened',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.622864b52a', 'Open')
},
{
id: 'merged',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.2319d87718', 'Merged')
},
{
id: 'closed',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.6fad211c66', 'Closed')
},
{
id: 'all',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.26824f60dd', 'All')
}
]
}
export function getSmartWorkspaceNameModes(): SmartWorkspaceNameModeOption[] {
return [
{
id: 'smart',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.b3c60c2b7c', 'Smart'),
Icon: Sparkles
},
{
id: 'github',
label: translate(
'auto.components.new.workspace.SmartWorkspaceNameField.0a180280bd',
'GitHub'
),
Icon: Github
},
{
id: 'linear',
label: translate(
'auto.components.new.workspace.SmartWorkspaceNameField.7a47af0565',
'Linear'
),
Icon: LinearModeIcon
},
{
id: 'gitlab',
label: translate(
'auto.components.new.workspace.SmartWorkspaceNameField.2cfc6be192',
'GitLab'
),
Icon: Gitlab
},
{
id: 'branches',
label: translate(
'auto.components.new.workspace.SmartWorkspaceNameField.2e4c7c95fe',
'Branch'
),
Icon: GitBranch
},
{
id: 'text',
label: translate('auto.components.new.workspace.SmartWorkspaceNameField.6f07a18604', 'Name'),
Icon: CaseSensitive
}
]
}
@@ -23,37 +23,52 @@ type FeatureSetupRow = {
const FEATURE_SETUP_ROWS: readonly FeatureSetupRow[] = [
{
id: 'browserUse',
title: translate(
'auto.components.onboarding.FeatureSetupChecklist.ea85d9e628',
'Agent Browser Use'
),
description: translate(
'auto.components.onboarding.FeatureSetupChecklist.01426f3a23',
'Agents can navigate sites, inspect pages, and work through browser tasks.'
),
get title() {
return translate(
'auto.components.onboarding.FeatureSetupChecklist.ea85d9e628',
'Agent Browser Use'
)
},
get description() {
return translate(
'auto.components.onboarding.FeatureSetupChecklist.01426f3a23',
'Agents can navigate sites, inspect pages, and work through browser tasks.'
)
},
setupSummary: 'Enables browser use, prepares orca-cli, and leaves cookies for Settings.',
icon: <Globe2 className="size-4" />
},
{
id: 'computerUse',
title: translate('auto.components.onboarding.FeatureSetupChecklist.1ecfb490ac', 'Computer Use'),
description: translate(
'auto.components.onboarding.FeatureSetupChecklist.c5292c409d',
'Agents can inspect app windows and operate local apps when you ask.'
),
get title() {
return translate(
'auto.components.onboarding.FeatureSetupChecklist.1ecfb490ac',
'Computer Use'
)
},
get description() {
return translate(
'auto.components.onboarding.FeatureSetupChecklist.c5292c409d',
'Agents can inspect app windows and operate local apps when you ask.'
)
},
setupSummary: 'Registers the Orca CLI, opens permissions, and prepares the skill.',
icon: <MonitorCog className="size-4" />
},
{
id: 'orchestration',
title: translate(
'auto.components.onboarding.FeatureSetupChecklist.399cf885c0',
'Agent Orchestration'
),
description: translate(
'auto.components.onboarding.FeatureSetupChecklist.77f74946f5',
'Agents can message each other, take tasks, and coordinate handoffs.'
),
get title() {
return translate(
'auto.components.onboarding.FeatureSetupChecklist.399cf885c0',
'Agent Orchestration'
)
},
get description() {
return translate(
'auto.components.onboarding.FeatureSetupChecklist.77f74946f5',
'Agents can message each other, take tasks, and coordinate handoffs.'
)
},
setupSummary: 'Registers the Orca CLI, enables orchestration, and prepares the skill.',
icon: <Workflow className="size-4" />
}
@@ -17,41 +17,60 @@ import { translate } from '@/i18n/i18n'
const stepCopy = {
agent: {
title: translate(
'auto.components.onboarding.OnboardingFlow.198b148b3c',
'Pick your default agent'
),
subtitle: translate(
'auto.components.onboarding.OnboardingFlow.322fc50a18',
"Orca works with every CLI agent. Choose the one you'll reach for most. Switch any time."
)
get title() {
return translate(
'auto.components.onboarding.OnboardingFlow.198b148b3c',
'Pick your default agent'
)
},
get subtitle() {
return translate(
'auto.components.onboarding.OnboardingFlow.322fc50a18',
"Orca works with every CLI agent. Choose the one you'll reach for most. Switch any time."
)
}
},
theme: {
title: translate(
'auto.components.onboarding.OnboardingFlow.f396db9f20',
'Make it feel like home'
),
subtitle: translate(
'auto.components.onboarding.OnboardingFlow.04ae28d8ca',
'Pick the look you want to stare at for hours.'
)
get title() {
return translate(
'auto.components.onboarding.OnboardingFlow.f396db9f20',
'Make it feel like home'
)
},
get subtitle() {
return translate(
'auto.components.onboarding.OnboardingFlow.04ae28d8ca',
'Pick the look you want to stare at for hours.'
)
}
},
notifications: {
title: translate(
'auto.components.onboarding.OnboardingFlow.b054332836',
'Set up notifications'
),
subtitle: translate(
'auto.components.onboarding.OnboardingFlow.ff92d15436',
'Orca will notify you know when agents are done or need help.'
)
get title() {
return translate(
'auto.components.onboarding.OnboardingFlow.b054332836',
'Set up notifications'
)
},
get subtitle() {
return translate(
'auto.components.onboarding.OnboardingFlow.ff92d15436',
'Orca will notify you when agents are done or need help.'
)
}
},
integrations: {
title: translate('auto.components.onboarding.OnboardingFlow.ae3b00ca82', 'Set up GitHub tasks'),
subtitle: translate(
'auto.components.onboarding.OnboardingFlow.97c42cda00',
'Install the GitHub CLI to:'
)
get title() {
return translate(
'auto.components.onboarding.OnboardingFlow.ae3b00ca82',
'Set up GitHub tasks'
)
},
get subtitle() {
return translate(
'auto.components.onboarding.OnboardingFlow.97c42cda00',
'Install the GitHub CLI to:'
)
}
}
} as const
@@ -10,14 +10,18 @@ import {
import { translate } from '@/i18n/i18n'
export const ONBOARDING_SKIP_CONFIRMATION_COPY = {
title: translate(
'auto.components.onboarding.OnboardingSkipConfirmationDialog.e4726b2d50',
'Skip onboarding?'
),
description: translate(
'auto.components.onboarding.OnboardingSkipConfirmationDialog.9f47f345a4',
"It won't take long!"
),
get title() {
return translate(
'auto.components.onboarding.OnboardingSkipConfirmationDialog.e4726b2d50',
'Skip onboarding?'
)
},
get description() {
return translate(
'auto.components.onboarding.OnboardingSkipConfirmationDialog.9f47f345a4',
"It won't take long!"
)
},
skipLabel: 'Skip',
keepGoingLabel: 'No, keep going'
} as const
@@ -21,17 +21,23 @@ export type BundledPet = {
export const BUNDLED_PETS: readonly BundledPet[] = [
{
id: DEFAULT_PET_ID,
label: translate('auto.components.pet.pet.models.2528586aa7', 'Claudino'),
get label() {
return translate('auto.components.pet.pet.models.2528586aa7', 'Claudino')
},
url: claudeUrl
},
{
id: OPENCODE_PET_ID,
label: translate('auto.components.pet.pet.models.a84d5677ff', 'OpenCode'),
get label() {
return translate('auto.components.pet.pet.models.a84d5677ff', 'OpenCode')
},
url: opencodeUrl
},
{
id: GREMLIN_PET_ID,
label: translate('auto.components.pet.pet.models.7433516faf', 'Gremlin'),
get label() {
return translate('auto.components.pet.pet.models.7433516faf', 'Gremlin')
},
url: gremlinUrl
}
] as const
@@ -384,8 +384,18 @@ describe('ConflictSummaryCard', () => {
expect(cherryPickMarkup).not.toContain('Abort rebase')
})
it('renders rebase abort with the quiet outline review-conflicts button treatment', () => {
const markup = renderToStaticMarkup(
it('renders abort actions with the quiet outline review-conflicts button treatment', () => {
const mergeMarkup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="merge"
unresolvedCount={1}
isResolvingWithAI={false}
onAbortOperation={vi.fn()}
onResolveWithAI={vi.fn()}
onReview={vi.fn()}
/>
)
const rebaseMarkup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="rebase"
unresolvedCount={1}
@@ -396,8 +406,10 @@ describe('ConflictSummaryCard', () => {
/>
)
expect(buttonContaining(markup, 'Review conflicts')).toContain('data-variant="outline"')
expect(buttonContaining(markup, 'Abort rebase')).toContain('data-variant="outline"')
expect(buttonContaining(mergeMarkup, 'Review conflicts')).toContain('data-variant="outline"')
expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"')
expect(buttonContaining(rebaseMarkup, 'Review conflicts')).toContain('data-variant="outline"')
expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"')
})
it('renders the Sparkles icon on the idle Resolve with AI button', () => {
@@ -435,7 +447,7 @@ describe('OperationBanner', () => {
expect(cherryPickMarkup).not.toContain('Abort rebase')
})
it('keeps rebase abort non-destructive while preserving merge abort styling', () => {
it('renders abort actions with the quiet outline button treatment', () => {
const mergeMarkup = renderToStaticMarkup(
<OperationBanner conflictOperation="merge" onAbortOperation={vi.fn()} />
)
@@ -443,7 +455,7 @@ describe('OperationBanner', () => {
<OperationBanner conflictOperation="rebase" onAbortOperation={vi.fn()} />
)
expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="destructive"')
expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"')
expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"')
})
})
@@ -5687,14 +5687,6 @@ function DiffCommentsInlineList({
)
}
function conflictAbortButtonVariant(
conflictOperation: GitConflictOperation
): 'outline' | 'destructive' {
// Why: aborting a rebase is the escape hatch for this state, so it should
// match the quiet outline conflict-review action instead of reading as red.
return conflictOperation === 'rebase' ? 'outline' : 'destructive'
}
export function ConflictSummaryCard({
conflictOperation,
unresolvedCount,
@@ -5770,7 +5762,9 @@ export function ConflictSummaryCard({
{(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? (
<Button
type="button"
variant={conflictAbortButtonVariant(conflictOperation)}
// Why: abort is the escape hatch for this state, so match the quiet
// outline conflict-review action instead of reading as destructive.
variant="outline"
size="sm"
className="mt-1.5 h-7 w-full text-xs"
disabled={isResolvingWithAI || isAbortingOperation}
@@ -5821,7 +5815,9 @@ export function OperationBanner({
{(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? (
<Button
type="button"
variant={conflictAbortButtonVariant(conflictOperation)}
// Why: abort is the escape hatch for this state, so match the quiet
// outline conflict-review action instead of reading as destructive.
variant="outline"
size="sm"
className="mt-2 h-7 w-full text-xs"
disabled={isAbortingOperation}
@@ -42,20 +42,28 @@ type PermissionDefinition = {
const PERMISSIONS: PermissionDefinition[] = [
{
id: 'accessibility',
label: translate('auto.components.settings.ComputerUsePane.6b5a2cd3a5', 'Accessibility'),
description: translate(
'auto.components.settings.ComputerUsePane.4d03dec2d0',
'Read app interface trees and perform requested actions.'
),
get label() {
return translate('auto.components.settings.ComputerUsePane.6b5a2cd3a5', 'Accessibility')
},
get description() {
return translate(
'auto.components.settings.ComputerUsePane.4d03dec2d0',
'Read app interface trees and perform requested actions.'
)
},
icon: <Accessibility className="size-4" />
},
{
id: 'screenshots',
label: translate('auto.components.settings.ComputerUsePane.07bbe4c4cb', 'Screenshots'),
description: translate(
'auto.components.settings.ComputerUsePane.0c9a33f468',
'Capture app windows so agents can inspect visual state.'
),
get label() {
return translate('auto.components.settings.ComputerUsePane.07bbe4c4cb', 'Screenshots')
},
get description() {
return translate(
'auto.components.settings.ComputerUsePane.0c9a33f468',
'Capture app windows so agents can inspect visual state.'
)
},
icon: <Camera className="size-4" />
}
]
@@ -34,103 +34,142 @@ type PermissionDefinition = {
const PERMISSIONS: PermissionDefinition[] = [
{
id: 'microphone',
label: translate('auto.components.settings.DeveloperPermissionsPane.16381e040a', 'Microphone'),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.cc8151d9fa',
'Voice input, transcription, audio recording, sox, ffmpeg, and Whisper CLIs.'
),
get label() {
return translate('auto.components.settings.DeveloperPermissionsPane.16381e040a', 'Microphone')
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.cc8151d9fa',
'Voice input, transcription, audio recording, sox, ffmpeg, and Whisper CLIs.'
)
},
actionLabel: 'Request',
icon: <Mic className="size-4" />
},
{
id: 'camera',
label: translate('auto.components.settings.DeveloperPermissionsPane.e5b5f3d6b9', 'Camera'),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.550cfa3750',
'Webcam capture and camera-driven local test apps.'
),
get label() {
return translate('auto.components.settings.DeveloperPermissionsPane.e5b5f3d6b9', 'Camera')
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.550cfa3750',
'Webcam capture and camera-driven local test apps.'
)
},
actionLabel: 'Request',
icon: <Camera className="size-4" />
},
{
id: 'screen',
label: translate(
'auto.components.settings.DeveloperPermissionsPane.f24f31a884',
'Screen Recording'
),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.0639db5496',
'Screenshot, visual automation, and UI inspection tools.'
),
get label() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.f24f31a884',
'Screen Recording'
)
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.0639db5496',
'Screenshot, visual automation, and UI inspection tools.'
)
},
actionLabel: 'Open Settings',
icon: <MonitorUp className="size-4" />
},
{
id: 'accessibility',
label: translate(
'auto.components.settings.DeveloperPermissionsPane.5b2f22ca2d',
'Accessibility'
),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.9f35980756',
'Keystroke injection, window control, and UI automation tools.'
),
get label() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.5b2f22ca2d',
'Accessibility'
)
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.9f35980756',
'Keystroke injection, window control, and UI automation tools.'
)
},
actionLabel: 'Request',
icon: <Accessibility className="size-4" />
},
{
id: 'full-disk-access',
label: translate(
'auto.components.settings.DeveloperPermissionsPane.c566bca278',
'Full Disk Access'
),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
'Persistent access to protected folders from terminal sessions.'
),
get label() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.c566bca278',
'Full Disk Access'
)
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
'Persistent access to protected folders from terminal sessions.'
)
},
actionLabel: 'Open Settings',
icon: <HardDrive className="size-4" />
},
{
id: 'automation',
label: translate('auto.components.settings.DeveloperPermissionsPane.e119f0d66b', 'Automation'),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.4a73f5217a',
'Apple Events for scripts that control other local apps.'
),
get label() {
return translate('auto.components.settings.DeveloperPermissionsPane.e119f0d66b', 'Automation')
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.4a73f5217a',
'Apple Events for scripts that control other local apps.'
)
},
actionLabel: 'Trigger Prompt',
icon: <Workflow className="size-4" />
},
{
id: 'local-network',
label: translate(
'auto.components.settings.DeveloperPermissionsPane.e7bb06007c',
'Local Network'
),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.f903bf20b5',
'Discovery and access for development servers on your network.'
),
get label() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.e7bb06007c',
'Local Network'
)
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.f903bf20b5',
'Discovery and access for development servers on your network.'
)
},
actionLabel: 'Trigger Prompt',
icon: <Network className="size-4" />
},
{
id: 'usb',
label: translate('auto.components.settings.DeveloperPermissionsPane.bf51e4a542', 'USB Devices'),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.dfbc12c8c8',
'Hardware debugging and device tools that talk to USB devices.'
),
get label() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.bf51e4a542',
'USB Devices'
)
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.dfbc12c8c8',
'Hardware debugging and device tools that talk to USB devices.'
)
},
actionLabel: 'Open Settings',
icon: <Usb className="size-4" />
},
{
id: 'bluetooth',
label: translate('auto.components.settings.DeveloperPermissionsPane.b2210b1b4f', 'Bluetooth'),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.4cfaa7e98a',
'Bluetooth device tools and local hardware experiments.'
),
get label() {
return translate('auto.components.settings.DeveloperPermissionsPane.b2210b1b4f', 'Bluetooth')
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.4cfaa7e98a',
'Bluetooth device tools and local hardware experiments.'
)
},
actionLabel: 'Open Settings',
icon: <Bluetooth className="size-4" />
}
@@ -19,31 +19,39 @@ type RepositorySourceControlAiHostedReviewDefaultsProps = {
const HOSTED_REVIEW_DEFAULT_ROWS: { key: HostedReviewDefaultKey; label: string }[] = [
{
key: 'draft',
label: translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.981eae7e14',
'Draft by default'
)
get label() {
return translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.981eae7e14',
'Draft by default'
)
}
},
{
key: 'useTemplate',
label: translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.d32b87e754',
'Use review template when available'
)
get label() {
return translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.d32b87e754',
'Use review template when available'
)
}
},
{
key: 'generateDetailsOnOpen',
label: translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.14f1eb99d0',
'Generate details when opening Create PR'
)
get label() {
return translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.14f1eb99d0',
'Generate details when opening Create PR'
)
}
},
{
key: 'openAfterCreate',
label: translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.629ed8a9d3',
'Open hosted review after creation'
)
get label() {
return translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.629ed8a9d3',
'Open hosted review after creation'
)
}
}
]
@@ -89,29 +89,51 @@ import { translate } from '@/i18n/i18n'
const SETTINGS_NAV_GROUPS = [
{
id: 'capabilities',
title: translate('auto.components.settings.Settings.23c6874fdf', 'AI Capabilities')
get title() {
return translate('auto.components.settings.Settings.23c6874fdf', 'AI Capabilities')
}
},
{
id: 'setup',
get title() {
return translate('auto.components.settings.Settings.9abb9be3bc', 'Set Up')
}
},
{ id: 'setup', title: translate('auto.components.settings.Settings.9abb9be3bc', 'Set Up') },
{
id: 'workflows',
title: translate('auto.components.settings.Settings.e1578cd4bc', 'Workflows')
get title() {
return translate('auto.components.settings.Settings.e1578cd4bc', 'Workflows')
}
},
{
id: 'interface',
title: translate('auto.components.settings.Settings.8bd117d669', 'Interface')
get title() {
return translate('auto.components.settings.Settings.8bd117d669', 'Interface')
}
},
{
id: 'remote',
title: translate('auto.components.settings.Settings.23931df7e8', 'Remote Access')
get title() {
return translate('auto.components.settings.Settings.23931df7e8', 'Remote Access')
}
},
{
id: 'security',
title: translate('auto.components.settings.Settings.084d8fac5b', 'Privacy & Security')
get title() {
return translate('auto.components.settings.Settings.084d8fac5b', 'Privacy & Security')
}
},
{
id: 'advanced',
get title() {
return translate('auto.components.settings.Settings.1c87f8d024', 'Advanced')
}
},
{ id: 'advanced', title: translate('auto.components.settings.Settings.1c87f8d024', 'Advanced') },
{
id: 'experimental',
title: translate('auto.components.settings.Settings.8b017f2506', 'Experimental')
get title() {
return translate('auto.components.settings.Settings.8b017f2506', 'Experimental')
}
}
] as const
@@ -29,36 +29,51 @@ type SourceControlAiActionRecipeDefaultsProps = {
}
const ACTION_RECIPES_SEARCH_ENTRY = {
title: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.a79c567194',
'Action recipes'
),
description: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.cf01d41bce',
'Agent, CLI arguments, and command template used by each Source Control AI button.'
),
keywords: [
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.926d58e87f', 'agent'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.db9bd75d10',
'arguments'
),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.2576299196', 'args'),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.673369fe0c', 'cli'),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.d74fdc776c', 'command'),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.eb7e8f3b39', 'model'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.2037c78a6f',
'template'
),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.cb67b938c5', 'fix'),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.06a9dab64d', 'checks'),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.e5b24893ba', 'commit'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.7ab1437a12',
'pull request'
get title() {
return translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.a79c567194',
'Action recipes'
)
]
},
get description() {
return translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.cf01d41bce',
'Agent, CLI arguments, and command template used by each Source Control AI button.'
)
},
get keywords() {
return [
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.926d58e87f', 'agent'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.db9bd75d10',
'arguments'
),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.2576299196', 'args'),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.673369fe0c', 'cli'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.d74fdc776c',
'command'
),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.eb7e8f3b39', 'model'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.2037c78a6f',
'template'
),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.cb67b938c5', 'fix'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.06a9dab64d',
'checks'
),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.e5b24893ba',
'commit'
),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.7ab1437a12',
'pull request'
)
]
}
}
export function SourceControlAiActionRecipeDefaults({
@@ -127,19 +142,7 @@ export function SourceControlAiActionRecipeDefaults({
<SearchableSetting
title={ACTION_RECIPES_SEARCH_ENTRY.title}
description={ACTION_RECIPES_SEARCH_ENTRY.description}
keywords={[
'agent',
'arguments',
'args',
'cli',
'command',
'model',
'template',
'fix',
'checks',
'commit',
'pull request'
]}
keywords={ACTION_RECIPES_SEARCH_ENTRY.keywords}
className="space-y-3 px-1 py-2"
>
<div className="space-y-0.5">
@@ -29,7 +29,9 @@ export const STATUS_LABELS: Record<SshConnectionStatus, string> = {
connected: 'Connected',
reconnecting: 'Reconnecting\u2026',
'reconnection-failed': 'Reconnection failed',
error: translate('auto.components.settings.SshTargetCard.18968ede9e', 'Error')
get error() {
return translate('auto.components.settings.SshTargetCard.18968ede9e', 'Error')
}
}
export function statusColor(status: SshConnectionStatus): string {
@@ -26,38 +26,54 @@ const TASK_PROVIDER_OPTIONS: readonly {
}[] = [
{
id: 'github',
label: translate('auto.components.settings.TasksPane.e14063e727', 'GitHub'),
description: translate(
'auto.components.settings.TasksPane.1db47236cd',
'Show GitHub in the Tasks source picker and sidebar shortcuts.'
),
get label() {
return translate('auto.components.settings.TasksPane.e14063e727', 'GitHub')
},
get description() {
return translate(
'auto.components.settings.TasksPane.1db47236cd',
'Show GitHub in the Tasks source picker and sidebar shortcuts.'
)
},
Icon: ({ className }) => <Github className={className} />
},
{
id: 'gitlab',
label: translate('auto.components.settings.TasksPane.7c5d7fdc20', 'GitLab'),
description: translate(
'auto.components.settings.TasksPane.dd67a1b6e1',
'Show GitLab in the Tasks source picker and sidebar shortcuts.'
),
get label() {
return translate('auto.components.settings.TasksPane.7c5d7fdc20', 'GitLab')
},
get description() {
return translate(
'auto.components.settings.TasksPane.dd67a1b6e1',
'Show GitLab in the Tasks source picker and sidebar shortcuts.'
)
},
Icon: ({ className }) => <Gitlab className={className} />
},
{
id: 'linear',
label: translate('auto.components.settings.TasksPane.09ae2d7c51', 'Linear'),
description: translate(
'auto.components.settings.TasksPane.e4170c9615',
'Show Linear in the Tasks source picker and sidebar shortcuts.'
),
get label() {
return translate('auto.components.settings.TasksPane.09ae2d7c51', 'Linear')
},
get description() {
return translate(
'auto.components.settings.TasksPane.e4170c9615',
'Show Linear in the Tasks source picker and sidebar shortcuts.'
)
},
Icon: ({ className }) => <LinearIcon className={className} />
},
{
id: 'jira',
label: translate('auto.components.settings.TasksPane.6b23a34f6d', 'Jira'),
description: translate(
'auto.components.settings.TasksPane.8e1305fcc6',
'Show Jira in the Tasks source picker and sidebar shortcuts.'
),
get label() {
return translate('auto.components.settings.TasksPane.6b23a34f6d', 'Jira')
},
get description() {
return translate(
'auto.components.settings.TasksPane.8e1305fcc6',
'Show Jira in the Tasks source picker and sidebar shortcuts.'
)
},
Icon: ({ className }) => <JiraIcon className={className} />
}
]
@@ -19,57 +19,339 @@ const COLOR_OVERRIDE_GROUPS: {
keys: { key: keyof TerminalColorOverrides; label: string; description: string }[]
}[] = [
{
label: translate("auto.components.settings.TerminalWindowSection.cf37ff69f6", "Base"),
get label() {
return translate('auto.components.settings.TerminalWindowSection.cf37ff69f6', 'Base')
},
keys: [
{ key: 'foreground', label: translate("auto.components.settings.TerminalWindowSection.79f6bfb76e", "Foreground"), description: translate("auto.components.settings.TerminalWindowSection.026a0b8013", "Main text color") },
{ key: 'background', label: translate("auto.components.settings.TerminalWindowSection.cc1b2ffeb2", "Background"), description: translate("auto.components.settings.TerminalWindowSection.da64e8f4c1", "Terminal background color") },
{ key: 'cursor', label: translate("auto.components.settings.TerminalWindowSection.c9e1fdf42f", "Cursor"), description: translate("auto.components.settings.TerminalWindowSection.cd0700762b", "Cursor color") },
{
key: 'foreground',
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.79f6bfb76e',
'Foreground'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.026a0b8013',
'Main text color'
)
}
},
{
key: 'background',
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.cc1b2ffeb2',
'Background'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.da64e8f4c1',
'Terminal background color'
)
}
},
{
key: 'cursor',
get label() {
return translate('auto.components.settings.TerminalWindowSection.c9e1fdf42f', 'Cursor')
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.cd0700762b',
'Cursor color'
)
}
},
{
key: 'cursorAccent',
label: translate("auto.components.settings.TerminalWindowSection.a2d9f095a7", "Cursor Text"),
description: translate("auto.components.settings.TerminalWindowSection.7f4063076c", "Color of text under the cursor (block cursor)")
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.a2d9f095a7',
'Cursor Text'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.7f4063076c',
'Color of text under the cursor (block cursor)'
)
}
},
{
key: 'selectionBackground',
label: translate("auto.components.settings.TerminalWindowSection.40c3cfd30a", "Selection Background"),
description: translate("auto.components.settings.TerminalWindowSection.74d8555f85", "Background color of selected text")
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.40c3cfd30a',
'Selection Background'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.74d8555f85',
'Background color of selected text'
)
}
},
{
key: 'selectionForeground',
label: translate("auto.components.settings.TerminalWindowSection.8b450b5305", "Selection Foreground"),
description: translate("auto.components.settings.TerminalWindowSection.b2c0857c49", "Text color of selected text")
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.8b450b5305',
'Selection Foreground'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.b2c0857c49',
'Text color of selected text'
)
}
},
{
key: 'bold',
label: translate("auto.components.settings.TerminalWindowSection.862e463f7f", "Bold Text"),
description: translate("auto.components.settings.TerminalWindowSection.fb8c6f1967", "Color for bold text. Falls back to the normal color if not set.")
get label() {
return translate('auto.components.settings.TerminalWindowSection.862e463f7f', 'Bold Text')
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.fb8c6f1967',
'Color for bold text. Falls back to the normal color if not set.'
)
}
}
]
},
{
label: translate("auto.components.settings.TerminalWindowSection.68e9f07de0", "ANSI Normal"),
get label() {
return translate('auto.components.settings.TerminalWindowSection.68e9f07de0', 'ANSI Normal')
},
keys: [
{ key: 'black', label: translate("auto.components.settings.TerminalWindowSection.adfdee23cb", "Black"), description: translate("auto.components.settings.TerminalWindowSection.cf4437a2f7", "ANSI black color") },
{ key: 'red', label: translate("auto.components.settings.TerminalWindowSection.3a78f30b50", "Red"), description: translate("auto.components.settings.TerminalWindowSection.b41270f5ca", "ANSI red color") },
{ key: 'green', label: translate("auto.components.settings.TerminalWindowSection.8f2092b315", "Green"), description: translate("auto.components.settings.TerminalWindowSection.8a673d4206", "ANSI green color") },
{ key: 'yellow', label: translate("auto.components.settings.TerminalWindowSection.bb516de873", "Yellow"), description: translate("auto.components.settings.TerminalWindowSection.09c1c6b096", "ANSI yellow color") },
{ key: 'blue', label: translate("auto.components.settings.TerminalWindowSection.292a4c7316", "Blue"), description: translate("auto.components.settings.TerminalWindowSection.9635a71c51", "ANSI blue color") },
{ key: 'magenta', label: translate("auto.components.settings.TerminalWindowSection.d5e92fcd94", "Magenta"), description: translate("auto.components.settings.TerminalWindowSection.1705318506", "ANSI magenta color") },
{ key: 'cyan', label: translate("auto.components.settings.TerminalWindowSection.fb8bb4eb1f", "Cyan"), description: translate("auto.components.settings.TerminalWindowSection.bd4c759327", "ANSI cyan color") },
{ key: 'white', label: translate("auto.components.settings.TerminalWindowSection.0cb4459fb8", "White"), description: translate("auto.components.settings.TerminalWindowSection.28846b1ca6", "ANSI white color") }
{
key: 'black',
get label() {
return translate('auto.components.settings.TerminalWindowSection.adfdee23cb', 'Black')
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.cf4437a2f7',
'ANSI black color'
)
}
},
{
key: 'red',
get label() {
return translate('auto.components.settings.TerminalWindowSection.3a78f30b50', 'Red')
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.b41270f5ca',
'ANSI red color'
)
}
},
{
key: 'green',
get label() {
return translate('auto.components.settings.TerminalWindowSection.8f2092b315', 'Green')
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.8a673d4206',
'ANSI green color'
)
}
},
{
key: 'yellow',
get label() {
return translate('auto.components.settings.TerminalWindowSection.bb516de873', 'Yellow')
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.09c1c6b096',
'ANSI yellow color'
)
}
},
{
key: 'blue',
get label() {
return translate('auto.components.settings.TerminalWindowSection.292a4c7316', 'Blue')
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.9635a71c51',
'ANSI blue color'
)
}
},
{
key: 'magenta',
get label() {
return translate('auto.components.settings.TerminalWindowSection.d5e92fcd94', 'Magenta')
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.1705318506',
'ANSI magenta color'
)
}
},
{
key: 'cyan',
get label() {
return translate('auto.components.settings.TerminalWindowSection.fb8bb4eb1f', 'Cyan')
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.bd4c759327',
'ANSI cyan color'
)
}
},
{
key: 'white',
get label() {
return translate('auto.components.settings.TerminalWindowSection.0cb4459fb8', 'White')
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.28846b1ca6',
'ANSI white color'
)
}
}
]
},
{
label: translate("auto.components.settings.TerminalWindowSection.1be593d3e8", "ANSI Bright"),
get label() {
return translate('auto.components.settings.TerminalWindowSection.1be593d3e8', 'ANSI Bright')
},
keys: [
{ key: 'brightBlack', label: translate("auto.components.settings.TerminalWindowSection.260d69ce9a", "Bright Black"), description: translate("auto.components.settings.TerminalWindowSection.f30c492769", "ANSI bright black color") },
{ key: 'brightRed', label: translate("auto.components.settings.TerminalWindowSection.32b1b6acd7", "Bright Red"), description: translate("auto.components.settings.TerminalWindowSection.667de68863", "ANSI bright red color") },
{ key: 'brightGreen', label: translate("auto.components.settings.TerminalWindowSection.7dafd57730", "Bright Green"), description: translate("auto.components.settings.TerminalWindowSection.0ffb02f921", "ANSI bright green color") },
{ key: 'brightYellow', label: translate("auto.components.settings.TerminalWindowSection.936a326be3", "Bright Yellow"), description: translate("auto.components.settings.TerminalWindowSection.e2ef5f4ab7", "ANSI bright yellow color") },
{ key: 'brightBlue', label: translate("auto.components.settings.TerminalWindowSection.66820332fa", "Bright Blue"), description: translate("auto.components.settings.TerminalWindowSection.bef6c0f6bf", "ANSI bright blue color") },
{ key: 'brightMagenta', label: translate("auto.components.settings.TerminalWindowSection.e56e7d6ea0", "Bright Magenta"), description: translate("auto.components.settings.TerminalWindowSection.fe4d89ef85", "ANSI bright magenta color") },
{ key: 'brightCyan', label: translate("auto.components.settings.TerminalWindowSection.f94adc4113", "Bright Cyan"), description: translate("auto.components.settings.TerminalWindowSection.1601140f03", "ANSI bright cyan color") },
{ key: 'brightWhite', label: translate("auto.components.settings.TerminalWindowSection.16948119cb", "Bright White"), description: translate("auto.components.settings.TerminalWindowSection.42e01a6055", "ANSI bright white color") }
{
key: 'brightBlack',
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.260d69ce9a',
'Bright Black'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.f30c492769',
'ANSI bright black color'
)
}
},
{
key: 'brightRed',
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.32b1b6acd7',
'Bright Red'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.667de68863',
'ANSI bright red color'
)
}
},
{
key: 'brightGreen',
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.7dafd57730',
'Bright Green'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.0ffb02f921',
'ANSI bright green color'
)
}
},
{
key: 'brightYellow',
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.936a326be3',
'Bright Yellow'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.e2ef5f4ab7',
'ANSI bright yellow color'
)
}
},
{
key: 'brightBlue',
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.66820332fa',
'Bright Blue'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.bef6c0f6bf',
'ANSI bright blue color'
)
}
},
{
key: 'brightMagenta',
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.e56e7d6ea0',
'Bright Magenta'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.fe4d89ef85',
'ANSI bright magenta color'
)
}
},
{
key: 'brightCyan',
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.f94adc4113',
'Bright Cyan'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.1601140f03',
'ANSI bright cyan color'
)
}
},
{
key: 'brightWhite',
get label() {
return translate(
'auto.components.settings.TerminalWindowSection.16948119cb',
'Bright White'
)
},
get description() {
return translate(
'auto.components.settings.TerminalWindowSection.42e01a6055',
'ANSI bright white color'
)
}
}
]
}
]
@@ -106,18 +388,37 @@ export function TerminalWindowSection({
return (
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{translate("auto.components.settings.TerminalWindowSection.b96ba13ed1", "Window")}</h3>
<p className="text-xs text-muted-foreground">{translate("auto.components.settings.TerminalWindowSection.00eaa6b881", "Window appearance and background settings.")}</p>
<h3 className="text-sm font-semibold">
{translate('auto.components.settings.TerminalWindowSection.b96ba13ed1', 'Window')}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.TerminalWindowSection.00eaa6b881',
'Window appearance and background settings.'
)}
</p>
</div>
<SearchableSetting
title={translate("auto.components.settings.TerminalWindowSection.ea7b1a158e", "Background Opacity")}
description={translate("auto.components.settings.TerminalWindowSection.03acb60aa0", "Controls the transparency of the terminal background.")}
title={translate(
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
'Background Opacity'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.03acb60aa0',
'Controls the transparency of the terminal background.'
)}
keywords={['opacity', 'transparency', 'background', 'alpha']}
>
<NumberField
label={translate("auto.components.settings.TerminalWindowSection.ea7b1a158e", "Background Opacity")}
description={translate("auto.components.settings.TerminalWindowSection.809f37738d", "Controls the transparency of the terminal background. 1 is fully opaque, 0 is fully transparent.")}
label={translate(
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
'Background Opacity'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.809f37738d',
'Controls the transparency of the terminal background. 1 is fully opaque, 0 is fully transparent.'
)}
value={settings.terminalBackgroundOpacity ?? 1}
defaultValue={1}
min={0}
@@ -131,16 +432,31 @@ export function TerminalWindowSection({
</SearchableSetting>
<SearchableSetting
title={translate("auto.components.settings.TerminalWindowSection.2b82242f43", "Window Blur")}
description={translate("auto.components.settings.TerminalWindowSection.97950bb087", "Apply background blur to the terminal window. Requires restart.")}
title={translate(
'auto.components.settings.TerminalWindowSection.2b82242f43',
'Window Blur'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.97950bb087',
'Apply background blur to the terminal window. Requires restart.'
)}
keywords={['window', 'blur', 'background', 'transparency', 'vibrancy']}
className="space-y-3 py-2"
>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label>{translate("auto.components.settings.TerminalWindowSection.2b82242f43", "Window Blur")}</Label>
<Label>
{translate(
'auto.components.settings.TerminalWindowSection.2b82242f43',
'Window Blur'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.TerminalWindowSection.97950bb087", "Apply background blur to the terminal window. Requires restart.")}</p>
{translate(
'auto.components.settings.TerminalWindowSection.97950bb087',
'Apply background blur to the terminal window. Requires restart.'
)}
</p>
</div>
<button
role="switch"
@@ -162,9 +478,17 @@ export function TerminalWindowSection({
<div className="flex items-center justify-between gap-3 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2.5">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-300">
{translate("auto.components.settings.TerminalWindowSection.c65bb9ce63", "Restart required")}</p>
{translate(
'auto.components.settings.TerminalWindowSection.c65bb9ce63',
'Restart required'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.TerminalWindowSection.53ce336e15", "Restart Orca to apply the window blur change.")}</p>
{translate(
'auto.components.settings.TerminalWindowSection.53ce336e15',
'Restart Orca to apply the window blur change.'
)}
</p>
</div>
<Button
size="sm"
@@ -174,20 +498,40 @@ export function TerminalWindowSection({
onClick={() => void handleRelaunch()}
>
<RotateCw className={`size-3 ${relaunchingBlur ? 'animate-spin' : ''}`} />
{relaunchingBlur ? translate("auto.components.settings.TerminalWindowSection.907131d741", "Restarting…") : translate("auto.components.settings.TerminalWindowSection.8abdab9f7c", "Restart now")}
{relaunchingBlur
? translate(
'auto.components.settings.TerminalWindowSection.907131d741',
'Restarting…'
)
: translate(
'auto.components.settings.TerminalWindowSection.8abdab9f7c',
'Restart now'
)}
</Button>
</div>
) : null}
</SearchableSetting>
<SearchableSetting
title={translate("auto.components.settings.TerminalWindowSection.36b8402015", "Horizontal Padding")}
description={translate("auto.components.settings.TerminalWindowSection.25e2f8e8e1", "Horizontal padding around the terminal grid in pixels.")}
title={translate(
'auto.components.settings.TerminalWindowSection.36b8402015',
'Horizontal Padding'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.25e2f8e8e1',
'Horizontal padding around the terminal grid in pixels.'
)}
keywords={['padding', 'horizontal', 'spacing', 'margin']}
>
<NumberField
label={translate("auto.components.settings.TerminalWindowSection.36b8402015", "Horizontal Padding")}
description={translate("auto.components.settings.TerminalWindowSection.25e2f8e8e1", "Horizontal padding around the terminal grid in pixels.")}
label={translate(
'auto.components.settings.TerminalWindowSection.36b8402015',
'Horizontal Padding'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.25e2f8e8e1',
'Horizontal padding around the terminal grid in pixels.'
)}
value={settings.terminalPaddingX ?? 4}
defaultValue={4}
min={0}
@@ -199,13 +543,25 @@ export function TerminalWindowSection({
</SearchableSetting>
<SearchableSetting
title={translate("auto.components.settings.TerminalWindowSection.1afcc1d973", "Vertical Padding")}
description={translate("auto.components.settings.TerminalWindowSection.1846f6ee6a", "Vertical padding around the terminal grid in pixels.")}
title={translate(
'auto.components.settings.TerminalWindowSection.1afcc1d973',
'Vertical Padding'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.1846f6ee6a',
'Vertical padding around the terminal grid in pixels.'
)}
keywords={['padding', 'vertical', 'spacing', 'margin']}
>
<NumberField
label={translate("auto.components.settings.TerminalWindowSection.1afcc1d973", "Vertical Padding")}
description={translate("auto.components.settings.TerminalWindowSection.1846f6ee6a", "Vertical padding around the terminal grid in pixels.")}
label={translate(
'auto.components.settings.TerminalWindowSection.1afcc1d973',
'Vertical Padding'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.1846f6ee6a',
'Vertical padding around the terminal grid in pixels.'
)}
value={settings.terminalPaddingY ?? 4}
defaultValue={4}
min={0}
@@ -217,15 +573,30 @@ export function TerminalWindowSection({
</SearchableSetting>
<SearchableSetting
title={translate("auto.components.settings.TerminalWindowSection.3530908ef9", "Hide Mouse While Typing")}
description={translate("auto.components.settings.TerminalWindowSection.1d1920dc8a", "Hide the mouse cursor when typing in the terminal.")}
title={translate(
'auto.components.settings.TerminalWindowSection.3530908ef9',
'Hide Mouse While Typing'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
'Hide the mouse cursor when typing in the terminal.'
)}
keywords={['mouse', 'hide', 'typing', 'cursor']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
<Label>{translate("auto.components.settings.TerminalWindowSection.3530908ef9", "Hide Mouse While Typing")}</Label>
<Label>
{translate(
'auto.components.settings.TerminalWindowSection.3530908ef9',
'Hide Mouse While Typing'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.TerminalWindowSection.1d1920dc8a", "Hide the mouse cursor when typing in the terminal.")}</p>
{translate(
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
'Hide the mouse cursor when typing in the terminal.'
)}
</p>
</div>
<button
role="switch"
@@ -250,8 +621,14 @@ export function TerminalWindowSection({
</SearchableSetting>
<SearchableSetting
title={translate("auto.components.settings.TerminalWindowSection.63f8d9336e", "Color Overrides")}
description={translate("auto.components.settings.TerminalWindowSection.e86e09b5c7", "Override individual terminal colors.")}
title={translate(
'auto.components.settings.TerminalWindowSection.63f8d9336e',
'Color Overrides'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.e86e09b5c7',
'Override individual terminal colors.'
)}
keywords={['color', 'override', 'ansi', 'palette', 'theme']}
className="space-y-3"
>
@@ -263,7 +640,11 @@ export function TerminalWindowSection({
<span className={`transition-transform ${colorOverridesExpanded ? 'rotate-90' : ''}`}>
▶
</span>
{translate("auto.components.settings.TerminalWindowSection.63f8d9336e", "Color Overrides")}</button>
{translate(
'auto.components.settings.TerminalWindowSection.63f8d9336e',
'Color Overrides'
)}
</button>
<div
className={`grid overflow-hidden transition-all duration-300 ease-out ${
colorOverridesExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
@@ -299,7 +680,11 @@ export function TerminalWindowSection({
size="sm"
onClick={() => updateSettings({ terminalColorOverrides: undefined })}
>
{translate("auto.components.settings.TerminalWindowSection.03c855d15f", "Reset all color overrides")}</Button>
{translate(
'auto.components.settings.TerminalWindowSection.03c855d15f',
'Reset all color overrides'
)}
</Button>
</div>
</div>
</div>
@@ -3,25 +3,33 @@ import { translate } from '@/i18n/i18n'
export const AUTO_RESTORE_FIT_OPTIONS: { value: string; label: string; ms: number | null }[] = [
{
value: 'indefinite',
label: translate(
'auto.components.settings.MobilePane.aa1263e881',
'Keep at phone size (default)'
),
get label() {
return translate(
'auto.components.settings.MobilePane.aa1263e881',
'Keep at phone size (default)'
)
},
ms: null
},
{
value: '60s',
label: translate('auto.components.settings.MobilePane.c474aa09d8', 'After 1 minute'),
get label() {
return translate('auto.components.settings.MobilePane.c474aa09d8', 'After 1 minute')
},
ms: 60_000
},
{
value: '5m',
label: translate('auto.components.settings.MobilePane.d4ba07d914', 'After 5 minutes'),
get label() {
return translate('auto.components.settings.MobilePane.d4ba07d914', 'After 5 minutes')
},
ms: 5 * 60_000
},
{
value: '30m',
label: translate('auto.components.settings.MobilePane.ff865419dc', 'After 30 minutes'),
get label() {
return translate('auto.components.settings.MobilePane.ff865419dc', 'After 30 minutes')
},
ms: 30 * 60_000
}
]
@@ -3,17 +3,15 @@ import { describe, expect, it, vi } from 'vitest'
import { Dialog } from '@/components/ui/dialog'
import { TooltipProvider } from '@/components/ui/tooltip'
import { CreateStep } from './AddRepoCreateStep'
import type { GitAvailability, RepoKind } from './create-project-defaults'
import type { GitAvailability } from './create-project-defaults'
function renderCreateStep({
createName = '',
createKind = 'git',
gitAvailability = 'available',
createParent = '/Users/alice/orca/projects',
parentDefaultPending = false
}: {
createName?: string
createKind?: RepoKind
gitAvailability?: GitAvailability
createParent?: string
parentDefaultPending?: boolean
@@ -24,7 +22,6 @@ function renderCreateStep({
<CreateStep
createName={createName}
createParent={createParent}
createKind={createKind}
createError={null}
isCreating={false}
defaultParent="/Users/alice/orca/projects"
@@ -33,7 +30,6 @@ function renderCreateStep({
parentDefaultPending={parentDefaultPending}
onNameChange={vi.fn()}
onParentChange={vi.fn()}
onKindChange={vi.fn()}
onPickParent={vi.fn()}
onCreate={vi.fn()}
/>
@@ -43,24 +39,31 @@ function renderCreateStep({
}
describe('CreateStep', () => {
it('renders the name-first create UI with advanced controls collapsed', () => {
it('renders the conductor-style Git project form without templates or kind selection', () => {
const html = renderCreateStep()
expect(html).toContain('Create a new project')
expect(html).toContain('Name')
expect(html).toContain('Git repository in ~/orca/projects')
// The summary card itself is the collapsed disclosure for the uncommon settings.
expect(html).toContain('aria-expanded="false"')
expect(html).toContain('Create project')
expect(html).toContain('Project name')
expect(html).not.toContain('Git repo:')
expect(html).not.toContain('>project-name</span>')
expect(html).toContain('Parent folder')
expect(html).toContain('Browse')
expect(html).not.toContain('Template')
expect(html).not.toContain('Project kind')
expect(html).not.toContain('Location</span>')
expect(html).not.toContain('aria-label="Browse server filesystem"')
})
it('shows the Git fallback explanation in the collapsed summary', () => {
const html = renderCreateStep({ createKind: 'folder', gitAvailability: 'unavailable' })
it('shows the repo name in the helper only after a project name is entered', () => {
const html = renderCreateStep({ createName: 'demo-project' })
expect(html).toContain('Folder in ~/orca/projects')
expect(html).toContain('Git isn&#x27;t installed, so a plain folder is the default.')
expect(html).toContain('Git repo:')
expect(html).toContain('demo-project')
})
it('requires Git instead of falling back to folder creation', () => {
const html = renderCreateStep({ gitAvailability: 'unavailable' })
expect(html).toContain('Git is required to create a project.')
expect(html).toContain('disabled=""')
})
it('disables create while an auto-filled parent belongs to a previous target', () => {
@@ -1,30 +1,17 @@
// Step for AddRepoDialog (orca#763), split out so create-project state stays scoped.
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { ChevronDown, Folder, GitBranch, Loader2 } from 'lucide-react'
import React, { useMemo, useState } from 'react'
import { CornerDownLeft, FolderOpen, Loader2 } from 'lucide-react'
import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import {
CreateProjectLocationField,
CreateProjectParentBrowser
} from './CreateProjectLocationField'
import { CreateProjectParentBrowser } from './CreateProjectLocationField'
import { translate } from '@/i18n/i18n'
import {
formatCreateProjectParentSummary,
joinCreateProjectPath,
type GitAvailability,
type RepoKind
} from './create-project-defaults'
// ── UI helpers ───────────────────────────────────────────────────────
const CREATE_PROJECT_NAME_PLACEHOLDER = 'project-name'
import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut'
import { formatCreateProjectParentSummary, type GitAvailability } from './create-project-defaults'
type CreateStepProps = {
createName: string
createParent: string
createKind: RepoKind
createError: string | null
isCreating: boolean
defaultParent?: string
@@ -35,7 +22,6 @@ type CreateStepProps = {
runtimeEnvironmentId?: string | null
onNameChange: (value: string) => void
onParentChange: (value: string) => void
onKindChange: (kind: RepoKind) => void
onPickParent: () => void
onCreate: () => void
}
@@ -43,7 +29,6 @@ type CreateStepProps = {
export function CreateStep({
createName,
createParent,
createKind,
createError,
isCreating,
defaultParent = '',
@@ -54,52 +39,16 @@ export function CreateStep({
runtimeEnvironmentId,
onNameChange,
onParentChange,
onKindChange,
onPickParent,
onCreate
}: CreateStepProps): React.JSX.Element {
const radioGroupRef = useRef<HTMLDivElement>(null)
const radioFocusFrameRef = useRef<number | null>(null)
const [browsingParent, setBrowsingParent] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const cancelRadioFocusFrame = useCallback((): void => {
if (radioFocusFrameRef.current === null) {
return
}
cancelAnimationFrame(radioFocusFrameRef.current)
radioFocusFrameRef.current = null
}, [])
const setRadioGroupNode = useCallback(
(node: HTMLDivElement | null): void => {
// Why: the queued arrow-key focus is only valid while this radiogroup is mounted.
if (!node) {
cancelRadioFocusFrame()
}
radioGroupRef.current = node
},
[cancelRadioFocusFrame]
)
// Arrow keys cycle selection within the radiogroup (WAI-ARIA radio pattern).
const cycleKind = useCallback(() => {
const next = createKind === 'git' ? 'folder' : 'git'
onKindChange(next)
cancelRadioFocusFrame()
radioFocusFrameRef.current = requestAnimationFrame(() => {
radioFocusFrameRef.current = null
const nextEl = radioGroupRef.current?.querySelector<HTMLButtonElement>(
`[data-kind="${next}"]`
)
nextEl?.focus()
})
}, [cancelRadioFocusFrame, createKind, onKindChange])
const canSubmit =
createName.trim().length > 0 &&
createParent.trim().length > 0 &&
gitAvailability !== 'checking' &&
gitAvailability !== 'unavailable' &&
!parentDefaultPending &&
!isCreating
const missingLocationLabel = translate(
@@ -111,7 +60,7 @@ export function CreateStep({
'server folder not selected'
)
const summaryParent = useMemo(
const parentSummary = useMemo(
() =>
formatCreateProjectParentSummary({
parent: createParent,
@@ -128,14 +77,8 @@ export function CreateStep({
runtimeEnvironmentId
]
)
const targetPathPreview = useMemo(() => {
const name = createName.trim() || CREATE_PROJECT_NAME_PLACEHOLDER
return createParent.trim() ? joinCreateProjectPath(createParent, name) : ''
}, [createName, createParent])
const kindLabel =
createKind === 'git'
? translate('auto.components.sidebar.AddRepoCreateStep.11fd2a7db8', 'Git repository')
: translate('auto.components.sidebar.AddRepoCreateStep.038729c107', 'Folder')
const repoNamePreview = createName.trim()
const submitShortcutModifierLabel = getScreenSubmitModifierLabel()
const showGitFallback = gitAvailability === 'unavailable'
const showGitChecking = gitAvailability === 'checking'
const showRuntimeMissingParent =
@@ -157,14 +100,14 @@ export function CreateStep({
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.sidebar.AddRepoCreateStep.c7b9f94456',
'Create a new project'
'auto.components.sidebar.AddRepoCreateStep.createProjectTitle',
'Create project'
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.sidebar.AddRepoCreateStep.b100311784',
'Name it and Orca will create a real project with sensible defaults.'
'auto.components.sidebar.AddRepoCreateStep.createProjectDescription',
'Create a local Git repo and first workspace.'
)}
</DialogDescription>
</DialogHeader>
@@ -173,14 +116,16 @@ export function CreateStep({
(= content size), so a long path inside the Location row would blow out
the dialog width even with flex + truncate on the row itself. min-w-0
here caps the grid track at the dialog's max-width. */}
<div className="space-y-3.5 pt-1 min-w-0">
{/* Name. Monospaced because it ends up as a directory name. */}
<div className="space-y-1">
<div className="min-w-0 space-y-5 pt-1">
<div className="space-y-2">
<label
htmlFor="create-project-name"
className="text-[11px] font-medium text-muted-foreground block"
className="block text-sm font-medium text-foreground"
>
{translate('auto.components.sidebar.AddRepoCreateStep.a8149a3a5a', 'Name')}
{translate(
'auto.components.sidebar.AddRepoCreateStep.projectNameLabel',
'Project name'
)}
</label>
<Input
id="create-project-name"
@@ -190,194 +135,110 @@ export function CreateStep({
'auto.components.sidebar.AddRepoCreateStep.0ae45b8238',
'my-project'
)}
className="h-11 text-sm font-mono"
className="h-11 text-sm"
disabled={isCreating}
autoFocus
autoComplete="off"
spellCheck={false}
/>
{repoNamePreview ? (
<p className="text-sm text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoCreateStep.createsGitRepoHelp',
'Git repo:'
)}{' '}
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono">{repoNamePreview}</span>
</p>
) : null}
</div>
{/* Summary card doubles as the disclosure for the uncommon settings, so the
defaults and the controls to change them live in one place. */}
<div className="min-w-0 rounded-md border border-border bg-muted/30">
<button
type="button"
onClick={() => setAdvancedOpen((open) => !open)}
aria-expanded={advancedOpen}
className="flex w-full min-w-0 items-start gap-2.5 rounded-md px-3 py-2.5 text-left transition-colors cursor-pointer hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
<div className="space-y-2">
<label
htmlFor="create-project-parent"
className="block text-sm font-medium text-foreground"
>
<span className="mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-background/60 text-muted-foreground">
{createKind === 'git' ? (
<GitBranch className="size-3.5" />
) : (
<Folder className="size-3.5" />
)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{translate(
'auto.components.sidebar.AddRepoCreateStep.685b5eefe1',
'{{kind}} in {{parent}}',
{
kind: kindLabel,
parent: summaryParent
}
)}
</p>
{showGitChecking ? (
<p className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
{translate(
'auto.components.sidebar.AddRepoCreateStep.2a762f3b19',
'Checking Git on this host...'
)}
</p>
) : showGitFallback ? (
<p className="mt-0.5 text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoCreateStep.fe1e616c5b',
"Git isn't installed, so a plain folder is the default."
)}
</p>
) : showRuntimeMissingParent ? (
<p className="mt-0.5 text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoCreateStep.c234df77f7',
'Choose or enter a server parent folder before creating.'
)}
</p>
) : targetPathPreview ? (
<p
className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground"
title={targetPathPreview}
>
{targetPathPreview}
</p>
) : null}
</div>
<ChevronDown
className={cn(
'size-4 shrink-0 self-center text-muted-foreground transition-transform',
advancedOpen && 'rotate-180'
{translate(
'auto.components.sidebar.AddRepoCreateStep.parentFolderLabel',
'Parent folder'
)}
</label>
<div className="flex min-w-0 gap-2">
<Input
id="create-project-parent"
value={createParent}
onChange={(e) => onParentChange(e.target.value)}
placeholder={translate(
'auto.components.sidebar.CreateProjectLocationField.2a20a603a3',
'/home/user/projects'
)}
className="h-11 min-w-0 flex-1 font-mono text-sm"
disabled={isCreating}
spellCheck={false}
/>
</button>
{advancedOpen && (
<div className="space-y-3 border-t border-border px-3 py-3">
{/* Real radiogroup so screen readers announce the segmented choice. */}
<div className="space-y-1.5">
<span className="text-[11px] font-medium text-muted-foreground block">
{translate(
'auto.components.sidebar.AddRepoCreateStep.180e9b5e48',
'Project kind'
)}
</span>
<div
ref={setRadioGroupNode}
role="radiogroup"
aria-label={translate(
'auto.components.sidebar.AddRepoCreateStep.180e9b5e48',
'Project kind'
)}
className="grid grid-cols-2 rounded-md border border-border bg-muted/30 p-0.5"
>
{(['git', 'folder'] as const).map((kind) => {
const selected = createKind === kind
const label =
kind === 'git'
? translate(
'auto.components.sidebar.AddRepoCreateStep.11fd2a7db8',
'Git repository'
)
: translate(
'auto.components.sidebar.AddRepoCreateStep.038729c107',
'Folder'
)
const Icon = kind === 'git' ? GitBranch : Folder
return (
<button
key={kind}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => onKindChange(kind)}
onKeyDown={(e) => {
// Why: keep keyboard radio navigation intact inside the compact segmented control.
if (
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowDown'
) {
e.preventDefault()
cycleKind()
} else if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
onKindChange(kind)
}
}}
disabled={isCreating}
data-kind={kind}
className={cn(
'inline-flex min-w-0 items-center justify-center gap-1.5 rounded-sm border px-2.5 py-2 text-xs font-medium outline-none transition-colors',
// Why: the segment sits on a muted card, so bg-background alone
// is too subtle; the border makes the selected state legible.
selected
? 'border-border bg-background text-foreground shadow-xs'
: 'border-transparent text-muted-foreground hover:text-foreground',
'focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60'
)}
>
<Icon className="size-3.5 shrink-0" />
<span className="truncate">{label}</span>
</button>
)
})}
</div>
{showGitFallback && (
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoCreateStep.fe1e616c5b',
"Git isn't installed, so a plain folder is the default."
)}
</p>
)}
</div>
{/* The local picker returns client paths; runtime servers browse host paths via RPC. */}
<CreateProjectLocationField
createParent={createParent}
isCreating={isCreating}
manualParentEntry={manualParentEntry}
runtimeEnvironmentId={runtimeEnvironmentId}
onParentChange={onParentChange}
onPickParent={onPickParent}
onBrowseServer={() => setBrowsingParent(true)}
/>
{targetPathPreview && (
<p className="min-w-0 break-all rounded-md border border-border bg-background/40 px-2.5 py-2 font-mono text-[11px] text-muted-foreground">
{targetPathPreview}
</p>
<Button
type="button"
variant="outline"
onClick={() => {
if (runtimeEnvironmentId) {
setBrowsingParent(true)
} else {
onPickParent()
}
}}
disabled={isCreating || (manualParentEntry && !runtimeEnvironmentId)}
size="sm"
className="h-11 shrink-0 gap-1.5 px-3"
>
<FolderOpen className="size-3.5" />
{translate('auto.components.sidebar.AddRepoCreateStep.browseParentFolder', 'Browse')}
</Button>
</div>
{showGitChecking ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{translate(
'auto.components.sidebar.AddRepoCreateStep.2a762f3b19',
'Checking Git on this host...'
)}
</div>
</p>
) : showGitFallback ? (
<p className="text-sm text-destructive" role="alert">
{translate(
'auto.components.sidebar.AddRepoCreateStep.gitRequiredError',
'Git is required to create a project.'
)}
</p>
) : showRuntimeMissingParent ? (
<p className="text-sm text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoCreateStep.c234df77f7',
'Choose or enter a server parent folder before creating.'
)}
</p>
) : (
<p className="truncate text-sm text-muted-foreground" title={parentSummary}>
{parentSummary}
</p>
)}
</div>
{createError && (
<p className="text-[11px] text-destructive" role="alert">
<p className="mt-6 text-sm text-destructive" role="alert">
{createError}
</p>
)}
<Button onClick={onCreate} disabled={!canSubmit} size="lg" className="w-full">
{isCreating
? translate('auto.components.sidebar.AddRepoCreateStep.85085d74d2', 'Creating…')
: translate('auto.components.sidebar.AddRepoCreateStep.45b7c26034', 'Create project')}
</Button>
<div className="flex justify-end pt-2">
<Button onClick={onCreate} disabled={!canSubmit} size="lg">
{isCreating
? translate('auto.components.sidebar.AddRepoCreateStep.85085d74d2', 'Creating…')
: translate('auto.components.sidebar.AddRepoCreateStep.createAction', 'Create')}
<span className="ml-1 inline-flex items-center gap-0.5 rounded border border-primary-foreground/25 bg-primary-foreground/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary-foreground/75">
<span>{submitShortcutModifierLabel}</span>
<CornerDownLeft className="size-3" />
</span>
</Button>
</div>
</div>
</>
)
@@ -133,12 +133,10 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const {
createName,
createParent,
createKind,
createError,
isCreating,
setCreateName,
setCreateParent,
setCreateKind,
setCreateError,
resetCreateState,
handlePickParent,
@@ -153,14 +151,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
createRuntimeParentStatus,
createParentDefaultPending,
resetCreateDefaultState,
markCreateParentTouched,
markCreateKindTouched
markCreateParentTouched
} = useCreateProjectDefaults({
step,
activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId,
createParent,
setCreateParent,
setCreateKind
setCreateParent
})
const {
@@ -332,7 +328,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
nestedGroupName={nestedGroupName}
createName={createName}
createParent={createParent}
createKind={createKind}
createError={createError}
isCreating={isCreating}
createDefaultParent={createDefaultParent}
@@ -390,11 +385,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
setCreateParent(value)
setCreateError(null)
}}
onCreateKindChange={(kind) => {
markCreateKindTouched()
setCreateKind(kind)
setCreateError(null)
}}
onPickCreateParent={() => {
void handlePickParent().then((dir) => {
if (dir) {
@@ -53,7 +53,6 @@ function renderStepContent(overrides: Partial<StepContentProps>): string {
nestedGroupName: 'platform',
createName: '',
createParent: '',
createKind: 'git',
createError: null,
isCreating: false,
createDefaultParent: '',
@@ -82,7 +81,6 @@ function renderStepContent(overrides: Partial<StepContentProps>): string {
onImportNestedRepos: vi.fn(),
onCreateNameChange: vi.fn(),
onCreateParentChange: vi.fn(),
onCreateKindChange: vi.fn(),
onPickCreateParent: vi.fn(),
onCreate: vi.fn(),
...overrides
@@ -129,8 +127,9 @@ describe('AddRepoDialogStepContent nested imports', () => {
activeRuntimeEnvironmentId: 'env-1'
})
expect(html).toContain('Create a new project')
expect(html).toContain('server folder not selected')
expect(html).toContain('Create project')
expect(html).toContain('Choose or enter a server parent folder before creating.')
expect(html).toContain('Browse')
})
it('offers server browsing for remote clone destinations', () => {
@@ -38,7 +38,6 @@ type AddRepoDialogStepContentProps = {
nestedGroupName: string
createName: string
createParent: string
createKind: 'git' | 'folder'
createError: string | null
isCreating: boolean
createDefaultParent: string
@@ -67,7 +66,6 @@ type AddRepoDialogStepContentProps = {
onImportNestedRepos: (mode: 'group' | 'separate') => void
onCreateNameChange: (name: string) => void
onCreateParentChange: (parent: string) => void
onCreateKindChange: (kind: 'git' | 'folder') => void
onPickCreateParent: () => void
onCreate: () => void
}
@@ -100,7 +98,6 @@ export function AddRepoDialogStepContent({
nestedGroupName,
createName,
createParent,
createKind,
createError,
isCreating,
createDefaultParent,
@@ -129,7 +126,6 @@ export function AddRepoDialogStepContent({
onImportNestedRepos,
onCreateNameChange,
onCreateParentChange,
onCreateKindChange,
onPickCreateParent,
onCreate
}: AddRepoDialogStepContentProps): React.JSX.Element | null {
@@ -224,7 +220,6 @@ export function AddRepoDialogStepContent({
<CreateStep
createName={createName}
createParent={createParent}
createKind={createKind}
createError={createError}
isCreating={isCreating}
defaultParent={createDefaultParent}
@@ -235,7 +230,6 @@ export function AddRepoDialogStepContent({
runtimeEnvironmentId={activeRuntimeEnvironmentId}
onNameChange={onCreateNameChange}
onParentChange={onCreateParentChange}
onKindChange={onCreateKindChange}
onPickParent={onPickCreateParent}
onCreate={onCreate}
/>
@@ -131,7 +131,7 @@ describe('AddRepoLocalStartStep', () => {
expect(markup).toContain('Browse folder')
expect(markup).toContain('Clone from URL')
expect(markup).toContain('Remote project')
expect(markup).toContain('Create new project')
expect(markup).toContain('Create project')
expect(markup).toContain('Other ways to add')
expect(markup).not.toContain('More options')
})
@@ -140,7 +140,7 @@ describe('AddRepoLocalStartStep', () => {
const titles = getActionTitles(false)
expect(titles.primary).toBe('Browse folder')
expect(titles.secondary).toEqual(['Clone from URL', 'Remote project', 'Create new project'])
expect(titles.secondary).toEqual(['Clone from URL', 'Remote project', 'Create project'])
})
it('keeps Browse folder primary for SSH-likely users', () => {
@@ -149,14 +149,14 @@ describe('AddRepoLocalStartStep', () => {
expect(markup).toContain('Browse folder')
expect(markup).toContain('Remote project')
expect(markup).toContain('Clone from URL')
expect(markup).toContain('Create new project')
expect(markup).toContain('Create project')
})
it('orders secondary actions remote-first for SSH-likely users', () => {
const titles = getActionTitles(true)
expect(titles.primary).toBe('Browse folder')
expect(titles.secondary).toEqual(['Remote project', 'Clone from URL', 'Create new project'])
expect(titles.secondary).toEqual(['Remote project', 'Clone from URL', 'Create project'])
})
it('focuses Browse folder when the default Add Project step opens', async () => {
@@ -188,7 +188,7 @@ describe('AddRepoLocalStartStep', () => {
expect(findButton(container, 'Clone from URL').disabled).toBe(false)
expect(findButton(container, 'Remote project').disabled).toBe(false)
expect(findButton(container, 'Create new project').disabled).toBe(false)
expect(findButton(container, 'Create project').disabled).toBe(false)
await act(async () => {
root.unmount()
@@ -29,53 +29,195 @@ type SidebarWorkspaceOptionsMenuProps = {
}
const GROUP_BY_OPTIONS = [
{ id: 'none', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.c2c7a45cda", "None") },
{ id: 'workspace-status', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.e029a2d775", "Status") },
{ id: 'pr-status', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.0f9b959b31", "PR") },
{ id: 'repo', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf", "Project") }
{
id: 'none',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.c2c7a45cda', 'None')
}
},
{
id: 'workspace-status',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.e029a2d775', 'Status')
}
},
{
id: 'pr-status',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.0f9b959b31', 'PR')
}
},
{
id: 'repo',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf', 'Project')
}
}
] as const
const CARD_LAYOUT_OPTIONS = [
{ id: 'detailed', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.cc17bd443b", "Detailed") },
{ id: 'compact', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb", "Compact") }
{
id: 'detailed',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.cc17bd443b', 'Detailed')
}
},
{
id: 'compact',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb', 'Compact')
}
}
] as const
const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
{ id: 'issue', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.91dfc653e8", "GitHub ticket") },
{ id: 'linear-issue', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.ca4d3c522e", "Linear issue") },
{ id: 'pr', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b8dcc6f321", "PR/MR link") },
{ id: 'comment', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.26c71e536c", "Notes") },
{ id: 'ports', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b64d8bcca0", "Ports") },
{
id: 'issue',
get label() {
return translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.91dfc653e8',
'GitHub ticket'
)
}
},
{
id: 'linear-issue',
get label() {
return translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.ca4d3c522e',
'Linear issue'
)
}
},
{
id: 'pr',
get label() {
return translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.b8dcc6f321',
'PR/MR link'
)
}
},
{
id: 'comment',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.26c71e536c', 'Notes')
}
},
{
id: 'ports',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b64d8bcca0', 'Ports')
}
},
// Why: toggles the inline "Agent activity" list rendered below each
// workspace card body (see WorktreeCard -> WorktreeCardAgents). Off hides
// the list; there is no alternate surface.
{ id: 'inline-agents', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.d7084e8bc8", "Agent activity") }
{
id: 'inline-agents',
get label() {
return translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.d7084e8bc8',
'Agent activity'
)
}
}
]
const AGENT_ACTIVITY_DISPLAY_OPTIONS: { id: AgentActivityDisplayMode; label: string }[] = [
{ id: 'compact', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb", "Compact") },
{ id: 'full', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.2a81e07366", "Full list") }
{
id: 'compact',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb', 'Compact')
}
},
{
id: 'full',
get label() {
return translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.2a81e07366',
'Full list'
)
}
}
]
const SORT_OPTIONS = [
{ id: 'name', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.3728165cdd", "Name"), description: null },
{
id: 'name',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.3728165cdd', 'Name')
},
description: null
},
{
id: 'smart',
label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.503462f2b4", "Agent Activity"),
description: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b759bb87ee", "Agents that need attention, then most recent activity.")
get label() {
return translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.503462f2b4',
'Agent Activity'
)
},
get description() {
return translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.b759bb87ee',
'Agents that need attention, then most recent activity.'
)
}
},
{
id: 'recent',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162', 'Recent')
},
description: null
},
{
id: 'repo',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf', 'Project')
},
description: null
},
{ id: 'recent', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162", "Recent"), description: null },
{ id: 'repo', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf", "Project"), description: null },
{
id: 'manual',
label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51", "Manual"),
description: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.7153d07485", "Drag workspaces to arrange them within each group.")
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51', 'Manual')
},
get description() {
return translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.7153d07485',
'Drag workspaces to arrange them within each group.'
)
}
}
] as const
const PROJECT_ORDER_OPTIONS = [
{ id: 'manual', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51", "Manual"), description: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.6664282a7b", "Drag projects to arrange them") },
{ id: 'recent', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162", "Recent"), description: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.af9249c505", "Most recent workspace activity") }
{
id: 'manual',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51', 'Manual')
},
get description() {
return translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.6664282a7b',
'Drag projects to arrange them'
)
}
},
{
id: 'recent',
get label() {
return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162', 'Recent')
},
get description() {
return translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.af9249c505',
'Most recent workspace activity'
)
}
}
] as const
const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsMenu({
@@ -148,8 +290,15 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
className="relative text-muted-foreground"
aria-label={
hasAnyFilter
? translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041", "Workspace options ({{value0}} active)", { value0: activeFilterLabel })
: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.9919ae1082", "Workspace options")
? translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041',
'Workspace options ({{value0}} active)',
{ value0: activeFilterLabel }
)
: translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.9919ae1082',
'Workspace options'
)
}
data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined}
>
@@ -168,7 +317,16 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{hasAnyFilter ? translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041", "Workspace options ({{value0}})", { value0: activeFilterLabel }) : translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.9919ae1082", "Workspace options")}
{hasAnyFilter
? translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041',
'Workspace options ({{value0}})',
{ value0: activeFilterLabel }
)
: translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.9919ae1082',
'Workspace options'
)}
</TooltipContent>
</Tooltip>
<DropdownMenuContent
@@ -178,7 +336,9 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
className="w-72 pb-2"
data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined}
>
<DropdownMenuLabel>{translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.dc0bb670bc", "Group by")}</DropdownMenuLabel>
<DropdownMenuLabel>
{translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.dc0bb670bc', 'Group by')}
</DropdownMenuLabel>
<div className="px-2 pt-0.5 pb-1">
<ToggleGroup
type="single"
@@ -208,7 +368,12 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span className="flex flex-1 items-center justify-between">
<span>{translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.7bada3b1ab", "Sort by")}</span>
<span>
{translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.7bada3b1ab',
'Sort by'
)}
</span>
<span className="text-[11px] font-medium text-muted-foreground">{sortLabel}</span>
</span>
</DropdownMenuSubTrigger>
@@ -250,11 +415,16 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
{/* Why: project order only has a visible effect when grouping by
project; hide it in none/status/PR modes to avoid a dead control. */}
{groupBy === "repo" && (
{groupBy === 'repo' && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span className="flex flex-1 items-center justify-between">
<span>{translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.09faabd875", "Project order")}</span>
<span>
{translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.09faabd875',
'Project order'
)}
</span>
<span className="text-[11px] font-medium text-muted-foreground">
{projectOrderLabel}
</span>
@@ -292,7 +462,12 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span className="flex flex-1 items-center justify-between">
<span>{translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.320b675c9a", "Card layout")}</span>
<span>
{translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.320b675c9a',
'Card layout'
)}
</span>
<span className="text-[11px] font-medium text-muted-foreground">
{cardLayoutLabel}
</span>
@@ -326,9 +501,19 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span className="flex flex-1 items-center justify-between">
<span>{translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.ba87080fb7", "Show properties")}</span>
{cardLayout === "compact" ? (
<span className="text-[11px] font-medium text-muted-foreground">{translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.3d4b9c4997", "Hover")}</span>
<span>
{translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.ba87080fb7',
'Show properties'
)}
</span>
{cardLayout === 'compact' ? (
<span className="text-[11px] font-medium text-muted-foreground">
{translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.3d4b9c4997',
'Hover'
)}
</span>
) : visiblePropertyCount > 0 ? (
<span className="text-[11px] font-medium text-muted-foreground">
{visiblePropertyCount}
@@ -352,7 +537,11 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="px-2 py-1 text-[11px] font-medium text-muted-foreground">
{translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.95c9754653", "Agent activity layout")}</DropdownMenuLabel>
{translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.95c9754653',
'Agent activity layout'
)}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={agentActivityDisplayMode}
onValueChange={(value) =>
@@ -24,14 +24,36 @@ type SshDisconnectedDialogProps = {
}
const STATUS_MESSAGES: Partial<Record<SshConnectionStatus, string>> = {
disconnected: 'This remote repository is not connected.',
reconnecting: 'Reconnecting to the remote host...',
'reconnection-failed': 'Reconnection to the remote host failed.',
error: translate(
'auto.components.sidebar.SshDisconnectedDialog.376bed88e5',
'The connection to the remote host encountered an error.'
),
'auth-failed': 'Authentication to the remote host failed.'
get disconnected() {
return translate(
'auto.components.sidebar.SshDisconnectedDialog.disconnected',
'This remote repository is not connected.'
)
},
get reconnecting() {
return translate(
'auto.components.sidebar.SshDisconnectedDialog.reconnecting',
'Reconnecting to the remote host...'
)
},
get 'reconnection-failed'() {
return translate(
'auto.components.sidebar.SshDisconnectedDialog.reconnectionFailed',
'Reconnection to the remote host failed.'
)
},
get error() {
return translate(
'auto.components.sidebar.SshDisconnectedDialog.376bed88e5',
'The connection to the remote host encountered an error.'
)
},
get 'auth-failed'() {
return translate(
'auto.components.sidebar.SshDisconnectedDialog.authFailed',
'Authentication to the remote host failed.'
)
}
}
function isReconnectable(status: SshConnectionStatus): boolean {
@@ -76,9 +98,21 @@ export function SshDisconnectedDialog({
status === 'connecting' ||
status === 'deploying-relay' ||
status === 'reconnecting'
const reconnectingMessage =
STATUS_MESSAGES.reconnecting ??
translate(
'auto.components.sidebar.SshDisconnectedDialog.reconnecting',
'Reconnecting to the remote host...'
)
const disconnectedMessage =
STATUS_MESSAGES.disconnected ??
translate(
'auto.components.sidebar.SshDisconnectedDialog.disconnected',
'This remote repository is not connected.'
)
const message = isConnecting
? 'Reconnecting to the remote host...'
: (STATUS_MESSAGES[status] ?? 'This remote repository is not connected.')
? reconnectingMessage
: (STATUS_MESSAGES[status] ?? disconnectedMessage)
const showReconnect = isReconnectable(status)
useEffect(() => {
@@ -71,12 +71,12 @@ export function getAddRepoLocalStartActions({
kind: 'create' as const,
icon: Plus,
title: translate(
'auto.components.sidebar.add.repo.local.start.actions.c709860596',
'Create new project'
'auto.components.sidebar.add.repo.local.start.actions.createProjectTitle',
'Create project'
),
description: translate(
'auto.components.sidebar.add.repo.local.start.actions.d72789705e',
'Start from an empty folder'
'auto.components.sidebar.add.repo.local.start.actions.createGitProjectDescription',
'Create a local Git repository'
),
onClick: onOpenCreateStep
}
@@ -63,16 +63,14 @@ function useHarness(overrides: Partial<Parameters<typeof useCreateProjectDefault
mocks.stateIndex = 0
mocks.refIndex = 0
const setCreateParent = vi.fn()
const setCreateKind = vi.fn()
const result = useCreateProjectDefaults({
step: 'create',
activeRuntimeEnvironmentId: null,
createParent: '',
setCreateParent,
setCreateKind,
...overrides
})
return { result, setCreateParent, setCreateKind }
return { result, setCreateParent }
}
describe('useCreateProjectDefaults', () => {
@@ -93,16 +91,15 @@ describe('useCreateProjectDefaults', () => {
mocks.getDefaultCreateProjectParent.mockResolvedValue('/Users/alice/orca/projects')
})
it('auto-fills the local default parent and defaults to git when available', async () => {
it('auto-fills the local default parent and records Git availability', async () => {
mocks.isGitAvailable.mockResolvedValue(true)
const { setCreateParent, setCreateKind } = useHarness()
const { setCreateParent } = useHarness()
await flushAsync()
expect(setCreateParent).toHaveBeenCalledWith('/Users/alice/orca/projects')
expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects')
expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('available')
expect(setCreateKind).toHaveBeenCalledWith('git')
expect(mocks.getDefaultCreateProjectParent).toHaveBeenCalled()
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
})
@@ -128,24 +125,22 @@ describe('useCreateProjectDefaults', () => {
expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects')
})
it('defaults to folder with a visible fallback when Git is unavailable', async () => {
it('records unavailable Git without changing project kind', async () => {
mocks.isGitAvailable.mockResolvedValue(false)
const { setCreateKind } = useHarness()
useHarness()
await flushAsync()
expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unavailable')
expect(setCreateKind).toHaveBeenCalledWith('folder')
})
it('reports unknown availability and keeps the kind when the Git probe fails', async () => {
it('reports unknown availability when the Git probe fails', async () => {
mocks.isGitAvailable.mockRejectedValue(new Error('probe failed'))
const { setCreateKind } = useHarness()
useHarness()
await flushAsync()
expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unknown')
expect(setCreateKind).not.toHaveBeenCalled()
})
it('does not overwrite a parent the user already chose', async () => {
@@ -161,7 +156,7 @@ describe('useCreateProjectDefaults', () => {
mocks.browseRuntimeServerDirectory.mockResolvedValue({ resolvedPath: '/home/alice' })
mocks.callRuntimeRpc.mockResolvedValue({ available: true })
const { setCreateParent, setCreateKind } = useHarness({ activeRuntimeEnvironmentId: 'env-1' })
const { setCreateParent } = useHarness({ activeRuntimeEnvironmentId: 'env-1' })
await flushAsync()
expect(mocks.browseRuntimeServerDirectory).toHaveBeenCalledWith('env-1', '~')
@@ -176,7 +171,6 @@ describe('useCreateProjectDefaults', () => {
{ timeoutMs: 3000 }
)
expect(mocks.isGitAvailable).not.toHaveBeenCalled()
expect(setCreateKind).toHaveBeenCalledWith('git')
})
it('replaces an untouched local default when switching to a runtime target', async () => {
@@ -249,11 +243,10 @@ describe('useCreateProjectDefaults', () => {
})
it('does nothing outside the create step', async () => {
const { setCreateParent, setCreateKind } = useHarness({ step: 'add' })
const { setCreateParent } = useHarness({ step: 'add' })
await flushAsync()
expect(setCreateParent).not.toHaveBeenCalled()
expect(setCreateKind).not.toHaveBeenCalled()
expect(mocks.isGitAvailable).not.toHaveBeenCalled()
expect(mocks.browseRuntimeServerDirectory).not.toHaveBeenCalled()
})
@@ -5,11 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { browseRuntimeServerDirectory } from '@/runtime/runtime-server-directory-browser'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import type { AddRepoDialogStep } from './add-repo-dialog-types'
import {
getDefaultCreateProjectParent,
type GitAvailability,
type RepoKind
} from './create-project-defaults'
import { getDefaultCreateProjectParent, type GitAvailability } from './create-project-defaults'
const LOCAL_GIT_AVAILABILITY_TIMEOUT_MS = 1500
const RUNTIME_GIT_AVAILABILITY_TIMEOUT_MS = 3000
@@ -51,14 +47,12 @@ export function useCreateProjectDefaults({
step,
activeRuntimeEnvironmentId,
createParent,
setCreateParent,
setCreateKind
setCreateParent
}: {
step: AddRepoDialogStep
activeRuntimeEnvironmentId: string | null | undefined
createParent: string
setCreateParent: (value: string) => void
setCreateKind: (kind: RepoKind) => void
}): {
createDefaultParent: string
createGitAvailability: GitAvailability
@@ -66,7 +60,6 @@ export function useCreateProjectDefaults({
createParentDefaultPending: boolean
resetCreateDefaultState: () => void
markCreateParentTouched: (value?: string) => void
markCreateKindTouched: () => void
} {
const [createDefaultParent, setCreateDefaultParent] = useState('')
const [createGitAvailability, setCreateGitAvailability] = useState<GitAvailability>('unknown')
@@ -76,7 +69,6 @@ export function useCreateProjectDefaults({
const autoFilledCreateParentRef = useRef<AutoFilledCreateParent | null>(null)
const createParentProvenanceRef = useRef<CreateParentProvenance | null>(null)
const createParentTouchedRef = useRef(false)
const createKindTouchedRef = useRef(false)
const createParentDefaultGenRef = useRef(0)
const createGitProbeGenRef = useRef(0)
const activeCreateParentRuntimeEnvironmentId = activeRuntimeEnvironmentId?.trim() || null
@@ -96,7 +88,6 @@ export function useCreateProjectDefaults({
autoFilledCreateParentRef.current = null
createParentProvenanceRef.current = null
createParentTouchedRef.current = false
createKindTouchedRef.current = false
setCreateDefaultParent('')
setCreateGitAvailability('unknown')
setCreateRuntimeParentStatus('idle')
@@ -114,9 +105,6 @@ export function useCreateProjectDefaults({
},
[activeCreateParentRuntimeEnvironmentId, createParent]
)
const markCreateKindTouched = useCallback(() => {
createKindTouchedRef.current = true
}, [])
const createParentDefaultPending =
step === 'create' &&
@@ -282,10 +270,6 @@ export function useCreateProjectDefaults({
return
}
setCreateGitAvailability(available ? 'available' : 'unavailable')
if (createKindTouchedRef.current) {
return
}
setCreateKind(available ? 'git' : 'folder')
})
.catch(() => {
if (gen !== createGitProbeGenRef.current) {
@@ -293,7 +277,7 @@ export function useCreateProjectDefaults({
}
setCreateGitAvailability('unknown')
})
}, [activeRuntimeEnvironmentId, setCreateKind, step])
}, [activeRuntimeEnvironmentId, step])
return {
createDefaultParent,
@@ -301,7 +285,6 @@ export function useCreateProjectDefaults({
createRuntimeParentStatus,
createParentDefaultPending: createParentPending,
resetCreateDefaultState,
markCreateParentTouched,
markCreateKindTouched
markCreateParentTouched
}
}
@@ -89,7 +89,7 @@ describe('useCreateRepo default-checkout handoff', () => {
vi.clearAllMocks()
mocks.stateIndex = 0
mocks.stateSetters = []
mocks.stateValues = ['created', '/projects', 'git', null, false]
mocks.stateValues = ['created', '/projects', null, false]
mocks.storeState.repos = []
mocks.storeState.worktreesByRepo = {}
mocks.storeState.settings.activeRuntimeEnvironmentId = null
@@ -163,11 +163,11 @@ describe('useCreateRepo default-checkout handoff', () => {
)
})
it('marks onboarding folder progress when a created folder project opens', async () => {
it('uses the folder completion path if IPC returns a folder project', async () => {
const repo = makeRepo({ kind: 'folder' })
const worktree = { id: `${repo.id}::/projects/created` }
const closeModal = vi.fn()
mocks.stateValues = ['created', '/projects', 'folder', null, false]
mocks.stateValues = ['created', '/projects', null, false]
mocks.createRepo.mockResolvedValue({ repo })
mocks.fetchWorktrees.mockImplementation(async (repoId: string) => {
mocks.storeState.worktreesByRepo = { [repoId]: [worktree] }
@@ -181,7 +181,7 @@ describe('useCreateRepo default-checkout handoff', () => {
expect(mocks.createRepo).toHaveBeenCalledWith({
parentPath: '/projects',
name: 'created',
kind: 'folder'
kind: 'git'
})
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id)
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktree.id, {
@@ -10,7 +10,6 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { Repo } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import type { RepoKind } from './create-project-defaults'
export function useCreateRepo(
fetchWorktrees: (
@@ -22,7 +21,6 @@ export function useCreateRepo(
) {
const [createName, setCreateName] = useState('')
const [createParent, setCreateParent] = useState('')
const [createKind, setCreateKind] = useState<RepoKind>('git')
const [createError, setCreateError] = useState<string | null>(null)
const [isCreating, setIsCreating] = useState(false)
const mountedRef = useMountedRef()
@@ -36,7 +34,6 @@ export function useCreateRepo(
createGenRef.current++
setCreateName('')
setCreateParent('')
setCreateKind('git')
setCreateError(null)
setIsCreating(false)
}, [])
@@ -82,14 +79,14 @@ export function useCreateRepo(
{
parentPath,
name,
kind: createKind
kind: 'git'
},
{ timeoutMs: 60_000 }
)
: await window.api.repos.create({
parentPath,
name,
kind: createKind
kind: 'git'
})
// Why: if the user closed the dialog or clicked Back mid-create,
// createGenRef was bumped by resetCreateState. Ignore stale results.
@@ -170,17 +167,15 @@ export function useCreateRepo(
setIsCreating(false)
}
}
}, [createName, createParent, createKind, fetchWorktrees, mountedRef, closeModal, onGitRepoReady])
}, [createName, createParent, fetchWorktrees, mountedRef, closeModal, onGitRepoReady])
return {
createName,
createParent,
createKind,
createError,
isCreating,
setCreateName,
setCreateParent,
setCreateKind,
setCreateError,
resetCreateState,
handlePickParent,
@@ -128,22 +128,30 @@ export const PR_GROUP_META: Record<
}
> = {
done: {
label: translate('auto.components.sidebar.worktree.list.groups.5076efc3d2', 'Done'),
get label() {
return translate('auto.components.sidebar.worktree.list.groups.5076efc3d2', 'Done')
},
icon: ConductorDoneIcon,
tone: 'text-[#c7a594]'
},
'in-review': {
label: translate('auto.components.sidebar.worktree.list.groups.6798dc7c94', 'In review'),
get label() {
return translate('auto.components.sidebar.worktree.list.groups.6798dc7c94', 'In review')
},
icon: ConductorReviewIcon,
tone: 'text-[#16a34a]'
},
'in-progress': {
label: translate('auto.components.sidebar.worktree.list.groups.7c2f009786', 'In progress'),
get label() {
return translate('auto.components.sidebar.worktree.list.groups.7c2f009786', 'In progress')
},
icon: ConductorProgressIcon,
tone: 'text-[#d4a300]'
},
closed: {
label: translate('auto.components.sidebar.worktree.list.groups.682ed5d551', 'Closed'),
get label() {
return translate('auto.components.sidebar.worktree.list.groups.682ed5d551', 'Closed')
},
icon: CircleX,
tone: 'text-zinc-600 dark:text-zinc-300'
}
@@ -161,7 +169,9 @@ export function getProjectGroupHeaderKey(groupId: string | null): string {
export const PINNED_GROUP_KEY = 'pinned'
export const PINNED_GROUP_META = {
label: translate('auto.components.sidebar.worktree.list.groups.4aeefc5996', 'Pinned'),
get label() {
return translate('auto.components.sidebar.worktree.list.groups.4aeefc5996', 'Pinned')
},
tone: 'text-foreground',
icon: Pin
} as const
@@ -169,7 +179,9 @@ export const PINNED_GROUP_META = {
export const ALL_GROUP_KEY = 'all'
export const ALL_GROUP_META = {
label: translate('auto.components.sidebar.worktree.list.groups.0ed04075b8', 'All'),
get label() {
return translate('auto.components.sidebar.worktree.list.groups.0ed04075b8', 'All')
},
tone: 'text-foreground',
icon: List
} as const
@@ -0,0 +1,63 @@
import type {
ClaudeUsageBreakdownRow,
ClaudeUsageDailyPoint,
ClaudeUsageSessionRow,
ClaudeUsageSummary
} from '../../../../shared/claude-usage-types'
import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart'
import { ClaudeUsageRecentSessionsTable } from './ClaudeUsageRecentSessionsTable'
import { UsageBreakdownSection } from './UsageBreakdownSection'
import { translate } from '@/i18n/i18n'
type ClaudeUsageDetailsProps = {
daily: ClaudeUsageDailyPoint[]
modelBreakdown: ClaudeUsageBreakdownRow[]
projectBreakdown: ClaudeUsageBreakdownRow[]
recentSessions: ClaudeUsageSessionRow[]
summary: ClaudeUsageSummary | null | undefined
}
export function ClaudeUsageDetails({
daily,
modelBreakdown,
projectBreakdown,
recentSessions,
summary
}: ClaudeUsageDetailsProps): React.JSX.Element {
return (
<>
<ClaudeUsageDailyChart daily={daily} />
<div className="grid gap-4 xl:grid-cols-2">
<UsageBreakdownSection
title={translate('auto.components.stats.ClaudeUsagePane.0f394c24e3', 'By model')}
topLabel={translate('auto.components.stats.ClaudeUsagePane.c3fdbc5474', 'Top model:')}
topValue={summary?.topModel}
rows={modelBreakdown.map((row) => ({
key: row.key,
label: row.label,
tokens: row.inputTokens + row.outputTokens,
sessions: row.sessions,
eventsOrTurns: row.turns
}))}
eventsOrTurns="turns"
/>
<UsageBreakdownSection
title={translate('auto.components.stats.ClaudeUsagePane.7dc9e5613b', 'By project')}
topLabel={translate('auto.components.stats.ClaudeUsagePane.f97435845c', 'Top project:')}
topValue={summary?.topProject}
rows={projectBreakdown.map((row) => ({
key: row.key,
label: row.label,
tokens: row.inputTokens + row.outputTokens,
sessions: row.sessions,
eventsOrTurns: row.turns
}))}
eventsOrTurns="turns"
/>
</div>
<ClaudeUsageRecentSessionsTable recentSessions={recentSessions} summary={summary ?? null} />
</>
)
}
@@ -23,12 +23,10 @@ import {
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart'
import { ClaudeUsageDetails } from './ClaudeUsageDetails'
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
import { ShareUsageButton } from './ShareUsageButton'
import { ClaudeUsageRecentSessionsTable } from './ClaudeUsageRecentSessionsTable'
import { StatCard } from './StatCard'
import { UsageBreakdownSection } from './UsageBreakdownSection'
import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters'
import { translate } from '@/i18n/i18n'
@@ -36,18 +34,30 @@ const RANGE_OPTIONS: ClaudeUsageRange[] = ['7d', '30d', '90d', 'all']
const SCOPE_OPTIONS: { value: ClaudeUsageScope; label: string }[] = [
{
value: 'orca',
label: translate('auto.components.stats.ClaudeUsagePane.4f8368c272', 'Orca worktrees only')
get label() {
return translate('auto.components.stats.ClaudeUsagePane.4f8368c272', 'Orca worktrees only')
}
},
{
value: 'all',
label: translate('auto.components.stats.ClaudeUsagePane.5ce4842c2c', 'All local Claude usage')
get label() {
return translate('auto.components.stats.ClaudeUsagePane.5ce4842c2c', 'All local Claude usage')
}
}
]
const RANGE_LABELS: Record<ClaudeUsageRange, string> = {
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
all: 'All time'
get '7d'() {
return translate('auto.components.stats.ClaudeUsagePane.rangeLast7Days', 'Last 7 days')
},
get '30d'() {
return translate('auto.components.stats.ClaudeUsagePane.rangeLast30Days', 'Last 30 days')
},
get '90d'() {
return translate('auto.components.stats.ClaudeUsagePane.rangeLast90Days', 'Last 90 days')
},
get all() {
return translate('auto.components.stats.ClaudeUsagePane.rangeAllTime', 'All time')
}
}
export function ClaudeUsagePane(): React.JSX.Element {
@@ -312,41 +322,13 @@ export function ClaudeUsagePane(): React.JSX.Element {
)}
</p>
<ClaudeUsageDailyChart daily={daily} />
<div className="grid gap-4 xl:grid-cols-2">
<UsageBreakdownSection
title={translate('auto.components.stats.ClaudeUsagePane.0f394c24e3', 'By model')}
topLabel={translate('auto.components.stats.ClaudeUsagePane.c3fdbc5474', 'Top model:')}
topValue={summary?.topModel}
rows={modelBreakdown.map((row) => ({
key: row.key,
label: row.label,
tokens: row.inputTokens + row.outputTokens,
sessions: row.sessions,
eventsOrTurns: row.turns
}))}
eventsOrTurns="turns"
/>
<UsageBreakdownSection
title={translate('auto.components.stats.ClaudeUsagePane.7dc9e5613b', 'By project')}
topLabel={translate(
'auto.components.stats.ClaudeUsagePane.f97435845c',
'Top project:'
)}
topValue={summary?.topProject}
rows={projectBreakdown.map((row) => ({
key: row.key,
label: row.label,
tokens: row.inputTokens + row.outputTokens,
sessions: row.sessions,
eventsOrTurns: row.turns
}))}
eventsOrTurns="turns"
/>
</div>
<ClaudeUsageRecentSessionsTable recentSessions={recentSessions} summary={summary} />
<ClaudeUsageDetails
daily={daily}
modelBreakdown={modelBreakdown}
projectBreakdown={projectBreakdown}
recentSessions={recentSessions}
summary={summary}
/>
</>
)}
</div>
@@ -0,0 +1,64 @@
import type {
CodexUsageBreakdownRow,
CodexUsageDailyPoint,
CodexUsageSessionRow,
CodexUsageSummary
} from '../../../../shared/codex-usage-types'
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
import { CodexUsageRecentSessionsTable } from './CodexUsageRecentSessionsTable'
import { UsageBreakdownSection } from './UsageBreakdownSection'
import { translate } from '@/i18n/i18n'
type CodexUsageDetailsProps = {
daily: CodexUsageDailyPoint[]
modelBreakdown: CodexUsageBreakdownRow[]
projectBreakdown: CodexUsageBreakdownRow[]
recentSessions: CodexUsageSessionRow[]
summary: CodexUsageSummary | null | undefined
}
export function CodexUsageDetails({
daily,
modelBreakdown,
projectBreakdown,
recentSessions,
summary
}: CodexUsageDetailsProps): React.JSX.Element {
return (
<>
<CodexUsageDailyChart daily={daily} />
<div className="grid gap-4 xl:grid-cols-2">
<UsageBreakdownSection
title={translate('auto.components.stats.CodexUsagePane.5a0d1d69cd', 'By model')}
topLabel={translate('auto.components.stats.CodexUsagePane.95d2d89285', 'Top model:')}
topValue={summary?.topModel}
rows={modelBreakdown.map((row) => ({
key: row.key,
label: row.label,
tokens: row.totalTokens,
sessions: row.sessions,
eventsOrTurns: row.events,
hasInferredPricing: row.hasInferredPricing
}))}
eventsOrTurns="events"
/>
<UsageBreakdownSection
title={translate('auto.components.stats.CodexUsagePane.b98718aaab', 'By project')}
topLabel={translate('auto.components.stats.CodexUsagePane.829ee743f2', 'Top project:')}
topValue={summary?.topProject}
rows={projectBreakdown.map((row) => ({
key: row.key,
label: row.label,
tokens: row.totalTokens,
sessions: row.sessions,
eventsOrTurns: row.events
}))}
eventsOrTurns="events"
/>
</div>
<CodexUsageRecentSessionsTable recentSessions={recentSessions} />
</>
)
}
@@ -23,11 +23,9 @@ import {
} from '../ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
import { CodexUsageDetails } from './CodexUsageDetails'
import { ShareUsageButton } from './ShareUsageButton'
import { CodexUsageRecentSessionsTable } from './CodexUsageRecentSessionsTable'
import { StatCard } from './StatCard'
import { UsageBreakdownSection } from './UsageBreakdownSection'
import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters'
import { translate } from '@/i18n/i18n'
@@ -35,18 +33,30 @@ const RANGE_OPTIONS: CodexUsageRange[] = ['7d', '30d', '90d', 'all']
const SCOPE_OPTIONS: { value: CodexUsageScope; label: string }[] = [
{
value: 'orca',
label: translate('auto.components.stats.CodexUsagePane.201766b754', 'Orca worktrees only')
get label() {
return translate('auto.components.stats.CodexUsagePane.201766b754', 'Orca worktrees only')
}
},
{
value: 'all',
label: translate('auto.components.stats.CodexUsagePane.4fe8820098', 'All local Codex usage')
get label() {
return translate('auto.components.stats.CodexUsagePane.4fe8820098', 'All local Codex usage')
}
}
]
const RANGE_LABELS: Record<CodexUsageRange, string> = {
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
all: 'All time'
get '7d'() {
return translate('auto.components.stats.CodexUsagePane.rangeLast7Days', 'Last 7 days')
},
get '30d'() {
return translate('auto.components.stats.CodexUsagePane.rangeLast30Days', 'Last 30 days')
},
get '90d'() {
return translate('auto.components.stats.CodexUsagePane.rangeLast90Days', 'Last 90 days')
},
get all() {
return translate('auto.components.stats.CodexUsagePane.rangeAllTime', 'All time')
}
}
export function CodexUsagePane(): React.JSX.Element {
@@ -293,42 +303,13 @@ export function CodexUsagePane(): React.JSX.Element {
)}
</p>
<CodexUsageDailyChart daily={daily} />
<div className="grid gap-4 xl:grid-cols-2">
<UsageBreakdownSection
title={translate('auto.components.stats.CodexUsagePane.5a0d1d69cd', 'By model')}
topLabel={translate('auto.components.stats.CodexUsagePane.95d2d89285', 'Top model:')}
topValue={summary?.topModel}
rows={modelBreakdown.map((row) => ({
key: row.key,
label: row.label,
tokens: row.totalTokens,
sessions: row.sessions,
eventsOrTurns: row.events,
hasInferredPricing: row.hasInferredPricing
}))}
eventsOrTurns="events"
/>
<UsageBreakdownSection
title={translate('auto.components.stats.CodexUsagePane.b98718aaab', 'By project')}
topLabel={translate(
'auto.components.stats.CodexUsagePane.829ee743f2',
'Top project:'
)}
topValue={summary?.topProject}
rows={projectBreakdown.map((row) => ({
key: row.key,
label: row.label,
tokens: row.totalTokens,
sessions: row.sessions,
eventsOrTurns: row.events
}))}
eventsOrTurns="events"
/>
</div>
<CodexUsageRecentSessionsTable recentSessions={recentSessions} />
<CodexUsageDetails
daily={daily}
modelBreakdown={modelBreakdown}
projectBreakdown={projectBreakdown}
recentSessions={recentSessions}
summary={summary}
/>
</>
)}
</div>
@@ -0,0 +1,64 @@
import type {
OpenCodeUsageBreakdownRow,
OpenCodeUsageDailyPoint,
OpenCodeUsageSessionRow,
OpenCodeUsageSummary
} from '../../../../shared/opencode-usage-types'
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
import { OpenCodeUsageRecentSessionsTable } from './OpenCodeUsageRecentSessionsTable'
import { UsageBreakdownSection } from './UsageBreakdownSection'
import { translate } from '@/i18n/i18n'
type OpenCodeUsageDetailsProps = {
daily: OpenCodeUsageDailyPoint[]
modelBreakdown: OpenCodeUsageBreakdownRow[]
projectBreakdown: OpenCodeUsageBreakdownRow[]
recentSessions: OpenCodeUsageSessionRow[]
summary: OpenCodeUsageSummary | null | undefined
}
export function OpenCodeUsageDetails({
daily,
modelBreakdown,
projectBreakdown,
recentSessions,
summary
}: OpenCodeUsageDetailsProps): React.JSX.Element {
return (
<>
<CodexUsageDailyChart daily={daily} />
<div className="grid gap-4 xl:grid-cols-2">
<UsageBreakdownSection
title={translate('auto.components.stats.OpenCodeUsagePane.040c044d39', 'By model')}
topLabel={translate('auto.components.stats.OpenCodeUsagePane.a15206a63a', 'Top model:')}
topValue={summary?.topModel}
rows={modelBreakdown.map((row) => ({
key: row.key,
label: row.label,
tokens: row.totalTokens,
sessions: row.sessions,
eventsOrTurns: row.events,
estimatedCostUsd: row.estimatedCostUsd
}))}
eventsOrTurns="events"
/>
<UsageBreakdownSection
title={translate('auto.components.stats.OpenCodeUsagePane.0f0a1684bb', 'By project')}
topLabel={translate('auto.components.stats.OpenCodeUsagePane.048ffe4d65', 'Top project:')}
topValue={summary?.topProject}
rows={projectBreakdown.map((row) => ({
key: row.key,
label: row.label,
tokens: row.totalTokens,
sessions: row.sessions,
eventsOrTurns: row.events
}))}
eventsOrTurns="events"
/>
</div>
<OpenCodeUsageRecentSessionsTable recentSessions={recentSessions} />
</>
)
}
@@ -26,10 +26,8 @@ import {
} from '../ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
import { OpenCodeUsageRecentSessionsTable } from './OpenCodeUsageRecentSessionsTable'
import { OpenCodeUsageDetails } from './OpenCodeUsageDetails'
import { StatCard } from './StatCard'
import { UsageBreakdownSection } from './UsageBreakdownSection'
import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters'
import { translate } from '@/i18n/i18n'
@@ -37,21 +35,33 @@ const RANGE_OPTIONS: OpenCodeUsageRange[] = ['7d', '30d', '90d', 'all']
const SCOPE_OPTIONS: { value: OpenCodeUsageScope; label: string }[] = [
{
value: 'orca',
label: translate('auto.components.stats.OpenCodeUsagePane.e04c58327c', 'Orca worktrees only')
get label() {
return translate('auto.components.stats.OpenCodeUsagePane.e04c58327c', 'Orca worktrees only')
}
},
{
value: 'all',
label: translate(
'auto.components.stats.OpenCodeUsagePane.144a6050e9',
'All local OpenCode usage'
)
get label() {
return translate(
'auto.components.stats.OpenCodeUsagePane.144a6050e9',
'All local OpenCode usage'
)
}
}
]
const RANGE_LABELS: Record<OpenCodeUsageRange, string> = {
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
all: 'All time'
get '7d'() {
return translate('auto.components.stats.OpenCodeUsagePane.rangeLast7Days', 'Last 7 days')
},
get '30d'() {
return translate('auto.components.stats.OpenCodeUsagePane.rangeLast30Days', 'Last 30 days')
},
get '90d'() {
return translate('auto.components.stats.OpenCodeUsagePane.rangeLast90Days', 'Last 90 days')
},
get all() {
return translate('auto.components.stats.OpenCodeUsagePane.rangeAllTime', 'All time')
}
}
export function OpenCodeUsagePane(): React.JSX.Element {
@@ -313,45 +323,13 @@ export function OpenCodeUsagePane(): React.JSX.Element {
)}
</p>
<CodexUsageDailyChart daily={daily} />
<div className="grid gap-4 xl:grid-cols-2">
<UsageBreakdownSection
title={translate('auto.components.stats.OpenCodeUsagePane.040c044d39', 'By model')}
topLabel={translate(
'auto.components.stats.OpenCodeUsagePane.a15206a63a',
'Top model:'
)}
topValue={summary?.topModel}
rows={modelBreakdown.map((row) => ({
key: row.key,
label: row.label,
tokens: row.totalTokens,
sessions: row.sessions,
eventsOrTurns: row.events,
estimatedCostUsd: row.estimatedCostUsd
}))}
eventsOrTurns="events"
/>
<UsageBreakdownSection
title={translate('auto.components.stats.OpenCodeUsagePane.0f0a1684bb', 'By project')}
topLabel={translate(
'auto.components.stats.OpenCodeUsagePane.048ffe4d65',
'Top project:'
)}
topValue={summary?.topProject}
rows={projectBreakdown.map((row) => ({
key: row.key,
label: row.label,
tokens: row.totalTokens,
sessions: row.sessions,
eventsOrTurns: row.events
}))}
eventsOrTurns="events"
/>
</div>
<OpenCodeUsageRecentSessionsTable recentSessions={recentSessions} />
<OpenCodeUsageDetails
daily={daily}
modelBreakdown={modelBreakdown}
projectBreakdown={projectBreakdown}
recentSessions={recentSessions}
summary={summary}
/>
</>
)}
</div>
@@ -48,10 +48,30 @@ function formatTrackingSince(timestamp: number | null): string {
type UsageTab = 'overview' | 'claude' | 'codex' | 'opencode'
const USAGE_ANALYTICS_OPTIONS = [
{ id: 'overview', label: translate('auto.components.stats.StatsPane.b2cf4310ce', 'Overview') },
{ id: 'claude', label: translate('auto.components.stats.StatsPane.85457c02fe', 'Claude') },
{ id: 'codex', label: translate('auto.components.stats.StatsPane.7d26110cea', 'Codex') },
{ id: 'opencode', label: translate('auto.components.stats.StatsPane.1e696db2f6', 'OpenCode') }
{
id: 'overview',
get label() {
return translate('auto.components.stats.StatsPane.b2cf4310ce', 'Overview')
}
},
{
id: 'claude',
get label() {
return translate('auto.components.stats.StatsPane.85457c02fe', 'Claude')
}
},
{
id: 'codex',
get label() {
return translate('auto.components.stats.StatsPane.7d26110cea', 'Codex')
}
},
{
id: 'opencode',
get label() {
return translate('auto.components.stats.StatsPane.1e696db2f6', 'OpenCode')
}
}
] as const satisfies readonly { id: UsageTab; label: string }[]
function UsageAnalyticsOptionIcon({ tab }: { tab: UsageTab }): React.JSX.Element {
@@ -11,43 +11,63 @@ import { translate } from '@/i18n/i18n'
const TAB_COLORS = [
{
label: translate('auto.components.tab.bar.SortableTabContextMenu.20baa43c05', 'None'),
get label() {
return translate('auto.components.tab.bar.SortableTabContextMenu.20baa43c05', 'None')
},
value: null
},
{
label: translate('auto.components.tab.bar.SortableTabContextMenu.cb3eadefd2', 'Blue'),
get label() {
return translate('auto.components.tab.bar.SortableTabContextMenu.cb3eadefd2', 'Blue')
},
value: '#3b82f6'
},
{
label: translate('auto.components.tab.bar.SortableTabContextMenu.c2d8b0991f', 'Purple'),
get label() {
return translate('auto.components.tab.bar.SortableTabContextMenu.c2d8b0991f', 'Purple')
},
value: '#a855f7'
},
{
label: translate('auto.components.tab.bar.SortableTabContextMenu.03cf6dab1a', 'Pink'),
get label() {
return translate('auto.components.tab.bar.SortableTabContextMenu.03cf6dab1a', 'Pink')
},
value: '#ec4899'
},
{
label: translate('auto.components.tab.bar.SortableTabContextMenu.620aec6729', 'Red'),
get label() {
return translate('auto.components.tab.bar.SortableTabContextMenu.620aec6729', 'Red')
},
value: '#ef4444'
},
{
label: translate('auto.components.tab.bar.SortableTabContextMenu.a47629b3cf', 'Orange'),
get label() {
return translate('auto.components.tab.bar.SortableTabContextMenu.a47629b3cf', 'Orange')
},
value: '#f97316'
},
{
label: translate('auto.components.tab.bar.SortableTabContextMenu.69682e2ce4', 'Yellow'),
get label() {
return translate('auto.components.tab.bar.SortableTabContextMenu.69682e2ce4', 'Yellow')
},
value: '#eab308'
},
{
label: translate('auto.components.tab.bar.SortableTabContextMenu.be905e9b0a', 'Green'),
get label() {
return translate('auto.components.tab.bar.SortableTabContextMenu.be905e9b0a', 'Green')
},
value: '#22c55e'
},
{
label: translate('auto.components.tab.bar.SortableTabContextMenu.845576bed1', 'Teal'),
get label() {
return translate('auto.components.tab.bar.SortableTabContextMenu.845576bed1', 'Teal')
},
value: '#14b8a6'
},
{
label: translate('auto.components.tab.bar.SortableTabContextMenu.7703990447', 'Gray'),
get label() {
return translate('auto.components.tab.bar.SortableTabContextMenu.7703990447', 'Gray')
},
value: '#9ca3af'
}
] as const
+124 -46
View File
@@ -464,39 +464,32 @@
}
}
},
"folder": {
"workspace": {
"path": {
"status": {
"title": {
"missing": "Folder not found",
"not": {
"directory": "Path is not a folder"
},
"ambiguous": {
"connection": "Cannot determine connection"
},
"unavailable": "Cannot check folder"
},
"description": {
"missing": "Orca cannot find {{value0}}. Remove and re-import this folder workspace.",
"not": {
"directory": "{{value0}} exists, but it is not a folder."
},
"ambiguous": {
"connection": "Orca cannot tell which SSH connection owns this folder scope."
},
"unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again."
},
"create": {
"description": {
"missing": "Orca cannot find {{value0}}. Remove and re-import the folder."
},
"title": {
"failed": "Failed to create folder workspace"
}
}
}
"folderWorkspacePathStatus": {
"title": {
"missing": "Folder not found",
"notDirectory": "Path is not a folder",
"ambiguousConnection": "Cannot determine connection",
"unavailable": "Cannot check folder"
},
"description": {
"missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.",
"notDirectory": "{{path}} exists, but it is not a folder.",
"ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.",
"unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again."
},
"createError": {
"title": {
"missing": "Folder not found",
"notDirectory": "Path is not a folder",
"ambiguousConnection": "Cannot determine connection",
"unavailable": "Cannot check folder",
"generic": "Failed to create folder workspace"
},
"description": {
"missing": "Orca cannot find {{path}}. Remove and re-import the folder.",
"notDirectory": "{{path}} exists, but it is not a folder.",
"ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.",
"unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again."
}
}
}
@@ -1036,7 +1029,15 @@
"f660aa1454": "Connecting",
"7711ad5122": "Local setup command",
"e5db1b0419": "Combined setup command",
"addProjectBeforeWorkspace": "Add a project before creating a workspace."
"addProjectBeforeWorkspace": "Add a project before creating a workspace.",
"sshNotConnected": "SSH not connected",
"connectingSsh": "Connecting SSH...",
"sshAuthenticationFailed": "SSH authentication failed",
"preparingSshConnection": "Preparing SSH connection...",
"connected": "Connected",
"reconnectingSsh": "Reconnecting SSH...",
"sshReconnectionFailed": "SSH reconnection failed",
"notConnected": "Not connected"
},
"NewWorkspaceComposerModal": {
"fa90f739a5": "Choose the project, workspace name, and agent before creating the workspace."
@@ -1811,7 +1812,11 @@
"015b4e607d": "Cancel",
"e3bd59143c": "Insert",
"f24783f470": "https://...",
"ec6310b731": "Use an http:// or https:// image URL."
"ec6310b731": "Use an http:// or https:// image URL.",
"b7e4a1c902": "Paste, drop, or click to add files",
"8f1c2d4e6a": "Nothing to preview",
"c91f0a2b14": "Write",
"d82b1e3f05": "Preview"
},
"IssueSourceSelector": {
"d6aeb2012b": "Showing issues from",
@@ -1885,6 +1890,47 @@
}
}
}
},
"CloseReasonDropdown": {
"e1f2a3b4c5": "Choose close reason"
},
"GitHubIssueCommentComposer": {
"082515176a": "Failed to add comment",
"9f88657c4e": "Issue closed",
"e9b7cb7d17": "Failed to close issue",
"bd3b4492a0": "Issue reopened",
"f2a8c1d903": "Failed to reopen issue",
"a1b2c3d4e5": "Add a comment",
"c5c117270e": "Add your comment here, be kind",
"f6a7b8c9d0": "Close issue",
"b1c2d3e4f5": "Reopen issue",
"0a73f59e85": "Send comment",
"bf43425540": "Comment"
},
"GitHubWorkItemAssigneePopoverContent": {
"cddd9b04a7": "Loading assignees",
"a00830d3f7": "No users",
"4f8b6f2c1d": "Filter assignees..."
},
"GitHubWorkItemLabelPopoverContent": {
"2aa9acdf34": "Edit labels on GitHub",
"cddd9b04a7": "Loading labels",
"de26e2eb06": "No labels",
"8b0d52ee3a": "Filter labels..."
},
"githubIssueCloseReasons": {
"completed": {
"label": "Close as completed",
"description": "Done, closed, fixed, resolved"
},
"notPlanned": {
"label": "Close as not planned",
"description": "Won't fix, can't repro, stale"
},
"duplicate": {
"label": "Close as duplicate",
"description": "Duplicate of another issue"
}
}
},
"linear": {
@@ -2787,7 +2833,11 @@
"4f8368c272": "Orca worktrees only",
"cfe2282ffa": "Unknown",
"7765a4c3e1": "n/a",
"2d41fd45c6": " • Last scan error: {{value0}}"
"2d41fd45c6": " • Last scan error: {{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"CodexUsageDailyChart": {
"1e6f62d7e3": "Reasoning",
@@ -2836,7 +2886,11 @@
"bf6cf2d4dd": "Unknown",
"ae255c3dba": "n/a",
"247c93ca92": "• inferred pricing",
"8a6655f7a2": " • Last scan error: {{value0}}"
"8a6655f7a2": " • Last scan error: {{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"OpenCodeUsagePane": {
"349f7c3f5c": "Total",
@@ -2875,7 +2929,11 @@
"e04c58327c": "Orca worktrees only",
"362231082f": "Unknown",
"8095a63426": "n/a",
"6cc7782458": " • Last scan error: {{value0}}"
"6cc7782458": " • Last scan error: {{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"ShareUsageButton": {
"7d6b25323d": "Share on X",
@@ -3114,7 +3172,15 @@
"2c12db1511": "Project already added",
"875dda0995": "Enter a server parent path.",
"45b7c26034": "Create project",
"85085d74d2": "Creating…"
"85085d74d2": "Creating…",
"createProjectTitle": "Create project",
"createProjectDescription": "Create a local Git repo and first workspace.",
"projectNameLabel": "Project name",
"createsGitRepoHelp": "Git repo:",
"parentFolderLabel": "Parent folder",
"browseParentFolder": "Browse",
"gitRequiredError": "Git is required to create a project.",
"createAction": "Create"
},
"AddRepoNestedImportStep": {
"496f68cf8c": "Scanning repositories. Click to stop.",
@@ -3491,7 +3557,11 @@
"376bed88e5": "The connection to the remote host encountered an error.",
"4afcca1d24": "Reconnect",
"11552bf786": "SSH Disconnected",
"cb5938ae79": "Reconnecting..."
"cb5938ae79": "Reconnecting...",
"disconnected": "This remote repository is not connected.",
"reconnecting": "Reconnecting to the remote host...",
"reconnectionFailed": "Reconnection to the remote host failed.",
"authFailed": "Authentication to the remote host failed."
},
"SshTargetRow": {
"4677394048": "Connecting…",
@@ -3715,7 +3785,9 @@
"a6c20dca96": "Open a project from an SSH target",
"3d162cc76f": "Remote project",
"fb4fc5380e": "Local project, Git repo, or folder with many repos",
"2281fdc8c7": "Browse folder"
"2281fdc8c7": "Browse folder",
"createProjectTitle": "Create project",
"createGitProjectDescription": "Create a local Git repository"
}
}
}
@@ -7527,7 +7599,6 @@
"5788d1059d": "Could not update review thread. Check the GitHub API budget.",
"07871c0589": "Link another PR",
"7202f4a40a": "unlink PR",
"a9d7c128e4": "unlink {{value0}}",
"7f4489f370": "Refresh",
"5c88c6db07": "Open on {{value0}}",
"7fad8509fe": "Fix With AI",
@@ -7542,14 +7613,15 @@
"e56c42122e": "destructive",
"786e3c143f": "Delete",
"653c105ecc": "More PR actions",
"b4f3ec62a1": "More {{value0}} actions",
"f316a8ca2b": "No unresolved comments selected.",
"d00ebdc402": "Resolve {{value0}} Comments With AI",
"ed3f79c031": "Review the prompt before starting an agent. Selected threads are marked resolved after launch.",
"f273f2271c": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.",
"aa95b81a3a": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.",
"495b2f8c4b": "Started the agent, but could not mark the selected comments resolved.",
"3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host."
"3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host.",
"b4f3ec62a1": "More {{value0}} actions",
"a9d7c128e4": "unlink {{value0}}"
},
"CreatePullRequestDialog": {
"2bc1b4345e": "Cancel",
@@ -8933,7 +9005,11 @@
"3c4adfd821": "fix login race condition",
"56a0271428": "Isolated workspaces",
"ef737dcee1": "GitHub & Linear tasks",
"ac51c061e2": "codex"
"ac51c061e2": "codex",
"47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.",
"70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.",
"f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.",
"5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents."
},
"FeatureWallBody": {
"25ec5356d6": "Setup"
@@ -9006,7 +9082,9 @@
"6e3f5223c5": "Explorer",
"ab2901bce6": "Checks",
"d7f80060ca": "Source Control",
"8e715588e4": "Search"
"8e715588e4": "Search",
"a6c8b9e32f": "Checks passed",
"f4d5e1a7b2": "3 checks"
},
"ReviewShipAnimatedVisual": {
"4d99496b8c": "Create PR",
+134 -56
View File
@@ -464,39 +464,32 @@
}
}
},
"folder": {
"workspace": {
"path": {
"status": {
"title": {
"missing": "Carpeta no encontrada",
"not": {
"directory": "La ruta no es una carpeta"
},
"ambiguous": {
"connection": "No se puede determinar la conexión"
},
"unavailable": "No se puede comprobar la carpeta"
},
"description": {
"missing": "Orca no puede encontrar {{value0}}. Elimina y vuelve a importar este espacio de trabajo de carpeta.",
"not": {
"directory": "{{value0}} existe, pero no es una carpeta."
},
"ambiguous": {
"connection": "Orca no puede determinar qué conexión SSH pertenece a este ámbito de carpeta."
},
"unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el runtime o la conexión SSH e inténtalo de nuevo."
},
"create": {
"description": {
"missing": "Orca no puede encontrar {{value0}}. Elimina y vuelve a importar la carpeta."
},
"title": {
"failed": "No se pudo crear el espacio de trabajo de carpeta"
}
}
}
"folderWorkspacePathStatus": {
"title": {
"missing": "Carpeta no encontrada",
"notDirectory": "La ruta no es una carpeta",
"ambiguousConnection": "No se puede determinar la conexión",
"unavailable": "No se puede comprobar la carpeta"
},
"description": {
"missing": "Orca no puede encontrar {{path}}. Elimina y vuelve a importar este espacio de trabajo de carpeta.",
"notDirectory": "{{path}} existe, pero no es una carpeta.",
"ambiguousConnection": "Orca no puede determinar qué conexión SSH pertenece a este ámbito de carpeta.",
"unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el runtime o la conexión SSH e inténtalo de nuevo."
},
"createError": {
"title": {
"missing": "Carpeta no encontrada",
"notDirectory": "La ruta no es una carpeta",
"ambiguousConnection": "No se puede determinar la conexión",
"unavailable": "No se puede comprobar la carpeta",
"generic": "No se pudo crear el espacio de trabajo de carpeta"
},
"description": {
"missing": "Orca no puede encontrar {{path}}. Elimina y vuelve a importar la carpeta.",
"notDirectory": "{{path}} existe, pero no es una carpeta.",
"ambiguousConnection": "Orca no puede determinar qué conexión SSH pertenece a este ámbito de carpeta.",
"unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el runtime o la conexión SSH e inténtalo de nuevo."
}
}
}
@@ -1036,7 +1029,15 @@
"f660aa1454": "Conectando",
"7711ad5122": "Comando de configuración local",
"e5db1b0419": "Comando de configuración combinado",
"addProjectBeforeWorkspace": "Agregue un proyecto antes de crear un espacio de trabajo."
"addProjectBeforeWorkspace": "Agregue un proyecto antes de crear un espacio de trabajo.",
"sshNotConnected": "SSH not connected",
"connectingSsh": "Connecting SSH...",
"sshAuthenticationFailed": "SSH authentication failed",
"preparingSshConnection": "Preparing SSH connection...",
"connected": "Connected",
"reconnectingSsh": "Reconnecting SSH...",
"sshReconnectionFailed": "SSH reconnection failed",
"notConnected": "Not connected"
},
"NewWorkspaceComposerModal": {
"fa90f739a5": "Elija el proyecto, el nombre del espacio de trabajo y el agente antes de crear el espacio de trabajo."
@@ -1811,7 +1812,11 @@
"015b4e607d": "Cancelar",
"e3bd59143c": "Insertar",
"f24783f470": "https://...",
"ec6310b731": "Utilice una URL de imagen http:// o https://."
"ec6310b731": "Utilice una URL de imagen http:// o https://.",
"b7e4a1c902": "Paste, drop, or click to add files",
"8f1c2d4e6a": "Nothing to preview",
"c91f0a2b14": "Write",
"d82b1e3f05": "Preview"
},
"IssueSourceSelector": {
"d6aeb2012b": "Mostrando problemas de",
@@ -1885,6 +1890,47 @@
}
}
}
},
"CloseReasonDropdown": {
"e1f2a3b4c5": "Choose close reason"
},
"GitHubIssueCommentComposer": {
"082515176a": "Failed to add comment",
"9f88657c4e": "Issue closed",
"e9b7cb7d17": "Failed to close issue",
"bd3b4492a0": "Issue reopened",
"f2a8c1d903": "Failed to reopen issue",
"a1b2c3d4e5": "Add a comment",
"c5c117270e": "Add your comment here, be kind",
"f6a7b8c9d0": "Close issue",
"b1c2d3e4f5": "Reopen issue",
"0a73f59e85": "Send comment",
"bf43425540": "Comment"
},
"GitHubWorkItemAssigneePopoverContent": {
"cddd9b04a7": "Loading assignees",
"a00830d3f7": "No users",
"4f8b6f2c1d": "Filter assignees..."
},
"GitHubWorkItemLabelPopoverContent": {
"2aa9acdf34": "Edit labels on GitHub",
"cddd9b04a7": "Loading labels",
"de26e2eb06": "No labels",
"8b0d52ee3a": "Filter labels..."
},
"githubIssueCloseReasons": {
"completed": {
"label": "Close as completed",
"description": "Done, closed, fixed, resolved"
},
"notPlanned": {
"label": "Close as not planned",
"description": "Won't fix, can't repro, stale"
},
"duplicate": {
"label": "Close as duplicate",
"description": "Duplicate of another issue"
}
}
},
"linear": {
@@ -2787,7 +2833,11 @@
"4f8368c272": "Solo árboles de trabajo Orca",
"cfe2282ffa": "Desconocido",
"7765a4c3e1": "n / A",
"2d41fd45c6": "• Error del último análisis: {{value0}}"
"2d41fd45c6": "• Error del último análisis: {{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"CodexUsageDailyChart": {
"1e6f62d7e3": "Razonamiento",
@@ -2836,7 +2886,11 @@
"bf6cf2d4dd": "Desconocido",
"ae255c3dba": "n / A",
"247c93ca92": "• precios inferidos",
"8a6655f7a2": "• Error del último análisis: {{value0}}"
"8a6655f7a2": "• Error del último análisis: {{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"OpenCodeUsagePane": {
"349f7c3f5c": "Total",
@@ -2875,7 +2929,11 @@
"e04c58327c": "Solo árboles de trabajo Orca",
"362231082f": "Desconocido",
"8095a63426": "n / A",
"6cc7782458": "• Error del último análisis: {{value0}}"
"6cc7782458": "• Error del último análisis: {{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"ShareUsageButton": {
"7d6b25323d": "Compartir en X",
@@ -2996,20 +3054,20 @@
}
},
"UsageBreakdownSection": {
"7765a4c3e1": "n / A",
"247c93ca92": "• precios inferidos"
"7765a4c3e1": "n/a",
"247c93ca92": "• inferred pricing"
},
"UsageSessionsTable": {
"1afc25eb06": "vueltas",
"0f03975d59": "Eventos",
"1afc25eb06": "Turns",
"0f03975d59": "Events",
"21ea00bfa8": "Cache",
"e0b988599d": "Total",
"01476891c7": "Último activo",
"c17bed0416": "Proyecto",
"f6a2c8d019": "Modelo",
"faf3444859": "Aporte",
"a8b7487ff7": "Producción",
"cfe2282ffa": "Desconocido"
"01476891c7": "Last active",
"c17bed0416": "Project",
"f6a2c8d019": "Model",
"faf3444859": "Input",
"a8b7487ff7": "Output",
"cfe2282ffa": "Unknown"
}
},
"sparse": {
@@ -3114,7 +3172,15 @@
"fe1e616c5b": "Git no está instalado, así que una carpeta simple será el valor predeterminado.",
"c234df77f7": "Elige o introduce una carpeta principal del servidor antes de crear.",
"3a13f6e88b": "ubicación no seleccionada",
"6ed14c0281": "carpeta del servidor no seleccionada"
"6ed14c0281": "carpeta del servidor no seleccionada",
"createProjectTitle": "Crear proyecto",
"createProjectDescription": "Crea un repositorio Git local y el primer espacio de trabajo.",
"projectNameLabel": "Nombre del proyecto",
"createsGitRepoHelp": "Repositorio Git:",
"parentFolderLabel": "Carpeta principal",
"browseParentFolder": "Explorar",
"gitRequiredError": "Git es necesario para crear un proyecto.",
"createAction": "Crear"
},
"AddRepoNestedImportStep": {
"496f68cf8c": "Escaneo de repositorios. Haga clic para detener.",
@@ -3491,7 +3557,11 @@
"376bed88e5": "La conexión al host remoto encontró un error.",
"4afcca1d24": "Reconectar",
"11552bf786": "SSH desconectado",
"cb5938ae79": "Reconectando..."
"cb5938ae79": "Reconectando...",
"disconnected": "This remote repository is not connected.",
"reconnecting": "Reconnecting to the remote host...",
"reconnectionFailed": "Reconnection to the remote host failed.",
"authFailed": "Authentication to the remote host failed."
},
"SshTargetRow": {
"4677394048": "Conectando…",
@@ -3715,7 +3785,9 @@
"a6c20dca96": "Abrir un proyecto desde un destino SSH",
"3d162cc76f": "Proyecto remoto",
"fb4fc5380e": "Proyecto local, repo de Git o carpeta con muchos repos",
"2281fdc8c7": "Explorar carpeta"
"2281fdc8c7": "Explorar carpeta",
"createProjectTitle": "Crear proyecto",
"createGitProjectDescription": "Crear un repositorio Git local"
}
}
}
@@ -7527,7 +7599,6 @@
"5788d1059d": "No se pudo actualizar el hilo de revisión. Consulta el presupuesto de la API de GitHub.",
"07871c0589": "Vincular otro PR",
"7202f4a40a": "desvincular relaciones públicas",
"a9d7c128e4": "desvincular {{value0}}",
"7f4489f370": "Refrescar",
"5c88c6db07": "Abierto el {{value0}}",
"7fad8509fe": "Arreglar con IA",
@@ -7542,14 +7613,15 @@
"e56c42122e": "destructivo",
"786e3c143f": "Borrar",
"653c105ecc": "Más acciones de relaciones públicas",
"b4f3ec62a1": "Más acciones de {{value0}}",
"f316a8ca2b": "No hay comentarios sin resolver seleccionados.",
"d00ebdc402": "Resolver comentarios de {{value0}} con IA",
"ed3f79c031": "Revisa el prompt antes de iniciar un agente. Los hilos seleccionados se marcan como resueltos después del inicio.",
"f273f2271c": "Agente iniciado. Marcados {{value0}} como resueltos, omitidos {{value1}}, con error {{value2}}.",
"aa95b81a3a": "Agente iniciado. Marcados {{value0}} como resueltos, omitidos {{value1}}, con error {{value2}}.",
"495b2f8c4b": "Agente iniciado, pero no se pudieron marcar los comentarios seleccionados como resueltos.",
"3c3ad3a1d2": "Agente iniciado. Ningún comentario seleccionado se puede marcar como resuelto en el host."
"3c3ad3a1d2": "Agente iniciado. Ningún comentario seleccionado se puede marcar como resuelto en el host.",
"b4f3ec62a1": "More {{value0}} actions",
"a9d7c128e4": "unlink {{value0}}"
},
"CreatePullRequestDialog": {
"2bc1b4345e": "Cancelar",
@@ -8933,7 +9005,11 @@
"3c4adfd821": "arreglar la condición de carrera de inicio de sesión",
"56a0271428": "Espacios de trabajo aislados",
"ef737dcee1": "GitHub y tareas Lineares",
"ac51c061e2": "codex"
"ac51c061e2": "codex",
"47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.",
"70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.",
"f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.",
"5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents."
},
"FeatureWallBody": {
"25ec5356d6": "Configuración"
@@ -9006,7 +9082,9 @@
"6e3f5223c5": "Explorador",
"ab2901bce6": "cheques",
"d7f80060ca": "Control de fuente",
"8e715588e4": "Buscar"
"8e715588e4": "Buscar",
"a6c8b9e32f": "Checks passed",
"f4d5e1a7b2": "3 checks"
},
"ReviewShipAnimatedVisual": {
"4d99496b8c": "Crear relaciones públicas",
+136 -58
View File
@@ -464,39 +464,32 @@
}
}
},
"folder": {
"workspace": {
"path": {
"status": {
"title": {
"missing": "フォルダーが見つかりません",
"not": {
"directory": "パスはフォルダーではありません"
},
"ambiguous": {
"connection": "接続を特定できません"
},
"unavailable": "フォルダーを確認できません"
},
"description": {
"missing": "Orca は {{value0}} を見つけられません。このフォルダーワークスペースを削除して再インポートしてください。",
"not": {
"directory": "{{value0}} は存在しますが、フォルダーではありません。"
},
"ambiguous": {
"connection": "Orca はこのフォルダースコープをどの SSH 接続が所有しているか判別できません。"
},
"unavailable": "Orca は現在このフォルダーを確認できません。ランタイムまたは SSH 接続を確認して再試行してください。"
},
"create": {
"description": {
"missing": "Orca は {{value0}} を見つけられません。フォルダーを削除して再インポートしてください。"
},
"title": {
"failed": "フォルダーワークスペースの作成に失敗しました"
}
}
}
"folderWorkspacePathStatus": {
"title": {
"missing": "フォルダーが見つかりません",
"notDirectory": "パスはフォルダーではありません",
"ambiguousConnection": "接続を特定できません",
"unavailable": "フォルダーを確認できません"
},
"description": {
"missing": "Orca は {{path}} を見つけられません。このフォルダーワークスペースを削除して再インポートしてください。",
"notDirectory": "{{path}} は存在しますが、フォルダーではありません。",
"ambiguousConnection": "Orca はこのフォルダースコープをどの SSH 接続が所有しているか判別できません。",
"unavailable": "Orca は現在このフォルダーを確認できません。ランタイムまたは SSH 接続を確認して再試行してください。"
},
"createError": {
"title": {
"missing": "フォルダーが見つかりません",
"notDirectory": "パスはフォルダーではありません",
"ambiguousConnection": "接続を特定できません",
"unavailable": "フォルダーを確認できません",
"generic": "フォルダーワークスペースの作成に失敗しました"
},
"description": {
"missing": "Orca は {{path}} を見つけられません。フォルダーを削除して再インポートしてください。",
"notDirectory": "{{path}} は存在しますが、フォルダーではありません。",
"ambiguousConnection": "Orca はこのフォルダースコープをどの SSH 接続が所有しているか判別できません。",
"unavailable": "Orca は現在このフォルダーを確認できません。ランタイムまたは SSH 接続を確認して再試行してください。"
}
}
}
@@ -1036,7 +1029,15 @@
"f660aa1454": "接続中",
"7711ad5122": "ローカルセットアップコマンド",
"e5db1b0419": "組み合わせセットアップコマンド",
"addProjectBeforeWorkspace": "ワークスペースを作成する前にプロジェクトを追加します。"
"addProjectBeforeWorkspace": "ワークスペースを作成する前にプロジェクトを追加します。",
"sshNotConnected": "SSH not connected",
"connectingSsh": "Connecting SSH...",
"sshAuthenticationFailed": "SSH authentication failed",
"preparingSshConnection": "Preparing SSH connection...",
"connected": "Connected",
"reconnectingSsh": "Reconnecting SSH...",
"sshReconnectionFailed": "SSH reconnection failed",
"notConnected": "Not connected"
},
"NewWorkspaceComposerModal": {
"fa90f739a5": "ワークスペースを作成する前に、プロジェクト、ワークスペース名、および agent を選択します。"
@@ -1811,7 +1812,11 @@
"015b4e607d": "キャンセル",
"e3bd59143c": "入れる",
"f24783f470": "https://...",
"ec6310b731": "http:// または https:// 画像 URL を使用します。"
"ec6310b731": "http:// または https:// 画像 URL を使用します。",
"b7e4a1c902": "Paste, drop, or click to add files",
"8f1c2d4e6a": "Nothing to preview",
"c91f0a2b14": "Write",
"d82b1e3f05": "Preview"
},
"IssueSourceSelector": {
"d6aeb2012b": "からのイシューを表示しています",
@@ -1885,6 +1890,47 @@
}
}
}
},
"CloseReasonDropdown": {
"e1f2a3b4c5": "Choose close reason"
},
"GitHubIssueCommentComposer": {
"082515176a": "Failed to add comment",
"9f88657c4e": "Issue closed",
"e9b7cb7d17": "Failed to close issue",
"bd3b4492a0": "Issue reopened",
"f2a8c1d903": "Failed to reopen issue",
"a1b2c3d4e5": "Add a comment",
"c5c117270e": "Add your comment here, be kind",
"f6a7b8c9d0": "Close issue",
"b1c2d3e4f5": "Reopen issue",
"0a73f59e85": "Send comment",
"bf43425540": "Comment"
},
"GitHubWorkItemAssigneePopoverContent": {
"cddd9b04a7": "Loading assignees",
"a00830d3f7": "No users",
"4f8b6f2c1d": "Filter assignees..."
},
"GitHubWorkItemLabelPopoverContent": {
"2aa9acdf34": "Edit labels on GitHub",
"cddd9b04a7": "Loading labels",
"de26e2eb06": "No labels",
"8b0d52ee3a": "Filter labels..."
},
"githubIssueCloseReasons": {
"completed": {
"label": "Close as completed",
"description": "Done, closed, fixed, resolved"
},
"notPlanned": {
"label": "Close as not planned",
"description": "Won't fix, can't repro, stale"
},
"duplicate": {
"label": "Close as duplicate",
"description": "Duplicate of another issue"
}
}
},
"linear": {
@@ -2787,7 +2833,11 @@
"4f8368c272": "Orca ワークツリーのみ",
"cfe2282ffa": "未知",
"7765a4c3e1": "該当なし",
"2d41fd45c6": "• 最終スキャン エラー: {{value0}}"
"2d41fd45c6": "• 最終スキャン エラー: {{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"CodexUsageDailyChart": {
"1e6f62d7e3": "推論",
@@ -2836,7 +2886,11 @@
"bf6cf2d4dd": "未知",
"ae255c3dba": "該当なし",
"247c93ca92": "• 推定価格",
"8a6655f7a2": "• 最終スキャン エラー: {{value0}}"
"8a6655f7a2": "• 最終スキャン エラー: {{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"OpenCodeUsagePane": {
"349f7c3f5c": "合計",
@@ -2875,7 +2929,11 @@
"e04c58327c": "Orca ワークツリーのみ",
"362231082f": "未知",
"8095a63426": "該当なし",
"6cc7782458": "• 最終スキャン エラー: {{value0}}"
"6cc7782458": "• 最終スキャン エラー: {{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"ShareUsageButton": {
"7d6b25323d": "Xで共有する",
@@ -2996,20 +3054,20 @@
}
},
"UsageBreakdownSection": {
"7765a4c3e1": "該当なし",
"247c93ca92": "• 推定価格"
"7765a4c3e1": "n/a",
"247c93ca92": "• inferred pricing"
},
"UsageSessionsTable": {
"1afc25eb06": "ターン",
"0f03975d59": "イベント",
"21ea00bfa8": "キャッシュ",
"e0b988599d": "合計",
"01476891c7": "最後にアクティブだった",
"c17bed0416": "プロジェクト",
"f6a2c8d019": "モデル",
"faf3444859": "入力",
"a8b7487ff7": "出力",
"cfe2282ffa": "未知"
"1afc25eb06": "Turns",
"0f03975d59": "Events",
"21ea00bfa8": "Cache",
"e0b988599d": "Total",
"01476891c7": "Last active",
"c17bed0416": "Project",
"f6a2c8d019": "Model",
"faf3444859": "Input",
"a8b7487ff7": "Output",
"cfe2282ffa": "Unknown"
}
},
"sparse": {
@@ -3095,7 +3153,15 @@
"fe1e616c5b": "Git がインストールされていないため、通常のフォルダが既定になります。",
"c234df77f7": "作成する前に、サーバーの親フォルダを選択または入力してください。",
"3a13f6e88b": "場所が選択されていません",
"6ed14c0281": "サーバーフォルダが選択されていません"
"6ed14c0281": "サーバーフォルダが選択されていません",
"createProjectTitle": "プロジェクトの作成",
"createProjectDescription": "ローカルの Git リポジトリと最初のワークスペースを作成します。",
"projectNameLabel": "プロジェクト名",
"createsGitRepoHelp": "Git リポジトリ:",
"parentFolderLabel": "親フォルダ",
"browseParentFolder": "参照",
"gitRequiredError": "プロジェクトを作成するには Git が必要です。",
"createAction": "作成"
},
"AddRepoNestedImportStep": {
"496f68cf8c": "リポジトリをスキャンしています。クリックして停止します。",
@@ -3472,7 +3538,11 @@
"376bed88e5": "リモート ホストへの接続でエラーが発生しました。",
"4afcca1d24": "再接続",
"11552bf786": "SSHが切断されました",
"cb5938ae79": "再接続中..."
"cb5938ae79": "再接続中...",
"disconnected": "This remote repository is not connected.",
"reconnecting": "Reconnecting to the remote host...",
"reconnectionFailed": "Reconnection to the remote host failed.",
"authFailed": "Authentication to the remote host failed."
},
"SshTargetRow": {
"4677394048": "接続中…",
@@ -3696,7 +3766,9 @@
"a6c20dca96": "SSH ターゲットからプロジェクトを開く",
"3d162cc76f": "リモートプロジェクト",
"fb4fc5380e": "ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー",
"2281fdc8c7": "フォルダを参照する"
"2281fdc8c7": "フォルダを参照する",
"createProjectTitle": "プロジェクトの作成",
"createGitProjectDescription": "ローカルの Git リポジトリを作成"
}
}
}
@@ -7527,7 +7599,6 @@
"5788d1059d": "レビュー スレッドを更新できませんでした。 GitHub API の予算を確認してください。",
"07871c0589": "別の PR をリンクする",
"7202f4a40a": "PR のリンクを解除する",
"a9d7c128e4": "{{value0}} のリンクを解除する",
"7f4489f370": "更新",
"5c88c6db07": "{{value0}}にオープン",
"7fad8509fe": "AI で修正する",
@@ -7542,14 +7613,15 @@
"e56c42122e": "破壊的な",
"786e3c143f": "削除",
"653c105ecc": "その他の PR 操作",
"b4f3ec62a1": "その他の {{value0}} 操作",
"f316a8ca2b": "未解決のコメントが選択されていません。",
"d00ebdc402": "{{value0}} のコメントを AI で解決",
"ed3f79c031": "agent を開始する前にプロンプトを確認してください。選択したスレッドは開始後に解決済みにマークされます。",
"f273f2271c": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。",
"aa95b81a3a": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。",
"495b2f8c4b": "agent を開始しましたが、選択したコメントを解決済みにできませんでした。",
"3c3ad3a1d2": "agent を開始しました。選択したコメントにホスト上で解決済みにできるものはありません。"
"3c3ad3a1d2": "agent を開始しました。選択したコメントにホスト上で解決済みにできるものはありません。",
"b4f3ec62a1": "More {{value0}} actions",
"a9d7c128e4": "unlink {{value0}}"
},
"CreatePullRequestDialog": {
"2bc1b4345e": "キャンセル",
@@ -8933,7 +9005,11 @@
"3c4adfd821": "ログイン競合状態を修正",
"56a0271428": "隔離されたワークスペース",
"ef737dcee1": "GitHub とLinearタスク",
"ac51c061e2": "codex"
"ac51c061e2": "codex",
"47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.",
"70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.",
"f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.",
"5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents."
},
"FeatureWallBody": {
"25ec5356d6": "設定"
@@ -9006,7 +9082,9 @@
"6e3f5223c5": "エクスプローラ",
"ab2901bce6": "チェック",
"d7f80060ca": "ソース管理",
"8e715588e4": "検索"
"8e715588e4": "検索",
"a6c8b9e32f": "Checks passed",
"f4d5e1a7b2": "3 checks"
},
"ReviewShipAnimatedVisual": {
"4d99496b8c": "PRを作成する",
+136 -58
View File
@@ -464,39 +464,32 @@
}
}
},
"folder": {
"workspace": {
"path": {
"status": {
"title": {
"missing": "폴더를 찾을 수 없습니다",
"not": {
"directory": "경로가 폴더가 아닙니다"
},
"ambiguous": {
"connection": "연결을 확인할 수 없습니다"
},
"unavailable": "폴더를 확인할 수 없습니다"
},
"description": {
"missing": "Orca가 {{value0}}을(를) 찾을 수 없습니다. 이 폴더 워크스페이스를 제거하고 다시 가져오세요.",
"not": {
"directory": "{{value0}}이(가) 존재하지만 폴더가 아닙니다."
},
"ambiguous": {
"connection": "Orca가 이 폴더 범위를 소유한 SSH 연결을 확인할 수 없습니다."
},
"unavailable": "Orca가 지금 이 폴더를 확인할 수 없습니다. 런타임 또는 SSH 연결을 확인한 후 다시 시도하세요."
},
"create": {
"description": {
"missing": "Orca가 {{value0}}을(를) 찾을 수 없습니다. 폴더를 제거하고 다시 가져오세요."
},
"title": {
"failed": "폴더 워크스페이스를 만들지 못했습니다"
}
}
}
"folderWorkspacePathStatus": {
"title": {
"missing": "폴더를 찾을 수 없습니다",
"notDirectory": "경로가 폴더가 아닙니다",
"ambiguousConnection": "연결을 확인할 수 없습니다",
"unavailable": "폴더를 확인할 수 없습니다"
},
"description": {
"missing": "Orca가 {{path}}을(를) 찾을 수 없습니다. 이 폴더 워크스페이스를 제거하고 다시 가져오세요.",
"notDirectory": "{{path}}이(가) 존재하지만 폴더가 아닙니다.",
"ambiguousConnection": "Orca가 이 폴더 범위를 소유한 SSH 연결을 확인할 수 없습니다.",
"unavailable": "Orca가 지금 이 폴더를 확인할 수 없습니다. 런타임 또는 SSH 연결을 확인한 후 다시 시도하세요."
},
"createError": {
"title": {
"missing": "폴더를 찾을 수 없습니다",
"notDirectory": "경로가 폴더가 아닙니다",
"ambiguousConnection": "연결을 확인할 수 없습니다",
"unavailable": "폴더를 확인할 수 없습니다",
"generic": "폴더 워크스페이스를 만들지 못했습니다"
},
"description": {
"missing": "Orca가 {{path}}을(를) 찾을 수 없습니다. 폴더를 제거하고 다시 가져오세요.",
"notDirectory": "{{path}}이(가) 존재하지만 폴더가 아닙니다.",
"ambiguousConnection": "Orca가 이 폴더 범위를 소유한 SSH 연결을 확인할 수 없습니다.",
"unavailable": "Orca가 지금 이 폴더를 확인할 수 없습니다. 런타임 또는 SSH 연결을 확인한 후 다시 시도하세요."
}
}
}
@@ -1036,7 +1029,15 @@
"f660aa1454": "연결 중",
"7711ad5122": "로컬 설정 명령",
"e5db1b0419": "결합된 설정 명령",
"addProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 추가하세요."
"addProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 추가하세요.",
"sshNotConnected": "SSH not connected",
"connectingSsh": "Connecting SSH...",
"sshAuthenticationFailed": "SSH authentication failed",
"preparingSshConnection": "Preparing SSH connection...",
"connected": "Connected",
"reconnectingSsh": "Reconnecting SSH...",
"sshReconnectionFailed": "SSH reconnection failed",
"notConnected": "Not connected"
},
"NewWorkspaceComposerModal": {
"fa90f739a5": "워크스페이스를 생성하기 전에 프로젝트, 워크스페이스 이름, agent를 선택하세요."
@@ -1811,7 +1812,11 @@
"015b4e607d": "취소",
"e3bd59143c": "끼워 넣다",
"f24783f470": "https://...",
"ec6310b731": "http:// 또는 https:// 이미지 URL을 사용하세요."
"ec6310b731": "http:// 또는 https:// 이미지 URL을 사용하세요.",
"b7e4a1c902": "Paste, drop, or click to add files",
"8f1c2d4e6a": "Nothing to preview",
"c91f0a2b14": "Write",
"d82b1e3f05": "Preview"
},
"IssueSourceSelector": {
"d6aeb2012b": "다음에서 이슈 표시",
@@ -1885,6 +1890,47 @@
}
}
}
},
"CloseReasonDropdown": {
"e1f2a3b4c5": "Choose close reason"
},
"GitHubIssueCommentComposer": {
"082515176a": "Failed to add comment",
"9f88657c4e": "Issue closed",
"e9b7cb7d17": "Failed to close issue",
"bd3b4492a0": "Issue reopened",
"f2a8c1d903": "Failed to reopen issue",
"a1b2c3d4e5": "Add a comment",
"c5c117270e": "Add your comment here, be kind",
"f6a7b8c9d0": "Close issue",
"b1c2d3e4f5": "Reopen issue",
"0a73f59e85": "Send comment",
"bf43425540": "Comment"
},
"GitHubWorkItemAssigneePopoverContent": {
"cddd9b04a7": "Loading assignees",
"a00830d3f7": "No users",
"4f8b6f2c1d": "Filter assignees..."
},
"GitHubWorkItemLabelPopoverContent": {
"2aa9acdf34": "Edit labels on GitHub",
"cddd9b04a7": "Loading labels",
"de26e2eb06": "No labels",
"8b0d52ee3a": "Filter labels..."
},
"githubIssueCloseReasons": {
"completed": {
"label": "Close as completed",
"description": "Done, closed, fixed, resolved"
},
"notPlanned": {
"label": "Close as not planned",
"description": "Won't fix, can't repro, stale"
},
"duplicate": {
"label": "Close as duplicate",
"description": "Duplicate of another issue"
}
}
},
"linear": {
@@ -2787,7 +2833,11 @@
"4f8368c272": "Orca 작업 트리만 해당",
"cfe2282ffa": "알려지지 않은",
"7765a4c3e1": "해당 없음",
"2d41fd45c6": "• 마지막 스캔 오류: {{value0}}"
"2d41fd45c6": "• 마지막 스캔 오류: {{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"CodexUsageDailyChart": {
"1e6f62d7e3": "추리",
@@ -2836,7 +2886,11 @@
"bf6cf2d4dd": "알려지지 않은",
"ae255c3dba": "해당 없음",
"247c93ca92": "• 추론된 가격",
"8a6655f7a2": "• 마지막 스캔 오류: {{value0}}"
"8a6655f7a2": "• 마지막 스캔 오류: {{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"OpenCodeUsagePane": {
"349f7c3f5c": "총",
@@ -2875,7 +2929,11 @@
"e04c58327c": "Orca 작업 트리만 해당",
"362231082f": "알려지지 않은",
"8095a63426": "해당 없음",
"6cc7782458": "• 마지막 스캔 오류: {{value0}}"
"6cc7782458": "• 마지막 스캔 오류: {{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"ShareUsageButton": {
"7d6b25323d": "X에 공유",
@@ -2996,20 +3054,20 @@
}
},
"UsageBreakdownSection": {
"7765a4c3e1": "해당 없음",
"247c93ca92": "• 추론된 가격"
"7765a4c3e1": "n/a",
"247c93ca92": "• inferred pricing"
},
"UsageSessionsTable": {
"1afc25eb06": "턴",
"0f03975d59": "이벤트",
"21ea00bfa8": "캐시",
"e0b988599d": "총",
"01476891c7": "마지막 활동",
"c17bed0416": "프로젝트",
"f6a2c8d019": "모델",
"faf3444859": "입력",
"a8b7487ff7": "출력",
"cfe2282ffa": "알려지지 않은"
"1afc25eb06": "Turns",
"0f03975d59": "Events",
"21ea00bfa8": "Cache",
"e0b988599d": "Total",
"01476891c7": "Last active",
"c17bed0416": "Project",
"f6a2c8d019": "Model",
"faf3444859": "Input",
"a8b7487ff7": "Output",
"cfe2282ffa": "Unknown"
}
},
"sparse": {
@@ -3095,7 +3153,15 @@
"fe1e616c5b": "Git이 설치되어 있지 않아 일반 폴더가 기본값입니다.",
"c234df77f7": "만들기 전에 서버 상위 폴더를 선택하거나 입력하세요.",
"3a13f6e88b": "위치가 선택되지 않음",
"6ed14c0281": "서버 폴더가 선택되지 않음"
"6ed14c0281": "서버 폴더가 선택되지 않음",
"createProjectTitle": "프로젝트 생성",
"createProjectDescription": "로컬 Git 저장소와 첫 번째 작업 공간을 만듭니다.",
"projectNameLabel": "프로젝트 이름",
"createsGitRepoHelp": "Git 저장소:",
"parentFolderLabel": "상위 폴더",
"browseParentFolder": "찾아보기",
"gitRequiredError": "프로젝트를 만들려면 Git이 필요합니다.",
"createAction": "생성"
},
"AddRepoNestedImportStep": {
"496f68cf8c": "저장소를 스캔하는 중입니다. 중지하려면 클릭하세요.",
@@ -3472,7 +3538,11 @@
"376bed88e5": "원격 호스트 연결에 오류가 발생했습니다.",
"4afcca1d24": "다시 연결",
"11552bf786": "SSH 연결 끊김",
"cb5938ae79": "다시 연결하는 중..."
"cb5938ae79": "다시 연결하는 중...",
"disconnected": "This remote repository is not connected.",
"reconnecting": "Reconnecting to the remote host...",
"reconnectionFailed": "Reconnection to the remote host failed.",
"authFailed": "Authentication to the remote host failed."
},
"SshTargetRow": {
"4677394048": "연결 중…",
@@ -3696,7 +3766,9 @@
"a6c20dca96": "SSH 대상에서 프로젝트 열기",
"3d162cc76f": "원격 프로젝트",
"fb4fc5380e": "로컬 프로젝트, Git repo 또는 repos가 많은 폴더",
"2281fdc8c7": "폴더 찾아보기"
"2281fdc8c7": "폴더 찾아보기",
"createProjectTitle": "프로젝트 생성",
"createGitProjectDescription": "로컬 Git 저장소 생성"
}
}
}
@@ -7527,7 +7599,6 @@
"5788d1059d": "리뷰 스레드를 업데이트할 수 없습니다. GitHub API 예산을 확인하세요.",
"07871c0589": "다른 PR 연결",
"7202f4a40a": "PR 연결 해제",
"a9d7c128e4": "{{value0}} 연결 해제",
"7f4489f370": "새로고침",
"5c88c6db07": "{{value0}}에 영업 시작",
"7fad8509fe": "AI로 고치다",
@@ -7542,14 +7613,15 @@
"e56c42122e": "파괴적인",
"786e3c143f": "삭제",
"653c105ecc": "더 많은 PR 작업",
"b4f3ec62a1": "더 많은 {{value0}} 작업",
"f316a8ca2b": "선택한 해결되지 않은 댓글이 없습니다.",
"d00ebdc402": "AI로 {{value0}} 댓글 해결",
"ed3f79c031": "agent를 시작하기 전에 prompt를 검토하세요. 선택한 스레드는 시작 후 해결됨으로 표시됩니다.",
"f273f2271c": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.",
"aa95b81a3a": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.",
"495b2f8c4b": "agent를 시작했지만 선택한 댓글을 해결됨으로 표시할 수 없습니다.",
"3c3ad3a1d2": "agent를 시작했습니다. 호스트에서 해결됨으로 표시할 수 있는 선택된 댓글이 없습니다."
"3c3ad3a1d2": "agent를 시작했습니다. 호스트에서 해결됨으로 표시할 수 있는 선택된 댓글이 없습니다.",
"b4f3ec62a1": "More {{value0}} actions",
"a9d7c128e4": "unlink {{value0}}"
},
"CreatePullRequestDialog": {
"2bc1b4345e": "취소",
@@ -8933,7 +9005,11 @@
"3c4adfd821": "로그인 경쟁 조건 수정",
"56a0271428": "격리된 워크스페이스",
"ef737dcee1": "GitHub 및 Linear 작업",
"ac51c061e2": "codex"
"ac51c061e2": "codex",
"47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.",
"70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.",
"f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.",
"5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents."
},
"FeatureWallBody": {
"25ec5356d6": "설정"
@@ -9006,7 +9082,9 @@
"6e3f5223c5": "탐침",
"ab2901bce6": "검사",
"d7f80060ca": "소스 제어",
"8e715588e4": "검색"
"8e715588e4": "검색",
"a6c8b9e32f": "Checks passed",
"f4d5e1a7b2": "3 checks"
},
"ReviewShipAnimatedVisual": {
"4d99496b8c": "PR 작성",
+136 -58
View File
@@ -464,39 +464,32 @@
}
}
},
"folder": {
"workspace": {
"path": {
"status": {
"title": {
"missing": "未找到文件夹",
"not": {
"directory": "路径不是文件夹"
},
"ambiguous": {
"connection": "无法确定连接"
},
"unavailable": "无法检查文件夹"
},
"description": {
"missing": "Orca 找不到 {{value0}}。请移除并重新导入此文件夹工作区。",
"not": {
"directory": "{{value0}} 存在,但它不是文件夹。"
},
"ambiguous": {
"connection": "Orca 无法判断哪个 SSH 连接拥有此文件夹范围。"
},
"unavailable": "Orca 现在无法验证此文件夹。请检查运行时或 SSH 连接,然后重试。"
},
"create": {
"description": {
"missing": "Orca 找不到 {{value0}}。请移除并重新导入该文件夹。"
},
"title": {
"failed": "创建文件夹工作区失败"
}
}
}
"folderWorkspacePathStatus": {
"title": {
"missing": "未找到文件夹",
"notDirectory": "路径不是文件夹",
"ambiguousConnection": "无法确定连接",
"unavailable": "无法检查文件夹"
},
"description": {
"missing": "Orca 找不到 {{path}}。请移除并重新导入此文件夹工作区。",
"notDirectory": "{{path}} 存在,但它不是文件夹。",
"ambiguousConnection": "Orca 无法判断哪个 SSH 连接拥有此文件夹范围。",
"unavailable": "Orca 现在无法验证此文件夹。请检查运行时或 SSH 连接,然后重试。"
},
"createError": {
"title": {
"missing": "未找到文件夹",
"notDirectory": "路径不是文件夹",
"ambiguousConnection": "无法确定连接",
"unavailable": "无法检查文件夹",
"generic": "创建文件夹工作区失败"
},
"description": {
"missing": "Orca 找不到 {{path}}。请移除并重新导入该文件夹。",
"notDirectory": "{{path}} 存在,但它不是文件夹。",
"ambiguousConnection": "Orca 无法判断哪个 SSH 连接拥有此文件夹范围。",
"unavailable": "Orca 现在无法验证此文件夹。请检查运行时或 SSH 连接,然后重试。"
}
}
}
@@ -1036,7 +1029,15 @@
"f660aa1454": "连接中",
"7711ad5122": "本地设置命令",
"e5db1b0419": "组合设置命令",
"addProjectBeforeWorkspace": "在创建工作区之前添加项目。"
"addProjectBeforeWorkspace": "在创建工作区之前添加项目。",
"sshNotConnected": "SSH not connected",
"connectingSsh": "Connecting SSH...",
"sshAuthenticationFailed": "SSH authentication failed",
"preparingSshConnection": "Preparing SSH connection...",
"connected": "Connected",
"reconnectingSsh": "Reconnecting SSH...",
"sshReconnectionFailed": "SSH reconnection failed",
"notConnected": "Not connected"
},
"NewWorkspaceComposerModal": {
"fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。"
@@ -1811,7 +1812,11 @@
"015b4e607d": "取消",
"e3bd59143c": "插入",
"f24783f470": "https://...",
"ec6310b731": "使用 http:// 或 https:// 图片 URL。"
"ec6310b731": "使用 http:// 或 https:// 图片 URL。",
"b7e4a1c902": "Paste, drop, or click to add files",
"8f1c2d4e6a": "Nothing to preview",
"c91f0a2b14": "Write",
"d82b1e3f05": "Preview"
},
"IssueSourceSelector": {
"d6aeb2012b": "显示议题来自",
@@ -1885,6 +1890,47 @@
}
}
}
},
"CloseReasonDropdown": {
"e1f2a3b4c5": "Choose close reason"
},
"GitHubIssueCommentComposer": {
"082515176a": "Failed to add comment",
"9f88657c4e": "Issue closed",
"e9b7cb7d17": "Failed to close issue",
"bd3b4492a0": "Issue reopened",
"f2a8c1d903": "Failed to reopen issue",
"a1b2c3d4e5": "Add a comment",
"c5c117270e": "Add your comment here, be kind",
"f6a7b8c9d0": "Close issue",
"b1c2d3e4f5": "Reopen issue",
"0a73f59e85": "Send comment",
"bf43425540": "Comment"
},
"GitHubWorkItemAssigneePopoverContent": {
"cddd9b04a7": "Loading assignees",
"a00830d3f7": "No users",
"4f8b6f2c1d": "Filter assignees..."
},
"GitHubWorkItemLabelPopoverContent": {
"2aa9acdf34": "Edit labels on GitHub",
"cddd9b04a7": "Loading labels",
"de26e2eb06": "No labels",
"8b0d52ee3a": "Filter labels..."
},
"githubIssueCloseReasons": {
"completed": {
"label": "Close as completed",
"description": "Done, closed, fixed, resolved"
},
"notPlanned": {
"label": "Close as not planned",
"description": "Won't fix, can't repro, stale"
},
"duplicate": {
"label": "Close as duplicate",
"description": "Duplicate of another issue"
}
}
},
"linear": {
@@ -2787,7 +2833,11 @@
"4f8368c272": "仅 Orca 工作树",
"cfe2282ffa": "未知",
"7765a4c3e1": "不适用",
"2d41fd45c6": "• 上次扫描错误:{{value0}}"
"2d41fd45c6": "• 上次扫描错误:{{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"CodexUsageDailyChart": {
"1e6f62d7e3": "推理",
@@ -2836,7 +2886,11 @@
"bf6cf2d4dd": "未知",
"ae255c3dba": "不适用",
"247c93ca92": "• 推断定价",
"8a6655f7a2": "• 上次扫描错误:{{value0}}"
"8a6655f7a2": "• 上次扫描错误:{{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"OpenCodeUsagePane": {
"349f7c3f5c": "全部的",
@@ -2875,7 +2929,11 @@
"e04c58327c": "仅 Orca 工作树",
"362231082f": "未知",
"8095a63426": "不适用",
"6cc7782458": "• 上次扫描错误:{{value0}}"
"6cc7782458": "• 上次扫描错误:{{value0}}",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"rangeLast90Days": "Last 90 days",
"rangeAllTime": "All time"
},
"ShareUsageButton": {
"7d6b25323d": "分享到 X",
@@ -2996,20 +3054,20 @@
}
},
"UsageBreakdownSection": {
"7765a4c3e1": "不适用",
"247c93ca92": "• 推断定价"
"7765a4c3e1": "n/a",
"247c93ca92": "• inferred pricing"
},
"UsageSessionsTable": {
"1afc25eb06": "轮次",
"0f03975d59": "事件",
"21ea00bfa8": "缓存",
"e0b988599d": "总计",
"01476891c7": "最后活跃",
"c17bed0416": "项目",
"f6a2c8d019": "模型",
"faf3444859": "输入",
"a8b7487ff7": "输出",
"cfe2282ffa": "未知"
"1afc25eb06": "Turns",
"0f03975d59": "Events",
"21ea00bfa8": "Cache",
"e0b988599d": "Total",
"01476891c7": "Last active",
"c17bed0416": "Project",
"f6a2c8d019": "Model",
"faf3444859": "Input",
"a8b7487ff7": "Output",
"cfe2282ffa": "Unknown"
}
},
"sparse": {
@@ -3095,7 +3153,15 @@
"fe1e616c5b": "未安装 Git,因此默认创建普通文件夹。",
"c234df77f7": "创建前请选择或输入服务器父文件夹。",
"3a13f6e88b": "未选择位置",
"6ed14c0281": "未选择服务器文件夹"
"6ed14c0281": "未选择服务器文件夹",
"createProjectTitle": "创建项目",
"createProjectDescription": "创建本地 Git 存储库和第一个工作区。",
"projectNameLabel": "项目名称",
"createsGitRepoHelp": "Git 存储库:",
"parentFolderLabel": "父文件夹",
"browseParentFolder": "浏览",
"gitRequiredError": "创建项目需要 Git。",
"createAction": "创建"
},
"AddRepoNestedImportStep": {
"496f68cf8c": "扫描存储库。单击停止。",
@@ -3472,7 +3538,11 @@
"376bed88e5": "与远程主机的连接遇到错误。",
"4afcca1d24": "重新连接",
"11552bf786": "SSH 已断开",
"cb5938ae79": "正在重新连接..."
"cb5938ae79": "正在重新连接...",
"disconnected": "This remote repository is not connected.",
"reconnecting": "Reconnecting to the remote host...",
"reconnectionFailed": "Reconnection to the remote host failed.",
"authFailed": "Authentication to the remote host failed."
},
"SshTargetRow": {
"4677394048": "正在连接…",
@@ -3696,7 +3766,9 @@
"a6c20dca96": "从 SSH 目标打开项目",
"3d162cc76f": "远程项目",
"fb4fc5380e": "本地项目、Git repo 或包含多个 repos 的文件夹",
"2281fdc8c7": "浏览文件夹"
"2281fdc8c7": "浏览文件夹",
"createProjectTitle": "创建项目",
"createGitProjectDescription": "创建本地 Git 存储库"
}
}
}
@@ -7527,7 +7599,6 @@
"5788d1059d": "无法更新评论主题。检查 GitHub API 预算。",
"07871c0589": "链接另一个 PR",
"7202f4a40a": "取消PR链接",
"a9d7c128e4": "取消 {{value0}} 链接",
"7f4489f370": "刷新",
"5c88c6db07": "打开于 {{value0}}",
"7fad8509fe": "用 AI 修复",
@@ -7542,14 +7613,15 @@
"e56c42122e": "destructive",
"786e3c143f": "删除",
"653c105ecc": "更多 PR 操作",
"b4f3ec62a1": "更多 {{value0}} 操作",
"f316a8ca2b": "未选择未解决的评论。",
"d00ebdc402": "使用 AI 解决 {{value0}} 评论",
"ed3f79c031": "启动 agent 前请检查提示词。启动后,选中的线程会被标记为已解决。",
"f273f2271c": "已启动 agent。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。",
"aa95b81a3a": "已启动 agent。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。",
"495b2f8c4b": "已启动 agent,但无法将选中的评论标记为已解决。",
"3c3ad3a1d2": "已启动 agent。选中的评论中没有可在托管平台上标记为已解决的评论。"
"3c3ad3a1d2": "已启动 agent。选中的评论中没有可在托管平台上标记为已解决的评论。",
"b4f3ec62a1": "More {{value0}} actions",
"a9d7c128e4": "unlink {{value0}}"
},
"CreatePullRequestDialog": {
"2bc1b4345e": "取消",
@@ -8933,7 +9005,11 @@
"3c4adfd821": "修复登录竞争条件",
"56a0271428": "独立的工作区",
"ef737dcee1": "GitHub 和 Linear 任务",
"ac51c061e2": "codex"
"ac51c061e2": "codex",
"47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.",
"70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.",
"f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.",
"5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents."
},
"FeatureWallBody": {
"25ec5356d6": "设置"
@@ -9006,7 +9082,9 @@
"6e3f5223c5": "探险家",
"ab2901bce6": "检查项",
"d7f80060ca": "源代码控制",
"8e715588e4": "搜索"
"8e715588e4": "搜索",
"a6c8b9e32f": "Checks passed",
"f4d5e1a7b2": "3 checks"
},
"ReviewShipAnimatedVisual": {
"4d99496b8c": "创建PR",
@@ -0,0 +1,82 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
const RENDERER_ROOT = resolve('src/renderer/src')
const FUNCTION_KINDS = new Set<ts.SyntaxKind>([
ts.SyntaxKind.FunctionDeclaration,
ts.SyntaxKind.FunctionExpression,
ts.SyntaxKind.ArrowFunction,
ts.SyntaxKind.MethodDeclaration,
ts.SyntaxKind.Constructor,
ts.SyntaxKind.GetAccessor,
ts.SyntaxKind.SetAccessor
])
function collectSourceFiles(dir: string, files: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const filePath = resolve(dir, name)
const stat = statSync(filePath)
if (stat.isDirectory()) {
collectSourceFiles(filePath, files)
} else if (
/\.(ts|tsx)$/.test(name) &&
!/\.test\.(ts|tsx)$/.test(name) &&
!filePath.includes('/i18n/locales/')
) {
files.push(filePath)
}
}
return files
}
function isInsideFunction(node: ts.Node): boolean {
let parent = node.parent
while (parent && parent.kind !== ts.SyntaxKind.SourceFile) {
if (FUNCTION_KINDS.has(parent.kind)) {
return true
}
parent = parent.parent
}
return false
}
describe('i18n import-time safety', () => {
it('does not evaluate translate() at module load time', () => {
const violations: string[] = []
for (const filePath of collectSourceFiles(RENDERER_ROOT)) {
const source = readFileSync(filePath, 'utf8')
if (!source.includes('translate(')) {
continue
}
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)
function visit(node: ts.Node): void {
if (
ts.isCallExpression(node) &&
node.expression.getText(sourceFile) === 'translate' &&
!isInsideFunction(node)
) {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
node.getStart(sourceFile)
)
violations.push(`${relative(process.cwd(), filePath)}:${line + 1}:${character + 1}`)
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
}
expect(violations).toEqual([])
})
})
@@ -1,6 +1,6 @@
import { translate } from '@/i18n/i18n'
import type { FolderWorkspacePathStatus } from '../../../shared/folder-workspace-path-status'
import { blocksFolderWorkspaceActivation } from '../../../shared/folder-workspace-path-status'
import { translate } from '@/i18n/i18n'
export function getFolderWorkspacePathStatusTitle(
status: FolderWorkspacePathStatus | null | undefined
@@ -10,21 +10,21 @@ export function getFolderWorkspacePathStatusTitle(
}
switch (status.reason) {
case 'missing':
return translate('auto.lib.folder.workspace.path.status.title.missing', 'Folder not found')
return translate('auto.lib.folderWorkspacePathStatus.title.missing', 'Folder not found')
case 'not-directory':
return translate(
'auto.lib.folder.workspace.path.status.title.not.directory',
'auto.lib.folderWorkspacePathStatus.title.notDirectory',
'Path is not a folder'
)
case 'ambiguous-connection':
return translate(
'auto.lib.folder.workspace.path.status.title.ambiguous.connection',
'auto.lib.folderWorkspacePathStatus.title.ambiguousConnection',
'Cannot determine connection'
)
case undefined:
case 'unavailable':
return translate(
'auto.lib.folder.workspace.path.status.title.unavailable',
'auto.lib.folderWorkspacePathStatus.title.unavailable',
'Cannot check folder'
)
}
@@ -39,25 +39,25 @@ export function getFolderWorkspacePathStatusDescription(
switch (status.reason) {
case 'missing':
return translate(
'auto.lib.folder.workspace.path.status.description.missing',
'Orca cannot find {{value0}}. Remove and re-import this folder workspace.',
{ value0: status.path }
'auto.lib.folderWorkspacePathStatus.description.missing',
'Orca cannot find {{path}}. Remove and re-import this folder workspace.',
{ path: status.path }
)
case 'not-directory':
return translate(
'auto.lib.folder.workspace.path.status.description.not.directory',
'{{value0}} exists, but it is not a folder.',
{ value0: status.path }
'auto.lib.folderWorkspacePathStatus.description.notDirectory',
'{{path}} exists, but it is not a folder.',
{ path: status.path }
)
case 'ambiguous-connection':
return translate(
'auto.lib.folder.workspace.path.status.description.ambiguous.connection',
'auto.lib.folderWorkspacePathStatus.description.ambiguousConnection',
'Orca cannot tell which SSH connection owns this folder scope.'
)
case undefined:
case 'unavailable':
return translate(
'auto.lib.folder.workspace.path.status.description.unavailable',
'auto.lib.folderWorkspacePathStatus.description.unavailable',
'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.'
)
}
@@ -71,35 +71,38 @@ export function formatFolderWorkspaceCreateError(error: unknown): {
const path = message.includes(':') ? message.slice(message.indexOf(':') + 1) : ''
if (message.startsWith('folder_workspace_path_missing:')) {
return {
title: translate('auto.lib.folder.workspace.path.status.title.missing', 'Folder not found'),
title: translate(
'auto.lib.folderWorkspacePathStatus.createError.title.missing',
'Folder not found'
),
description: translate(
'auto.lib.folder.workspace.path.status.create.description.missing',
'Orca cannot find {{value0}}. Remove and re-import the folder.',
{ value0: path }
'auto.lib.folderWorkspacePathStatus.createError.description.missing',
'Orca cannot find {{path}}. Remove and re-import the folder.',
{ path }
)
}
}
if (message.startsWith('folder_workspace_path_not_directory:')) {
return {
title: translate(
'auto.lib.folder.workspace.path.status.title.not.directory',
'auto.lib.folderWorkspacePathStatus.createError.title.notDirectory',
'Path is not a folder'
),
description: translate(
'auto.lib.folder.workspace.path.status.description.not.directory',
'{{value0}} exists, but it is not a folder.',
{ value0: path }
'auto.lib.folderWorkspacePathStatus.createError.description.notDirectory',
'{{path}} exists, but it is not a folder.',
{ path }
)
}
}
if (message.startsWith('folder_workspace_connection_ambiguous:')) {
return {
title: translate(
'auto.lib.folder.workspace.path.status.title.ambiguous.connection',
'auto.lib.folderWorkspacePathStatus.createError.title.ambiguousConnection',
'Cannot determine connection'
),
description: translate(
'auto.lib.folder.workspace.path.status.description.ambiguous.connection',
'auto.lib.folderWorkspacePathStatus.createError.description.ambiguousConnection',
'Orca cannot tell which SSH connection owns this folder scope.'
)
}
@@ -107,18 +110,18 @@ export function formatFolderWorkspaceCreateError(error: unknown): {
if (message.startsWith('folder_workspace_path_unavailable:')) {
return {
title: translate(
'auto.lib.folder.workspace.path.status.title.unavailable',
'auto.lib.folderWorkspacePathStatus.createError.title.unavailable',
'Cannot check folder'
),
description: translate(
'auto.lib.folder.workspace.path.status.description.unavailable',
'auto.lib.folderWorkspacePathStatus.createError.description.unavailable',
'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.'
)
}
}
return {
title: translate(
'auto.lib.folder.workspace.path.status.create.title.failed',
'auto.lib.folderWorkspacePathStatus.createError.title.generic',
'Failed to create folder workspace'
),
description: message
+13 -5
View File
@@ -2,6 +2,7 @@ import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { useTranslation } from 'react-i18next'
import App from './App'
import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary'
import {
@@ -35,8 +36,9 @@ if (!rootElement) {
throw new Error('Renderer root element not found.')
}
createRoot(rootElement).render(
<StrictMode>
function RendererRoot(): React.JSX.Element {
useTranslation()
return (
<RecoverableRenderErrorBoundary
boundaryId="app.root"
surface="app-root"
@@ -46,10 +48,16 @@ createRoot(rootElement).render(
'The app shell could not finish rendering. Retry to remount it, or relaunch Orca if the error persists.'
)}
>
<I18nProvider>
<App />
</I18nProvider>
<App />
</RecoverableRenderErrorBoundary>
)
}
createRoot(rootElement).render(
<StrictMode>
<I18nProvider>
<RendererRoot />
</I18nProvider>
</StrictMode>
)
recordRendererCrashBreadcrumb('renderer_bootstrap_rendered')
+22 -14
View File
@@ -2,6 +2,7 @@ import '../assets/main.css'
import { lazy, Suspense, useMemo, useState } from 'react'
import ReactDOM from 'react-dom/client'
import { useTranslation } from 'react-i18next'
import WebConnect from './WebConnect'
import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary'
import {
@@ -46,23 +47,30 @@ function WebRoot(): React.JSX.Element {
installWebPreloadApi()
return (
<Suspense fallback={<div className="min-h-screen bg-background" />}>
<I18nProvider>
<App />
</I18nProvider>
<App />
</Suspense>
)
}
function WebRootBoundary(): React.JSX.Element {
useTranslation()
return (
<RecoverableRenderErrorBoundary
boundaryId="web.root"
surface="web-root"
title={translate('app.recoverableError.webTitle', 'Orca web hit a renderer error.')}
description={translate(
'app.recoverableError.webDescription',
'Retry the web client or reconnect to the paired runtime.'
)}
>
<WebRoot />
</RecoverableRenderErrorBoundary>
)
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<RecoverableRenderErrorBoundary
boundaryId="web.root"
surface="web-root"
title={translate('app.recoverableError.webTitle', 'Orca web hit a renderer error.')}
description={translate(
'app.recoverableError.webDescription',
'Retry the web client or reconnect to the paired runtime.'
)}
>
<WebRoot />
</RecoverableRenderErrorBoundary>
<I18nProvider>
<WebRootBoundary />
</I18nProvider>
)
+4
View File
@@ -1382,8 +1382,12 @@ export type GitHubCreateIssueFields = {
assignees?: string[]
}
export type GitHubIssueCloseReason = 'completed' | 'not_planned' | 'duplicate'
export type GitHubIssueUpdate = {
state?: 'open' | 'closed'
stateReason?: GitHubIssueCloseReason
duplicateOf?: number
title?: string
// Why: body writes use the REST issue endpoint instead of `gh issue edit`
// because that command does not consistently cover every body-edit case the