OPE-296: Add linear integration for starting sessions from issues (#3235)
* feat(linear): start sessions from Linear issues Authorize a Linear workspace on this OpenChamber server, map teams to projects, attach an issue from chat, start a session or worktree from an issue, and post started/completed/failed comments that open the session. Hidden in VS Code. * feat(linear): connect more than one Linear workspace Store each OAuth grant on this OpenChamber server and keep one current, so Settings can add and switch workspaces without dropping the others. Project mapping is per workspace. Remove the Linear button next to New Chat; start-from-issue stays on New Worktree. * feat(linear): add a right-hand issues panel Browse and filter issues in the rail, open a card to change status or start a session, and collapse search plus most filters to icons on a narrow panel. * feat(linear): open issues in the rail and filter by Linear status The rail icon only shows after Linear is connected. Clicking a Linear row on work status opens the panel. Status options match the card, including Done, Canceled, and Duplicate. The Integrations experimental warning sits under Third-party integrations. * fix(linear): use stable OAuth callback broker * fix(chat): preview Linear issue attachments The context switch missed linear-issue, so tsc treated the preview helpers as incomplete. * fix(ui): restore Linear i18n parity and the #2903 sync harness Turkish was missing the Linear dictionaries, and the subagent test still wrapped only SyncContext after reads moved to SyncRuntimeContext. * fix(linear): drop changelog hunks and close review races Keep changelogs out of this PR, restore CodeMirror ranges, ignore stale Linear list pages, and leave a persisted Linear tab open until auth has actually resolved. * fix(linear): tint active issue filters and clear them in one click * fix(markdown): read escaped brackets as text, not display math `\[...\]` is display math in LaTeX and an escaped bracket pair in CommonMark. The block tokenizer claimed every `\[`, so prose like `[title \[Bug\] more](url)` was handed to KaTeX: "Bug" rendered as a centered formula and the block token split the paragraph, tearing the link into three pieces. Linear, GitHub and any other source that escapes brackets the way CommonMark requires hit this. Display math now has to own its line — `\[` starts one and `\]` ends one. A formula on its own line still renders; `\[` mid-sentence stays an escape, which is what CommonMark says it is and what prose almost always means. Inline `\(...\)` keeps the same ambiguity, but inline math is legitimately mid-sentence, so there is no position to judge it by. Covered by regression tests, including the verbatim comment body that surfaced this. * feat(linear): make session status comments opt-in and public-only A status comment lands in a Linear workspace the whole team reads, and the link it carried pointed at whatever origin started the session — usually loopback or a LAN address. Everyone but its author got a dead link, and nobody had agreed to the comments in the first place. Comments are now off until the user turns them on in Settings -> Integrations -> Linear, and the check lives on the server: the event hub posts completed and failure without going through the interface, so a client-side gate would not hold. When the resolved origin is not publicly reachable the server posts nothing at all rather than a link only its author can open; `isPublicSessionOrigin` rejects loopback, private LAN, carrier-grade NAT, link-local and single-label hosts. The desktop deep-link origin is gone with it, since no one else can follow one either. The comment body also dropped the session title. It repeated the issue the comment already sits on, and issue titles routinely carry brackets ("[Bug] ...") that broke the markdown link. The body is now one short link, and `sessionTitle` is gone from the route, client and types. Also caps the dedupe file at the newest 500 sessions; it grew forever. * fix(linear): match the pull request panel and clear review findings Comments in the Linear panel now render as the same avatar timeline the pull request panel uses, with the shared time-format preference instead of a raw locale string. Comment authors carry `avatarUrl`, which the GraphQL selection was not requesting. Review findings from the same pass: - `status-runtime.js` hand-rolled `typeof` narrowing and failed the vendored anti-slop lint; it now parses through `parse.js` like every other file in the module. - `useLinearAuthStore` turned any failed request into `connected: false` with `hasChecked: true`. Since the rail icon, the composer entry and the worktree option all gate on `connected === true`, one network blip hid Linear for the rest of the session, and Settings only re-checked when it had never checked. It now keeps the last known status and leaves `hasChecked` false so the next caller retries. - `LinearIssuesView` (1096 lines) was a static import in `ContextPanel`, shipping in the main bundle although its rail icon stays hidden until a workspace is connected. It is lazy now, like `GitView`. - Dropped dead code: the unused port helpers left over from the loopback callback, two re-exported default values nothing read, and a redundant export in `linkedIssues`. - Integrations is no longer badged beta.
This commit is contained in:
@@ -49,6 +49,7 @@ import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
@@ -247,6 +248,7 @@ function App({ apis }: AppProps) {
|
||||
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
|
||||
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
|
||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||
const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus);
|
||||
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
||||
// Embedded chats start inactive until the parent panel identifies the active
|
||||
// tab. Otherwise a newly loaded background tab can focus its composer first
|
||||
@@ -345,7 +347,8 @@ function App({ apis }: AppProps) {
|
||||
}
|
||||
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
}, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
|
||||
void refreshLinearAuthStatus(apis.linear, { force: true });
|
||||
}, [apis.github, apis.linear, embeddedSessionChat, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
|
||||
|
||||
useAppFontEffects();
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -630,6 +631,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
const clearError = useSessionUIStore((state) => state.clearError);
|
||||
const setIsMobile = useUIStore((state) => state.setIsMobile);
|
||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||
const refreshLinearAuthStatus = useLinearAuthStore((state) => state.refreshStatus);
|
||||
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const [connectionEpoch, setConnectionEpoch] = React.useState(0);
|
||||
@@ -678,6 +680,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
const refreshInPlace = () => {
|
||||
void initializeApp();
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
void refreshLinearAuthStatus(apis.linear, { force: true });
|
||||
if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' });
|
||||
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' });
|
||||
};
|
||||
@@ -746,7 +749,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
lastNativeResumeSyncEventAtRef.current = now;
|
||||
window.dispatchEvent(new Event('openchamber:system-resume'));
|
||||
}
|
||||
}, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]);
|
||||
}, [agentsCount, apis.github, apis.linear, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
|
||||
|
||||
useNativeMobileChrome();
|
||||
useNativeMobileLifecycle(handleNativeResume);
|
||||
@@ -1031,7 +1034,8 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
React.useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
}, [apis.github, isConnected, refreshGitHubAuthStatus]);
|
||||
void refreshLinearAuthStatus(apis.linear, { force: true });
|
||||
}, [apis.github, apis.linear, isConnected, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
|
||||
|
||||
// Discover all worktrees for every known project so the draft session's
|
||||
// worktree/branch dropdown can list every available branch — not only the
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@/sync/attachment-files';
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { buildLinkedIssue } from '@/lib/linkedIssues';
|
||||
import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues';
|
||||
import { useUserMessageHistory } from "@/sync/sync-context";
|
||||
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||
@@ -66,6 +66,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
|
||||
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
|
||||
import { LinearIssuePickerDialog } from '@/components/session/LinearIssuePickerDialog';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { DraftPresetChips } from './DraftPresetChips';
|
||||
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
|
||||
@@ -426,7 +427,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
||||
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
|
||||
const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen);
|
||||
const { git: runtimeGit, vscode: vscodeApi } = useRuntimeAPIs();
|
||||
const { git: runtimeGit, vscode: vscodeApi, linear: runtimeLinear } = useRuntimeAPIs();
|
||||
const cycleAgentShortcutOverride = useUIStore((state) => state.shortcutOverrides.cycle_agent);
|
||||
const cycleAgentShortcut = React.useMemo(() => (
|
||||
getEffectiveShortcutCombo('cycle_agent', cycleAgentShortcutOverride ? { cycle_agent: cycleAgentShortcutOverride } : undefined)
|
||||
@@ -722,6 +723,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
// Issue linking state
|
||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||
const [prPickerOpen, setPrPickerOpen] = React.useState(false);
|
||||
const [linearPickerOpen, setLinearPickerOpen] = React.useState(false);
|
||||
const [linkedIssue, setLinkedIssue] = React.useState<{
|
||||
number: number;
|
||||
title: string;
|
||||
@@ -740,6 +742,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<{
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
|
||||
// Message queue
|
||||
const messageQueueTarget = currentSessionId
|
||||
@@ -972,6 +981,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
setPrPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const openLinearPicker = React.useCallback(() => {
|
||||
setLinearPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const getSubmitErrorMessage = (error: unknown, fallback: string) => {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
return message.toLowerCase().includes('runtime changed')
|
||||
@@ -1158,6 +1171,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
linkedPr: linkedPr
|
||||
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||
: null,
|
||||
linkedLinearIssue: linkedLinearIssue
|
||||
? { identifier: linkedLinearIssue.identifier, title: linkedLinearIssue.title, url: linkedLinearIssue.url, contextText: linkedLinearIssue.contextText }
|
||||
: null,
|
||||
}, {
|
||||
parseAgentMention: (text) => {
|
||||
const { sanitizedText, mention } = parseAgentMentions(text, agents);
|
||||
@@ -1395,6 +1411,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (linkedLinearIssue && linkTargetSessionId) {
|
||||
void sessionActions.setLinkedIssue(
|
||||
linkTargetSessionId,
|
||||
linkTargetDirectory,
|
||||
buildLinkedLinearIssue({
|
||||
identifier: linkedLinearIssue.identifier,
|
||||
title: linkedLinearIssue.title,
|
||||
url: linkedLinearIssue.url,
|
||||
author: linkedLinearIssue.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Clear linked issue after successful message send
|
||||
if (linkedIssue) {
|
||||
@@ -1403,6 +1433,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (linkedPr) {
|
||||
setLinkedPr(null);
|
||||
}
|
||||
if (linkedLinearIssue) {
|
||||
setLinkedLinearIssue(null);
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
const rawMessage =
|
||||
error instanceof Error
|
||||
@@ -2528,6 +2561,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
|
||||
const footerGapClass = 'gap-x-1.5 gap-y-0';
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const showLinearPicker = Boolean(runtimeLinear) && !isVSCode;
|
||||
// The work-status panel carries the agent's todos and the changed-file
|
||||
// count, but only on the desktop/web layout — VS Code and mobile have no
|
||||
// panel, so these keep their place above the composer there.
|
||||
@@ -2608,6 +2642,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
draftPickerOpen: mobileDraftPicker !== null,
|
||||
issuePickerOpen,
|
||||
prPickerOpen,
|
||||
linearPickerOpen,
|
||||
isDragging,
|
||||
},
|
||||
});
|
||||
@@ -2778,6 +2813,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onRemove={() => setLinkedPr(null)}
|
||||
/>
|
||||
) : null}
|
||||
{linkedLinearIssue && !isVSCode ? (
|
||||
<LinkedReferenceRow
|
||||
numberLabel={linkedLinearIssue.identifier}
|
||||
title={linkedLinearIssue.title}
|
||||
url={linkedLinearIssue.url}
|
||||
author={linkedLinearIssue.author}
|
||||
openInBrowserLabel={t('chat.chatInput.linked.linearIssue.openInBrowserAria')}
|
||||
removeLabel={t('chat.chatInput.linked.linearIssue.removeAria')}
|
||||
onReopenPicker={() => setLinearPickerOpen(true)}
|
||||
onRemove={() => setLinkedLinearIssue(null)}
|
||||
/>
|
||||
) : null}
|
||||
<RevertedMessageDock
|
||||
sessionId={currentSessionId}
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
@@ -2843,6 +2890,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onPickLocalFiles={handlePickLocalFiles}
|
||||
onOpenIssuePicker={openIssuePicker}
|
||||
onOpenPrPicker={openPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
onOpenLinearPicker={openLinearPicker}
|
||||
onOpenAttachSheet={openMobileAttachSheet}
|
||||
onStartDictation={toggleDictation}
|
||||
onAbort={handleAbort}
|
||||
@@ -3023,6 +3072,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onPickLocalFiles={handlePickLocalFiles}
|
||||
onOpenIssuePicker={openIssuePicker}
|
||||
onOpenPrPicker={openPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
onOpenLinearPicker={openLinearPicker}
|
||||
onOpenAttachSheet={openMobileAttachSheet}
|
||||
onToggleExpandedInput={handleToggleExpandedInput}
|
||||
onTogglePermissionAutoAccept={handlePermissionAutoAcceptToggle}
|
||||
@@ -3087,6 +3138,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onSelect={(issue) => {
|
||||
setLinkedIssue(issue);
|
||||
setLinkedPr(null);
|
||||
setLinkedLinearIssue(null);
|
||||
}}
|
||||
/>
|
||||
<GitHubPrPickerDialog
|
||||
@@ -3095,6 +3147,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onSelect={(pr) => {
|
||||
setLinkedPr(pr);
|
||||
setLinkedIssue(null);
|
||||
setLinkedLinearIssue(null);
|
||||
}}
|
||||
/>
|
||||
<LinearIssuePickerDialog
|
||||
open={linearPickerOpen}
|
||||
onOpenChange={setLinearPickerOpen}
|
||||
mode="select"
|
||||
onSelect={(issue) => {
|
||||
setLinkedLinearIssue(issue);
|
||||
setLinkedIssue(null);
|
||||
setLinkedPr(null);
|
||||
}}
|
||||
/>
|
||||
<ReviewFlowDialog
|
||||
@@ -3178,6 +3241,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
<Icon name="git-pull-request" className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
|
||||
{t('chat.chatInput.actions.linkGithubPr')}
|
||||
</button>
|
||||
{showLinearPicker ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => {
|
||||
mobileShell.skipNextOverlayCloseRestore();
|
||||
setMobileAttachMenuOpen(false);
|
||||
requestAnimationFrame(openLinearPicker);
|
||||
}}
|
||||
>
|
||||
<Icon name="linear" className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
|
||||
{t('chat.chatInput.actions.linkLinearIssue')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
) : null}
|
||||
|
||||
@@ -558,17 +558,29 @@ interface FilePart {
|
||||
|
||||
const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link';
|
||||
const GITHUB_PR_LINK_MIME = 'application/vnd.github.pull-request-link';
|
||||
const LINEAR_ISSUE_LINK_MIME = 'application/vnd.openchamber.linear-issue-link';
|
||||
|
||||
const getGitHubLinkKind = (file: FilePart): 'issue' | 'pr' | null => {
|
||||
type IssueLinkKind = 'github-issue' | 'github-pr' | 'linear-issue';
|
||||
|
||||
const getIssueLinkKind = (file: FilePart): IssueLinkKind | null => {
|
||||
if (file.mime === GITHUB_ISSUE_LINK_MIME) {
|
||||
return 'issue';
|
||||
return 'github-issue';
|
||||
}
|
||||
if (file.mime === GITHUB_PR_LINK_MIME) {
|
||||
return 'pr';
|
||||
return 'github-pr';
|
||||
}
|
||||
if (file.mime === LINEAR_ISSUE_LINK_MIME) {
|
||||
return 'linear-issue';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const issueLinkIcon = (kind: IssueLinkKind): 'github' | 'git-pull-request' | 'linear' => {
|
||||
if (kind === 'github-pr') return 'git-pull-request';
|
||||
if (kind === 'linear-issue') return 'linear';
|
||||
return 'github';
|
||||
};
|
||||
|
||||
interface MessageFilesDisplayProps {
|
||||
files: FilePart[];
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
@@ -591,7 +603,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
};
|
||||
|
||||
const resolveDisplayName = React.useCallback((file: FilePart): string => {
|
||||
const isGitHubLink = getGitHubLinkKind(file) !== null;
|
||||
const isGitHubLink = getIssueLinkKind(file) !== null;
|
||||
if (isGitHubLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
|
||||
return file.filename.trim();
|
||||
}
|
||||
@@ -665,11 +677,11 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
const fileName = resolveDisplayName(file);
|
||||
const ext = fileName.split('.').pop() || '';
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
const issueLinkKind = getIssueLinkKind(file);
|
||||
return (
|
||||
<Tooltip key={`file-${file.url || file.filename || index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
{githubLinkKind && file.url ? (
|
||||
{issueLinkKind && file.url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -677,11 +689,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
}}
|
||||
className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{githubLinkKind === 'pr' ? (
|
||||
<Icon name="git-pull-request" className="text-muted-foreground h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="github" className="text-muted-foreground h-3.5 w-3.5" />
|
||||
)}
|
||||
<Icon name={issueLinkIcon(issueLinkKind)} className="text-muted-foreground h-3.5 w-3.5" />
|
||||
<div className="overflow-hidden max-w-[220px]">
|
||||
<span className="truncate block" title={fileName}>{fileName}</span>
|
||||
</div>
|
||||
@@ -764,7 +772,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
const fileName = resolveDisplayName(file);
|
||||
const isImage = file.mime?.startsWith('image/');
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
const issueLinkKind = getIssueLinkKind(file);
|
||||
|
||||
if (isImage && file.url) {
|
||||
return (
|
||||
@@ -787,7 +795,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
);
|
||||
}
|
||||
|
||||
if (githubLinkKind && file.url) {
|
||||
if (issueLinkKind && file.url) {
|
||||
return (
|
||||
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -802,11 +810,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{githubLinkKind === 'pr' ? (
|
||||
<Icon name="git-pull-request" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
) : (
|
||||
<Icon name="github" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
)}
|
||||
<Icon name={issueLinkIcon(issueLinkKind)} className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{fileName}</p>
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface MobileComposerHolders {
|
||||
draftPickerOpen: boolean;
|
||||
issuePickerOpen: boolean;
|
||||
prPickerOpen: boolean;
|
||||
linearPickerOpen: boolean;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
@@ -204,7 +205,8 @@ export function useMobileComposerShell(
|
||||
|| holders.controlsPanelOpen
|
||||
|| holders.attachMenuOpen
|
||||
|| holders.issuePickerOpen
|
||||
|| holders.prPickerOpen;
|
||||
|| holders.prPickerOpen
|
||||
|| holders.linearPickerOpen;
|
||||
|
||||
// Installed PWA (standalone): a focus() from a bare timeout is outside the
|
||||
// user gesture and iOS refuses to raise the keyboard for it (Safari
|
||||
@@ -212,7 +214,7 @@ export function useMobileComposerShell(
|
||||
// 'oc:mobile-overlay-closed' synchronously from the same React flush as the
|
||||
// click that closed it — refocus right there, while the gesture is live.
|
||||
const pickerDialogsOpenRef = React.useRef(false);
|
||||
pickerDialogsOpenRef.current = holders.issuePickerOpen || holders.prPickerOpen;
|
||||
pickerDialogsOpenRef.current = holders.issuePickerOpen || holders.prPickerOpen || holders.linearPickerOpen;
|
||||
const skipNextCloseRestoreRef = React.useRef(false);
|
||||
const openSheetCountRef = React.useRef(0);
|
||||
const holdFocusUntilRef = React.useRef(0);
|
||||
@@ -307,6 +309,7 @@ export function useMobileComposerShell(
|
||||
|| holders.draftPickerOpen
|
||||
|| holders.issuePickerOpen
|
||||
|| holders.prPickerOpen
|
||||
|| holders.linearPickerOpen
|
||||
|| holders.isDragging;
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
+14
@@ -40,6 +40,7 @@ const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageIn
|
||||
syntheticTexts: [],
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -203,6 +204,17 @@ describe('synthetic context', () => {
|
||||
.toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' });
|
||||
});
|
||||
|
||||
test('a linked Linear issue is sent as context', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'fix it',
|
||||
linkedLinearIssue: { identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12', contextText: 'linear body' },
|
||||
}), deps());
|
||||
expect(result.additionalParts).toHaveLength(1);
|
||||
expect(result.additionalParts[0].text).toBe('linear body');
|
||||
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual({ kind: 'linear-issue', identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12' });
|
||||
});
|
||||
|
||||
test('synthetic texts precede the linked references', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'x',
|
||||
@@ -255,6 +267,7 @@ describe('full assembly order', () => {
|
||||
syntheticTexts: ['synthetic'],
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
|
||||
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
|
||||
linkedLinearIssue: { identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12', contextText: 'linear' },
|
||||
}), deps());
|
||||
|
||||
expect(result.primaryText).toBe('q1');
|
||||
@@ -265,6 +278,7 @@ describe('full assembly order', () => {
|
||||
'issue',
|
||||
'pr-how',
|
||||
'pr-diff',
|
||||
'linear',
|
||||
'use: deploy',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface OutgoingMessageInput {
|
||||
syntheticTexts: readonly string[];
|
||||
linkedIssue: { number: number; title: string; url: string; contextText: string } | null;
|
||||
linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null;
|
||||
linkedLinearIssue: { identifier: string; title: string; url: string; contextText: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +162,11 @@ export function buildOutgoingMessage(
|
||||
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
|
||||
}
|
||||
|
||||
if (input.linkedLinearIssue) {
|
||||
const { identifier, title, url, contextText } = input.linkedLinearIssue;
|
||||
additionalParts.push(createContextPart({ kind: 'linear-issue', identifier, title, url }, contextText));
|
||||
}
|
||||
|
||||
const skillInstruction = deps.buildSkillInstruction(skillNames);
|
||||
if (skillInstruction) {
|
||||
additionalParts.push({ text: skillInstruction, synthetic: true });
|
||||
|
||||
@@ -26,6 +26,8 @@ type ComposerAttachmentControlsProps = {
|
||||
handlePickLocalFiles: () => void;
|
||||
openIssuePicker: () => void;
|
||||
openPrPicker: () => void;
|
||||
showLinearPicker?: boolean;
|
||||
openLinearPicker?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
onMenuOpenChange?: (open: boolean) => void;
|
||||
/** Mobile: open the attachment bottom sheet instead of the dropdown menu. */
|
||||
@@ -41,6 +43,8 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
||||
handlePickLocalFiles,
|
||||
openIssuePicker,
|
||||
openPrPicker,
|
||||
showLinearPicker,
|
||||
openLinearPicker,
|
||||
onOpenSettings,
|
||||
} = props;
|
||||
|
||||
@@ -114,6 +118,16 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
||||
<Icon name="git-pull-request"/>
|
||||
{t('chat.chatInput.actions.linkGithubPr')}
|
||||
</DropdownMenuItem>
|
||||
{showLinearPicker && openLinearPicker ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
requestAnimationFrame(openLinearPicker);
|
||||
}}
|
||||
>
|
||||
<Icon name="linear"/>
|
||||
{t('chat.chatInput.actions.linkLinearIssue')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -136,6 +150,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
||||
prev.isVSCode === next.isVSCode
|
||||
&& prev.footerIconButtonClass === next.footerIconButtonClass
|
||||
&& prev.iconSizeClass === next.iconSizeClass
|
||||
&& prev.showLinearPicker === next.showLinearPicker
|
||||
&& prev.onOpenSettings === next.onOpenSettings
|
||||
&& prev.onMenuOpenChange === next.onMenuOpenChange
|
||||
&& prev.onOpenMobileSheet === next.onOpenMobileSheet
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface ComposerFooterProps {
|
||||
onPickLocalFiles: () => void;
|
||||
onOpenIssuePicker: () => void;
|
||||
onOpenPrPicker: () => void;
|
||||
showLinearPicker?: boolean;
|
||||
onOpenLinearPicker?: () => void;
|
||||
onOpenAttachSheet: () => void;
|
||||
onToggleExpandedInput: () => void;
|
||||
onTogglePermissionAutoAccept: () => void;
|
||||
@@ -94,6 +96,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
||||
onPickLocalFiles,
|
||||
onOpenIssuePicker,
|
||||
onOpenPrPicker,
|
||||
showLinearPicker,
|
||||
onOpenLinearPicker,
|
||||
onOpenAttachSheet,
|
||||
onToggleExpandedInput,
|
||||
onTogglePermissionAutoAccept,
|
||||
@@ -130,6 +134,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
||||
handlePickLocalFiles={onPickLocalFiles}
|
||||
openIssuePicker={onOpenIssuePicker}
|
||||
openPrPicker={onOpenPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
openLinearPicker={onOpenLinearPicker}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onOpenMobileSheet={onOpenAttachSheet}
|
||||
/>
|
||||
@@ -199,6 +205,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
||||
handlePickLocalFiles={onPickLocalFiles}
|
||||
openIssuePicker={onOpenIssuePicker}
|
||||
openPrPicker={onOpenPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
openLinearPicker={onOpenLinearPicker}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
<FocusModeButton
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface MobilePillComposerProps {
|
||||
onPickLocalFiles: () => void;
|
||||
onOpenIssuePicker: () => void;
|
||||
onOpenPrPicker: () => void;
|
||||
showLinearPicker?: boolean;
|
||||
onOpenLinearPicker?: () => void;
|
||||
onOpenAttachSheet: () => void;
|
||||
onStartDictation: () => void;
|
||||
onAbort: () => void;
|
||||
@@ -63,6 +65,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
|
||||
onPickLocalFiles,
|
||||
onOpenIssuePicker,
|
||||
onOpenPrPicker,
|
||||
showLinearPicker,
|
||||
onOpenLinearPicker,
|
||||
onOpenAttachSheet,
|
||||
onStartDictation,
|
||||
onAbort,
|
||||
@@ -95,6 +99,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
|
||||
handlePickLocalFiles={onPickLocalFiles}
|
||||
openIssuePicker={onOpenIssuePicker}
|
||||
openPrPicker={onOpenPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
openLinearPicker={onOpenLinearPicker}
|
||||
onOpenMobileSheet={onOpenAttachSheet}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -56,4 +56,12 @@ describe('messagePreview', () => {
|
||||
const parts = [contextPart(chatQuote('quoted bit'), 'raw model text')]
|
||||
expect(getPromptPreviewText(parts)).toBe('raw model text')
|
||||
})
|
||||
|
||||
test('labels a Linear issue attachment from its identifier and title', () => {
|
||||
const parts = [contextPart(
|
||||
{ kind: 'linear-issue', identifier: 'ENG-12', title: 'Fix login', url: 'https://linear.app/eng-12' },
|
||||
'fetched issue body',
|
||||
)]
|
||||
expect(getPromptPreviewText(parts, t)).toBe('ENG-12 Fix login')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,6 +57,8 @@ const contextSummary = (payload: ContextPartPayload, t: Translate): string => {
|
||||
return `#${payload.number} ${payload.title}`;
|
||||
case 'github-pr':
|
||||
return `#${payload.number} ${payload.title}`;
|
||||
case 'linear-issue':
|
||||
return `${payload.identifier} ${payload.title}`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,6 +80,7 @@ const contextBody = (payload: ContextPartPayload): string => {
|
||||
return payload.quote;
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
case 'linear-issue':
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -318,3 +318,41 @@ describe('CJK-aware link parsing', () => {
|
||||
expect(hrefOf(renderMarkdownSync('[a](url "title")'))).toBe('url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Escaped brackets versus display math', () => {
|
||||
// `\[...\]` is display math in LaTeX and an escaped bracket pair in
|
||||
// CommonMark. Prose escapes brackets far more often than it opens display
|
||||
// math mid-sentence, so math only wins when it owns its line.
|
||||
test('keeps escaped brackets inside a link as link text', () => {
|
||||
const html = renderMarkdownSync(
|
||||
'[OpenChamber session completed: OPE-316 \\[Bug\\] Opening files](https://example.com/?session=ses_1)',
|
||||
);
|
||||
expect(html).toContain('href="https://example.com/?session=ses_1"');
|
||||
expect(html).toContain('[Bug]');
|
||||
expect(html).not.toContain('katex');
|
||||
});
|
||||
|
||||
test('leaves escaped brackets in prose as literal brackets', () => {
|
||||
const html = renderMarkdownSync('Release \\[Bug\\] fixed in v2.');
|
||||
expect(html).toContain('[Bug]');
|
||||
expect(html).not.toContain('katex');
|
||||
});
|
||||
|
||||
// Verbatim body of a Linear status comment, which Linear itself renders as
|
||||
// one link while we used to split it into three blocks.
|
||||
test('renders a Linear comment with an escaped-bracket title as one link', () => {
|
||||
const html = renderMarkdownSync(
|
||||
'[OpenChamber session completed: OPE-316 \\[Bug\\] Opening files with template-literal'
|
||||
+ ' code triggers catastrophic backtracking → renderer OOM → black/frozen desktop app'
|
||||
+ ' (v1.17.2)](http://127.0.0.1:63418/?session=ses_fb0bb916effe26bQ1Ofr6Rv4Ei)',
|
||||
);
|
||||
expect(html.match(/<a /g)).toHaveLength(1);
|
||||
expect(html).toContain('[Bug]');
|
||||
expect(html).not.toContain('katex');
|
||||
});
|
||||
|
||||
test('still renders display math that owns its line', () => {
|
||||
expect(renderMarkdownSync('\\[x = y\\]')).toContain('katex');
|
||||
expect(renderMarkdownSync('Before\n\n\\[\nx = y\n\\]\n\nAfter')).toContain('katex');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -314,15 +314,25 @@ const inlineMathExtension = {
|
||||
},
|
||||
};
|
||||
|
||||
// `\[` is display math in LaTeX, but it is also CommonMark's escape for a
|
||||
// literal `[`, and prose escapes brackets far more often than it opens display
|
||||
// math. Reading every `\[` as math turned text like
|
||||
// `[title \[Bug\] more](url)` into a KaTeX block that split the paragraph and
|
||||
// tore the link apart. Display math therefore has to own its line: it must
|
||||
// start one and its `\]` must end one. Anything mid-sentence stays an escape.
|
||||
const BLOCK_MATH_RE = /^[ \t]*\\\[([\s\S]+?)\\\][ \t]*(?:\n|$)/;
|
||||
const BLOCK_MATH_LINE_START_RE = /(?:^|\n)[ \t]*\\\[/;
|
||||
|
||||
const blockMathExtension = {
|
||||
name: 'blockMath',
|
||||
level: 'block' as const,
|
||||
start(src: string) {
|
||||
const index = src.indexOf('\\[');
|
||||
return index < 0 ? undefined : index;
|
||||
const match = BLOCK_MATH_LINE_START_RE.exec(src);
|
||||
// Point marked at the `\[` itself, never at the newline before it.
|
||||
return match ? match.index + match[0].length - 2 : undefined;
|
||||
},
|
||||
tokenizer(src: string): MathToken | undefined {
|
||||
const match = /^\\\[([\s\S]+?)\\\]/.exec(src);
|
||||
const match = BLOCK_MATH_RE.exec(src);
|
||||
if (!match) return undefined;
|
||||
return { type: 'blockMath', raw: match[0], text: match[1] ?? '' };
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readContextPart } from '@/lib/messages/contextParts';
|
||||
|
||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
const LINEAR_ISSUE_CONTEXT_PREFIX = 'Linear issue context (JSON)';
|
||||
|
||||
type GitHubIssueContextPayload = {
|
||||
issue?: {
|
||||
@@ -20,6 +21,14 @@ type GitHubPrContextPayload = {
|
||||
};
|
||||
};
|
||||
|
||||
type LinearIssueContextPayload = {
|
||||
issue?: {
|
||||
identifier?: unknown;
|
||||
title?: unknown;
|
||||
url?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const isPositiveNumber = (value: unknown): value is number => {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
};
|
||||
@@ -79,6 +88,24 @@ const buildGitHubAttachmentPart = (text: string): Part | null => {
|
||||
} as Part;
|
||||
}
|
||||
|
||||
const linearPayload = parseSyntheticJsonPayload<LinearIssueContextPayload>(text, LINEAR_ISSUE_CONTEXT_PREFIX);
|
||||
if (linearPayload) {
|
||||
const issue = linearPayload.issue;
|
||||
const identifier = issue?.identifier;
|
||||
const title = issue?.title;
|
||||
const url = issue?.url;
|
||||
if (typeof identifier !== 'string' || identifier.trim().length === 0 || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.openchamber.linear-issue-link',
|
||||
filename: `${identifier}: ${title}`,
|
||||
url,
|
||||
} as Part;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -106,7 +133,8 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
const normalizedText = text.trimStart();
|
||||
return shouldKeepSyntheticUserText(text, planModeEnabled)
|
||||
|| normalizedText.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX)
|
||||
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX);
|
||||
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX)
|
||||
|| normalizedText.startsWith(LINEAR_ISSUE_CONTEXT_PREFIX);
|
||||
})
|
||||
.map((part) => {
|
||||
const rawPart = part as Record<string, unknown>;
|
||||
@@ -119,10 +147,18 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
|
||||
if (synthetic) {
|
||||
const contextPayload = readContextPart(part);
|
||||
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr') {
|
||||
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr' || contextPayload?.kind === 'linear-issue') {
|
||||
// SAFETY: same display-only file-part shape the legacy
|
||||
// buildGitHubAttachmentPart produces; consumed by
|
||||
// FileAttachment, which matches on the mime type.
|
||||
if (contextPayload.kind === 'linear-issue') {
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.openchamber.linear-issue-link',
|
||||
filename: `${contextPayload.identifier}: ${contextPayload.title}`,
|
||||
url: contextPayload.url,
|
||||
} as Part;
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
mime: contextPayload.kind === 'github-issue'
|
||||
|
||||
@@ -127,10 +127,11 @@ Why: only navigation tools use the compact static path; all other tools need obs
|
||||
annotations, PR comments/checks): `UserContextPart.tsx`. `UserTextPart`
|
||||
routes to it when the part's metadata carries an `openchamberContext`
|
||||
payload (see `lib/messages/contextParts.ts`, which owns both the send-time
|
||||
builder and the read-back parser). Linked GitHub issues/PRs are instead
|
||||
converted to link file-parts in `normalizeUserDisplayParts.ts`. Legacy
|
||||
pre-metadata messages still render via text sniffing (`<terminal_context>`
|
||||
blocks, `GitHub issue context (JSON)` prefixes).
|
||||
builder and the read-back parser). Linked GitHub issues/PRs and Linear
|
||||
issues are instead converted to link file-parts in
|
||||
`normalizeUserDisplayParts.ts`. Legacy pre-metadata messages still render
|
||||
via text sniffing (`<terminal_context>` blocks, `GitHub issue context (JSON)`
|
||||
and `Linear issue context (JSON)` prefixes).
|
||||
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
||||
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
||||
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
|
||||
|
||||
@@ -185,6 +185,7 @@ const UserContextPart: React.FC<{
|
||||
);
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
case 'linear-issue':
|
||||
// Rendered as link attachments by normalizeUserDisplayParts.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -319,8 +319,10 @@ Stored in session metadata as a **snapshot** (`lib/linkedIssues.ts`, namespace
|
||||
pinned messages. Number, title, url, author and avatar only — the body,
|
||||
comments and state belong to GitHub, and mirroring them would mean owning their
|
||||
staleness. The stored title can drift; that is the price of a store that never
|
||||
needs refreshing. The row opens the real thread, which is where current state
|
||||
lives.
|
||||
needs refreshing. A GitHub row opens github.com. A Linear row opens the
|
||||
right-hand Linear panel when Linear is connected on desktop/web; otherwise it
|
||||
opens the Linear URL (no rail in VS Code or the phone shell, and none while
|
||||
disconnected).
|
||||
|
||||
Writes happen **after** the send promise resolves and are deliberately
|
||||
swallowed on failure: the message went out, and a missing bookkeeping entry
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Icon } from '@/components/icon/Icon';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useSession } from '@/sync/sync-context';
|
||||
import { getLinkedIssues } from '@/lib/linkedIssues';
|
||||
import { getLinkedIssues, canOpenLinearIssueInContextPanel } from '@/lib/linkedIssues';
|
||||
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
@@ -12,6 +12,10 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { resolveProjectContextId } from '@/lib/projectContextApi';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useMobileAppActions } from '@/apps/mobileAppContext';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
|
||||
import { useReportWorkStatusPresence } from './presenceContext';
|
||||
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
|
||||
@@ -32,6 +36,11 @@ type Props = {
|
||||
*/
|
||||
export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory }) => {
|
||||
const { t } = useI18n();
|
||||
const { linear } = useRuntimeAPIs();
|
||||
const linearConnected = useLinearAuthStore((state) => state.status?.connected === true);
|
||||
const mobileActions = useMobileAppActions();
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
const setLinearIssueFocus = useUIStore((state) => state.setLinearIssueFocus);
|
||||
|
||||
const session = useSession(sessionId ?? '', directory ?? undefined);
|
||||
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
|
||||
@@ -138,6 +147,23 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
const pinnedCount = visibleKnowledge.notes.length + visibleKnowledge.plans.length;
|
||||
|
||||
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
|
||||
const openLinkedIssue = React.useCallback((entry: (typeof linked)[number]) => {
|
||||
if (
|
||||
entry.kind === 'linear'
|
||||
&& directory
|
||||
&& canOpenLinearIssueInContextPanel({
|
||||
linearAvailable: Boolean(linear),
|
||||
linearConnected,
|
||||
inDedicatedMobileShell: mobileActions != null,
|
||||
directory,
|
||||
})
|
||||
) {
|
||||
setLinearIssueFocus(entry.identifier);
|
||||
openContextPanelTab(directory, { mode: 'linear' });
|
||||
return;
|
||||
}
|
||||
window.open(entry.url, '_blank', 'noopener,noreferrer');
|
||||
}, [directory, linear, linearConnected, mobileActions, openContextPanelTab, setLinearIssueFocus]);
|
||||
// Connected servers only. A disabled server contributes nothing to the
|
||||
// context, so counting it here contradicts the MCP section right above,
|
||||
// which shows the same servers switched off.
|
||||
@@ -158,8 +184,8 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
// The heading names what is distinctive about this session when there is
|
||||
// something — an attached thread — and falls back to the ambient counts
|
||||
// when there is not. `1 · 33 · 2` said nothing without opening the section.
|
||||
const issueCount = linked.filter((entry) => entry.kind === 'issue').length;
|
||||
const prCount = linked.length - issueCount;
|
||||
const issueCount = linked.filter((entry) => entry.kind === 'issue' || entry.kind === 'linear').length;
|
||||
const prCount = linked.filter((entry) => entry.kind === 'pull').length;
|
||||
const summaryParts: string[] = [];
|
||||
if (issueCount > 0) {
|
||||
summaryParts.push(issueCount === 1
|
||||
@@ -206,17 +232,23 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
<img src={entry.authorAvatarUrl} alt="" className="size-4 shrink-0 rounded-full" loading="lazy" />
|
||||
) : (
|
||||
<Icon
|
||||
name={entry.kind === 'pull' ? 'git-pull-request' : 'error-warning'}
|
||||
name={entry.kind === 'pull' ? 'git-pull-request' : entry.kind === 'linear' ? 'linear' : 'error-warning'}
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
label={entry.title}
|
||||
muted
|
||||
// The stored snapshot is enough to render; the live thread only ever
|
||||
// exists on github.com.
|
||||
onClick={() => window.open(entry.url, '_blank', 'noopener,noreferrer')}
|
||||
ariaLabel={t('chat.workStatus.linkedIssues.open', { number: entry.number })}
|
||||
value={<WorkStatusValue tone="muted">{`#${entry.number}`}</WorkStatusValue>}
|
||||
// GitHub threads still live on github.com. A Linear issue opens in
|
||||
// the right-hand panel when that rail exists; otherwise the Linear URL.
|
||||
onClick={() => openLinkedIssue(entry)}
|
||||
ariaLabel={entry.kind === 'linear'
|
||||
? t('chat.workStatus.linkedIssues.openLinear', { identifier: entry.identifier })
|
||||
: t('chat.workStatus.linkedIssues.open', { number: entry.number })}
|
||||
value={(
|
||||
<WorkStatusValue tone="muted">
|
||||
{entry.kind === 'linear' ? entry.identifier : `#${entry.number}`}
|
||||
</WorkStatusValue>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ export const iconSpriteData = {
|
||||
"target": `<path d="M12 1.99999C12.5523 1.99999 13 2.4477 13 2.99999C12.9999 3.55224 12.5522 3.99999 12 3.99999C7.58172 3.99999 4 7.58171 4 12C4.00004 16.4182 7.58174 20 12 20C16.4182 20 19.9999 16.4182 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C21.9999 17.5228 17.5228 22 12 22C6.47717 22 2.00004 17.5228 2 12C2 6.47714 6.47715 1.99999 12 1.99999ZM12 5.99999C12.5523 5.99999 13 6.4477 13 6.99999C12.9999 7.55224 12.5522 7.99999 12 7.99999C9.79085 7.99999 7.99999 9.79085 7.99999 12C8.00004 14.2091 9.79088 16 12 16C14.2091 16 15.9999 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C17.9999 15.3137 15.3137 18 12 18C8.68631 18 6.00004 15.3137 6 12C6 8.68628 8.68629 5.99999 12 5.99999ZM17.6562 2.10057C18.0468 1.71005 18.6807 1.71005 19.0713 2.10057C19.4614 2.49105 19.4615 3.12419 19.0713 3.51463L18.3633 4.22069L18.3642 4.22167C17.9737 4.61219 17.9737 5.2452 18.3642 5.63573C18.7548 6.02612 19.3878 6.02621 19.7783 5.63573L20.4853 4.9287C20.8759 4.53839 21.5089 4.53826 21.8994 4.9287C22.2899 5.31915 22.2897 5.95222 21.8994 6.34276L19.7783 8.46483C19.5909 8.65223 19.3363 8.75671 19.0713 8.75682H16.6572L12.707 12.707C12.3165 13.0974 11.6834 13.0974 11.293 12.707C10.9025 12.3165 10.9026 11.6835 11.293 11.293L15.2422 7.34374V4.9287C15.2422 4.66356 15.3477 4.40916 15.5351 4.22167L17.6562 2.10057Z" fill="currentColor"/>`,
|
||||
"target-fill": `<path d="M12 2C12.5523 2 13 2.44772 13 3C13 3.55228 12.5523 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 6C12.5523 6 13 6.44772 13 7C13 7.55228 12.5523 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6ZM18.5713 2.10059C18.8474 2.1006 19.0712 2.32449 19.0713 2.60059V4.42969C19.0716 4.70553 19.2954 4.92866 19.5713 4.92871H21.3994C21.6754 4.92871 21.8992 5.15275 21.8994 5.42871V6.34375L20.0107 8.23242C19.6358 8.60719 19.1268 8.81824 18.5967 8.81836H16.5967L12.707 12.707C12.3165 13.0974 11.6835 13.0975 11.293 12.707C10.9027 12.3165 10.9026 11.6834 11.293 11.293L15.1826 7.4043V5.4043C15.1826 4.87411 15.3928 4.36526 15.7676 3.99023L17.6572 2.10059H18.5713Z" fill="currentColor"/>`,
|
||||
"task": `<path d="M19 4H5V20H19V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H19.9997C20.5519 2 20.9996 2.44772 20.9997 3L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11.2929 13.1213L15.5355 8.87868L16.9497 10.2929L11.2929 15.9497L7.40381 12.0607L8.81802 10.6464L11.2929 13.1213Z" fill="currentColor"/>`,
|
||||
"team": `<path d="M12 11C14.7614 11 17 13.2386 17 16V22H15V16C15 14.4023 13.7511 13.0963 12.1763 13.0051L12 13C10.4023 13 9.09634 14.2489 9.00509 15.8237L9 16V22H7V16C7 13.2386 9.23858 11 12 11ZM5.5 14C5.77885 14 6.05009 14.0326 6.3101 14.0942C6.14202 14.594 6.03873 15.122 6.00896 15.6693L6 16L6.0007 16.0856C5.88757 16.0456 5.76821 16.0187 5.64446 16.0069L5.5 16C4.7203 16 4.07955 16.5949 4.00687 17.3555L4 17.5V22H2V17.5C2 15.567 3.567 14 5.5 14ZM18.5 14C20.433 14 22 15.567 22 17.5V22H20V17.5C20 16.7203 19.4051 16.0796 18.6445 16.0069L18.5 16C18.3248 16 18.1566 16.03 18.0003 16.0852L18 16C18 15.3343 17.8916 14.694 17.6915 14.0956C17.9499 14.0326 18.2211 14 18.5 14ZM5.5 8C6.88071 8 8 9.11929 8 10.5C8 11.8807 6.88071 13 5.5 13C4.11929 13 3 11.8807 3 10.5C3 9.11929 4.11929 8 5.5 8ZM18.5 8C19.8807 8 21 9.11929 21 10.5C21 11.8807 19.8807 13 18.5 13C17.1193 13 16 11.8807 16 10.5C16 9.11929 17.1193 8 18.5 8ZM5.5 10C5.22386 10 5 10.2239 5 10.5C5 10.7761 5.22386 11 5.5 11C5.77614 11 6 10.7761 6 10.5C6 10.2239 5.77614 10 5.5 10ZM18.5 10C18.2239 10 18 10.2239 18 10.5C18 10.7761 18.2239 11 18.5 11C18.7761 11 19 10.7761 19 10.5C19 10.2239 18.7761 10 18.5 10ZM12 2C14.2091 2 16 3.79086 16 6C16 8.20914 14.2091 10 12 10C9.79086 10 8 8.20914 8 6C8 3.79086 9.79086 2 12 2ZM12 4C10.8954 4 10 4.89543 10 6C10 7.10457 10.8954 8 12 8C13.1046 8 14 7.10457 14 6C14 4.89543 13.1046 4 12 4Z" fill="currentColor"/>`,
|
||||
"terminal": `<path d="M10.9999 12L3.92886 19.0711L2.51465 17.6569L8.1715 12L2.51465 6.34317L3.92886 4.92896L10.9999 12ZM10.9999 19H20.9999V21H10.9999V19Z" fill="currentColor"/>`,
|
||||
"terminal-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM12 15H18V17H12V15ZM8.66685 12L5.83842 9.17157L7.25264 7.75736L11.4953 12L7.25264 16.2426L5.83842 14.8284L8.66685 12Z" fill="currentColor"/>`,
|
||||
"terminal-window": `<path d="M20 9V5H4V9H20ZM20 11H4V19H20V11ZM3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM5 12H8V17H5V12ZM5 6H7V8H5V6ZM9 6H11V8H9V6Z" fill="currentColor"/>`,
|
||||
|
||||
@@ -16,6 +16,9 @@ const WalkthroughView = lazyWithChunkRecovery(() => import('@/components/views/w
|
||||
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then((m) => ({ default: m.DiffView })));
|
||||
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then((m) => ({ default: m.FilesView })));
|
||||
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then((m) => ({ default: m.GitView })));
|
||||
// The Linear rail icon stays hidden until a workspace is connected, so most
|
||||
// users never render this panel; keep it out of the main bundle.
|
||||
const LinearIssuesView = lazyWithChunkRecovery(() => import('@/components/views/LinearIssuesView').then((m) => ({ default: m.LinearIssuesView })));
|
||||
const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then((m) => ({ default: m.PlanView })));
|
||||
import { ProjectContextPanel } from './RightSidebarTabs';
|
||||
import { SidebarFilesTree } from './SidebarFilesTree';
|
||||
@@ -119,6 +122,7 @@ const getModeLabel = (
|
||||
if (mode === 'browser') return t('contextPanel.mode.browser');
|
||||
if (mode === 'git') return t('layout.rightSidebar.git');
|
||||
if (mode === 'pr') return t('contextPanel.mode.pr');
|
||||
if (mode === 'linear') return t('contextPanel.mode.linear');
|
||||
if (mode === 'notes') return t('contextRail.surface.notes');
|
||||
if (mode === 'terminal') return t('layout.mainTab.terminal');
|
||||
return t('contextPanel.mode.context');
|
||||
@@ -213,6 +217,10 @@ const getTabIcon = (
|
||||
return <Icon name="github" className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (tab.mode === 'linear') {
|
||||
return <Icon name="linear" className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (tab.mode === 'notes') {
|
||||
return <Icon name="sticky-note" className="h-3.5 w-3.5" />;
|
||||
}
|
||||
@@ -940,6 +948,8 @@ export const ContextPanel: React.FC = () => {
|
||||
? <React.Suspense fallback={null}><GitView isActive={isOpen} /></React.Suspense>
|
||||
: activeTab?.mode === 'pr'
|
||||
? <PullRequestView />
|
||||
: activeTab?.mode === 'linear'
|
||||
? <React.Suspense fallback={null}><LinearIssuesView /></React.Suspense>
|
||||
: activeTab?.mode === 'notes'
|
||||
? <ProjectContextPanel />
|
||||
: activeTab?.mode === 'plan'
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
|
||||
import { ContextRailSurfacesDialog } from './ContextRailSurfacesDialog';
|
||||
|
||||
@@ -165,8 +166,11 @@ export const ContextPanelRail: React.FC = () => {
|
||||
const contextRailHiddenSurfaces = useUIStore((state) => state.contextRailHiddenSurfaces);
|
||||
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
|
||||
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
||||
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
|
||||
const linearConnected = useLinearAuthStore((state) => state.status?.connected === true);
|
||||
const { screenWidth } = useDeviceInfo();
|
||||
const gitStatus = useGitStatus(directoryKey || null);
|
||||
|
||||
@@ -263,8 +267,16 @@ export const ContextPanelRail: React.FC = () => {
|
||||
isVSCode: isVSCodeRuntime(),
|
||||
screenWidth,
|
||||
tabs,
|
||||
linearConnected,
|
||||
});
|
||||
}, [contextRailHiddenSurfaces, contextRailOrder, planModeEnabled, screenWidth, tabs]);
|
||||
}, [contextRailHiddenSurfaces, contextRailOrder, linearConnected, planModeEnabled, screenWidth, tabs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!directoryKey || !linearAuthChecked || linearConnected || activeMode !== 'linear') {
|
||||
return;
|
||||
}
|
||||
closeContextPanel(directoryKey);
|
||||
}, [activeMode, closeContextPanel, directoryKey, linearAuthChecked, linearConnected]);
|
||||
|
||||
const [isSurfacesDialogOpen, setIsSurfacesDialogOpen] = React.useState(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Guards from the OPE-296 review: stale Linear list pages must not land, and a
|
||||
* persisted Linear tab must survive reload until auth has actually resolved.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const railSource = readFileSync(join(__dirname, '..', 'ContextPanelRail.tsx'), 'utf-8');
|
||||
const issuesViewSource = readFileSync(join(__dirname, '..', '..', 'views', 'LinearIssuesView.tsx'), 'utf-8');
|
||||
const pickerSource = readFileSync(join(__dirname, '..', '..', 'session', 'LinearIssuePickerDialog.tsx'), 'utf-8');
|
||||
|
||||
const sliceFn = (source: string, marker: string, length: number) => {
|
||||
const start = source.indexOf(marker);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
return source.slice(start, start + length);
|
||||
};
|
||||
|
||||
describe('Linear panel review guards', () => {
|
||||
test('disconnect-close waits for Linear auth to resolve', () => {
|
||||
const effect = sliceFn(railSource, 'if (!directoryKey || !linearAuthChecked || linearConnected || activeMode !== \'linear\')', 240);
|
||||
expect(effect).toContain('closeContextPanel(directoryKey)');
|
||||
expect(railSource).toContain('state.hasChecked');
|
||||
});
|
||||
|
||||
test('rail loadMore shares listRequestId with refresh', () => {
|
||||
const loadMore = sliceFn(issuesViewSource, 'const loadMore = React.useCallback(async () => {', 900);
|
||||
expect(loadMore).toContain('const requestId = listRequestId.current + 1');
|
||||
expect(loadMore).toContain('if (requestId !== listRequestId.current) return');
|
||||
});
|
||||
|
||||
test('picker refresh and loadMore reject stale pages', () => {
|
||||
const refresh = sliceFn(pickerSource, 'const refresh = React.useCallback(async (search = \'\') => {', 1400);
|
||||
const loadMore = sliceFn(pickerSource, 'const loadMore = React.useCallback(async () => {', 900);
|
||||
expect(refresh).toContain('const requestId = listRequestId.current + 1');
|
||||
expect(refresh).toContain('if (requestId !== listRequestId.current) return');
|
||||
expect(loadMore).toContain('const requestId = listRequestId.current + 1');
|
||||
expect(loadMore).toContain('if (requestId !== listRequestId.current) return');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { SETTINGS_DESCRIPTION_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { LinearSettings } from './LinearSettings';
|
||||
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
@@ -15,25 +15,17 @@ export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
|
||||
onOpenPluginManager,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasLinear = Boolean(getRegisteredRuntimeAPIs()?.linear);
|
||||
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
title={t('settings.page.integrations.title')}
|
||||
description={(
|
||||
<div className="space-y-3">
|
||||
<p className={SETTINGS_DESCRIPTION_CLASS}>{t('settings.page.integrations.description')}</p>
|
||||
<div role="alert" className="flex items-start gap-2 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
|
||||
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
{t('settings.integrations.experimentalWarning')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
showSaveStatus={false}
|
||||
description={t('settings.page.integrations.description')}
|
||||
showSaveStatus
|
||||
>
|
||||
{hasLinear ? <LinearSettings /> : null}
|
||||
<ThirdPartyIntegrationsSection
|
||||
divider={false}
|
||||
divider={hasLinear}
|
||||
onOpenProviderSetup={onOpenProviderSetup}
|
||||
onOpenPluginManager={onOpenPluginManager}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import React from 'react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
SettingsControlGroup,
|
||||
SettingsFieldRow,
|
||||
SETTINGS_FIELDS_STACK_CLASS,
|
||||
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { reportSettingsSaveState } from '@/lib/persistence';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import type { LinearAPI, LinearMappingResult } from '@/lib/api/types';
|
||||
|
||||
const NONE = '__none__';
|
||||
const INHERIT = '__inherit__';
|
||||
|
||||
export function LinearProjectMapping({
|
||||
linear,
|
||||
connected,
|
||||
organizationId,
|
||||
}: {
|
||||
linear: LinearAPI;
|
||||
connected: boolean;
|
||||
organizationId?: string | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const [mapping, setMapping] = React.useState<LinearMappingResult | null>(null);
|
||||
const [loadFailed, setLoadFailed] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
const loadMapping = React.useCallback(async () => {
|
||||
if (!connected) {
|
||||
setMapping(null);
|
||||
setLoadFailed(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const next = await linear.mappingGet();
|
||||
if (next.connected === false) {
|
||||
setMapping(null);
|
||||
setLoadFailed(false);
|
||||
return;
|
||||
}
|
||||
setMapping(next);
|
||||
setLoadFailed(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load Linear mapping:', error);
|
||||
setLoadFailed(true);
|
||||
}
|
||||
}, [connected, linear, organizationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadMapping();
|
||||
}, [loadMapping]);
|
||||
|
||||
const saveMapping = React.useCallback(async (next: LinearMappingResult) => {
|
||||
const teamProjectPaths: { [teamId: string]: string } = {};
|
||||
for (const team of next.teams ?? []) {
|
||||
if (team.projectPath) {
|
||||
teamProjectPaths[team.id] = team.projectPath;
|
||||
}
|
||||
}
|
||||
setIsSaving(true);
|
||||
reportSettingsSaveState('saving');
|
||||
try {
|
||||
const saved = await linear.mappingSet({
|
||||
defaultProjectPath: next.defaultProjectPath ?? null,
|
||||
teamProjectPaths,
|
||||
});
|
||||
if (saved.connected === false) {
|
||||
setMapping(null);
|
||||
reportSettingsSaveState('error');
|
||||
return;
|
||||
}
|
||||
setMapping(saved);
|
||||
setLoadFailed(false);
|
||||
reportSettingsSaveState('saved');
|
||||
} catch (error) {
|
||||
console.error('Failed to save Linear mapping:', error);
|
||||
reportSettingsSaveState('error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [linear]);
|
||||
|
||||
if (!connected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loadFailed && !mapping) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.integrations.linear.mapping.loadFailed')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectLabel = (path: string) => {
|
||||
const project = projects.find((entry) => entry.path === path);
|
||||
return project?.label?.trim() || path;
|
||||
};
|
||||
|
||||
const defaultProjectLabel = (value: string | undefined) => {
|
||||
if (!value || value === NONE) {
|
||||
return t('settings.integrations.linear.mapping.defaultProject.placeholder');
|
||||
}
|
||||
return projectLabel(value);
|
||||
};
|
||||
|
||||
const teamProjectLabel = (value: string | undefined) => {
|
||||
if (!value || value === INHERIT) {
|
||||
return t('settings.integrations.linear.mapping.teams.useDefault');
|
||||
}
|
||||
return projectLabel(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={SETTINGS_FIELDS_STACK_CLASS}>
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.integrations.linear.mapping.emptyProjects')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingsFieldRow
|
||||
label={t('settings.integrations.linear.mapping.defaultProject')}
|
||||
info={t('settings.integrations.linear.mapping.defaultProject.info')}
|
||||
settingsItem="integrations.linear.mapping"
|
||||
>
|
||||
<Select
|
||||
value={mapping.defaultProjectPath || NONE}
|
||||
disabled={isSaving || projects.length === 0}
|
||||
onValueChange={(value) => {
|
||||
void saveMapping({
|
||||
...mapping,
|
||||
defaultProjectPath: value === NONE ? null : value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size={SETTINGS_SELECT_SIZE}
|
||||
className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}
|
||||
aria-label={t('settings.integrations.linear.mapping.defaultProject.aria')}
|
||||
>
|
||||
<SelectValue placeholder={t('settings.integrations.linear.mapping.defaultProject.placeholder')}>
|
||||
{defaultProjectLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>
|
||||
{t('settings.integrations.linear.mapping.defaultProject.placeholder')}
|
||||
</SelectItem>
|
||||
{mapping.defaultProjectPath && !projects.some((entry) => entry.path === mapping.defaultProjectPath) ? (
|
||||
<SelectItem value={mapping.defaultProjectPath}>{mapping.defaultProjectPath}</SelectItem>
|
||||
) : null}
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.path}>
|
||||
{projectLabel(project.path)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsFieldRow>
|
||||
|
||||
<SettingsControlGroup
|
||||
title={t('settings.integrations.linear.mapping.teams')}
|
||||
info={t('settings.integrations.linear.mapping.teams.info')}
|
||||
>
|
||||
{(mapping.teams ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.integrations.linear.mapping.emptyTeams')}
|
||||
</p>
|
||||
) : (
|
||||
<div className={SETTINGS_FIELDS_STACK_CLASS}>
|
||||
{(mapping.teams ?? []).map((team) => (
|
||||
<SettingsFieldRow
|
||||
key={team.id}
|
||||
label={`${team.key} · ${team.name}`}
|
||||
>
|
||||
<Select
|
||||
value={team.projectPath || INHERIT}
|
||||
disabled={isSaving || projects.length === 0}
|
||||
onValueChange={(value) => {
|
||||
void saveMapping({
|
||||
...mapping,
|
||||
teams: (mapping.teams ?? []).map((entry) => (
|
||||
entry.id === team.id
|
||||
? { ...entry, projectPath: value === INHERIT ? null : value }
|
||||
: entry
|
||||
)),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size={SETTINGS_SELECT_SIZE}
|
||||
className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}
|
||||
aria-label={t('settings.integrations.linear.mapping.teams.aria', { team: team.key })}
|
||||
>
|
||||
<SelectValue placeholder={t('settings.integrations.linear.mapping.teams.useDefault')}>
|
||||
{teamProjectLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={INHERIT}>
|
||||
{t('settings.integrations.linear.mapping.teams.useDefault')}
|
||||
</SelectItem>
|
||||
{team.projectPath && !projects.some((entry) => entry.path === team.projectPath) ? (
|
||||
<SelectItem value={team.projectPath}>{team.projectPath}</SelectItem>
|
||||
) : null}
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.path}>
|
||||
{projectLabel(project.path)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsFieldRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsControlGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
SettingsFieldRow,
|
||||
SETTINGS_FIELDS_STACK_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { reportSettingsSaveState } from '@/lib/persistence';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { LinearAPI } from '@/lib/api/types';
|
||||
|
||||
/**
|
||||
* Status comments are written into a Linear workspace other people read, so
|
||||
* they stay off until the user turns them on. The server posts nothing while
|
||||
* this is off, including the completed and failure comments the event hub
|
||||
* sends without going through this interface.
|
||||
*/
|
||||
export function LinearSessionComments({
|
||||
linear,
|
||||
connected,
|
||||
}: {
|
||||
linear: LinearAPI;
|
||||
connected: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [enabled, setEnabled] = React.useState<boolean | null>(null);
|
||||
const [loadFailed, setLoadFailed] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!connected) {
|
||||
setEnabled(null);
|
||||
setLoadFailed(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void linear.preferencesGet()
|
||||
.then((preferences) => {
|
||||
if (cancelled) return;
|
||||
setEnabled(preferences.sessionComments);
|
||||
setLoadFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setLoadFailed(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connected, linear]);
|
||||
|
||||
const save = React.useCallback(async (next: boolean) => {
|
||||
const previous = enabled;
|
||||
setEnabled(next);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const saved = await linear.preferencesSet({ sessionComments: next });
|
||||
setEnabled(saved.sessionComments);
|
||||
reportSettingsSaveState('saved');
|
||||
} catch {
|
||||
setEnabled(previous);
|
||||
reportSettingsSaveState('error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [enabled, linear]);
|
||||
|
||||
if (!connected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loadFailed) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.integrations.linear.sessionComments.loadFailed')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={SETTINGS_FIELDS_STACK_CLASS}>
|
||||
<SettingsFieldRow
|
||||
label={t('settings.integrations.linear.sessionComments.label')}
|
||||
info={t('settings.integrations.linear.sessionComments.info')}
|
||||
settingsItem="integrations.linear.session-comments"
|
||||
>
|
||||
<Switch
|
||||
checked={enabled === true}
|
||||
disabled={enabled === null || isSaving}
|
||||
onCheckedChange={(checked) => { void save(checked); }}
|
||||
aria-label={t('settings.integrations.linear.sessionComments.aria')}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { focusDesktopWindow, isDesktopShell } from '@/lib/desktop';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { LinearProjectMapping } from './LinearProjectMapping';
|
||||
import { LinearSessionComments } from './LinearSessionComments';
|
||||
|
||||
const AUTHORIZATION_WATCH_MS = 3 * 60_000;
|
||||
const AUTHORIZATION_POLL_MS = 1_500;
|
||||
|
||||
type WorkspaceSnapshot = {
|
||||
connected: boolean;
|
||||
ids: string;
|
||||
currentId: string;
|
||||
currentAuthorizedAt: number;
|
||||
};
|
||||
|
||||
function snapshotWorkspaces(status: {
|
||||
connected?: boolean;
|
||||
organization?: { id?: string } | null;
|
||||
workspaces?: Array<{ id: string; current: boolean; authorizedAt?: number | null }>;
|
||||
} | null): WorkspaceSnapshot {
|
||||
const workspaces = status?.workspaces ?? [];
|
||||
const current = workspaces.find((entry) => entry.current);
|
||||
return {
|
||||
connected: Boolean(status?.connected),
|
||||
ids: workspaces.map((entry) => entry.id).slice().sort().join(','),
|
||||
currentId: current?.id || status?.organization?.id || '',
|
||||
currentAuthorizedAt: current?.authorizedAt ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function authorizationCompleted(previous: WorkspaceSnapshot, next: WorkspaceSnapshot): boolean {
|
||||
if (!next.connected) return false;
|
||||
if (!previous.connected) return true;
|
||||
return next.ids !== previous.ids
|
||||
|| next.currentId !== previous.currentId
|
||||
|| next.currentAuthorizedAt !== previous.currentAuthorizedAt;
|
||||
}
|
||||
|
||||
export const LinearSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const runtimeLinear = getRegisteredRuntimeAPIs()?.linear;
|
||||
const status = useLinearAuthStore((state) => state.status);
|
||||
const isLoading = useLinearAuthStore((state) => state.isLoading);
|
||||
const hasChecked = useLinearAuthStore((state) => state.hasChecked);
|
||||
const refreshStatus = useLinearAuthStore((state) => state.refreshStatus);
|
||||
const setStatus = useLinearAuthStore((state) => state.setStatus);
|
||||
|
||||
const [isBusy, setIsBusy] = React.useState(false);
|
||||
const [isWaiting, setIsWaiting] = React.useState(false);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const pollTimerRef = React.useRef<number | null>(null);
|
||||
|
||||
const stopWaiting = React.useCallback(() => {
|
||||
if (pollTimerRef.current != null) {
|
||||
window.clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
setIsWaiting(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!runtimeLinear) {
|
||||
return;
|
||||
}
|
||||
if (!hasChecked) {
|
||||
void refreshStatus(runtimeLinear);
|
||||
}
|
||||
return () => {
|
||||
stopWaiting();
|
||||
};
|
||||
}, [hasChecked, refreshStatus, runtimeLinear, stopWaiting]);
|
||||
|
||||
const startConnect = React.useCallback(async () => {
|
||||
if (!runtimeLinear) return;
|
||||
stopWaiting();
|
||||
setIsBusy(true);
|
||||
const previous = snapshotWorkspaces(useLinearAuthStore.getState().status);
|
||||
try {
|
||||
const payload = await runtimeLinear.authStart(isDesktopShell() ? 'desktop' : 'web');
|
||||
setIsWaiting(true);
|
||||
setOpen(true);
|
||||
void openExternalUrl(payload.authorizationUrl);
|
||||
|
||||
const deadline = Date.now() + AUTHORIZATION_WATCH_MS;
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
void (async () => {
|
||||
if (Date.now() > deadline) {
|
||||
stopWaiting();
|
||||
toast.error(t('settings.integrations.linear.toast.authorizationFailed'));
|
||||
return;
|
||||
}
|
||||
const next = await refreshStatus(runtimeLinear, { force: true });
|
||||
if (authorizationCompleted(previous, snapshotWorkspaces(next))) {
|
||||
stopWaiting();
|
||||
toast.success(t('settings.integrations.linear.toast.connected'));
|
||||
void focusDesktopWindow();
|
||||
}
|
||||
})();
|
||||
}, AUTHORIZATION_POLL_MS);
|
||||
} catch (error) {
|
||||
console.error('Failed to start Linear connect:', error);
|
||||
toast.error(t('settings.integrations.linear.toast.startConnectFailed'));
|
||||
stopWaiting();
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [refreshStatus, runtimeLinear, stopWaiting, t]);
|
||||
|
||||
const activateWorkspace = React.useCallback(async (organizationId: string) => {
|
||||
if (!runtimeLinear || !organizationId) return;
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const payload = await runtimeLinear.authActivate(organizationId);
|
||||
setStatus(payload);
|
||||
toast.success(t('settings.integrations.linear.toast.workspaceSwitched'));
|
||||
} catch (error) {
|
||||
console.error('Failed to switch Linear workspace:', error);
|
||||
toast.error(t('settings.integrations.linear.toast.workspaceSwitchFailed'));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [runtimeLinear, setStatus, t]);
|
||||
|
||||
const disconnect = React.useCallback(async () => {
|
||||
if (!runtimeLinear) return;
|
||||
setIsBusy(true);
|
||||
try {
|
||||
stopWaiting();
|
||||
await runtimeLinear.authDisconnect();
|
||||
toast.success(t('settings.integrations.linear.toast.disconnected'));
|
||||
await refreshStatus(runtimeLinear, { force: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect Linear:', error);
|
||||
toast.error(t('settings.integrations.linear.toast.disconnectFailed'));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [refreshStatus, runtimeLinear, stopWaiting, t]);
|
||||
|
||||
if (!runtimeLinear) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connected = Boolean(status?.connected);
|
||||
const user = status?.user;
|
||||
const organization = status?.organization;
|
||||
const workspaces = status?.workspaces ?? [];
|
||||
const otherWorkspaces = workspaces.filter((workspace) => !workspace.current);
|
||||
const displayName = user?.displayName?.trim() || user?.name?.trim() || t('settings.integrations.linear.label.unknownUser');
|
||||
const statusLabel = isWaiting
|
||||
? t('settings.integrations.linear.status.waiting')
|
||||
: isLoading && !hasChecked
|
||||
? t('common.loading')
|
||||
: connected
|
||||
? (organization?.name?.trim() || t('settings.integrations.linear.status.connected'))
|
||||
: t('settings.integrations.linear.status.notConnected');
|
||||
const statusClassName = isWaiting
|
||||
? 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]'
|
||||
: connected
|
||||
? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
|
||||
: 'bg-[var(--surface-muted)] text-muted-foreground';
|
||||
const expanded = isWaiting || open;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.integrations.firstParty.title')}
|
||||
info={t('settings.integrations.firstParty.info')}
|
||||
divider={false}
|
||||
settingsItem="integrations.first-party"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<Collapsible
|
||||
open={expanded}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (isWaiting) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-settings-item="integrations.linear"
|
||||
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
|
||||
<Icon name="linear" className="size-5 text-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-foreground">
|
||||
{t('settings.integrations.linear.title')}
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
|
||||
{t('settings.integrations.linear.description')}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
'max-w-36 shrink-0 truncate rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||
statusClassName,
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<Icon
|
||||
name="arrow-down-s"
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out motion-reduce:transition-none',
|
||||
expanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
{connected ? (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{user?.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={t('settings.integrations.linear.avatarAlt.withName', { name: displayName })}
|
||||
className="size-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]">
|
||||
<Icon name="linear" className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{displayName}</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{[organization?.name, user?.email].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isWaiting ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.integrations.linear.flow.description')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{connected ? (
|
||||
<>
|
||||
<LinearProjectMapping
|
||||
linear={runtimeLinear}
|
||||
connected={connected}
|
||||
organizationId={organization?.id ?? null}
|
||||
/>
|
||||
<LinearSessionComments linear={runtimeLinear} connected={connected} />
|
||||
{otherWorkspaces.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{t('settings.integrations.linear.label.otherWorkspaces')}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{otherWorkspaces.map((workspace) => {
|
||||
const workspaceUser = workspace.user;
|
||||
const workspaceName = workspace.name?.trim()
|
||||
|| t('settings.integrations.linear.status.connected');
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-[var(--surface-subtle)] bg-[var(--surface-muted)] px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-foreground">{workspaceName}</div>
|
||||
{workspaceUser?.email ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{workspaceUser.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void activateWorkspace(workspace.id)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{t('settings.integrations.linear.actions.switchTo')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void startConnect()}
|
||||
disabled={isBusy || isWaiting}
|
||||
data-settings-item="integrations.linear.add-workspace"
|
||||
>
|
||||
{t('settings.integrations.linear.actions.addWorkspace')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void disconnect()}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{t('settings.integrations.linear.actions.disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : isWaiting ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="typography-micro text-muted-foreground animate-pulse">
|
||||
{t('settings.integrations.linear.flow.waiting')}
|
||||
</span>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={isBusy} onClick={stopWaiting}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => void startConnect()}
|
||||
disabled={isBusy || (isLoading && !hasChecked)}
|
||||
>
|
||||
{isBusy ? <Icon name="loader-4" className="size-3.5 animate-spin" /> : null}
|
||||
{t('settings.integrations.linear.actions.connect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -414,6 +414,12 @@ export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSecti
|
||||
settingsItem="integrations.third-party"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div role="alert" className="flex items-start gap-2 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
|
||||
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
{t('settings.integrations.experimentalWarning')}
|
||||
</p>
|
||||
</div>
|
||||
{THIRD_PARTY_PLUGINS.map(renderPlugin)}
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
@@ -61,6 +61,14 @@ const PROMPT_PAGE_MAP: Record<string, PromptPageConfig> = {
|
||||
{ id: 'github.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'linear.issue.review': {
|
||||
titleKey: 'settings.magicPrompts.page.group.linearIssueReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.linearIssueReview.description',
|
||||
blocks: [
|
||||
{ id: 'linear.issue.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'linear.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'github.pr.checks.review': {
|
||||
titleKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.description',
|
||||
|
||||
@@ -35,6 +35,12 @@ export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItem
|
||||
{ id: 'github.pr.comment.single', titleKey: 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.linear',
|
||||
items: [
|
||||
{ id: 'linear.issue.review', titleKey: 'settings.magicPrompts.sidebar.item.linearIssueReview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.planning',
|
||||
items: [
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { buildIssueContextText, startLinearIssueSession } from '@/lib/linearStartSession';
|
||||
import type { LinearIssueSummary, LinearMappingResult } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const parseLinearIssueQuery = (value: string): string | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const urlMatch = trimmed.match(/linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i);
|
||||
if (urlMatch) return urlMatch[1].toUpperCase();
|
||||
if (/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(trimmed)) return trimmed.toUpperCase();
|
||||
return null;
|
||||
};
|
||||
|
||||
export function LinearIssuePickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode = 'select',
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode?: 'createSession' | 'select';
|
||||
onSelect?: (issue: {
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { linear } = useRuntimeAPIs();
|
||||
const linearAuthStatus = useLinearAuthStore((state) => state.status);
|
||||
const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
|
||||
const refreshStatus = useLinearAuthStore((state) => state.refreshStatus);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { isTablet } = useDeviceInfo();
|
||||
const alwaysShowActions = isMobile || isTablet;
|
||||
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [issues, setIssues] = React.useState<LinearIssueSummary[]>([]);
|
||||
const [cursor, setCursor] = React.useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
const [connected, setConnected] = React.useState(true);
|
||||
const [startingIssueKey, setStartingIssueKey] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [createInWorktree, setCreateInWorktree] = React.useState(false);
|
||||
const [mapping, setMapping] = React.useState<LinearMappingResult | null>(null);
|
||||
const [mappingError, setMappingError] = React.useState<string | null>(null);
|
||||
const listRequestId = React.useRef(0);
|
||||
|
||||
const directIdentifier = React.useMemo(() => parseLinearIssueQuery(query), [query]);
|
||||
const debouncedQuery = useDebouncedValue(query, 350);
|
||||
|
||||
const refresh = React.useCallback(async (search = '') => {
|
||||
if (linearAuthChecked && linearAuthStatus?.connected === false) {
|
||||
setConnected(false);
|
||||
setIssues([]);
|
||||
setHasMore(false);
|
||||
setCursor(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (!linear?.issuesList) {
|
||||
setConnected(true);
|
||||
setError(t('session.linearIssuePicker.error.runtimeUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = listRequestId.current + 1;
|
||||
listRequestId.current = requestId;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await linear.issuesList(search ? { query: search } : undefined);
|
||||
if (requestId !== listRequestId.current) return;
|
||||
setConnected(next.connected !== false);
|
||||
setIssues(next.issues ?? []);
|
||||
setCursor(next.cursor ?? null);
|
||||
setHasMore(Boolean(next.hasMore));
|
||||
} catch (e) {
|
||||
if (requestId !== listRequestId.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (requestId === listRequestId.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [linear, linearAuthChecked, linearAuthStatus, t]);
|
||||
|
||||
const refreshMapping = React.useCallback(async () => {
|
||||
if (mode !== 'createSession') {
|
||||
setMapping(null);
|
||||
setMappingError(null);
|
||||
return;
|
||||
}
|
||||
if (!linear?.mappingGet) {
|
||||
setMapping(null);
|
||||
setMappingError(t('session.linearIssuePicker.error.runtimeUnavailable'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const next = await linear.mappingGet();
|
||||
setMapping(next);
|
||||
setMappingError(null);
|
||||
} catch (e) {
|
||||
setMapping(null);
|
||||
setMappingError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [linear, mode, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('');
|
||||
setStartingIssueKey(null);
|
||||
setError(null);
|
||||
setIssues([]);
|
||||
setCursor(null);
|
||||
setHasMore(false);
|
||||
setIsLoading(false);
|
||||
setConnected(true);
|
||||
setCreateInWorktree(false);
|
||||
setMapping(null);
|
||||
setMappingError(null);
|
||||
return;
|
||||
}
|
||||
if (linear && !linearAuthChecked) {
|
||||
void refreshStatus(linear);
|
||||
}
|
||||
}, [open, linear, linearAuthChecked, refreshStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
void refresh(debouncedQuery.trim());
|
||||
}, [open, debouncedQuery, refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
void refreshMapping();
|
||||
}, [open, refreshMapping]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (!linear?.issuesList) return;
|
||||
if (isLoadingMore || isLoading) return;
|
||||
if (!hasMore || !cursor) return;
|
||||
|
||||
const requestId = listRequestId.current + 1;
|
||||
listRequestId.current = requestId;
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const search = debouncedQuery.trim();
|
||||
const next = await linear.issuesList({
|
||||
query: search || undefined,
|
||||
cursor,
|
||||
});
|
||||
if (requestId !== listRequestId.current) return;
|
||||
setConnected(next.connected !== false);
|
||||
setIssues((prev) => [...prev, ...(next.issues ?? [])]);
|
||||
setCursor(next.cursor ?? null);
|
||||
setHasMore(Boolean(next.hasMore));
|
||||
} catch (e) {
|
||||
if (requestId !== listRequestId.current) return;
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error(t('session.linearIssuePicker.toast.loadMoreFailed'), { description: message });
|
||||
} finally {
|
||||
if (requestId === listRequestId.current) {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [cursor, debouncedQuery, hasMore, isLoading, isLoadingMore, linear, t]);
|
||||
|
||||
const openLinearSettings = React.useCallback(() => {
|
||||
setSettingsPage('integrations');
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
const selectIssue = React.useCallback(async (issueKey: string) => {
|
||||
if (!linear?.issueGet) {
|
||||
toast.error(t('session.linearIssuePicker.error.runtimeUnavailable'));
|
||||
return;
|
||||
}
|
||||
if (startingIssueKey) return;
|
||||
setStartingIssueKey(issueKey);
|
||||
try {
|
||||
const issueRes = await linear.issueGet(issueKey);
|
||||
if (issueRes.connected === false) {
|
||||
toast.error(t('session.linearIssuePicker.error.notConnected'));
|
||||
return;
|
||||
}
|
||||
const issue = issueRes.issue;
|
||||
if (!issue) {
|
||||
toast.error(t('session.linearIssuePicker.error.issueNotFound'));
|
||||
return;
|
||||
}
|
||||
const comments = issue.comments ?? [];
|
||||
const login = issue.assignee?.displayName || issue.assignee?.name;
|
||||
onSelect?.({
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
contextText: buildIssueContextText({ issue, comments }),
|
||||
author: login
|
||||
? { login, avatarUrl: issue.assignee?.avatarUrl || undefined }
|
||||
: undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error(t('session.linearIssuePicker.toast.loadIssueDetailsFailed'), { description: message });
|
||||
} finally {
|
||||
setStartingIssueKey(null);
|
||||
}
|
||||
}, [linear, onOpenChange, onSelect, startingIssueKey, t]);
|
||||
|
||||
const startSession = React.useCallback(async (issueKey: string) => {
|
||||
if (startingIssueKey) return;
|
||||
setStartingIssueKey(issueKey);
|
||||
try {
|
||||
await startLinearIssueSession({
|
||||
linear,
|
||||
issueKey,
|
||||
createInWorktree,
|
||||
mapping,
|
||||
onMappingLoaded: (next) => {
|
||||
setMapping(next);
|
||||
setMappingError(null);
|
||||
},
|
||||
onSessionCreated: () => onOpenChange(false),
|
||||
t,
|
||||
});
|
||||
} finally {
|
||||
setStartingIssueKey(null);
|
||||
}
|
||||
}, [createInWorktree, linear, mapping, onOpenChange, startingIssueKey, t]);
|
||||
|
||||
const handleIssue = React.useCallback((issueKey: string) => {
|
||||
if (mode === 'select') {
|
||||
void selectIssue(issueKey);
|
||||
return;
|
||||
}
|
||||
void startSession(issueKey);
|
||||
}, [mode, selectIssue, startSession]);
|
||||
|
||||
const title = mode === 'select'
|
||||
? t('session.linearIssuePicker.title')
|
||||
: t('session.linearIssuePicker.title.createSession');
|
||||
const description = mode === 'select'
|
||||
? t('session.linearIssuePicker.description')
|
||||
: t('session.linearIssuePicker.description.createSession');
|
||||
const showDisconnected = linearAuthChecked && connected === false;
|
||||
const runtimeMissing = !linear;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="relative mt-2">
|
||||
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('session.linearIssuePicker.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cn(isMobile ? 'min-h-0 mt-2' : 'flex-1 overflow-y-auto mt-2')}>
|
||||
{runtimeMissing ? (
|
||||
<div className="text-center text-muted-foreground py-8">{t('session.linearIssuePicker.empty.runtimeUnavailable')}</div>
|
||||
) : null}
|
||||
|
||||
{mode === 'createSession' && mappingError ? (
|
||||
<div className="text-center text-muted-foreground py-8 break-words">{mappingError}</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
{t('session.linearIssuePicker.loading.issues')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showDisconnected ? (
|
||||
<div className="text-center text-muted-foreground py-8 space-y-3">
|
||||
<div>{t('session.linearIssuePicker.empty.notConnected')}</div>
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" onClick={openLinearSettings}>
|
||||
{t('session.linearIssuePicker.actions.openSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
|
||||
) : null}
|
||||
|
||||
{directIdentifier && linear && connected ? (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
|
||||
startingIssueKey === directIdentifier && 'bg-interactive-selection/30'
|
||||
)}
|
||||
onClick={() => handleIssue(directIdentifier)}
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground w-16 text-right flex-shrink-0">
|
||||
{directIdentifier}
|
||||
</span>
|
||||
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
|
||||
{t('session.linearIssuePicker.actions.useIssue', { identifier: directIdentifier })}
|
||||
</p>
|
||||
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||
{startingIssueKey === directIdentifier ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{issues.length === 0 && !isLoading && connected && linear ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{debouncedQuery.trim()
|
||||
? t('session.linearIssuePicker.empty.noIssuesFound')
|
||||
: t('session.linearIssuePicker.empty.noOpenIssuesFound')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{issues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
|
||||
startingIssueKey === issue.id && 'bg-interactive-selection/30'
|
||||
)}
|
||||
onClick={() => handleIssue(issue.id)}
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground w-16 text-right flex-shrink-0">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
|
||||
{issue.title}
|
||||
</p>
|
||||
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||
{startingIssueKey === issue.id ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<a
|
||||
href={issue.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors',
|
||||
alwaysShowActions ? 'flex' : 'hidden group-hover:flex'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t('session.linearIssuePicker.actions.openInLinearAria')}
|
||||
>
|
||||
<Icon name="external-link" className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasMore && connected && linear ? (
|
||||
<div className="py-2 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadMore()}
|
||||
disabled={isLoadingMore || Boolean(startingIssueKey)}
|
||||
className={cn(
|
||||
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
|
||||
(isLoadingMore || Boolean(startingIssueKey)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
{t('session.linearIssuePicker.loading.more')}
|
||||
</span>
|
||||
) : (
|
||||
t('session.linearIssuePicker.actions.loadMore')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mode !== 'select' ? (
|
||||
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
||||
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('session.linearIssuePicker.actions.sectionTitle')}</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={createInWorktree}
|
||||
onClick={() => setCreateInWorktree((value) => !value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setCreateInWorktree((value) => !value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCreateInWorktree((value) => !value);
|
||||
}}
|
||||
aria-label={t('session.linearIssuePicker.actions.toggleWorktreeAria')}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
{createInWorktree ? (
|
||||
<Icon name="checkbox" className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Icon name="checkbox-blank" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<span className="typography-meta text-muted-foreground">{t('session.linearIssuePicker.actions.createInWorktree')}</span>
|
||||
</div>
|
||||
<div className="hidden sm:block sm:flex-1" />
|
||||
<Button variant="outline" size="sm" onClick={() => void refresh(debouncedQuery.trim())} disabled={isLoading || Boolean(startingIssueKey)}>
|
||||
{t('session.linearIssuePicker.actions.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={() => onOpenChange(false)}
|
||||
renderHeader={(closeButton) => (
|
||||
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">{title}</h2>
|
||||
{closeButton}
|
||||
</div>
|
||||
<p className="typography-small text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</MobileOverlayPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icon name="linear" className="h-5 w-5" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -27,11 +27,12 @@ import { cn } from '@/lib/utils';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { buildLinkedIssue } from '@/lib/linkedIssues';
|
||||
import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
|
||||
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||
@@ -40,6 +41,7 @@ import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/ope
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { postLinearSessionStarted } from '@/lib/linearSessionStatus';
|
||||
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
||||
import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch';
|
||||
import {
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore';
|
||||
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
||||
import { LinearIssuePickerDialog } from './LinearIssuePickerDialog';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -59,6 +62,8 @@ import type {
|
||||
GitHubIssuesListResult,
|
||||
GitHubPullRequestContextResult,
|
||||
GitHubPullRequestSummary,
|
||||
LinearIssue,
|
||||
LinearIssueComment,
|
||||
} from '@/lib/api/types';
|
||||
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -72,6 +77,13 @@ interface ValidationState {
|
||||
touched: boolean;
|
||||
}
|
||||
|
||||
type LinkedLinearWorktreeIssue = {
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
};
|
||||
|
||||
// State for New Branch mode
|
||||
interface NewBranchState {
|
||||
branchName: string;
|
||||
@@ -80,6 +92,7 @@ interface NewBranchState {
|
||||
sourceBranch: string;
|
||||
linkedIssue: GitHubIssue | null;
|
||||
linkedPr: GitHubPullRequestSummary | null;
|
||||
linkedLinearIssue: LinkedLinearWorktreeIssue | null;
|
||||
includePrDiff: boolean;
|
||||
}
|
||||
|
||||
@@ -209,16 +222,29 @@ const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) =>
|
||||
return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
||||
};
|
||||
|
||||
const buildLinearIssueContextText = (args: {
|
||||
issue: LinearIssue;
|
||||
comments: LinearIssueComment[];
|
||||
}) => {
|
||||
const payload = {
|
||||
issue: args.issue,
|
||||
comments: args.comments,
|
||||
};
|
||||
return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
||||
};
|
||||
|
||||
export function NewWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onWorktreeCreated,
|
||||
}: NewWorktreeDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { github, git } = useRuntimeAPIs();
|
||||
const { github, git, linear } = useRuntimeAPIs();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const linearAuthStatus = useLinearAuthStore((state) => state.status);
|
||||
const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
|
||||
const projectDirectory = activeProject?.path ?? null;
|
||||
@@ -240,6 +266,7 @@ export function NewWorktreeDialog({
|
||||
sourceBranch: '',
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
includePrDiff: false,
|
||||
});
|
||||
|
||||
@@ -290,6 +317,7 @@ export function NewWorktreeDialog({
|
||||
}, [existingWorktreeNames]);
|
||||
|
||||
const [githubDialogOpen, setGithubDialogOpen] = React.useState(false);
|
||||
const [linearDialogOpen, setLinearDialogOpen] = React.useState(false);
|
||||
|
||||
// Desktop branch picker states
|
||||
const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false);
|
||||
@@ -480,12 +508,9 @@ export function NewWorktreeDialog({
|
||||
directory: string;
|
||||
issue: GitHubIssue | null;
|
||||
pr: GitHubPullRequestSummary | null;
|
||||
linearIssue: LinkedLinearWorktreeIssue | null;
|
||||
includeDiff: boolean;
|
||||
}) => {
|
||||
if (!projectDirectory || !github) {
|
||||
return;
|
||||
}
|
||||
|
||||
const configState = useConfigStore.getState();
|
||||
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
|
||||
const defaultModel = resolveDefaultModelSelection();
|
||||
@@ -500,6 +525,69 @@ export function NewWorktreeDialog({
|
||||
|
||||
const variant = resolveDefaultVariant(providerID, modelID);
|
||||
|
||||
if (args.linearIssue) {
|
||||
if (!linear?.issueGet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issueRes = await linear.issueGet(args.linearIssue.identifier);
|
||||
if (issueRes.connected === false || !issueRes.issue) {
|
||||
throw new Error('Failed to load issue context');
|
||||
}
|
||||
|
||||
const issue = issueRes.issue;
|
||||
const comments = issue.comments ?? [];
|
||||
const login = issue.assignee?.displayName || issue.assignee?.name;
|
||||
const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', {
|
||||
identifier: issue.identifier,
|
||||
});
|
||||
const instructionsText = await renderMagicPrompt('linear.issue.review.instructions');
|
||||
const contextText = buildLinearIssueContextText({ issue, comments });
|
||||
|
||||
postLinearSessionStarted(linear, {
|
||||
sessionId: args.sessionId,
|
||||
issueIdentifier: issue.identifier,
|
||||
});
|
||||
|
||||
await useSessionUIStore.getState().sendMessage(
|
||||
visiblePromptText,
|
||||
providerID,
|
||||
modelID,
|
||||
agentName,
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
{ text: instructionsText, synthetic: true },
|
||||
{ text: contextText, synthetic: true },
|
||||
],
|
||||
variant,
|
||||
undefined,
|
||||
{ sessionId: args.sessionId, directory: args.directory },
|
||||
);
|
||||
|
||||
void sessionActions.setLinkedIssue(
|
||||
args.sessionId,
|
||||
args.directory,
|
||||
buildLinkedLinearIssue({
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
author: login
|
||||
? { login, avatarUrl: issue.assignee?.avatarUrl || undefined }
|
||||
: args.linearIssue.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
|
||||
toast.success(t('session.newWorktree.toast.sessionFromIssue'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectDirectory || !github) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.issue) {
|
||||
if (!github.issueGet || !github.issueComments) {
|
||||
return;
|
||||
@@ -615,6 +703,7 @@ export function NewWorktreeDialog({
|
||||
}
|
||||
}, [
|
||||
github,
|
||||
linear,
|
||||
projectDirectory,
|
||||
resolveDefaultAgentName,
|
||||
resolveDefaultModelSelection,
|
||||
@@ -702,6 +791,7 @@ export function NewWorktreeDialog({
|
||||
sourceBranch: '',
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
includePrDiff: false,
|
||||
});
|
||||
}, [open, generateUniqueSlug]);
|
||||
@@ -862,9 +952,10 @@ export function NewWorktreeDialog({
|
||||
try {
|
||||
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
||||
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null;
|
||||
const linkedLinearIssue = mode === 'new-branch' ? newBranchState.linkedLinearIssue : null;
|
||||
const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
||||
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false;
|
||||
const shouldCreateSession = Boolean(linkedIssue || linkedPrState);
|
||||
const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedLinearIssue);
|
||||
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const sourceBranch = newBranchState.sourceBranch;
|
||||
@@ -914,7 +1005,9 @@ export function NewWorktreeDialog({
|
||||
await waitForWorktreeBootstrap(metadata.path);
|
||||
}
|
||||
|
||||
const sessionTitle = linkedIssue
|
||||
const sessionTitle = linkedLinearIssue
|
||||
? `${linkedLinearIssue.identifier} ${linkedLinearIssue.title}`.trim()
|
||||
: linkedIssue
|
||||
? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
|
||||
: linkedPrState
|
||||
? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
|
||||
@@ -966,10 +1059,14 @@ export function NewWorktreeDialog({
|
||||
directory: metadata.path,
|
||||
issue: linkedIssue,
|
||||
pr: linkedPrState,
|
||||
linearIssue: linkedLinearIssue,
|
||||
includeDiff: includePrDiff,
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : t('session.newWorktree.error.sendGitHubContextFailed');
|
||||
toast.error(t('session.newWorktree.error.sendGitHubContextFailed'), { description: message });
|
||||
const fallback = linkedLinearIssue
|
||||
? t('session.newWorktree.error.sendLinearContextFailed')
|
||||
: t('session.newWorktree.error.sendGitHubContextFailed');
|
||||
const message = error instanceof Error ? error.message : fallback;
|
||||
toast.error(fallback, { description: message });
|
||||
});
|
||||
} else {
|
||||
onWorktreeCreated?.(metadata.path);
|
||||
@@ -999,6 +1096,7 @@ export function NewWorktreeDialog({
|
||||
...prev,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
includePrDiff: false,
|
||||
branchName: '',
|
||||
}));
|
||||
@@ -1012,6 +1110,7 @@ export function NewWorktreeDialog({
|
||||
...prev,
|
||||
linkedIssue: issue,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
includePrDiff: false,
|
||||
branchName: newBranchName,
|
||||
worktreeName: slugifyWorktreeName(newBranchName),
|
||||
@@ -1023,6 +1122,7 @@ export function NewWorktreeDialog({
|
||||
...prev,
|
||||
linkedPr: pr,
|
||||
linkedIssue: null,
|
||||
linkedLinearIssue: null,
|
||||
includePrDiff: result.includeDiff ?? false,
|
||||
branchName: pr.head,
|
||||
worktreeName: slugifyWorktreeName(pr.head),
|
||||
@@ -1031,8 +1131,33 @@ export function NewWorktreeDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinearSelect = (issue: {
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
}) => {
|
||||
const newBranchName = `issue-${issue.identifier}-${generateBranchSlug()}`;
|
||||
setNewBranchState(prev => ({
|
||||
...prev,
|
||||
linkedLinearIssue: {
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
author: issue.author,
|
||||
},
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
includePrDiff: false,
|
||||
branchName: newBranchName,
|
||||
worktreeName: slugifyWorktreeName(newBranchName),
|
||||
isSyncingWorktreeName: true,
|
||||
}));
|
||||
};
|
||||
|
||||
// GitHub connection check
|
||||
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true;
|
||||
const isLinearConnected = Boolean(linear) && linearAuthChecked && linearAuthStatus?.connected === true;
|
||||
|
||||
// Check if form is valid for submission
|
||||
const isFormValid = mode === 'existing-branch'
|
||||
@@ -1046,12 +1171,42 @@ export function NewWorktreeDialog({
|
||||
...prev,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
branchName: '',
|
||||
includePrDiff: false,
|
||||
isSyncingWorktreeName: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const startFromIssueButtons = mode === 'new-branch' && (isGitHubConnected || isLinearConnected) ? (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{isGitHubConnected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setGithubDialogOpen(true)}
|
||||
className="h-8 w-8 px-0"
|
||||
title={t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||
aria-label={t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||
>
|
||||
<Icon name="github" className="size-4 text-status-success" />
|
||||
</Button>
|
||||
)}
|
||||
{isLinearConnected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLinearDialogOpen(true)}
|
||||
className="h-8 w-8 px-0"
|
||||
title={t('session.newWorktree.actions.startFromLinearIssue')}
|
||||
aria-label={t('session.newWorktree.actions.startFromLinearIssue')}
|
||||
>
|
||||
<Icon name="linear" className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Footer content
|
||||
const footerContent = (
|
||||
<div className={cn('flex gap-2', isMobile ? 'flex-col w-full' : 'flex-row items-center')}>
|
||||
@@ -1277,21 +1432,11 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<label className="typography-ui-label text-foreground block font-semibold">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="typography-ui-label text-foreground font-semibold shrink-0">
|
||||
{t('session.newWorktree.branchName')}
|
||||
</label>
|
||||
{mode === 'new-branch' && isGitHubConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGithubDialogOpen(true)}
|
||||
className="gap-1.5 h-7"
|
||||
>
|
||||
<Icon name="github" className="size-4 text-status-success" />
|
||||
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||
</Button>
|
||||
)}
|
||||
{startFromIssueButtons}
|
||||
</div>
|
||||
<Input
|
||||
value={newBranchState.branchName}
|
||||
@@ -1302,6 +1447,7 @@ export function NewWorktreeDialog({
|
||||
isSyncingWorktreeName: true,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
}));
|
||||
}}
|
||||
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
||||
@@ -1329,6 +1475,17 @@ export function NewWorktreeDialog({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{newBranchState.linkedLinearIssue && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
||||
<span className="typography-micro">
|
||||
{t('session.newWorktree.fromLinearIssue', {
|
||||
identifier: newBranchState.linkedLinearIssue.identifier,
|
||||
title: newBranchState.linkedLinearIssue.title,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1527,12 +1684,23 @@ export function NewWorktreeDialog({
|
||||
)}
|
||||
|
||||
{/* Linked Item Preview - Two row minimal display */}
|
||||
{(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && (
|
||||
{(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
|
||||
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
|
||||
{/* Row 1: Type, number, title, actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||||
<Icon
|
||||
name={newBranchState.linkedLinearIssue ? 'linear' : 'github'}
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0',
|
||||
newBranchState.linkedLinearIssue ? 'text-foreground' : 'text-status-success',
|
||||
)}
|
||||
/>
|
||||
|
||||
{newBranchState.linkedLinearIssue && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{newBranchState.linkedLinearIssue.identifier}
|
||||
</span>
|
||||
)}
|
||||
{newBranchState.linkedIssue && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
|
||||
@@ -1545,11 +1713,11 @@ export function NewWorktreeDialog({
|
||||
)}
|
||||
|
||||
<span className="typography-micro text-foreground truncate flex-1">
|
||||
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
||||
{newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
||||
</span>
|
||||
|
||||
<a
|
||||
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
||||
href={newBranchState.linkedLinearIssue?.url || newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
@@ -1745,21 +1913,11 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="typography-ui-label text-foreground block font-semibold">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="typography-ui-label text-foreground font-semibold shrink-0">
|
||||
{t('session.newWorktree.branchName')}
|
||||
</label>
|
||||
{mode === 'new-branch' && isGitHubConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGithubDialogOpen(true)}
|
||||
className="gap-1.5 h-7"
|
||||
>
|
||||
<Icon name="github" className="size-4 text-status-success" />
|
||||
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||
</Button>
|
||||
)}
|
||||
{startFromIssueButtons}
|
||||
</div>
|
||||
<Input
|
||||
value={newBranchState.branchName}
|
||||
@@ -1770,6 +1928,7 @@ export function NewWorktreeDialog({
|
||||
isSyncingWorktreeName: true,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
}));
|
||||
}}
|
||||
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
||||
@@ -1797,6 +1956,17 @@ export function NewWorktreeDialog({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{newBranchState.linkedLinearIssue && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
||||
<span className="typography-micro">
|
||||
{t('session.newWorktree.fromLinearIssue', {
|
||||
identifier: newBranchState.linkedLinearIssue.identifier,
|
||||
title: newBranchState.linkedLinearIssue.title,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1968,12 +2138,23 @@ export function NewWorktreeDialog({
|
||||
)}
|
||||
|
||||
{/* Linked Item Preview - Two row minimal display */}
|
||||
{(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && (
|
||||
{(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
|
||||
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
|
||||
{/* Row 1: Type, number, title, actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||||
<Icon
|
||||
name={newBranchState.linkedLinearIssue ? 'linear' : 'github'}
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0',
|
||||
newBranchState.linkedLinearIssue ? 'text-foreground' : 'text-status-success',
|
||||
)}
|
||||
/>
|
||||
|
||||
{newBranchState.linkedLinearIssue && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{newBranchState.linkedLinearIssue.identifier}
|
||||
</span>
|
||||
)}
|
||||
{newBranchState.linkedIssue && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
|
||||
@@ -1986,11 +2167,11 @@ export function NewWorktreeDialog({
|
||||
)}
|
||||
|
||||
<span className="typography-micro text-foreground truncate flex-1">
|
||||
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
||||
{newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
||||
</span>
|
||||
|
||||
<a
|
||||
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
||||
href={newBranchState.linkedLinearIssue?.url || newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
@@ -2067,6 +2248,12 @@ export function NewWorktreeDialog({
|
||||
onOpenChange={setGithubDialogOpen}
|
||||
onSelect={handleGitHubSelect}
|
||||
/>
|
||||
<LinearIssuePickerDialog
|
||||
open={linearDialogOpen}
|
||||
onOpenChange={setLinearDialogOpen}
|
||||
mode="select"
|
||||
onSelect={handleLinearSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -964,7 +964,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
: <Icon name={iconName!} className="h-[18px] w-[18px] shrink-0 sm:h-4 sm:w-4" />}
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
|
||||
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
|
||||
{(page.slug === 'tunnel' || page.slug === 'integrations') && (
|
||||
{page.slug === 'tunnel' && (
|
||||
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
|
||||
{t('settings.view.badge.beta')}
|
||||
</span>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstr
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
|
||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||
@@ -505,6 +506,7 @@ export const useKeyboardShortcuts = () => {
|
||||
isVSCode: isVSCodeRuntime(),
|
||||
screenWidth: window.innerWidth,
|
||||
tabs: panel?.tabs ?? [],
|
||||
linearConnected: useLinearAuthStore.getState().status?.connected === true,
|
||||
});
|
||||
const target = visibleSurfaces[switchSurfaceDigit - 1];
|
||||
if (target) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore';
|
||||
import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router';
|
||||
import { openSessionFromRoute } from '@/lib/router/openSessionFromRoute';
|
||||
import type { RouteState, AppRouteState } from '@/lib/router';
|
||||
import { resolveSettingsSlug } from '@/lib/settings/metadata';
|
||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
@@ -48,7 +49,6 @@ export function useRouter(): void {
|
||||
const isApplyingRouteRef = React.useRef(false);
|
||||
|
||||
// Get store actions (stable references)
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
|
||||
@@ -67,11 +67,7 @@ export function useRouter(): void {
|
||||
try {
|
||||
// 1. Apply session first (may trigger async operations)
|
||||
if (route.sessionId) {
|
||||
const currentSessionId = useSessionUIStore.getState().currentSessionId;
|
||||
if (route.sessionId !== currentSessionId) {
|
||||
const directoryHint = useSessionUIStore.getState().getDirectoryForSession(route.sessionId);
|
||||
setCurrentSession(route.sessionId, directoryHint);
|
||||
}
|
||||
await openSessionFromRoute(route.sessionId);
|
||||
}
|
||||
|
||||
// 2. Handle settings first because it is a full-screen overlay.
|
||||
@@ -107,7 +103,7 @@ export function useRouter(): void {
|
||||
isApplyingRouteRef.current = false;
|
||||
}
|
||||
},
|
||||
[setCurrentSession, setSettingsDialogOpen, setSettingsPage, navigateToDiff]
|
||||
[setSettingsDialogOpen, setSettingsPage, navigateToDiff]
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1145,6 +1145,199 @@ export type GitHubDeviceFlowComplete =
|
||||
| { connected: true; user: GitHubUserSummary; scope?: string }
|
||||
| { connected: false; status?: string; error?: string };
|
||||
|
||||
export type LinearUserSummary = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
export type LinearOrganizationSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
urlKey: string | null;
|
||||
};
|
||||
|
||||
export type LinearWorkspaceSummary = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
urlKey: string | null;
|
||||
current: boolean;
|
||||
user?: LinearUserSummary | null;
|
||||
authorizedAt?: number | null;
|
||||
};
|
||||
|
||||
export type LinearAuthStatus = {
|
||||
connected: boolean;
|
||||
user?: LinearUserSummary | null;
|
||||
organization?: LinearOrganizationSummary | null;
|
||||
scope?: string;
|
||||
workspaces?: LinearWorkspaceSummary[];
|
||||
};
|
||||
|
||||
export type LinearAuthStart = {
|
||||
authorizationUrl: string;
|
||||
expiresIn: number;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
export type LinearAuthOrigin = 'desktop' | 'web';
|
||||
|
||||
export type LinearIssueState = {
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
type: string | null;
|
||||
};
|
||||
|
||||
export type LinearWorkflowState = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string | null;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type LinearIssueAssignee = {
|
||||
name: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
export type LinearIssueTeam = {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type LinearIssuePriority = 0 | 1 | 2 | 3 | 4;
|
||||
|
||||
export type LinearIssueLabel = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
};
|
||||
|
||||
export type LinearIssueSummary = {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
state?: LinearIssueState | null;
|
||||
assignee?: LinearIssueAssignee | null;
|
||||
team?: LinearIssueTeam | null;
|
||||
priority?: LinearIssuePriority | null;
|
||||
labels?: LinearIssueLabel[];
|
||||
};
|
||||
|
||||
export type LinearIssueComment = {
|
||||
id: string;
|
||||
body: string;
|
||||
createdAt: string | null;
|
||||
user?: { name: string | null; displayName: string | null; avatarUrl?: string | null } | null;
|
||||
};
|
||||
|
||||
export type LinearIssue = LinearIssueSummary & {
|
||||
description?: string | null;
|
||||
comments?: LinearIssueComment[];
|
||||
};
|
||||
|
||||
export type LinearIssueListStatus = 'all' | 'backlog' | 'todo' | 'started' | 'inReview' | 'completed' | 'canceled' | 'duplicate';
|
||||
export type LinearIssueListAssignee = 'any' | 'me';
|
||||
export type LinearIssueListPriority = 'all' | 'none' | 'urgent' | 'high' | 'medium' | 'low';
|
||||
|
||||
export type LinearIssuesListOptions = {
|
||||
query?: string;
|
||||
cursor?: string;
|
||||
status?: LinearIssueListStatus;
|
||||
assignee?: LinearIssueListAssignee;
|
||||
teamId?: string;
|
||||
priority?: LinearIssueListPriority;
|
||||
};
|
||||
|
||||
export type LinearIssuesListResult = {
|
||||
connected: boolean;
|
||||
issues?: LinearIssueSummary[];
|
||||
cursor?: string | null;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
export type LinearIssueGetResult = {
|
||||
connected: boolean;
|
||||
issue?: LinearIssue | null;
|
||||
};
|
||||
|
||||
export type LinearIssueStatesResult = {
|
||||
connected: boolean;
|
||||
states?: LinearWorkflowState[];
|
||||
};
|
||||
|
||||
export type LinearIssueUpdateInput = {
|
||||
id: string;
|
||||
stateId: string;
|
||||
};
|
||||
|
||||
export type LinearIssueUpdateResult = {
|
||||
connected: boolean;
|
||||
issue?: LinearIssue | null;
|
||||
};
|
||||
|
||||
export type LinearTeamMapping = {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
projectPath: string | null;
|
||||
};
|
||||
|
||||
export type LinearMappingResult = {
|
||||
connected: boolean;
|
||||
defaultProjectPath?: string | null;
|
||||
teams?: LinearTeamMapping[];
|
||||
};
|
||||
|
||||
export type LinearMappingWrite = {
|
||||
defaultProjectPath: string | null;
|
||||
teamProjectPaths: { [teamId: string]: string };
|
||||
};
|
||||
|
||||
export type LinearSessionStatusKind = 'started' | 'completed' | 'failure';
|
||||
|
||||
export type LinearSessionStatusPostInput = {
|
||||
kind: LinearSessionStatusKind;
|
||||
sessionId: string;
|
||||
issueIdentifier?: string;
|
||||
sessionOrigin?: string;
|
||||
};
|
||||
|
||||
export type LinearSessionStatusPostResult =
|
||||
| { connected: false }
|
||||
| { connected: true; posted: true; commentId: string | null }
|
||||
| {
|
||||
connected: true;
|
||||
posted: false;
|
||||
skipped: 'already-posted' | 'issue-not-found' | 'not-started' | 'disabled' | 'origin-not-public';
|
||||
};
|
||||
|
||||
export type LinearPreferences = {
|
||||
/** Status comments are off until the user opts in. */
|
||||
sessionComments: boolean;
|
||||
};
|
||||
|
||||
export interface LinearAPI {
|
||||
authStatus(): Promise<LinearAuthStatus>;
|
||||
authStart(origin?: LinearAuthOrigin): Promise<LinearAuthStart>;
|
||||
authDisconnect(): Promise<{ removed: boolean }>;
|
||||
authActivate(organizationId: string): Promise<LinearAuthStatus>;
|
||||
issuesList(options?: LinearIssuesListOptions): Promise<LinearIssuesListResult>;
|
||||
issueGet(id: string): Promise<LinearIssueGetResult>;
|
||||
issueStates(teamId: string): Promise<LinearIssueStatesResult>;
|
||||
issueUpdate(input: LinearIssueUpdateInput): Promise<LinearIssueUpdateResult>;
|
||||
mappingGet(): Promise<LinearMappingResult>;
|
||||
mappingSet(mapping: LinearMappingWrite): Promise<LinearMappingResult>;
|
||||
sessionStatusPost(input: LinearSessionStatusPostInput): Promise<LinearSessionStatusPostResult>;
|
||||
preferencesGet(): Promise<LinearPreferences>;
|
||||
preferencesSet(preferences: LinearPreferences): Promise<LinearPreferences>;
|
||||
}
|
||||
|
||||
export interface GitHubAPI {
|
||||
authStatus(): Promise<GitHubAuthStatus>;
|
||||
authStart(): Promise<GitHubDeviceFlowStart>;
|
||||
@@ -1269,6 +1462,7 @@ export interface RuntimeAPIs {
|
||||
permissions: PermissionsAPI;
|
||||
notifications: NotificationsAPI;
|
||||
github?: GitHubAPI;
|
||||
linear?: LinearAPI;
|
||||
push?: PushAPI;
|
||||
diagnostics?: DiagnosticsAPI;
|
||||
clientAuth?: ClientAuthAPI;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
|
||||
export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.title': 'OpenCode Go Nutzungsverfolgung',
|
||||
@@ -2218,5 +2219,6 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.option.themeMode.light.description': 'Immer helles Erscheinungsbild verwenden',
|
||||
'settings.openchamber.visual.option.themeMode.dark.description': 'Immer dunkles Erscheinungsbild verwenden',
|
||||
'chat.message.userText.collapseAria': 'Benutzernachricht einklappen',
|
||||
...linearIntegrationI18n.de,
|
||||
...thirdPartyIntegrationI18n.de,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { settingsDict } from './de.settings';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict = {
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n.de,
|
||||
...linearPanelI18n.de,
|
||||
'common.language.german': 'Deutsch',
|
||||
'common.loading': 'Wird geladen...',
|
||||
'common.unavailable': 'Nicht verfügbar',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
|
||||
export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.title': 'OpenCode Go usage tracking',
|
||||
@@ -2217,5 +2218,6 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
|
||||
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
|
||||
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
|
||||
...linearIntegrationI18n.en,
|
||||
...thirdPartyIntegrationI18n.en,
|
||||
} as const;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { settingsDict } from './en.settings';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict = {
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n.en,
|
||||
...linearPanelI18n.en,
|
||||
'terminalView.actions.attachSelection': 'Attach selected output',
|
||||
'terminalView.actions.restart': 'Restart terminal',
|
||||
'chat.message.terminalContext': '{terminal}, lines {start}-{end}',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
|
||||
export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.title': 'Seguimiento de uso de OpenCode Go',
|
||||
@@ -2227,5 +2228,6 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
|
||||
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
|
||||
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
|
||||
...linearIntegrationI18n.es,
|
||||
...thirdPartyIntegrationI18n.es,
|
||||
} as const;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { I18nKey } from './en';
|
||||
import { settingsDict } from './es.settings';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n.es,
|
||||
...linearPanelI18n.es,
|
||||
'terminalView.actions.attachSelection': 'Adjuntar salida seleccionada',
|
||||
'terminalView.actions.restart': 'Reiniciar terminal',
|
||||
'chat.message.terminalContext': '{terminal}, líneas {start}-{end}',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
|
||||
export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.title': 'Suivi de l’utilisation d’OpenCode Go',
|
||||
@@ -2227,5 +2228,6 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
|
||||
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
|
||||
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
|
||||
...linearIntegrationI18n.fr,
|
||||
...thirdPartyIntegrationI18n.fr,
|
||||
} as const;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { settingsDict } from './fr.settings';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict = {
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n.fr,
|
||||
...linearPanelI18n.fr,
|
||||
'terminalView.actions.attachSelection': 'Joindre la sortie sélectionnée',
|
||||
'terminalView.actions.restart': 'Redémarrer le terminal',
|
||||
'chat.message.terminalContext': '{terminal}, lignes {start}-{end}',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
|
||||
export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.title': 'OpenCode Go 使用量追跡',
|
||||
@@ -2227,5 +2228,6 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。',
|
||||
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア',
|
||||
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー',
|
||||
...linearIntegrationI18n.ja,
|
||||
...thirdPartyIntegrationI18n.ja,
|
||||
} as const;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { I18nKey } from './en';
|
||||
import { settingsDict } from './ja.settings';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n.ja,
|
||||
...linearPanelI18n.ja,
|
||||
'terminalView.actions.attachSelection': '選択した出力を添付',
|
||||
'terminalView.actions.restart': 'ターミナルを再起動',
|
||||
'chat.message.terminalContext': '{terminal}、{start}〜{end}行',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
|
||||
export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.title': 'OpenCode Go 사용량 추적',
|
||||
@@ -2227,5 +2228,6 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
|
||||
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
|
||||
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
|
||||
...linearIntegrationI18n.ko,
|
||||
...thirdPartyIntegrationI18n.ko,
|
||||
} as const;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { I18nKey } from './en';
|
||||
import { settingsDict } from './ko.settings';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n.ko,
|
||||
...linearPanelI18n.ko,
|
||||
'terminalView.actions.attachSelection': '선택한 출력 첨부',
|
||||
'terminalView.actions.restart': '터미널 다시 시작',
|
||||
'chat.message.terminalContext': '{terminal}, {start}-{end}행',
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
|
||||
const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const;
|
||||
|
||||
const requiredKeys = [
|
||||
'settings.integrations.firstParty.title',
|
||||
'settings.integrations.firstParty.info',
|
||||
'settings.integrations.linear.title',
|
||||
'settings.integrations.linear.description',
|
||||
'settings.integrations.linear.info',
|
||||
'settings.integrations.linear.status.notConnected',
|
||||
'settings.integrations.linear.status.connected',
|
||||
'settings.integrations.linear.status.waiting',
|
||||
'settings.integrations.linear.actions.connect',
|
||||
'settings.integrations.linear.actions.disconnect',
|
||||
'settings.integrations.linear.actions.addWorkspace',
|
||||
'settings.integrations.linear.actions.switchTo',
|
||||
'settings.integrations.linear.label.otherWorkspaces',
|
||||
'settings.integrations.linear.flow.title',
|
||||
'settings.integrations.linear.flow.description',
|
||||
'settings.integrations.linear.flow.waiting',
|
||||
'settings.integrations.linear.toast.connected',
|
||||
'settings.integrations.linear.toast.disconnected',
|
||||
'settings.integrations.linear.toast.workspaceSwitched',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed',
|
||||
'settings.integrations.linear.toast.startConnectFailed',
|
||||
'settings.integrations.linear.toast.disconnectFailed',
|
||||
'settings.integrations.linear.toast.authorizationFailed',
|
||||
'settings.integrations.linear.avatarAlt.withName',
|
||||
'settings.integrations.linear.avatarAlt.fallback',
|
||||
'settings.integrations.linear.label.unknownUser',
|
||||
'settings.integrations.linear.mapping.defaultProject',
|
||||
'settings.integrations.linear.mapping.defaultProject.info',
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria',
|
||||
'settings.integrations.linear.mapping.teams',
|
||||
'settings.integrations.linear.mapping.teams.info',
|
||||
'settings.integrations.linear.mapping.teams.useDefault',
|
||||
'settings.integrations.linear.mapping.teams.aria',
|
||||
'settings.integrations.linear.mapping.emptyProjects',
|
||||
'settings.integrations.linear.mapping.emptyTeams',
|
||||
'settings.integrations.linear.mapping.loadFailed',
|
||||
'settings.integrations.linear.sessionComments.label',
|
||||
'settings.integrations.linear.sessionComments.info',
|
||||
'settings.integrations.linear.sessionComments.aria',
|
||||
'settings.integrations.linear.sessionComments.loadFailed',
|
||||
'settings.magicPrompts.sidebar.group.linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description',
|
||||
] as const;
|
||||
|
||||
describe('linear integration translations', () => {
|
||||
test('provides every required key in every supported locale', () => {
|
||||
const english = linearIntegrationI18n.en;
|
||||
for (const locale of locales) {
|
||||
for (const key of requiredKeys) {
|
||||
const value = linearIntegrationI18n[locale][key];
|
||||
expect(value).toBeTruthy();
|
||||
if (
|
||||
locale !== 'en'
|
||||
&& key !== 'settings.integrations.linear.title'
|
||||
&& key !== 'settings.magicPrompts.sidebar.group.linear'
|
||||
) {
|
||||
expect(value).not.toBe(english[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
/** Linear first-party integration settings strings — merged into each locale's settings dictionary. */
|
||||
export const linearIntegrationI18n = {
|
||||
en: {
|
||||
'settings.integrations.firstParty.title': 'Built-in integrations',
|
||||
'settings.integrations.firstParty.info': 'Sign-ins for services that ship with OpenChamber. The login stays on this computer so web, desktop, and a paired phone share it.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
'settings.integrations.linear.description': 'Connect Linear workspaces on this OpenChamber server.',
|
||||
'settings.integrations.linear.info': 'Connect one or more Linear workspaces. OpenChamber stores the logins on this computer so web, desktop, and a paired phone share them.',
|
||||
'settings.integrations.linear.status.notConnected': 'Not connected',
|
||||
'settings.integrations.linear.status.connected': 'Connected',
|
||||
'settings.integrations.linear.status.waiting': 'Waiting',
|
||||
'settings.integrations.linear.actions.connect': 'Connect',
|
||||
'settings.integrations.linear.actions.disconnect': 'Disconnect',
|
||||
'settings.integrations.linear.actions.addWorkspace': 'Add workspace',
|
||||
'settings.integrations.linear.actions.switchTo': 'Switch to',
|
||||
'settings.integrations.linear.label.otherWorkspaces': 'Other workspaces',
|
||||
'settings.integrations.linear.flow.title': 'Waiting for Linear',
|
||||
'settings.integrations.linear.flow.description': 'Finish signing in in the browser tab that just opened.',
|
||||
'settings.integrations.linear.flow.waiting': 'Waiting for authorization…',
|
||||
'settings.integrations.linear.toast.connected': 'Linear connected',
|
||||
'settings.integrations.linear.toast.disconnected': 'Linear disconnected',
|
||||
'settings.integrations.linear.toast.workspaceSwitched': 'Switched Linear workspace',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Could not switch Linear workspace',
|
||||
'settings.integrations.linear.toast.startConnectFailed': 'Could not start Linear sign-in',
|
||||
'settings.integrations.linear.toast.disconnectFailed': 'Could not disconnect Linear',
|
||||
'settings.integrations.linear.toast.authorizationFailed': 'Linear authorization timed out. Click Connect to try again.',
|
||||
'settings.integrations.linear.avatarAlt.withName': 'Linear avatar for {name}',
|
||||
'settings.integrations.linear.avatarAlt.fallback': 'Linear avatar',
|
||||
'settings.integrations.linear.label.unknownUser': 'Unknown user',
|
||||
'settings.integrations.linear.mapping.defaultProject': 'Default project',
|
||||
'settings.integrations.linear.mapping.defaultProject.info': 'New sessions from Linear issues use this project unless the issue\'s team has its own mapping.',
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder': 'None',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria': 'Default project for Linear issues',
|
||||
'settings.integrations.linear.mapping.teams': 'Team projects',
|
||||
'settings.integrations.linear.mapping.teams.info': 'Optional. An issue from a mapped team opens in that project instead of the default.',
|
||||
'settings.integrations.linear.mapping.teams.useDefault': 'Use default',
|
||||
'settings.integrations.linear.mapping.teams.aria': 'Project for Linear team {team}',
|
||||
'settings.integrations.linear.mapping.emptyProjects': 'Add a project first, then map Linear teams to it.',
|
||||
'settings.integrations.linear.mapping.emptyTeams': 'This Linear workspace has no teams.',
|
||||
'settings.integrations.linear.mapping.loadFailed': 'Could not load Linear project mapping.',
|
||||
'settings.integrations.linear.sessionComments.label': 'Session comments',
|
||||
'settings.integrations.linear.sessionComments.info': 'Adds a comment to the issue when a session starts, finishes, or fails. Comments are only posted when this server has a public address, so the link opens the session for everyone on the issue.',
|
||||
'settings.integrations.linear.sessionComments.aria': 'Post session status comments to Linear',
|
||||
'settings.integrations.linear.sessionComments.loadFailed': 'Could not load Linear comment settings.',
|
||||
'settings.magicPrompts.sidebar.group.linear': 'Linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue Review',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue Review',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts used when starting a session from a Linear issue: visible user message + hidden instructions.',
|
||||
},
|
||||
de: {
|
||||
'settings.integrations.firstParty.title': 'Eingebaute Integrationen',
|
||||
'settings.integrations.firstParty.info': 'Anmeldungen für Dienste, die mit OpenChamber mitgeliefert werden. Die Anmeldung bleibt auf diesem Computer, damit Web, Desktop und ein gekoppeltes Telefon sie teilen.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
'settings.integrations.linear.description': 'Verbinde Linear-Workspaces mit diesem OpenChamber-Server.',
|
||||
'settings.integrations.linear.info': 'Verbinde einen oder mehrere Linear-Workspaces. OpenChamber speichert die Anmeldungen auf diesem Computer, damit Web, Desktop und ein gekoppeltes Telefon sie teilen.',
|
||||
'settings.integrations.linear.status.notConnected': 'Nicht verbunden',
|
||||
'settings.integrations.linear.status.connected': 'Verbunden',
|
||||
'settings.integrations.linear.status.waiting': 'Warten',
|
||||
'settings.integrations.linear.actions.connect': 'Verbinden',
|
||||
'settings.integrations.linear.actions.disconnect': 'Trennen',
|
||||
'settings.integrations.linear.actions.addWorkspace': 'Workspace hinzufügen',
|
||||
'settings.integrations.linear.actions.switchTo': 'Wechseln zu',
|
||||
'settings.integrations.linear.label.otherWorkspaces': 'Andere Workspaces',
|
||||
'settings.integrations.linear.flow.title': 'Warte auf Linear',
|
||||
'settings.integrations.linear.flow.description': 'Schließe die Anmeldung im gerade geöffneten Browser-Tab ab.',
|
||||
'settings.integrations.linear.flow.waiting': 'Warte auf die Autorisierung…',
|
||||
'settings.integrations.linear.toast.connected': 'Linear verbunden',
|
||||
'settings.integrations.linear.toast.disconnected': 'Linear getrennt',
|
||||
'settings.integrations.linear.toast.workspaceSwitched': 'Linear-Workspace gewechselt',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear-Workspace konnte nicht gewechselt werden',
|
||||
'settings.integrations.linear.toast.startConnectFailed': 'Linear-Anmeldung konnte nicht gestartet werden',
|
||||
'settings.integrations.linear.toast.disconnectFailed': 'Linear konnte nicht getrennt werden',
|
||||
'settings.integrations.linear.toast.authorizationFailed': 'Die Linear-Autorisierung ist abgelaufen. Klicke auf Verbinden, um es erneut zu versuchen.',
|
||||
'settings.integrations.linear.avatarAlt.withName': 'Linear-Avatar für {name}',
|
||||
'settings.integrations.linear.avatarAlt.fallback': 'Linear-Avatar',
|
||||
'settings.integrations.linear.label.unknownUser': 'Unbekannter Benutzer',
|
||||
'settings.integrations.linear.mapping.defaultProject': 'Standardprojekt',
|
||||
'settings.integrations.linear.mapping.defaultProject.info': 'Neue Sitzungen aus Linear-Issues nutzen dieses Projekt, sofern das Team des Issues keine eigene Zuordnung hat.',
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Keines',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria': 'Standardprojekt für Linear-Issues',
|
||||
'settings.integrations.linear.mapping.teams': 'Team-Projekte',
|
||||
'settings.integrations.linear.mapping.teams.info': 'Optional. Ein Issue eines zugeordneten Teams öffnet sich in diesem Projekt statt im Standard.',
|
||||
'settings.integrations.linear.mapping.teams.useDefault': 'Standard verwenden',
|
||||
'settings.integrations.linear.mapping.teams.aria': 'Projekt für Linear-Team {team}',
|
||||
'settings.integrations.linear.mapping.emptyProjects': 'Füge zuerst ein Projekt hinzu und ordne dann Linear-Teams zu.',
|
||||
'settings.integrations.linear.mapping.emptyTeams': 'Dieser Linear-Workspace hat keine Teams.',
|
||||
'settings.integrations.linear.mapping.loadFailed': 'Linear-Projektzuordnung konnte nicht geladen werden.',
|
||||
'settings.integrations.linear.sessionComments.label': 'Sitzungskommentare',
|
||||
'settings.integrations.linear.sessionComments.info': 'Kommentiert das Issue, wenn eine Sitzung startet, endet oder fehlschlägt. Kommentare werden nur gepostet, wenn dieser Server eine öffentliche Adresse hat, damit der Link die Sitzung für alle Beteiligten öffnet.',
|
||||
'settings.integrations.linear.sessionComments.aria': 'Statuskommentare zu Sitzungen in Linear posten',
|
||||
'settings.integrations.linear.sessionComments.loadFailed': 'Linear-Kommentareinstellungen konnten nicht geladen werden.',
|
||||
'settings.magicPrompts.sidebar.group.linear': 'Linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue-Review',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue-Review',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Eingabeaufforderungen beim Start einer Sitzung aus einem Linear-Issue: sichtbare Benutzernachricht + versteckte Anweisungen.',
|
||||
},
|
||||
fr: {
|
||||
'settings.integrations.firstParty.title': 'Intégrations natives',
|
||||
'settings.integrations.firstParty.info': 'Connexions aux services fournis avec OpenChamber. La connexion reste sur cet ordinateur pour que le web, le bureau et un téléphone apparié la partagent.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
'settings.integrations.linear.description': 'Connectez des espaces Linear à ce serveur OpenChamber.',
|
||||
'settings.integrations.linear.info': 'Connectez un ou plusieurs espaces Linear. OpenChamber enregistre les connexions sur cet ordinateur pour que le web, le bureau et un téléphone apparié les partagent.',
|
||||
'settings.integrations.linear.status.notConnected': 'Non connecté',
|
||||
'settings.integrations.linear.status.connected': 'Connecté',
|
||||
'settings.integrations.linear.status.waiting': 'En attente',
|
||||
'settings.integrations.linear.actions.connect': 'Connecter',
|
||||
'settings.integrations.linear.actions.disconnect': 'Déconnecter',
|
||||
'settings.integrations.linear.actions.addWorkspace': 'Ajouter un workspace',
|
||||
'settings.integrations.linear.actions.switchTo': 'Basculer vers',
|
||||
'settings.integrations.linear.label.otherWorkspaces': 'Autres workspaces',
|
||||
'settings.integrations.linear.flow.title': 'En attente de Linear',
|
||||
'settings.integrations.linear.flow.description': 'Terminez la connexion dans l’onglet du navigateur qui vient de s’ouvrir.',
|
||||
'settings.integrations.linear.flow.waiting': 'En attente de l’autorisation…',
|
||||
'settings.integrations.linear.toast.connected': 'Linear connecté',
|
||||
'settings.integrations.linear.toast.disconnected': 'Linear déconnecté',
|
||||
'settings.integrations.linear.toast.workspaceSwitched': 'Workspace Linear modifié',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Impossible de changer de workspace Linear',
|
||||
'settings.integrations.linear.toast.startConnectFailed': 'Impossible de démarrer la connexion Linear',
|
||||
'settings.integrations.linear.toast.disconnectFailed': 'Impossible de déconnecter Linear',
|
||||
'settings.integrations.linear.toast.authorizationFailed': 'L’autorisation Linear a expiré. Cliquez sur Connecter pour réessayer.',
|
||||
'settings.integrations.linear.avatarAlt.withName': 'Avatar Linear de {name}',
|
||||
'settings.integrations.linear.avatarAlt.fallback': 'Avatar Linear',
|
||||
'settings.integrations.linear.label.unknownUser': 'Utilisateur inconnu',
|
||||
'settings.integrations.linear.mapping.defaultProject': 'Projet par défaut',
|
||||
'settings.integrations.linear.mapping.defaultProject.info': 'Les nouvelles sessions depuis des tickets Linear utilisent ce projet, sauf si l’équipe du ticket a sa propre association.',
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Aucun',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria': 'Projet par défaut pour les tickets Linear',
|
||||
'settings.integrations.linear.mapping.teams': 'Projets par équipe',
|
||||
'settings.integrations.linear.mapping.teams.info': 'Facultatif. Un ticket d’une équipe associée s’ouvre dans ce projet plutôt que dans le projet par défaut.',
|
||||
'settings.integrations.linear.mapping.teams.useDefault': 'Utiliser le défaut',
|
||||
'settings.integrations.linear.mapping.teams.aria': 'Projet pour l’équipe Linear {team}',
|
||||
'settings.integrations.linear.mapping.emptyProjects': 'Ajoutez d’abord un projet, puis associez les équipes Linear.',
|
||||
'settings.integrations.linear.mapping.emptyTeams': 'Cet espace Linear n’a aucune équipe.',
|
||||
'settings.integrations.linear.mapping.loadFailed': 'Impossible de charger l’association des projets Linear.',
|
||||
'settings.integrations.linear.sessionComments.label': 'Commentaires de session',
|
||||
'settings.integrations.linear.sessionComments.info': 'Ajoute un commentaire au ticket quand une session démarre, se termine ou échoue. Les commentaires ne sont publiés que si ce serveur a une adresse publique, afin que le lien ouvre la session pour tout le monde.',
|
||||
'settings.integrations.linear.sessionComments.aria': 'Publier les commentaires d’état de session dans Linear',
|
||||
'settings.integrations.linear.sessionComments.loadFailed': 'Impossible de charger les réglages de commentaires Linear.',
|
||||
'settings.magicPrompts.sidebar.group.linear': 'Linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revue d’issue',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title': 'Revue d’issue',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts utilisés au démarrage d’une session depuis un ticket Linear : message utilisateur visible + instructions masquées.',
|
||||
},
|
||||
es: {
|
||||
'settings.integrations.firstParty.title': 'Integraciones nativas',
|
||||
'settings.integrations.firstParty.info': 'Inicios de sesión de los servicios incluidos en OpenChamber. El inicio de sesión se guarda en este ordenador para que la web, el escritorio y un teléfono emparejado lo compartan.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
'settings.integrations.linear.description': 'Conecta espacios de Linear a este servidor de OpenChamber.',
|
||||
'settings.integrations.linear.info': 'Conecta uno o más espacios de Linear. OpenChamber guarda los inicios de sesión en este ordenador para que la web, el escritorio y un teléfono emparejado los compartan.',
|
||||
'settings.integrations.linear.status.notConnected': 'No conectado',
|
||||
'settings.integrations.linear.status.connected': 'Conectado',
|
||||
'settings.integrations.linear.status.waiting': 'Esperando',
|
||||
'settings.integrations.linear.actions.connect': 'Conectar',
|
||||
'settings.integrations.linear.actions.disconnect': 'Desconectar',
|
||||
'settings.integrations.linear.actions.addWorkspace': 'Añadir workspace',
|
||||
'settings.integrations.linear.actions.switchTo': 'Cambiar a',
|
||||
'settings.integrations.linear.label.otherWorkspaces': 'Otros workspaces',
|
||||
'settings.integrations.linear.flow.title': 'Esperando a Linear',
|
||||
'settings.integrations.linear.flow.description': 'Termina de iniciar sesión en la pestaña del navegador que acaba de abrirse.',
|
||||
'settings.integrations.linear.flow.waiting': 'Esperando la autorización…',
|
||||
'settings.integrations.linear.toast.connected': 'Linear conectado',
|
||||
'settings.integrations.linear.toast.disconnected': 'Linear desconectado',
|
||||
'settings.integrations.linear.toast.workspaceSwitched': 'Workspace de Linear cambiado',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed': 'No se pudo cambiar el workspace de Linear',
|
||||
'settings.integrations.linear.toast.startConnectFailed': 'No se pudo iniciar la conexión con Linear',
|
||||
'settings.integrations.linear.toast.disconnectFailed': 'No se pudo desconectar Linear',
|
||||
'settings.integrations.linear.toast.authorizationFailed': 'La autorización de Linear ha caducado. Haz clic en Conectar para intentarlo de nuevo.',
|
||||
'settings.integrations.linear.avatarAlt.withName': 'Avatar de Linear de {name}',
|
||||
'settings.integrations.linear.avatarAlt.fallback': 'Avatar de Linear',
|
||||
'settings.integrations.linear.label.unknownUser': 'Usuario desconocido',
|
||||
'settings.integrations.linear.mapping.defaultProject': 'Proyecto predeterminado',
|
||||
'settings.integrations.linear.mapping.defaultProject.info': 'Las sesiones nuevas desde issues de Linear usan este proyecto, salvo que el equipo del issue tenga su propia asignación.',
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Ninguno',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria': 'Proyecto predeterminado para issues de Linear',
|
||||
'settings.integrations.linear.mapping.teams': 'Proyectos por equipo',
|
||||
'settings.integrations.linear.mapping.teams.info': 'Opcional. Un issue de un equipo asignado se abre en ese proyecto en lugar del predeterminado.',
|
||||
'settings.integrations.linear.mapping.teams.useDefault': 'Usar el predeterminado',
|
||||
'settings.integrations.linear.mapping.teams.aria': 'Proyecto para el equipo de Linear {team}',
|
||||
'settings.integrations.linear.mapping.emptyProjects': 'Añade primero un proyecto y luego asigna equipos de Linear.',
|
||||
'settings.integrations.linear.mapping.emptyTeams': 'Este espacio de Linear no tiene equipos.',
|
||||
'settings.integrations.linear.mapping.loadFailed': 'No se pudo cargar la asignación de proyectos de Linear.',
|
||||
'settings.integrations.linear.sessionComments.label': 'Comentarios de sesión',
|
||||
'settings.integrations.linear.sessionComments.info': 'Añade un comentario a la incidencia cuando una sesión empieza, termina o falla. Los comentarios solo se publican si este servidor tiene una dirección pública, para que el enlace abra la sesión a todos.',
|
||||
'settings.integrations.linear.sessionComments.aria': 'Publicar comentarios de estado de sesión en Linear',
|
||||
'settings.integrations.linear.sessionComments.loadFailed': 'No se pudieron cargar los ajustes de comentarios de Linear.',
|
||||
'settings.magicPrompts.sidebar.group.linear': 'Linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revisión de issue',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title': 'Revisión de issue',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados al iniciar una sesión desde un issue de Linear: mensaje visible del usuario e instrucciones ocultas.',
|
||||
},
|
||||
ja: {
|
||||
'settings.integrations.firstParty.title': '標準連携',
|
||||
'settings.integrations.firstParty.info': 'OpenChamber に同梱されているサービスのログインです。このコンピュータに保存され、Web、デスクトップ、ペアリングしたスマホで共有されます。',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
'settings.integrations.linear.description': 'この OpenChamber サーバーに Linear ワークスペースを接続します。複数接続できます。',
|
||||
'settings.integrations.linear.info': 'Linear ワークスペースを1つ以上接続します。ログインはこのコンピュータに保存され、Web、デスクトップ、ペアリングしたスマホで共有されます。',
|
||||
'settings.integrations.linear.status.notConnected': '未接続',
|
||||
'settings.integrations.linear.status.connected': '接続済み',
|
||||
'settings.integrations.linear.status.waiting': '待機中',
|
||||
'settings.integrations.linear.actions.connect': '接続',
|
||||
'settings.integrations.linear.actions.disconnect': '切断',
|
||||
'settings.integrations.linear.actions.addWorkspace': 'ワークスペースを追加',
|
||||
'settings.integrations.linear.actions.switchTo': '切り替える',
|
||||
'settings.integrations.linear.label.otherWorkspaces': '他のワークスペース',
|
||||
'settings.integrations.linear.flow.title': 'Linear を待っています',
|
||||
'settings.integrations.linear.flow.description': '開いたブラウザタブでサインインを完了してください。',
|
||||
'settings.integrations.linear.flow.waiting': '認可を待っています…',
|
||||
'settings.integrations.linear.toast.connected': 'Linear に接続しました',
|
||||
'settings.integrations.linear.toast.disconnected': 'Linear を切断しました',
|
||||
'settings.integrations.linear.toast.workspaceSwitched': 'Linear ワークスペースを切り替えました',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear ワークスペースを切り替えられませんでした',
|
||||
'settings.integrations.linear.toast.startConnectFailed': 'Linear のサインインを開始できませんでした',
|
||||
'settings.integrations.linear.toast.disconnectFailed': 'Linear を切断できませんでした',
|
||||
'settings.integrations.linear.toast.authorizationFailed': 'Linear の認可がタイムアウトしました。接続をもう一度押してください。',
|
||||
'settings.integrations.linear.avatarAlt.withName': '{name} の Linear アバター',
|
||||
'settings.integrations.linear.avatarAlt.fallback': 'Linear アバター',
|
||||
'settings.integrations.linear.label.unknownUser': '不明なユーザー',
|
||||
'settings.integrations.linear.mapping.defaultProject': 'デフォルトのプロジェクト',
|
||||
'settings.integrations.linear.mapping.defaultProject.info': 'Linear Issueから作る新しいセッションはこのプロジェクトを使います。チームに個別の割り当てがある場合はそちらを使います。',
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder': 'なし',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issueのデフォルトプロジェクト',
|
||||
'settings.integrations.linear.mapping.teams': 'チームのプロジェクト',
|
||||
'settings.integrations.linear.mapping.teams.info': '任意。割り当てたチームのIssueは、デフォルトではなくそのプロジェクトで開きます。',
|
||||
'settings.integrations.linear.mapping.teams.useDefault': 'デフォルトを使う',
|
||||
'settings.integrations.linear.mapping.teams.aria': 'Linearチーム {team} のプロジェクト',
|
||||
'settings.integrations.linear.mapping.emptyProjects': '先にプロジェクトを追加してから、Linearチームを割り当ててください。',
|
||||
'settings.integrations.linear.mapping.emptyTeams': 'このLinearワークスペースにはチームがありません。',
|
||||
'settings.integrations.linear.mapping.loadFailed': 'Linearのプロジェクト割り当てを読み込めませんでした。',
|
||||
'settings.integrations.linear.sessionComments.label': 'セッションのコメント',
|
||||
'settings.integrations.linear.sessionComments.info': 'セッションの開始・完了・失敗時にイシューへコメントします。リンクを誰でも開けるよう、このサーバーが公開アドレスを持つ場合のみ投稿します。',
|
||||
'settings.integrations.linear.sessionComments.aria': 'セッション状態のコメントを Linear に投稿',
|
||||
'settings.integrations.linear.sessionComments.loadFailed': 'Linear のコメント設定を読み込めませんでした。',
|
||||
'settings.magicPrompts.sidebar.group.linear': 'Linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue レビュー',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue レビュー',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear の Issue からセッションを開始するときに使うプロンプト: 表示ユーザーメッセージ + 非表示の指示。',
|
||||
},
|
||||
ko: {
|
||||
'settings.integrations.firstParty.title': '기본 제공 통합',
|
||||
'settings.integrations.firstParty.info': 'OpenChamber에 포함된 서비스 로그인입니다. 이 컴퓨터에 저장되며 웹, 데스크톱, 페어링된 휴대폰이 공유합니다.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
'settings.integrations.linear.description': '이 OpenChamber 서버에 Linear 워크스페이스를 연결하세요. 여러 개를 연결할 수 있습니다.',
|
||||
'settings.integrations.linear.info': 'Linear 워크스페이스를 하나 이상 연결하세요. 로그인은 이 컴퓨터에 저장되며 웹, 데스크톱, 페어링된 휴대폰이 공유합니다.',
|
||||
'settings.integrations.linear.status.notConnected': '연결되지 않음',
|
||||
'settings.integrations.linear.status.connected': '연결됨',
|
||||
'settings.integrations.linear.status.waiting': '대기 중',
|
||||
'settings.integrations.linear.actions.connect': '연결',
|
||||
'settings.integrations.linear.actions.disconnect': '연결 해제',
|
||||
'settings.integrations.linear.actions.addWorkspace': '워크스페이스 추가',
|
||||
'settings.integrations.linear.actions.switchTo': '전환',
|
||||
'settings.integrations.linear.label.otherWorkspaces': '다른 워크스페이스',
|
||||
'settings.integrations.linear.flow.title': 'Linear 대기 중',
|
||||
'settings.integrations.linear.flow.description': '방금 열린 브라우저 탭에서 로그인을 완료하세요.',
|
||||
'settings.integrations.linear.flow.waiting': '권한 부여를 기다리는 중…',
|
||||
'settings.integrations.linear.toast.connected': 'Linear가 연결됨',
|
||||
'settings.integrations.linear.toast.disconnected': 'Linear 연결이 해제됨',
|
||||
'settings.integrations.linear.toast.workspaceSwitched': 'Linear 워크스페이스를 전환했습니다',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear 워크스페이스를 전환하지 못했습니다',
|
||||
'settings.integrations.linear.toast.startConnectFailed': 'Linear 로그인을 시작하지 못했습니다',
|
||||
'settings.integrations.linear.toast.disconnectFailed': 'Linear 연결을 해제하지 못했습니다',
|
||||
'settings.integrations.linear.toast.authorizationFailed': 'Linear 권한 부여가 시간 초과되었습니다. 연결을 다시 누르세요.',
|
||||
'settings.integrations.linear.avatarAlt.withName': '{name}의 Linear 아바타',
|
||||
'settings.integrations.linear.avatarAlt.fallback': 'Linear 아바타',
|
||||
'settings.integrations.linear.label.unknownUser': '알 수 없는 사용자',
|
||||
'settings.integrations.linear.mapping.defaultProject': '기본 프로젝트',
|
||||
'settings.integrations.linear.mapping.defaultProject.info': 'Linear 이슈에서 만드는 새 세션은 이 프로젝트를 사용합니다. 해당 팀에 별도 연결이 있으면 그쪽을 씁니다.',
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder': '없음',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria': 'Linear 이슈의 기본 프로젝트',
|
||||
'settings.integrations.linear.mapping.teams': '팀 프로젝트',
|
||||
'settings.integrations.linear.mapping.teams.info': '선택 사항입니다. 연결한 팀의 이슈는 기본값 대신 그 프로젝트에서 열립니다.',
|
||||
'settings.integrations.linear.mapping.teams.useDefault': '기본값 사용',
|
||||
'settings.integrations.linear.mapping.teams.aria': 'Linear 팀 {team}의 프로젝트',
|
||||
'settings.integrations.linear.mapping.emptyProjects': '먼저 프로젝트를 추가한 다음 Linear 팀을 연결하세요.',
|
||||
'settings.integrations.linear.mapping.emptyTeams': '이 Linear 워크스페이스에는 팀이 없습니다.',
|
||||
'settings.integrations.linear.mapping.loadFailed': 'Linear 프로젝트 연결을 불러오지 못했습니다.',
|
||||
'settings.integrations.linear.sessionComments.label': '세션 댓글',
|
||||
'settings.integrations.linear.sessionComments.info': '세션이 시작, 완료, 실패할 때 이슈에 댓글을 남깁니다. 링크를 모두가 열 수 있도록 이 서버에 공개 주소가 있을 때만 게시합니다.',
|
||||
'settings.integrations.linear.sessionComments.aria': '세션 상태 댓글을 Linear에 게시',
|
||||
'settings.integrations.linear.sessionComments.loadFailed': 'Linear 댓글 설정을 불러오지 못했습니다.',
|
||||
'settings.magicPrompts.sidebar.group.linear': 'Linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview': '이슈 리뷰',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title': '이슈 리뷰',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear 이슈로 세션을 시작할 때 쓰는 프롬프트입니다: 표시 사용자 메시지 + 숨겨진 지침.',
|
||||
},
|
||||
pl: {
|
||||
'settings.integrations.firstParty.title': 'Wbudowane integracje',
|
||||
'settings.integrations.firstParty.info': 'Logowania do usług dostarczanych z OpenChamber. Zapisujemy je na tym komputerze, żeby przeglądarka, aplikacja desktopowa i sparowany telefon z nich korzystały.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
'settings.integrations.linear.description': 'Połącz przestrzenie Linear z tym serwerem OpenChamber.',
|
||||
'settings.integrations.linear.info': 'Połącz jedną lub kilka przestrzeni Linear. OpenChamber zapisuje logowania na tym komputerze, żeby przeglądarka, aplikacja desktopowa i sparowany telefon z nich korzystały.',
|
||||
'settings.integrations.linear.status.notConnected': 'Nie połączono',
|
||||
'settings.integrations.linear.status.connected': 'Połączono',
|
||||
'settings.integrations.linear.status.waiting': 'Oczekiwanie',
|
||||
'settings.integrations.linear.actions.connect': 'Połącz',
|
||||
'settings.integrations.linear.actions.disconnect': 'Rozłącz',
|
||||
'settings.integrations.linear.actions.addWorkspace': 'Dodaj workspace',
|
||||
'settings.integrations.linear.actions.switchTo': 'Przełącz na',
|
||||
'settings.integrations.linear.label.otherWorkspaces': 'Inne przestrzenie',
|
||||
'settings.integrations.linear.flow.title': 'Oczekiwanie na Linear',
|
||||
'settings.integrations.linear.flow.description': 'Dokończ logowanie w karcie przeglądarki, która właśnie się otworzyła.',
|
||||
'settings.integrations.linear.flow.waiting': 'Oczekiwanie na autoryzację…',
|
||||
'settings.integrations.linear.toast.connected': 'Połączono z Linear',
|
||||
'settings.integrations.linear.toast.disconnected': 'Rozłączono Linear',
|
||||
'settings.integrations.linear.toast.workspaceSwitched': 'Przełączono workspace Linear',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Nie udało się przełączyć workspace Linear',
|
||||
'settings.integrations.linear.toast.startConnectFailed': 'Nie udało się rozpocząć logowania do Linear',
|
||||
'settings.integrations.linear.toast.disconnectFailed': 'Nie udało się rozłączyć Linear',
|
||||
'settings.integrations.linear.toast.authorizationFailed': 'Autoryzacja Linear wygasła. Kliknij Połącz, aby spróbować ponownie.',
|
||||
'settings.integrations.linear.avatarAlt.withName': 'Awatar Linear użytkownika {name}',
|
||||
'settings.integrations.linear.avatarAlt.fallback': 'Awatar Linear',
|
||||
'settings.integrations.linear.label.unknownUser': 'Nieznany użytkownik',
|
||||
'settings.integrations.linear.mapping.defaultProject': 'Domyślny projekt',
|
||||
'settings.integrations.linear.mapping.defaultProject.info': 'Nowe sesje ze zgłoszeń Linear używają tego projektu, chyba że zespół zgłoszenia ma własne przypisanie.',
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Brak',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria': 'Domyślny projekt dla zgłoszeń Linear',
|
||||
'settings.integrations.linear.mapping.teams': 'Projekty zespołów',
|
||||
'settings.integrations.linear.mapping.teams.info': 'Opcjonalnie. Zgłoszenie z przypisanego zespołu otworzy się w tym projekcie zamiast w domyślnym.',
|
||||
'settings.integrations.linear.mapping.teams.useDefault': 'Użyj domyślnego',
|
||||
'settings.integrations.linear.mapping.teams.aria': 'Projekt dla zespołu Linear {team}',
|
||||
'settings.integrations.linear.mapping.emptyProjects': 'Najpierw dodaj projekt, a potem przypisz zespoły Linear.',
|
||||
'settings.integrations.linear.mapping.emptyTeams': 'Ten obszar Linear nie ma zespołów.',
|
||||
'settings.integrations.linear.mapping.loadFailed': 'Nie udało się wczytać przypisania projektów Linear.',
|
||||
'settings.integrations.linear.sessionComments.label': 'Komentarze o sesji',
|
||||
'settings.integrations.linear.sessionComments.info': 'Dodaje komentarz do zgłoszenia, gdy sesja się zaczyna, kończy lub kończy błędem. Komentarze pojawiają się tylko wtedy, gdy ten serwer ma publiczny adres, żeby link otwierał sesję każdemu.',
|
||||
'settings.integrations.linear.sessionComments.aria': 'Publikuj komentarze o stanie sesji w Linear',
|
||||
'settings.integrations.linear.sessionComments.loadFailed': 'Nie udało się wczytać ustawień komentarzy Linear.',
|
||||
'settings.magicPrompts.sidebar.group.linear': 'Linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Przegląd zgłoszenia',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title': 'Przegląd zgłoszenia',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompty używane przy starcie sesji ze zgłoszenia Linear: widoczna wiadomość użytkownika i ukryte instrukcje.',
|
||||
},
|
||||
'pt-BR': {
|
||||
'settings.integrations.firstParty.title': 'Integrações nativas',
|
||||
'settings.integrations.firstParty.info': 'Logins dos serviços inclusos no OpenChamber. O login fica neste computador para que a web, o app desktop e um celular emparelhado o compartilhem.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
'settings.integrations.linear.description': 'Conecte espaços do Linear a este servidor OpenChamber.',
|
||||
'settings.integrations.linear.info': 'Conecte um ou mais espaços do Linear. O OpenChamber guarda os logins neste computador para que a web, o app desktop e um celular emparelhado os compartilhem.',
|
||||
'settings.integrations.linear.status.notConnected': 'Não conectado',
|
||||
'settings.integrations.linear.status.connected': 'Conectado',
|
||||
'settings.integrations.linear.status.waiting': 'Aguardando',
|
||||
'settings.integrations.linear.actions.connect': 'Conectar',
|
||||
'settings.integrations.linear.actions.disconnect': 'Desconectar',
|
||||
'settings.integrations.linear.actions.addWorkspace': 'Adicionar workspace',
|
||||
'settings.integrations.linear.actions.switchTo': 'Alternar para',
|
||||
'settings.integrations.linear.label.otherWorkspaces': 'Outros workspaces',
|
||||
'settings.integrations.linear.flow.title': 'Aguardando o Linear',
|
||||
'settings.integrations.linear.flow.description': 'Conclua o login na aba do navegador que acabou de abrir.',
|
||||
'settings.integrations.linear.flow.waiting': 'Aguardando autorização…',
|
||||
'settings.integrations.linear.toast.connected': 'Linear conectado',
|
||||
'settings.integrations.linear.toast.disconnected': 'Linear desconectado',
|
||||
'settings.integrations.linear.toast.workspaceSwitched': 'Workspace do Linear alterado',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Não foi possível alternar o workspace do Linear',
|
||||
'settings.integrations.linear.toast.startConnectFailed': 'Não foi possível iniciar o login no Linear',
|
||||
'settings.integrations.linear.toast.disconnectFailed': 'Não foi possível desconectar o Linear',
|
||||
'settings.integrations.linear.toast.authorizationFailed': 'A autorização do Linear expirou. Clique em Conectar para tentar de novo.',
|
||||
'settings.integrations.linear.avatarAlt.withName': 'Avatar do Linear de {name}',
|
||||
'settings.integrations.linear.avatarAlt.fallback': 'Avatar do Linear',
|
||||
'settings.integrations.linear.label.unknownUser': 'Usuário desconhecido',
|
||||
'settings.integrations.linear.mapping.defaultProject': 'Projeto padrão',
|
||||
'settings.integrations.linear.mapping.defaultProject.info': 'Novas sessões a partir de issues do Linear usam este projeto, a menos que a equipe da issue tenha o próprio mapeamento.',
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Nenhum',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria': 'Projeto padrão para issues do Linear',
|
||||
'settings.integrations.linear.mapping.teams': 'Projetos por equipe',
|
||||
'settings.integrations.linear.mapping.teams.info': 'Opcional. Uma issue de uma equipe mapeada abre nesse projeto em vez do padrão.',
|
||||
'settings.integrations.linear.mapping.teams.useDefault': 'Usar o padrão',
|
||||
'settings.integrations.linear.mapping.teams.aria': 'Projeto para a equipe do Linear {team}',
|
||||
'settings.integrations.linear.mapping.emptyProjects': 'Adicione um projeto primeiro e depois mapeie as equipes do Linear.',
|
||||
'settings.integrations.linear.mapping.emptyTeams': 'Este espaço do Linear não tem equipes.',
|
||||
'settings.integrations.linear.mapping.loadFailed': 'Não foi possível carregar o mapeamento de projetos do Linear.',
|
||||
'settings.integrations.linear.sessionComments.label': 'Comentários de sessão',
|
||||
'settings.integrations.linear.sessionComments.info': 'Comenta na issue quando uma sessão começa, termina ou falha. Os comentários só são publicados se este servidor tiver um endereço público, para que o link abra a sessão para todos.',
|
||||
'settings.integrations.linear.sessionComments.aria': 'Publicar comentários de status de sessão no Linear',
|
||||
'settings.integrations.linear.sessionComments.loadFailed': 'Não foi possível carregar as configurações de comentários do Linear.',
|
||||
'settings.magicPrompts.sidebar.group.linear': 'Linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Revisão de issue',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title': 'Revisão de issue',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados ao iniciar uma sessão a partir de uma issue do Linear: mensagem visível do usuário e instruções ocultas.',
|
||||
},
|
||||
uk: {
|
||||
'settings.integrations.firstParty.title': 'Вбудовані інтеграції',
|
||||
'settings.integrations.firstParty.info': 'Входи до сервісів, що входять до OpenChamber. Логін лишається на цьому комп’ютері, тож веб, десктоп і спарений телефон користуються одним обліковим записом.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
'settings.integrations.linear.description': 'Підключіть Linear workspace до цього сервера OpenChamber. Можна кілька.',
|
||||
'settings.integrations.linear.info': 'Підключіть один або кілька Linear workspace. OpenChamber зберігає входи на цьому комп’ютері, тож веб, десктоп і спарений телефон користуються ними.',
|
||||
'settings.integrations.linear.status.notConnected': 'Не підключено',
|
||||
'settings.integrations.linear.status.connected': 'Підключено',
|
||||
'settings.integrations.linear.status.waiting': 'Очікування',
|
||||
'settings.integrations.linear.actions.connect': 'Підключити',
|
||||
'settings.integrations.linear.actions.disconnect': 'Відключити',
|
||||
'settings.integrations.linear.actions.addWorkspace': 'Додати workspace',
|
||||
'settings.integrations.linear.actions.switchTo': 'Перемкнути на',
|
||||
'settings.integrations.linear.label.otherWorkspaces': 'Інші workspace',
|
||||
'settings.integrations.linear.flow.title': 'Очікування Linear',
|
||||
'settings.integrations.linear.flow.description': 'Завершіть вхід у вкладці браузера, яка щойно відкрилась.',
|
||||
'settings.integrations.linear.flow.waiting': 'Очікування авторизації…',
|
||||
'settings.integrations.linear.toast.connected': 'Linear підключено',
|
||||
'settings.integrations.linear.toast.disconnected': 'Linear відключено',
|
||||
'settings.integrations.linear.toast.workspaceSwitched': 'Перемкнуто Linear workspace',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Не вдалося перемкнути Linear workspace',
|
||||
'settings.integrations.linear.toast.startConnectFailed': 'Не вдалося почати вхід у Linear',
|
||||
'settings.integrations.linear.toast.disconnectFailed': 'Не вдалося відключити Linear',
|
||||
'settings.integrations.linear.toast.authorizationFailed': 'Авторизація Linear завершилась за часом. Натисніть Підключити ще раз.',
|
||||
'settings.integrations.linear.avatarAlt.withName': 'Аватар Linear для {name}',
|
||||
'settings.integrations.linear.avatarAlt.fallback': 'Аватар Linear',
|
||||
'settings.integrations.linear.label.unknownUser': 'Невідомий користувач',
|
||||
'settings.integrations.linear.mapping.defaultProject': 'Проєкт за замовчуванням',
|
||||
'settings.integrations.linear.mapping.defaultProject.info': 'Нові сесії з Linear issue використовують цей проєкт, якщо в команди issue немає власної прив’язки.',
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Немає',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria': 'Проєкт за замовчуванням для Linear issue',
|
||||
'settings.integrations.linear.mapping.teams': 'Проєкти команд',
|
||||
'settings.integrations.linear.mapping.teams.info': 'Не обов’язково. Issue з прив’язаної команди відкриється в цьому проєкті, а не в типовому.',
|
||||
'settings.integrations.linear.mapping.teams.useDefault': 'Використати типовий',
|
||||
'settings.integrations.linear.mapping.teams.aria': 'Проєкт для команди Linear {team}',
|
||||
'settings.integrations.linear.mapping.emptyProjects': 'Спочатку додайте проєкт, потім прив’яжіть команди Linear.',
|
||||
'settings.integrations.linear.mapping.emptyTeams': 'У цьому робочому просторі Linear немає команд.',
|
||||
'settings.integrations.linear.mapping.loadFailed': 'Не вдалося завантажити прив’язку проєктів Linear.',
|
||||
'settings.integrations.linear.sessionComments.label': 'Коментарі про сесію',
|
||||
'settings.integrations.linear.sessionComments.info': 'Додає коментар до тікета, коли сесія починається, завершується або падає. Коментарі публікуються, лише якщо цей сервер має публічну адресу, щоб посилання відкривало сесію для всіх.',
|
||||
'settings.integrations.linear.sessionComments.aria': 'Публікувати коментарі про стан сесії в Linear',
|
||||
'settings.integrations.linear.sessionComments.loadFailed': 'Не вдалося завантажити налаштування коментарів Linear.',
|
||||
'settings.magicPrompts.sidebar.group.linear': 'Linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Огляд issue',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title': 'Огляд issue',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Промпти для старту сесії з Linear issue: видиме повідомлення користувача та приховані інструкції.',
|
||||
},
|
||||
'zh-CN': {
|
||||
'settings.integrations.firstParty.title': '内置集成',
|
||||
'settings.integrations.firstParty.info': 'OpenChamber 自带服务的登录。登录保存在这台电脑上,网页、桌面应用和已配对的手机会共用它。',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
'settings.integrations.linear.description': '将 Linear 工作区连接到此 OpenChamber 服务器。可以连接多个。',
|
||||
'settings.integrations.linear.info': '连接一个或多个 Linear 工作区。OpenChamber 把登录保存在这台电脑上,网页、桌面应用和已配对的手机会共用它们。',
|
||||
'settings.integrations.linear.status.notConnected': '未连接',
|
||||
'settings.integrations.linear.status.connected': '已连接',
|
||||
'settings.integrations.linear.status.waiting': '等待中',
|
||||
'settings.integrations.linear.actions.connect': '连接',
|
||||
'settings.integrations.linear.actions.disconnect': '断开',
|
||||
'settings.integrations.linear.actions.addWorkspace': '添加工作区',
|
||||
'settings.integrations.linear.actions.switchTo': '切换到',
|
||||
'settings.integrations.linear.label.otherWorkspaces': '其他工作区',
|
||||
'settings.integrations.linear.flow.title': '正在等待 Linear',
|
||||
'settings.integrations.linear.flow.description': '请在刚打开的浏览器标签页中完成登录。',
|
||||
'settings.integrations.linear.flow.waiting': '正在等待授权…',
|
||||
'settings.integrations.linear.toast.connected': '已连接 Linear',
|
||||
'settings.integrations.linear.toast.disconnected': '已断开 Linear',
|
||||
'settings.integrations.linear.toast.workspaceSwitched': '已切换 Linear 工作区',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed': '无法切换 Linear 工作区',
|
||||
'settings.integrations.linear.toast.startConnectFailed': '无法开始 Linear 登录',
|
||||
'settings.integrations.linear.toast.disconnectFailed': '无法断开 Linear',
|
||||
'settings.integrations.linear.toast.authorizationFailed': 'Linear 授权已超时。请再次点击连接。',
|
||||
'settings.integrations.linear.avatarAlt.withName': '{name} 的 Linear 头像',
|
||||
'settings.integrations.linear.avatarAlt.fallback': 'Linear 头像',
|
||||
'settings.integrations.linear.label.unknownUser': '未知用户',
|
||||
'settings.integrations.linear.mapping.defaultProject': '默认项目',
|
||||
'settings.integrations.linear.mapping.defaultProject.info': '从 Linear Issue 新建的会话会使用此项目,除非该 Issue 所属团队有单独映射。',
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder': '无',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issue 的默认项目',
|
||||
'settings.integrations.linear.mapping.teams': '团队项目',
|
||||
'settings.integrations.linear.mapping.teams.info': '可选。来自已映射团队的 Issue 会在该项目中打开,而不是默认项目。',
|
||||
'settings.integrations.linear.mapping.teams.useDefault': '使用默认',
|
||||
'settings.integrations.linear.mapping.teams.aria': 'Linear 团队 {team} 的项目',
|
||||
'settings.integrations.linear.mapping.emptyProjects': '请先添加一个项目,再映射 Linear 团队。',
|
||||
'settings.integrations.linear.mapping.emptyTeams': '此 Linear 工作区没有团队。',
|
||||
'settings.integrations.linear.mapping.loadFailed': '无法加载 Linear 项目映射。',
|
||||
'settings.integrations.linear.sessionComments.label': '会话评论',
|
||||
'settings.integrations.linear.sessionComments.info': '会话开始、完成或失败时在议题下留言。仅当此服务器拥有公网地址时才发布,这样链接才能让所有人打开该会话。',
|
||||
'settings.integrations.linear.sessionComments.aria': '将会话状态评论发布到 Linear',
|
||||
'settings.integrations.linear.sessionComments.loadFailed': '无法加载 Linear 评论设置。',
|
||||
'settings.magicPrompts.sidebar.group.linear': 'Linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue 审查',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue 审查',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': '从 Linear Issue 开始会话时使用的提示词:可见用户消息 + 隐藏指令。',
|
||||
},
|
||||
'zh-TW': {
|
||||
'settings.integrations.firstParty.title': '內建整合',
|
||||
'settings.integrations.firstParty.info': 'OpenChamber 內建服務的登入。登入保存在這台電腦上,網頁、桌面應用程式和已配對的手機會共用它。',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
'settings.integrations.linear.description': '將 Linear 工作區連線到此 OpenChamber 伺服器。可以連線多個。',
|
||||
'settings.integrations.linear.info': '連接一個或多個 Linear 工作區。OpenChamber 把登入保存在這台電腦上,網頁、桌面應用程式和已配對的手機會共用它們。',
|
||||
'settings.integrations.linear.status.notConnected': '未連線',
|
||||
'settings.integrations.linear.status.connected': '已連線',
|
||||
'settings.integrations.linear.status.waiting': '等待中',
|
||||
'settings.integrations.linear.actions.connect': '連線',
|
||||
'settings.integrations.linear.actions.disconnect': '中斷連線',
|
||||
'settings.integrations.linear.actions.addWorkspace': '新增工作區',
|
||||
'settings.integrations.linear.actions.switchTo': '切換到',
|
||||
'settings.integrations.linear.label.otherWorkspaces': '其他工作區',
|
||||
'settings.integrations.linear.flow.title': '正在等待 Linear',
|
||||
'settings.integrations.linear.flow.description': '請在剛開啟的瀏覽器分頁中完成登入。',
|
||||
'settings.integrations.linear.flow.waiting': '正在等待授權…',
|
||||
'settings.integrations.linear.toast.connected': '已連線 Linear',
|
||||
'settings.integrations.linear.toast.disconnected': '已中斷 Linear',
|
||||
'settings.integrations.linear.toast.workspaceSwitched': '已切換 Linear 工作區',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed': '無法切換 Linear 工作區',
|
||||
'settings.integrations.linear.toast.startConnectFailed': '無法開始 Linear 登入',
|
||||
'settings.integrations.linear.toast.disconnectFailed': '無法中斷 Linear',
|
||||
'settings.integrations.linear.toast.authorizationFailed': 'Linear 授權已逾時。請再次按連線。',
|
||||
'settings.integrations.linear.avatarAlt.withName': '{name} 的 Linear 頭像',
|
||||
'settings.integrations.linear.avatarAlt.fallback': 'Linear 頭像',
|
||||
'settings.integrations.linear.label.unknownUser': '未知使用者',
|
||||
'settings.integrations.linear.mapping.defaultProject': '預設專案',
|
||||
'settings.integrations.linear.mapping.defaultProject.info': '從 Linear Issue 新增的會話會使用此專案,除非該 Issue 所屬團隊有單獨對應。',
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder': '無',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria': 'Linear Issue 的預設專案',
|
||||
'settings.integrations.linear.mapping.teams': '團隊專案',
|
||||
'settings.integrations.linear.mapping.teams.info': '選用。來自已對應團隊的 Issue 會在該專案中開啟,而不是預設專案。',
|
||||
'settings.integrations.linear.mapping.teams.useDefault': '使用預設',
|
||||
'settings.integrations.linear.mapping.teams.aria': 'Linear 團隊 {team} 的專案',
|
||||
'settings.integrations.linear.mapping.emptyProjects': '請先新增一個專案,再對應 Linear 團隊。',
|
||||
'settings.integrations.linear.mapping.emptyTeams': '此 Linear 工作區沒有團隊。',
|
||||
'settings.integrations.linear.mapping.loadFailed': '無法載入 Linear 專案對應。',
|
||||
'settings.integrations.linear.sessionComments.label': '工作階段留言',
|
||||
'settings.integrations.linear.sessionComments.info': '工作階段開始、完成或失敗時在議題留言。僅在這台伺服器有公開位址時才發布,這樣連結才能讓所有人開啟該工作階段。',
|
||||
'settings.integrations.linear.sessionComments.aria': '將工作階段狀態留言發布到 Linear',
|
||||
'settings.integrations.linear.sessionComments.loadFailed': '無法載入 Linear 留言設定。',
|
||||
'settings.magicPrompts.sidebar.group.linear': 'Linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue 審查',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue 審查',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': '從 Linear Issue 開始會話時使用的提示詞:可見使用者訊息 + 隱藏指令。',
|
||||
},
|
||||
tr: {
|
||||
'settings.integrations.firstParty.title': 'Yerleşik entegrasyonlar',
|
||||
'settings.integrations.firstParty.info': 'OpenChamber ile gelen hizmetlerin oturumları. Giriş bu bilgisayarda kalır; web, masaüstü ve eşlenen telefon paylaşır.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
'settings.integrations.linear.description': 'Linear çalışma alanlarını bu OpenChamber sunucusuna bağla.',
|
||||
'settings.integrations.linear.info': 'Bir veya daha fazla Linear çalışma alanı bağla. OpenChamber girişleri bu bilgisayarda tutar; web, masaüstü ve eşlenen telefon paylaşır.',
|
||||
'settings.integrations.linear.status.notConnected': 'Bağlı değil',
|
||||
'settings.integrations.linear.status.connected': 'Bağlı',
|
||||
'settings.integrations.linear.status.waiting': 'Bekleniyor',
|
||||
'settings.integrations.linear.actions.connect': 'Bağlan',
|
||||
'settings.integrations.linear.actions.disconnect': 'Bağlantıyı kes',
|
||||
'settings.integrations.linear.actions.addWorkspace': 'Çalışma alanı ekle',
|
||||
'settings.integrations.linear.actions.switchTo': 'Şuna geç',
|
||||
'settings.integrations.linear.label.otherWorkspaces': 'Diğer çalışma alanları',
|
||||
'settings.integrations.linear.flow.title': 'Linear bekleniyor',
|
||||
'settings.integrations.linear.flow.description': 'Az önce açılan tarayıcı sekmesinde girişi bitir.',
|
||||
'settings.integrations.linear.flow.waiting': 'Yetkilendirme bekleniyor…',
|
||||
'settings.integrations.linear.toast.connected': 'Linear bağlandı',
|
||||
'settings.integrations.linear.toast.disconnected': 'Linear bağlantısı kesildi',
|
||||
'settings.integrations.linear.toast.workspaceSwitched': 'Linear çalışma alanı değiştirildi',
|
||||
'settings.integrations.linear.toast.workspaceSwitchFailed': 'Linear çalışma alanı değiştirilemedi',
|
||||
'settings.integrations.linear.toast.startConnectFailed': 'Linear girişi başlatılamadı',
|
||||
'settings.integrations.linear.toast.disconnectFailed': 'Linear bağlantısı kesilemedi',
|
||||
'settings.integrations.linear.toast.authorizationFailed': "Linear yetkilendirmesi zaman aşımına uğradı. Yeniden bağlanmak için Bağlan'a bas.",
|
||||
'settings.integrations.linear.avatarAlt.withName': '{name} için Linear avatarı',
|
||||
'settings.integrations.linear.avatarAlt.fallback': 'Linear avatarı',
|
||||
'settings.integrations.linear.label.unknownUser': 'Bilinmeyen kullanıcı',
|
||||
'settings.integrations.linear.mapping.defaultProject': 'Varsayılan proje',
|
||||
'settings.integrations.linear.mapping.defaultProject.info': "Linear issue'larından yeni session'lar, ekibin kendi eşlemesi yoksa bu projeyi kullanır.",
|
||||
'settings.integrations.linear.mapping.defaultProject.placeholder': 'Yok',
|
||||
'settings.integrations.linear.mapping.defaultProject.aria': "Linear issue'ları için varsayılan proje",
|
||||
'settings.integrations.linear.mapping.teams': 'Ekip projeleri',
|
||||
'settings.integrations.linear.mapping.teams.info': 'İsteğe bağlı. Eşlenen bir ekipten gelen issue varsayılan yerine o projede açılır.',
|
||||
'settings.integrations.linear.mapping.teams.useDefault': 'Varsayılanı kullan',
|
||||
'settings.integrations.linear.mapping.teams.aria': 'Linear ekibi {team} için proje',
|
||||
'settings.integrations.linear.mapping.emptyProjects': 'Önce bir proje ekle, sonra Linear ekiplerini ona eşle.',
|
||||
'settings.integrations.linear.mapping.emptyTeams': 'Bu Linear çalışma alanında ekip yok.',
|
||||
'settings.integrations.linear.mapping.loadFailed': 'Linear proje eşlemesi yüklenemedi.',
|
||||
'settings.integrations.linear.sessionComments.label': 'Oturum yorumları',
|
||||
'settings.integrations.linear.sessionComments.info': 'Bir oturum başladığında, bittiğinde veya başarısız olduğunda göreve yorum ekler. Bağlantının herkeste açılabilmesi için yorumlar yalnızca bu sunucunun genel bir adresi varsa gönderilir.',
|
||||
'settings.integrations.linear.sessionComments.aria': 'Oturum durumu yorumlarını Linear’a gönder',
|
||||
'settings.integrations.linear.sessionComments.loadFailed': 'Linear yorum ayarları yüklenemedi.',
|
||||
'settings.magicPrompts.sidebar.group.linear': 'Linear',
|
||||
'settings.magicPrompts.sidebar.item.linearIssueReview': 'Issue incelemesi',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.title': 'Issue incelemesi',
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': "Linear issue'dan session başlatırken kullanılan prompt'lar: görünen kullanıcı mesajı + gizli talimatlar.",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
|
||||
const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const;
|
||||
|
||||
const requiredKeys = [
|
||||
'chat.chatInput.actions.linkLinearIssue',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria',
|
||||
'chat.chatInput.linked.linearIssue.removeAria',
|
||||
'session.linearIssuePicker.title',
|
||||
'session.linearIssuePicker.description',
|
||||
'session.linearIssuePicker.searchPlaceholder',
|
||||
'session.linearIssuePicker.empty.notConnected',
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable',
|
||||
'session.linearIssuePicker.empty.noIssuesFound',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound',
|
||||
'session.linearIssuePicker.loading.issues',
|
||||
'session.linearIssuePicker.loading.more',
|
||||
'session.linearIssuePicker.actions.openSettings',
|
||||
'session.linearIssuePicker.actions.useIssue',
|
||||
'session.linearIssuePicker.actions.loadMore',
|
||||
'session.linearIssuePicker.actions.openInLinearAria',
|
||||
'session.linearIssuePicker.toast.loadMoreFailed',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed',
|
||||
'session.linearIssuePicker.error.notConnected',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable',
|
||||
'session.linearIssuePicker.error.issueNotFound',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue',
|
||||
'session.linearIssuePicker.title.createSession',
|
||||
'session.linearIssuePicker.description.createSession',
|
||||
'session.linearIssuePicker.error.noMappedProject',
|
||||
'session.linearIssuePicker.error.noModelSelected',
|
||||
'session.linearIssuePicker.toast.sendContextFailed',
|
||||
'session.linearIssuePicker.toast.sessionCreated',
|
||||
'session.linearIssuePicker.toast.startSessionFailed',
|
||||
'session.linearIssuePicker.actions.sectionTitle',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria',
|
||||
'session.linearIssuePicker.actions.createInWorktree',
|
||||
'session.linearIssuePicker.actions.refresh',
|
||||
'chat.workStatus.linkedIssues.openLinear',
|
||||
'session.newWorktree.actions.startFromLinearIssue',
|
||||
'session.newWorktree.fromLinearIssue',
|
||||
'session.newWorktree.error.sendLinearContextFailed',
|
||||
] as const;
|
||||
|
||||
describe('linear issue picker translations', () => {
|
||||
test('provides every required key in every supported locale', () => {
|
||||
const english = linearIssuePickerI18n.en;
|
||||
for (const locale of locales) {
|
||||
for (const key of requiredKeys) {
|
||||
const value = linearIssuePickerI18n[locale][key];
|
||||
expect(value).toBeTruthy();
|
||||
if (locale !== 'en') {
|
||||
expect(value).not.toBe(english[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,471 @@
|
||||
/** Linear issue picker / composer strings — merged into each locale's main dictionary. */
|
||||
export const linearIssuePickerI18n = {
|
||||
en: {
|
||||
'chat.chatInput.actions.linkLinearIssue': 'Link Linear Issue',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Open issue in Linear',
|
||||
'chat.chatInput.linked.linearIssue.removeAria': 'Remove linked Linear issue',
|
||||
'session.linearIssuePicker.title': 'Link Linear Issue',
|
||||
'session.linearIssuePicker.description': 'Select an issue from your connected Linear workspace.',
|
||||
'session.linearIssuePicker.searchPlaceholder': 'Search by title, identifier, or Linear URL',
|
||||
'session.linearIssuePicker.empty.notConnected': 'Linear is not connected. Connect it in Settings → Integrations.',
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear is not available in this app.',
|
||||
'session.linearIssuePicker.empty.noIssuesFound': 'No issues found',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound': 'No open issues found',
|
||||
'session.linearIssuePicker.loading.issues': 'Loading issues...',
|
||||
'session.linearIssuePicker.loading.more': 'Loading...',
|
||||
'session.linearIssuePicker.actions.openSettings': 'Open settings',
|
||||
'session.linearIssuePicker.actions.useIssue': 'Use {identifier}',
|
||||
'session.linearIssuePicker.actions.loadMore': 'Load more',
|
||||
'session.linearIssuePicker.actions.openInLinearAria': 'Open in Linear',
|
||||
'session.linearIssuePicker.toast.loadMoreFailed': 'Failed to load more issues',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Failed to load issue details',
|
||||
'session.linearIssuePicker.error.notConnected': 'Linear not connected',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear is not available in this app',
|
||||
'session.linearIssuePicker.error.issueNotFound': 'Issue not found',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue': 'New Session From Linear Issue',
|
||||
'session.linearIssuePicker.title.createSession': 'New Session From Linear Issue',
|
||||
'session.linearIssuePicker.description.createSession': 'Creates a session in the project mapped to this Linear team, with the issue as the first prompt.',
|
||||
'session.linearIssuePicker.error.noMappedProject': 'Map this Linear team to a project in Settings → Integrations',
|
||||
'session.linearIssuePicker.error.noModelSelected': 'No model selected',
|
||||
'session.linearIssuePicker.toast.sendContextFailed': 'Failed to send issue context',
|
||||
'session.linearIssuePicker.toast.sessionCreated': 'Session created from issue',
|
||||
'session.linearIssuePicker.toast.startSessionFailed': 'Failed to start session',
|
||||
'session.linearIssuePicker.actions.sectionTitle': 'Actions',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Toggle worktree',
|
||||
'session.linearIssuePicker.actions.createInWorktree': 'Create in worktree',
|
||||
'session.linearIssuePicker.actions.refresh': 'Refresh',
|
||||
'chat.workStatus.linkedIssues.openLinear': 'Open {identifier} in Linear',
|
||||
'session.newWorktree.actions.startFromLinearIssue': 'Start from Linear Issue',
|
||||
'session.newWorktree.fromLinearIssue': 'From {identifier}: {title}',
|
||||
'session.newWorktree.error.sendLinearContextFailed': 'Failed to send Linear context',
|
||||
},
|
||||
de: {
|
||||
'chat.chatInput.actions.linkLinearIssue': 'Linear-Issue verknüpfen',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Issue in Linear öffnen',
|
||||
'chat.chatInput.linked.linearIssue.removeAria': 'Verknüpftes Linear-Issue entfernen',
|
||||
'session.linearIssuePicker.title': 'Linear-Issue verknüpfen',
|
||||
'session.linearIssuePicker.description': 'Wähle ein Issue aus deinem verbundenen Linear-Workspace.',
|
||||
'session.linearIssuePicker.searchPlaceholder': 'Nach Titel, Kennung oder Linear-URL suchen',
|
||||
'session.linearIssuePicker.empty.notConnected': 'Linear ist nicht verbunden. Verbinde es unter Einstellungen → Integrationen.',
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear ist in dieser App nicht verfügbar.',
|
||||
'session.linearIssuePicker.empty.noIssuesFound': 'Keine Issues gefunden',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound': 'Keine offenen Issues gefunden',
|
||||
'session.linearIssuePicker.loading.issues': 'Issues werden geladen...',
|
||||
'session.linearIssuePicker.loading.more': 'Wird geladen...',
|
||||
'session.linearIssuePicker.actions.openSettings': 'Einstellungen öffnen',
|
||||
'session.linearIssuePicker.actions.useIssue': '{identifier} verwenden',
|
||||
'session.linearIssuePicker.actions.loadMore': 'Mehr laden',
|
||||
'session.linearIssuePicker.actions.openInLinearAria': 'In Linear öffnen',
|
||||
'session.linearIssuePicker.toast.loadMoreFailed': 'Weitere Issues konnten nicht geladen werden',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issue-Details konnten nicht geladen werden',
|
||||
'session.linearIssuePicker.error.notConnected': 'Linear nicht verbunden',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear ist in dieser App nicht verfügbar',
|
||||
'session.linearIssuePicker.error.issueNotFound': 'Issue nicht gefunden',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue': 'Neue Sitzung aus Linear-Issue',
|
||||
'session.linearIssuePicker.title.createSession': 'Neue Sitzung aus Linear-Issue',
|
||||
'session.linearIssuePicker.description.createSession': 'Erstellt eine Sitzung im diesem Linear-Team zugeordneten Projekt, mit dem Issue als erstem Prompt.',
|
||||
'session.linearIssuePicker.error.noMappedProject': 'Ordne dieses Linear-Team in Einstellungen → Integrationen einem Projekt zu',
|
||||
'session.linearIssuePicker.error.noModelSelected': 'Kein Modell ausgewählt',
|
||||
'session.linearIssuePicker.toast.sendContextFailed': 'Issue-Kontext konnte nicht gesendet werden',
|
||||
'session.linearIssuePicker.toast.sessionCreated': 'Sitzung aus Issue erstellt',
|
||||
'session.linearIssuePicker.toast.startSessionFailed': 'Sitzung konnte nicht gestartet werden',
|
||||
'session.linearIssuePicker.actions.sectionTitle': 'Aktionen',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Worktree umschalten',
|
||||
'session.linearIssuePicker.actions.createInWorktree': 'In Worktree erstellen',
|
||||
'session.linearIssuePicker.actions.refresh': 'Aktualisieren',
|
||||
'chat.workStatus.linkedIssues.openLinear': '{identifier} in Linear öffnen',
|
||||
'session.newWorktree.actions.startFromLinearIssue': 'Von Linear-Issue starten',
|
||||
'session.newWorktree.fromLinearIssue': 'Von {identifier}: {title}',
|
||||
'session.newWorktree.error.sendLinearContextFailed': 'Linear-Kontext konnte nicht gesendet werden',
|
||||
},
|
||||
fr: {
|
||||
'chat.chatInput.actions.linkLinearIssue': 'Lier un ticket Linear',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Ouvrir le ticket dans Linear',
|
||||
'chat.chatInput.linked.linearIssue.removeAria': 'Retirer le ticket Linear lié',
|
||||
'session.linearIssuePicker.title': 'Lier un ticket Linear',
|
||||
'session.linearIssuePicker.description': 'Choisissez un ticket dans votre espace Linear connecté.',
|
||||
'session.linearIssuePicker.searchPlaceholder': 'Rechercher par titre, identifiant ou URL Linear',
|
||||
'session.linearIssuePicker.empty.notConnected': 'Linear n’est pas connecté. Connectez-le dans Paramètres → Intégrations.',
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear n’est pas disponible dans cette application.',
|
||||
'session.linearIssuePicker.empty.noIssuesFound': 'Aucun ticket trouvé',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound': 'Aucun ticket ouvert trouvé',
|
||||
'session.linearIssuePicker.loading.issues': 'Chargement des tickets...',
|
||||
'session.linearIssuePicker.loading.more': 'Chargement...',
|
||||
'session.linearIssuePicker.actions.openSettings': 'Ouvrir les paramètres',
|
||||
'session.linearIssuePicker.actions.useIssue': 'Utiliser {identifier}',
|
||||
'session.linearIssuePicker.actions.loadMore': 'Charger plus',
|
||||
'session.linearIssuePicker.actions.openInLinearAria': 'Ouvrir dans Linear',
|
||||
'session.linearIssuePicker.toast.loadMoreFailed': 'Impossible de charger d’autres tickets',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Impossible de charger les détails du ticket',
|
||||
'session.linearIssuePicker.error.notConnected': 'Linear non connecté',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear n’est pas disponible dans cette application',
|
||||
'session.linearIssuePicker.error.issueNotFound': 'Ticket introuvable',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue': 'Nouvelle session depuis un ticket Linear',
|
||||
'session.linearIssuePicker.title.createSession': 'Nouvelle session depuis un ticket Linear',
|
||||
'session.linearIssuePicker.description.createSession': 'Crée une session dans le projet associé à cette équipe Linear, avec le ticket comme premier message.',
|
||||
'session.linearIssuePicker.error.noMappedProject': 'Associez cette équipe Linear à un projet dans Paramètres → Intégrations',
|
||||
'session.linearIssuePicker.error.noModelSelected': 'Aucun modèle sélectionné',
|
||||
'session.linearIssuePicker.toast.sendContextFailed': 'Impossible d’envoyer le contexte du ticket',
|
||||
'session.linearIssuePicker.toast.sessionCreated': 'Session créée depuis le ticket',
|
||||
'session.linearIssuePicker.toast.startSessionFailed': 'Impossible de démarrer la session',
|
||||
'session.linearIssuePicker.actions.sectionTitle': 'Actions disponibles',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Activer ou désactiver le worktree',
|
||||
'session.linearIssuePicker.actions.createInWorktree': 'Créer dans un worktree',
|
||||
'session.linearIssuePicker.actions.refresh': 'Actualiser',
|
||||
'chat.workStatus.linkedIssues.openLinear': 'Ouvrir {identifier} dans Linear',
|
||||
'session.newWorktree.actions.startFromLinearIssue': 'Démarrer depuis un ticket Linear',
|
||||
'session.newWorktree.fromLinearIssue': 'Depuis {identifier} : {title}',
|
||||
'session.newWorktree.error.sendLinearContextFailed': 'Impossible d’envoyer le contexte Linear',
|
||||
},
|
||||
es: {
|
||||
'chat.chatInput.actions.linkLinearIssue': 'Vincular issue de Linear',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Abrir issue en Linear',
|
||||
'chat.chatInput.linked.linearIssue.removeAria': 'Quitar issue de Linear vinculado',
|
||||
'session.linearIssuePicker.title': 'Vincular issue de Linear',
|
||||
'session.linearIssuePicker.description': 'Elige un issue del espacio de Linear conectado.',
|
||||
'session.linearIssuePicker.searchPlaceholder': 'Buscar por título, identificador o URL de Linear',
|
||||
'session.linearIssuePicker.empty.notConnected': 'Linear no está conectado. Conéctalo en Ajustes → Integraciones.',
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear no está disponible en esta aplicación.',
|
||||
'session.linearIssuePicker.empty.noIssuesFound': 'No se encontraron issues',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound': 'No se encontraron issues abiertos',
|
||||
'session.linearIssuePicker.loading.issues': 'Cargando issues...',
|
||||
'session.linearIssuePicker.loading.more': 'Cargando...',
|
||||
'session.linearIssuePicker.actions.openSettings': 'Abrir ajustes',
|
||||
'session.linearIssuePicker.actions.useIssue': 'Usar {identifier}',
|
||||
'session.linearIssuePicker.actions.loadMore': 'Cargar más',
|
||||
'session.linearIssuePicker.actions.openInLinearAria': 'Abrir en Linear',
|
||||
'session.linearIssuePicker.toast.loadMoreFailed': 'No se pudieron cargar más issues',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'No se pudieron cargar los detalles del issue',
|
||||
'session.linearIssuePicker.error.notConnected': 'Linear no conectado',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear no está disponible en esta aplicación',
|
||||
'session.linearIssuePicker.error.issueNotFound': 'Issue no encontrado',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue': 'Nueva sesión desde un issue de Linear',
|
||||
'session.linearIssuePicker.title.createSession': 'Nueva sesión desde un issue de Linear',
|
||||
'session.linearIssuePicker.description.createSession': 'Crea una sesión en el proyecto asignado a este equipo de Linear, con el issue como primer mensaje.',
|
||||
'session.linearIssuePicker.error.noMappedProject': 'Asigna este equipo de Linear a un proyecto en Ajustes → Integraciones',
|
||||
'session.linearIssuePicker.error.noModelSelected': 'Ningún modelo seleccionado',
|
||||
'session.linearIssuePicker.toast.sendContextFailed': 'No se pudo enviar el contexto del issue',
|
||||
'session.linearIssuePicker.toast.sessionCreated': 'Sesión creada desde el issue',
|
||||
'session.linearIssuePicker.toast.startSessionFailed': 'No se pudo iniciar la sesión',
|
||||
'session.linearIssuePicker.actions.sectionTitle': 'Acciones',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Activar o desactivar worktree',
|
||||
'session.linearIssuePicker.actions.createInWorktree': 'Crear en worktree',
|
||||
'session.linearIssuePicker.actions.refresh': 'Actualizar',
|
||||
'chat.workStatus.linkedIssues.openLinear': 'Abrir {identifier} en Linear',
|
||||
'session.newWorktree.actions.startFromLinearIssue': 'Empezar desde un issue de Linear',
|
||||
'session.newWorktree.fromLinearIssue': 'Desde {identifier}: {title}',
|
||||
'session.newWorktree.error.sendLinearContextFailed': 'No se pudo enviar el contexto de Linear',
|
||||
},
|
||||
ja: {
|
||||
'chat.chatInput.actions.linkLinearIssue': 'Linear Issueをリンク',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'LinearでIssueを開く',
|
||||
'chat.chatInput.linked.linearIssue.removeAria': 'リンクしたLinear Issueを削除',
|
||||
'session.linearIssuePicker.title': 'Linear Issueをリンク',
|
||||
'session.linearIssuePicker.description': '接続中のLinearワークスペースからIssueを選びます。',
|
||||
'session.linearIssuePicker.searchPlaceholder': 'タイトル、識別子、またはLinearのURLで検索',
|
||||
'session.linearIssuePicker.empty.notConnected': 'Linearは未接続です。設定 → 連携 で接続してください。',
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable': 'このアプリではLinearを利用できません。',
|
||||
'session.linearIssuePicker.empty.noIssuesFound': 'Issueが見つかりません',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound': '未完了のIssueはありません',
|
||||
'session.linearIssuePicker.loading.issues': 'Issueを読み込み中...',
|
||||
'session.linearIssuePicker.loading.more': '読み込み中...',
|
||||
'session.linearIssuePicker.actions.openSettings': '設定を開く',
|
||||
'session.linearIssuePicker.actions.useIssue': '{identifier} を使う',
|
||||
'session.linearIssuePicker.actions.loadMore': 'さらに読み込む',
|
||||
'session.linearIssuePicker.actions.openInLinearAria': 'Linearで開く',
|
||||
'session.linearIssuePicker.toast.loadMoreFailed': 'これ以上のIssueを読み込めませんでした',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issueの詳細を読み込めませんでした',
|
||||
'session.linearIssuePicker.error.notConnected': 'Linear未接続',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable': 'このアプリではLinearを利用できません',
|
||||
'session.linearIssuePicker.error.issueNotFound': 'Issueが見つかりません',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue': 'Linear Issueから新しいセッション',
|
||||
'session.linearIssuePicker.title.createSession': 'Linear Issueから新しいセッション',
|
||||
'session.linearIssuePicker.description.createSession': 'このLinearチームに割り当てたプロジェクトでセッションを作り、Issueを最初のプロンプトにします。',
|
||||
'session.linearIssuePicker.error.noMappedProject': '設定 → 連携 でこのLinearチームをプロジェクトに割り当ててください',
|
||||
'session.linearIssuePicker.error.noModelSelected': 'モデルが選択されていません',
|
||||
'session.linearIssuePicker.toast.sendContextFailed': 'Issueのコンテキストを送信できませんでした',
|
||||
'session.linearIssuePicker.toast.sessionCreated': 'Issueからセッションを作成しました',
|
||||
'session.linearIssuePicker.toast.startSessionFailed': 'セッションを開始できませんでした',
|
||||
'session.linearIssuePicker.actions.sectionTitle': '操作',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria': 'ワークツリーを切り替え',
|
||||
'session.linearIssuePicker.actions.createInWorktree': 'ワークツリーで作成',
|
||||
'session.linearIssuePicker.actions.refresh': '更新',
|
||||
'chat.workStatus.linkedIssues.openLinear': 'Linearで {identifier} を開く',
|
||||
'session.newWorktree.actions.startFromLinearIssue': 'Linear Issueから開始',
|
||||
'session.newWorktree.fromLinearIssue': '{identifier}: {title}から',
|
||||
'session.newWorktree.error.sendLinearContextFailed': 'Linearのコンテキストを送信できませんでした',
|
||||
},
|
||||
'pt-BR': {
|
||||
'chat.chatInput.actions.linkLinearIssue': 'Vincular issue do Linear',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Abrir issue no Linear',
|
||||
'chat.chatInput.linked.linearIssue.removeAria': 'Remover issue do Linear vinculada',
|
||||
'session.linearIssuePicker.title': 'Vincular issue do Linear',
|
||||
'session.linearIssuePicker.description': 'Selecione uma issue do espaço Linear conectado.',
|
||||
'session.linearIssuePicker.searchPlaceholder': 'Buscar por título, identificador ou URL do Linear',
|
||||
'session.linearIssuePicker.empty.notConnected': 'O Linear não está conectado. Conecte em Configurações → Integrações.',
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable': 'O Linear não está disponível neste app.',
|
||||
'session.linearIssuePicker.empty.noIssuesFound': 'Nenhuma issue encontrada',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound': 'Nenhuma issue aberta encontrada',
|
||||
'session.linearIssuePicker.loading.issues': 'Carregando issues...',
|
||||
'session.linearIssuePicker.loading.more': 'Carregando...',
|
||||
'session.linearIssuePicker.actions.openSettings': 'Abrir configurações',
|
||||
'session.linearIssuePicker.actions.useIssue': 'Usar {identifier}',
|
||||
'session.linearIssuePicker.actions.loadMore': 'Carregar mais',
|
||||
'session.linearIssuePicker.actions.openInLinearAria': 'Abrir no Linear',
|
||||
'session.linearIssuePicker.toast.loadMoreFailed': 'Não foi possível carregar mais issues',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Não foi possível carregar os detalhes da issue',
|
||||
'session.linearIssuePicker.error.notConnected': 'Linear não conectado',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable': 'O Linear não está disponível neste app',
|
||||
'session.linearIssuePicker.error.issueNotFound': 'Issue não encontrada',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue': 'Nova sessão a partir de uma issue do Linear',
|
||||
'session.linearIssuePicker.title.createSession': 'Nova sessão a partir de uma issue do Linear',
|
||||
'session.linearIssuePicker.description.createSession': 'Cria uma sessão no projeto associado a esta equipe do Linear, com a issue como o primeiro prompt.',
|
||||
'session.linearIssuePicker.error.noMappedProject': 'Associe esta equipe do Linear a um projeto em Configurações → Integrações',
|
||||
'session.linearIssuePicker.error.noModelSelected': 'Nenhum modelo selecionado',
|
||||
'session.linearIssuePicker.toast.sendContextFailed': 'Não foi possível enviar o contexto da issue',
|
||||
'session.linearIssuePicker.toast.sessionCreated': 'Sessão criada a partir da issue',
|
||||
'session.linearIssuePicker.toast.startSessionFailed': 'Não foi possível iniciar a sessão',
|
||||
'session.linearIssuePicker.actions.sectionTitle': 'Ações',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Ativar ou desativar worktree',
|
||||
'session.linearIssuePicker.actions.createInWorktree': 'Criar em worktree',
|
||||
'session.linearIssuePicker.actions.refresh': 'Atualizar',
|
||||
'chat.workStatus.linkedIssues.openLinear': 'Abrir {identifier} no Linear',
|
||||
'session.newWorktree.actions.startFromLinearIssue': 'Começar a partir de uma issue do Linear',
|
||||
'session.newWorktree.fromLinearIssue': 'De {identifier}: {title}',
|
||||
'session.newWorktree.error.sendLinearContextFailed': 'Não foi possível enviar o contexto do Linear',
|
||||
},
|
||||
uk: {
|
||||
'chat.chatInput.actions.linkLinearIssue': 'Прив’язати Linear issue',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Відкрити issue в Linear',
|
||||
'chat.chatInput.linked.linearIssue.removeAria': 'Прибрати прив’язаний Linear issue',
|
||||
'session.linearIssuePicker.title': 'Прив’язати Linear issue',
|
||||
'session.linearIssuePicker.description': 'Оберіть issue з підключеного робочого простору Linear.',
|
||||
'session.linearIssuePicker.searchPlaceholder': 'Пошук за назвою, ідентифікатором або URL Linear',
|
||||
'session.linearIssuePicker.empty.notConnected': 'Linear не підключено. Підключіть його в Налаштуваннях → Інтеграції.',
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear недоступний у цьому застосунку.',
|
||||
'session.linearIssuePicker.empty.noIssuesFound': 'Issue не знайдено',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound': 'Відкритих issue немає',
|
||||
'session.linearIssuePicker.loading.issues': 'Завантаження issue...',
|
||||
'session.linearIssuePicker.loading.more': 'Завантаження...',
|
||||
'session.linearIssuePicker.actions.openSettings': 'Відкрити налаштування',
|
||||
'session.linearIssuePicker.actions.useIssue': 'Використати {identifier}',
|
||||
'session.linearIssuePicker.actions.loadMore': 'Завантажити ще',
|
||||
'session.linearIssuePicker.actions.openInLinearAria': 'Відкрити в Linear',
|
||||
'session.linearIssuePicker.toast.loadMoreFailed': 'Не вдалося завантажити більше issue',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Не вдалося завантажити деталі issue',
|
||||
'session.linearIssuePicker.error.notConnected': 'Linear не підключено',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear недоступний у цьому застосунку',
|
||||
'session.linearIssuePicker.error.issueNotFound': 'Issue не знайдено',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue': 'Нова сесія з Linear issue',
|
||||
'session.linearIssuePicker.title.createSession': 'Нова сесія з Linear issue',
|
||||
'session.linearIssuePicker.description.createSession': 'Створює сесію в проєкті, прив’язаному до цієї команди Linear, з issue як першим запитом.',
|
||||
'session.linearIssuePicker.error.noMappedProject': 'Прив’яжіть цю команду Linear до проєкту в Налаштуваннях → Інтеграції',
|
||||
'session.linearIssuePicker.error.noModelSelected': 'Модель не вибрано',
|
||||
'session.linearIssuePicker.toast.sendContextFailed': 'Не вдалося надіслати контекст issue',
|
||||
'session.linearIssuePicker.toast.sessionCreated': 'Сесію створено з issue',
|
||||
'session.linearIssuePicker.toast.startSessionFailed': 'Не вдалося почати сесію',
|
||||
'session.linearIssuePicker.actions.sectionTitle': 'Дії',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Перемкнути worktree',
|
||||
'session.linearIssuePicker.actions.createInWorktree': 'Створити у worktree',
|
||||
'session.linearIssuePicker.actions.refresh': 'Оновити',
|
||||
'chat.workStatus.linkedIssues.openLinear': 'Відкрити {identifier} у Linear',
|
||||
'session.newWorktree.actions.startFromLinearIssue': 'Почати з Linear issue',
|
||||
'session.newWorktree.fromLinearIssue': 'З {identifier}: {title}',
|
||||
'session.newWorktree.error.sendLinearContextFailed': 'Не вдалося надіслати контекст Linear',
|
||||
},
|
||||
ko: {
|
||||
'chat.chatInput.actions.linkLinearIssue': 'Linear 이슈 연결',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Linear에서 이슈 열기',
|
||||
'chat.chatInput.linked.linearIssue.removeAria': '연결된 Linear 이슈 제거',
|
||||
'session.linearIssuePicker.title': 'Linear 이슈 연결',
|
||||
'session.linearIssuePicker.description': '연결된 Linear 워크스페이스에서 이슈를 선택하세요.',
|
||||
'session.linearIssuePicker.searchPlaceholder': '제목, 식별자 또는 Linear URL로 검색',
|
||||
'session.linearIssuePicker.empty.notConnected': 'Linear가 연결되어 있지 않습니다. 설정 → 연동에서 연결하세요.',
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable': '이 앱에서는 Linear를 사용할 수 없습니다.',
|
||||
'session.linearIssuePicker.empty.noIssuesFound': '이슈를 찾을 수 없습니다',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound': '열린 이슈가 없습니다',
|
||||
'session.linearIssuePicker.loading.issues': '이슈를 불러오는 중...',
|
||||
'session.linearIssuePicker.loading.more': '불러오는 중...',
|
||||
'session.linearIssuePicker.actions.openSettings': '설정 열기',
|
||||
'session.linearIssuePicker.actions.useIssue': '{identifier} 사용',
|
||||
'session.linearIssuePicker.actions.loadMore': '더 보기',
|
||||
'session.linearIssuePicker.actions.openInLinearAria': 'Linear에서 열기',
|
||||
'session.linearIssuePicker.toast.loadMoreFailed': '이슈를 더 불러오지 못했습니다',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed': '이슈 세부 정보를 불러오지 못했습니다',
|
||||
'session.linearIssuePicker.error.notConnected': 'Linear가 연결되지 않음',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable': '이 앱에서는 Linear를 사용할 수 없습니다',
|
||||
'session.linearIssuePicker.error.issueNotFound': '이슈를 찾을 수 없습니다',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue': 'Linear 이슈로 새 세션 만들기',
|
||||
'session.linearIssuePicker.title.createSession': 'Linear 이슈로 새 세션 만들기',
|
||||
'session.linearIssuePicker.description.createSession': '이 Linear 팀에 연결한 프로젝트에서 세션을 만들고, 이슈를 첫 프롬프트로 넣습니다.',
|
||||
'session.linearIssuePicker.error.noMappedProject': '설정 → 연동에서 이 Linear 팀을 프로젝트에 연결하세요',
|
||||
'session.linearIssuePicker.error.noModelSelected': '모델이 선택되지 않았습니다',
|
||||
'session.linearIssuePicker.toast.sendContextFailed': '이슈 컨텍스트를 보내지 못했습니다',
|
||||
'session.linearIssuePicker.toast.sessionCreated': '이슈에서 세션을 만들었습니다',
|
||||
'session.linearIssuePicker.toast.startSessionFailed': '세션을 시작하지 못했습니다',
|
||||
'session.linearIssuePicker.actions.sectionTitle': '작업',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria': '워크트리 전환',
|
||||
'session.linearIssuePicker.actions.createInWorktree': '워크트리에서 만들기',
|
||||
'session.linearIssuePicker.actions.refresh': '새로고침',
|
||||
'chat.workStatus.linkedIssues.openLinear': 'Linear에서 {identifier} 열기',
|
||||
'session.newWorktree.actions.startFromLinearIssue': 'Linear 이슈에서 시작',
|
||||
'session.newWorktree.fromLinearIssue': '{identifier}: {title}에서',
|
||||
'session.newWorktree.error.sendLinearContextFailed': 'Linear 컨텍스트를 보내지 못했습니다',
|
||||
},
|
||||
pl: {
|
||||
'chat.chatInput.actions.linkLinearIssue': 'Powiąż zgłoszenie Linear',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria': 'Otwórz zgłoszenie w Linear',
|
||||
'chat.chatInput.linked.linearIssue.removeAria': 'Usuń powiązane zgłoszenie Linear',
|
||||
'session.linearIssuePicker.title': 'Powiąż zgłoszenie Linear',
|
||||
'session.linearIssuePicker.description': 'Wybierz zgłoszenie z połączonego obszaru Linear.',
|
||||
'session.linearIssuePicker.searchPlaceholder': 'Szukaj po tytule, identyfikatorze lub adresie URL Linear',
|
||||
'session.linearIssuePicker.empty.notConnected': 'Linear nie jest połączony. Połącz go w Ustawieniach → Integracje.',
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear jest niedostępny w tej aplikacji.',
|
||||
'session.linearIssuePicker.empty.noIssuesFound': 'Nie znaleziono zgłoszeń',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound': 'Nie znaleziono otwartych zgłoszeń',
|
||||
'session.linearIssuePicker.loading.issues': 'Ładowanie zgłoszeń...',
|
||||
'session.linearIssuePicker.loading.more': 'Ładowanie...',
|
||||
'session.linearIssuePicker.actions.openSettings': 'Otwórz ustawienia',
|
||||
'session.linearIssuePicker.actions.useIssue': 'Użyj {identifier}',
|
||||
'session.linearIssuePicker.actions.loadMore': 'Załaduj więcej',
|
||||
'session.linearIssuePicker.actions.openInLinearAria': 'Otwórz w Linear',
|
||||
'session.linearIssuePicker.toast.loadMoreFailed': 'Nie udało się załadować kolejnych zgłoszeń',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Nie udało się załadować szczegółów zgłoszenia',
|
||||
'session.linearIssuePicker.error.notConnected': 'Linear niepołączony',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear jest niedostępny w tej aplikacji',
|
||||
'session.linearIssuePicker.error.issueNotFound': 'Nie znaleziono zgłoszenia',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue': 'Nowa sesja ze zgłoszenia Linear',
|
||||
'session.linearIssuePicker.title.createSession': 'Nowa sesja ze zgłoszenia Linear',
|
||||
'session.linearIssuePicker.description.createSession': 'Tworzy sesję w projekcie przypisanym do tego zespołu Linear, ze zgłoszeniem jako pierwszym poleceniem.',
|
||||
'session.linearIssuePicker.error.noMappedProject': 'Przypisz ten zespół Linear do projektu w Ustawieniach → Integracje',
|
||||
'session.linearIssuePicker.error.noModelSelected': 'Nie wybrano modelu',
|
||||
'session.linearIssuePicker.toast.sendContextFailed': 'Nie udało się wysłać kontekstu zgłoszenia',
|
||||
'session.linearIssuePicker.toast.sessionCreated': 'Utworzono sesję ze zgłoszenia',
|
||||
'session.linearIssuePicker.toast.startSessionFailed': 'Nie udało się rozpocząć sesji',
|
||||
'session.linearIssuePicker.actions.sectionTitle': 'Czynności',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria': 'Przełącz worktree',
|
||||
'session.linearIssuePicker.actions.createInWorktree': 'Utwórz w worktree',
|
||||
'session.linearIssuePicker.actions.refresh': 'Odśwież',
|
||||
'chat.workStatus.linkedIssues.openLinear': 'Otwórz {identifier} w Linear',
|
||||
'session.newWorktree.actions.startFromLinearIssue': 'Zacznij od zgłoszenia Linear',
|
||||
'session.newWorktree.fromLinearIssue': 'Z {identifier}: {title}',
|
||||
'session.newWorktree.error.sendLinearContextFailed': 'Nie udało się wysłać kontekstu Linear',
|
||||
},
|
||||
'zh-CN': {
|
||||
'chat.chatInput.actions.linkLinearIssue': '关联 Linear Issue',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria': '在 Linear 中打开 Issue',
|
||||
'chat.chatInput.linked.linearIssue.removeAria': '移除已关联的 Linear Issue',
|
||||
'session.linearIssuePicker.title': '关联 Linear Issue',
|
||||
'session.linearIssuePicker.description': '从已连接的 Linear 工作区选择一个 Issue。',
|
||||
'session.linearIssuePicker.searchPlaceholder': '按标题、标识符或 Linear 链接搜索',
|
||||
'session.linearIssuePicker.empty.notConnected': '尚未连接 Linear。请到设置 → 集成 中连接。',
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable': '此应用中无法使用 Linear。',
|
||||
'session.linearIssuePicker.empty.noIssuesFound': '未找到 Issue',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound': '没有未完成的 Issue',
|
||||
'session.linearIssuePicker.loading.issues': '正在加载 Issue...',
|
||||
'session.linearIssuePicker.loading.more': '正在加载...',
|
||||
'session.linearIssuePicker.actions.openSettings': '打开设置',
|
||||
'session.linearIssuePicker.actions.useIssue': '使用 {identifier}',
|
||||
'session.linearIssuePicker.actions.loadMore': '加载更多',
|
||||
'session.linearIssuePicker.actions.openInLinearAria': '在 Linear 中打开',
|
||||
'session.linearIssuePicker.toast.loadMoreFailed': '无法加载更多 Issue',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed': '无法加载 Issue 详情',
|
||||
'session.linearIssuePicker.error.notConnected': '未连接 Linear',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable': '此应用中无法使用 Linear',
|
||||
'session.linearIssuePicker.error.issueNotFound': '未找到 Issue',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue': '从 Linear Issue 新建会话',
|
||||
'session.linearIssuePicker.title.createSession': '从 Linear Issue 新建会话',
|
||||
'session.linearIssuePicker.description.createSession': '在映射到此 Linear 团队的项目中创建会话,并以该 Issue 作为第一条提示。',
|
||||
'session.linearIssuePicker.error.noMappedProject': '请在设置 → 集成 中将此 Linear 团队映射到一个项目',
|
||||
'session.linearIssuePicker.error.noModelSelected': '未选择模型',
|
||||
'session.linearIssuePicker.toast.sendContextFailed': '无法发送 Issue 上下文',
|
||||
'session.linearIssuePicker.toast.sessionCreated': '已从 Issue 创建会话',
|
||||
'session.linearIssuePicker.toast.startSessionFailed': '无法开始会话',
|
||||
'session.linearIssuePicker.actions.sectionTitle': '操作',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria': '切换 worktree',
|
||||
'session.linearIssuePicker.actions.createInWorktree': '在 worktree 中创建',
|
||||
'session.linearIssuePicker.actions.refresh': '刷新',
|
||||
'chat.workStatus.linkedIssues.openLinear': '在 Linear 中打开 {identifier}',
|
||||
'session.newWorktree.actions.startFromLinearIssue': '从 Linear Issue 开始',
|
||||
'session.newWorktree.fromLinearIssue': '来自 {identifier}:{title}',
|
||||
'session.newWorktree.error.sendLinearContextFailed': '无法发送 Linear 上下文',
|
||||
},
|
||||
'zh-TW': {
|
||||
'chat.chatInput.actions.linkLinearIssue': '關聯 Linear Issue',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria': '在 Linear 中開啟 Issue',
|
||||
'chat.chatInput.linked.linearIssue.removeAria': '移除已關聯的 Linear Issue',
|
||||
'session.linearIssuePicker.title': '關聯 Linear Issue',
|
||||
'session.linearIssuePicker.description': '從已連線的 Linear 工作區選擇一個 Issue。',
|
||||
'session.linearIssuePicker.searchPlaceholder': '依標題、識別碼或 Linear 網址搜尋',
|
||||
'session.linearIssuePicker.empty.notConnected': '尚未連線 Linear。請到設定 → 整合 中連線。',
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable': '此應用程式無法使用 Linear。',
|
||||
'session.linearIssuePicker.empty.noIssuesFound': '找不到 Issue',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound': '沒有未完成的 Issue',
|
||||
'session.linearIssuePicker.loading.issues': '正在載入 Issue...',
|
||||
'session.linearIssuePicker.loading.more': '正在載入...',
|
||||
'session.linearIssuePicker.actions.openSettings': '開啟設定',
|
||||
'session.linearIssuePicker.actions.useIssue': '使用 {identifier}',
|
||||
'session.linearIssuePicker.actions.loadMore': '載入更多',
|
||||
'session.linearIssuePicker.actions.openInLinearAria': '在 Linear 中開啟',
|
||||
'session.linearIssuePicker.toast.loadMoreFailed': '無法載入更多 Issue',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed': '無法載入 Issue 詳細資料',
|
||||
'session.linearIssuePicker.error.notConnected': '未連線 Linear',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable': '此應用程式無法使用 Linear',
|
||||
'session.linearIssuePicker.error.issueNotFound': '找不到 Issue',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue': '從 Linear Issue 新增會話',
|
||||
'session.linearIssuePicker.title.createSession': '從 Linear Issue 新增會話',
|
||||
'session.linearIssuePicker.description.createSession': '在對應到此 Linear 團隊的專案中建立會話,並以該 Issue 作為第一則提示。',
|
||||
'session.linearIssuePicker.error.noMappedProject': '請在設定 → 整合 中將此 Linear 團隊對應到一個專案',
|
||||
'session.linearIssuePicker.error.noModelSelected': '尚未選擇模型',
|
||||
'session.linearIssuePicker.toast.sendContextFailed': '無法傳送 Issue 內容',
|
||||
'session.linearIssuePicker.toast.sessionCreated': '已從 Issue 建立會話',
|
||||
'session.linearIssuePicker.toast.startSessionFailed': '無法開始會話',
|
||||
'session.linearIssuePicker.actions.sectionTitle': '操作',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria': '切換 worktree',
|
||||
'session.linearIssuePicker.actions.createInWorktree': '在 worktree 中建立',
|
||||
'session.linearIssuePicker.actions.refresh': '重新整理',
|
||||
'chat.workStatus.linkedIssues.openLinear': '在 Linear 中開啟 {identifier}',
|
||||
'session.newWorktree.actions.startFromLinearIssue': '從 Linear Issue 開始',
|
||||
'session.newWorktree.fromLinearIssue': '來自 {identifier}:{title}',
|
||||
'session.newWorktree.error.sendLinearContextFailed': '無法傳送 Linear 內容',
|
||||
},
|
||||
tr: {
|
||||
'chat.chatInput.actions.linkLinearIssue': 'Linear Issue bağla',
|
||||
'chat.chatInput.linked.linearIssue.openInBrowserAria': "Issue'u Linear'da aç",
|
||||
'chat.chatInput.linked.linearIssue.removeAria': "Bağlı Linear issue'u kaldır",
|
||||
'session.linearIssuePicker.title': 'Linear Issue bağla',
|
||||
'session.linearIssuePicker.description': 'Bağlı Linear çalışma alanından bir issue seç.',
|
||||
'session.linearIssuePicker.searchPlaceholder': "Başlığa, tanımlayıcıya veya Linear URL'sine göre ara",
|
||||
'session.linearIssuePicker.empty.notConnected': "Linear bağlı değil. Ayarlar → Entegrasyonlar'dan bağla.",
|
||||
'session.linearIssuePicker.empty.runtimeUnavailable': 'Linear bu uygulamada kullanılamıyor.',
|
||||
'session.linearIssuePicker.empty.noIssuesFound': 'Issue bulunamadı',
|
||||
'session.linearIssuePicker.empty.noOpenIssuesFound': 'Açık issue bulunamadı',
|
||||
'session.linearIssuePicker.loading.issues': "Issue'lar yükleniyor...",
|
||||
'session.linearIssuePicker.loading.more': 'Yükleniyor...',
|
||||
'session.linearIssuePicker.actions.openSettings': 'Ayarları aç',
|
||||
'session.linearIssuePicker.actions.useIssue': '{identifier} kullan',
|
||||
'session.linearIssuePicker.actions.loadMore': 'Daha fazla yükle',
|
||||
'session.linearIssuePicker.actions.openInLinearAria': "Linear'da aç",
|
||||
'session.linearIssuePicker.toast.loadMoreFailed': 'Daha fazla issue yüklenemedi',
|
||||
'session.linearIssuePicker.toast.loadIssueDetailsFailed': 'Issue ayrıntıları yüklenemedi',
|
||||
'session.linearIssuePicker.error.notConnected': 'Linear bağlı değil',
|
||||
'session.linearIssuePicker.error.runtimeUnavailable': 'Linear bu uygulamada kullanılamıyor',
|
||||
'session.linearIssuePicker.error.issueNotFound': 'Issue bulunamadı',
|
||||
'chat.chatInput.actions.newSessionFromLinearIssue': "Linear Issue'dan yeni session",
|
||||
'session.linearIssuePicker.title.createSession': "Linear Issue'dan yeni session",
|
||||
'session.linearIssuePicker.description.createSession': 'Bu Linear ekibine eşlenen projede bir session oluşturur; ilk prompt issue olur.',
|
||||
'session.linearIssuePicker.error.noMappedProject': "Bu Linear ekibini Ayarlar → Entegrasyonlar'da bir projeye eşle",
|
||||
'session.linearIssuePicker.error.noModelSelected': 'Model seçilmedi',
|
||||
'session.linearIssuePicker.toast.sendContextFailed': 'Issue bağlamı gönderilemedi',
|
||||
'session.linearIssuePicker.toast.sessionCreated': "Issue'dan session oluşturuldu",
|
||||
'session.linearIssuePicker.toast.startSessionFailed': 'Session başlatılamadı',
|
||||
'session.linearIssuePicker.actions.sectionTitle': 'İşlemler',
|
||||
'session.linearIssuePicker.actions.toggleWorktreeAria': "Worktree'yi aç veya kapat",
|
||||
'session.linearIssuePicker.actions.createInWorktree': "Worktree'de oluştur",
|
||||
'session.linearIssuePicker.actions.refresh': 'Yenile',
|
||||
'chat.workStatus.linkedIssues.openLinear': "{identifier} issue'unu Linear'da aç",
|
||||
'session.newWorktree.actions.startFromLinearIssue': "Linear Issue'dan başla",
|
||||
'session.newWorktree.fromLinearIssue': '{identifier}: {title}',
|
||||
'session.newWorktree.error.sendLinearContextFailed': 'Linear bağlamı gönderilemedi',
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const;
|
||||
|
||||
const requiredKeys = [
|
||||
'contextPanel.mode.linear',
|
||||
'contextRail.surface.linear.description',
|
||||
'contextPanel.linear.actions.backToList',
|
||||
'contextPanel.linear.actions.startSession',
|
||||
'contextPanel.linear.actions.closeIssue',
|
||||
'contextPanel.linear.actions.closeSearch',
|
||||
'contextPanel.linear.label.status',
|
||||
'contextPanel.linear.label.team',
|
||||
'contextPanel.linear.label.assignee',
|
||||
'contextPanel.linear.label.unassigned',
|
||||
'contextPanel.linear.label.priority',
|
||||
'contextPanel.linear.label.labels',
|
||||
'contextPanel.linear.priority.none',
|
||||
'contextPanel.linear.priority.urgent',
|
||||
'contextPanel.linear.priority.high',
|
||||
'contextPanel.linear.priority.medium',
|
||||
'contextPanel.linear.priority.low',
|
||||
'contextPanel.linear.label.comments',
|
||||
'contextPanel.linear.label.statusAria',
|
||||
'contextPanel.linear.label.workspace',
|
||||
'contextPanel.linear.label.workspaceAria',
|
||||
'contextPanel.linear.filter.statusAria',
|
||||
'contextPanel.linear.filter.assigneeAria',
|
||||
'contextPanel.linear.filter.teamAria',
|
||||
'contextPanel.linear.filter.priorityAria',
|
||||
'contextPanel.linear.filter.searchAria',
|
||||
'contextPanel.linear.filter.clear',
|
||||
'contextPanel.linear.filter.clearAria',
|
||||
'contextPanel.linear.filter.status.all',
|
||||
'contextPanel.linear.filter.status.backlog',
|
||||
'contextPanel.linear.filter.status.todo',
|
||||
'contextPanel.linear.filter.status.started',
|
||||
'contextPanel.linear.filter.status.inReview',
|
||||
'contextPanel.linear.filter.status.completed',
|
||||
'contextPanel.linear.filter.status.canceled',
|
||||
'contextPanel.linear.filter.status.duplicate',
|
||||
'contextPanel.linear.filter.assignee.any',
|
||||
'contextPanel.linear.filter.assignee.me',
|
||||
'contextPanel.linear.filter.team.all',
|
||||
'contextPanel.linear.filter.priority.all',
|
||||
'contextPanel.linear.empty.noDescription',
|
||||
'contextPanel.linear.empty.noComments',
|
||||
'contextPanel.linear.empty.noMatchingIssues',
|
||||
'contextPanel.linear.loading.issue',
|
||||
'contextPanel.linear.toast.statusUpdated',
|
||||
'contextPanel.linear.toast.statusUpdateFailed',
|
||||
'contextPanel.linear.toast.closeFailed',
|
||||
'contextPanel.linear.toast.workspaceSwitched',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed',
|
||||
'contextPanel.linear.error.noCompletedState',
|
||||
] as const;
|
||||
|
||||
const matchingEnglishAllowed = new Set<string>([
|
||||
'contextPanel.mode.linear',
|
||||
'contextPanel.linear.label.status',
|
||||
'contextPanel.linear.label.team',
|
||||
]);
|
||||
|
||||
describe('linear panel translations', () => {
|
||||
test('provides every required key in every supported locale', () => {
|
||||
const english = linearPanelI18n.en;
|
||||
for (const locale of locales) {
|
||||
for (const key of requiredKeys) {
|
||||
const value = linearPanelI18n[locale][key];
|
||||
expect(value).toBeTruthy();
|
||||
if (locale !== 'en' && !matchingEnglishAllowed.has(key)) {
|
||||
expect(value).not.toBe(english[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,627 @@
|
||||
/** Linear context-rail panel strings — merged into each locale's main dictionary. */
|
||||
export const linearPanelI18n = {
|
||||
en: {
|
||||
'contextPanel.mode.linear': 'Linear',
|
||||
'contextRail.surface.linear.description': 'Browse Linear issues, change status, and start a session',
|
||||
'contextPanel.linear.actions.backToList': 'Back to issues',
|
||||
'contextPanel.linear.actions.startSession': 'Start session',
|
||||
'contextPanel.linear.actions.closeIssue': 'Close issue',
|
||||
'contextPanel.linear.actions.closeSearch': 'Close search',
|
||||
'contextPanel.linear.label.status': 'Status',
|
||||
'contextPanel.linear.label.team': 'Team',
|
||||
'contextPanel.linear.label.assignee': 'Assignee',
|
||||
'contextPanel.linear.label.unassigned': 'Unassigned',
|
||||
'contextPanel.linear.label.priority': 'Priority',
|
||||
'contextPanel.linear.label.labels': 'Labels',
|
||||
'contextPanel.linear.priority.none': 'No priority',
|
||||
'contextPanel.linear.priority.urgent': 'Urgent',
|
||||
'contextPanel.linear.priority.high': 'High',
|
||||
'contextPanel.linear.priority.medium': 'Medium',
|
||||
'contextPanel.linear.priority.low': 'Low',
|
||||
'contextPanel.linear.label.comments': 'Comments',
|
||||
'contextPanel.linear.label.statusAria': 'Linear issue status',
|
||||
'contextPanel.linear.label.workspace': 'Workspace',
|
||||
'contextPanel.linear.label.workspaceAria': 'Linear workspace',
|
||||
'contextPanel.linear.filter.statusAria': 'Filter issues by status',
|
||||
'contextPanel.linear.filter.assigneeAria': 'Filter issues by assignee',
|
||||
'contextPanel.linear.filter.teamAria': 'Filter issues by team',
|
||||
'contextPanel.linear.filter.priorityAria': 'Filter issues by priority',
|
||||
'contextPanel.linear.filter.searchAria': 'Search issues',
|
||||
'contextPanel.linear.filter.clear': 'Clear',
|
||||
'contextPanel.linear.filter.clearAria': 'Clear issue filters',
|
||||
'contextPanel.linear.filter.status.all': 'All',
|
||||
'contextPanel.linear.filter.status.backlog': 'Backlog',
|
||||
'contextPanel.linear.filter.status.todo': 'To Do',
|
||||
'contextPanel.linear.filter.status.started': 'In Progress',
|
||||
'contextPanel.linear.filter.status.inReview': 'In Review',
|
||||
'contextPanel.linear.filter.status.completed': 'Done',
|
||||
'contextPanel.linear.filter.status.canceled': 'Canceled',
|
||||
'contextPanel.linear.filter.status.duplicate': 'Duplicate',
|
||||
'contextPanel.linear.filter.assignee.any': 'Anyone',
|
||||
'contextPanel.linear.filter.assignee.me': 'Assigned to me',
|
||||
'contextPanel.linear.filter.team.all': 'All teams',
|
||||
'contextPanel.linear.filter.priority.all': 'All priorities',
|
||||
'contextPanel.linear.empty.noDescription': 'No description',
|
||||
'contextPanel.linear.empty.noComments': 'No comments',
|
||||
'contextPanel.linear.empty.noMatchingIssues': 'No issues match these filters',
|
||||
'contextPanel.linear.loading.issue': 'Loading issue…',
|
||||
'contextPanel.linear.toast.statusUpdated': 'Issue status updated',
|
||||
'contextPanel.linear.toast.statusUpdateFailed': 'Could not update issue status',
|
||||
'contextPanel.linear.toast.closeFailed': 'Could not close issue',
|
||||
'contextPanel.linear.toast.workspaceSwitched': 'Switched Linear workspace',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed': 'Could not switch Linear workspace',
|
||||
'contextPanel.linear.error.noCompletedState': 'This team has no completed status',
|
||||
},
|
||||
de: {
|
||||
'contextPanel.mode.linear': 'Linear',
|
||||
'contextRail.surface.linear.description': 'Linear-Issues durchsuchen, Status ändern und eine Sitzung starten',
|
||||
'contextPanel.linear.actions.backToList': 'Zurück zu den Issues',
|
||||
'contextPanel.linear.actions.startSession': 'Sitzung starten',
|
||||
'contextPanel.linear.actions.closeIssue': 'Issue schließen',
|
||||
'contextPanel.linear.actions.closeSearch': 'Suche schließen',
|
||||
'contextPanel.linear.label.status': 'Status',
|
||||
'contextPanel.linear.label.team': 'Team',
|
||||
'contextPanel.linear.label.assignee': 'Zugewiesen',
|
||||
'contextPanel.linear.label.unassigned': 'Nicht zugewiesen',
|
||||
'contextPanel.linear.label.priority': 'Priorität',
|
||||
'contextPanel.linear.label.labels': 'Kennzeichnungen',
|
||||
'contextPanel.linear.priority.none': 'Keine Priorität',
|
||||
'contextPanel.linear.priority.urgent': 'Dringend',
|
||||
'contextPanel.linear.priority.high': 'Hoch',
|
||||
'contextPanel.linear.priority.medium': 'Mittel',
|
||||
'contextPanel.linear.priority.low': 'Niedrig',
|
||||
'contextPanel.linear.label.comments': 'Kommentare',
|
||||
'contextPanel.linear.label.statusAria': 'Status des Linear-Issues',
|
||||
'contextPanel.linear.label.workspace': 'Arbeitsbereich',
|
||||
'contextPanel.linear.label.workspaceAria': 'Linear-Workspace',
|
||||
'contextPanel.linear.filter.statusAria': 'Issues nach Status filtern',
|
||||
'contextPanel.linear.filter.assigneeAria': 'Issues nach Zuweisung filtern',
|
||||
'contextPanel.linear.filter.teamAria': 'Issues nach Team filtern',
|
||||
'contextPanel.linear.filter.priorityAria': 'Issues nach Priorität filtern',
|
||||
'contextPanel.linear.filter.searchAria': 'Issues durchsuchen',
|
||||
'contextPanel.linear.filter.clear': 'Zurücksetzen',
|
||||
'contextPanel.linear.filter.clearAria': 'Issue-Filter zurücksetzen',
|
||||
'contextPanel.linear.filter.status.all': 'Alle',
|
||||
'contextPanel.linear.filter.status.backlog': 'Warteliste',
|
||||
'contextPanel.linear.filter.status.todo': 'Zu tun',
|
||||
'contextPanel.linear.filter.status.started': 'In Bearbeitung',
|
||||
'contextPanel.linear.filter.status.inReview': 'In Prüfung',
|
||||
'contextPanel.linear.filter.status.completed': 'Erledigt',
|
||||
'contextPanel.linear.filter.status.canceled': 'Abgebrochen',
|
||||
'contextPanel.linear.filter.status.duplicate': 'Duplikat',
|
||||
'contextPanel.linear.filter.assignee.any': 'Alle Personen',
|
||||
'contextPanel.linear.filter.assignee.me': 'Mir zugewiesen',
|
||||
'contextPanel.linear.filter.team.all': 'Alle Teams',
|
||||
'contextPanel.linear.filter.priority.all': 'Alle Prioritäten',
|
||||
'contextPanel.linear.empty.noDescription': 'Keine Beschreibung',
|
||||
'contextPanel.linear.empty.noComments': 'Keine Kommentare',
|
||||
'contextPanel.linear.empty.noMatchingIssues': 'Keine Issues passen zu diesen Filtern',
|
||||
'contextPanel.linear.loading.issue': 'Issue wird geladen…',
|
||||
'contextPanel.linear.toast.statusUpdated': 'Issue-Status aktualisiert',
|
||||
'contextPanel.linear.toast.statusUpdateFailed': 'Issue-Status konnte nicht aktualisiert werden',
|
||||
'contextPanel.linear.toast.closeFailed': 'Issue konnte nicht geschlossen werden',
|
||||
'contextPanel.linear.toast.workspaceSwitched': 'Linear-Workspace gewechselt',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear-Workspace konnte nicht gewechselt werden',
|
||||
'contextPanel.linear.error.noCompletedState': 'Dieses Team hat keinen erledigten Status',
|
||||
},
|
||||
fr: {
|
||||
'contextPanel.mode.linear': 'Linear',
|
||||
'contextRail.surface.linear.description': 'Parcourir les tickets Linear, changer le statut et démarrer une session',
|
||||
'contextPanel.linear.actions.backToList': 'Retour aux tickets',
|
||||
'contextPanel.linear.actions.startSession': 'Démarrer une session',
|
||||
'contextPanel.linear.actions.closeIssue': 'Fermer le ticket',
|
||||
'contextPanel.linear.actions.closeSearch': 'Fermer la recherche',
|
||||
'contextPanel.linear.label.status': 'Statut',
|
||||
'contextPanel.linear.label.team': 'Équipe',
|
||||
'contextPanel.linear.label.assignee': 'Assigné',
|
||||
'contextPanel.linear.label.unassigned': 'Non assigné',
|
||||
'contextPanel.linear.label.priority': 'Priorité',
|
||||
'contextPanel.linear.label.labels': 'Libellés',
|
||||
'contextPanel.linear.priority.none': 'Sans priorité',
|
||||
'contextPanel.linear.priority.urgent': 'Urgente',
|
||||
'contextPanel.linear.priority.high': 'Haute',
|
||||
'contextPanel.linear.priority.medium': 'Moyenne',
|
||||
'contextPanel.linear.priority.low': 'Basse',
|
||||
'contextPanel.linear.label.comments': 'Commentaires',
|
||||
'contextPanel.linear.label.statusAria': 'Statut du ticket Linear',
|
||||
'contextPanel.linear.label.workspace': 'Espace de travail',
|
||||
'contextPanel.linear.label.workspaceAria': 'Espace de travail Linear',
|
||||
'contextPanel.linear.filter.statusAria': 'Filtrer les tickets par statut',
|
||||
'contextPanel.linear.filter.assigneeAria': 'Filtrer les tickets par assigné',
|
||||
'contextPanel.linear.filter.teamAria': 'Filtrer les tickets par équipe',
|
||||
'contextPanel.linear.filter.priorityAria': 'Filtrer les tickets par priorité',
|
||||
'contextPanel.linear.filter.searchAria': 'Rechercher des tickets',
|
||||
'contextPanel.linear.filter.clear': 'Effacer',
|
||||
'contextPanel.linear.filter.clearAria': 'Effacer les filtres des tickets',
|
||||
'contextPanel.linear.filter.status.all': 'Tous',
|
||||
'contextPanel.linear.filter.status.backlog': 'Liste d’attente',
|
||||
'contextPanel.linear.filter.status.todo': 'À faire',
|
||||
'contextPanel.linear.filter.status.started': 'En cours',
|
||||
'contextPanel.linear.filter.status.inReview': 'En revue',
|
||||
'contextPanel.linear.filter.status.completed': 'Terminé',
|
||||
'contextPanel.linear.filter.status.canceled': 'Annulé',
|
||||
'contextPanel.linear.filter.status.duplicate': 'Doublon',
|
||||
'contextPanel.linear.filter.assignee.any': 'Tout le monde',
|
||||
'contextPanel.linear.filter.assignee.me': 'Assignés à moi',
|
||||
'contextPanel.linear.filter.team.all': 'Toutes les équipes',
|
||||
'contextPanel.linear.filter.priority.all': 'Toutes les priorités',
|
||||
'contextPanel.linear.empty.noDescription': 'Aucune description',
|
||||
'contextPanel.linear.empty.noComments': 'Aucun commentaire',
|
||||
'contextPanel.linear.empty.noMatchingIssues': 'Aucun ticket ne correspond à ces filtres',
|
||||
'contextPanel.linear.loading.issue': 'Chargement du ticket…',
|
||||
'contextPanel.linear.toast.statusUpdated': 'Statut du ticket mis à jour',
|
||||
'contextPanel.linear.toast.statusUpdateFailed': 'Impossible de mettre à jour le statut du ticket',
|
||||
'contextPanel.linear.toast.closeFailed': 'Impossible de fermer le ticket',
|
||||
'contextPanel.linear.toast.workspaceSwitched': 'Workspace Linear modifié',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed': 'Impossible de changer de workspace Linear',
|
||||
'contextPanel.linear.error.noCompletedState': 'Cette équipe n’a pas de statut terminé',
|
||||
},
|
||||
es: {
|
||||
'contextPanel.mode.linear': 'Linear',
|
||||
'contextRail.surface.linear.description': 'Explora issues de Linear, cambia el estado e inicia una sesión',
|
||||
'contextPanel.linear.actions.backToList': 'Volver a los issues',
|
||||
'contextPanel.linear.actions.startSession': 'Iniciar sesión',
|
||||
'contextPanel.linear.actions.closeIssue': 'Cerrar issue',
|
||||
'contextPanel.linear.actions.closeSearch': 'Cerrar búsqueda',
|
||||
'contextPanel.linear.label.status': 'Estado',
|
||||
'contextPanel.linear.label.team': 'Equipo',
|
||||
'contextPanel.linear.label.assignee': 'Asignado',
|
||||
'contextPanel.linear.label.unassigned': 'Sin asignar',
|
||||
'contextPanel.linear.label.priority': 'Prioridad',
|
||||
'contextPanel.linear.label.labels': 'Etiquetas',
|
||||
'contextPanel.linear.priority.none': 'Sin prioridad',
|
||||
'contextPanel.linear.priority.urgent': 'Urgente',
|
||||
'contextPanel.linear.priority.high': 'Alta',
|
||||
'contextPanel.linear.priority.medium': 'Media',
|
||||
'contextPanel.linear.priority.low': 'Baja',
|
||||
'contextPanel.linear.label.comments': 'Comentarios',
|
||||
'contextPanel.linear.label.statusAria': 'Estado del issue de Linear',
|
||||
'contextPanel.linear.label.workspace': 'Espacio de trabajo',
|
||||
'contextPanel.linear.label.workspaceAria': 'Espacio de trabajo de Linear',
|
||||
'contextPanel.linear.filter.statusAria': 'Filtrar issues por estado',
|
||||
'contextPanel.linear.filter.assigneeAria': 'Filtrar issues por asignado',
|
||||
'contextPanel.linear.filter.teamAria': 'Filtrar issues por equipo',
|
||||
'contextPanel.linear.filter.priorityAria': 'Filtrar issues por prioridad',
|
||||
'contextPanel.linear.filter.searchAria': 'Buscar issues',
|
||||
'contextPanel.linear.filter.clear': 'Borrar',
|
||||
'contextPanel.linear.filter.clearAria': 'Borrar filtros de issues',
|
||||
'contextPanel.linear.filter.status.all': 'Todos',
|
||||
'contextPanel.linear.filter.status.backlog': 'Lista de espera',
|
||||
'contextPanel.linear.filter.status.todo': 'Por hacer',
|
||||
'contextPanel.linear.filter.status.started': 'En curso',
|
||||
'contextPanel.linear.filter.status.inReview': 'En revisión',
|
||||
'contextPanel.linear.filter.status.completed': 'Hecho',
|
||||
'contextPanel.linear.filter.status.canceled': 'Cancelado',
|
||||
'contextPanel.linear.filter.status.duplicate': 'Duplicado',
|
||||
'contextPanel.linear.filter.assignee.any': 'Cualquiera',
|
||||
'contextPanel.linear.filter.assignee.me': 'Asignados a mí',
|
||||
'contextPanel.linear.filter.team.all': 'Todos los equipos',
|
||||
'contextPanel.linear.filter.priority.all': 'Todas las prioridades',
|
||||
'contextPanel.linear.empty.noDescription': 'Sin descripción',
|
||||
'contextPanel.linear.empty.noComments': 'Sin comentarios',
|
||||
'contextPanel.linear.empty.noMatchingIssues': 'Ningún issue coincide con estos filtros',
|
||||
'contextPanel.linear.loading.issue': 'Cargando issue…',
|
||||
'contextPanel.linear.toast.statusUpdated': 'Estado del issue actualizado',
|
||||
'contextPanel.linear.toast.statusUpdateFailed': 'No se pudo actualizar el estado del issue',
|
||||
'contextPanel.linear.toast.closeFailed': 'No se pudo cerrar el issue',
|
||||
'contextPanel.linear.toast.workspaceSwitched': 'Workspace de Linear cambiado',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed': 'No se pudo cambiar el workspace de Linear',
|
||||
'contextPanel.linear.error.noCompletedState': 'Este equipo no tiene un estado completado',
|
||||
},
|
||||
ja: {
|
||||
'contextPanel.mode.linear': 'Linear',
|
||||
'contextRail.surface.linear.description': 'Linear の Issue を一覧し、状態を変えてセッションを開始します',
|
||||
'contextPanel.linear.actions.backToList': 'Issue 一覧に戻る',
|
||||
'contextPanel.linear.actions.startSession': 'セッションを開始',
|
||||
'contextPanel.linear.actions.closeIssue': 'Issue をクローズ',
|
||||
'contextPanel.linear.actions.closeSearch': '検索を閉じる',
|
||||
'contextPanel.linear.label.status': '状態',
|
||||
'contextPanel.linear.label.team': 'チーム',
|
||||
'contextPanel.linear.label.assignee': '担当者',
|
||||
'contextPanel.linear.label.unassigned': '未割り当て',
|
||||
'contextPanel.linear.label.priority': '優先度',
|
||||
'contextPanel.linear.label.labels': 'ラベル',
|
||||
'contextPanel.linear.priority.none': '優先度なし',
|
||||
'contextPanel.linear.priority.urgent': '緊急',
|
||||
'contextPanel.linear.priority.high': '高',
|
||||
'contextPanel.linear.priority.medium': '中',
|
||||
'contextPanel.linear.priority.low': '低',
|
||||
'contextPanel.linear.label.comments': 'コメント',
|
||||
'contextPanel.linear.label.statusAria': 'Linear Issue の状態',
|
||||
'contextPanel.linear.label.workspace': 'ワークスペース',
|
||||
'contextPanel.linear.label.workspaceAria': 'Linear ワークスペース',
|
||||
'contextPanel.linear.filter.statusAria': '状態で Issue を絞り込む',
|
||||
'contextPanel.linear.filter.assigneeAria': '担当者で Issue を絞り込む',
|
||||
'contextPanel.linear.filter.teamAria': 'チームで Issue を絞り込む',
|
||||
'contextPanel.linear.filter.priorityAria': '優先度で Issue を絞り込む',
|
||||
'contextPanel.linear.filter.searchAria': 'Issue を検索',
|
||||
'contextPanel.linear.filter.clear': 'クリア',
|
||||
'contextPanel.linear.filter.clearAria': 'Issue フィルターをクリア',
|
||||
'contextPanel.linear.filter.status.all': 'すべて',
|
||||
'contextPanel.linear.filter.status.backlog': 'バックログ',
|
||||
'contextPanel.linear.filter.status.todo': '未着手',
|
||||
'contextPanel.linear.filter.status.started': '進行中',
|
||||
'contextPanel.linear.filter.status.inReview': 'レビュー中',
|
||||
'contextPanel.linear.filter.status.completed': '完了',
|
||||
'contextPanel.linear.filter.status.canceled': 'キャンセル',
|
||||
'contextPanel.linear.filter.status.duplicate': '重複',
|
||||
'contextPanel.linear.filter.assignee.any': '全員',
|
||||
'contextPanel.linear.filter.assignee.me': '自分に割り当て',
|
||||
'contextPanel.linear.filter.team.all': 'すべてのチーム',
|
||||
'contextPanel.linear.filter.priority.all': 'すべての優先度',
|
||||
'contextPanel.linear.empty.noDescription': '説明はありません',
|
||||
'contextPanel.linear.empty.noComments': 'コメントはありません',
|
||||
'contextPanel.linear.empty.noMatchingIssues': 'この条件に合う Issue はありません',
|
||||
'contextPanel.linear.loading.issue': 'Issue を読み込み中…',
|
||||
'contextPanel.linear.toast.statusUpdated': 'Issue の状態を更新しました',
|
||||
'contextPanel.linear.toast.statusUpdateFailed': 'Issue の状態を更新できませんでした',
|
||||
'contextPanel.linear.toast.closeFailed': 'Issue をクローズできませんでした',
|
||||
'contextPanel.linear.toast.workspaceSwitched': 'Linear ワークスペースを切り替えました',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear ワークスペースを切り替えられませんでした',
|
||||
'contextPanel.linear.error.noCompletedState': 'このチームには完了ステータスがありません',
|
||||
},
|
||||
ko: {
|
||||
'contextPanel.mode.linear': 'Linear',
|
||||
'contextRail.surface.linear.description': 'Linear 이슈를 보고 상태를 바꾼 뒤 세션을 시작합니다',
|
||||
'contextPanel.linear.actions.backToList': '이슈 목록으로',
|
||||
'contextPanel.linear.actions.startSession': '세션 시작',
|
||||
'contextPanel.linear.actions.closeIssue': '이슈 닫기',
|
||||
'contextPanel.linear.actions.closeSearch': '검색 닫기',
|
||||
'contextPanel.linear.label.status': '상태',
|
||||
'contextPanel.linear.label.team': '팀',
|
||||
'contextPanel.linear.label.assignee': '담당자',
|
||||
'contextPanel.linear.label.unassigned': '담당자 없음',
|
||||
'contextPanel.linear.label.priority': '우선순위',
|
||||
'contextPanel.linear.label.labels': '레이블',
|
||||
'contextPanel.linear.priority.none': '우선순위 없음',
|
||||
'contextPanel.linear.priority.urgent': '긴급',
|
||||
'contextPanel.linear.priority.high': '높음',
|
||||
'contextPanel.linear.priority.medium': '보통',
|
||||
'contextPanel.linear.priority.low': '낮음',
|
||||
'contextPanel.linear.label.comments': '댓글',
|
||||
'contextPanel.linear.label.statusAria': 'Linear 이슈 상태',
|
||||
'contextPanel.linear.label.workspace': '워크스페이스',
|
||||
'contextPanel.linear.label.workspaceAria': 'Linear 워크스페이스',
|
||||
'contextPanel.linear.filter.statusAria': '상태로 이슈 필터',
|
||||
'contextPanel.linear.filter.assigneeAria': '담당자로 이슈 필터',
|
||||
'contextPanel.linear.filter.teamAria': '팀으로 이슈 필터',
|
||||
'contextPanel.linear.filter.priorityAria': '우선순위로 이슈 필터',
|
||||
'contextPanel.linear.filter.searchAria': '이슈 검색',
|
||||
'contextPanel.linear.filter.clear': '지우기',
|
||||
'contextPanel.linear.filter.clearAria': '이슈 필터 지우기',
|
||||
'contextPanel.linear.filter.status.all': '전체',
|
||||
'contextPanel.linear.filter.status.backlog': '백로그',
|
||||
'contextPanel.linear.filter.status.todo': '할 일',
|
||||
'contextPanel.linear.filter.status.started': '작업 중',
|
||||
'contextPanel.linear.filter.status.inReview': '검토 중',
|
||||
'contextPanel.linear.filter.status.completed': '완료',
|
||||
'contextPanel.linear.filter.status.canceled': '취소됨',
|
||||
'contextPanel.linear.filter.status.duplicate': '중복',
|
||||
'contextPanel.linear.filter.assignee.any': '누구나',
|
||||
'contextPanel.linear.filter.assignee.me': '내게 할당됨',
|
||||
'contextPanel.linear.filter.team.all': '모든 팀',
|
||||
'contextPanel.linear.filter.priority.all': '모든 우선순위',
|
||||
'contextPanel.linear.empty.noDescription': '설명이 없습니다',
|
||||
'contextPanel.linear.empty.noComments': '댓글이 없습니다',
|
||||
'contextPanel.linear.empty.noMatchingIssues': '이 필터에 맞는 이슈가 없습니다',
|
||||
'contextPanel.linear.loading.issue': '이슈를 불러오는 중…',
|
||||
'contextPanel.linear.toast.statusUpdated': '이슈 상태를 업데이트했습니다',
|
||||
'contextPanel.linear.toast.statusUpdateFailed': '이슈 상태를 업데이트하지 못했습니다',
|
||||
'contextPanel.linear.toast.closeFailed': '이슈를 닫지 못했습니다',
|
||||
'contextPanel.linear.toast.workspaceSwitched': 'Linear 워크스페이스를 전환했습니다',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear 워크스페이스를 전환하지 못했습니다',
|
||||
'contextPanel.linear.error.noCompletedState': '이 팀에는 완료 상태가 없습니다',
|
||||
},
|
||||
pl: {
|
||||
'contextPanel.mode.linear': 'Linear',
|
||||
'contextRail.surface.linear.description': 'Przeglądaj zgłoszenia Linear, zmieniaj status i uruchamiaj sesję',
|
||||
'contextPanel.linear.actions.backToList': 'Wróć do zgłoszeń',
|
||||
'contextPanel.linear.actions.startSession': 'Uruchom sesję',
|
||||
'contextPanel.linear.actions.closeIssue': 'Zamknij zgłoszenie',
|
||||
'contextPanel.linear.actions.closeSearch': 'Zamknij wyszukiwanie',
|
||||
'contextPanel.linear.label.status': 'Status',
|
||||
'contextPanel.linear.label.team': 'Zespół',
|
||||
'contextPanel.linear.label.assignee': 'Przypisane',
|
||||
'contextPanel.linear.label.unassigned': 'Nieprzypisane',
|
||||
'contextPanel.linear.label.priority': 'Priorytet',
|
||||
'contextPanel.linear.label.labels': 'Etykiety',
|
||||
'contextPanel.linear.priority.none': 'Brak priorytetu',
|
||||
'contextPanel.linear.priority.urgent': 'Pilne',
|
||||
'contextPanel.linear.priority.high': 'Wysoki',
|
||||
'contextPanel.linear.priority.medium': 'Średni',
|
||||
'contextPanel.linear.priority.low': 'Niski',
|
||||
'contextPanel.linear.label.comments': 'Komentarze',
|
||||
'contextPanel.linear.label.statusAria': 'Status zgłoszenia Linear',
|
||||
'contextPanel.linear.label.workspace': 'Obszar roboczy',
|
||||
'contextPanel.linear.label.workspaceAria': 'Workspace Linear',
|
||||
'contextPanel.linear.filter.statusAria': 'Filtruj zgłoszenia według statusu',
|
||||
'contextPanel.linear.filter.assigneeAria': 'Filtruj zgłoszenia według osoby',
|
||||
'contextPanel.linear.filter.teamAria': 'Filtruj zgłoszenia według zespołu',
|
||||
'contextPanel.linear.filter.priorityAria': 'Filtruj zgłoszenia według priorytetu',
|
||||
'contextPanel.linear.filter.searchAria': 'Szukaj zgłoszeń',
|
||||
'contextPanel.linear.filter.clear': 'Wyczyść',
|
||||
'contextPanel.linear.filter.clearAria': 'Wyczyść filtry zgłoszeń',
|
||||
'contextPanel.linear.filter.status.all': 'Wszystkie',
|
||||
'contextPanel.linear.filter.status.backlog': 'Lista oczekujących',
|
||||
'contextPanel.linear.filter.status.todo': 'Do zrobienia',
|
||||
'contextPanel.linear.filter.status.started': 'W toku',
|
||||
'contextPanel.linear.filter.status.inReview': 'W recenzji',
|
||||
'contextPanel.linear.filter.status.completed': 'Ukończone',
|
||||
'contextPanel.linear.filter.status.canceled': 'Anulowane',
|
||||
'contextPanel.linear.filter.status.duplicate': 'Duplikat',
|
||||
'contextPanel.linear.filter.assignee.any': 'Ktokolwiek',
|
||||
'contextPanel.linear.filter.assignee.me': 'Przypisane do mnie',
|
||||
'contextPanel.linear.filter.team.all': 'Wszystkie zespoły',
|
||||
'contextPanel.linear.filter.priority.all': 'Wszystkie priorytety',
|
||||
'contextPanel.linear.empty.noDescription': 'Brak opisu',
|
||||
'contextPanel.linear.empty.noComments': 'Brak komentarzy',
|
||||
'contextPanel.linear.empty.noMatchingIssues': 'Żadne zgłoszenie nie pasuje do tych filtrów',
|
||||
'contextPanel.linear.loading.issue': 'Wczytywanie zgłoszenia…',
|
||||
'contextPanel.linear.toast.statusUpdated': 'Zaktualizowano status zgłoszenia',
|
||||
'contextPanel.linear.toast.statusUpdateFailed': 'Nie udało się zaktualizować statusu zgłoszenia',
|
||||
'contextPanel.linear.toast.closeFailed': 'Nie udało się zamknąć zgłoszenia',
|
||||
'contextPanel.linear.toast.workspaceSwitched': 'Przełączono workspace Linear',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed': 'Nie udało się przełączyć workspace Linear',
|
||||
'contextPanel.linear.error.noCompletedState': 'Ten zespół nie ma statusu ukończenia',
|
||||
},
|
||||
'pt-BR': {
|
||||
'contextPanel.mode.linear': 'Linear',
|
||||
'contextRail.surface.linear.description': 'Navegue pelas issues do Linear, altere o status e inicie uma sessão',
|
||||
'contextPanel.linear.actions.backToList': 'Voltar às issues',
|
||||
'contextPanel.linear.actions.startSession': 'Iniciar sessão',
|
||||
'contextPanel.linear.actions.closeIssue': 'Fechar issue',
|
||||
'contextPanel.linear.actions.closeSearch': 'Fechar pesquisa',
|
||||
'contextPanel.linear.label.status': 'Status',
|
||||
'contextPanel.linear.label.team': 'Equipe',
|
||||
'contextPanel.linear.label.assignee': 'Responsável',
|
||||
'contextPanel.linear.label.unassigned': 'Sem responsável',
|
||||
'contextPanel.linear.label.priority': 'Prioridade',
|
||||
'contextPanel.linear.label.labels': 'Etiquetas',
|
||||
'contextPanel.linear.priority.none': 'Sem prioridade',
|
||||
'contextPanel.linear.priority.urgent': 'Urgente',
|
||||
'contextPanel.linear.priority.high': 'Alta',
|
||||
'contextPanel.linear.priority.medium': 'Média',
|
||||
'contextPanel.linear.priority.low': 'Baixa',
|
||||
'contextPanel.linear.label.comments': 'Comentários',
|
||||
'contextPanel.linear.label.statusAria': 'Status da issue do Linear',
|
||||
'contextPanel.linear.label.workspace': 'Espaço de trabalho',
|
||||
'contextPanel.linear.label.workspaceAria': 'Workspace do Linear',
|
||||
'contextPanel.linear.filter.statusAria': 'Filtrar issues por status',
|
||||
'contextPanel.linear.filter.assigneeAria': 'Filtrar issues por responsável',
|
||||
'contextPanel.linear.filter.teamAria': 'Filtrar issues por equipe',
|
||||
'contextPanel.linear.filter.priorityAria': 'Filtrar issues por prioridade',
|
||||
'contextPanel.linear.filter.searchAria': 'Pesquisar issues',
|
||||
'contextPanel.linear.filter.clear': 'Limpar',
|
||||
'contextPanel.linear.filter.clearAria': 'Limpar filtros de issues',
|
||||
'contextPanel.linear.filter.status.all': 'Todas',
|
||||
'contextPanel.linear.filter.status.backlog': 'Lista de espera',
|
||||
'contextPanel.linear.filter.status.todo': 'A fazer',
|
||||
'contextPanel.linear.filter.status.started': 'Em andamento',
|
||||
'contextPanel.linear.filter.status.inReview': 'Em revisão',
|
||||
'contextPanel.linear.filter.status.completed': 'Concluído',
|
||||
'contextPanel.linear.filter.status.canceled': 'Cancelado',
|
||||
'contextPanel.linear.filter.status.duplicate': 'Duplicado',
|
||||
'contextPanel.linear.filter.assignee.any': 'Qualquer pessoa',
|
||||
'contextPanel.linear.filter.assignee.me': 'Atribuídas a mim',
|
||||
'contextPanel.linear.filter.team.all': 'Todas as equipes',
|
||||
'contextPanel.linear.filter.priority.all': 'Todas as prioridades',
|
||||
'contextPanel.linear.empty.noDescription': 'Sem descrição',
|
||||
'contextPanel.linear.empty.noComments': 'Sem comentários',
|
||||
'contextPanel.linear.empty.noMatchingIssues': 'Nenhuma issue corresponde a estes filtros',
|
||||
'contextPanel.linear.loading.issue': 'Carregando issue…',
|
||||
'contextPanel.linear.toast.statusUpdated': 'Status da issue atualizado',
|
||||
'contextPanel.linear.toast.statusUpdateFailed': 'Não foi possível atualizar o status da issue',
|
||||
'contextPanel.linear.toast.closeFailed': 'Não foi possível fechar a issue',
|
||||
'contextPanel.linear.toast.workspaceSwitched': 'Workspace do Linear alterado',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed': 'Não foi possível alternar o workspace do Linear',
|
||||
'contextPanel.linear.error.noCompletedState': 'Esta equipe não tem um status de concluído',
|
||||
},
|
||||
uk: {
|
||||
'contextPanel.mode.linear': 'Linear',
|
||||
'contextRail.surface.linear.description': 'Переглядайте Linear issue, змінюйте статус і запускайте сесію',
|
||||
'contextPanel.linear.actions.backToList': 'Назад до issues',
|
||||
'contextPanel.linear.actions.startSession': 'Почати сесію',
|
||||
'contextPanel.linear.actions.closeIssue': 'Закрити issue',
|
||||
'contextPanel.linear.actions.closeSearch': 'Закрити пошук',
|
||||
'contextPanel.linear.label.status': 'Статус',
|
||||
'contextPanel.linear.label.team': 'Команда',
|
||||
'contextPanel.linear.label.assignee': 'Виконавець',
|
||||
'contextPanel.linear.label.unassigned': 'Не призначено',
|
||||
'contextPanel.linear.label.priority': 'Пріоритет',
|
||||
'contextPanel.linear.label.labels': 'Мітки',
|
||||
'contextPanel.linear.priority.none': 'Без пріоритету',
|
||||
'contextPanel.linear.priority.urgent': 'Терміновий',
|
||||
'contextPanel.linear.priority.high': 'Високий',
|
||||
'contextPanel.linear.priority.medium': 'Середній',
|
||||
'contextPanel.linear.priority.low': 'Низький',
|
||||
'contextPanel.linear.label.comments': 'Коментарі',
|
||||
'contextPanel.linear.label.statusAria': 'Статус Linear issue',
|
||||
'contextPanel.linear.label.workspace': 'Робочий простір',
|
||||
'contextPanel.linear.label.workspaceAria': 'Робочий простір Linear',
|
||||
'contextPanel.linear.filter.statusAria': 'Фільтрувати issues за статусом',
|
||||
'contextPanel.linear.filter.assigneeAria': 'Фільтрувати issues за виконавцем',
|
||||
'contextPanel.linear.filter.teamAria': 'Фільтрувати issues за командою',
|
||||
'contextPanel.linear.filter.priorityAria': 'Фільтрувати issues за пріоритетом',
|
||||
'contextPanel.linear.filter.searchAria': 'Шукати issues',
|
||||
'contextPanel.linear.filter.clear': 'Скинути',
|
||||
'contextPanel.linear.filter.clearAria': 'Скинути фільтри issues',
|
||||
'contextPanel.linear.filter.status.all': 'Усі',
|
||||
'contextPanel.linear.filter.status.backlog': 'Беклог',
|
||||
'contextPanel.linear.filter.status.todo': 'До виконання',
|
||||
'contextPanel.linear.filter.status.started': 'У роботі',
|
||||
'contextPanel.linear.filter.status.inReview': 'На перегляді',
|
||||
'contextPanel.linear.filter.status.completed': 'Готово',
|
||||
'contextPanel.linear.filter.status.canceled': 'Скасовано',
|
||||
'contextPanel.linear.filter.status.duplicate': 'Дублікат',
|
||||
'contextPanel.linear.filter.assignee.any': 'Будь-хто',
|
||||
'contextPanel.linear.filter.assignee.me': 'Призначені мені',
|
||||
'contextPanel.linear.filter.team.all': 'Усі команди',
|
||||
'contextPanel.linear.filter.priority.all': 'Усі пріоритети',
|
||||
'contextPanel.linear.empty.noDescription': 'Немає опису',
|
||||
'contextPanel.linear.empty.noComments': 'Немає коментарів',
|
||||
'contextPanel.linear.empty.noMatchingIssues': 'Немає issues за цими фільтрами',
|
||||
'contextPanel.linear.loading.issue': 'Завантаження issue…',
|
||||
'contextPanel.linear.toast.statusUpdated': 'Статус issue оновлено',
|
||||
'contextPanel.linear.toast.statusUpdateFailed': 'Не вдалося оновити статус issue',
|
||||
'contextPanel.linear.toast.closeFailed': 'Не вдалося закрити issue',
|
||||
'contextPanel.linear.toast.workspaceSwitched': 'Перемкнуто Linear workspace',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed': 'Не вдалося перемкнути Linear workspace',
|
||||
'contextPanel.linear.error.noCompletedState': 'У цієї команди немає статусу completed',
|
||||
},
|
||||
'zh-CN': {
|
||||
'contextPanel.mode.linear': 'Linear',
|
||||
'contextRail.surface.linear.description': '浏览 Linear Issue、更改状态并开始会话',
|
||||
'contextPanel.linear.actions.backToList': '返回 Issue 列表',
|
||||
'contextPanel.linear.actions.startSession': '开始会话',
|
||||
'contextPanel.linear.actions.closeIssue': '关闭 Issue',
|
||||
'contextPanel.linear.actions.closeSearch': '关闭搜索',
|
||||
'contextPanel.linear.label.status': '状态',
|
||||
'contextPanel.linear.label.team': '团队',
|
||||
'contextPanel.linear.label.assignee': '负责人',
|
||||
'contextPanel.linear.label.unassigned': '未指派',
|
||||
'contextPanel.linear.label.priority': '优先级',
|
||||
'contextPanel.linear.label.labels': '标签',
|
||||
'contextPanel.linear.priority.none': '无优先级',
|
||||
'contextPanel.linear.priority.urgent': '紧急',
|
||||
'contextPanel.linear.priority.high': '高',
|
||||
'contextPanel.linear.priority.medium': '中',
|
||||
'contextPanel.linear.priority.low': '低',
|
||||
'contextPanel.linear.label.comments': '评论',
|
||||
'contextPanel.linear.label.statusAria': 'Linear Issue 状态',
|
||||
'contextPanel.linear.label.workspace': '工作区',
|
||||
'contextPanel.linear.label.workspaceAria': 'Linear 工作区',
|
||||
'contextPanel.linear.filter.statusAria': '按状态筛选 Issue',
|
||||
'contextPanel.linear.filter.assigneeAria': '按负责人筛选 Issue',
|
||||
'contextPanel.linear.filter.teamAria': '按团队筛选 Issue',
|
||||
'contextPanel.linear.filter.priorityAria': '按优先级筛选 Issue',
|
||||
'contextPanel.linear.filter.searchAria': '搜索 Issue',
|
||||
'contextPanel.linear.filter.clear': '清除',
|
||||
'contextPanel.linear.filter.clearAria': '清除 Issue 筛选',
|
||||
'contextPanel.linear.filter.status.all': '全部',
|
||||
'contextPanel.linear.filter.status.backlog': '待办池',
|
||||
'contextPanel.linear.filter.status.todo': '待办',
|
||||
'contextPanel.linear.filter.status.started': '进行中',
|
||||
'contextPanel.linear.filter.status.inReview': '审核中',
|
||||
'contextPanel.linear.filter.status.completed': '已完成',
|
||||
'contextPanel.linear.filter.status.canceled': '已取消',
|
||||
'contextPanel.linear.filter.status.duplicate': '重复',
|
||||
'contextPanel.linear.filter.assignee.any': '任何人',
|
||||
'contextPanel.linear.filter.assignee.me': '指派给我',
|
||||
'contextPanel.linear.filter.team.all': '所有团队',
|
||||
'contextPanel.linear.filter.priority.all': '所有优先级',
|
||||
'contextPanel.linear.empty.noDescription': '没有描述',
|
||||
'contextPanel.linear.empty.noComments': '没有评论',
|
||||
'contextPanel.linear.empty.noMatchingIssues': '没有符合这些筛选条件的 Issue',
|
||||
'contextPanel.linear.loading.issue': '正在加载 Issue…',
|
||||
'contextPanel.linear.toast.statusUpdated': '已更新 Issue 状态',
|
||||
'contextPanel.linear.toast.statusUpdateFailed': '无法更新 Issue 状态',
|
||||
'contextPanel.linear.toast.closeFailed': '无法关闭 Issue',
|
||||
'contextPanel.linear.toast.workspaceSwitched': '已切换 Linear 工作区',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed': '无法切换 Linear 工作区',
|
||||
'contextPanel.linear.error.noCompletedState': '此团队没有已完成状态',
|
||||
},
|
||||
'zh-TW': {
|
||||
'contextPanel.mode.linear': 'Linear',
|
||||
'contextRail.surface.linear.description': '瀏覽 Linear Issue、變更狀態並開始會話',
|
||||
'contextPanel.linear.actions.backToList': '返回 Issue 列表',
|
||||
'contextPanel.linear.actions.startSession': '開始會話',
|
||||
'contextPanel.linear.actions.closeIssue': '關閉 Issue',
|
||||
'contextPanel.linear.actions.closeSearch': '關閉搜尋',
|
||||
'contextPanel.linear.label.status': '狀態',
|
||||
'contextPanel.linear.label.team': '團隊',
|
||||
'contextPanel.linear.label.assignee': '負責人',
|
||||
'contextPanel.linear.label.unassigned': '未指派',
|
||||
'contextPanel.linear.label.priority': '優先級',
|
||||
'contextPanel.linear.label.labels': '標籤',
|
||||
'contextPanel.linear.priority.none': '無優先級',
|
||||
'contextPanel.linear.priority.urgent': '緊急',
|
||||
'contextPanel.linear.priority.high': '高',
|
||||
'contextPanel.linear.priority.medium': '中',
|
||||
'contextPanel.linear.priority.low': '低',
|
||||
'contextPanel.linear.label.comments': '留言',
|
||||
'contextPanel.linear.label.statusAria': 'Linear Issue 狀態',
|
||||
'contextPanel.linear.label.workspace': '工作區',
|
||||
'contextPanel.linear.label.workspaceAria': 'Linear 工作區',
|
||||
'contextPanel.linear.filter.statusAria': '依狀態篩選 Issue',
|
||||
'contextPanel.linear.filter.assigneeAria': '依負責人篩選 Issue',
|
||||
'contextPanel.linear.filter.teamAria': '依團隊篩選 Issue',
|
||||
'contextPanel.linear.filter.priorityAria': '依優先級篩選 Issue',
|
||||
'contextPanel.linear.filter.searchAria': '搜尋 Issue',
|
||||
'contextPanel.linear.filter.clear': '清除',
|
||||
'contextPanel.linear.filter.clearAria': '清除 Issue 篩選',
|
||||
'contextPanel.linear.filter.status.all': '全部',
|
||||
'contextPanel.linear.filter.status.backlog': '待辦池',
|
||||
'contextPanel.linear.filter.status.todo': '待辦',
|
||||
'contextPanel.linear.filter.status.started': '進行中',
|
||||
'contextPanel.linear.filter.status.inReview': '審核中',
|
||||
'contextPanel.linear.filter.status.completed': '已完成',
|
||||
'contextPanel.linear.filter.status.canceled': '已取消',
|
||||
'contextPanel.linear.filter.status.duplicate': '重複',
|
||||
'contextPanel.linear.filter.assignee.any': '任何人',
|
||||
'contextPanel.linear.filter.assignee.me': '指派給我',
|
||||
'contextPanel.linear.filter.team.all': '所有團隊',
|
||||
'contextPanel.linear.filter.priority.all': '所有優先級',
|
||||
'contextPanel.linear.empty.noDescription': '沒有描述',
|
||||
'contextPanel.linear.empty.noComments': '沒有留言',
|
||||
'contextPanel.linear.empty.noMatchingIssues': '沒有符合這些篩選條件的 Issue',
|
||||
'contextPanel.linear.loading.issue': '正在載入 Issue…',
|
||||
'contextPanel.linear.toast.statusUpdated': '已更新 Issue 狀態',
|
||||
'contextPanel.linear.toast.statusUpdateFailed': '無法更新 Issue 狀態',
|
||||
'contextPanel.linear.toast.closeFailed': '無法關閉 Issue',
|
||||
'contextPanel.linear.toast.workspaceSwitched': '已切換 Linear 工作區',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed': '無法切換 Linear 工作區',
|
||||
'contextPanel.linear.error.noCompletedState': '此團隊沒有已完成狀態',
|
||||
},
|
||||
tr: {
|
||||
'contextPanel.mode.linear': 'Linear',
|
||||
'contextRail.surface.linear.description': "Linear issue'larını incele, durumu değiştir ve session başlat",
|
||||
'contextPanel.linear.actions.backToList': 'Issue listesine dön',
|
||||
'contextPanel.linear.actions.startSession': 'Session başlat',
|
||||
'contextPanel.linear.actions.closeIssue': "Issue'u kapat",
|
||||
'contextPanel.linear.actions.closeSearch': 'Aramayı kapat',
|
||||
'contextPanel.linear.label.status': 'Durum',
|
||||
'contextPanel.linear.label.team': 'Ekip',
|
||||
'contextPanel.linear.label.assignee': 'Atanan',
|
||||
'contextPanel.linear.label.unassigned': 'Atanmamış',
|
||||
'contextPanel.linear.label.priority': 'Öncelik',
|
||||
'contextPanel.linear.label.labels': 'Etiketler',
|
||||
'contextPanel.linear.priority.none': 'Öncelik yok',
|
||||
'contextPanel.linear.priority.urgent': 'Acil',
|
||||
'contextPanel.linear.priority.high': 'Yüksek',
|
||||
'contextPanel.linear.priority.medium': 'Orta',
|
||||
'contextPanel.linear.priority.low': 'Düşük',
|
||||
'contextPanel.linear.label.comments': 'Yorumlar',
|
||||
'contextPanel.linear.label.statusAria': 'Linear issue durumu',
|
||||
'contextPanel.linear.label.workspace': 'Çalışma alanı',
|
||||
'contextPanel.linear.label.workspaceAria': 'Linear çalışma alanı',
|
||||
'contextPanel.linear.filter.statusAria': "Issue'ları duruma göre süz",
|
||||
'contextPanel.linear.filter.assigneeAria': "Issue'ları atanan kişiye göre süz",
|
||||
'contextPanel.linear.filter.teamAria': "Issue'ları ekibe göre süz",
|
||||
'contextPanel.linear.filter.priorityAria': "Issue'ları önceliğe göre süz",
|
||||
'contextPanel.linear.filter.searchAria': "Issue'larda ara",
|
||||
'contextPanel.linear.filter.clear': 'Temizle',
|
||||
'contextPanel.linear.filter.clearAria': "Issue filtrelerini temizle",
|
||||
'contextPanel.linear.filter.status.all': 'Tümü',
|
||||
'contextPanel.linear.filter.status.backlog': 'Bekleme listesi',
|
||||
'contextPanel.linear.filter.status.todo': 'Yapılacak',
|
||||
'contextPanel.linear.filter.status.started': 'Devam ediyor',
|
||||
'contextPanel.linear.filter.status.inReview': 'İncelemede',
|
||||
'contextPanel.linear.filter.status.completed': 'Bitti',
|
||||
'contextPanel.linear.filter.status.canceled': 'İptal',
|
||||
'contextPanel.linear.filter.status.duplicate': 'Yinelenen',
|
||||
'contextPanel.linear.filter.assignee.any': 'Herkes',
|
||||
'contextPanel.linear.filter.assignee.me': 'Bana atananlar',
|
||||
'contextPanel.linear.filter.team.all': 'Tüm ekipler',
|
||||
'contextPanel.linear.filter.priority.all': 'Tüm öncelikler',
|
||||
'contextPanel.linear.empty.noDescription': 'Açıklama yok',
|
||||
'contextPanel.linear.empty.noComments': 'Yorum yok',
|
||||
'contextPanel.linear.empty.noMatchingIssues': 'Bu süzgeçlere uyan issue yok',
|
||||
'contextPanel.linear.loading.issue': 'Issue yükleniyor…',
|
||||
'contextPanel.linear.toast.statusUpdated': 'Issue durumu güncellendi',
|
||||
'contextPanel.linear.toast.statusUpdateFailed': 'Issue durumu güncellenemedi',
|
||||
'contextPanel.linear.toast.closeFailed': 'Issue kapatılamadı',
|
||||
'contextPanel.linear.toast.workspaceSwitched': 'Linear çalışma alanı değiştirildi',
|
||||
'contextPanel.linear.toast.workspaceSwitchFailed': 'Linear çalışma alanı değiştirilemedi',
|
||||
'contextPanel.linear.error.noCompletedState': 'Bu ekibin tamamlandı durumu yok',
|
||||
},
|
||||
} as const;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
|
||||
export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.title': 'Śledzenie użycia OpenCode Go',
|
||||
@@ -2220,5 +2221,6 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
|
||||
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
|
||||
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
|
||||
...linearIntegrationI18n.pl,
|
||||
...thirdPartyIntegrationI18n.pl,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { I18nKey } from './en';
|
||||
import { settingsDict } from './pl.settings';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n.pl,
|
||||
...linearPanelI18n.pl,
|
||||
'terminalView.actions.attachSelection': 'Dołącz zaznaczone dane wyjściowe',
|
||||
'terminalView.actions.restart': 'Uruchom terminal ponownie',
|
||||
'chat.message.terminalContext': '{terminal}, wiersze {start}-{end}',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
|
||||
export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.title': 'Monitoramento de uso do OpenCode Go',
|
||||
@@ -2227,5 +2228,6 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
|
||||
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
|
||||
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
|
||||
...linearIntegrationI18n['pt-BR'],
|
||||
...thirdPartyIntegrationI18n['pt-BR'],
|
||||
} as const;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { I18nKey } from './en';
|
||||
import { settingsDict } from './pt-BR.settings';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n['pt-BR'],
|
||||
...linearPanelI18n['pt-BR'],
|
||||
'terminalView.actions.attachSelection': 'Anexar saída selecionada',
|
||||
'terminalView.actions.restart': 'Reiniciar terminal',
|
||||
'chat.message.terminalContext': '{terminal}, linhas {start}-{end}',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
|
||||
export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.title': 'OpenCode Go kullanım takibi',
|
||||
@@ -2218,4 +2219,5 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.sessionTabsAria': 'Başlıktaki session sekmelerini aç/kapat',
|
||||
'settings.openchamber.visual.field.sessionTabsInfo': 'Açtığınız session\'lar başlıkta sekmeler olarak dizilir. Kapatırsanız düz session başlığına döner.',
|
||||
...thirdPartyIntegrationI18n.tr,
|
||||
...linearIntegrationI18n.tr,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { settingsDict } from './tr.settings';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict = {
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n.tr,
|
||||
...linearPanelI18n.tr,
|
||||
'terminalView.actions.attachSelection': 'Seçili çıktıyı ekle',
|
||||
'terminalView.actions.restart': 'Terminali yeniden başlat',
|
||||
'chat.message.terminalContext': '{terminal}, {start}-{end}. satırlar',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
|
||||
export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.title': 'Відстеження використання OpenCode Go',
|
||||
@@ -2227,5 +2228,6 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
|
||||
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
|
||||
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
|
||||
...linearIntegrationI18n.uk,
|
||||
...thirdPartyIntegrationI18n.uk,
|
||||
} as const;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { I18nKey } from './en';
|
||||
import { settingsDict } from './uk.settings';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n.uk,
|
||||
...linearPanelI18n.uk,
|
||||
'terminalView.actions.attachSelection': 'Прикріпити вибраний вивід',
|
||||
'terminalView.actions.restart': 'Перезапустити термінал',
|
||||
'chat.message.terminalContext': '{terminal}, рядки {start}-{end}',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
|
||||
export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量跟踪',
|
||||
@@ -2227,5 +2228,6 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
|
||||
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
|
||||
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
|
||||
...linearIntegrationI18n['zh-CN'],
|
||||
...thirdPartyIntegrationI18n['zh-CN'],
|
||||
} as const;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { I18nKey } from './en';
|
||||
import { settingsDict } from './zh-CN.settings';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n['zh-CN'],
|
||||
...linearPanelI18n['zh-CN'],
|
||||
'terminalView.actions.attachSelection': '附加所选输出',
|
||||
'terminalView.actions.restart': '重启终端',
|
||||
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { linearIntegrationI18n } from './linear-integration.i18n';
|
||||
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
|
||||
export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量追蹤',
|
||||
@@ -2227,5 +2228,6 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
|
||||
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
|
||||
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
|
||||
...linearIntegrationI18n['zh-TW'],
|
||||
...thirdPartyIntegrationI18n['zh-TW'],
|
||||
} as const;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { I18nKey } from './en';
|
||||
import { settingsDict } from './zh-TW.settings';
|
||||
import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n['zh-TW'],
|
||||
...linearPanelI18n['zh-TW'],
|
||||
'terminalView.actions.attachSelection': '附加所選輸出',
|
||||
'terminalView.actions.restart': '重新啟動終端',
|
||||
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveLinearMappedProjectPath } from './linearProjectMapping';
|
||||
import type { LinearMappingResult } from './api/types';
|
||||
|
||||
const mapping = (): LinearMappingResult => ({
|
||||
connected: true,
|
||||
defaultProjectPath: '/default',
|
||||
teams: [
|
||||
{ id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/eng' },
|
||||
{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null },
|
||||
],
|
||||
});
|
||||
|
||||
describe('resolveLinearMappedProjectPath', () => {
|
||||
test('prefers the team path over the default', () => {
|
||||
expect(resolveLinearMappedProjectPath(mapping(), { id: 'team-eng', key: 'ENG', name: 'Engineering' }))
|
||||
.toBe('/eng');
|
||||
});
|
||||
|
||||
test('falls back to the default when the team has no path', () => {
|
||||
expect(resolveLinearMappedProjectPath(mapping(), { id: 'team-des', key: 'DES', name: 'Design' }))
|
||||
.toBe('/default');
|
||||
});
|
||||
|
||||
test('matches a team by key when the id is missing', () => {
|
||||
expect(resolveLinearMappedProjectPath(mapping(), { id: '', key: 'ENG', name: 'Engineering' }))
|
||||
.toBe('/eng');
|
||||
});
|
||||
|
||||
test('returns null when Linear is disconnected or unmapped', () => {
|
||||
expect(resolveLinearMappedProjectPath({ connected: false }, { id: 'team-eng', key: 'ENG', name: 'Engineering' }))
|
||||
.toBeNull();
|
||||
expect(resolveLinearMappedProjectPath({
|
||||
connected: true,
|
||||
defaultProjectPath: null,
|
||||
teams: [{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null }],
|
||||
}, { id: 'team-des', key: 'DES', name: 'Design' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LinearIssueTeam, LinearMappingResult } from '@/lib/api/types';
|
||||
|
||||
export function resolveLinearMappedProjectPath(
|
||||
mapping: LinearMappingResult | null | undefined,
|
||||
team: LinearIssueTeam | null | undefined,
|
||||
): string | null {
|
||||
if (!mapping || mapping.connected === false) {
|
||||
return null;
|
||||
}
|
||||
const teams = mapping.teams ?? [];
|
||||
if (team?.id) {
|
||||
const byId = teams.find((entry) => entry.id === team.id);
|
||||
if (byId?.projectPath) {
|
||||
return byId.projectPath;
|
||||
}
|
||||
}
|
||||
if (team?.key) {
|
||||
const byKey = teams.find((entry) => entry.key === team.key);
|
||||
if (byKey?.projectPath) {
|
||||
return byKey.projectPath;
|
||||
}
|
||||
}
|
||||
return mapping.defaultProjectPath?.trim() || null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveLinearSessionOrigin } from './linearSessionStatus';
|
||||
|
||||
describe('resolveLinearSessionOrigin', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: originalWindow,
|
||||
});
|
||||
});
|
||||
|
||||
test('uses the page origin on web', () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
location: { origin: 'https://app.example.com' },
|
||||
},
|
||||
});
|
||||
expect(resolveLinearSessionOrigin()).toBe('https://app.example.com');
|
||||
});
|
||||
|
||||
test('uses the desktop loopback origin instead of the packaged UI scheme', () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
location: { origin: 'openchamber-ui://app' },
|
||||
__OPENCHAMBER_ELECTRON__: { runtime: 'electron' },
|
||||
__OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:3001',
|
||||
},
|
||||
});
|
||||
expect(resolveLinearSessionOrigin()).toBe('http://127.0.0.1:3001');
|
||||
});
|
||||
|
||||
test('reports no origin when the desktop shell has no http loopback', () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
location: { origin: 'openchamber-ui://app' },
|
||||
__OPENCHAMBER_ELECTRON__: { runtime: 'electron' },
|
||||
__OPENCHAMBER_LOCAL_ORIGIN__: 'openchamber-ui://app',
|
||||
},
|
||||
});
|
||||
// A deep link is unopenable for everyone but this machine, so the server
|
||||
// gets no origin and posts no comment.
|
||||
expect(resolveLinearSessionOrigin()).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { LinearAPI } from '@/lib/api/types';
|
||||
import { isElectronShell } from '@/lib/desktop';
|
||||
import { getLocalDesktopOrigin } from '@/lib/desktopCurrentHost';
|
||||
|
||||
function isHttpOrigin(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin Linear comments should open. Packaged desktop UI lives on
|
||||
* `openchamber-ui://`, which is not a URL a browser can load from Linear, so
|
||||
* report the http origin the local server actually listens on instead. The
|
||||
* server decides whether that origin is reachable by anyone else; a comment is
|
||||
* only posted when it is.
|
||||
*/
|
||||
export function resolveLinearSessionOrigin(): string | undefined {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
if (isElectronShell()) {
|
||||
const localOrigin = getLocalDesktopOrigin().trim();
|
||||
if (localOrigin && isHttpOrigin(localOrigin)) {
|
||||
return new URL(localOrigin).origin;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const origin = window.location.origin.trim();
|
||||
return origin || undefined;
|
||||
}
|
||||
|
||||
export function postLinearSessionStarted(
|
||||
linear: LinearAPI | undefined,
|
||||
args: { sessionId: string; issueIdentifier: string },
|
||||
): void {
|
||||
if (!linear?.sessionStatusPost) return;
|
||||
void linear.sessionStatusPost({
|
||||
kind: 'started',
|
||||
sessionId: args.sessionId,
|
||||
issueIdentifier: args.issueIdentifier,
|
||||
sessionOrigin: resolveLinearSessionOrigin(),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildIssueContextText } from './linearStartSession';
|
||||
import type { LinearIssue } from '@/lib/api/types';
|
||||
|
||||
const issue: LinearIssue = {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-12',
|
||||
title: 'Broken login',
|
||||
url: 'https://linear.app/openchamber/issue/ENG-12',
|
||||
description: 'Users cannot sign in.',
|
||||
comments: [],
|
||||
};
|
||||
|
||||
describe('buildIssueContextText', () => {
|
||||
test('serializes the issue and comments as JSON context', () => {
|
||||
const text = buildIssueContextText({
|
||||
issue,
|
||||
comments: [{
|
||||
id: 'comment-1',
|
||||
body: 'Still broken',
|
||||
createdAt: '2026-08-24T10:00:00.000Z',
|
||||
user: { name: 'Ada', displayName: 'Ada Lovelace' },
|
||||
}],
|
||||
});
|
||||
expect(text.startsWith('Linear issue context (JSON)\n')).toBe(true);
|
||||
expect(text).toContain('"identifier": "ENG-12"');
|
||||
expect(text).toContain('Still broken');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { toast } from '@/components/ui';
|
||||
import type { LinearAPI, LinearIssue, LinearIssueComment, LinearMappingResult } from '@/lib/api/types';
|
||||
import type { I18nKey, I18nParams } from '@/lib/i18n';
|
||||
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
||||
import { modelVariantNames } from '@/lib/modelVariants';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
|
||||
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
||||
import { buildLinkedLinearIssue } from '@/lib/linkedIssues';
|
||||
import { resolveLinearMappedProjectPath } from '@/lib/linearProjectMapping';
|
||||
import { postLinearSessionStarted } from '@/lib/linearSessionStatus';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
|
||||
type TranslateFn = (key: I18nKey, params?: I18nParams) => string;
|
||||
|
||||
export function buildIssueContextText(args: {
|
||||
issue: LinearIssue;
|
||||
comments: LinearIssueComment[];
|
||||
}): string {
|
||||
const payload = {
|
||||
issue: args.issue,
|
||||
comments: args.comments,
|
||||
};
|
||||
return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
||||
}
|
||||
|
||||
function resolveDefaultAgentName(): string | undefined {
|
||||
const configState = useConfigStore.getState();
|
||||
const settingsDefaultAgent = configState.settingsDefaultAgent;
|
||||
if (settingsDefaultAgent) {
|
||||
return settingsDefaultAgent;
|
||||
}
|
||||
const visibleAgents = configState.agents.filter((agent) => !agent.hidden);
|
||||
return (
|
||||
configState.currentAgentName
|
||||
|| visibleAgents.find((agent) => agent.mode === 'primary' || !agent.mode)?.name
|
||||
|| visibleAgents[0]?.name
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDefaultModelSelection(): { providerID: string; modelID: string } | null {
|
||||
const configState = useConfigStore.getState();
|
||||
const settingsDefaultModel = configState.settingsDefaultModel;
|
||||
if (!settingsDefaultModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseModelIdentifier(settingsDefaultModel);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const { providerId: providerID, modelId: modelID } = parsed;
|
||||
|
||||
const modelMetadata = configState.getModelMetadata(providerID, modelID);
|
||||
if (!modelMetadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { providerID, modelID };
|
||||
}
|
||||
|
||||
function resolveDefaultVariant(providerID: string, modelID: string): string | undefined {
|
||||
const configState = useConfigStore.getState();
|
||||
const settingsDefaultVariant = configState.settingsDefaultVariant;
|
||||
const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID
|
||||
? configState.currentVariant
|
||||
: undefined;
|
||||
|
||||
const provider = configState.providers.find((entry) => entry.id === providerID);
|
||||
const model = provider?.models.find((entry) => entry.id === modelID);
|
||||
const variantNames = modelVariantNames(model);
|
||||
if (variantNames.length === 0) {
|
||||
return settingsDefaultVariant || currentVariant || undefined;
|
||||
}
|
||||
if (settingsDefaultVariant && variantNames.includes(settingsDefaultVariant)) {
|
||||
return settingsDefaultVariant;
|
||||
}
|
||||
if (currentVariant && variantNames.includes(currentVariant)) {
|
||||
return currentVariant;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function startLinearIssueSession(args: {
|
||||
linear: LinearAPI | undefined;
|
||||
issueKey: string;
|
||||
createInWorktree: boolean;
|
||||
mapping?: LinearMappingResult | null;
|
||||
onMappingLoaded?: (mapping: LinearMappingResult) => void;
|
||||
onSessionCreated?: () => void;
|
||||
t: TranslateFn;
|
||||
}): Promise<boolean> {
|
||||
const { linear, issueKey, createInWorktree, t } = args;
|
||||
if (!linear?.issueGet || !linear.mappingGet) {
|
||||
toast.error(t('session.linearIssuePicker.error.runtimeUnavailable'));
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
let mappingView = args.mapping;
|
||||
if (!mappingView) {
|
||||
mappingView = await linear.mappingGet();
|
||||
args.onMappingLoaded?.(mappingView);
|
||||
}
|
||||
if (mappingView.connected === false) {
|
||||
toast.error(t('session.linearIssuePicker.error.notConnected'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const issueRes = await linear.issueGet(issueKey);
|
||||
if (issueRes.connected === false) {
|
||||
toast.error(t('session.linearIssuePicker.error.notConnected'));
|
||||
return false;
|
||||
}
|
||||
const issue = issueRes.issue;
|
||||
if (!issue) {
|
||||
toast.error(t('session.linearIssuePicker.error.issueNotFound'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const projectDirectory = resolveLinearMappedProjectPath(mappingView, issue.team);
|
||||
if (!projectDirectory) {
|
||||
toast.error(t('session.linearIssuePicker.error.noMappedProject'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const comments = issue.comments ?? [];
|
||||
const sessionTitle = `${issue.identifier} ${issue.title}`.trim();
|
||||
const login = issue.assignee?.displayName || issue.assignee?.name;
|
||||
|
||||
const { sessionId, sessionDirectory } = await (async () => {
|
||||
if (createInWorktree) {
|
||||
const preferred = `issue-${issue.identifier}-${generateBranchSlug()}`;
|
||||
const created = await createWorktreeSessionForNewBranch(
|
||||
projectDirectory,
|
||||
preferred,
|
||||
undefined,
|
||||
{ returnAfterDirectoryCreated: true },
|
||||
);
|
||||
if (!created?.id) {
|
||||
throw new Error('Failed to create worktree session');
|
||||
}
|
||||
return { sessionId: created.id, sessionDirectory: created.path };
|
||||
}
|
||||
|
||||
const session = await sessionActions.createSession(sessionTitle, projectDirectory, null);
|
||||
if (!session?.id) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
return { sessionId: session.id, sessionDirectory: session.directory ?? projectDirectory };
|
||||
})();
|
||||
|
||||
void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined);
|
||||
|
||||
try {
|
||||
useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
args.onSessionCreated?.();
|
||||
useUIStore.getState().closeMainSurfaces();
|
||||
useUIStore.getState().setSessionSwitcherOpen(false);
|
||||
|
||||
postLinearSessionStarted(linear, {
|
||||
sessionId,
|
||||
issueIdentifier: issue.identifier,
|
||||
});
|
||||
|
||||
const configState = useConfigStore.getState();
|
||||
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
|
||||
const defaultModel = resolveDefaultModelSelection();
|
||||
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
|
||||
const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
|
||||
const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined;
|
||||
if (!providerID || !modelID) {
|
||||
toast.error(t('session.linearIssuePicker.error.noModelSelected'));
|
||||
return true;
|
||||
}
|
||||
|
||||
const variant = resolveDefaultVariant(providerID, modelID);
|
||||
const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', {
|
||||
identifier: issue.identifier,
|
||||
});
|
||||
const instructionsText = await renderMagicPrompt('linear.issue.review.instructions');
|
||||
const contextText = buildIssueContextText({ issue, comments });
|
||||
|
||||
void sessionActions.setLinkedIssue(
|
||||
sessionId,
|
||||
sessionDirectory,
|
||||
buildLinkedLinearIssue({
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
author: login
|
||||
? { login, avatarUrl: issue.assignee?.avatarUrl || undefined }
|
||||
: undefined,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
|
||||
void useSessionUIStore.getState().sendMessage(
|
||||
visiblePromptText,
|
||||
providerID,
|
||||
modelID,
|
||||
agentName,
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
{ text: instructionsText, synthetic: true },
|
||||
{ text: contextText, synthetic: true },
|
||||
],
|
||||
variant,
|
||||
undefined,
|
||||
{ sessionId, directory: sessionDirectory },
|
||||
).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(t('session.linearIssuePicker.toast.sendContextFailed'), {
|
||||
description: message,
|
||||
});
|
||||
});
|
||||
|
||||
toast.success(t('session.linearIssuePicker.toast.sessionCreated'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(t('session.linearIssuePicker.toast.startSessionFailed'), { description: message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { buildLinkedIssue, buildLinkedIssueId, getLinkedIssues, withLinkedIssue, type LinkedIssue } from './linkedIssues';
|
||||
import { buildLinkedIssue, buildLinkedIssueId, buildLinkedLinearIssue, canOpenLinearIssueInContextPanel, getLinkedIssues, withLinkedIssue, type LinkedIssue } from './linkedIssues';
|
||||
|
||||
const issue = (overrides: Partial<LinkedIssue> = {}): LinkedIssue => ({
|
||||
type LinkedGitHubIssue = Exclude<LinkedIssue, { kind: 'linear' }>;
|
||||
|
||||
const issue = (overrides: Partial<LinkedGitHubIssue> = {}): LinkedGitHubIssue => ({
|
||||
id: 'owner/repo#12',
|
||||
number: 12,
|
||||
title: 'Rail badge count',
|
||||
@@ -76,6 +78,28 @@ describe('buildLinkedIssue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLinkedLinearIssue', () => {
|
||||
test('stores the Linear identifier without inventing a GitHub number', () => {
|
||||
const built = buildLinkedLinearIssue({
|
||||
identifier: 'ENG-12',
|
||||
title: 'Broken login',
|
||||
url: 'https://linear.app/openchamber/issue/ENG-12',
|
||||
author: { login: 'Ada', avatarUrl: 'https://avatars/1' },
|
||||
linkedAt: 5,
|
||||
});
|
||||
expect(built).toEqual({
|
||||
id: 'linear:ENG-12',
|
||||
identifier: 'ENG-12',
|
||||
title: 'Broken login',
|
||||
url: 'https://linear.app/openchamber/issue/ENG-12',
|
||||
kind: 'linear',
|
||||
author: 'Ada',
|
||||
authorAvatarUrl: 'https://avatars/1',
|
||||
linkedAt: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLinkedIssues', () => {
|
||||
test('returns an empty list for a session with no metadata', () => {
|
||||
expect(getLinkedIssues(undefined)).toEqual([]);
|
||||
@@ -95,6 +119,17 @@ describe('getLinkedIssues', () => {
|
||||
expect(getLinkedIssues(session)).toEqual([good]);
|
||||
});
|
||||
|
||||
test('keeps Linear entries next to GitHub ones', () => {
|
||||
const github = issue();
|
||||
const linear = buildLinkedLinearIssue({
|
||||
identifier: 'ENG-12',
|
||||
title: 'Broken login',
|
||||
url: 'https://linear.app/openchamber/issue/ENG-12',
|
||||
linkedAt: 2,
|
||||
});
|
||||
expect(getLinkedIssues(sessionWith([github, linear]))).toEqual([github, linear]);
|
||||
});
|
||||
|
||||
test('survives a non-array payload', () => {
|
||||
expect(getLinkedIssues(sessionWith({ nope: true }))).toEqual([]);
|
||||
});
|
||||
@@ -143,3 +178,41 @@ describe('withLinkedIssue', () => {
|
||||
expect((next.openchamber as { linked_issues: LinkedIssue[] }).linked_issues).toEqual([issue()]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canOpenLinearIssueInContextPanel', () => {
|
||||
test('opens the rail when Linear is connected, the shell has a context panel, and a directory is known', () => {
|
||||
expect(canOpenLinearIssueInContextPanel({
|
||||
linearAvailable: true,
|
||||
linearConnected: true,
|
||||
inDedicatedMobileShell: false,
|
||||
directory: '/repo',
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('falls back when Linear is missing, disconnected, the mobile shell is open, or the directory is blank', () => {
|
||||
expect(canOpenLinearIssueInContextPanel({
|
||||
linearAvailable: false,
|
||||
linearConnected: true,
|
||||
inDedicatedMobileShell: false,
|
||||
directory: '/repo',
|
||||
})).toBe(false);
|
||||
expect(canOpenLinearIssueInContextPanel({
|
||||
linearAvailable: true,
|
||||
linearConnected: false,
|
||||
inDedicatedMobileShell: false,
|
||||
directory: '/repo',
|
||||
})).toBe(false);
|
||||
expect(canOpenLinearIssueInContextPanel({
|
||||
linearAvailable: true,
|
||||
linearConnected: true,
|
||||
inDedicatedMobileShell: true,
|
||||
directory: '/repo',
|
||||
})).toBe(false);
|
||||
expect(canOpenLinearIssueInContextPanel({
|
||||
linearAvailable: true,
|
||||
linearConnected: true,
|
||||
inDedicatedMobileShell: false,
|
||||
directory: ' ',
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,20 +2,17 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { getSessionMetadata, type SessionMetadataRecord } from './sessionReviewMetadata';
|
||||
|
||||
/**
|
||||
* GitHub issues and pull requests a user has linked to a session.
|
||||
* Issues and pull requests a user has linked to a session.
|
||||
*
|
||||
* Stored as a **snapshot**, not a reference: number, title, author and avatar
|
||||
* only. Enough to render a row and open the thing, and nothing more — the body,
|
||||
* comments and state of an issue belong to GitHub, and mirroring them here
|
||||
* would mean owning their staleness. The stored title can drift from the real
|
||||
* one; that is the accepted cost of a storage that never needs refreshing.
|
||||
* Stored as a **snapshot**, not a reference: identifier or number, title, author
|
||||
* and avatar only. Enough to render a row and open the thing, and nothing more.
|
||||
*
|
||||
* Rides the same session-metadata channel as pinned messages
|
||||
* (`contextObligatoryMessages`), so it inherits their persistence and sync for
|
||||
* free.
|
||||
*/
|
||||
|
||||
export type LinkedIssue = {
|
||||
export type LinkedGitHubIssue = {
|
||||
/** `owner/repo#number`, unique per session and stable across renames. */
|
||||
id: string;
|
||||
number: number;
|
||||
@@ -27,10 +24,24 @@ export type LinkedIssue = {
|
||||
linkedAt: number;
|
||||
};
|
||||
|
||||
export type LinkedLinearIssue = {
|
||||
/** `linear:{identifier}`, unique per session. */
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
kind: 'linear';
|
||||
author?: string;
|
||||
authorAvatarUrl?: string;
|
||||
linkedAt: number;
|
||||
};
|
||||
|
||||
export type LinkedIssue = LinkedGitHubIssue | LinkedLinearIssue;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
|
||||
const isLinkedIssue = (value: unknown): value is LinkedIssue => (
|
||||
const isLinkedGitHubIssue = (value: unknown): value is LinkedGitHubIssue => (
|
||||
isRecord(value)
|
||||
&& typeof value.id === 'string'
|
||||
&& value.id.length > 0
|
||||
@@ -43,9 +54,29 @@ const isLinkedIssue = (value: unknown): value is LinkedIssue => (
|
||||
&& Number.isFinite(value.linkedAt)
|
||||
);
|
||||
|
||||
const isLinkedLinearIssue = (value: unknown): value is LinkedLinearIssue => (
|
||||
isRecord(value)
|
||||
&& typeof value.id === 'string'
|
||||
&& value.id.length > 0
|
||||
&& typeof value.identifier === 'string'
|
||||
&& value.identifier.length > 0
|
||||
&& typeof value.title === 'string'
|
||||
&& typeof value.url === 'string'
|
||||
&& value.kind === 'linear'
|
||||
&& typeof value.linkedAt === 'number'
|
||||
&& Number.isFinite(value.linkedAt)
|
||||
);
|
||||
|
||||
const isLinkedIssue = (value: unknown): value is LinkedIssue => (
|
||||
isLinkedGitHubIssue(value) || isLinkedLinearIssue(value)
|
||||
);
|
||||
|
||||
export const buildLinkedIssueId = (owner: string, repo: string, number: number): string =>
|
||||
`${owner}/${repo}#${number}`;
|
||||
|
||||
const buildLinkedLinearIssueId = (identifier: string): string =>
|
||||
`linear:${identifier}`;
|
||||
|
||||
/**
|
||||
* Builds the stored snapshot from what an attach flow already has.
|
||||
*
|
||||
@@ -61,7 +92,7 @@ export const buildLinkedIssue = (input: {
|
||||
kind: 'issue' | 'pull';
|
||||
author?: { login?: string; avatarUrl?: string } | null;
|
||||
linkedAt: number;
|
||||
}): LinkedIssue => {
|
||||
}): LinkedGitHubIssue => {
|
||||
const match = /github\.com\/([^/]+)\/([^/]+)\//.exec(input.url);
|
||||
const id = match
|
||||
? buildLinkedIssueId(match[1], match[2], input.number)
|
||||
@@ -79,6 +110,35 @@ export const buildLinkedIssue = (input: {
|
||||
};
|
||||
};
|
||||
|
||||
export const buildLinkedLinearIssue = (input: {
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
author?: { login?: string; avatarUrl?: string } | null;
|
||||
linkedAt: number;
|
||||
}): LinkedLinearIssue => ({
|
||||
id: buildLinkedLinearIssueId(input.identifier),
|
||||
identifier: input.identifier,
|
||||
title: input.title,
|
||||
url: input.url,
|
||||
kind: 'linear',
|
||||
author: input.author?.login ?? undefined,
|
||||
authorAvatarUrl: input.author?.avatarUrl ?? undefined,
|
||||
linkedAt: input.linkedAt,
|
||||
});
|
||||
|
||||
export const canOpenLinearIssueInContextPanel = (options: {
|
||||
linearAvailable: boolean;
|
||||
linearConnected: boolean;
|
||||
inDedicatedMobileShell: boolean;
|
||||
directory: string | null | undefined;
|
||||
}): boolean => (
|
||||
options.linearAvailable
|
||||
&& options.linearConnected
|
||||
&& !options.inDedicatedMobileShell
|
||||
&& Boolean(options.directory?.trim())
|
||||
);
|
||||
|
||||
export const getLinkedIssues = (session: Session | null | undefined): LinkedIssue[] => {
|
||||
const openchamber = getSessionMetadata(session).openchamber;
|
||||
if (!isRecord(openchamber) || !Array.isArray(openchamber.linked_issues)) return [];
|
||||
|
||||
@@ -13,6 +13,8 @@ export type MagicPromptId =
|
||||
| 'github.pr.review.instructions'
|
||||
| 'github.issue.review.visible'
|
||||
| 'github.issue.review.instructions'
|
||||
| 'linear.issue.review.visible'
|
||||
| 'linear.issue.review.instructions'
|
||||
| 'github.pr.checks.review.visible'
|
||||
| 'github.pr.checks.review.instructions'
|
||||
| 'github.pr.comments.review.visible'
|
||||
@@ -56,7 +58,7 @@ export interface MagicPromptDefinition {
|
||||
id: MagicPromptId;
|
||||
title: string;
|
||||
description: string;
|
||||
group: 'Git' | 'GitHub' | 'Planning' | 'Session';
|
||||
group: 'Git' | 'GitHub' | 'Linear' | 'Planning' | 'Session';
|
||||
template: string;
|
||||
placeholders?: Array<{ key: string; description: string }>;
|
||||
}
|
||||
@@ -261,6 +263,61 @@ Question/Support:
|
||||
- Answer/guidance (max 6 lines)
|
||||
- Missing info (max 4)
|
||||
|
||||
Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`,
|
||||
},
|
||||
{
|
||||
id: 'linear.issue.review.visible',
|
||||
title: 'Linear Issue Review Visible Prompt',
|
||||
group: 'Linear',
|
||||
description: 'Visible user message when creating a session from a Linear issue.',
|
||||
placeholders: [
|
||||
{ key: 'identifier', description: 'Linear issue identifier, such as ENG-12.' },
|
||||
],
|
||||
template: 'Review this Linear issue {{identifier}} using the provided issue context',
|
||||
},
|
||||
{
|
||||
id: 'linear.issue.review.instructions',
|
||||
title: 'Linear Issue Review Instructions',
|
||||
group: 'Linear',
|
||||
description: 'Hidden instructions attached when generating a Linear issue review response.',
|
||||
template: `Review this Linear issue using the provided issue context.
|
||||
|
||||
Process:
|
||||
- First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: <one label>.
|
||||
- Gather any needed repository context (code, config, docs) to validate assumptions.
|
||||
- After gathering, if anything is still unclear or cannot be verified, do not speculate — state what's missing and ask targeted questions.
|
||||
|
||||
Mode selection by type:
|
||||
- Bug / Question/Support / Ops: deliver the response directly using the matching template below. Do not bombard me with questions for straightforward diagnosis; use "Missing info" / "Repro/diagnostics needed" fields instead.
|
||||
- Feature request / Refactor with substantive unknowns: this is effectively a planning session. Do not emit the Feature template on the first turn. Instead, ask me focused clarifying questions in batches of at most 3, one topic at a time (scope, constraints, tradeoffs, UX, etc.), wait for answers, drop questions that became irrelevant, and repeat until you have no more substantive questions. Only then emit the Feature template.
|
||||
|
||||
Output rules:
|
||||
- Compact output; pick ONE template below and omit the others.
|
||||
- No emojis. No code snippets. No fenced blocks.
|
||||
- Short inline code identifiers allowed.
|
||||
- Reference evidence with file paths and line ranges when applicable; if exact lines are not available, cite the file and say "approx" + why.
|
||||
- Keep the entire response under ~300 words (applies to the final template output, not to clarifying-question turns).
|
||||
|
||||
Templates (choose one):
|
||||
Bug:
|
||||
- Summary (1-2 sentences)
|
||||
- Likely cause (max 2)
|
||||
- Repro/diagnostics needed (max 3)
|
||||
- Fix approach (max 4 steps)
|
||||
- Verification (max 3)
|
||||
|
||||
Feature:
|
||||
- Summary (1-2 sentences)
|
||||
- Requirements (max 4)
|
||||
- Unknowns/questions (max 4)
|
||||
- Proposed plan (max 5 steps)
|
||||
- Verification (max 3)
|
||||
|
||||
Question/Support:
|
||||
- Summary (1-2 sentences)
|
||||
- Answer/guidance (max 6 lines)
|
||||
- Missing info (max 4)
|
||||
|
||||
Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -114,6 +114,13 @@ describe('round-trip through part metadata', () => {
|
||||
expect(readContextPart(part)).toEqual(payload);
|
||||
});
|
||||
|
||||
test('linear references carry picker-built text and the identifier', () => {
|
||||
const payload: ContextPartPayload = { kind: 'linear-issue', identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12' };
|
||||
const part = asPart(payload, 'Linear issue context (JSON)\n{}');
|
||||
expect(part.text).toBe('Linear issue context (JSON)\n{}');
|
||||
expect(readContextPart(part)).toEqual(payload);
|
||||
});
|
||||
|
||||
test('non-text parts, missing metadata, and malformed payloads read as null', () => {
|
||||
expect(readContextPart({ type: 'file', metadata: {} })).toBeNull();
|
||||
expect(readContextPart({ type: 'text' })).toBeNull();
|
||||
|
||||
@@ -96,6 +96,13 @@ type GitHubPrContext = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
type LinearIssueContext = {
|
||||
kind: 'linear-issue';
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ContextPartPayload =
|
||||
| CodeCommentContext
|
||||
| TerminalContextPayload
|
||||
@@ -105,7 +112,8 @@ export type ContextPartPayload =
|
||||
| FileQuoteContext
|
||||
| ChatQuoteContext
|
||||
| GitHubIssueContext
|
||||
| GitHubPrContext;
|
||||
| GitHubPrContext
|
||||
| LinearIssueContext;
|
||||
|
||||
export type ContextPartMetadata = { [K in typeof CONTEXT_METADATA_KEY]: ContextPartPayload };
|
||||
|
||||
@@ -154,6 +162,7 @@ export function formatContextText(payload: ContextPartPayload): string {
|
||||
return `Attached failed GitHub PR check (${payload.label}):\n\`\`\`\n${payload.output}\n\`\`\`${payload.text ? `\n\n${payload.text}` : ''}`;
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
case 'linear-issue':
|
||||
// Linked issues/PRs carry server-fetched context text built by
|
||||
// their pickers; there is no default text to derive here.
|
||||
return '';
|
||||
@@ -162,8 +171,9 @@ export function formatContextText(payload: ContextPartPayload): string {
|
||||
|
||||
/**
|
||||
* Build the synthetic part for one context payload. `text` overrides the
|
||||
* derived text; github-issue/github-pr payloads require it because their
|
||||
* model-facing context is fetched by the picker, not derived from metadata.
|
||||
* derived text; github-issue/github-pr/linear-issue payloads require it
|
||||
* because their model-facing context is fetched by the picker, not derived
|
||||
* from metadata.
|
||||
*/
|
||||
export function createContextPart(payload: ContextPartPayload, text?: string): ContextPart {
|
||||
const resolvedText = text ?? formatContextText(payload);
|
||||
@@ -297,6 +307,12 @@ const contextPayloadSchema = z.discriminatedUnion('kind', [
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('linear-issue'),
|
||||
identifier: z.string().min(1),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** The subset of a message part that context read-back inspects. */
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
import { openSessionFromRoute } from './openSessionFromRoute';
|
||||
|
||||
const SESSION_ID = 'ses_linear_open';
|
||||
const PROJECT_DIR = '/projects/linear-from-url';
|
||||
const OTHER_DIR = '/projects/linear-from-url-other';
|
||||
|
||||
const buildSession = (id: string, directory: string): Session => ({
|
||||
id,
|
||||
title: id,
|
||||
directory,
|
||||
time: { created: 1, updated: 2 },
|
||||
} as Session);
|
||||
|
||||
describe('openSessionFromRoute', () => {
|
||||
beforeEach(() => {
|
||||
useSessionUIStore.getState().setCurrentSession(null);
|
||||
useGlobalSessionsStore.setState({
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
sessionsByDirectory: new Map(),
|
||||
hasLoaded: true,
|
||||
status: 'ready',
|
||||
});
|
||||
});
|
||||
|
||||
test('selects the routed session once the global list knows its directory', async () => {
|
||||
useGlobalSessionsStore.setState({
|
||||
activeSessions: [buildSession(SESSION_ID, PROJECT_DIR)],
|
||||
archivedSessions: [],
|
||||
hasLoaded: true,
|
||||
status: 'ready',
|
||||
});
|
||||
|
||||
await openSessionFromRoute(SESSION_ID);
|
||||
|
||||
expect(useSessionUIStore.getState().currentSessionId).toBe(SESSION_ID);
|
||||
expect(useSessionUIStore.getState().currentSessionDirectory).toBe(PROJECT_DIR);
|
||||
});
|
||||
|
||||
test('replaces a guessed directory once the global list knows the owner', async () => {
|
||||
const id = 'ses_linear_guessed';
|
||||
useSessionUIStore.getState().setCurrentSession(id);
|
||||
const guessed = useSessionUIStore.getState().currentSessionDirectory;
|
||||
|
||||
useGlobalSessionsStore.setState({
|
||||
activeSessions: [buildSession(id, OTHER_DIR)],
|
||||
archivedSessions: [],
|
||||
hasLoaded: true,
|
||||
status: 'ready',
|
||||
});
|
||||
|
||||
await openSessionFromRoute(id);
|
||||
|
||||
expect(useSessionUIStore.getState().currentSessionId).toBe(id);
|
||||
expect(useSessionUIStore.getState().currentSessionDirectory).toBe(OTHER_DIR);
|
||||
expect(guessed).not.toBe(OTHER_DIR);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ensureGlobalSessionsLoaded, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
/**
|
||||
* Select a session named by `/?session=`. Cold loads often do not know the
|
||||
* owning directory yet, so a first selection may guess the active project.
|
||||
* After the global session list is available, re-select with that directory
|
||||
* unless the user already moved to a different session.
|
||||
*/
|
||||
export async function openSessionFromRoute(sessionId: string): Promise<void> {
|
||||
const id = sessionId.trim();
|
||||
if (!id) return;
|
||||
|
||||
const initial = useSessionUIStore.getState();
|
||||
if (initial.currentSessionId !== id) {
|
||||
initial.setCurrentSession(id, initial.getDirectoryForSession(id));
|
||||
}
|
||||
|
||||
const snapshot = await ensureGlobalSessionsLoaded().catch(() => null);
|
||||
if (!snapshot) return;
|
||||
|
||||
const latest = useSessionUIStore.getState();
|
||||
if (latest.currentSessionId !== id) return;
|
||||
|
||||
const session = [...snapshot.activeSessions, ...snapshot.archivedSessions]
|
||||
.find((entry) => entry.id === id);
|
||||
if (!session) return;
|
||||
|
||||
const directory = resolveGlobalSessionDirectory(session);
|
||||
if (!directory || directory === latest.currentSessionDirectory) return;
|
||||
|
||||
latest.setCurrentSession(id, directory);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { parseRoute } from './parseRoute';
|
||||
|
||||
describe('parseRoute session', () => {
|
||||
test('reads a session id including OpenCode underscores', () => {
|
||||
const route = parseRoute(new URLSearchParams('session=ses_abc123'));
|
||||
expect(route.sessionId).toBe('ses_abc123');
|
||||
});
|
||||
|
||||
test('decodes a percent-encoded session id', () => {
|
||||
const route = parseRoute(new URLSearchParams('session=ses%5Fabc123'));
|
||||
expect(route.sessionId).toBe('ses_abc123');
|
||||
});
|
||||
|
||||
test('ignores a blank session param', () => {
|
||||
const route = parseRoute(new URLSearchParams('session='));
|
||||
expect(route.sessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -202,7 +202,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
|
||||
{ slug: 'voice', title: 'Voice', group: 'general', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
|
||||
{ slug: 'tunnel', title: 'External Tunnel', group: 'projects', kind: 'single', keywords: ['tunnel', 'external', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode },
|
||||
{ slug: 'about', title: 'About', group: 'general', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile && !ctx.isVSCode },
|
||||
{ slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger'] },
|
||||
{ slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger', 'linear'] },
|
||||
] as const;
|
||||
|
||||
const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {
|
||||
|
||||
@@ -38,4 +38,30 @@ describe('settings search', () => {
|
||||
|
||||
expect(results.some((result) => result.id === 'integrations.third-party.opencode-cursor-oauth')).toBe(true);
|
||||
});
|
||||
|
||||
test('finds Linear connect on the integrations page', () => {
|
||||
const results = buildSettingsSearchResults({
|
||||
query: 'linear',
|
||||
runtimeCtx,
|
||||
t,
|
||||
getPageTitle: (page) => page,
|
||||
});
|
||||
|
||||
expect(results.some((result) => result.id === 'integrations.linear')).toBe(true);
|
||||
expect(results.some((result) => result.id === 'integrations.linear.add-workspace')).toBe(true);
|
||||
expect(results.some((result) => result.id === 'integrations.linear.mapping')).toBe(true);
|
||||
});
|
||||
|
||||
test('hides Linear connect in VS Code', () => {
|
||||
const results = buildSettingsSearchResults({
|
||||
query: 'linear',
|
||||
runtimeCtx: { ...runtimeCtx, isVSCode: true },
|
||||
t,
|
||||
getPageTitle: (page) => page,
|
||||
});
|
||||
|
||||
expect(results.some((result) => result.id === 'integrations.linear')).toBe(false);
|
||||
expect(results.some((result) => result.id === 'integrations.linear.add-workspace')).toBe(false);
|
||||
expect(results.some((result) => result.id === 'integrations.linear.mapping')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -984,6 +984,38 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
isAvailable: (ctx) => ctx.isWeb && !ctx.isDesktop && !ctx.isVSCode,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'integrations.first-party',
|
||||
page: 'integrations',
|
||||
titleKey: 'settings.integrations.firstParty.title',
|
||||
descriptionKey: 'settings.integrations.firstParty.info',
|
||||
keywords: ['built-in', 'first-party', 'native', 'linear'],
|
||||
isAvailable: (ctx) => !ctx.isVSCode,
|
||||
},
|
||||
{
|
||||
id: 'integrations.linear',
|
||||
page: 'integrations',
|
||||
titleKey: 'settings.integrations.linear.title',
|
||||
descriptionKey: 'settings.integrations.linear.description',
|
||||
keywords: ['linear', 'issues', 'oauth', 'connect', 'workspace'],
|
||||
isAvailable: (ctx) => !ctx.isVSCode,
|
||||
},
|
||||
{
|
||||
id: 'integrations.linear.add-workspace',
|
||||
page: 'integrations',
|
||||
titleKey: 'settings.integrations.linear.actions.addWorkspace',
|
||||
descriptionKey: 'settings.integrations.linear.description',
|
||||
keywords: ['linear', 'workspace', 'add', 'connect', 'oauth'],
|
||||
isAvailable: (ctx) => !ctx.isVSCode,
|
||||
},
|
||||
{
|
||||
id: 'integrations.linear.mapping',
|
||||
page: 'integrations',
|
||||
titleKey: 'settings.integrations.linear.mapping.defaultProject',
|
||||
descriptionKey: 'settings.integrations.linear.mapping.defaultProject.info',
|
||||
keywords: ['linear', 'project', 'team', 'map', 'workspace', 'directory'],
|
||||
isAvailable: (ctx) => !ctx.isVSCode,
|
||||
},
|
||||
{
|
||||
id: 'integrations.third-party',
|
||||
page: 'integrations',
|
||||
|
||||
@@ -26,10 +26,10 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by
|
||||
(`useUIStore.contextRailHiddenSurfaces`, edited from the rail's trailing
|
||||
configure button — `ContextRailSurfacesDialog`), drops the plan surface
|
||||
unless plan mode is enabled,
|
||||
drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, and hides
|
||||
`has-content` surfaces until a tab of their mode exists. Both consumers use
|
||||
it so the digit shown on a rail badge always maps to the same surface the
|
||||
shortcut opens.
|
||||
drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, hides
|
||||
Linear unless a workspace is connected, and hides `has-content` surfaces
|
||||
until a tab of their mode exists. Both consumers use it so the digit shown
|
||||
on a rail badge always maps to the same surface the shortcut opens.
|
||||
|
||||
## Adding a surface
|
||||
|
||||
@@ -53,7 +53,22 @@ the `openContext*` actions in `useUIStore`.
|
||||
positions). Chat tab records stay open, but only the active chat iframe is
|
||||
mounted while the panel is open. A selected chat restores its state from
|
||||
the session stores. A closed panel mounts no chat iframe.
|
||||
Singleton surfaces (git, pr, notes, plan, context) remount on switch. These
|
||||
Singleton surfaces (git, pr, linear, notes, plan, context) remount on switch. These
|
||||
surfaces must restore their state from stores or snapshots.
|
||||
- Runtime scope: desktop/web `MainLayout` only. VS Code and the dedicated
|
||||
mobile shell have their own layouts and do not consume this registry.
|
||||
Linear is a desktop/web singleton on this rail. VS Code and mobile omit it
|
||||
(no this registry, and VS Code has no `RuntimeAPIs.linear`). The Linear
|
||||
rail icon is hidden until a Linear workspace is connected. A persisted Linear
|
||||
tab stays open across reload until auth has resolved; only a confirmed
|
||||
disconnect closes the panel. The surface lists
|
||||
issues with status (All, Backlog, To Do, In Progress, In Review, Done, Canceled, Duplicate), assignee, team, and priority filters, can switch
|
||||
the current workspace, and keeps Start session in a footer on the issue card.
|
||||
Those filters restore from `useUIStore` when the surface remounts. Non-default
|
||||
status, assignee, team, priority, and search tint the filter icon `text-primary`,
|
||||
same as the context rail; one control clears them. Workspace switch is not a
|
||||
filter. Work-status Context sources
|
||||
can open a specific issue here through `linearIssueFocus`. Below 520px
|
||||
search and the filters other than status drop to icons; status keeps its label. The card
|
||||
shows priority and labels. Changing filters keeps the previous list
|
||||
until the next page arrives.
|
||||
|
||||
@@ -12,6 +12,7 @@ const baseOptions = {
|
||||
isVSCode: false,
|
||||
screenWidth: 1200,
|
||||
tabs: [],
|
||||
linearConnected: true,
|
||||
} as const;
|
||||
|
||||
describe('getVisibleContextRailSurfaces', () => {
|
||||
@@ -63,4 +64,17 @@ describe('getVisibleContextRailSurfaces', () => {
|
||||
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, railOrder: ['git', 'context'] });
|
||||
expect(surfaces.slice(0, 2).map((surface) => surface.id)).toEqual(['git', 'context']);
|
||||
});
|
||||
|
||||
test('places Linear after Pull Request in the default order', () => {
|
||||
const ids = getVisibleContextRailSurfaces(baseOptions).map((surface) => surface.id);
|
||||
const pr = ids.indexOf('pr');
|
||||
const linear = ids.indexOf('linear');
|
||||
expect(pr).toBeGreaterThanOrEqual(0);
|
||||
expect(linear).toBe(pr + 1);
|
||||
});
|
||||
|
||||
test('hides Linear until a workspace is connected', () => {
|
||||
expect(getVisibleContextRailSurfaces({ ...baseOptions, linearConnected: false }).some((s) => s.id === 'linear')).toBe(false);
|
||||
expect(getVisibleContextRailSurfaces({ ...baseOptions, linearConnected: true }).some((s) => s.id === 'linear')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ContextSurfaceId =
|
||||
| 'editor'
|
||||
| 'git'
|
||||
| 'pr'
|
||||
| 'linear'
|
||||
| 'diff'
|
||||
| 'walkthrough'
|
||||
| 'terminal'
|
||||
@@ -65,6 +66,15 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
|
||||
labelKey: 'contextPanel.mode.pr',
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
id: 'linear',
|
||||
descriptionKey: 'contextRail.surface.linear.description',
|
||||
defaultWidthFraction: 0.45,
|
||||
mode: 'linear',
|
||||
icon: 'linear',
|
||||
labelKey: 'contextPanel.mode.linear',
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
id: 'diff',
|
||||
descriptionKey: 'contextRail.surface.diff.description',
|
||||
@@ -194,6 +204,8 @@ type VisibleRailSurfacesOptions = {
|
||||
isVSCode: boolean;
|
||||
screenWidth: number;
|
||||
tabs: readonly { mode: ContextPanelMode }[];
|
||||
/** Linear's rail icon stays off until a workspace is connected. */
|
||||
linearConnected: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -225,6 +237,9 @@ export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOption
|
||||
if (surface.id === 'browser' && options.isVSCode) {
|
||||
return false;
|
||||
}
|
||||
if (surface.id === 'linear' && !options.linearConnected) {
|
||||
return false;
|
||||
}
|
||||
if (surface.availability === 'has-content') {
|
||||
return options.tabs.some((tab) => tab.mode === surface.mode);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ Examples:
|
||||
- `useFeatureFlagsStore.ts`
|
||||
- `useUpdateStore.ts`
|
||||
|
||||
These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection.
|
||||
These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted.
|
||||
|
||||
Context-panel session chats mount only the active chat iframe. After installing
|
||||
its message listener, the iframe requests its authoritative visibility from the
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { create } from 'zustand';
|
||||
import type { LinearAuthStatus, RuntimeAPIs } from '@/lib/api/types';
|
||||
|
||||
type LinearAuthStatusWithError = LinearAuthStatus & { error?: string };
|
||||
|
||||
type LinearAuthStore = {
|
||||
status: LinearAuthStatusWithError | null;
|
||||
isLoading: boolean;
|
||||
hasChecked: boolean;
|
||||
setStatus: (status: LinearAuthStatusWithError | null) => void;
|
||||
refreshStatus: (
|
||||
runtimeLinear?: RuntimeAPIs['linear'],
|
||||
options?: { force?: boolean }
|
||||
) => Promise<LinearAuthStatusWithError | null>;
|
||||
};
|
||||
|
||||
const fetchStatus = async (
|
||||
runtimeLinear?: RuntimeAPIs['linear']
|
||||
): Promise<LinearAuthStatusWithError> => {
|
||||
if (!runtimeLinear) {
|
||||
return { connected: false };
|
||||
}
|
||||
return runtimeLinear.authStatus();
|
||||
};
|
||||
|
||||
let inFlightAuthRefresh: Promise<LinearAuthStatusWithError | null> | null = null;
|
||||
|
||||
export const useLinearAuthStore = create<LinearAuthStore>((set, get) => ({
|
||||
status: null,
|
||||
isLoading: false,
|
||||
hasChecked: false,
|
||||
setStatus: (status) => set({ status, hasChecked: true }),
|
||||
refreshStatus: async (runtimeLinear, options) => {
|
||||
if (!runtimeLinear) {
|
||||
return get().status;
|
||||
}
|
||||
const { hasChecked, status } = get();
|
||||
if (hasChecked && !options?.force) {
|
||||
return status;
|
||||
}
|
||||
|
||||
if (inFlightAuthRefresh) return inFlightAuthRefresh;
|
||||
|
||||
set({ isLoading: true });
|
||||
inFlightAuthRefresh = (async () => {
|
||||
try {
|
||||
const payload = await fetchStatus(runtimeLinear);
|
||||
set({ status: payload, isLoading: false, hasChecked: true });
|
||||
return payload;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// A failed request is not an authoritative disconnect. Keep the last
|
||||
// known status and leave `hasChecked` false so the next caller retries
|
||||
// instead of hiding Linear for the rest of the session.
|
||||
set((state) => ({
|
||||
status: state.status
|
||||
? { ...state.status, error: message }
|
||||
: { connected: false, error: message },
|
||||
isLoading: false,
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
})().finally(() => { inFlightAuthRefresh = null; });
|
||||
|
||||
return inFlightAuthRefresh;
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } from './useUIStore';
|
||||
|
||||
describe('linear issue list filters', () => {
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({
|
||||
linearIssueListStatus: 'all',
|
||||
linearIssueListAssignee: 'any',
|
||||
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
|
||||
linearIssueListPriority: 'all',
|
||||
linearIssueFocus: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('stores status, assignee, team, and priority across setter calls', () => {
|
||||
useUIStore.getState().setLinearIssueListStatus('todo');
|
||||
expect(useUIStore.getState().linearIssueListStatus).toBe('todo');
|
||||
useUIStore.getState().setLinearIssueListStatus('started');
|
||||
expect(useUIStore.getState().linearIssueListStatus).toBe('started');
|
||||
useUIStore.getState().setLinearIssueListStatus('inReview');
|
||||
expect(useUIStore.getState().linearIssueListStatus).toBe('inReview');
|
||||
useUIStore.getState().setLinearIssueListStatus('completed');
|
||||
expect(useUIStore.getState().linearIssueListStatus).toBe('completed');
|
||||
useUIStore.getState().setLinearIssueListStatus('canceled');
|
||||
expect(useUIStore.getState().linearIssueListStatus).toBe('canceled');
|
||||
useUIStore.getState().setLinearIssueListStatus('duplicate');
|
||||
expect(useUIStore.getState().linearIssueListStatus).toBe('duplicate');
|
||||
useUIStore.getState().setLinearIssueListStatus('backlog');
|
||||
expect(useUIStore.getState().linearIssueListStatus).toBe('backlog');
|
||||
useUIStore.getState().setLinearIssueListStatus('all');
|
||||
useUIStore.getState().setLinearIssueListAssignee('me');
|
||||
useUIStore.getState().setLinearIssueListTeamId('team-eng');
|
||||
useUIStore.getState().setLinearIssueListPriority('urgent');
|
||||
|
||||
expect(useUIStore.getState().linearIssueListStatus).toBe('all');
|
||||
expect(useUIStore.getState().linearIssueListAssignee).toBe('me');
|
||||
expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng');
|
||||
expect(useUIStore.getState().linearIssueListPriority).toBe('urgent');
|
||||
});
|
||||
|
||||
test('resets status, assignee, team, and priority together', () => {
|
||||
useUIStore.getState().setLinearIssueListStatus('todo');
|
||||
useUIStore.getState().setLinearIssueListAssignee('me');
|
||||
useUIStore.getState().setLinearIssueListTeamId('team-eng');
|
||||
useUIStore.getState().setLinearIssueListPriority('urgent');
|
||||
|
||||
useUIStore.getState().resetLinearIssueListFilters();
|
||||
|
||||
expect(useUIStore.getState().linearIssueListStatus).toBe('all');
|
||||
expect(useUIStore.getState().linearIssueListAssignee).toBe('any');
|
||||
expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS);
|
||||
expect(useUIStore.getState().linearIssueListPriority).toBe('all');
|
||||
});
|
||||
|
||||
test('treats a blank team id as all teams', () => {
|
||||
useUIStore.getState().setLinearIssueListTeamId('team-eng');
|
||||
useUIStore.getState().setLinearIssueListTeamId(' ');
|
||||
expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS);
|
||||
});
|
||||
|
||||
test('stores a one-shot Linear issue identifier for the rail panel', () => {
|
||||
useUIStore.getState().setLinearIssueFocus(' ENG-12 ');
|
||||
expect(useUIStore.getState().linearIssueFocus).toBe('ENG-12');
|
||||
useUIStore.getState().setLinearIssueFocus(' ');
|
||||
expect(useUIStore.getState().linearIssueFocus).toBeNull();
|
||||
useUIStore.getState().setLinearIssueFocus('ENG-12');
|
||||
useUIStore.getState().setLinearIssueFocus(null);
|
||||
expect(useUIStore.getState().linearIssueFocus).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,14 @@ import type { ShortcutCombo } from '@/lib/shortcuts';
|
||||
import type { DraftStarterRef } from '@/lib/draftStarters';
|
||||
import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions';
|
||||
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import type { TerminalShell } from '@/lib/api/types';
|
||||
import type { LinearIssueListAssignee, LinearIssueListPriority, LinearIssueListStatus, TerminalShell } from '@/lib/api/types';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
|
||||
import { isWindowsArm64 } from '@/lib/platform';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
|
||||
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch';
|
||||
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
|
||||
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal';
|
||||
export type MermaidRenderingMode = 'svg' | 'ascii';
|
||||
export type UserMessageRenderingMode = 'markdown' | 'plain';
|
||||
export type ChatRenderMode = 'sorted' | 'live';
|
||||
@@ -40,6 +40,37 @@ function normalizeFileEditorKeymap(value: unknown): FileEditorKeymap {
|
||||
return value === 'vim' ? 'vim' : 'default';
|
||||
}
|
||||
|
||||
export const LINEAR_ISSUE_LIST_ALL_TEAMS = 'all';
|
||||
|
||||
function sanitizeLinearIssueListStatus(value: unknown): LinearIssueListStatus {
|
||||
return value === 'all'
|
||||
|| value === 'backlog'
|
||||
|| value === 'todo'
|
||||
|| value === 'started'
|
||||
|| value === 'inReview'
|
||||
|| value === 'completed'
|
||||
|| value === 'canceled'
|
||||
|| value === 'duplicate'
|
||||
? value
|
||||
: 'all';
|
||||
}
|
||||
|
||||
function sanitizeLinearIssueListAssignee(value: unknown): LinearIssueListAssignee {
|
||||
return value === 'me' || value === 'any' ? value : 'any';
|
||||
}
|
||||
|
||||
function sanitizeLinearIssueListTeamId(value: unknown): string {
|
||||
if (typeof value !== 'string') return LINEAR_ISSUE_LIST_ALL_TEAMS;
|
||||
const teamId = value.trim();
|
||||
return teamId || LINEAR_ISSUE_LIST_ALL_TEAMS;
|
||||
}
|
||||
|
||||
function sanitizeLinearIssueListPriority(value: unknown): LinearIssueListPriority {
|
||||
return value === 'none' || value === 'urgent' || value === 'high' || value === 'medium' || value === 'low' || value === 'all'
|
||||
? value
|
||||
: 'all';
|
||||
}
|
||||
|
||||
type ContextPanelTab = {
|
||||
id: string;
|
||||
mode: ContextPanelMode;
|
||||
@@ -342,7 +373,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
// Legacy 'preview' tabs are converted to 'browser' by the v14 migration;
|
||||
// anything still carrying an unknown mode here is discarded rather than
|
||||
// resurrected into a tab the panel cannot render.
|
||||
if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') {
|
||||
if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'linear' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -618,7 +649,7 @@ const sanitizeContextPanelByDirectory = (
|
||||
if (candidate.widthByMode && typeof candidate.widthByMode === 'object') {
|
||||
for (const [mode, value] of Object.entries(candidate.widthByMode as Record<string, unknown>)) {
|
||||
if (
|
||||
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'notes' || mode === 'terminal')
|
||||
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'linear' || mode === 'notes' || mode === 'terminal')
|
||||
&& typeof value === 'number'
|
||||
&& Number.isFinite(value)
|
||||
) {
|
||||
@@ -787,6 +818,12 @@ interface UIStore {
|
||||
/** Width of the walkthrough table of contents, in pixels. */
|
||||
walkthroughTocWidth: number;
|
||||
gitChangesViewMode: 'flat' | 'tree';
|
||||
linearIssueListStatus: LinearIssueListStatus;
|
||||
linearIssueListAssignee: LinearIssueListAssignee;
|
||||
linearIssueListTeamId: string;
|
||||
linearIssueListPriority: LinearIssueListPriority;
|
||||
/** One-shot identifier for opening a Linear issue in the rail panel. Not persisted. */
|
||||
linearIssueFocus: string | null;
|
||||
isTimelineDialogOpen: boolean;
|
||||
isPromptNavigatorPanelOpen: boolean;
|
||||
isImagePreviewOpen: boolean;
|
||||
@@ -983,6 +1020,12 @@ interface UIStore {
|
||||
setDiffWrapLines: (wrap: boolean) => void;
|
||||
setWalkthroughTocWidth: (width: number) => void;
|
||||
setGitChangesViewMode: (mode: 'flat' | 'tree') => void;
|
||||
setLinearIssueListStatus: (status: LinearIssueListStatus) => void;
|
||||
setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void;
|
||||
setLinearIssueListTeamId: (teamId: string) => void;
|
||||
setLinearIssueListPriority: (priority: LinearIssueListPriority) => void;
|
||||
resetLinearIssueListFilters: () => void;
|
||||
setLinearIssueFocus: (identifier: string | null) => void;
|
||||
setMultiRunLauncherOpen: (open: boolean) => void;
|
||||
setTimelineDialogOpen: (open: boolean) => void;
|
||||
setPromptNavigatorPanelOpen: (open: boolean) => void;
|
||||
@@ -1140,6 +1183,11 @@ export const useUIStore = create<UIStore>()(
|
||||
diffWrapLines: false,
|
||||
walkthroughTocWidth: 224,
|
||||
gitChangesViewMode: 'flat',
|
||||
linearIssueListStatus: 'all',
|
||||
linearIssueListAssignee: 'any',
|
||||
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
|
||||
linearIssueListPriority: 'all',
|
||||
linearIssueFocus: null,
|
||||
isTimelineDialogOpen: false,
|
||||
isPromptNavigatorPanelOpen: false,
|
||||
isImagePreviewOpen: false,
|
||||
@@ -2055,7 +2103,37 @@ export const useUIStore = create<UIStore>()(
|
||||
setGitChangesViewMode: (mode) => {
|
||||
set({ gitChangesViewMode: mode });
|
||||
},
|
||||
|
||||
|
||||
setLinearIssueListStatus: (status) => {
|
||||
set({ linearIssueListStatus: sanitizeLinearIssueListStatus(status) });
|
||||
},
|
||||
|
||||
setLinearIssueListAssignee: (assignee) => {
|
||||
set({ linearIssueListAssignee: sanitizeLinearIssueListAssignee(assignee) });
|
||||
},
|
||||
|
||||
setLinearIssueListTeamId: (teamId) => {
|
||||
set({ linearIssueListTeamId: sanitizeLinearIssueListTeamId(teamId) });
|
||||
},
|
||||
|
||||
setLinearIssueListPriority: (priority) => {
|
||||
set({ linearIssueListPriority: sanitizeLinearIssueListPriority(priority) });
|
||||
},
|
||||
|
||||
resetLinearIssueListFilters: () => {
|
||||
set({
|
||||
linearIssueListStatus: 'all',
|
||||
linearIssueListAssignee: 'any',
|
||||
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
|
||||
linearIssueListPriority: 'all',
|
||||
});
|
||||
},
|
||||
|
||||
setLinearIssueFocus: (identifier) => {
|
||||
const trimmed = identifier?.trim() ?? '';
|
||||
set({ linearIssueFocus: trimmed || null });
|
||||
},
|
||||
|
||||
setInputBarOffset: (offset) => {
|
||||
set({ inputBarOffset: offset });
|
||||
},
|
||||
@@ -2712,6 +2790,11 @@ export const useUIStore = create<UIStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
state.linearIssueListStatus = sanitizeLinearIssueListStatus(state.linearIssueListStatus);
|
||||
state.linearIssueListAssignee = sanitizeLinearIssueListAssignee(state.linearIssueListAssignee);
|
||||
state.linearIssueListTeamId = sanitizeLinearIssueListTeamId(state.linearIssueListTeamId);
|
||||
state.linearIssueListPriority = sanitizeLinearIssueListPriority(state.linearIssueListPriority);
|
||||
|
||||
state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap);
|
||||
state.largeTextPasteBehavior = normalizeLargeTextPasteBehavior(state.largeTextPasteBehavior);
|
||||
|
||||
@@ -2789,6 +2872,10 @@ export const useUIStore = create<UIStore>()(
|
||||
diffWrapLines: state.diffWrapLines,
|
||||
walkthroughTocWidth: state.walkthroughTocWidth,
|
||||
gitChangesViewMode: state.gitChangesViewMode,
|
||||
linearIssueListStatus: state.linearIssueListStatus,
|
||||
linearIssueListAssignee: state.linearIssueListAssignee,
|
||||
linearIssueListTeamId: state.linearIssueListTeamId,
|
||||
linearIssueListPriority: state.linearIssueListPriority,
|
||||
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
||||
notificationMode: state.notificationMode,
|
||||
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
|
||||
|
||||
Reference in New Issue
Block a user