Merge origin/main into deferred OpenCode restart branch.
Adopt main's providerAuth helpers (OAuth index preservation, OAuth-only API key hiding, always-load auth methods) while keeping deferred Apply & Restart for provider mutations. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
@@ -159,6 +159,7 @@ const MAX_MOBILE_COMPOSER_LINES = 16;
|
||||
*/
|
||||
const MOBILE_COMPOSER_BOUND_GAP_PX = 4;
|
||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
const EMPTY_SENDING_IDS: string[] = [];
|
||||
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
|
||||
const renameFileForAttachmentCitation = (file: File, filename: string): File => {
|
||||
if (file.name === filename) {
|
||||
@@ -945,9 +946,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
hasContent: options.presetText.trim().length > 0 || attachedFiles.length > 0 || hasDrafts,
|
||||
}
|
||||
: getCurrentInputSnapshot();
|
||||
const queuedMessagesToSend = queuedMessageId
|
||||
// A queued item stays in the queue until its own send resolves, so the
|
||||
// auto-send hook may already be delivering one of these. Merging it here
|
||||
// would send the same message twice (the window is seconds over a relay).
|
||||
const sendingIds = messageQueueTarget
|
||||
? useMessageQueueStore.getState().sendingIds[getMessageQueueKey(messageQueueTarget)] ?? EMPTY_SENDING_IDS
|
||||
: EMPTY_SENDING_IDS;
|
||||
const queuedMessagesToSend = (queuedMessageId
|
||||
? queuedMessages.filter((message) => message.id === queuedMessageId)
|
||||
: queuedMessages;
|
||||
: queuedMessages
|
||||
).filter((message) => !sendingIds.includes(message.id));
|
||||
|
||||
if (queuedOnly && autoReviewRunning) {
|
||||
return;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
|
||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||
|
||||
interface TextSelectionMenuProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
@@ -309,6 +310,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
// Clear selection
|
||||
window.getSelection()?.removeAllRanges();
|
||||
queueMicrotask(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
|
||||
|
||||
const handleCreateNewSession = React.useCallback(async () => {
|
||||
|
||||
@@ -43,6 +43,7 @@ import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
|
||||
import { isBrowserClientRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type FileNode = {
|
||||
@@ -190,6 +191,7 @@ interface FileRowProps {
|
||||
root: string;
|
||||
isExpanded: boolean;
|
||||
isActive: boolean;
|
||||
isBrowserClient: boolean;
|
||||
status?: FileStatus | null;
|
||||
badge?: { modified: number; added: number } | null;
|
||||
permissions: {
|
||||
@@ -211,6 +213,7 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
root,
|
||||
isExpanded,
|
||||
isActive,
|
||||
isBrowserClient,
|
||||
status,
|
||||
badge,
|
||||
permissions,
|
||||
@@ -223,6 +226,9 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
const { t } = useI18n();
|
||||
const isDir = node.type === 'directory';
|
||||
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions;
|
||||
const canDownload = !isDir && Boolean(downloadFile);
|
||||
const canRevealPath = canReveal && !isBrowserClient;
|
||||
const hasMenuActions = canRename || canCreateFile || canCreateFolder || canDelete || canDownload || canRevealPath;
|
||||
|
||||
// Menu open state is local to each row so opening a menu in one row
|
||||
// never re-renders its siblings. Previously this state lived on the
|
||||
@@ -231,10 +237,10 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
const [rightClickOpen, setRightClickOpen] = React.useState(false);
|
||||
|
||||
const handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
|
||||
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) return;
|
||||
if (!hasMenuActions) return;
|
||||
event?.preventDefault();
|
||||
setRightClickOpen(true);
|
||||
}, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal]);
|
||||
}, [hasMenuActions]);
|
||||
|
||||
const handleInteraction = React.useCallback(() => {
|
||||
if (isDir) {
|
||||
@@ -283,10 +289,10 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
toast.error(t('sidebarFilesTree.toast.operationFailed'));
|
||||
});
|
||||
}}>
|
||||
<Icon name="download" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.save')}
|
||||
<Icon name="download" className="mr-2 h-4 w-4" /> {t(isBrowserClient ? 'sidebarFilesTree.menu.download' : 'sidebarFilesTree.menu.save')}
|
||||
</Item>
|
||||
)}
|
||||
{canReveal && (
|
||||
{canRevealPath && (
|
||||
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRevealPath(node.path); }}>
|
||||
<Icon name="folder-received" className="mr-2 h-4 w-4" /> {t(getRevealLabelKey())}
|
||||
</Item>
|
||||
@@ -362,7 +368,7 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && (
|
||||
{hasMenuActions && (
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 focus-within:opacity-100 group-hover:opacity-100">
|
||||
<DropdownMenu
|
||||
open={contextMenuOpen}
|
||||
@@ -406,6 +412,7 @@ const areFileRowPropsEqual = (prev: FileRowProps, next: FileRowProps): boolean =
|
||||
&& prev.root === next.root
|
||||
&& prev.isExpanded === next.isExpanded
|
||||
&& prev.isActive === next.isActive
|
||||
&& prev.isBrowserClient === next.isBrowserClient
|
||||
&& prev.status === next.status
|
||||
&& prev.badge === next.badge
|
||||
&& prev.permissions === next.permissions
|
||||
@@ -422,7 +429,8 @@ const MemoizedFileRow = React.memo(FileRow, areFileRowPropsEqual);
|
||||
|
||||
export const SidebarFilesTree: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { files } = useRuntimeAPIs();
|
||||
const { files, runtime } = useRuntimeAPIs();
|
||||
const isBrowserClient = isBrowserClientRuntime(runtime.platform);
|
||||
const currentDirectory = useEffectiveDirectory() ?? '';
|
||||
const root = normalizePath(currentDirectory.trim());
|
||||
const showHidden = useDirectoryShowHidden();
|
||||
@@ -1045,6 +1053,7 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
root={root}
|
||||
isExpanded={isExpanded}
|
||||
isActive={isActive}
|
||||
isBrowserClient={isBrowserClient}
|
||||
status={!isDir ? getFileStatus(node.path) : undefined}
|
||||
badge={isDir ? getFolderBadge(node.path) : undefined}
|
||||
permissions={fileRowPermissions}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { shouldLoadAvailableProviders, shouldLoadProviderAuthMethods } from './providerAvailability';
|
||||
import { listOAuthMethods, normalizeAuthType } from './providerAuthMethods';
|
||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import {
|
||||
getOAuthAuthMethods,
|
||||
normalizeAuthType,
|
||||
parseAuthPayload,
|
||||
shouldShowApiKeyAuth,
|
||||
} from './providerAuth';
|
||||
|
||||
describe('ProvidersPage available provider loading', () => {
|
||||
test('loads available providers only in add-provider mode', () => {
|
||||
@@ -9,35 +14,47 @@ describe('ProvidersPage available provider loading', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProvidersPage auth method loading', () => {
|
||||
test('loads auth methods for add mode and reconnect panel', () => {
|
||||
expect(shouldLoadProviderAuthMethods(false, false)).toBe(false);
|
||||
expect(shouldLoadProviderAuthMethods(true, false)).toBe(true);
|
||||
expect(shouldLoadProviderAuthMethods(false, true)).toBe(true);
|
||||
expect(shouldLoadProviderAuthMethods(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProvidersPage OAuth method indexes', () => {
|
||||
test('preserves the original provider.auth() index after filtering', () => {
|
||||
const methods = listOAuthMethods([
|
||||
{ type: 'api' },
|
||||
{ type: 'oauth', label: 'Browser' },
|
||||
]);
|
||||
expect(methods).toEqual([{ method: { type: 'oauth', label: 'Browser' }, methodIndex: 1 }]);
|
||||
});
|
||||
|
||||
test('keeps multiple OAuth indexes relative to the full methods array', () => {
|
||||
const methods = listOAuthMethods([
|
||||
{ type: 'oauth', label: 'First' },
|
||||
{ type: 'api' },
|
||||
{ type: 'oauth', label: 'Second' },
|
||||
]);
|
||||
expect(methods.map((entry) => entry.methodIndex)).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
test('detects oauth from labels when type is missing', () => {
|
||||
expect(normalizeAuthType({ label: 'Sign in with OAuth' })).toBe('oauth');
|
||||
expect(normalizeAuthType({ name: 'API Key' })).toBe('api');
|
||||
describe('provider auth method helpers', () => {
|
||||
test('normalizeAuthType recognizes oauth and api labels', () => {
|
||||
expect(normalizeAuthType({ type: 'oauth', label: 'Login with Cursor' })).toBe('oauth');
|
||||
expect(normalizeAuthType({ type: 'api', label: 'API Key' })).toBe('api');
|
||||
expect(normalizeAuthType({ label: 'OAuth browser login' })).toBe('oauth');
|
||||
expect(normalizeAuthType({ name: 'API key' })).toBe('api');
|
||||
});
|
||||
|
||||
test('parseAuthPayload keeps only object auth method entries', () => {
|
||||
expect(parseAuthPayload({
|
||||
cursor: [{ type: 'oauth', label: 'Cursor' }, 'skip'],
|
||||
openai: null,
|
||||
})).toEqual({
|
||||
cursor: [{ type: 'oauth', label: 'Cursor' }],
|
||||
});
|
||||
expect(parseAuthPayload(null)).toEqual({});
|
||||
});
|
||||
|
||||
test('shouldShowApiKeyAuth hides API key for oauth-only providers', () => {
|
||||
expect(shouldShowApiKeyAuth([{ type: 'oauth', label: 'Cursor OAuth' }])).toBe(false);
|
||||
expect(shouldShowApiKeyAuth([
|
||||
{ type: 'api', label: 'API Key' },
|
||||
{ type: 'oauth', label: 'ChatGPT' },
|
||||
])).toBe(true);
|
||||
expect(shouldShowApiKeyAuth([{ type: 'api', label: 'API Key' }])).toBe(true);
|
||||
// Unknown / unloaded methods keep the legacy API key fallback.
|
||||
expect(shouldShowApiKeyAuth([])).toBe(true);
|
||||
});
|
||||
|
||||
test('getOAuthAuthMethods preserves original method indexes', () => {
|
||||
const methods = [
|
||||
{ type: 'api', label: 'API Key' },
|
||||
{ type: 'oauth', label: 'OAuth' },
|
||||
{ type: 'oauth', label: 'Device' },
|
||||
];
|
||||
expect(getOAuthAuthMethods(methods)).toEqual([
|
||||
{ method: methods[1], methodIndex: 1 },
|
||||
{ method: methods[2], methodIndex: 2 },
|
||||
]);
|
||||
expect(getOAuthAuthMethods([{ type: 'oauth', label: 'Cursor' }])).toEqual([
|
||||
{ method: { type: 'oauth', label: 'Cursor' }, methodIndex: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,8 +25,13 @@ import type { ModelMetadata } from '@/types';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { shouldLoadAvailableProviders, shouldLoadProviderAuthMethods } from './providerAvailability';
|
||||
import { listOAuthMethods } from './providerAuthMethods';
|
||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import {
|
||||
getOAuthAuthMethods,
|
||||
parseAuthPayload,
|
||||
shouldShowApiKeyAuth,
|
||||
type AuthMethod,
|
||||
} from './providerAuth';
|
||||
import { CustomProviderForm } from './CustomProviderForm';
|
||||
import {
|
||||
buildAuthSetRequest,
|
||||
@@ -60,16 +65,6 @@ const formatTokens = (value?: number | null) => {
|
||||
|
||||
const ADD_PROVIDER_ID = '__add_provider__';
|
||||
|
||||
interface AuthMethod {
|
||||
type?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
help?: string;
|
||||
method?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ProviderOption {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -90,19 +85,6 @@ interface ProviderSources {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const parseAuthPayload = (payload: unknown): Record<string, AuthMethod[]> => {
|
||||
if (!isRecord(payload)) {
|
||||
return {};
|
||||
}
|
||||
const result: Record<string, AuthMethod[]> = {};
|
||||
for (const [providerId, value] of Object.entries(payload)) {
|
||||
if (Array.isArray(value)) {
|
||||
result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeProviderEntry = (entry: unknown): ProviderOption | null => {
|
||||
if (typeof entry === 'string') {
|
||||
return { id: entry };
|
||||
@@ -182,7 +164,6 @@ export const ProvidersPage: React.FC = () => {
|
||||
const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState<string | null>(null);
|
||||
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null);
|
||||
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
|
||||
const loadAuthMethods = shouldLoadProviderAuthMethods(isAddMode, showAuthPanel);
|
||||
const isCustomCreateMode = isAddMode && candidateProviderId === CUSTOM_PROVIDER_ID;
|
||||
const isCustomEditMode = Boolean(
|
||||
editingCustomProviderId
|
||||
@@ -198,13 +179,16 @@ export const ProvidersPage: React.FC = () => {
|
||||
}, [providers, selectedProviderId, setSelectedProvider]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!loadAuthMethods) {
|
||||
// Auth methods drive which credential UI to show (API key vs OAuth). Keep
|
||||
// them loaded for the active provider view so OAuth-only plugins never fall
|
||||
// back to an API key form merely because methods were never fetched.
|
||||
if (!selectedProviderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const fetchAuthMethods = async () => {
|
||||
const loadAuthMethods = async () => {
|
||||
setAuthLoading(true);
|
||||
try {
|
||||
const result = await opencodeClient.getSdkClient().provider.auth();
|
||||
@@ -224,12 +208,12 @@ export const ProvidersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
void fetchAuthMethods();
|
||||
loadAuthMethods();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [loadAuthMethods, t]);
|
||||
}, [selectedProviderId, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldLoadAvailableProviders(isAddMode)) {
|
||||
@@ -316,6 +300,26 @@ export const ProvidersPage: React.FC = () => {
|
||||
}
|
||||
}, [selectedProviderId, editingCustomProviderId]);
|
||||
|
||||
// Unauthenticated providers (OAuth-only plugins before login) should open the
|
||||
// auth panel instead of a false "Connected" summary.
|
||||
React.useEffect(() => {
|
||||
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
|
||||
return;
|
||||
}
|
||||
const sources = providerSources[selectedProviderId];
|
||||
if (!sources) {
|
||||
return;
|
||||
}
|
||||
const provider = providers.find((entry) => entry.id === selectedProviderId);
|
||||
const envEntries = Array.isArray(provider?.env)
|
||||
? provider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
: [];
|
||||
const hasCreds = Boolean(sources.auth.exists) || envEntries.length > 0;
|
||||
if (!hasCreds) {
|
||||
setShowAuthPanel(true);
|
||||
}
|
||||
}, [selectedProviderId, providerSources, providers]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
|
||||
return;
|
||||
@@ -773,123 +777,126 @@ export const ProvidersPage: React.FC = () => {
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
||||
</label>
|
||||
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInputs[candidateProviderId] ?? ''}
|
||||
onChange={(event) =>
|
||||
setApiKeyInputs((prev) => ({
|
||||
...prev,
|
||||
[candidateProviderId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={() => handleSaveApiKey(candidateProviderId)}
|
||||
disabled={authBusyKey === `api:${candidateProviderId}`}
|
||||
>
|
||||
{authBusyKey === `api:${candidateProviderId}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
|
||||
const candidateOAuthMethods = listOAuthMethods(candidateAuthMethods);
|
||||
|
||||
if (candidateOAuthMethods.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const candidateOAuthMethods = getOAuthAuthMethods(candidateAuthMethods);
|
||||
const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
|
||||
{candidateOAuthMethods.map(({ method, methodIndex }) => {
|
||||
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
|
||||
const codeKey = `${candidateProviderId}:${methodIndex}`;
|
||||
const isPending =
|
||||
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex;
|
||||
<>
|
||||
{showApiKey ? (
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
||||
</label>
|
||||
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInputs[candidateProviderId] ?? ''}
|
||||
onChange={(event) =>
|
||||
setApiKeyInputs((prev) => ({
|
||||
...prev,
|
||||
[candidateProviderId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={() => handleSaveApiKey(candidateProviderId)}
|
||||
disabled={authBusyKey === `api:${candidateProviderId}`}
|
||||
>
|
||||
{authBusyKey === `api:${candidateProviderId}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
return (
|
||||
<div key={`${candidateProviderId}-${methodLabel}-${methodIndex}`} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
||||
{(method.description || method.help) && (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{String(method.description || method.help)}
|
||||
{candidateOAuthMethods.length > 0 ? (
|
||||
<div className={cn('space-y-4', showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')}>
|
||||
{candidateOAuthMethods.map(({ method, methodIndex }) => {
|
||||
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
|
||||
const codeKey = `${candidateProviderId}:${methodIndex}`;
|
||||
const isPending =
|
||||
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex;
|
||||
|
||||
return (
|
||||
<div key={`${candidateProviderId}-${methodIndex}-${methodLabel}`} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
||||
{(method.description || method.help) && (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{String(method.description || method.help)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthStart(candidateProviderId, methodIndex)}
|
||||
disabled={authBusyKey === `oauth:${candidateProviderId}:${methodIndex}`}
|
||||
>
|
||||
{t('settings.providers.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oauthDetails[codeKey]?.instructions && (
|
||||
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
|
||||
{oauthDetails[codeKey]?.instructions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.userCode && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
value={oauthCodes[codeKey] ?? ''}
|
||||
onChange={(event) =>
|
||||
setOauthCodes((prev) => ({
|
||||
...prev,
|
||||
[codeKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthComplete(candidateProviderId, methodIndex)}
|
||||
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}`}
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthStart(candidateProviderId, methodIndex)}
|
||||
disabled={authBusyKey === `oauth:${candidateProviderId}:${methodIndex}`}
|
||||
>
|
||||
{t('settings.providers.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oauthDetails[codeKey]?.instructions && (
|
||||
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
|
||||
{oauthDetails[codeKey]?.instructions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.userCode && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
value={oauthCodes[codeKey] ?? ''}
|
||||
onChange={(event) =>
|
||||
setOauthCodes((prev) => ({
|
||||
...prev,
|
||||
[codeKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthComplete(candidateProviderId, methodIndex)}
|
||||
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}`}
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
@@ -914,7 +921,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
|
||||
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
|
||||
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
|
||||
const oauthAuthMethods = listOAuthMethods(providerAuthMethods);
|
||||
const oauthAuthMethods = getOAuthAuthMethods(providerAuthMethods);
|
||||
const showApiKeyAuth = shouldShowApiKeyAuth(providerAuthMethods);
|
||||
const sourcesLoaded = Boolean(selectedSources);
|
||||
const isEditableCustomProvider = sourcesLoaded
|
||||
&& isConfigDefinedCustomProvider(selectedProvider, selectedSources);
|
||||
@@ -924,7 +932,11 @@ export const ProvidersPage: React.FC = () => {
|
||||
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
|
||||
const hasEnvCredentials = providerEnv.length > 0;
|
||||
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
||||
const authStatusIncomplete = isEditableCustomProvider && !hasCredentials;
|
||||
const authStatusIncomplete = sourcesLoaded && !hasCredentials;
|
||||
const showModelsSection = providerModels.length > 0 && (!sourcesLoaded || hasCredentials);
|
||||
const incompleteAuthHint = !showApiKeyAuth && oauthAuthMethods.length > 0
|
||||
? t('settings.providers.page.auth.useReconnectHint')
|
||||
: t('settings.providers.page.auth.incompleteHint');
|
||||
|
||||
const filteredModels = providerModels.filter((model) => {
|
||||
const name = typeof model?.name === 'string' ? model.name : '';
|
||||
@@ -1007,7 +1019,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
<Icon name="alert" className="w-4 h-4 text-[var(--status-warning)] shrink-0" />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.incomplete')}</span>
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.incompleteHint')}</SettingsInfoHint>
|
||||
<SettingsInfoHint>{incompleteAuthHint}</SettingsInfoHint>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 py-1.5">
|
||||
@@ -1020,37 +1032,39 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
||||
</label>
|
||||
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInputs[selectedProvider.id] ?? ''}
|
||||
onChange={(event) =>
|
||||
setApiKeyInputs((prev) => ({
|
||||
...prev,
|
||||
[selectedProvider.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={() => handleSaveApiKey(selectedProvider.id)}
|
||||
disabled={authBusyKey === `api:${selectedProvider.id}`}
|
||||
>
|
||||
{authBusyKey === `api:${selectedProvider.id}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||
</Button>
|
||||
{showApiKeyAuth ? (
|
||||
<div className="py-1.5">
|
||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
||||
</label>
|
||||
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInputs[selectedProvider.id] ?? ''}
|
||||
onChange={(event) =>
|
||||
setApiKeyInputs((prev) => ({
|
||||
...prev,
|
||||
[selectedProvider.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={() => handleSaveApiKey(selectedProvider.id)}
|
||||
disabled={authBusyKey === `api:${selectedProvider.id}`}
|
||||
>
|
||||
{authBusyKey === `api:${selectedProvider.id}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{oauthAuthMethods.length > 0 && (
|
||||
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
|
||||
<div className={cn('space-y-4', showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')}>
|
||||
{oauthAuthMethods.map(({ method, methodIndex }) => {
|
||||
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
|
||||
const codeKey = `${selectedProvider.id}:${methodIndex}`;
|
||||
@@ -1058,7 +1072,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === methodIndex;
|
||||
|
||||
return (
|
||||
<div key={`${selectedProvider.id}-${methodLabel}-${methodIndex}`} className="space-y-3">
|
||||
<div key={`${selectedProvider.id}-${methodIndex}-${methodLabel}`} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
||||
@@ -1168,14 +1182,13 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{showModelsSection ? (
|
||||
<SettingsSection
|
||||
title={t('settings.providers.page.models.title')}
|
||||
titleAccessory={
|
||||
providerModels.length > 0 ? (
|
||||
<span className="typography-micro text-muted-foreground font-normal">
|
||||
({providerModels.length})
|
||||
</span>
|
||||
) : null
|
||||
<span className="typography-micro text-muted-foreground font-normal">
|
||||
({providerModels.length})
|
||||
</span>
|
||||
}
|
||||
headerAction={(
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -1284,6 +1297,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface AuthMethod {
|
||||
type?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
help?: string;
|
||||
method?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OAuthAuthMethodEntry {
|
||||
method: AuthMethod;
|
||||
/** Index in the full provider auth-methods array (passed to oauth authorize/callback). */
|
||||
methodIndex: number;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
export const normalizeAuthType = (method: AuthMethod): string => {
|
||||
const raw = typeof method.type === 'string' ? method.type : '';
|
||||
const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase();
|
||||
const merged = `${raw} ${label}`.toLowerCase();
|
||||
if (merged.includes('oauth')) return 'oauth';
|
||||
if (merged.includes('api')) return 'api';
|
||||
return raw.toLowerCase();
|
||||
};
|
||||
|
||||
export const parseAuthPayload = (payload: unknown): Record<string, AuthMethod[]> => {
|
||||
if (!isRecord(payload)) {
|
||||
return {};
|
||||
}
|
||||
const result: Record<string, AuthMethod[]> = {};
|
||||
for (const [providerId, value] of Object.entries(payload)) {
|
||||
if (Array.isArray(value)) {
|
||||
result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Show the API key form when the provider declares API auth, or when auth
|
||||
* methods are still unknown (empty). OAuth-only providers must not get an
|
||||
* API key prompt.
|
||||
*/
|
||||
export const shouldShowApiKeyAuth = (methods: AuthMethod[]): boolean => {
|
||||
if (methods.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return methods.some((method) => normalizeAuthType(method) === 'api');
|
||||
};
|
||||
|
||||
export const getOAuthAuthMethods = (methods: AuthMethod[]): OAuthAuthMethodEntry[] =>
|
||||
methods
|
||||
.map((method, methodIndex) => ({ method, methodIndex }))
|
||||
.filter(({ method }) => normalizeAuthType(method) === 'oauth');
|
||||
@@ -1,24 +0,0 @@
|
||||
export type ProviderAuthMethod = {
|
||||
type?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
help?: string;
|
||||
};
|
||||
|
||||
export const normalizeAuthType = (method: ProviderAuthMethod): string => {
|
||||
const raw = typeof method.type === 'string' ? method.type : '';
|
||||
const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase();
|
||||
const merged = `${raw} ${label}`.toLowerCase();
|
||||
if (merged.includes('oauth')) return 'oauth';
|
||||
if (merged.includes('api')) return 'api';
|
||||
return raw.toLowerCase();
|
||||
};
|
||||
|
||||
/** OAuth methods with the original provider.auth() method index OpenCode expects. */
|
||||
export const listOAuthMethods = (
|
||||
methods: ProviderAuthMethod[],
|
||||
): Array<{ method: ProviderAuthMethod; methodIndex: number }> =>
|
||||
methods
|
||||
.map((method, methodIndex) => ({ method, methodIndex }))
|
||||
.filter(({ method }) => normalizeAuthType(method) === 'oauth');
|
||||
@@ -1,5 +1 @@
|
||||
export const shouldLoadAvailableProviders = (isAddMode: boolean): boolean => isAddMode;
|
||||
|
||||
/** Auth methods are needed when adding a provider or reconnecting an existing one. */
|
||||
export const shouldLoadProviderAuthMethods = (isAddMode: boolean, showAuthPanel: boolean): boolean =>
|
||||
isAddMode || showAuthPanel;
|
||||
|
||||
@@ -827,6 +827,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
|
||||
const archiveSession = useSessionUIStore((state) => state.archiveSession);
|
||||
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
|
||||
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
|
||||
const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions);
|
||||
|
||||
const {
|
||||
copiedSessionId,
|
||||
@@ -839,6 +841,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
handleCopySessionId,
|
||||
handleUnshareSession,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
confirmDeleteSession,
|
||||
} = useSessionActions({
|
||||
mobileVariant,
|
||||
@@ -858,6 +861,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
deleteSessions,
|
||||
archiveSession,
|
||||
archiveSessions,
|
||||
unarchiveSession,
|
||||
childrenMap,
|
||||
showDeletionDialog,
|
||||
setDeleteSessionConfirm,
|
||||
@@ -916,6 +920,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const stableHandleCopySessionId = useStableRenderCallback(handleCopySessionId);
|
||||
const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession);
|
||||
const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession);
|
||||
const stableHandleRestoreSession = useStableRenderCallback(handleRestoreSession);
|
||||
const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename);
|
||||
|
||||
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
|
||||
@@ -1579,6 +1584,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
createFolderAndStartRename={stableCreateFolderAndStartRename}
|
||||
openContextPanelTab={openContextPanelTab}
|
||||
handleDeleteSession={stableHandleDeleteSession}
|
||||
handleRestoreSession={stableHandleRestoreSession}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowSidebarActions}
|
||||
renderSessionNode={renderSessionNode}
|
||||
@@ -1752,6 +1758,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
handleBulkCreateFolderAndMove,
|
||||
handleBulkRemoveFromFolder,
|
||||
handleBulkDelete,
|
||||
handleBulkRestore,
|
||||
confirmBulkDelete,
|
||||
} = useSidebarBulkActions({
|
||||
isInlineEditing,
|
||||
@@ -1762,6 +1769,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
archiveSessions,
|
||||
unarchiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
});
|
||||
@@ -1909,6 +1917,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
onCreateFolderAndMove={handleBulkCreateFolderAndMove}
|
||||
onRemoveFromFolder={handleBulkRemoveFromFolder}
|
||||
canRemoveFromFolder={bulkCanRemoveFromFolder}
|
||||
onRestore={handleBulkRestore}
|
||||
onDelete={handleBulkDelete}
|
||||
onDone={handleExitSelectionMode}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ type Props = {
|
||||
onCreateFolderAndMove: () => void;
|
||||
onRemoveFromFolder: () => void;
|
||||
canRemoveFromFolder: boolean;
|
||||
onRestore: () => void;
|
||||
onDelete: () => void;
|
||||
onDone: () => void;
|
||||
};
|
||||
@@ -34,6 +35,7 @@ export const BulkActionBar: React.FC<Props> = ({
|
||||
onCreateFolderAndMove,
|
||||
onRemoveFromFolder,
|
||||
canRemoveFromFolder,
|
||||
onRestore,
|
||||
onDelete,
|
||||
onDone,
|
||||
}) => {
|
||||
@@ -98,6 +100,22 @@ export const BulkActionBar: React.FC<Props> = ({
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{archivedBucket ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
className={iconButtonClass}
|
||||
aria-label={t('sessions.sidebar.bulkActions.restore')}
|
||||
>
|
||||
<Icon name="inbox-unarchive" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.bulkActions.restore')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
||||
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread.
|
||||
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Unarchive is not possible through the upstream OpenCode HTTP API (`session.update` can only set a finite `time.archived`).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Restore (unarchive) is available per session (row context menu, Archive page row) and in bulk (selection bar) and writes `time.archived = 0` — the server cannot clear the field over HTTP, so the global session cache splits active/archived client-side (see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`).
|
||||
- Scheduled tasks (`ScheduledTasksDialog`, now a full-page surface on web/desktop) and per-project worktree management (`WorktreesView`, opened from the project menu) render as overlays inside `<main>` in `MainLayout`; the sidebar no longer mounts them.
|
||||
- Group-level PR-status polling/indicators and worktree-group drag-to-reorder were removed together with the worktree grouping level; `oc.sessions.groupOrder` is no longer read or written. Worktree PR/branch context lives in the Worktrees surface.
|
||||
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
|
||||
|
||||
@@ -92,6 +92,7 @@ type Props = {
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; sessionTitleFallback?: string; readOnly?: boolean }) => void;
|
||||
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void;
|
||||
handleRestoreSession: (session: Session) => void;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
renderSessionNode: (
|
||||
@@ -287,6 +288,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
createFolderAndStartRename,
|
||||
openContextPanelTab,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
mobileVariant,
|
||||
alwaysShowActions,
|
||||
renderSessionNode,
|
||||
@@ -1092,6 +1094,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
{t('sessions.sidebar.bulkActions.archive')}
|
||||
</Item>
|
||||
) : null}
|
||||
{archivedBucket ? (
|
||||
<Item className="[&>svg]:mr-1" onClick={() => handleRestoreSession(session)}>
|
||||
<Icon name="inbox-unarchive" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.bulkActions.restore')}
|
||||
</Item>
|
||||
) : null}
|
||||
<Item className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket, hardDelete: true })}>
|
||||
<Icon name="delete-bin" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.bulkActions.delete')}
|
||||
@@ -1607,6 +1615,7 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
|
||||
&& prev.openContextPanelTab === next.openContextPanelTab
|
||||
&& prev.handleDeleteSession === next.handleDeleteSession
|
||||
&& prev.handleRestoreSession === next.handleRestoreSession
|
||||
&& prev.renderSessionNode === next.renderSessionNode;
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ type Args = {
|
||||
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
archiveSession: (id: string) => Promise<boolean>;
|
||||
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
||||
unarchiveSession: (id: string) => Promise<boolean>;
|
||||
childrenMap: Map<string, Session[]>;
|
||||
showDeletionDialog: boolean;
|
||||
setDeleteSessionConfirm: DeleteSessionConfirmSetter;
|
||||
@@ -286,6 +287,18 @@ export const useSessionActions = (args: Args) => {
|
||||
await executeDeleteSession(session, { archivedBucket }, { descendantIds });
|
||||
}, [args, executeDeleteSession]);
|
||||
|
||||
const handleRestoreSession = React.useCallback(
|
||||
async (session: Session) => {
|
||||
const success = await args.unarchiveSession(session.id);
|
||||
if (success) {
|
||||
toast.success(t('sessions.sidebar.session.restore.success'));
|
||||
} else {
|
||||
toast.error(t('sessions.sidebar.session.restore.error'));
|
||||
}
|
||||
},
|
||||
[args, t],
|
||||
);
|
||||
|
||||
return {
|
||||
copiedSessionId,
|
||||
handleSessionSelect,
|
||||
@@ -297,6 +310,7 @@ export const useSessionActions = (args: Args) => {
|
||||
handleCopySessionId,
|
||||
handleUnshareSession,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
confirmDeleteSession,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ type Args = {
|
||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
||||
unarchiveSessions: (ids: string[]) => Promise<{ restoredIds: string[]; failedIds: string[] }>;
|
||||
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{
|
||||
sessionCount: number;
|
||||
@@ -50,6 +51,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
archiveSessions,
|
||||
unarchiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
} = args;
|
||||
@@ -206,6 +208,23 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
|
||||
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog, setBulkDeleteConfirm, hasSelection]);
|
||||
|
||||
const handleBulkRestore = React.useCallback(async () => {
|
||||
if (!hasSelection || !bulkScopeIsArchived) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
const { restoredIds, failedIds } = await unarchiveSessions(ids);
|
||||
if (restoredIds.length > 0) {
|
||||
toast.success(restoredIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.restoredSingle', { count: restoredIds.length })
|
||||
: t('sessions.sidebar.bulkActions.restoredPlural', { count: restoredIds.length }));
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(failedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.failedRestoreSingle', { count: failedIds.length })
|
||||
: t('sessions.sidebar.bulkActions.failedRestorePlural', { count: failedIds.length }));
|
||||
}
|
||||
useSessionMultiSelectStore.getState().clear();
|
||||
}, [bulkScopeIsArchived, hasSelection, selectedIds, t, unarchiveSessions]);
|
||||
|
||||
const confirmBulkDelete = React.useCallback(async () => {
|
||||
setBulkDeleteConfirm(null);
|
||||
await executeBulkDelete();
|
||||
@@ -275,6 +294,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
handleBulkCreateFolderAndMove,
|
||||
handleBulkRemoveFromFolder,
|
||||
handleBulkDelete,
|
||||
handleBulkRestore,
|
||||
confirmBulkDelete,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -65,6 +65,12 @@ export const HelpDialog: React.FC = () => {
|
||||
icon: "layout-left",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'add_selection_to_chat',
|
||||
descriptionKey: "helpDialog.item.addSelectionToChat",
|
||||
icon: "add",
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'cycle_agent',
|
||||
keys: '',
|
||||
|
||||
@@ -32,6 +32,18 @@ const TINT_DESTRUCTIVE = [
|
||||
"dark:active:bg-[color-mix(in_srgb,var(--status-error)_20%,transparent)]",
|
||||
].join(" ")
|
||||
|
||||
const TINT_INFO = [
|
||||
"bg-[color-mix(in_srgb,var(--status-info)_4%,var(--background))]",
|
||||
"text-[var(--status-info)]",
|
||||
"border border-[color-mix(in_srgb,var(--status-info)_8%,transparent)]",
|
||||
"hover:bg-[color-mix(in_srgb,var(--status-info)_7%,var(--background))]",
|
||||
"active:bg-[color-mix(in_srgb,var(--status-info)_10%,var(--background))]",
|
||||
"dark:bg-[color-mix(in_srgb,var(--status-info)_7%,transparent)]",
|
||||
"dark:border-[color-mix(in_srgb,var(--status-info)_12%,transparent)]",
|
||||
"dark:hover:bg-[color-mix(in_srgb,var(--status-info)_10%,transparent)]",
|
||||
"dark:active:bg-[color-mix(in_srgb,var(--status-info)_14%,transparent)]",
|
||||
].join(" ")
|
||||
|
||||
const buttonVariants = cva(
|
||||
[
|
||||
"group relative inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] typography-ui-label font-medium lowercase tracking-[0.01em] shrink-0 select-none",
|
||||
@@ -49,6 +61,7 @@ const buttonVariants = cva(
|
||||
TINT_DESTRUCTIVE,
|
||||
"focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
),
|
||||
info: TINT_INFO,
|
||||
neutral:
|
||||
"bg-interactive-hover text-foreground border border-border/60 hover:bg-interactive-active",
|
||||
outline:
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
@@ -28,6 +29,7 @@ export function ArchiveView(): React.ReactNode {
|
||||
const setOpen = useUIStore((state) => state.setArchivePageOpen);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const archivedSessions = useGlobalSessionsStore(useShallow((state) => open ? state.archivedSessions : []));
|
||||
const [query, setQuery] = React.useState('');
|
||||
@@ -87,6 +89,16 @@ export function ArchiveView(): React.ReactNode {
|
||||
setOpen(false);
|
||||
}, [setActiveMainTab, setCurrentSession, setOpen]);
|
||||
|
||||
const restoreSession = React.useCallback((session: Session) => {
|
||||
void unarchiveSession(session.id).then((success) => {
|
||||
if (success) {
|
||||
toast.success(t('sessions.sidebar.session.restore.success'));
|
||||
} else {
|
||||
toast.error(t('sessions.sidebar.session.restore.error'));
|
||||
}
|
||||
});
|
||||
}, [t, unarchiveSession]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const renderDirectoryItem = (
|
||||
@@ -196,7 +208,7 @@ export function ArchiveView(): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="group relative flex cursor-pointer items-center gap-3 rounded-md py-1 pl-2 pr-2 transition-[padding] hover:bg-interactive-hover/40 hover:pr-8 focus-within:pr-8"
|
||||
className="group relative flex cursor-pointer items-center gap-3 rounded-md py-1 pl-2 pr-2 transition-[padding] hover:bg-interactive-hover/40 hover:pr-14 focus-within:pr-14"
|
||||
onClick={() => openSession(session)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -218,6 +230,17 @@ export function ArchiveView(): React.ReactNode {
|
||||
<span className="flex-shrink-0 text-[0.72rem] text-muted-foreground/75">
|
||||
{formatSessionDateLabel(session.time?.archived ?? session.time?.updated ?? session.time?.created ?? Date.now())}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
restoreSession(session);
|
||||
}}
|
||||
className="absolute right-7 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity pointer-events-none hover:text-foreground group-hover:opacity-100 group-hover:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('sessions.archivePage.restoreSessionAria', { title: session.title || t('sessions.sidebar.session.untitled') })}
|
||||
>
|
||||
<Icon name="inbox-unarchive" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -70,7 +70,7 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { useMessageTTS } from '@/hooks/useMessageTTS';
|
||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
|
||||
import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
|
||||
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
|
||||
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -361,6 +361,7 @@ interface FileRowProps {
|
||||
isExpanded: boolean;
|
||||
isActive: boolean;
|
||||
isMobile: boolean;
|
||||
isBrowserClient: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
status?: FileStatus | null;
|
||||
badge?: { modified: number; added: number } | null;
|
||||
@@ -388,6 +389,7 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
isExpanded,
|
||||
isActive,
|
||||
isMobile,
|
||||
isBrowserClient,
|
||||
alwaysShowActions,
|
||||
status,
|
||||
badge,
|
||||
@@ -405,14 +407,17 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
const { t } = useI18n();
|
||||
const isDir = node.type === 'directory';
|
||||
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions;
|
||||
const canDownload = !isDir && Boolean(downloadFile);
|
||||
const canRevealPath = canReveal && !isBrowserClient;
|
||||
const hasMenuActions = canRename || canCreateFile || canCreateFolder || canDelete || canDownload || canRevealPath;
|
||||
|
||||
const handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
|
||||
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) {
|
||||
if (!hasMenuActions) {
|
||||
return;
|
||||
}
|
||||
event?.preventDefault();
|
||||
setRightClickMenuPath(node.path);
|
||||
}, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setRightClickMenuPath]);
|
||||
}, [hasMenuActions, node.path, setRightClickMenuPath]);
|
||||
|
||||
const handleInteraction = React.useCallback(() => {
|
||||
if (isDir) {
|
||||
@@ -474,10 +479,10 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
toast.error(t('sidebarFilesTree.toast.operationFailed'));
|
||||
});
|
||||
}}>
|
||||
<Icon name="download" className="mr-2 size-4" /> {t('sidebarFilesTree.menu.save')}
|
||||
<Icon name="download" className="mr-2 size-4" /> {t(isBrowserClient ? 'sidebarFilesTree.menu.download' : 'sidebarFilesTree.menu.save')}
|
||||
</Item>
|
||||
)}
|
||||
{canReveal && (
|
||||
{canRevealPath && (
|
||||
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRevealPath(node.path); }}>
|
||||
<Icon name="folder-received" className="mr-2 size-4" /> {t(getRevealLabelKey())}
|
||||
</Item>
|
||||
@@ -546,7 +551,7 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && (
|
||||
{hasMenuActions && (
|
||||
<div className={cn(
|
||||
"absolute right-1 top-1/2 -translate-y-1/2",
|
||||
alwaysShowActions ? "opacity-100" : "opacity-0 focus-within:opacity-100 group-hover:opacity-100"
|
||||
@@ -720,6 +725,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const { files, runtime } = useRuntimeAPIs();
|
||||
const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem();
|
||||
const { isMobile, isTablet, screenWidth } = useDeviceInfo();
|
||||
const isBrowserClient = isBrowserClientRuntime(runtime.platform);
|
||||
const alwaysShowActions = isMobile || isTablet;
|
||||
const showHidden = useDirectoryShowHidden();
|
||||
const showGitignored = useFilesViewShowGitignored();
|
||||
@@ -2302,6 +2308,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
isExpanded={isExpanded}
|
||||
isActive={isActive}
|
||||
isMobile={isMobile}
|
||||
isBrowserClient={isBrowserClient}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
status={!isDir ? getFileStatus(node.path) : undefined}
|
||||
badge={isDir ? getFolderBadge(node.path) : undefined}
|
||||
|
||||
Reference in New Issue
Block a user