diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index 8ddc9daa..392db41d 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -61,7 +61,21 @@ jobs: - name: Set workspace versions if: ${{ inputs.set_versions && inputs.version != '' }} - run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version + shell: bash + env: + NPM_CONFIG_FETCH_RETRIES: 5 + NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000 + NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000 + run: | + set -euo pipefail + for attempt in 1 2 3; do + if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then + exit 0 + fi + echo "npm version failed (attempt $attempt/3); retrying..." >&2 + sleep $((attempt * 10)) + done + exit 1 - name: Install dependencies run: npm ci --workspaces --include=optional @@ -72,6 +86,112 @@ jobs: - name: Build macOS binaries (Electron) run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app + - name: Ad-hoc sign Electron macOS app bundles (seal resources) + shell: bash + run: | + set -euo pipefail + + release_root="packages/electron-app/release" + apps=() + while IFS= read -r -d '' app; do + apps+=("$app") + done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0) + + if [ "${#apps[@]}" -eq 0 ]; then + echo "No CodeNomad.app found under $release_root" >&2 + exit 1 + fi + + # GitHub macOS runners typically have no signing identity. Without any signature, + # the shipped .app can fail Gatekeeper with: + # code has no resources but signature indicates they must be present + # Ad-hoc signing seals bundle resources and makes the signature internally consistent. + if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then + echo "No valid macOS codesigning identity found; applying ad-hoc signature" + for app in "${apps[@]}"; do + echo "codesign (adhoc): $app" + codesign --force --deep --sign - "$app" + codesign --verify --deep --strict --verbose=2 "$app" + done + else + echo "macOS codesigning identity present; skipping ad-hoc signing" + fi + + - name: Repackage Electron macOS zips (ditto) + shell: bash + run: | + set -euo pipefail + + # Prefer the workflow-provided version; fall back to package.json. + VERSION_TO_USE="${VERSION:-}" + if [ -z "$VERSION_TO_USE" ]; then + VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version") + fi + + release_root="packages/electron-app/release" + # macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`. + # Use find to locate built app bundles instead of ** globs. + apps=() + while IFS= read -r -d '' app; do + apps+=("$app") + done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0) + if [ "${#apps[@]}" -eq 0 ]; then + echo "No CodeNomad.app found under $release_root" >&2 + exit 1 + fi + + for app in "${apps[@]}"; do + bundle_dir=$(basename "$(dirname "$app")") + arch="x64" + if [[ "$bundle_dir" == *"arm64"* ]]; then + arch="arm64" + fi + + out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip" + rm -f "$out_zip" + echo "ditto -ck: $app -> $out_zip" + ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip" + done + + - name: Validate Electron macOS codesign (unzipped) + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + + tmp_dir=$(mktemp -d) + trap 'rm -rf "$tmp_dir"' EXIT + + zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip) + if [ "${#zips[@]}" -eq 0 ]; then + echo "No Electron macOS zip artifacts found to validate" >&2 + exit 1 + fi + + for zip in "${zips[@]}"; do + echo "Validating codesign for: $zip" + extract_dir="$tmp_dir/$(basename "$zip" .zip)" + mkdir -p "$extract_dir" + + # Use ditto for extraction as well to preserve bundle metadata. + ditto -x -k "$zip" "$extract_dir" + + app_path="" + for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do + if [ -d "$candidate" ]; then + app_path="$candidate" + break + fi + done + + if [ -z "$app_path" ]; then + echo "No .app found after extracting $zip" >&2 + exit 1 + fi + + codesign --verify --deep --strict --verbose=2 "$app_path" + done + - name: Upload release assets if: ${{ inputs.upload && inputs.tag != '' }} run: | diff --git a/package-lock.json b/package-lock.json index 99303498..d8ab5dee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.11.4", + "version": "0.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.11.4", + "version": "0.12.1", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -11985,7 +11985,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.11.4", + "version": "0.12.1", "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", @@ -11995,6 +11995,7 @@ "devDependencies": { "7zip-bin": "^5.2.0", "app-builder-bin": "^4.2.0", + "cross-env": "^7.0.3", "electron": "39.0.0", "electron-builder": "^24.0.0", "electron-vite": "4.0.1", @@ -12021,7 +12022,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.11.4", + "version": "0.12.1", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -12062,7 +12063,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.11.4", + "version": "0.12.1", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -12070,7 +12071,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.11.4", + "version": "0.12.1", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", diff --git a/package.json b/package.json index e2381e85..18ee0d30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.11.4", + "version": "0.12.1", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index 299fb24c..37efb960 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.11.1", + "minServerVersion": "0.11.4", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index a9b940c0..de00d1f5 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -431,7 +431,9 @@ export class CliProcessManager extends EventEmitter { if (options.dev) { const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000" - args.push("--ui-dev-server", devServer, "--log-level", "debug") + const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim() + const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info" + args.push("--ui-dev-server", devServer, "--log-level", logLevel) } return args diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index b1918510..48f10b14 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.11.4", + "version": "0.12.1", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { @@ -15,7 +15,10 @@ }, "homepage": "https://github.com/NeuralNomadsAI/CodeNomad", "scripts": { - "dev": "electron-vite dev", + "dev": "npm run dev:info", + "dev:info": "cross-env CLI_LOG_LEVEL=info electron-vite dev", + "dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev", + "dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev", "dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts", "build": "electron-vite build", "typecheck": "tsc --noEmit -p tsconfig.json", @@ -42,6 +45,7 @@ "devDependencies": { "7zip-bin": "^5.2.0", "app-builder-bin": "^4.2.0", + "cross-env": "^7.0.3", "electron": "39.0.0", "electron-builder": "^24.0.0", "electron-vite": "4.0.1", diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index 4c3ce075..82ed1e6d 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -4,6 +4,6 @@ "private": true, "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.2.10" + "@opencode-ai/plugin": "1.2.14" } } \ No newline at end of file diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 1755c004..a0571f3b 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.11.4", + "version": "0.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.11.4", + "version": "0.12.1", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index 02082c24..2e9cc3ac 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.11.4", + "version": "0.12.1", "description": "CodeNomad Server", "license": "MIT", "author": { diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index b86075f5..4a05a8e2 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.11.4", + "version": "0.12.1", "private": true, "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 50ab2cdd..d8fbf761 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.11.4", + "version": "0.12.1", "private": true, "license": "MIT", "type": "module", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 62caf483..cee2ab06 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -496,17 +496,24 @@ const App: Component = () => { const isActiveInstance = () => activeInstanceId() === instance.id const isVisible = () => isActiveInstance() && !showFolderSelection() return ( -
- - handleCloseSession(instance.id, sessionId)} - onNewSession={() => handleNewSession(instance.id)} - handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} - handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} - onExecuteCommand={executeCommand} +
+ + handleCloseSession(instance.id, sessionId)} + onNewSession={() => handleNewSession(instance.id)} + handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} + handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} + onExecuteCommand={executeCommand} tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} onEnterMobileFullscreen={() => void enterMobileFullscreen()} diff --git a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx index c6694f80..ace89d5f 100644 --- a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx @@ -61,6 +61,11 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { // Keep enough gutter space so unified diffs don't overlap `+`/`-` markers. lineNumbersMinChars: 4, lineDecorationsWidth: 12, + // Use legacy diff algorithm for better performance with large files + // See: https://github.com/microsoft/vscode/issues/184037 + diffAlgorithm: "legacy", + // Limit computation time to avoid freezing on large files + maxComputationTime: 10000, }) setReady(true) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 43998a17..84e55578 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -62,6 +62,9 @@ const log = getLogger("session") interface InstanceShellProps { instance: Instance + // Provided by App-level instance tabs; lets us pause heavy rendering + // work for inactive instances while keeping them mounted for fast switching. + isActiveInstance?: boolean escapeInDebounce: boolean paletteCommands: Accessor onCloseSession: (sessionId: string) => Promise | void @@ -115,6 +118,7 @@ const InstanceShell2: Component = (props) => { const desktopQuery = useMediaQuery("(min-width: 1280px)") const tabletQuery = useMediaQuery("(min-width: 768px)") + const compactHeaderQuery = useMediaQuery("(max-width: 1024px)") const layoutMode = createMemo(() => { if (desktopQuery()) return "desktop" @@ -123,6 +127,7 @@ const InstanceShell2: Component = (props) => { }) const isPhoneLayout = createMemo(() => layoutMode() === "phone") + const compactHeaderLayout = createMemo(() => isPhoneLayout() || compactHeaderQuery()) const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout()) const compactPromptLayout = createMemo(() => layoutMode() !== "desktop") const leftPinningSupported = createMemo(() => layoutMode() !== "phone") @@ -596,7 +601,7 @@ const InstanceShell2: Component = (props) => {
@@ -634,8 +639,8 @@ const InstanceShell2: Component = (props) => { - -
+ +
= (props) => {
- + = (props) => { {rightAppBarButtonIcon()} -
+
- + + +
} @@ -796,12 +803,14 @@ const InstanceShell2: Component = (props) => { > {(sessionId) => { - const isActive = () => activeSessionIdForInstance() === sessionId + const isActive = () => Boolean(props.isActiveInstance) && activeSessionIdForInstance() === sessionId return (
= (props) => { return ( <> -
+
}> {sessionLayout} diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index fc0e58aa..a4168973 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -7,7 +7,7 @@ import { type Accessor, type Component, } from "solid-js" -import type { ToolState } from "@opencode-ai/sdk" +import type { ToolState } from "@opencode-ai/sdk/v2" import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import IconButton from "@suid/material/IconButton" import MenuOpenIcon from "@suid/icons-material/MenuOpen" diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx index a4fcd46a..2062b1c7 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx @@ -1,4 +1,4 @@ -import { For, Show, type Accessor, type Component, type JSX } from "solid-js" +import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js" import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer" @@ -32,14 +32,18 @@ interface ChangesTabProps { } const ChangesTab: Component = (props) => { - const renderContent = (): JSX.Element => { - const sessionId = props.activeSessionId() + const sessionId = createMemo(() => props.activeSessionId()) + const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info")) + const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null)) - const hasSession = Boolean(sessionId && sessionId !== "info") - const diffs = hasSession ? props.activeSessionDiffs() : null + const sorted = createMemo(() => { + const list = diffs() + if (!Array.isArray(list)) return [] + return [...list].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) + }) - const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : [] - const totals = sorted.reduce( + const totals = createMemo(() => { + return sorted().reduce( (acc, item) => { acc.additions += typeof item.additions === "number" ? item.additions : 0 acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 @@ -47,41 +51,61 @@ const ChangesTab: Component = (props) => { }, { additions: 0, deletions: 0 }, ) + }) - const mostChanged = sorted.length - ? sorted.reduce((best, item) => { - const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 - const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0 - const bestScore = bestAdd + bestDel + const mostChanged = createMemo(() => { + const items = sorted() + if (items.length === 0) return null + return items.reduce((best, item) => { + const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 + const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0 + const bestScore = bestAdd + bestDel - const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 - const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 - const score = add + del + const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 + const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 + const score = add + del - if (score > bestScore) return item - if (score < bestScore) return best - return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best - }, sorted[0]) - : null + if (score > bestScore) return item + if (score < bestScore) return best + return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best + }, items[0]) + }) - // Auto-select the most-changed file if none selected. + const selectedFileData = createMemo(() => { const currentSelected = props.selectedFile() - const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged - - const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}` - - const emptyViewerMessage = () => { - if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected") - if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading") - if (!Array.isArray(diffs) || diffs.length === 0) return props.t("instanceShell.sessionChanges.empty") - return props.t("instanceShell.filesShell.viewerEmpty") + const items = sorted() + if (currentSelected) { + const match = items.find((f) => f.file === currentSelected) + if (match) return match } + return mostChanged() + }) + + const scopeKey = createMemo(() => `${props.instanceId}:${hasSession() ? sessionId() : "no-session"}`) + + const emptyViewerMessage = createMemo(() => { + if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected") + const currentDiffs = diffs() + if (currentDiffs === undefined) return props.t("instanceShell.sessionChanges.loading") + if (!Array.isArray(currentDiffs) || currentDiffs.length === 0) return props.t("instanceShell.sessionChanges.empty") + return props.t("instanceShell.filesShell.viewerEmpty") + }) + + const headerPath = createMemo(() => { + const file = selectedFileData() + return file?.file ? String(file.file) : props.t("instanceShell.rightPanel.tabs.changes") + }) + + const renderContent = (): JSX.Element => { + const sortedList = sorted() + const totalsValue = totals() + const selected = selectedFileData() const renderViewer = () => (
0 ? selectedFileData : null} + when={selected && hasSession() && sortedList.length > 0 ? selected : null} fallback={
{emptyViewerMessage()} @@ -90,7 +114,7 @@ const ChangesTab: Component = (props) => { > {(file) => ( = (props) => { ) const renderListPanel = () => ( - 0} fallback={renderEmptyList()}> - + 0} fallback={renderEmptyList()}> + {(item) => (
{ props.onSelectFile(item.file, props.isPhoneLayout()) }} @@ -134,11 +158,11 @@ const ChangesTab: Component = (props) => { ) const renderListOverlay = () => ( - 0} fallback={renderEmptyList()}> - + 0} fallback={renderEmptyList()}> + {(item) => (
{ props.onSelectFile(item.file, true) }} @@ -159,8 +183,6 @@ const ChangesTab: Component = (props) => { ) - const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes")) - return ( = (props) => {
- +{totals.additions} + +{totalsValue.additions} - -{totals.deletions} + -{totalsValue.deletions}
diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx index 4575f1e5..b2ab7ff4 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx @@ -1,4 +1,4 @@ -import { For, Show, type Accessor, type Component, type JSX } from "solid-js" +import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js" import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import { RefreshCw } from "lucide-solid" @@ -46,17 +46,18 @@ interface GitChangesTabProps { } const GitChangesTab: Component = (props) => { - const renderContent = (): JSX.Element => { - const sessionId = props.activeSessionId() + const sessionId = createMemo(() => props.activeSessionId()) + const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info")) + const entries = createMemo(() => (hasSession() ? props.entries() : null)) - const hasSession = Boolean(sessionId && sessionId !== "info") - const entries = hasSession ? props.entries() : null + const sorted = createMemo(() => { + const list = entries() + if (!Array.isArray(list)) return [] + return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) + }) - const sorted = Array.isArray(entries) - ? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) - : [] - - const totals = sorted.reduce( + const totals = createMemo(() => { + return sorted().reduce( (acc, item) => { acc.additions += typeof item.added === "number" ? item.added : 0 acc.deletions += typeof item.removed === "number" ? item.removed : 0 @@ -64,21 +65,33 @@ const GitChangesTab: Component = (props) => { }, { additions: 0, deletions: 0 }, ) + }) - const nonDeleted = sorted.filter((item) => item && item.status !== "deleted") - - const emptyViewerMessage = () => { - if (!hasSession) return "Select a session to view changes." - if (entries === null) return "Loading git changes…" - if (nonDeleted.length === 0) return "No git changes yet." - return "No file selected." - } + const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted")) + const selectedEntry = createMemo(() => { + const list = sorted() const selectedPath = props.selectedPath() const fallbackPath = props.mostChangedPath() - const selectedEntry = - sorted.find((item) => item.path === selectedPath) || - (fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null) + const found = + list.find((item) => item.path === selectedPath) || + (fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined) + return found ?? null + }) + + const emptyViewerMessage = createMemo(() => { + if (!hasSession()) return "Select a session to view changes." + const currentEntries = entries() + if (currentEntries === null) return "Loading git changes…" + if (nonDeleted().length === 0) return "No git changes yet." + return "No file selected." + }) + + const renderContent = (): JSX.Element => { + const totalsValue = totals() + const selected = selectedEntry() + const sortedList = sorted() + const nonDeletedList = nonDeleted() const renderViewer = () => (
@@ -91,12 +104,12 @@ const GitChangesTab: Component = (props) => { fallback={ = (props) => { } > {(file) => ( - - )} + + )} } > @@ -141,8 +154,8 @@ const GitChangesTab: Component = (props) => { const renderEmptyList = () =>
{emptyViewerMessage()}
const renderListPanel = () => ( - 0} fallback={renderEmptyList()}> - + 0} fallback={renderEmptyList()}> + {(item) => (
= (props) => { ) const renderListOverlay = () => ( - 0} fallback={renderEmptyList()}> - + 0} fallback={renderEmptyList()}> + {(item) => (
= (props) => { ) return ( - - - {selectedEntry?.path || "Git Changes"} - + + + {selected?.path || "Git Changes"} +
- +{totals.additions} + +{totalsValue.additions} - -{totals.deletions} + -{totalsValue.deletions} {(err) => {err()}}
@@ -226,23 +239,23 @@ const GitChangesTab: Component = (props) => { class="files-header-icon-button" title={props.t("instanceShell.rightPanel.actions.refresh")} aria-label={props.t("instanceShell.rightPanel.actions.refresh")} - disabled={!hasSession || props.statusLoading() || entries === null} + disabled={!hasSession() || props.statusLoading() || entries() === null} style={{ "margin-left": "auto" }} onClick={() => props.onRefresh()} > - - - } + + + } list={{ panel: renderListPanel, overlay: renderListOverlay }} viewer={renderViewer()} listOpen={props.listOpen()} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index e6839af6..b52c16d9 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -1,5 +1,5 @@ import { For, Show, type Accessor, type Component } from "solid-js" -import type { ToolState } from "@opencode-ai/sdk" +import type { ToolState } from "@opencode-ai/sdk/v2" import { Accordion } from "@kobalte/core" import { Tooltip } from "@kobalte/core/tooltip" diff --git a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts index faee193a..5eb98bf2 100644 --- a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts +++ b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts @@ -1,5 +1,5 @@ import { batch, createMemo, type Accessor } from "solid-js" -import type { ToolState } from "@opencode-ai/sdk" +import type { ToolState } from "@opencode-ai/sdk/v2" import type { Session } from "../../../types/session" import { activeParentSessionId, diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 39638e1b..a4908f16 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -92,7 +92,6 @@ export function Markdown(props: MarkdownProps) { const globalCache = cacheHandle.get() if (globalCache && cacheMatches(globalCache)) { setHtml(globalCache.html) - part.renderCache = globalCache notifyRendered() return } @@ -100,14 +99,11 @@ export function Markdown(props: MarkdownProps) { const commitCacheEntry = (renderedHtml: string) => { const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version } setHtml(renderedHtml) - part.renderCache = cacheEntry cacheHandle.set(cacheEntry) notifyRendered() } if (!highlightEnabled) { - part.renderCache = undefined - try { const rendered = await renderMarkdown(text, { suppressHighlight: true }) @@ -185,7 +181,6 @@ export function Markdown(props: MarkdownProps) { if (latestRequestedText === text) { const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version } setHtml(rendered) - part.renderCache = cacheEntry cacheHandle.set(cacheEntry) notifyRendered() } @@ -202,5 +197,15 @@ export function Markdown(props: MarkdownProps) { const proseClass = () => "markdown-body" - return
+ return ( +
+ ) } diff --git a/packages/ui/src/components/message-anchors.ts b/packages/ui/src/components/message-anchors.ts new file mode 100644 index 00000000..266ce5ff --- /dev/null +++ b/packages/ui/src/components/message-anchors.ts @@ -0,0 +1,9 @@ +export const MESSAGE_ANCHOR_PREFIX = "message-anchor-" + +export function getMessageAnchorId(messageId: string) { + return `${MESSAGE_ANCHOR_PREFIX}${messageId}` +} + +export function getMessageIdFromAnchorId(anchorId: string) { + return anchorId.startsWith(MESSAGE_ANCHOR_PREFIX) ? anchorId.slice(MESSAGE_ANCHOR_PREFIX.length) : anchorId +} diff --git a/packages/ui/src/components/message-block-list.tsx b/packages/ui/src/components/message-block-list.tsx deleted file mode 100644 index 3db083cc..00000000 --- a/packages/ui/src/components/message-block-list.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Index, type Accessor } from "solid-js" -import VirtualItem from "./virtual-item" -import MessageBlock from "./message-block" -import type { InstanceMessageStore } from "../stores/message-v2/instance-store" - -export function getMessageAnchorId(messageId: string) { - return `message-anchor-${messageId}` -} - -const VIRTUAL_ITEM_MARGIN_PX = 800 - -interface MessageBlockListProps { - instanceId: string - sessionId: string - store: () => InstanceMessageStore - messageIds: () => string[] - lastAssistantIndex: () => number - showThinking: () => boolean - thinkingDefaultExpanded: () => boolean - showUsageMetrics: () => boolean - scrollContainer: Accessor - loading?: boolean - onRevert?: (messageId: string) => void - onFork?: (messageId?: string) => void - onContentRendered?: () => void - setBottomSentinel: (element: HTMLDivElement | null) => void - suspendMeasurements?: () => boolean -} - -export default function MessageBlockList(props: MessageBlockListProps) { - return ( - <> - - {(messageId, index) => ( - !props.loading} - suspendMeasurements={props.suspendMeasurements} - > - - - )} - -