diff --git a/.gitignore b/.gitignore index f1c2306..c0a3512 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ test-results/ # Local runtime state, uploads, generated files, pairing tokens, logs .codexmobile/ +.superpowers/ +.worktrees/ .playwright-mcp/ .tmp* .tmp*/ diff --git a/client/manifest.test.mjs b/client/manifest.test.mjs new file mode 100644 index 0000000..2804a44 --- /dev/null +++ b/client/manifest.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import test from 'node:test'; + +test('manifest prefers fullscreen PWA display with standalone fallback', async () => { + const manifest = JSON.parse(await fs.readFile(new URL('./manifest.webmanifest', import.meta.url), 'utf8')); + + assert.equal(manifest.display, 'standalone'); + assert.deepEqual(manifest.display_override, ['fullscreen', 'standalone']); +}); diff --git a/client/manifest.webmanifest b/client/manifest.webmanifest index 809d6d8..0f1147d 100644 --- a/client/manifest.webmanifest +++ b/client/manifest.webmanifest @@ -6,6 +6,7 @@ "start_url": "/", "scope": "/", "display": "standalone", + "display_override": ["fullscreen", "standalone"], "background_color": "#f7f7f4", "theme_color": "#f7f7f4", "orientation": "portrait", diff --git a/client/src/App.jsx b/client/src/App.jsx index 395b02a..7e53408 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -14,6 +14,7 @@ import { GitBranch, GitCommitHorizontal, Headphones, + HelpCircle, Image, Loader2, Menu, @@ -36,7 +37,8 @@ import { UploadCloud, Volume2, Wifi, - X + X, + Zap } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; @@ -49,19 +51,53 @@ import { mergeActivityStep } from './activity-merge.js'; import { isPlaceholderTimelineItem } from './activity-timeline.js'; import { isNearChatBottom, shouldFollowChatOutput } from './chat-scroll.js'; import { composerSendState, desktopBridgeCanCreateThread } from './send-state.js'; +import { buildClientCollaborationMode } from './plan-mode-client.js'; +import { + dragEventHasFiles, + filesFromClipboardEvent, + filesFromDropEvent +} from './upload-inputs.js'; import { detectComposerToken, filteredSlashCommands, + COMPOSER_MODE_OPTIONS, + composerModeLabel, + migrateComposerMode, + normalizeComposerMode, + selectedComposerModeForSession, + threadStartedComposerModeSourceIds, replaceComposerToken } from './composer-shortcuts.js'; +import { + THEME_OPTIONS, + applyThemeToDocument, + normalizeThemePreference, + resolveThemePreference +} from './theme-preference.js'; +import { + DEFAULT_PERMISSION_MODE, + PERMISSION_OPTIONS, + permissionLabel, + permissionShortLabel +} from './permission-mode.js'; +import { + DEFAULT_MODEL_SPEED, + MODEL_SPEED_OPTIONS, + normalizeModelSpeed, + serviceTierForModelSpeed +} from './model-preferences.js'; import { connectionRecoveryState } from './connection-recovery.js'; import { browserNotificationPermission, isStandalonePwa, + markUserInputMessageResolved, + mergePendingUserInputMessages, notificationFromPayload, notificationPreferenceEnabled, setNotificationPreferenceEnabled, - shouldUseWebNotification + shouldUseWebNotification, + upsertUserInputMessage, + userInputKey } from './notification-events.js'; import { browserPushSupported, @@ -142,6 +178,8 @@ const DEFAULT_REASONING_EFFORT = 'xhigh'; const REASONING_DEFAULT_VERSION = 'xhigh-v1'; const THEME_KEY = 'codexmobile.theme'; const SELECTED_SKILLS_KEY = 'codexmobile.selectedSkills'; +const MODEL_SPEED_KEY = 'codexmobile.modelSpeed'; +const FOLLOW_UP_MODE_KEY = 'codexmobile.followUpMode'; const VOICE_MAX_RECORDING_MS = 90 * 1000; const VOICE_MAX_UPLOAD_BYTES = 10 * 1024 * 1024; const VOICE_MIME_CANDIDATES = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm']; @@ -219,13 +257,6 @@ async function copyTextToClipboard(text) { } } -const PERMISSION_OPTIONS = [ - { value: 'default', label: '默认权限' }, - { value: 'acceptEdits', label: '自动接受编辑' }, - { value: 'bypassPermissions', label: '完全访问', danger: true } -]; -const DEFAULT_PERMISSION_MODE = 'bypassPermissions'; - const REASONING_OPTIONS = [ { value: 'low', label: '低' }, { value: 'medium', label: '中' }, @@ -233,6 +264,11 @@ const REASONING_OPTIONS = [ { value: 'xhigh', label: '超高' } ]; +const FOLLOW_UP_OPTIONS = [ + { value: 'queue', label: '排队', description: '当前任务结束后自动发送' }, + { value: 'steer', label: '引导', description: '提交给当前运行,不中断模型' } +]; + function formatTime(value) { if (!value) { return ''; @@ -425,14 +461,14 @@ function shortModelName(model) { .replace(/-mini$/i, ' mini'); } -function permissionLabel(value) { - return PERMISSION_OPTIONS.find((option) => option.value === value)?.label || '默认权限'; -} - function reasoningLabel(value) { return REASONING_OPTIONS.find((option) => option.value === value)?.label || '超高'; } +function normalizeFollowUpMode(value) { + return value === 'steer' ? 'steer' : 'queue'; +} + function safeStoredJsonArray(key) { try { const parsed = JSON.parse(localStorage.getItem(key) || '[]'); @@ -1452,8 +1488,8 @@ function Drawer({ onNewConversation, onSync, syncing, - theme, - setTheme, + themePreference, + setThemePreference, canCreateThread = true, createThreadUnavailableReason = '' }) { @@ -1514,20 +1550,16 @@ function Drawer({ 主题选择
- - + {THEME_OPTIONS.map((option) => ( + + ))}
@@ -3967,7 +3999,102 @@ function renderMarkdownBlocks(content, onPreviewImage) { return blocks.length ? blocks : null; } -function ChatMessage({ message, now, onPreviewImage, onDeleteMessage }) { +function UserInputRequestMessage({ message, onSubmit }) { + const [answers, setAnswers] = useState({}); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const answered = message.status === 'answered'; + const questions = Array.isArray(message.questions) ? message.questions : []; + + function questionKey(question, index) { + return question?.id || `question-${index}`; + } + + function setQuestionAnswer(questionId, value) { + setAnswers((current) => ({ + ...current, + [questionId]: { answers: value ? [value] : [] } + })); + } + + async function submit(nextAnswers) { + setBusy(true); + setError(''); + try { + await onSubmit?.(message, nextAnswers); + } catch (submitError) { + setError(submitError.message || '提交失败'); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ {answered ? : } + {answered ? '已提交选择' : '等待你的选择'} +
+ {questions.map((question, index) => { + const id = questionKey(question, index); + const selectedAnswer = answers[id]?.answers?.[0] || ''; + const hasOptions = Array.isArray(question.options) && question.options.length > 0; + const disabled = busy || answered; + return ( +
+ {question.header ? {question.header} : null} + {question.question ?

{question.question}

: null} + {hasOptions ? ( +
+ {question.options.map((option, optionIndex) => { + const optionLabel = String(option?.label || ''); + const optionKey = optionLabel || `option-${optionIndex}`; + return ( + + ); + })} +
+ ) : null} + {question.isOther || !hasOptions ? ( + setQuestionAnswer(id, event.target.value)} + /> + ) : null} +
+ ); + })} + {error || message.error ?
{error || message.error}
: null} + {!answered ? ( +
+ + +
+ ) : null} +
+
+ ); +} + +function ChatMessage({ message, now, onPreviewImage, onDeleteMessage, onSubmitUserInput }) { const [copied, setCopied] = useState(false); const copiedTimerRef = useRef(null); @@ -3977,6 +4104,9 @@ function ChatMessage({ message, now, onPreviewImage, onDeleteMessage }) { } }, []); + if (message.role === 'user_input_request') { + return ; + } if (message.role === 'activity') { return ; } @@ -4025,7 +4155,7 @@ function ChatMessage({ message, now, onPreviewImage, onDeleteMessage }) { ); } -function ChatPane({ messages, selectedSession, running, now, onPreviewImage, onDeleteMessage }) { +function ChatPane({ messages, selectedSession, running, now, onPreviewImage, onDeleteMessage, onSubmitUserInput }) { const paneRef = useRef(null); const contentRef = useRef(null); const bottomPinnedRef = useRef(true); @@ -4119,6 +4249,7 @@ function ChatPane({ messages, selectedSession, running, now, onPreviewImage, onD now={now} onPreviewImage={onPreviewImage} onDeleteMessage={onDeleteMessage} + onSubmitUserInput={onSubmitUserInput} /> ))} @@ -4353,8 +4484,14 @@ function Composer({ models, selectedModel, onSelectModel, + selectedModelSpeed, + onSelectModelSpeed, selectedReasoningEffort, onSelectReasoningEffort, + followUpMode, + onSelectFollowUpMode, + composerMode, + onSelectComposerMode, skills, selectedSkillPaths, onToggleSkill, @@ -4365,6 +4502,7 @@ function Composer({ attachments, onUploadFiles, onRemoveAttachment, + dropActive, fileMentions, onAddFileMention, onRemoveFileMention, @@ -4381,6 +4519,7 @@ function Composer({ const imageInputRef = useRef(null); const fileInputRef = useRef(null); const [openMenu, setOpenMenu] = useState(null); + const [modelMenuPanel, setModelMenuPanel] = useState('root'); const [skillFilter, setSkillFilter] = useState(''); const [cursorPosition, setCursorPosition] = useState(0); const [fileSearch, setFileSearch] = useState({ query: '', loading: false, results: [] }); @@ -4417,6 +4556,7 @@ function Composer({ uploading, desktopBridge, steerable: runStatus?.steerable !== false, + followUpMode, sessionIsDraft: isDraftSession(selectedSession) }); const stopMode = sendState.mode === 'abort'; @@ -4490,6 +4630,12 @@ function Composer({ } function runSlashCommand(command) { + if (command.action === 'set-mode') { + onSelectComposerMode?.(command.mode || 'chat'); + replaceCurrentToken(''); + setOpenMenu(null); + return; + } replaceCurrentToken(command.prompt ? `${command.prompt} ` : ''); if (command.action === 'open-context') { setOpenMenu('context'); @@ -4532,6 +4678,9 @@ function Composer({ } function toggleMenu(name) { + if (name === 'model') { + setModelMenuPanel('root'); + } setOpenMenu((current) => (current === name ? null : name)); if (name !== 'skill') { setSkillFilter(''); @@ -4547,6 +4696,23 @@ function Composer({ setOpenMenu(null); } + function uploadFiles(files) { + if (!files.length) { + return; + } + onUploadFiles(files); + setOpenMenu(null); + } + + function handlePaste(event) { + const files = filesFromClipboardEvent(event); + if (!files.length) { + return; + } + event.preventDefault(); + uploadFiles(files); + } + const tokenPanelOpen = !openMenu && composerToken && ( (composerToken.type === 'slash' && slashMatches.length > 0) || (composerToken.type === 'skill') || @@ -4554,7 +4720,10 @@ function Composer({ ); return ( -
+ 文件 + + ) : null} {openMenu === 'permission' ? ( @@ -4600,6 +4779,24 @@ function Composer({ ))} ) : null} + {openMenu === 'composer-mode' ? ( +
+ {COMPOSER_MODE_OPTIONS.map((option) => ( + + ))} +
+ ) : null} {openMenu === 'skill' ? (
@@ -4648,37 +4845,88 @@ function Composer({ ) : null} {openMenu === 'model' ? (
-
模型
- {modelList.map((model) => ( - - ))} -
-
智能
- {REASONING_OPTIONS.map((option) => ( - - ))} + {modelMenuPanel === 'root' ? ( + <> +
智能
+ {REASONING_OPTIONS.map((option) => ( + + ))} +
+ + + + ) : null} + {modelMenuPanel === 'model' ? ( + <> + +
+ {modelList.map((model) => ( + + ))} + + ) : null} + {modelMenuPanel === 'speed' ? ( + <> + +
+ {MODEL_SPEED_OPTIONS.map((option) => ( + + ))} + + ) : null}
) : null} {openMenu === 'context' ? ( @@ -4776,33 +5024,38 @@ function Composer({ )} {openMenu === 'send-mode' ? (
+
跟进行为
@@ -4816,12 +5069,18 @@ function Composer({ > - 中止并发送 - 停下当前任务,用这条消息重新引导 + 停止并发送 + 停止当前任务,用这条消息重新开始
) : null} + {dropActive ? ( + + ) : null}
{attachments.length || selectedFileMentions.length ? (
@@ -4857,6 +5116,7 @@ function Composer({ onClick={updateCursorFromTextarea} onKeyUp={updateCursorFromTextarea} onFocus={() => setOpenMenu(null)} + onPaste={handlePaste} placeholder="给 Codex 发送消息" />
@@ -4864,8 +5124,25 @@ function Composer({ - +