diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f2dd5d15e7..1ece8a98f0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -15,6 +15,16 @@ release workflow then asks that reporter to verify once the fix ships. - +## Documentation impact + +Documentation-impact: TODO + +For changes to user-visible CLI, Desktop, configuration, provider, permission, +or tool behavior, use one of: + +- `Documentation-impact: updated - ` and update `docs/*.md`. +- `Documentation-impact: none - `. + ## Cache impact Cache-impact: TODO diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f49173618..df12d8a785 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -412,7 +412,8 @@ jobs: .github/workflows/release.yml \ .github/workflows/release-npm.yml \ .github/workflows/release-desktop.yml \ - .github/workflows/release-verify-issues.yml + .github/workflows/release-verify-issues.yml \ + .github/workflows/docs-impact.yml bash scripts/release-workflows.test.sh node scripts/check-single-release-public-contract.mjs diff --git a/.github/workflows/docs-impact.yml b/.github/workflows/docs-impact.yml new file mode 100644 index 0000000000..41b9e205e0 --- /dev/null +++ b/.github/workflows/docs-impact.yml @@ -0,0 +1,39 @@ +name: Documentation impact guard + +on: + pull_request_target: + branches: [main-v2] + types: [opened, synchronize, reopened, edited, ready_for_review] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: docs-impact-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + docs-impact: + runs-on: ubuntu-latest + steps: + # Execute only the trusted base-branch checker; never run contributor code + # from a pull_request_target workflow. + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.base.sha }} + + - name: Collect PR changed files + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: | + gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \ + --jq '.[].filename' > "$RUNNER_TEMP/docs-impact-files.txt" + + - name: Check documentation impact declaration + env: + PR_BODY: ${{ github.event.pull_request.body }} + DOCS_IMPACT_CHANGED_FILES_FILE: ${{ runner.temp }}/docs-impact-files.txt + run: bash scripts/check-docs-impact.sh diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 56d11303c6..d7217da35b 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -261,6 +261,17 @@ jobs: - name: Cache hit guard run: ./scripts/cache-guard.sh + - name: Verify embedded documentation identity + env: + DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.version }} + DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }} + run: | + if [ ! -f scripts/verify-embedded-docs.sh ]; then + echo "Legacy candidate predates the embedded docs contract; skipping." + exit 0 + fi + bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION" + build: name: build (${{ matrix.name }}) needs: [resolve, cache-guard] diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index d7df92839a..a8b3c2e444 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -125,6 +125,18 @@ jobs: go-version-file: go.mod cache: true - run: ./scripts/cache-guard.sh + - name: Verify embedded documentation identity + env: + DOCS_BUILD_VERSION: v${{ inputs.base_version }} + DOCS_SOURCE_REVISION: ${{ inputs.approved_sha }} + run: | + if [ ! -f scripts/verify-embedded-docs.sh ]; then + echo "Legacy candidate predates the embedded docs contract; skipping." + exit 0 + fi + revision="$DOCS_SOURCE_REVISION" + if [ -z "$revision" ]; then revision="$(git rev-parse HEAD)"; fi + bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$revision" npm: name: publish npm packages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fd901cde2..094fd9eb55 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -179,6 +179,16 @@ jobs: go-version-file: go.mod cache: true - run: ./scripts/cache-guard.sh + - name: Verify embedded documentation identity + env: + DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.tag }} + DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }} + run: | + if [ ! -f scripts/verify-embedded-docs.sh ]; then + echo "Legacy candidate predates the embedded docs contract; skipping." + exit 0 + fi + bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION" goreleaser: name: archives + checksums + homebrew tap diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 57978c2792..294b12c9bd 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -14,7 +14,7 @@ builds: flags: - -trimpath ldflags: - - -s -w -X main.version={{ .Tag }} + - -s -w -X main.version={{ .Tag }} -X reasonix/internal/productdocs.linkedVersion={{ .Tag }} -X reasonix/internal/productdocs.linkedRevision={{ .Commit }} goos: [darwin, linux, windows] goarch: [amd64, arm64] diff --git a/desktop/app.go b/desktop/app.go index 3b7bfaa7ef..4e77305ddd 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -7584,6 +7584,7 @@ func (a *App) Commands() []CommandInfo { {Name: "model", Description: i18n.M.CmdModel, Kind: "builtin", Group: "actions"}, {Name: "provider", Description: i18n.M.CmdProvider, Kind: "builtin", Group: "management"}, {Name: "effort", Description: i18n.M.CmdEffort, Kind: "builtin", Group: "actions"}, + {Name: "docs", Description: i18n.M.CmdDocs, Hint: "", Kind: "builtin", Group: "integrations"}, {Name: "memory", Description: i18n.M.CmdMemory, Kind: "builtin", Group: "management"}, {Name: "migrate", Description: i18n.M.CmdMigrate, Kind: "builtin", Group: "management"}, {Name: "goal", Description: i18n.M.CmdGoal, Kind: "builtin", Group: "actions"}, diff --git a/desktop/app_test.go b/desktop/app_test.go index 284ea352e2..bb027d0f23 100644 --- a/desktop/app_test.go +++ b/desktop/app_test.go @@ -372,9 +372,12 @@ func (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error { return r.ask(ctx) } -func TestCommandsIncludesEffortNotThinking(t *testing.T) { +func TestCommandsIncludesDocsAndEffortNotThinking(t *testing.T) { app := NewApp() cmds := app.Commands() + if !hasCommand(cmds, "docs") { + t.Fatalf("Commands() should include docs: %+v", cmds) + } if !hasCommand(cmds, "effort") { t.Fatalf("Commands() should include effort: %+v", cmds) } diff --git a/desktop/build_identity_packaging_test.go b/desktop/build_identity_packaging_test.go index 81b6d80c56..f537f3b986 100644 --- a/desktop/build_identity_packaging_test.go +++ b/desktop/build_identity_packaging_test.go @@ -16,8 +16,9 @@ func TestDesktopBuildLinksSharedSourceRevision(t *testing.T) { `SOURCE_REVISION="$(git -C "$ROOT" rev-parse --verify HEAD)"`, `SOURCE_REVISION="$SOURCE_REVISION+dirty"`, `source_revision_ldflag="-X reasonix/internal/remote/protocol.linkedSourceRevision=$SOURCE_REVISION"`, - `ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $source_revision_ldflag"`, - `-X main.version=$VERSION $source_revision_ldflag`, + `product_docs_ldflags="-X reasonix/internal/productdocs.linkedVersion=$VERSION -X reasonix/internal/productdocs.linkedRevision=$SOURCE_REVISION"`, + `ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $source_revision_ldflag $product_docs_ldflags"`, + `-X main.version=$VERSION $source_revision_ldflag $product_docs_ldflags`, } { if !strings.Contains(script, want) { t.Fatalf("desktop-build.sh does not preserve the shared build identity %q", want) diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index cffd311dea..2a8836c2c4 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -15,7 +15,7 @@ "test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/terminal-theme.test.ts && tsx src/__tests__/workspace-layout.test.ts", "test:usage-stats": "tsx src/__tests__/usage-stats-format.test.ts && tsx src/__tests__/usage-stats-panel.test.tsx", "pretest": "pnpm test:terminal && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && pnpm test:usage-stats && pnpm test:composer-menu-viewport", - "test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts", + "test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts && tsx src/__tests__/virtual-menu-identity.test.tsx", "test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/goal-action-errors.test.tsx && tsx src/__tests__/goal-activation-tab-routing.test.tsx && tsx src/__tests__/session-export.test.ts && tsx src/__tests__/conversation-width.test.ts && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/github-link-rendering.test.tsx && tsx src/__tests__/markdown-image-proxy.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-preferences.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/theme-editor-keyboard.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-editor-model-picker.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/model-switcher-refresh.test.tsx && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/language-highlighting.test.ts && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/footer-decision-overflow.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/shortcuts-recorder-focus.test.tsx && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/line-number-code.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/diagnostics-settings.test.tsx && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/delivery-worktree.test.ts && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts", "test:remote": "tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && tsx src/__tests__/remote-secret-dialog.test.tsx && tsx src/__tests__/provider-trust-dialog.test.tsx && tsx src/__tests__/workbench-target.test.ts", "test:performance": "tsx src/__tests__/history-performance-benchmark.tsx", diff --git a/desktop/frontend/src/__tests__/virtual-menu-identity.test.tsx b/desktop/frontend/src/__tests__/virtual-menu-identity.test.tsx new file mode 100644 index 0000000000..54084c12b1 --- /dev/null +++ b/desktop/frontend/src/__tests__/virtual-menu-identity.test.tsx @@ -0,0 +1,154 @@ +// Run: tsx src/__tests__/virtual-menu-identity.test.tsx + +import { JSDOM } from "jsdom"; +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { SlashMenu } from "../components/SlashMenu"; +import { LocaleProvider } from "../lib/i18n"; +import type { CommandInfo } from "../lib/types"; + +let passed = 0; +let failed = 0; + +function ok(value: boolean, label: string) { + if (value) { + process.stdout.write(` PASS ${label}\n`); + passed += 1; + } else { + process.stdout.write(` FAIL ${label}\n`); + failed += 1; + } +} + +function rect(height: number): DOMRect { + return { + left: 0, + top: 0, + right: 600, + bottom: height, + width: 600, + height, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; +} + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +const dom = new JSDOM("
", { + pretendToBeVisual: true, + url: "http://localhost/", +}); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +globalThis.window = dom.window as unknown as Window & typeof globalThis; +globalThis.document = dom.window.document; +globalThis.Element = dom.window.Element; +globalThis.HTMLElement = dom.window.HTMLElement; +globalThis.Node = dom.window.Node; +globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; +dom.window.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); +globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); + +Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", { + configurable: true, + value(this: HTMLElement) { + if (this.classList.contains("slashmenu")) return rect(360); + if (this.classList.contains("slashmenu__row")) { + return rect(this.firstElementChild?.classList.contains("slashmenu__group") ? 26 : 34); + } + return rect(0); + }, +}); +Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { + configurable: true, + get() { + return 600; + }, +}); +Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", { + configurable: true, + get(this: HTMLElement) { + if (this.classList.contains("slashmenu")) return 360; + if (this.classList.contains("slashmenu__row")) { + return this.firstElementChild?.classList.contains("slashmenu__group") ? 26 : 34; + } + return 0; + }, +}); + +const initialItems: CommandInfo[] = [ + { name: "documentation", description: "Documentation", kind: "skill" }, + { name: "docx", description: "Word documents", kind: "skill" }, + { name: "lark-doc", description: "Lark documents", kind: "skill" }, + { name: "grill-with-docs", description: "Review with docs", kind: "skill" }, + { name: "tencent-docs", description: "Tencent documents", kind: "skill" }, + { name: "docs", description: "Search bundled documentation", kind: "builtin", group: "integrations" }, +]; + +const filteredItems = [initialItems[0]!, initialItems[4]!, initialItems[5]!]; + +function Menu({ items }: { items: CommandInfo[] }) { + return ( + + {}} onHover={() => {}} /> + + ); +} + +function rowPositions(): Array<{ label: string; position: number; transform: string }> { + return Array.from(document.querySelectorAll(".slashmenu__row")).map((row) => { + const match = row.style.transform.match(/translate3d\(0(?:px)?, ([\d.-]+)px, 0(?:px)?\)/); + const name = row.querySelector(".slashmenu__name")?.textContent; + return { + label: name ?? "group", + position: match ? Number(match[1]) : Number.NaN, + transform: row.style.transform, + }; + }); +} + +console.log("\nvirtual menu item identity"); + +const rootElement = document.getElementById("root"); +if (!rootElement) throw new Error("missing root"); +const root = createRoot(rootElement); + +await act(async () => { + root.render(); + await new Promise((resolve) => setTimeout(resolve, 0)); +}); +const initialDocsRow = Array.from(document.querySelectorAll(".slashmenu__row")) + .find((row) => row.querySelector(".slashmenu__name")?.textContent === "/docs"); +await act(async () => { + root.render(); + await new Promise((resolve) => setTimeout(resolve, 0)); +}); + +const positions = rowPositions(); +ok( + JSON.stringify(positions) === JSON.stringify([ + { label: "group", position: 0, transform: "translate3d(0, 0px, 0)" }, + { label: "/documentation", position: 26, transform: "translate3d(0, 26px, 0)" }, + { label: "/tencent-docs", position: 60, transform: "translate3d(0, 60px, 0)" }, + { label: "group", position: 94, transform: "translate3d(0, 94px, 0)" }, + { label: "/docs", position: 120, transform: "translate3d(0, 120px, 0)" }, + ]), + `filtered rows keep contiguous semantic positions: got ${JSON.stringify(positions)}`, +); +const filteredDocsRow = Array.from(document.querySelectorAll(".slashmenu__row")) + .find((row) => row.querySelector(".slashmenu__name")?.textContent === "/docs"); +ok(initialDocsRow === filteredDocsRow, "/docs keeps the same semantic DOM row while its filtered index changes"); + +await act(async () => root.unmount()); +dom.window.close(); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index 99c7cd3cea..1f55640898 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -1414,7 +1414,10 @@ export function Composer({ ], [includePastChatsItem, atMatches], ); - + const atMenuItemKey = useCallback( + (item: AtMenuItem) => item.kind === "pastChats" ? "past:chats" : (item.entry.isDir ? "d:" : "f:") + (item.entry.path || item.entry.name), + [], + ); // --- which menu (if any) is open --- (slash command names win; then slash // arguments; then @-refs — they're rarely valid at once) @@ -4055,7 +4058,7 @@ export function Composer({ (it.kind === "pastChats" ? "past:chats" : (it.entry.isDir ? "d:" : "f:") + (it.entry.path || it.entry.name))} + itemKey={atMenuItemKey} renderItem={(it, i) => it.kind === "pastChats" ? (