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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 28 additions & 24 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@ jobs:
target:
# macOS now ships two single-arch shards so Apple Silicon and Intel
# users get separate DMGs. `rust` and `tauri` stay aligned per shard.
- { os: ubuntu-22.04, label: "linux-x64", rust: "x86_64-unknown-linux-gnu", tauri: "x86_64-unknown-linux-gnu" }
- { os: macos-15-intel, label: "macos-x64", rust: "x86_64-apple-darwin", tauri: "x86_64-apple-darwin" }
- { os: macos-14, label: "macos-arm64", rust: "aarch64-apple-darwin", tauri: "aarch64-apple-darwin" }
- { os: windows-latest, label: "windows-x64", rust: "x86_64-pc-windows-msvc", tauri: "x86_64-pc-windows-msvc" }
runs-on: ${{ matrix.target.os }}
# secrets.* isn't accessible from step-level if:, so expose presence as a
Expand Down Expand Up @@ -116,27 +113,14 @@ jobs:
test -f dist/index.html
ls dist/assets/*.js >/dev/null

# tauri-action signs OS bundles whenever the corresponding env vars are
# defined — including when they're defined to the empty string, which is
# what `secrets.UNSET` evaluates to. Gate each signing block on its
# certificate being non-empty so unsigned prereleases don't trip
# `security import` / `signtool` on missing certs.
- name: Build Tauri bundle (unsigned)
if: >-
(matrix.target.os != 'windows-latest' || env.HAS_WIN_CERT != 'true')
&& (!startsWith(matrix.target.os, 'macos-') || env.HAS_APPLE_CERT != 'true')
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: desktop
tagName: ${{ steps.tag.outputs.name }}
releaseName: Reasonix ${{ steps.tag.outputs.name }}
releaseDraft: true
prerelease: false
args: --target ${{ matrix.target.tauri }}
- name: Disable updater artifacts for unsigned Windows beta
shell: bash
run: |
node -e "const fs=require('fs'); const p='desktop/src-tauri/tauri.conf.json'; const c=JSON.parse(fs.readFileSync(p,'utf8')); c.bundle.createUpdaterArtifacts=false; fs.writeFileSync(p, JSON.stringify(c, null, 2) + '\n');"

- name: Build Tauri bundle (Windows, unsigned)
working-directory: desktop
run: npm run tauri -- build --target ${{ matrix.target.tauri }}

- name: Build Tauri bundle (Windows, signed)
if: matrix.target.os == 'windows-latest' && env.HAS_WIN_CERT == 'true'
Expand All @@ -155,6 +139,26 @@ jobs:
prerelease: false
args: --target ${{ matrix.target.tauri }}

- name: Upload Windows bundle artifact
uses: actions/upload-artifact@v4
with:
name: reasonix-windows-x64-${{ steps.tag.outputs.name }}
path: |
desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe

- name: Upload Windows bundle to draft release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.name }}
name: Reasonix ${{ steps.tag.outputs.name }}
target_commitish: ${{ github.sha }}
draft: true
prerelease: true
files: |
desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe

- name: Build Tauri bundle (macOS, signed + notarized)
if: startsWith(matrix.target.os, 'macos-') && env.HAS_APPLE_CERT == 'true'
uses: tauri-apps/tauri-action@v0
Expand Down
92 changes: 92 additions & 0 deletions desktop/src/ui/composer-at-popup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,4 +253,96 @@ describe("desktop Composer @ popup", () => {
expect(typeof draftUpdate).toBe("function");
expect((draftUpdate as (current: string) => string)("@foo")).toBe("@src/very/deep/foo.ts ");
});

it("removes the matching @ token when closing a picked mention chip", async () => {
const setDraft = vi.fn();
const { container, rerender, onMentionQuery } = renderComposer({
draft: "",
setDraft,
});
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("missing textarea");

fireEvent.change(textarea, {
target: {
value: "inspect @foo before send",
selectionStart: "inspect @foo".length,
selectionEnd: "inspect @foo".length,
},
});
await waitFor(() => expect(onMentionQuery).toHaveBeenCalled());
const nonce = onMentionQuery.mock.calls[0]?.[1] as number;

rerender(
<Composer
draft="inspect @foo before send"
setDraft={setDraft}
onSend={vi.fn()}
onAbort={vi.fn()}
disabled={false}
busy={false}
modelLabel="deepseek-v4-flash"
reasoningEffort="high"
onModelChange={vi.fn()}
onEffortChange={vi.fn()}
editMode="review"
onEditModeChange={vi.fn()}
textareaRef={createRef<HTMLTextAreaElement>()}
slashCommands={[]}
onMentionQuery={onMentionQuery}
onMentionPreview={vi.fn()}
onMentionPicked={vi.fn()}
mentionResults={{ nonce, query: "foo", results: ["src/very/deep/foo.ts"] }}
workspaceDir="/repo"
/>,
);

const row = await waitFor(() => {
const item = container.querySelector(".popup-item");
expect(item).not.toBeNull();
return item as HTMLElement;
});

fireEvent.click(row);
const pickDraftUpdate = setDraft.mock.calls.at(-1)?.[0];
expect(typeof pickDraftUpdate).toBe("function");
const pickedDraft = (pickDraftUpdate as (current: string) => string)(
"inspect @foo before send",
);
expect(pickedDraft).toBe("inspect @src/very/deep/foo.ts before send");

rerender(
<Composer
draft={pickedDraft}
setDraft={setDraft}
onSend={vi.fn()}
onAbort={vi.fn()}
disabled={false}
busy={false}
modelLabel="deepseek-v4-flash"
reasoningEffort="high"
onModelChange={vi.fn()}
onEffortChange={vi.fn()}
editMode="review"
onEditModeChange={vi.fn()}
textareaRef={createRef<HTMLTextAreaElement>()}
slashCommands={[]}
onMentionQuery={onMentionQuery}
onMentionPreview={vi.fn()}
onMentionPicked={vi.fn()}
mentionResults={null}
workspaceDir="/repo"
/>,
);

const close = container.querySelector(".composer-tags .x");
expect(close).not.toBeNull();
fireEvent.click(close!);

const closeDraftUpdate = setDraft.mock.calls.at(-1)?.[0];
expect(typeof closeDraftUpdate).toBe("function");
expect((closeDraftUpdate as (current: string) => string)(pickedDraft)).toBe(
"inspect before send",
);
});
});
29 changes: 25 additions & 4 deletions desktop/src/ui/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export type MentionItem = {

export type Chip = { kind: "at"; label: string } | { kind: "slash"; label: string };

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/** For long paths show only the filename; truncate filename if it's still too long. */
function chipLabel(label: string, maxLen = 32): string {
if (label.length <= maxLen) return label;
Expand Down Expand Up @@ -132,6 +136,20 @@ function highlightTokens(text: string): React.ReactNode {
return parts.length > 0 ? parts : text;
}

function removeAtTokenFromDraft(text: string, label: string): string {
const re = new RegExp(`(^|\\s+)@${escapeRegExp(label)}(\\s+|$)`, "g");
let next = text;
let prev = "";
while (next !== prev) {
prev = next;
next = next.replace(re, (_match, leading: string, trailing: string) => {
if (!leading || !trailing) return "";
return " ";
});
}
return next;
}

function slashIcon(cmd: string) {
const m: Record<string, React.ReactNode> = {
"/clear": <I.x size={12} />,
Expand Down Expand Up @@ -258,7 +276,7 @@ export function Composer({
const result: Chip[] = [];
for (const [label, kind] of pickedChips) {
const sigil = kind === "at" ? "@" : "/";
const escaped = sigil + label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escaped = sigil + escapeRegExp(label);
const re = new RegExp(`(?:^|\\s)${escaped}(?:\\s|$)`);
if (re.test(draft)) {
result.push({ kind, label });
Expand Down Expand Up @@ -737,13 +755,16 @@ export function Composer({
<button
type="button"
className="x"
onClick={() =>
onClick={() => {
setPickedChips((prev) => {
const next = new Map(prev);
next.delete(row.chip.label);
return next;
})
}
});
if (row.chip.kind === "at") {
setDraft((current) => removeAtTokenFromDraft(current, row.chip.label));
}
}}
title={t("composer.close")}
aria-label={t("composer.close")}
>
Expand Down