Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/app/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@ipollowork/app",
"private": true,
"version": "0.50.34",
"version": "0.50.35",
"type": "module",
"scripts": {
"test": "bun test --isolate tests/",
Expand Down
10 changes: 10 additions & 0 deletions apps/app/src/app/lib/ipollowork-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
import type {
WorkBoardConfig,
WorkBoardConfigValue,
WorkAutomationModel,
WorkItem,
WorkItemCreateInput,
WorkItemListResponse,
Expand Down Expand Up @@ -1641,6 +1642,15 @@ export function createiPolloWorkServerClient(options: { baseUrl: string; token?:
timeoutMs: timeouts.config,
},
),
setWorkAutomationModel: (workspaceId: string, value: WorkAutomationModel, signal?: AbortSignal) =>
requestJson<{ ok: boolean }>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/work-automation-model`, {
token,
hostToken,
method: "PUT",
body: value,
timeoutMs: timeouts.config,
signal,
}),
getWorkBoard: (workspaceId: string) =>
requestJson<WorkBoardConfig>(baseUrl, `/workspace/${encodeURIComponent(workspaceId)}/work-board`, {
token,
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2626,6 +2626,8 @@ export default {
"providers.not_connected": "Not connected to a server",
"providers.not_oauth_flow_prefix": "Selected auth method is not an OAuth flow for",
"providers.oauth_failed": "Failed to complete OAuth",
"providers.oauth_unconfirmed": "The app could not confirm this authorization. Start a new authorization and sign in on the new browser page.",
"providers.oauth_retry": "Restart authorization",
"providers.oauth_method_required": "OAuth method is required",
"providers.provider_error": "Provider error ({provider})",
"providers.provider_id_required": "Provider ID is required",
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2353,6 +2353,8 @@ export default {
"providers.not_connected": "未连接到服务器",
"providers.not_oauth_flow_prefix": "所选认证方式不是OAuth流程:",
"providers.oauth_failed": "完成OAuth失败",
"providers.oauth_unconfirmed": "应用尚未确认这次授权。请重新授权,并在新打开的浏览器页面完成登录。",
"providers.oauth_retry": "重新授权",
"providers.oauth_method_required": "OAuth方式为必填项",
"providers.provider_error": "提供商错误({provider})",
"providers.provider_id_required": "提供商ID为必填项",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "@/components/ui/dialog";
import { desktopFetchViaMain, openDesktopAuthUrl, openDesktopUrl } from "@/app/lib/desktop";
import { isDesktopRuntime } from "@/app/utils";
import { t } from "@/i18n";
import { Button } from "@/components/ui/button";
import { ProviderIcon } from "../../../design-system/provider-icon";
import { TextInput } from "../../../design-system/text-input";
Expand All @@ -43,6 +44,7 @@ import type {
ProviderAuthMethod,
ProviderAuthProvider,
ProviderOAuthStartResult,
ProviderRefreshOptions,
} from "./store";

type ProviderOAuthSession = ProviderOAuthStartResult & {
Expand Down Expand Up @@ -71,7 +73,7 @@ export type ProviderAuthModalProps = {
methodIndex: number,
code?: string,
) => Promise<{ connected: boolean; pending?: boolean; message?: string }>;
onRefreshProviders?: () => Promise<unknown>;
onRefreshProviders?: (options?: ProviderRefreshOptions) => Promise<unknown>;
showiPolloWorkModelsSubscribe?: boolean;
onSubscribeiPolloWorkModels?: () => void | Promise<void>;
onClose: () => void;
Expand Down Expand Up @@ -102,7 +104,8 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {

const searchInputRef = useRef<HTMLInputElement | null>(null);
const providerPollRef = useRef<number | null>(null);
const oauthAutoCompletionKeyRef = useRef<string | null>(null);
const providerPollInFlightRef = useRef(false);
const oauthAutoCompletionRef = useRef<ProviderOAuthSession | null>(null);
const oauthCodeCopiedResetRef = useRef<number | null>(null);
const autoOpenedPreferredProviderIdRef = useRef<string | null>(null);

Expand Down Expand Up @@ -205,7 +208,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
setSearchQuery("");
setActiveEntryIndex(0);
setLocalError(null);
oauthAutoCompletionKeyRef.current = null;
oauthAutoCompletionRef.current = null;
setOauthAutoBusy(false);
setOauthCodeCopied(false);
setOauthBrowserOpened(false);
Expand Down Expand Up @@ -286,17 +289,15 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
!!activeProviderId && (props.connectedProviderIds ?? []).includes(activeProviderId);

const pollProviders = async () => {
const id = activeProviderId;
if (!id || pollingBusy) return;
if (!activeProviderId || providerPollInFlightRef.current) return;
providerPollInFlightRef.current = true;
setPollingBusy(true);
try {
await props.onRefreshProviders?.();
await props.onRefreshProviders?.({ skipSharedImport: true });
} finally {
providerPollInFlightRef.current = false;
setPollingBusy(false);
}
if ((props.connectedProviderIds ?? []).includes(id)) {
handleClose();
}
};

const startProviderPolling = () => {
Expand Down Expand Up @@ -367,16 +368,20 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
};

const attemptOauthAutoCompletion = async (session: ProviderOAuthSession) => {
const completionKey = `${session.providerId}:${session.methodIndex}`;
if (oauthAutoCompletionKeyRef.current === completionKey) return;
oauthAutoCompletionKeyRef.current = completionKey;
if (oauthAutoCompletionRef.current === session) return;
oauthAutoCompletionRef.current = session;
setOauthAutoBusy(true);
try {
await submitOauth(session.providerId, session.methodIndex);
} catch {
// submitOauth already exposes the actionable provider error in the modal.
const result = await props.onSubmitOAuth(session.providerId, session.methodIndex);
if (oauthAutoCompletionRef.current !== session) return;
if (result.connected) handleClose();
else setLocalError(t("providers.oauth_unconfirmed"));
} catch (error) {
if (oauthAutoCompletionRef.current === session) {
setLocalError(error instanceof Error ? error.message : t("providers.oauth_failed"));
}
} finally {
if (oauthAutoCompletionKeyRef.current === completionKey) {
if (oauthAutoCompletionRef.current === session) {
setOauthAutoBusy(false);
}
}
Expand All @@ -396,6 +401,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
setLocalError(null);
setOauthCodeInput("");
setOauthSession(null);
oauthAutoCompletionRef.current = null;
setOauthCodeCopied(false);
setOauthBrowserOpened(false);
try {
Expand Down Expand Up @@ -595,7 +601,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
}
setOauthSession(null);
setOauthCodeInput("");
oauthAutoCompletionKeyRef.current = null;
oauthAutoCompletionRef.current = null;
setOauthAutoBusy(false);
setOauthCodeCopied(false);
setOauthBrowserOpened(false);
Expand Down Expand Up @@ -1141,7 +1147,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-gray-12">{selectedEntry.name}</div>
<div className="text-xs text-gray-10 mt-1">Waiting for browser confirmation.</div>
{!errorMessage ? <div className="text-xs text-gray-10 mt-1">Waiting for browser confirmation.</div> : null}
</div>
<Button variant="outline" onClick={handleBack} disabled={actionDisabled}>
Back
Expand Down Expand Up @@ -1170,7 +1176,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
</Button>
</div>
) : null}
{isOpenAiHeadlessSession && !oauthBrowserOpened ? (
{errorMessage ? null : isOpenAiHeadlessSession && !oauthBrowserOpened ? (
<div className="flex items-center gap-2 text-xs text-gray-9">
<span>Authorization checks will start after you click Open Browser.</span>
</div>
Expand All @@ -1183,11 +1189,13 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
<div className="flex items-center justify-between gap-3">
<Button
variant="outline"
disabled={actionDisabled || Boolean(errorMessage && oauthAutoBusy)}
onClick={() => {
void openOauthUrl(oauthSession.providerId, oauthSession.authorization.url ?? "");
if (errorMessage) void startOauth(selectedEntry, oauthSession.methodIndex);
else void openOauthUrl(oauthSession.providerId, oauthSession.authorization.url ?? "");
}}
>
{isOpenAiHeadlessSession
{errorMessage ? t("providers.oauth_retry") : isOpenAiHeadlessSession
? oauthBrowserOpened
? "Reopen Browser"
: "Open Browser"
Expand Down
27 changes: 20 additions & 7 deletions apps/app/src/react-app/domains/connections/provider-auth/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ type CreateProviderAuthStoreOptions = {
focusPromptSoon?: () => void;
};

type ProviderRefreshOptions = {
export type ProviderRefreshOptions = {
dispose?: boolean;
force?: boolean;
skipSharedImport?: boolean;
Expand Down Expand Up @@ -556,6 +556,8 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions)
key: string;
promise: Promise<ProviderOAuthCompletionResult>;
} | null = null;
let awaitingProviderOAuthCallback = false;
const isProviderOAuthPending = () => providerAuthStartInFlight !== null || awaitingProviderOAuthCallback;

const emitChange = () => {
for (const listener of listeners) listener();
Expand Down Expand Up @@ -1976,15 +1978,18 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions)
const accountEnvKeys = await refreshAccountConnectedProviderIds().catch(() => null);
const client = options.client();
if (!client) return null;
// Importing shared provider configuration can reload the engine and
// discard the in-memory authorization before the callback saves it.
const skipSharedImport = optionsArg.skipSharedImport || isProviderOAuthPending();
const deferSharedImport = Boolean(
accountEnvKeys
&& options.deferSharedProviderImport?.()
&& !optionsArg?.skipSharedImport
&& !skipSharedImport
&& !optionsArg?.force
&& !optionsArg?.dispose
&& !optionsArg?.runtimeEnvironmentChanged,
);
const sharedProviderImport = optionsArg?.skipSharedImport || !accountEnvKeys || deferSharedImport
const sharedProviderImport = skipSharedImport || !accountEnvKeys || deferSharedImport
? { changed: false, requiresReload: false }
: await importSharedProviderConnections(accountEnvKeys).catch(() => ({
changed: false,
Expand Down Expand Up @@ -2037,7 +2042,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions)
// recovered connection error; credential and OAuth failures remain.
setStateField("providerAuthError", null);
}
if (deferSharedImport && accountEnvKeys) {
if (deferSharedImport && accountEnvKeys && !isProviderOAuthPending()) {
// The account provider list is the settings-page source of truth. Show
// it immediately, then reconcile credentials into the OpenCode runtime
// without holding the first render behind config writes and reloads.
Expand Down Expand Up @@ -2145,7 +2150,12 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions)

try {
const trimmedCode = code?.trim();
await connection.completeOAuth(resolved, methodIndex, trimmedCode || undefined);
awaitingProviderOAuthCallback = true;
try {
await connection.completeOAuth(resolved, methodIndex, trimmedCode || undefined);
} finally {
awaitingProviderOAuthCallback = false;
}
await ensureProjectProviderDisabledState(resolved, false);
const updated = await refreshProviders({
dispose: true,
Expand Down Expand Up @@ -2177,9 +2187,12 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions)
}
return { connected: false, pending: true };
}
const message = describeProviderError(error, t("providers.oauth_failed"));
const callbackFailed = error instanceof Error && error.message.includes("ProviderAuthOauthCallbackFailed");
const message = callbackFailed
? t("providers.oauth_unconfirmed")
: describeProviderError(error, t("providers.oauth_failed"));
setStateField("providerAuthError", message);
throw error instanceof Error ? error : new Error(message);
throw !callbackFailed && error instanceof Error ? error : new Error(message, { cause: error });
}
}

Expand Down
9 changes: 6 additions & 3 deletions apps/app/src/react-app/domains/session/chat/session-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ import { resolveExtensionIconUrl } from "@/react-app/design-system/extension-ico
import { useDenAuth } from "../../cloud/den-auth-provider";
import ProviderAuthModal, { type ProviderAuthModalProps } from "../../connections/provider-auth/provider-auth-modal";
import { RenameSessionModal } from "../modals/rename-session-modal";
import { AppSidebar } from "../sidebar/app-sidebar";
import { AppSidebar, workspaceEngineLabel } from "../sidebar/app-sidebar";
import type { iPolloWorkSessionType, iPolloWorkTemplateId } from "../sidebar/app-sidebar-provider";
import { readSessionType, sessionTypeForTemplate, setSessionType, subscribeToSessionType } from "../sidebar/session-type";
import { SessionSurface, StarterCapabilityChip, type SessionSurfaceHandle, type SessionSurfaceProps } from "../surface/session-surface";
Expand Down Expand Up @@ -884,6 +884,7 @@ function InitialProjectTaskStarter({
pendingDraft?: ComposerDraft | null;
onSubmit: (draft: ComposerDraft) => Promise<boolean>;
}) {
const engineLabel = workspaceEngineLabel(engineId);
const draft = useComposerStateStore((state) => getComposerDraft(state, draftScope));
const attachments = useComposerStateStore((state) => getComposerAttachments(state, draftScope));
const pastedText = useComposerStateStore((state) => getComposerPasteParts(state, draftScope));
Expand Down Expand Up @@ -1154,15 +1155,17 @@ function InitialProjectTaskStarter({
{onEngineChange ? (
<Select value={engineId || DEFAULT_ENGINE_ID} onValueChange={(value) => { if (value) onEngineChange(value); }}>
<SelectTrigger className="h-8 w-auto gap-2 border-0 bg-transparent px-2 text-xs shadow-none" aria-label={t("conversation_work.engine")} data-testid="conversation-engine-picker">
<SelectValue>{engineId === CODEX_HARNESS_ENGINE_ID ? t("projects.engine_codex") : engineId === DEEPSEEK_HARNESS_ENGINE_ID ? t("projects.engine_dsh") : t("projects.engine_opencode")}</SelectValue>
<SelectValue>{engineLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_ENGINE_ID}>{t("projects.engine_opencode")}</SelectItem>
<SelectItem value={CODEX_HARNESS_ENGINE_ID}>{t("projects.engine_codex")}</SelectItem>
<SelectItem value={DEEPSEEK_HARNESS_ENGINE_ID}>{t("projects.engine_dsh")}</SelectItem>
</SelectContent>
</Select>
) : null}
) : (
<span className="inline-flex h-8 items-center px-2 text-xs" data-testid="conversation-project-engine">{engineLabel}</span>
)}
</div>
) : null}
<div data-testid="new-conversation-starter-composer-shell" className="mt-6 w-full shrink-0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ interface SessionStatusIndicatorProps {
isActive: boolean;
}

function workspaceEngineLabel(engineId?: string | null) {
export function workspaceEngineLabel(engineId?: string | null) {
const resolvedEngineId = engineId?.trim() || DEFAULT_ENGINE_ID;
if (resolvedEngineId === CODEX_HARNESS_ENGINE_ID) return t("projects.engine_codex");
if (resolvedEngineId === DEEPSEEK_HARNESS_ENGINE_ID) return t("projects.engine_dsh");
Expand Down
5 changes: 4 additions & 1 deletion apps/app/src/react-app/domains/session/sync/session-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,11 @@ function observeInterruptedUserMessage(
run.resumeStartedAt = null;
return "resumed";
}
// OpenCode can announce a native user ID before sending the prompt parts.
// Leave unconfirmed IDs eligible to resume when the text arrives.
if (run.interrupted) return "ignore";
run.observedUserMessageIds.add(message.id);
return run.interrupted ? "ignore" : "normal";
return "normal";
}

function reconcileInterruptedUserMessage(workspaceId: string, sessionId: string, run: InterruptedRun, message: UIMessage) {
Expand Down
1 change: 0 additions & 1 deletion apps/app/src/react-app/domains/work/work-center.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,6 @@ export function WorkCenter(props: WorkCenterProps) {
board={editorBoard}
defaultStatus={createStatus || editorBoard.columns[0]?.id || "planned"}
scheduleMode={props.mode === "global" || projectView === "schedule"}
allowAutomation={!props.sessionId || Boolean(editingEntry?.item.automation)}
initialSchedule={createSchedule}
saving={createMutation.isPending || updateMutation.isPending}
deleting={deleteMutation.isPending}
Expand Down
3 changes: 1 addition & 2 deletions apps/app/src/react-app/domains/work/work-item-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ type WorkItemSheetProps = {
board: WorkBoardConfig;
defaultStatus: string;
scheduleMode: boolean;
allowAutomation?: boolean;
initialSchedule: WorkItemScheduleDraft | null;
saving: boolean;
deleting: boolean;
Expand Down Expand Up @@ -615,7 +614,7 @@ export function WorkItemSheet(props: WorkItemSheetProps) {
: null}
</div>
{invalidRange ? <p id="work-item-range-error" className="mt-2 text-xs text-destructive">{t("work.field.invalid_range")}</p> : null}
{!props.item?.execution && props.allowAutomation !== false ? (
{!props.item?.execution ? (
<div className="mt-4 border-t border-dls-border/70 pt-4" data-testid="work-item-automation">
<div className="flex items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-dls-hover text-dls-secondary">
Expand Down
3 changes: 2 additions & 1 deletion apps/app/src/react-app/infra/preferred-chat-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function resolvePreferredSelectableChatModel(input: {
* Resolve the model an engine can execute without changing the app-wide
* preference. OpenCode defaults to its live free models; a paid shared choice
* inherited from another engine is not consent to use that route in OpenCode.
* An available choice made in this engine remains authoritative.
* A choice made in this engine stays selected even while it is unavailable.
*/
export function resolveEngineSelectableChatModel(input: {
engineId?: string | null;
Expand All @@ -76,6 +76,7 @@ export function resolveEngineSelectableChatModel(input: {
if (current && input.providers.some((provider) => (
provider.providerID === current.providerID && provider.modelIDs.includes(current.modelID)
))) return current;
if (input.selectedForEngine) return null;

if (isOpenCode) {
const defaultModel = input.defaults?.opencode;
Expand Down
Loading
Loading