diff --git a/apps/app/package.json b/apps/app/package.json index 7fdcf3a11..5cbea6062 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,7 +1,7 @@ { "name": "@ipollowork/app", "private": true, - "version": "0.17.23", + "version": "0.17.25", "type": "module", "scripts": { "test": "bun test --isolate tests/", @@ -50,7 +50,7 @@ "@ipollowork/types": "workspace:*", "@ipollowork/ui": "workspace:*", "@lexical/react": "^0.35.0", - "@opencode-ai/sdk": "^1.17.11", + "@opencode-ai/sdk": "^1.18.3", "@paper-design/shaders-react": "0.0.72", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-use-controllable-state": "^1.2.2", diff --git a/apps/app/src/app/types.ts b/apps/app/src/app/types.ts index e7954549e..71c4e82a7 100644 --- a/apps/app/src/app/types.ts +++ b/apps/app/src/app/types.ts @@ -95,7 +95,7 @@ export type MessageGroup = export type PromptMode = "prompt" | "shell"; export type ComposerPart = - | { type: "text"; text: string } + | { type: "text"; text: string; synthetic?: boolean } | { type: "agent"; name: string } | { type: "skill"; name: string } | { type: "file"; path: string; label?: string } diff --git a/apps/app/src/components/chat/message-list.tsx b/apps/app/src/components/chat/message-list.tsx index b1279fc54..ecc3cacc6 100644 --- a/apps/app/src/components/chat/message-list.tsx +++ b/apps/app/src/components/chat/message-list.tsx @@ -22,6 +22,7 @@ import { import type { SessionStatus } from "@opencode-ai/sdk/v2/client" import { openDesktopUrl } from "@/app/lib/desktop" import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX } from "@/app/types" +import { t } from "@/i18n" import { ApplyPatchTool } from "@/components/tools/apply-patch" import { BashTool } from "@/components/tools/bash" import { EditTool } from "@/components/tools/edit" @@ -126,7 +127,7 @@ class ToolMessage extends React.Component render() { if (this.state.failed) { return ( -
Tool step unavailable
+
{t("chat.tool_step_unavailable")}
) } return @@ -288,11 +289,11 @@ function CopyMessageButton({ messages }: CopyMessageButtonProps) { } return ( - + ) : null} - + - + - + @@ -378,44 +383,44 @@ export function ExtensionDetailModal({ {oauth ? (
- Authentication - OAuth required + {t("extension_detail.authentication")} + {t("extension_detail.oauth_required")}
) : null}
- Status + {t("extension_detail.status")} {connected - ? connectedLabel ?? (kind === "skill" || kind === "plugin" ? "Installed" : "Connected") + ? connectedLabel ?? (kind === "skill" || kind === "plugin" ? t("extension_detail.installed") : t("extension_detail.connected")) : connecting - ? connectingLabel - : disconnectedLabel ?? (kind === "skill" || kind === "plugin" ? "Not installed" : "Not connected")} + ? resolvedConnectingLabel + : disconnectedLabel ?? (kind === "skill" || kind === "plugin" ? t("extension_detail.not_installed") : t("extension_detail.not_connected"))}
- Visibility - {hidden ? "Hidden" : "Shown"} + {t("extension_detail.visibility")} + {hidden ? t("extension_detail.hidden") : t("extension_detail.shown")}
{preview ? (
- Release stage - Preview + {t("extension_detail.release_stage")} + {t("extension_detail.preview")}
) : null} {beta ? (
- Release stage - Alpha + {t("extension_detail.release_stage")} + {t("extension_detail.alpha")}
) : null} {disabledReason ? (
- Availability + {t("extension_detail.availability")} {disabledReason}
) : null} @@ -429,7 +434,7 @@ export function ExtensionDetailModal({ {kind === "skill" && trigger ? ( - Trigger + {t("extension_detail.trigger")}
@@ -449,7 +454,7 @@ export function ExtensionDetailModal({ return (
- Skill content + {t("extension_detail.skill_content")}
@@ -462,11 +467,11 @@ export function ExtensionDetailModal({ {showEnablementCard && ((kind !== "skill" && kind !== "ui-control") || (!trigger && !contentPreview && kind !== "ui-control")) ? ( - What this enables + {t("extension_detail.what_enables")}
- {kindDesc[kind]} + {translatedKindDescription}
@@ -488,7 +493,7 @@ export function ExtensionDetailModal({ onClose(); }} > - Show + {t("extension_detail.show")} ) : !hidden && onHide ? ( ) : null}
}> - Close + {t("extension_detail.close")} {connected && onUninstall ? ( ) : null} {!connected && onConnect ? ( @@ -527,10 +532,10 @@ export function ExtensionDetailModal({ {connecting ? ( <> - {connectingLabel} + {resolvedConnectingLabel} ) : ( - connectLabel + resolvedConnectLabel )} ) : null} @@ -555,13 +560,13 @@ function UiControlConnectionDetails(props: UiControlConnectionDetailsProps) {
- How to connect another client + {t("extension_detail.connect_other_client")}
-
iPolloWork desktop starts a private localhost bridge automatically.
-
Your MCP client starts ipollowork-ui-mcp over stdio; the wrapper discovers the bridge and proxies UI tools to it.
-
Do not point clients at the random localhost bridge URL directly.
+
{t("extension_detail.bridge_started")}
+
{t("extension_detail.client_starts").split("ipollowork-ui-mcp")[0]}ipollowork-ui-mcp{t("extension_detail.client_starts").split("ipollowork-ui-mcp")[1]}
+
{t("extension_detail.do_not_point")}
@@ -590,7 +595,7 @@ function UiControlConnectionDetails(props: UiControlConnectionDetailsProps) { - Discovery + {t("extension_detail.discovery")}
@@ -598,7 +603,7 @@ function UiControlConnectionDetails(props: UiControlConnectionDetailsProps) { - Production discovery file + {t("extension_detail.production_discovery")} ~/Library/Application Support/com.differentai.ipollowork/ipollowork-ui-control.json @@ -606,7 +611,7 @@ function UiControlConnectionDetails(props: UiControlConnectionDetailsProps) { - Dev discovery file + {t("extension_detail.dev_discovery")} ~/Library/Application Support/com.differentai.ipollowork.dev/ipollowork-ui-control.json @@ -614,7 +619,7 @@ function UiControlConnectionDetails(props: UiControlConnectionDetailsProps) { - Override + {t("extension_detail.override")} IPOLLOWORK_UI_CONTROL_DISCOVERY=/path/to/ipollowork-ui-control.json @@ -623,7 +628,7 @@ function UiControlConnectionDetails(props: UiControlConnectionDetailsProps) { {props.environment?.IPOLLOWORK_UI_CONTROL_DISCOVERY ? ( - Current override + {t("extension_detail.current_override")} {props.environment.IPOLLOWORK_UI_CONTROL_DISCOVERY} diff --git a/apps/app/src/react-app/design-system/web-unavailable-surface.tsx b/apps/app/src/react-app/design-system/web-unavailable-surface.tsx index 46f6ccf7f..ee2ddf667 100644 --- a/apps/app/src/react-app/design-system/web-unavailable-surface.tsx +++ b/apps/app/src/react-app/design-system/web-unavailable-surface.tsx @@ -1,6 +1,7 @@ /** @jsxImportSource react */ import type { ReactNode } from "react"; import { ArrowUpRight } from "lucide-react"; +import { t } from "@/i18n"; export type WebUnavailableSurfaceProps = { unavailable: boolean; @@ -10,9 +11,6 @@ export type WebUnavailableSurfaceProps = { contentClassName?: string; }; -const MESSAGE = - "This feature is currently unavailable in iPolloWork Web, check iPolloWork Desktop for full functionality."; - export function WebUnavailableSurface(props: WebUnavailableSurfaceProps) { const innerProps = props.unavailable ? { @@ -35,14 +33,14 @@ export function WebUnavailableSurface(props: WebUnavailableSurfaceProps) { } > diff --git a/apps/app/src/react-app/domains/connections/modals/claude-plugin-import-modal.tsx b/apps/app/src/react-app/domains/connections/modals/claude-plugin-import-modal.tsx index 39e98d37b..f843bee63 100644 --- a/apps/app/src/react-app/domains/connections/modals/claude-plugin-import-modal.tsx +++ b/apps/app/src/react-app/domains/connections/modals/claude-plugin-import-modal.tsx @@ -12,6 +12,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { t } from "@/i18n"; import { TextInput } from "../../../design-system/text-input"; import type { iPolloWorkClaudePluginPreview } from "../../../../app/lib/ipollowork-server"; @@ -58,11 +59,11 @@ function reducer(state: ModalState, action: ModalAction): ModalState { return { ...state, ...action }; } -const COMPONENT_LABELS: Record = { - mcp: { singular: "MCP server", plural: "MCP servers" }, - skill: { singular: "Skill", plural: "Skills" }, - command: { singular: "Command", plural: "Commands" }, - agent: { singular: "Agent", plural: "Agents" }, +const COMPONENT_LABEL_KEYS: Record = { + mcp: "claude_plugin.component_mcp", + skill: "claude_plugin.component_skill", + command: "claude_plugin.component_command", + agent: "claude_plugin.component_agent", }; export function ClaudePluginImportModal(props: ClaudePluginImportModalProps) { @@ -77,7 +78,7 @@ export function ClaudePluginImportModal(props: ClaudePluginImportModalProps) { const handlePreview = async () => { const url = state.url.trim(); if (!url) { - dispatch({ error: "Enter a GitHub repository URL." }); + dispatch({ error: t("claude_plugin.invalid_url") }); return; } dispatch({ previewing: true, error: null, preview: null, previewedUrl: null }); @@ -87,7 +88,7 @@ export function ClaudePluginImportModal(props: ClaudePluginImportModalProps) { } catch (error) { dispatch({ previewing: false, - error: error instanceof Error ? error.message : "Failed to load plugin preview", + error: error instanceof Error ? error.message : t("claude_plugin.load_preview_failed"), }); } }; @@ -106,7 +107,7 @@ export function ClaudePluginImportModal(props: ClaudePluginImportModalProps) { } catch (error) { dispatch({ installing: false, - error: error instanceof Error ? error.message : "Failed to install plugin", + error: error instanceof Error ? error.message : t("claude_plugin.install_failed"), }); return; } @@ -134,10 +135,9 @@ export function ClaudePluginImportModal(props: ClaudePluginImportModalProps) { > - Install a plugin from GitHub + {t("claude_plugin.title")} - Works with Claude Code plugins: a repo with .claude-plugin/plugin.json bundling an MCP - server, skills, and commands. + {t("claude_plugin.description")} @@ -145,7 +145,7 @@ export function ClaudePluginImportModal(props: ClaudePluginImportModalProps) {
@@ -163,7 +163,7 @@ export function ClaudePluginImportModal(props: ClaudePluginImportModalProps) { ) : ( )} - Preview + {t("claude_plugin.preview")}
@@ -186,14 +186,12 @@ export function ClaudePluginImportModal(props: ClaudePluginImportModalProps) {
-
Will install
+
{t("claude_plugin.will_install")}
{groups.map((group) => (
- {group.items.length === 1 - ? `1 ${COMPONENT_LABELS[group.type]?.singular}` - : `${group.items.length} ${COMPONENT_LABELS[group.type]?.plural}`} + {t(COMPONENT_LABEL_KEYS[group.type] ?? "claude_plugin.component_skill", { count: group.items.length })}
    {group.items.map((item) => ( @@ -232,7 +230,7 @@ export function ClaudePluginImportModal(props: ClaudePluginImportModalProps) { render={ diff --git a/apps/app/src/react-app/domains/session/artifacts/artifact-panel.tsx b/apps/app/src/react-app/domains/session/artifacts/artifact-panel.tsx index f6877054e..284f30414 100644 --- a/apps/app/src/react-app/domains/session/artifacts/artifact-panel.tsx +++ b/apps/app/src/react-app/domains/session/artifacts/artifact-panel.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/dropdown-menu"; import { toast } from "@/components/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { t } from "@/i18n"; import { cn, formatFileSize } from "@/lib/utils"; import { type ArtifactPanelTab, usePanelTabStore } from "../panel/panel-tab-store"; import { isCollectibleArtifactTarget, type BinaryData, type Data, type OpenTarget, type TextData } from "./open-target"; @@ -443,7 +444,7 @@ function ArtifactPanelView({ client, workspaceId, workspaceRoot, isRemoteWorkspa }, onError: (cause, input) => { if (input.kind === "text") setAutoSaveBlockedDraft(input.data); - toast.error(cause instanceof Error ? cause.message : "Could not save this file."); + toast.error(cause instanceof Error ? cause.message : t("artifact.could_not_save_file")); }, }); @@ -566,12 +567,12 @@ function ArtifactPanelView({ client, workspaceId, workspaceRoot, isRemoteWorkspa }; const saveStatus = isSaving - ? "Saving…" + ? t("artifact.status_saving") : data?.kind === "text" && draft === data.data - ? "Saved" + ? t("artifact.status_saved") : draft === autoSaveBlockedDraft - ? "Save failed" - : "Unsaved"; + ? t("artifact.status_save_failed") + : t("artifact.status_unsaved"); const saveSpreadsheetContent = async (payload: Data) => { if (target.kind !== "file") { @@ -602,7 +603,7 @@ function ArtifactPanelView({ client, workspaceId, workspaceRoot, isRemoteWorkspa {target.name} - {target.exists === false ? "missing" : target.size !== undefined ? `${formatFileSize(target.size)}` : ""} + {target.exists === false ? t("artifact.missing") : target.size !== undefined ? `${formatFileSize(target.size)}` : ""}
@@ -612,10 +613,10 @@ function ArtifactPanelView({ client, workspaceId, workspaceRoot, isRemoteWorkspa type="button" className={cn( "rounded-lg px-2 py-1 text-[11px] transition-colors", - saveStatus === "Save failed" ? "text-destructive hover:bg-destructive/10" : "text-muted-foreground", + saveStatus === t("artifact.status_save_failed") ? "text-destructive hover:bg-destructive/10" : "text-muted-foreground", )} - disabled={saveStatus !== "Save failed"} - title={saveStatus === "Save failed" ? "Retry save" : saveStatus} + disabled={saveStatus !== t("artifact.status_save_failed")} + title={saveStatus === t("artifact.status_save_failed") ? t("artifact.retry_save") : saveStatus} onClick={() => { if (target.kind !== "file") return; setAutoSaveBlockedDraft(null); @@ -640,29 +641,29 @@ function ArtifactPanelView({ client, workspaceId, workspaceRoot, isRemoteWorkspa }} disabled={isSaving} > - Discard + {t("artifact.discard")} )} /> - Discard changes + {t("artifact.discard_changes")} void save()} disabled={isSaving || draft === data.data}>{isSaving ? "Saving" : "Save"} + )} /> - Save changes + {t("artifact.save_changes")} ) : ( setEditing(true)}>Edit + )} /> - Edit artifact + {t("artifact.edit_artifact")} ) ) : null} @@ -670,7 +671,7 @@ function ArtifactPanelView({ client, workspaceId, workspaceRoot, isRemoteWorkspa + )} @@ -688,45 +689,45 @@ function ArtifactPanelView({ client, workspaceId, workspaceRoot, isRemoteWorkspa void download()} aria-label="Download artifact"> + )} /> - Download artifact + {t("artifact.download_artifact")} ) : null} {target.kind === "file" && !isRemoteWorkspace ? ( void revealExternal()} aria-label="Show in folder"> + )} /> - Show in folder + {t("artifact.show_in_folder")} ) : null} void openExternal()} aria-label={isRemoteWorkspace ? "Download artifact" : "Open externally"}> + )} /> - {isRemoteWorkspace ? "Download artifact" : "Open externally"} + {isRemoteWorkspace ? t("artifact.download_artifact") : t("artifact.open_externally")} void close()} aria-label="Close artifact"> + )} /> - Close artifact + {t("artifact.close_artifact")}
@@ -735,7 +736,7 @@ function ArtifactPanelView({ client, workspaceId, workspaceRoot, isRemoteWorkspa {isLoading || (data?.kind === "binary" && !binaryObjectUrl) ? ( ) : isError ? ( - + ) : data?.kind === "text" && (editing || isDirectTextEdit) ? ( ) : target.preview === "markdown" && data?.kind === "text" ? ( diff --git a/apps/app/src/react-app/domains/session/artifacts/artifact-spreadsheet-editor.tsx b/apps/app/src/react-app/domains/session/artifacts/artifact-spreadsheet-editor.tsx index 4542af8a8..53effada8 100644 --- a/apps/app/src/react-app/domains/session/artifacts/artifact-spreadsheet-editor.tsx +++ b/apps/app/src/react-app/domains/session/artifacts/artifact-spreadsheet-editor.tsx @@ -4,6 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { Loader2, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { t } from "@/i18n"; import { parseSpreadsheet, serializeSpreadsheet, type SpreadsheetRows } from "./artifact-spreadsheet-model"; import { cn } from "@/lib/utils"; import type { Data } from "./open-target"; @@ -98,7 +99,7 @@ export function ArtifactSpreadsheetEditor(props: ArtifactSpreadsheetEditorProps) if (error) { return (
- {error instanceof Error ? error.message : "Failed to parse spreadsheet"} + {error instanceof Error ? error.message : t("artifact.failed_to_parse_spreadsheet")}
); } @@ -106,11 +107,11 @@ export function ArtifactSpreadsheetEditor(props: ArtifactSpreadsheetEditorProps) return (
- - + +
- - + +
diff --git a/apps/app/src/react-app/domains/session/artifacts/preview.tsx b/apps/app/src/react-app/domains/session/artifacts/preview.tsx index 86e81d0e4..9a50f91fd 100644 --- a/apps/app/src/react-app/domains/session/artifacts/preview.tsx +++ b/apps/app/src/react-app/domains/session/artifacts/preview.tsx @@ -3,6 +3,7 @@ import type * as React from "react"; import { Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; +import { t } from "@/i18n"; import { MarkdownBlock } from "../surface/markdown"; interface PreviewLoadingProps extends React.ComponentProps<"div"> {} @@ -94,5 +95,5 @@ export function ImagePreview({ src, alt, className, ...props }: ImagePreviewProp interface PreviewUnavailableProps extends React.ComponentProps<"div"> {} export function PreviewUnavailable({ className, ...props }: PreviewUnavailableProps) { - return
Preview unavailable. Open externally to view this file.
; + return
{t("artifact.preview_unavailable")}
; } diff --git a/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx b/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx index 6dce8b9a6..319e9f25c 100644 --- a/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx +++ b/apps/app/src/react-app/domains/session/chat/permission-approval-modal.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource react */ -import { Check, ChevronRight, Clock3, HardDrive, RefreshCcw, ShieldCheck, XCircle } from "lucide-react"; +import { Check, ChevronDown, ChevronRight, Clock3, HardDrive, RefreshCcw, ShieldCheck, XCircle } from "lucide-react"; import { useEffect, useMemo, useRef, type KeyboardEvent } from "react"; import { @@ -12,8 +12,15 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { t } from "@/i18n"; import type { PendingPermission } from "@/app/types"; +import { cn } from "@/lib/utils"; type PermissionPresentation = { title: string; @@ -22,6 +29,7 @@ type PermissionPresentation = { scopeLabel: string; scopeValue: string; isDoomLoop: boolean; + isExternalDirectory: boolean; note: string | null; }; @@ -38,6 +46,50 @@ type PermissionApprovalModalProps = { safeStringify?: (value: unknown) => string; }; +type PermissionAllowMenuProps = { + permissionId: string; + busy?: boolean; + respondPermission?: PermissionApprovalModalProps["respondPermission"]; + variant?: "default" | "outline"; + className?: string; +}; + +function PermissionAllowMenu(props: PermissionAllowMenuProps) { + const disabled = props.busy || !props.respondPermission; + const triggerClassName = cn("min-w-0 whitespace-nowrap", props.className); + + return ( + + + + {t("session.allow_once")} + + + )} + /> + + props.respondPermission?.(props.permissionId, "once")}> + + {t("session.allow_once")} + + props.respondPermission?.(props.permissionId, "always")}> + + {t("session.allow_for_session")} + + + + ); +} + const metadataDetailKeys: Array<{ key: string; labelKey: string; multiline?: boolean }> = [ { key: "command", labelKey: "session.permission_detail_command", multiline: true }, { key: "description", labelKey: "session.permission_detail_description" }, @@ -177,18 +229,21 @@ function describePermissionRequest(permission: PendingPermission): PermissionPre scopeLabel: tool ? t("session.doom_loop_tool_label") : t("session.doom_loop_repeated_call_label"), scopeValue: tool ?? (patterns.length ? patterns.join(", ") : t("session.doom_loop_repeated_tool_call")), isDoomLoop: true, + isExternalDirectory: false, note: t("session.doom_loop_note"), }; } const copy = permissionCopy(permission.permission); + const isExternalDirectory = permission.permission === "external_directory"; return { title: copy.title, message: copy.message, permissionLabel: readablePermissionLabel(permission.permission), - scopeLabel: t("session.scope_label"), + scopeLabel: isExternalDirectory ? t("session.permission_detail_path") : t("session.scope_label"), scopeValue: patterns.join(", ") || t("session.permission_scope_empty"), isDoomLoop: false, + isExternalDirectory, note: null, }; } @@ -337,7 +392,7 @@ export function PermissionApprovalModal(props: PermissionApprovalModalProps) {

{t("session.permission_decision_hint")}

-
+
{t("session.deny")} - props.respondPermission?.(props.permission.id, "once")} - disabled={props.busy || !props.respondPermission} - > - - {t("session.allow_once")} - - props.respondPermission?.(props.permission.id, "always")} - disabled={props.busy || !props.respondPermission} - > - - {t("session.allow_for_session")} - +
@@ -379,57 +425,50 @@ export function PermissionApprovalPanel(props: PermissionApprovalModalProps) { const Icon = presentation.isDoomLoop ? RefreshCcw : ShieldCheck; return ( -
-
-
-
- -
-
-
{presentation.title}
-
{presentation.message}
- {presentation.note ? ( -
{presentation.note}
- ) : null} -
+
+
+
+
+
- -
- - - +
+
{presentation.title}
+
{presentation.message}
+ {presentation.note ? ( +
{presentation.note}
+ ) : null}
-
-
+
+ + +
+
+ +
+
+ {!presentation.isExternalDirectory ? (
{t("session.permission_label")} @@ -438,28 +477,29 @@ export function PermissionApprovalPanel(props: PermissionApprovalModalProps) { {presentation.permissionLabel}
-
-
- {presentation.scopeLabel} -
-
- {presentation.scopeValue} -
+ ) : null} +
+
+ {presentation.scopeLabel} +
+
+ {presentation.scopeValue}
- - {hasMetadata ? ( -
- - {t("session.details_label")} - - -
-                {stringifyMetadata(metadata, props.safeStringify)}
-              
-
- ) : null}
+ + {hasMetadata ? ( +
+ + {t("session.details_label")} + + +
+              {stringifyMetadata(metadata, props.safeStringify)}
+            
+
+ ) : null} +
); } diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index f63a0642e..b8cc178e6 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -603,18 +603,28 @@ export function SessionPage(props: SessionPageProps) { ...brief, }, null, 2), baseUpdatedAt: null, - }); - setTemplateSessionData((current) => current ? { ...current, hasBrief: true } : current); - setTemplateSessionRevision((value) => value + 1); - setDismissedTemplateBriefSessionIds((current) => { + }); + setTemplateSessionData((current) => current ? { ...current, hasBrief: true } : current); + setTemplateSessionRevision((value) => value + 1); + setDismissedTemplateBriefSessionIds((current) => { if (!props.selectedSessionId || !current.has(props.selectedSessionId)) return current; const next = new Set(current); next.delete(props.selectedSessionId); return next; - }); - const prompt = templateBriefPrompt({ template, entryPath: state.entry, briefPath: state.briefPath }); - props.surface?.onSendDraft({ mode: "prompt", parts: [{ type: "text", text: prompt }], attachments: [], text: prompt }, props.selectedSessionId); - }, [props.ipolloworkServerClient, props.runtimeWorkspaceId, props.selectedSessionId, props.surface, templateSessionData]); + }); + const prompt = templateBriefPrompt({ template, entryPath: state.entry, briefPath: state.briefPath }); + const visibleTemplateMessage = t("templates.applied", { title: template.title }); + props.surface?.onSendDraft({ + mode: "prompt", + parts: [ + { type: "text", text: visibleTemplateMessage }, + { type: "text", text: prompt, synthetic: true }, + ], + attachments: [], + text: visibleTemplateMessage, + resolvedText: visibleTemplateMessage, + }, props.selectedSessionId); + }, [props.ipolloworkServerClient, props.runtimeWorkspaceId, props.selectedSessionId, props.surface, templateSessionData]); const closeTemplateBrief = useCallback(async () => { const sessionId = props.selectedSessionId; if (!sessionId) return; @@ -1741,15 +1751,23 @@ export function SessionPage(props: SessionPageProps) {
- ) : props.selectedSessionId ? ( -
- {t("session.loading_detail")} -
- ) : ( -
- -
- )} + ) : props.selectedSessionId ? ( +
+ {t("session.loading_detail")} +
+ ) : ( +
+
+ +
+ {t("session.preparing_workspace")} +
+

+ {t("session.loading_detail")} +

+
+
+ )}
) : null}
diff --git a/apps/app/src/react-app/domains/session/design/design-html-runtime.ts b/apps/app/src/react-app/domains/session/design/design-html-runtime.ts index 23b16c6a4..d0f197b62 100644 --- a/apps/app/src/react-app/domains/session/design/design-html-runtime.ts +++ b/apps/app/src/react-app/domains/session/design/design-html-runtime.ts @@ -176,7 +176,7 @@ function designNavigationRuntime(channel: string, editing: boolean) { || inlineAction.match(/window\.open\(\s*['\"]([^'\"]+)['\"]/i)?.[1] || ""; const label = control?.textContent?.trim().toLowerCase() || ""; - const conventionalHref = /^(?:??|??|sign\s*in|log\s*in)$/.test(label) ? "login.html" : ""; + const conventionalHref = /^(?:login|sign\s*in|log\s*in|登录|登入)$/.test(label) ? "login.html" : ""; const href = anchor?.getAttribute("href")?.trim() || control?.getAttribute("data-href")?.trim() || control?.getAttribute("data-url")?.trim() @@ -189,7 +189,7 @@ function designNavigationRuntime(channel: string, editing: boolean) { mobileHeader.dataset.menuOpen = "false"; const mobileToggle = mobileHeader.querySelector(".mobile-nav-toggle"); mobileToggle?.setAttribute("aria-expanded", "false"); - if (mobileToggle) mobileToggle.setAttribute("aria-label", mobileToggle.getAttribute("aria-label")?.includes("??") ? "????" : "Open navigation"); + if (mobileToggle) mobileToggle.setAttribute("aria-label", mobileToggle.getAttribute("aria-label")?.includes("关闭") ? "打开导航" : "Open navigation"); } event.stopPropagation(); if (href.startsWith("#")) { diff --git a/apps/app/src/react-app/domains/session/panel/side-panel.tsx b/apps/app/src/react-app/domains/session/panel/side-panel.tsx index 6ea1d7246..bfa2a575f 100644 --- a/apps/app/src/react-app/domains/session/panel/side-panel.tsx +++ b/apps/app/src/react-app/domains/session/panel/side-panel.tsx @@ -315,13 +315,13 @@ function BrowserPanelContent({ size="icon-sm" onClick={back} disabled={!tab.canGoBack} - aria-label="Go back" + aria-label={t("side_panel.back")} > )} /> - Back + {t("side_panel.back")} )} /> - Forward + {t("side_panel.forward")} {tab.status === "loading" ? : } )} /> - Reload + {t("side_panel.reload")} { urlFocusedRef.current = false; }} - placeholder="Enter URL..." + placeholder={t("side_panel.enter_url")} spellCheck={false} autoComplete="off" /> @@ -380,15 +380,15 @@ function BrowserPanelContent({ ) : (

- Browser panel is only available in the desktop app. + {t("side_panel.desktop_only")}

)} @@ -584,7 +584,7 @@ export function SidePanel({ @@ -592,7 +592,7 @@ export function SidePanel({ /> )} /> - Add side panel entry + {t("side_panel.add_entry")} -

Open an artifact or browser tab to get started.

+

{t("side_panel.empty_state")}

); } diff --git a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx index 0d2ecde10..41927f7aa 100644 --- a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx +++ b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx @@ -1423,7 +1423,7 @@ export function ReactSessionComposer(props: ComposerProps) { {([ ["commands", t("dashboard.commands")], ["skills", t("dashboard.skills")], - ["extensions", "Extensions"], + ["extensions", t("composer.extensions_label")], ["mcps", t("composer.mcps_label")], ] as const).map(([section, label]) => (
{entry.description}
@@ -1561,7 +1561,7 @@ export function ReactSessionComposer(props: ComposerProps) { ))}
) : ( -
No extensions enabled. Open Extensions to enable them.
+
{t("composer.no_extensions_enabled")}
) ) : null} {activePlugin ? ( @@ -1587,11 +1587,11 @@ export function ReactSessionComposer(props: ComposerProps) { ))} ) : ( -
No plugin files imported yet.
+
{t("composer.no_plugin_files")}
) ) : toolMenuSection.startsWith("plugin:") ? (
- {!pluginsLoaded && pluginsLoading ? t("composer.loading_commands") : "Plugin files are unavailable."} + {!pluginsLoaded && pluginsLoading ? t("composer.loading_commands") : t("composer.plugin_files_unavailable")}
) : null} @@ -1661,7 +1661,7 @@ export function ReactSessionComposer(props: ComposerProps) { disabled={props.busy} /> {props.modelUnavailable ? ( - Model no longer available + {t("composer.model_unavailable")} ) : null} = MIN_QUERY_LENGTH && totalMatches === 0; const counterText = activeQuery.length < MIN_QUERY_LENGTH ? "" - : totalMatches === 0 - ? "No matches" + : noMatches + ? t("find.no_matches") : `${activeIndex + 1}/${totalMatches}`; return ( @@ -316,12 +318,12 @@ export function SessionFindBar({ } }} className="h-7 w-48 bg-transparent px-1 text-sm text-dls-text outline-none placeholder:text-dls-secondary sm:h-8 sm:w-56" - placeholder="Find in conversation" - aria-label="Find in conversation" + placeholder={t("find.in_conversation")} + aria-label={t("find.in_conversation")} /> {counterText} @@ -332,7 +334,7 @@ export function SessionFindBar({ type="button" variant="ghost" size="icon-xs" - aria-label="Previous match" + aria-label={t("find.previous_match")} disabled={totalMatches === 0} onMouseDown={(event) => event.preventDefault()} onClick={jumpToPrevious} @@ -341,7 +343,7 @@ export function SessionFindBar({ } /> - Previous match (⇧↵) + {t("find.previous_match_hint")} event.preventDefault()} onClick={jumpToNext} @@ -359,7 +361,7 @@ export function SessionFindBar({ } /> - Next match (↵) + {t("find.next_match_hint")} event.preventDefault()} onClick={closeFind} > @@ -376,7 +378,7 @@ export function SessionFindBar({ } /> - Close (Esc) + {t("find.close_hint")} diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index 12f1f7387..abd680b64 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -209,11 +209,11 @@ function resolveFindOwnerSessionId() { return firstMountedSessionSurfaceId(); } -function statusLabel(snapshot: iPolloWorkSessionSnapshot | undefined, busy: boolean) { - if (busy) return "Running..."; - if (snapshot?.status.type === "busy") return "Running..."; - if (snapshot?.status.type === "retry") return `Retrying: ${snapshot.status.message}`; - return "Ready"; +function statusLabel(snapshot: iPolloWorkSessionSnapshot | undefined, busy: boolean) { + if (busy) return t("session.status_running"); + if (snapshot?.status.type === "busy") return t("session.status_running"); + if (snapshot?.status.type === "retry") return t("session.status_retrying", { message: snapshot.status.message }); + return t("session.status_ready"); } function controlTextArgument(args: unknown) { @@ -385,7 +385,7 @@ function SessionErrorCard({ error, onDismiss, onChangeModel, onOpenModelPicker } onDismiss(); }} > - Change model + {t("model_picker.change_model")} ) : null} @@ -394,7 +394,7 @@ function SessionErrorCard({ error, onDismiss, onChangeModel, onOpenModelPicker } type="button" className="shrink-0 rounded-full p-1 text-red-10 transition-colors hover:bg-red-3 hover:text-red-11" onClick={onDismiss} - aria-label="Dismiss error" + aria-label={t("session.dismiss_error")} > @@ -1341,8 +1341,8 @@ export function SessionSurface(props: SessionSurfaceProps) { className="mx-3 mb-2 flex w-[calc(100%-1.5rem)] items-center gap-2 rounded-lg border border-amber-7/40 bg-amber-2/30 px-3 py-2 text-left text-xs text-amber-11 transition-colors hover:bg-amber-3/40" onClick={() => props.onOpenSettingsSection?.("providers")} > - No AI model connected. - Add a provider to run tasks. + {t("session.no_model_connected")} + {t("session.add_provider_hint")} ) : null} @@ -1451,7 +1451,7 @@ export function SessionSurface(props: SessionSurfaceProps) { {model.transitionState === "switching" && showDelayedLoading ? (
- {model.renderSource === "cache" ? "Switching session from cache..." : "Switching session..."} + {model.renderSource === "cache" ? t("session.switching_from_cache") : t("session.switching")}
) : null} @@ -1514,7 +1514,7 @@ export function SessionSurface(props: SessionSurfaceProps) { {showDelayedLoading && pendingSessionLoad ? (
-
Opening session…
+
{t("session.opening")}
) : (snapshotQuery.isError || error) && !snapshot && renderedMessages.length === 0 ? ( @@ -1528,7 +1528,7 @@ export function SessionSurface(props: SessionSurfaceProps) { /> ) : (
- {snapshotQuery.error instanceof Error ? snapshotQuery.error.message : "Failed to load session."} + {snapshotQuery.error instanceof Error ? snapshotQuery.error.message : t("session.failed_to_load")}
)} diff --git a/apps/app/src/react-app/domains/session/sync/use-session-interactions.ts b/apps/app/src/react-app/domains/session/sync/use-session-interactions.ts index 4cd8efbb0..ccbeb0cca 100644 --- a/apps/app/src/react-app/domains/session/sync/use-session-interactions.ts +++ b/apps/app/src/react-app/domains/session/sync/use-session-interactions.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import type { iPolloWorkServerClient } from "@/app/lib/ipollowork-server"; import { unwrap } from "@/app/lib/opencode"; import type { Client, PendingPermission, PendingQuestion, TodoItem } from "@/app/types"; import { t } from "@/i18n"; @@ -28,10 +29,19 @@ export type UseSessionInteractionsInput = { workspaceId: string; sessionId: string | null; workspaceRoot: string; + ipolloworkServerClient?: iPolloWorkServerClient | null; + runtimeWorkspaceId?: string | null; }; export function useSessionInteractions(input: UseSessionInteractionsInput) { - const { client, workspaceId, sessionId, workspaceRoot } = input; + const { + client, + workspaceId, + sessionId, + workspaceRoot, + ipolloworkServerClient, + runtimeWorkspaceId, + } = input; const [permissionReplyBusy, setPermissionReplyBusy] = useState(false); const permissionReplyBusyRef = useRef(false); @@ -125,6 +135,21 @@ export function useSessionInteractions(input: UseSessionInteractionsInput) { setPermissionReplyBusy(true); try { const pendingPermission = pendingPermissions.find((permission) => permission.id === requestID); + if ( + reply === "always" && + pendingPermission?.permission === "external_directory" && + ipolloworkServerClient && + runtimeWorkspaceId + ) { + const requestedFolders = pendingPermission.patterns.filter((pattern) => pattern.trim().length > 0); + if (requestedFolders.length > 0) { + const current = await ipolloworkServerClient.listAuthorizedFolders(runtimeWorkspaceId); + await ipolloworkServerClient.setAuthorizedFolders(runtimeWorkspaceId, [ + ...current.folders, + ...requestedFolders, + ]); + } + } if (pendingPermission?.protocol === "v2") { const result = await client.v2.session.permission.reply({ sessionID: pendingPermission.sessionID, @@ -154,7 +179,15 @@ export function useSessionInteractions(input: UseSessionInteractionsInput) { setPermissionReplyBusy(false); } }, - [client, pendingPermissions, sessionId, workspaceId, workspaceRoot], + [ + client, + ipolloworkServerClient, + pendingPermissions, + runtimeWorkspaceId, + sessionId, + workspaceId, + workspaceRoot, + ], ); const activeQuestion = pendingQuestions[0] ?? null; diff --git a/apps/app/src/react-app/domains/session/video/video-panel.tsx b/apps/app/src/react-app/domains/session/video/video-panel.tsx index 7a4317e8c..e14d87424 100644 --- a/apps/app/src/react-app/domains/session/video/video-panel.tsx +++ b/apps/app/src/react-app/domains/session/video/video-panel.tsx @@ -5,6 +5,7 @@ import { AudioLines, Film, Layers3, Loader2, Play, RefreshCw, X } from "lucide-r import type { iPolloWorkServerClient } from "@/app/lib/ipollowork-server"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { t } from "@/i18n"; import { HYPERFRAMES_VERSION, hyperframesStudioPort, @@ -35,7 +36,7 @@ export function VideoPanel({ sessionId, workspaceRoot, client, workspaceId, isRe const terminalIdRef = React.useRef(null); const [revision, setRevision] = React.useState(0); const [status, setStatus] = React.useState<"starting" | "ready" | "failed">("starting"); - const [detail, setDetail] = React.useState(`Starting HyperFrames ${HYPERFRAMES_VERSION}...`); + const [detail, setDetail] = React.useState(() => t("video.starting_hyperframes", { version: HYPERFRAMES_VERSION })); const [studioFrameLoaded, setStudioFrameLoaded] = React.useState(false); const [studioChromeReady, setStudioChromeReady] = React.useState(false); const [simpleMode, setSimpleMode] = React.useState(true); @@ -67,18 +68,18 @@ export function VideoPanel({ sessionId, workspaceRoot, client, workspaceId, isRe React.useEffect(() => { setStatus("starting"); - setDetail(`Starting HyperFrames ${HYPERFRAMES_VERSION}...`); + setDetail(t("video.starting_hyperframes", { version: HYPERFRAMES_VERSION })); setStudioFrameLoaded(false); setStudioChromeReady(false); if (isRemoteWorkspace) { setStatus("failed"); - setDetail("Video Studio is available for local workspaces."); + setDetail(t("video.local_workspaces")); return; } const bridge = window.__IPOLLOWORK_ELECTRON__?.hyperframes; if (!bridge?.start || !bridge.stop) { setStatus("failed"); - setDetail("HyperFrames requires the iPolloWork desktop app."); + setDetail(t("video.requires_desktop")); return; } const startHyperframes = bridge.start; @@ -92,15 +93,15 @@ export function VideoPanel({ sessionId, workspaceRoot, client, workspaceId, isRe port: studioPort, }).then((result) => { if (disposed) return; - if (!result?.ok) throw new Error("Could not start HyperFrames."); + if (!result?.ok) throw new Error(t("video.could_not_start")); setStatus("ready"); - setDetail("Studio ready - click any canvas element to edit"); + setDetail(t("video.studio_ready")); setStudioFrameLoaded(false); setRevision((value) => value + 1); }).catch((cause) => { if (disposed) return; setStatus("failed"); - setDetail(cause instanceof Error ? cause.message : "Could not start HyperFrames."); + setDetail(cause instanceof Error ? cause.message : t("video.could_not_start")); }); return () => { @@ -114,27 +115,27 @@ export function VideoPanel({ sessionId, workspaceRoot, client, workspaceId, isRe
-

Video Studio

+

{t("video.title")}

- setVoicePanelOpen((open) => !open)} disabled={isRemoteWorkspace} aria-label="打开配音设置">} /> - 配音设置 + setVoicePanelOpen((open) => !open)} disabled={isRemoteWorkspace} aria-label={t("video.voice_settings")}>} /> + {t("video.voice_settings")} - { setVoicePanelOpen(true); setVoicePreviewRequest((value) => value + 1); }} disabled={isRemoteWorkspace} aria-label="试听当前配音">Preview} /> - 试听当前已选音色 + { setVoicePanelOpen(true); setVoicePreviewRequest((value) => value + 1); }} disabled={isRemoteWorkspace} aria-label={t("video.preview_voice")}>{t("video.preview")}} /> + {t("video.preview_voice")} - - - + + +
{isRemoteWorkspace ? ( -
Video Studio is available for local workspaces only.
+
{t("video.local_only")}
) : (
- {status === "starting" || (status === "ready" && !studioChromeReady) ?

{status === "starting" ? "Starting the native HyperFrames workspace..." : "Preparing the Video Studio..."}

: null} - {status === "ready" ?