diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml
index 4bd774c2..6566f105 100644
--- a/.github/workflows/electron-build.yml
+++ b/.github/workflows/electron-build.yml
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
@@ -21,7 +21,7 @@ jobs:
version: 9
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 24
cache: 'pnpm'
@@ -107,7 +107,7 @@ jobs:
fi
- name: Upload workflow artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: distributables
path: apps/x/apps/main/out/make/*
@@ -118,7 +118,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
@@ -126,7 +126,7 @@ jobs:
version: 9
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 24
cache: 'pnpm'
@@ -171,7 +171,7 @@ jobs:
working-directory: apps/x/apps/main
- name: Upload workflow artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: distributables-linux
path: apps/x/apps/main/out/make/*
@@ -182,7 +182,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
@@ -190,7 +190,7 @@ jobs:
version: 9
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 24
cache: 'pnpm'
@@ -237,7 +237,7 @@ jobs:
working-directory: apps/x/apps/main
- name: Upload workflow artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: distributables-windows
path: apps/x/apps/main/out/make/*
diff --git a/.github/workflows/rowboat-build.yml b/.github/workflows/rowboat-build.yml
index ef2f93fa..270e6263 100644
--- a/.github/workflows/rowboat-build.yml
+++ b/.github/workflows/rowboat-build.yml
@@ -8,10 +8,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
cache-dependency-path: 'apps/rowboat/package-lock.json'
node-version: '20'
@@ -29,10 +29,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
cache-dependency-path: 'apps/rowboat/package-lock.json'
node-version: '24'
diff --git a/.github/workflows/x-publish.yml b/.github/workflows/x-publish.yml
index 4f58b1df..c411ab68 100644
--- a/.github/workflows/x-publish.yml
+++ b/.github/workflows/x-publish.yml
@@ -12,10 +12,10 @@ jobs:
steps:
- name: Checkout repo
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Set up Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version: 24
registry-url: https://registry.npmjs.org/
diff --git a/README.md b/README.md
index 9ba7e099..640ee35c 100644
--- a/README.md
+++ b/README.md
@@ -141,11 +141,6 @@ Examples: Exa (web search), Twitter/X, ElevenLabs (voice), Slack, Linear/Jira, G
- No proprietary formats or hosted lock-in
- You can inspect, edit, back up, or delete everything at any time
-
-## Looking for Rowboat Web Studio?
-
-If you’re looking for Rowboat web Studio, start [here](https://docs.rowboatlabs.com/).
-
---
diff --git a/apps/cli/src/application/assistant/instructions.ts b/apps/cli/src/application/assistant/instructions.ts
index b6e49cf0..b22425c6 100644
--- a/apps/cli/src/application/assistant/instructions.ts
+++ b/apps/cli/src/application/assistant/instructions.ts
@@ -1,5 +1,8 @@
import { skillCatalog } from "./skills/index.js";
import { WorkDir as BASE_DIR } from "../../config/config.js";
+import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
+
+const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks.
@@ -39,6 +42,8 @@ When a user asks for ANY task that might require external capabilities (web sear
- Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files.
- Keep user data safe—double-check before editing or deleting important resources.
+${runtimeContextPrompt}
+
## Workspace access & scope
- You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually.
- If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location.
diff --git a/apps/cli/src/application/assistant/runtime-context.ts b/apps/cli/src/application/assistant/runtime-context.ts
new file mode 100644
index 00000000..f1011c2c
--- /dev/null
+++ b/apps/cli/src/application/assistant/runtime-context.ts
@@ -0,0 +1,69 @@
+export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh';
+export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
+
+export interface RuntimeContext {
+ platform: NodeJS.Platform;
+ osName: RuntimeOsName;
+ shellDialect: RuntimeShellDialect;
+ shellExecutable: string;
+}
+
+export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
+ return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
+}
+
+export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {
+ if (platform === 'win32') {
+ return {
+ platform,
+ osName: 'Windows',
+ shellDialect: 'windows-cmd',
+ shellExecutable: getExecutionShell(platform),
+ };
+ }
+
+ if (platform === 'darwin') {
+ return {
+ platform,
+ osName: 'macOS',
+ shellDialect: 'posix-sh',
+ shellExecutable: getExecutionShell(platform),
+ };
+ }
+
+ if (platform === 'linux') {
+ return {
+ platform,
+ osName: 'Linux',
+ shellDialect: 'posix-sh',
+ shellExecutable: getExecutionShell(platform),
+ };
+ }
+
+ return {
+ platform,
+ osName: 'Unknown',
+ shellDialect: 'posix-sh',
+ shellExecutable: getExecutionShell(platform),
+ };
+}
+
+export function getRuntimeContextPrompt(runtime: RuntimeContext): string {
+ if (runtime.shellDialect === 'windows-cmd') {
+ return `## Runtime Platform (CRITICAL)
+- Detected platform: **${runtime.platform}**
+- Detected OS: **${runtime.osName}**
+- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax)
+- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`).
+- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`).
+- Do not assume macOS/Linux command syntax when the runtime is Windows.`;
+ }
+
+ return `## Runtime Platform (CRITICAL)
+- Detected platform: **${runtime.platform}**
+- Detected OS: **${runtime.osName}**
+- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax)
+- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`).
+- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux).
+- Do not assume Windows command syntax when the runtime is POSIX.`;
+}
diff --git a/apps/cli/src/application/lib/command-executor.ts b/apps/cli/src/application/lib/command-executor.ts
index 814d9801..cd16f05e 100644
--- a/apps/cli/src/application/lib/command-executor.ts
+++ b/apps/cli/src/application/lib/command-executor.ts
@@ -1,11 +1,13 @@
import { exec, execSync } from 'child_process';
import { promisify } from 'util';
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js';
+import { getExecutionShell } from '../assistant/runtime-context.js';
const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
+const EXECUTION_SHELL = getExecutionShell();
function sanitizeToken(token: string): string {
return token.trim().replace(/^['"]+|['"]+$/g, '');
@@ -91,7 +93,7 @@ export async function executeCommand(
cwd: options?.cwd,
timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
- shell: '/bin/sh', // use sh for cross-platform compatibility
+ shell: EXECUTION_SHELL,
});
return {
@@ -125,7 +127,7 @@ export function executeCommandSync(
cwd: options?.cwd,
timeout: options?.timeout,
encoding: 'utf-8',
- shell: '/bin/sh',
+ shell: EXECUTION_SHELL,
});
return {
diff --git a/apps/docs/docs.json b/apps/docs/docs.json
index 3c381b70..8442b158 100644
--- a/apps/docs/docs.json
+++ b/apps/docs/docs.json
@@ -18,36 +18,12 @@
"group": "Getting Started",
"pages": [
"docs/getting-started/introduction",
- "docs/getting-started/quickstart",
- "docs/getting-started/license"
+ "docs/getting-started/quickstart"
]
},
- {
- "group": "Using Rowboat",
- "pages": [
- "docs/using-rowboat/rowboat-studio",
- "docs/using-rowboat/agents",
- "docs/using-rowboat/tools",
- "docs/using-rowboat/rag",
- "docs/using-rowboat/triggers",
- "docs/using-rowboat/jobs",
- "docs/using-rowboat/conversations",
- {
- "group": "Customise",
- "icon": "sliders",
- "pages": [
- "docs/using-rowboat/customise/custom-llms"
- ]
- }
- ]
- },
- {
- "group": "API & SDK",
- "pages": ["docs/api-sdk/using_the_api", "docs/api-sdk/using_the_sdk"]
- },
{
"group": "Development",
- "pages": ["docs/development/contribution-guide", "docs/development/roadmap"]
+ "pages": ["docs/development/contribution-guide", "docs/getting-started/license"]
}
]
},
diff --git a/apps/docs/docs/api-sdk/using_the_api.mdx b/apps/docs/docs/api-sdk/using_the_api.mdx
deleted file mode 100644
index 60125b90..00000000
--- a/apps/docs/docs/api-sdk/using_the_api.mdx
+++ /dev/null
@@ -1,173 +0,0 @@
----
-title: "Using the API"
-description: "This is a guide on using the HTTP API to power conversations with the assistant created in Studio."
-icon: "code"
----
-
-
-## Deploy your assistant to production on Studio
-
-
-
-
-## Obtain API key and Project ID
-
-Generate API keys via the developer configs in your project. Copy the Project ID from the same page.
-
-
-
-
-## API Endpoint
-
-```
-POST
/api/v1//chat
-```
-
-Where:
-
-- For self-hosted: `` is `http://localhost:3000`
-
-## Authentication
-
-Include your API key in the Authorization header:
-
-```
-Authorization: Bearer
-```
-
-## Examples
-
-### First Turn
-
-```bash
-curl --location '/api/v1//chat' \
---header 'Content-Type: application/json' \
---header 'Authorization: Bearer ' \
---data '{
- "messages": [
- {
- "role": "user",
- "content": "Hello, can you help me?"
- }
- ],
- "state": null
-}'
-```
-
-Response:
-```json
-{
- "messages": [
- {
- "role": "assistant",
- "content": "Hello! Yes, I'd be happy to help you. What can I assist you with today?",
- "agenticResponseType": "external"
- }
- ],
- "state": {
- "last_agent_name": "MainAgent"
- }
-}
-```
-
-### Subsequent Turn
-
-Notice how we include both the previous messages and the state from the last response:
-
-```bash
-curl --location '/api/v1//chat' \
---header 'Content-Type: application/json' \
---header 'Authorization: Bearer ' \
---data '{
- "messages": [
- {
- "role": "user",
- "content": "Hello, can you help me?"
- },
- {
- "role": "assistant",
- "content": "Hello! Yes, I'd be happy to help you. What can I assist you with today?",
- "agenticResponseType": "external"
- },
- {
- "role": "user",
- "content": "What services do you offer?"
- }
- ],
- "state": {
- "last_agent_name": "MainAgent"
- }
-}'
-```
-
-## API Specification
-
-### Request Schema
-
-```typescript
-{
- // Required fields
- messages: Message[]; // Array of message objects representing the conversation history
- state: any; // State object from previous response, or null for first message
-
- // Optional fields
- workflowId?: string; // Specific workflow ID to use (defaults to production workflow)
- testProfileId?: string; // Test profile ID for simulation
-}
-```
-
-### Message Types
-
-Messages can be one of the following types:
-
-1. System Message
-```typescript
-{
- role: "system";
- content: string;
-}
-```
-
-2. User Message
-```typescript
-{
- role: "user";
- content: string;
-}
-```
-
-3. Assistant Message
-```typescript
-{
- role: "assistant";
- content: string;
- agenticResponseType: "internal" | "external";
- agenticSender?: string | null;
-}
-```
-
-### Response Schema
-
-```typescript
-{
- messages: Message[]; // Array of new messages from this turn
- state: any; // State object to pass in the next request
-}
-```
-
-## Important Notes
-
-1. Always pass the complete conversation history in the `messages` array
-2. Always include the `state` from the previous response in your next request
-3. The last message in the response's `messages` array will be a user-facing assistant message (`agenticResponseType: "external"`)
-
-## Rate Limiting
-
-The API has rate limits per project. If exceeded, you'll receive a 429 status code.
-
-## Error Responses
-
-- 400: Invalid request body or missing/invalid Authorization header
-- 403: Invalid API key
-- 404: Project or workflow not found
-- 429: Rate limit exceeded
\ No newline at end of file
diff --git a/apps/docs/docs/api-sdk/using_the_sdk.mdx b/apps/docs/docs/api-sdk/using_the_sdk.mdx
deleted file mode 100644
index 0909224f..00000000
--- a/apps/docs/docs/api-sdk/using_the_sdk.mdx
+++ /dev/null
@@ -1,91 +0,0 @@
----
-title: "Using the SDK"
-description: "This is a guide on using the RowBoat Python SDK as an alternative to the [RowBoat HTTP API](/using_the_api) to power conversations with the assistant created in Studio."
-icon: "toolbox"
----
-
-## Prerequisites
-- ``` pip install rowboat ```
-- [Deploy your assistant to production](/using_the_api/#deploy-your-assistant-to-production-on-studio)
-- [Obtain your `` and ``](/using_the_api/#obtain-api-key-and-project-id)
-
-### API Host
-- For the open source installation, the `` is [http://localhost:3000](http://localhost:3000)
-- When using the hosted app, the `` is [https://app.rowboatlabs.com](https://app.rowboatlabs.com)
-
-## Usage
-
-### Basic Usage
-
-The main way to interact with Rowboat is using the `Client` class, which provides a stateless chat API. You can manage conversation state using the `conversationId` returned in each response.
-
-```python
-from rowboat.client import Client
-from rowboat.schema import UserMessage
-
-# Initialize the client
-client = Client(
- host="",
- projectId="",
- apiKey=""
-)
-
-# Start a new conversation
-result = client.run_turn(
- messages=[
- UserMessage(role='user', content="What is the capital of France?")
- ]
-)
-print(result.turn.output[-1].content)
-# The capital of France is Paris.
-
-print("Conversation ID:", result.conversationId)
-
-# Continue the conversation by passing the conversationId
-result = client.run_turn(
- messages=[
- UserMessage(role='user', content="What other major cities are in that country?")
- ],
- conversationId=result.conversationId
-)
-print(result.turn.output[-1].content)
-# Other major cities in France include Lyon, Marseille, Toulouse, and Nice.
-
-result = client.run_turn(
- messages=[
- UserMessage(role='user', content="What's the population of the first city you mentioned?")
- ],
- conversationId=result.conversationId
-)
-print(result.turn.output[-1].content)
-# Lyon has a population of approximately 513,000 in the city proper.
-```
-
-### Using Tool Overrides (Mock Tools)
-
-You can provide tool override instructions to test a specific configuration using the `mockTools` argument:
-
-```python
-result = client.run_turn(
- messages=[
- UserMessage(role='user', content="What's the weather?")
- ],
- mockTools={
- "weather_lookup": "The weather in any city is sunny and 25°C.",
- "calculator": "The result of any calculation is 42."
- }
-)
-print(result.turn.output[-1].content)
-```
-
-### Message Types
-
-You can use different message types as defined in `rowboat.schema`, such as `UserMessage`, `SystemMessage`, `AssistantMessage`, etc. See `schema.py` for all available message types.
-
-### Error Handling
-
-If the API returns a non-200 status code, a `ValueError` will be raised with the error details.
-
----
-
-For more advanced usage, see the docstrings in `client.py` and the message schemas in `schema.py`.
\ No newline at end of file
diff --git a/apps/docs/docs/development/contribution-guide.mdx b/apps/docs/docs/development/contribution-guide.mdx
index 94d0f46c..1089d7e0 100644
--- a/apps/docs/docs/development/contribution-guide.mdx
+++ b/apps/docs/docs/development/contribution-guide.mdx
@@ -1,59 +1,55 @@
---
title: "Contribution Guide"
-description: "Learn how to contribute to the Rowboat project and help improve our platform."
+description: "How to contribute to Rowboat — from bug reports to pull requests."
icon: "github"
---
-# Join the Rowboat Voyage
+# Contributing to Rowboat
-We're building Rowboat as an open-source, community-powered platform — and we'd love for you to hop aboard! Whether you're fixing typos, suggesting a new tool integration, or designing your dream multi-agent workflow, your contributions are valuable and welcome.
+Rowboat is open-source and we welcome contributions of all kinds — bug reports, feature ideas, code, and docs improvements.
-
-
-
+**Quick links:**
+- [GitHub Repository](https://github.com/rowboatlabs/rowboat)
+- [Discord Community](https://discord.gg/wajrgmJQ6b)
+- [Open Issues](https://github.com/rowboatlabs/rowboat/issues)
---
-## How You Can Contribute
+## Ways to Contribute
+**Report bugs or suggest improvements** — If something feels off or could be better, [open an issue](https://github.com/rowboatlabs/rowboat/issues/new). Include steps to reproduce for bugs, or a clear description of the improvement you have in mind.
-- **Tackle Open Issues**
- Browse our [GitHub Issues](https://github.com/rowboatlabs/rowboat/issues) for tags like `good first issue`, `help wanted`, or `bug` to find a spot that fits your skillset.
+**Fix an existing issue** — Browse [open issues](https://github.com/rowboatlabs/rowboat/issues) and comment on one to let us know you're working on it.
-- **Join the Community**
- Our [Discord](https://discord.gg/rxB8pzHxaS) is the go-to hub for brainstorming, feedback, and finding contributors for bigger efforts.
-
-- **Propose Something New**
- Have a new tool integration idea or found a bug? Open an issue and let’s discuss it!
+**Propose a new feature or integration** — Open an issue first so we can discuss the approach before you invest time building it.
+**Improve documentation** — Typos, unclear explanations, missing examples — all fair game.
---
-## Contribution Workflow & Best Practices
-
-Whether it's your first contribution or your fiftieth, here's what a great contribution looks like:
-
-| Step / Tip | Description |
-|-------------------------------|-----------------------------------------------------------------------------------------------|
-| **1. Fork the Repository** | Create a personal copy of [rowboatlabs/rowboat](https://github.com/rowboatlabs/rowboat) to start contributing. |
-| **2. Create a New Branch** | Use a descriptive name like `fix-tool-crash` or `feature-new-mcp`. Avoid committing to `main`. |
-| **3. Make Your Changes** | Focus your PR on a single issue or feature to keep things clean and reviewable. |
-| **4. Write Tests if Needed** | If you change logic, add relevant tests so your contribution is future-proof. |
-| **5. Run Tests & Lint Locally**| Make sure your branch passes all tests and code quality checks before pushing. |
-| **6. Document or Demo It** | Add helpful context: screenshots, example scripts, or a short video/gif to demonstrate your changes. |
-| **7. Submit a Pull Request** | Open a PR with a clear description of your changes and any context reviewers should know. |
-| **8. Collaborate on Feedback** | Our maintainers may leave comments — it’s all part of the process. Let’s improve it together. |
-| **9. Don’t Be Shy to Follow Up** | Feel free to ping the PR/issue if you're waiting on feedback. We appreciate polite nudges. |
-| **10. Celebrate the Merge!** | You just made Rowboat better. Thanks for contributing 🚀 |
-
-If you're fixing typos, spacing, or small tweaks — try bundling those into a related PR instead of sending them standalone. It helps keep reviews focused.
+## Contribution Workflow
+1. **Fork** [rowboatlabs/rowboat](https://github.com/rowboatlabs/rowboat) and clone it locally.
+2. **Create a branch from `dev`** with a descriptive name (`fix-tool-crash`, `feature-mcp-discovery`).
+3. **Make your changes.** Keep PRs focused on a single issue or feature.
+4. **Test your changes** locally before submitting.
+5. **Open a pull request against `dev`** (not `main`) with a clear description of what you changed and why. Screenshots or short demos are appreciated for UI changes.
+6. **Respond to feedback** — maintainers may request changes. This is normal and collaborative.
+7. **Merge!** Once approved, we'll merge your PR.
+For small fixes like typos or formatting, try bundling related changes into a single PR rather than submitting them individually.
---
-## Come Build With Us
+## Guidelines
-We believe great ideas come from the community — and that means **you**. Whether you're an engineer, designer, AI tinkerer, or curious beginner, there’s room on this boat for everyone.
+- **One PR, one concern.** Don't mix unrelated changes in the same pull request.
+- **Write clear commit messages.** A reviewer should understand what changed from the message alone.
+- **Follow existing code style.** Match the patterns you see in the codebase.
+- **Be patient and respectful.** We review PRs as quickly as we can. A polite ping on Discord is always welcome if things go quiet.
-Let’s build the future of AI workflows — together. 🫶
+---
+
+## Getting Help
+
+If you're stuck or unsure about anything, drop a message in our [Discord](https://discord.gg/wajrgmJQ6b). We're happy to help you get unblocked.
\ No newline at end of file
diff --git a/apps/docs/docs/getting-started/introduction.mdx b/apps/docs/docs/getting-started/introduction.mdx
index 8fde54db..fd76e87d 100644
--- a/apps/docs/docs/getting-started/introduction.mdx
+++ b/apps/docs/docs/getting-started/introduction.mdx
@@ -1,60 +1,96 @@
---
title: "Introduction"
-description: "Welcome to the official Rowboat documentation! Rowboat is a low-code AI IDE to build tool connected multi-agent assistants. Rowboat copilot builds the agents for you based on your requirements with the option do everything manually as well."
+description: "Welcome to the official Rowboat documentation! Rowboat is an open-source AI coworker that turns work into a knowledge graph and acts on it."
icon: "book-open"
---
-
-
-
-
-
-## What is RowBoat?
-**RowBoat is a state-of-art platform to build multi-agent AI systems in a visual interface, with the help of a copilot.**
-
-RowBoat enables you to build, manage and deploy user-facing assistants. An assistant is made up of multiple agents, each having access to a set of tools and working together to interact with the user as a single assistant. You can connect any tool to the agents.
-
-For example, you can build a *meeting prep assistant* that helps you prepare for upcoming meetings. One agent can access your Google Calendar to see your scheduled meetings, another agent can research the meeting attendees (such as finding their LinkedIn profiles or recent news), and a third agent can compile this research and send it to your email before the meeting. This way, you get automated, personalized meeting prep without manual effort.
+[](https://www.youtube.com/watch?v=5AWoGo-L16I)
---
-## How RowBoat works
+## What is Rowboat?
+Rowboat is a local-first AI coworker, with work memory. Rowboat connects to your email and meeting notes, builds a long-lived knowledge graph, and uses that context to help you get work done - privately, on your machine.
-### RowBoat Studio
-RowBoat Studio lets you create AI agents in minutes, using a visual interface and plain language.
-There are key components that you will work with:
-- Agents
-- Playground
-- Copilot
-
-
-
- Learn about Rowboat Studio and key concepts used in building assistants
-
-
-
-### RowBoat Chat API & SDK
-- [RowBoat Chat API](/docs/api-sdk/using_the_api) is a stateless HTTP API to interface with the assistant created on RowBoat Studio. You can use the API to drive end-user facing conversations in your app or website.
-- [RowBoat Chat SDK](/docs/api-sdk/using_the_sdk) is a simple Python SDK which wraps the HTTP API under the hood. It provides a clean interface for managing conversations using conversation IDs for state management.
+You can do things like:
+- `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph
+- `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note)
+- Visualize, edit, and update your knowledge graph anytime (it’s just Markdown)
+- Record voice memos that automatically capture and update key takeaways in the graph
---
-## Why RowBoat?
-Rowboat is the fastest way to build and deploy multi-agent assistants.
+## What it does
-
-
- Use plain language and a powerful visual interface to design and orchestrate multi-agent assistants with ease.
-
+Rowboat is a **local-first AI coworker** that can:
+- **Remember** the important context you don’t want to re-explain (people, projects, decisions, commitments)
+- **Understand** what’s relevant right now (before a meeting, while replying to an email, when writing a doc)
+- **Help you act** by drafting, summarizing, planning, and producing real artifacts (briefs, emails, docs, PDF slides)
-
- Add tools and connect to MCP servers in just minutes — no complex setup required.
-
+Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Markdown notes with backlinks — a transparent “working memory” you can inspect and edit.
-
- Accelerate development with battle-tested tooling tailored for building production-ready, multi-agent AI systems.
-
-
+## Integrations
+Rowboat builds memory from the work you already do, including:
+- **Gmail** (email)
+- **Granola** (meeting notes)
+- **Fireflies** (meeting notes)
+
+## How it’s different
+
+Most AI tools reconstruct context on demand by searching transcripts or documents.
+
+Rowboat maintains **long-lived knowledge** instead:
+- context accumulates over time
+- relationships are explicit and inspectable
+- notes are editable by you, not hidden inside a model
+- everything lives on your machine as plain Markdown
+
+The result is memory that compounds, rather than retrieval that starts cold every time.
+
+## What you can do with it
+
+- **Meeting prep** from prior decisions, threads, and open questions
+- **Email drafting** grounded in history and commitments
+- **Docs & decks** generated from your ongoing context (including PDF slides)
+- **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped
+- **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions)
+
+## Background agents
+
+Rowboat can spin up **background agents** to do repeatable work automatically - so routine tasks happen without you having to ask every time.
+
+Examples:
+- Draft email replies in the background (grounded in your past context and commitments)
+- Generate a daily voice note each morning (agenda, priorities, upcoming meetings)
+- Create recurring project updates from the latest emails/notes
+- Keep your knowledge graph up to date as new information comes in
+
+You control what runs, when it runs, and what gets written back into your local Markdown vault.
+
+## Bring your own model
+
+Rowboat works with the model setup you prefer:
+- **Local models** via Ollama or LM Studio
+- **Hosted models** (bring your own API key/provider)
+- Swap models anytime — your data stays in your local Markdown vault
+
+## Extend Rowboat with tools (MCP)
+
+Rowboat can connect to external tools and services via **Model Context Protocol (MCP)**.
+That means you can plug in (for example) search, databases, CRMs, support tools, and automations - or your own internal tools.
+
+Examples: Exa (web search), Twitter/X, ElevenLabs (voice), Slack, Linear/Jira, GitHub, and more.
+
+## Local-first by design
+
+- All data is stored locally as plain Markdown
+- No proprietary formats or hosted lock-in
+- You can inspect, edit, back up, or delete everything at any time
+
+---
+
+
+[Discord](https://discord.gg/wajrgmJQ6b) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
+
---
## Contributing
@@ -72,7 +108,7 @@ Need help using Rowboat? Join our community!
Join our growing discord community and interact with hundreds of developer using Rowboat!
diff --git a/apps/docs/docs/getting-started/quickstart.mdx b/apps/docs/docs/getting-started/quickstart.mdx
index 04fc761b..1ab0bc05 100644
--- a/apps/docs/docs/getting-started/quickstart.mdx
+++ b/apps/docs/docs/getting-started/quickstart.mdx
@@ -3,92 +3,25 @@ title: "Quickstart"
description: "guide to getting started with rowboat"
icon: "rocket"
---
----
-# Cloud Setup
+**Download latest for Mac/Windows/Linux:** [Download](https://www.rowboatlabs.com/downloads)
-Using the open-source version of Rowboat requires more technical skill to set up and navigate. For the smoothest experience, we recommend using our [hosted version](https://dev.rowboatlabs.com/)
+**All release files:** https://github.com/rowboatlabs/rowboat/releases/latest
----
+## Google setup (optional)
+To connect Gmail, Calendar, and Drive, follow [Google setup](https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md).
-# Local Setup
+## Voice notes (optional)
+To enable voice notes, add a Deepgram API key in `~/.rowboat/config/deepgram.json`:
-Pre-requisite: Ensure Docker is installed on your machine. You'll also need an OpenAI account and API key to use the Copilot and agents.
+```json
+{
+ "apiKey": ""
+}
+```
+## Web search (optional)
+To use Brave web search, add the Brave API key in `~/.rowboat/config/brave-search.json`.
-
-
- Export your OpenAI API key in your terminal:
+To use Exa research search, add the Exa API key in `~/.rowboat/config/exa-search.json`.
- ```bash
- export OPENAI_API_KEY=your-openai-api-key
- ```
-
-
-
- To use external tools and triggers, export your Composio API key:
-
- ```bash
- export COMPOSIO_API_KEY=your-composio-api-key
- export COMPOSIO_TRIGGERS_WEBHOOK_SECRET=your-webhook-secret
- ```
-
- For more detailed setup instructions, see the [Triggers](/docs/using-rowboat/triggers#local-setup) page.
-
-
-
- Clone the Rowboat repository and start the app using Docker:
-
- ```bash
- git clone git@github.com:rowboatlabs/rowboat.git
- cd rowboat
- ./start.sh
- ```
-
-
-
- Once Docker is running, open your browser and go to:
-
- [http://localhost:3000](http://localhost:3000)
-
-
-
-
-See the [Using custom LLM providers](#using-custom-llm-providers) section below for using custom providers like OpenRouter and LiteLLM.
-
----
-
-## Demo
-{/* (would be better to change this to a Getiing Started Tutorial) */}
-
-#### Create a multi-agent assistant with MCP tools by chatting with Rowboat
-[](https://youtu.be/YRTCw9UHRbU)
-
----
-
-## Integrate with Rowboat agents
-
-There are 2 ways to integrate with the agents you create in Rowboat
-
-
-
-
- Guide on using the HTTP API
-
-
-
- Guide on using the Python SDK
-
-
-
-
----
-
-## Using custom LLM providers
-By default, Rowboat uses OpenAI LLMs (gpt-4o, gpt-4.1, etc.) for both agents and copilot, when you export your OPENAI_API_KEY.
-
-However, you can also configure custom LLM providers (e.g. LiteLLM, OpenRouter) to use any of the hundreds of available LLMs beyond OpenAI, such as Claude, DeepSeek, Ollama LLMs and so on.
-
-Check out our page on customising
-
- Learn more about customising your Rowboat experience here
-
+(Use the same JSON format as above.)
diff --git a/apps/docs/docs/using-rowboat/agents.mdx b/apps/docs/docs/using-rowboat/agents.mdx
deleted file mode 100644
index ceaeccae..00000000
--- a/apps/docs/docs/using-rowboat/agents.mdx
+++ /dev/null
@@ -1,141 +0,0 @@
----
-title: "Agents"
-description: "Learn about creating and configuring individual agents within your multi-agent system"
-icon: "robot"
----
-
-## Overview
-
-Agents are the core building blocks of Rowboat's multi-agent system. Each agent carries out a specific part of a conversation, handles tasks via tools, and can collaborate with other agents to orchestrate complex workflows.
-
-They are powered by LLMs and can:
-- Respond to user input
-- Trigger tools or APIs
-- Pass control to other agents using @mentions
-- Fetch or process internal data
-- Execute RAG (Retrieval-Augmented Generation) queries
-- Participate in sequential pipeline workflows
-
----
-
-## Agent Types
-
-Rowboat supports several types of agents, each designed for specific use cases:
-
-| Name | Purpose | Characteristics |
-|------|---------|-----------------|
-| **Conversational Agents** (`conversation`) | Primary user-facing agents that interact directly with users and orchestrate workflows. | • Can respond to users and orchestrate workflows • Typically serve as the start agent (Hub Agent)|
-| **Task Agents** (`internal`) | Specialized agents that perform specific tasks without direct user interaction. | • Focused on specific functions • Return results to parent agents|
-| **Pipeline Agents** (`pipeline`) | Sequential workflow execution agents that process data in a chain. | • Execute in sequence within a pipeline • Cannot transfer to other agents directly|
-
-
----
-
-## Agent Configuration
-
-Agents are configured through two main tabs in the Rowboat Studio interface:
-
-### **Instructions Tab**
-
-
-#### Description
-A clear description of the agent's role and responsibilities
-
-#### Instructions
-Instructions are the backbone of the agent's behavior. Use the Copilot's structured format for consistency:
-
-**Recommended Structure:**
-```
-## 🧑💼 Role:
-[Clear description of the agent's role]
-
-## ⚙️ Steps to Follow:
-1. [Step 1]
-2. [Step 2]
-3. [Step 3]
-
-## 🎯 Scope:
-✅ In Scope:
-- [What the agent should handle]
-
-❌ Out of Scope:
-- [What the agent should NOT handle]
-
-## 📋 Guidelines:
-✔️ Dos:
-- [Positive behaviors]
-
-🚫 Don'ts:
-- [Negative behaviors]
-```
-
-#### Examples
-These help agents behave correctly in specific situations. Each example can include:
-- A sample user message
-- The expected agent response
-- Any tool calls (if applicable)
-
-### **Configurations Tab**
-
-#### Name
-Name of the agent
-
-
-#### Behaviour
-- **Agent Type**: Choose from `conversation`, `internal`, or `pipeline`
-- **Model**: Select the LLM model (GPT-4.1, GPT-4o, google/gemini-2.5-flash, etc.)
-
-#### RAG
-- **Add Source**: Connect data sources to enable RAG capabilities for the agent
-
----
-
-## Creating Your Initial Set of Agents
-
-Let Copilot bootstrap your agent graph.
-
-### Instruct Copilot
-
-Start by telling Copilot what your assistant is meant to do — it'll generate an initial set of agents with best-practice instructions, role definitions, and connected agents.
-
-
-
-
-
-### Inspect the Output
-
-After applying the suggested agents, take a close look at each one's:
-- **Instructions**: Define how the agent behaves
-- **Examples**: Guide agent responses and tool use
-
-
-
-
-
----
-
-## Updating Agent Behavior
-
-There are three ways to update an agent:
-
-### 1. With Copilot
-
-Copilot understands the current chat context and can help rewrite or improve an agent's behavior based on how it performed.
-
-
-
-
-
-
-
-### 2. Manual Edits
-
-You can always manually edit the agent's instructions.
-
-
-
-
-
----
-
-
diff --git a/apps/docs/docs/using-rowboat/conversations.mdx b/apps/docs/docs/using-rowboat/conversations.mdx
deleted file mode 100644
index c755f47d..00000000
--- a/apps/docs/docs/using-rowboat/conversations.mdx
+++ /dev/null
@@ -1,52 +0,0 @@
----
-title: "Conversations"
-description: "View and manage all conversations with your Rowboat agents"
-icon: "list-check"
----
-
-## Overview
-
-The Conversations page in Rowboat shows you all the interactions between users and your agents. Here you can monitor conversations, view detailed message exchanges, and understand how your agents are performing.
-
-
-
-
-
-## What You'll See
-
-The Conversations page displays a list of all conversations organized by time:
-
-- **Today**: Recent conversations from today
-- **This week**: Conversations from the current week
-- **This month**: Conversations from the current month
-- **Older**: Conversations from previous months
-
-Each conversation shows:
-- **Conversation ID**: Unique identifier for the conversation
-- **Created time**: When the conversation started
-- **Reason**: What triggered the conversation (chat or job)
-
-## Viewing Conversation Details
-Click on any conversation to see the detailed view with all the message exchanges:
-
-
-
-
-
-**Conversation Metadata**: Shows the Conversation ID, creation time, and last update time.
-
-**Workflow**: Shows the workflow JSON
-
-**Turns**: Each conversation is made up of turns, where:
-- **Turn #1, #2, etc.**: Numbered sequence of interactions
-- **Reason badge**: Shows why each turn happened (chat, API, job, etc.)
-- **Timestamp**: When each turn occurred
-- **Input messages**: What was sent to your agents
-- **Output messages**: What your agents responded with
-
-### Turn Details
-
-Each turn displays:
-- **Input**: The messages sent to your agents (user messages, system messages)
-- **Output**: The responses from your agents
-- **Error information**: Any issues that occurred during processing
diff --git a/apps/docs/docs/using-rowboat/customise/custom-llms.mdx b/apps/docs/docs/using-rowboat/customise/custom-llms.mdx
deleted file mode 100644
index 64b27ca1..00000000
--- a/apps/docs/docs/using-rowboat/customise/custom-llms.mdx
+++ /dev/null
@@ -1,53 +0,0 @@
----
-title: "Custom LLMs"
-description: "How to use and configure custom LLMs in Rowboat."
-
----
-
- This is currently only possible in the self hosted version of Rowboat
-
-## Using custom LLM providers
-
-By default, Rowboat uses OpenAI LLMs (gpt-4o, gpt-4.1, etc.) for both agents and copilot, when you export your OPENAI_API_KEY.
-
-However, you can also configure custom LLM providers (e.g. LiteLLM, OpenRouter) to use any of the hundreds of available LLMs beyond OpenAI, such as Claude, DeepSeek, Ollama LLMs and so on.
-
-
-
- Configure your environment variables to point to your preferred LLM backend. Example using LiteLLM:
-
- ```bash
- export PROVIDER_BASE_URL=http://host.docker.internal:4000/
- export PROVIDER_API_KEY=sk-1234
- ```
-
- Rowboat uses gpt-4.1 as the default model for agents and copilot. You can override these:
-
- ```bash
- export PROVIDER_DEFAULT_MODEL=claude-3-7-sonnet-latest
- export PROVIDER_COPILOT_MODEL=gpt-4.1
- ```
-
- **Notes:**
- - Copilot is optimized for gpt-4o/gpt-4.1. We strongly recommend using these models for best results.
- - You can use different models for the copilot and each agent, but all must be from the same provider (e.g., LiteLLM).
- - Rowboat is provider-agnostic — any backend implementing the OpenAI messages format should work.
- - OpenAI-specific tools (like web_search) will not function with non-OpenAI providers. Remove such tools to avoid errors.
-
-
-
- Clone the Rowboat repo and spin it up locally:
-
- ```bash
- git clone git@github.com:rowboatlabs/rowboat.git
- cd rowboat
- docker-compose up --build
- ```
-
-
-
- Once Docker is running, navigate to:
-
- [http://localhost:3000](http://localhost:3000)
-
-
diff --git a/apps/docs/docs/using-rowboat/jobs.mdx b/apps/docs/docs/using-rowboat/jobs.mdx
deleted file mode 100644
index 5b3e14df..00000000
--- a/apps/docs/docs/using-rowboat/jobs.mdx
+++ /dev/null
@@ -1,45 +0,0 @@
----
-title: "Jobs"
-description: "Monitor and inspect all your trigger executions and job runs"
-icon: "message"
----
-
-## Overview
-
-The Jobs page in Rowboat provides a comprehensive view of all your automated job executions. Here you can monitor the status of your triggers, inspect what happened during each run, and troubleshoot any issues that may have occurred.
-
-
-
-
-
-## What You'll See
-
-The Jobs page displays a list of all job runs from your triggers, including:
-
-- **External trigger executions** from webhook events
-- **One-time trigger runs** from scheduled jobs
-- **Recurring trigger executions** from cron-based schedules
-
-Each job run displays the following key information:
-- **Job ID**: Unique identifier for the job run
-- **Status**: Indicates if the job succeeded, failed, or is in progress
-- **Reason**: The trigger or cause for the job (e.g., external trigger, scheduled, cron)
-- **Created Time**: When the job was executed
-
-## Viewing Job Details
-
-### Expand a Job Run
-
-Click on any job run to expand it and see detailed information about what happened during execution:
-
-
-
-
-
-**Basic job details**: Job ID, Status, creation time, Updated time, Conversation ID and Turn ID. By clicking on the Conversation ID, you can view more in-depth details about the run.
-
-**Job Reason**: Why the job triggered - either external trigger, scheduled, or cron.
-
-**Job Input**: The input data sent to your assistant.
-
-**Job Output**: The final output produced by your agents.
\ No newline at end of file
diff --git a/apps/docs/docs/using-rowboat/rag.mdx b/apps/docs/docs/using-rowboat/rag.mdx
deleted file mode 100644
index 772d034d..00000000
--- a/apps/docs/docs/using-rowboat/rag.mdx
+++ /dev/null
@@ -1,68 +0,0 @@
----
-title: "RAG (Data)"
-description: "How to use our inbuilt RAG"
-icon: "database"
----
-
-# Using RAG in Rowboat
-
-Rowboat provides multiple ways to enhance your agents' context with Retrieval-Augmented Generation (RAG). This guide will help you set up and use each RAG feature.
-
-RAG is called "Data" on the build view in the Rowboat UI.
-
----
-
-## Types of RAG
-
-| RAG Type | Description | Configuration Required |
-|----------|-------------|------------------------|
-| **Text RAG** | Process and reason over text content directly | No configuration needed |
-| **File Uploads** | Upload PDF files directly from your device | No configuration needed |
-| **URL Scraping** | Scrape content from web URLs using Firecrawl | Requires API key setup |
-
- URL Scraping does not require any setup in the managed version of Rowboat.
-
-
-
-
-
-
-## RAG Features
-
-### 1. Text RAG
-
-Process and reason over text content directly
-
-
-### 2. File Uploads
-
-- Upload PDF files directly from your device
-- **Open Source Version**: Files are stored locally on your machine
-- **Managed Version**: Files are stored in cloud S3 storage
-- Files are parsed using OpenAI by default
-
- You can also use Google's Gemini model for parsing as it is better at parsing larger files.
-
-#### 2.1 Using Gemini for File Parsing
-
-To use Google's Gemini model for parsing uploaded PDFs, set the following variables:
-
-```bash
-# Enable Gemini for file parsing
-export USE_GEMINI_FILE_PARSING=true
-export GOOGLE_API_KEY=your_google_api_key
-```
-
-
-### 3. URL Scraping
-
-Rowboat uses Firecrawl for URL scraping. You can have a maximum of 100 URLs.
-
-**Open Source Version**: To enable URL scraping, set the following variables:
-
-```bash
-export USE_RAG_SCRAPING=true
-export FIRECRAWL_API_KEY=your_firecrawl_api_key
-```
-
-**Managed Version**: No configuration required - URL scraping is handled automatically.
diff --git a/apps/docs/docs/using-rowboat/rowboat-studio.mdx b/apps/docs/docs/using-rowboat/rowboat-studio.mdx
deleted file mode 100644
index edf74fb3..00000000
--- a/apps/docs/docs/using-rowboat/rowboat-studio.mdx
+++ /dev/null
@@ -1,60 +0,0 @@
----
-title: "Rowboat Studio"
-description: "Visual interface to build, test, and deploy multi-agent AI assistants using plain language"
-icon: "puzzle-piece"
----
-
-
-## Overview
-
-**Rowboat Studio** is your visual interface for building AI assistants — powered by agents, tools, and workflows — using plain language and minimal setup. It brings the process of creating multi-agent systems down to just a few clicks.
-
-Workflows created within Rowboat are known as **assistants**, and each assistant is composed of:
-- One or more **agents**
-- Attached **tools** and **MCP servers**
-
-Once built, assistants can be tested live in the **playground** and deployed in real-world products using the [API](/docs/api-sdk/using_the_api) or [SDK](/docs/api-sdk//using_the_sdk).
-
----
-
-## Key Components
-
-Here’s what you’ll interact with in Studio:
-
-| Component | Description | Highlights |
-|------------|-------------|------------|
-| **Agent** | Core building blocks of your assistant. Each agent handles a specific part of the conversation and performs tasks using tools and instructions. | • Define behavior in plain language • Connect agents into a graph • Attach tools and RAG sources |
-| **Playground** | Interactive testbed for conversations. Lets you simulate end-user chats with your assistant and inspect agent behavior in real time. | • Real-time feedback and debugging • See tool calls and agent handoffs • Test individual agents or the whole system |
-| **Copilot** | Your AI assistant for building assistants. Copilot creates and updates agents, tools, and instructions based on your plain-English prompts. | • Understands full system context • Improves agents based on playground chat • Builds workflows intelligently and fast |
-
-> **Agents are the heart of every assistant.** Learn more about how they work in the Agents page.
-
-
- Learn about creating and configuring individual agents within your multi-agent system
-
----
-
-## Building in Rowboat
-
-
-
- Use plain language to tell Copilot what you want your assistant to do. Copilot will auto-generate the agents, instructions, and tools that form the base of your assistant.
-
-
-
- Inspect the created agents — especially their instructions and examples — and refine or approve them before moving forward.
-
-
-
- Integrate external services, tools, and backend logic into your agents using Rowboat's modular system. Tools are tied to agents and triggered through instructions.
-
-
-
- Use the chat playground to simulate real-world conversations. You’ll see which agent takes control, what tools are triggered, and how your assistant flows.
-
-
-
- Assistants can be deployed into production using the **Rowboat Chat API** or the **Rowboat Chat SDK**. Both support stateless and stateful conversation flows.
-
-
-
diff --git a/apps/docs/docs/using-rowboat/tools.mdx b/apps/docs/docs/using-rowboat/tools.mdx
deleted file mode 100644
index 7a116d63..00000000
--- a/apps/docs/docs/using-rowboat/tools.mdx
+++ /dev/null
@@ -1,56 +0,0 @@
----
-title: "Tools"
-description: "Add and configure tools for your agents to interact with external services"
-icon: "wrench"
----
-
-## Overview
-
-The Tools page in Rowboat lets you add and configure tools that your agents can use to interact with external services, APIs, and systems. Tools enable your agents to perform real-world actions like sending emails, managing calendars, or processing payments.
-
-
-
-
-
-## Tool Types
-
-| Tool Type | Description | Use Case | Availability |
-|-----------|-------------|----------|--------------|
-| **Library Tools** | Pre-built integrations with popular services | Quick setup, no configuration needed | Managed and open source |
-| **MCP Tools** | Custom tools from MCP servers | Custom functionality, specialized APIs | Managed and open source |
-| **Webhook Tools** | HTTP endpoints for custom integrations | Your own systems, custom workflows | Open source only |
-
-
-## Library (Composio Tools)
-
-- Browse a library of 500+ toolkits from popular services
-- With 3000+ tools to choose from!
-- Click on a service to see available tools and add them to your workflow
-- Users must create a [Composio](https://composio.dev/) account and add their API key
-- Tools require authorization to work properly
-
-### Setting up Composio API Key
-
-To use Composio tools, get a Composio key and export it as an environment variable:
-
-```bash
-export COMPOSIO_API_KEY=your_api_key_here
-```
-
-Users can visit [Composio's toolkit documentation](https://docs.composio.dev/toolkits/introduction) for a deep dive into all the tools available.
-
-## Custom MCP Servers
-
-- Add your own MCP (Model Context Protocol) servers
-- Connect to custom tools and APIs you've built
-- Configure server URLs and authentication
-- Import tools from your MCP servers
-
-## Webhook
-
-Webhook tools are only available in the open source (local) version of Rowboat.
-
-- Create custom webhook tools
-- Configure HTTP endpoints for your agents to call
-- Set up custom authentication and parameters
-- Build integrations with your own systems
diff --git a/apps/docs/docs/using-rowboat/triggers.mdx b/apps/docs/docs/using-rowboat/triggers.mdx
deleted file mode 100644
index 32c81f66..00000000
--- a/apps/docs/docs/using-rowboat/triggers.mdx
+++ /dev/null
@@ -1,131 +0,0 @@
----
-title: "Triggers"
-description: "Learn about setting up automated triggers for your Rowboat agents"
-icon: "bolt"
----
-
-## Overview
-
-Triggers in Rowboat are automated mechanisms that activate your agents when specific events occur or conditions are met. They form the foundation of your automated workflow system, enabling your agents to respond to external events, scheduled times, and system conditions without manual intervention.
-
-
-
-## Trigger Types
-
-Rowboat supports three main categories of triggers, each designed for different automation scenarios:
-
-| Trigger Type | Purpose | Execution | Use Cases |
-|--------------|---------|-----------|-----------|
-| **External Triggers** | Connect to external services and events | Real-time via webhooks | Slack messages, GitHub events, email processing |
-| **One-Time Triggers** | Execute at specific predetermined times | Single execution at set time | Delayed responses, time-sensitive actions |
-| **Recurring Triggers** | Execute on repeating schedules | Continuous via cron expressions | Daily reports, periodic maintenance, regular syncs |
-
----
-
-## External Triggers (Composio Integration)
-
-External triggers are powered by **Composio** and allow users to use triggers from across 30+ services including Slack, GitHub, Gmail, Notion, Google Calendar, and more.
-
-
-
-
-
-### Creating External Triggers
-
-1. **Click New External Trigger**: Start the trigger creation process
-2. **Select a Toolkit**: Browse available toolkits or search for specific services
-3. **Choose Trigger Type**: Select the specific trigger from available options, click configure
-4. **Authenticate**: Complete OAuth2 flow or enter API keys for the selected service (your preferred method)
-5. **Configure**: Set up event filters and data mapping if required
-6. **Deploy**: Activate the trigger to start listening for events
-
-### Local Setup
-
-If you're running the open source version of Rowboat, you'll need to set up external triggers manually. In the managed version, this is all handled automatically for you.
-
-
-
- Sign into [Composio](https://composio.dev/) and create a new project for your Rowboat instance.
-
-
-
- Go to your project settings and copy the project API key. Export it in your Rowboat environment:
-
- ```bash
- export COMPOSIO_API_KEY=your-composio-api-key
- ```
-
-
-
- Use ngrok to expose your local Rowboat instance:
-
- ```bash
- ngrok http 3000
- ```
-
- Copy the generated ngrok URL (e.g., `https://a5fe8c0d45b8.ngrok-free.app`).
-
-
-
- In Composio, go to Events & Triggers section and set the Trigger Webhook URL to:
-
- ```
- {ngrok_url}/api/composio/webhook
- ```
-
- Example: `https://a5fe8c0d45b8.ngrok-free.app/api/composio/webhook`
-
-
-
- Copy the Webhook Secret from Composio and export it in Rowboat:
-
- ```bash
- export COMPOSIO_TRIGGERS_WEBHOOK_SECRET=your-webhook-secret
- ```
-
-
-
- Restart your Rowboat instance to load the new environment variables. You're now ready to use external triggers!
-
-
-
-Make sure your Rowboat assistant is deployed before receiving trigger calls
-
----
-
-## One-Time Triggers (Scheduled Jobs)
-
-One-time triggers execute your agents at a specific, predetermined time. They're useful for delayed responses, batch processing, time-sensitive actions, or coordinating with external schedules.
-
-
-
-
-
-### Creating One-Time Triggers
-1. Set the exact execution time (date and time)
-2. Configure the input messages for your agents
-3. Deploy to schedule the execution
-
----
-
-## Recurring Triggers (Cron-based Jobs)
-
-Recurring triggers execute your agents on a repeating schedule using cron expressions. They're ideal for daily reports, periodic maintenance, regular data syncs, and continuous monitoring tasks.
-
-
-
-
-
-### Creating Recurring Triggers
-1. Define the cron expression (e.g., `0 9 * * *` for daily at 9 AM)
-2. Configure the recurring message structure
-3. Enable the trigger to start the recurring schedule
-
-### Common Cron Patterns
-```cron
-0 9 * * * # Daily at 9:00 AM
-0 8 * * 1 # Every Monday at 8:00 AM
-*/15 * * * * # Every 15 minutes
-0 0 1 * * # First day of month at midnight
-```
-
diff --git a/apps/rowboat/package.json b/apps/rowboat/package.json
index 5b0bd228..c6c5e2b5 100644
--- a/apps/rowboat/package.json
+++ b/apps/rowboat/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "next dev --turbopack",
+ "dev": "npx next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts
index 72e1e589..4d272275 100644
--- a/apps/x/apps/main/src/ipc.ts
+++ b/apps/x/apps/main/src/ipc.ts
@@ -30,6 +30,8 @@ import * as composioHandler from './composio-handler.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
+import { search } from '@x/core/dist/search/search.js';
+import { versionHistory } from '@x/core';
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@@ -104,6 +106,18 @@ let watcher: FSWatcher | null = null;
const changeQueue = new Set();
let debounceTimer: ReturnType | null = null;
+/**
+ * Emit knowledge commit event to all renderer windows
+ */
+function emitKnowledgeCommitEvent(): void {
+ const windows = BrowserWindow.getAllWindows();
+ for (const win of windows) {
+ if (!win.isDestroyed() && win.webContents) {
+ win.webContents.send('knowledge:didCommit', {});
+ }
+ }
+}
+
/**
* Emit workspace change event to all renderer windows
*/
@@ -282,6 +296,9 @@ export function stopServicesWatcher(): void {
* Add new handlers here as you add channels to IPCChannels
*/
export function setupIpcHandlers() {
+ // Forward knowledge commit events to renderer for panel refresh
+ versionHistory.onCommit(() => emitKnowledgeCommitEvent());
+
registerIpcHandlers({
'app:getVersions': async () => {
// args is null for this channel (no request payload)
@@ -497,5 +514,22 @@ export function setupIpcHandlers() {
const mimeType = mimeMap[ext] || 'application/octet-stream';
return { data: buffer.toString('base64'), mimeType, size: stat.size };
},
+ // Knowledge version history handlers
+ 'knowledge:history': async (_event, args) => {
+ const commits = await versionHistory.getFileHistory(args.path);
+ return { commits };
+ },
+ 'knowledge:fileAtCommit': async (_event, args) => {
+ const content = await versionHistory.getFileAtCommit(args.path, args.oid);
+ return { content };
+ },
+ 'knowledge:restore': async (_event, args) => {
+ await versionHistory.restoreFile(args.path, args.oid);
+ return { ok: true };
+ },
+ // Search handler
+ 'search:query': async (_event, args) => {
+ return search(args.query, args.limit, args.types);
+ },
});
}
diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts
index 2a5330ab..34363b28 100644
--- a/apps/x/apps/main/src/main.ts
+++ b/apps/x/apps/main/src/main.ts
@@ -17,7 +17,6 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
-import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import started from "electron-squirrel-startup";
@@ -171,9 +170,6 @@ app.whenReady().then(async () => {
// start knowledge graph builder
initGraphBuilder();
- // start pre-built agent runner
- initPreBuiltRunner();
-
// start background agent runner (scheduled agents)
initAgentRunner();
diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts
index eba43956..2efc77c2 100644
--- a/apps/x/apps/main/src/oauth-handler.ts
+++ b/apps/x/apps/main/src/oauth-handler.ts
@@ -84,7 +84,7 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str
return clientIdOverride;
}
const oauthRepo = getOAuthRepo();
- const clientId = await oauthRepo.getClientId(provider);
+ const { clientId } = await oauthRepo.read(provider);
if (clientId) {
return clientId;
}
@@ -179,9 +179,9 @@ export async function connectProvider(provider: string, clientId?: string): Prom
// Build authorization URL
const authUrl = oauthClient.buildAuthorizationUrl(config, {
- redirectUri: REDIRECT_URI,
+ redirect_uri: REDIRECT_URI,
scope: scopes.join(' '),
- codeChallenge,
+ code_challenge: codeChallenge,
state,
});
@@ -212,11 +212,11 @@ export async function connectProvider(provider: string, clientId?: string): Prom
// Save tokens
console.log(`[OAuth] Token exchange successful for ${provider}`);
- await oauthRepo.saveTokens(provider, tokens);
+ await oauthRepo.upsert(provider, { tokens });
if (provider === 'google' && clientId) {
- await oauthRepo.setClientId(provider, clientId);
+ await oauthRepo.upsert(provider, { clientId });
}
- await oauthRepo.clearError(provider);
+ await oauthRepo.upsert(provider, { error: null });
// Trigger immediate sync for relevant providers
if (provider === 'google') {
@@ -281,7 +281,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom
export async function disconnectProvider(provider: string): Promise<{ success: boolean }> {
try {
const oauthRepo = getOAuthRepo();
- await oauthRepo.clearTokens(provider);
+ await oauthRepo.delete(provider);
return { success: true };
} catch (error) {
console.error('OAuth disconnect failed:', error);
@@ -297,7 +297,7 @@ export async function getAccessToken(provider: string): Promise {
try {
const oauthRepo = getOAuthRepo();
- let tokens = await oauthRepo.getTokens(provider);
+ const { tokens } = await oauthRepo.read(provider);
if (!tokens) {
return null;
}
@@ -306,7 +306,7 @@ export async function getAccessToken(provider: string): Promise {
if (oauthClient.isTokenExpired(tokens)) {
if (!tokens.refresh_token) {
// No refresh token, need to reconnect
- await oauthRepo.setError(provider, 'Missing refresh token. Please reconnect.');
+ await oauthRepo.upsert(provider, { error: 'Missing refresh token. Please reconnect.' });
return null;
}
@@ -316,11 +316,11 @@ export async function getAccessToken(provider: string): Promise {
// Refresh token, preserving existing scopes
const existingScopes = tokens.scopes;
- tokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes);
- await oauthRepo.saveTokens(provider, tokens);
+ const refreshedTokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes);
+ await oauthRepo.upsert(provider, { tokens });
} catch (error) {
const message = error instanceof Error ? error.message : 'Token refresh failed';
- await oauthRepo.setError(provider, message);
+ await oauthRepo.upsert(provider, { error: message });
console.error('Token refresh failed:', error);
return null;
}
diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx
index 7658c4c8..af6a4740 100644
--- a/apps/x/apps/renderer/src/App.tsx
+++ b/apps/x/apps/renderer/src/App.tsx
@@ -5,16 +5,16 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
-import { Button } from './components/ui/button';
-import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon, SquarePen } from 'lucide-react';
+import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor } from './components/markdown-editor';
-import { ChatInputBar } from './components/chat-button';
import { ChatSidebar } from './components/chat-sidebar';
+import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
+import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
import { useDebounce } from './hooks/use-debounce';
import { SidebarContentPanel } from '@/components/sidebar-content';
-import { SidebarSectionProvider, type ActiveSection } from '@/contexts/sidebar-context';
+import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import {
Conversation,
ConversationContent,
@@ -28,9 +28,6 @@ import {
} from '@/components/ai-elements/message';
import {
type PromptInputMessage,
- PromptInputProvider,
- PromptInputTextarea,
- usePromptInputController,
type FileMention,
} from '@/components/ai-elements/prompt-input';
@@ -46,13 +43,32 @@ import {
SidebarProvider,
useSidebar,
} from "@/components/ui/sidebar"
-import { TooltipProvider } from "@/components/ui/tooltip"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Toaster } from "@/components/ui/sonner"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { OnboardingModal } from '@/components/onboarding-modal'
+import { SearchDialog } from '@/components/search-dialog'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
+import { VersionHistoryPanel } from '@/components/version-history-panel'
import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
+import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
+import {
+ type ChatMessage,
+ type ChatTabViewState,
+ type ConversationItem,
+ type ToolCall,
+ createEmptyChatTabViewState,
+ getWebSearchCardData,
+ inferRunTitleFromMessage,
+ isChatMessage,
+ isErrorMessage,
+ isToolCall,
+ normalizeToolInput,
+ normalizeToolOutput,
+ parseAttachedFiles,
+ toToolState,
+} from '@/lib/chat-conversation'
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
import { toast } from "sonner"
@@ -66,52 +82,6 @@ interface TreeNode extends DirEntry {
loaded?: boolean
}
-interface ChatMessage {
- id: string;
- role: 'user' | 'assistant';
- content: string;
- timestamp: number;
-}
-
-interface ToolCall {
- id: string;
- name: string;
- input: ToolUIPart['input'];
- result?: ToolUIPart['output'];
- status: 'pending' | 'running' | 'completed' | 'error';
- timestamp: number;
-}
-
-interface ErrorMessage {
- id: string;
- kind: 'error';
- message: string;
- timestamp: number;
-}
-
-type ConversationItem = ChatMessage | ToolCall | ErrorMessage;
-
-type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error';
-
-const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
-const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
-const isErrorMessage = (item: ConversationItem): item is ErrorMessage => 'kind' in item && item.kind === 'error'
-
-const toToolState = (status: ToolCall['status']): ToolState => {
- switch (status) {
- case 'pending':
- return 'input-streaming'
- case 'running':
- return 'input-available'
- case 'completed':
- return 'output-available'
- case 'error':
- return 'output-error'
- default:
- return 'input-available'
- }
-}
-
const streamdownComponents = { pre: MarkdownPreOverride }
const DEFAULT_SIDEBAR_WIDTH = 256
@@ -132,48 +102,18 @@ const TITLEBAR_BUTTON_PX = 32
const TITLEBAR_BUTTON_GAP_PX = 4
const TITLEBAR_HEADER_GAP_PX = 8
const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
-const TITLEBAR_BUTTONS_COLLAPSED = 4
-const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
+const TITLEBAR_BUTTONS_COLLAPSED = 5
+const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4
+const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value))
-// Parse attached files from message content and return clean message + file paths
-const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
- const attachedFilesRegex = /\s*([\s\S]*?)\s*<\/attached-files>/
- const match = content.match(attachedFilesRegex)
-
- if (!match) {
- return { message: content, files: [] }
- }
-
- // Extract file paths from the XML
- const filesXml = match[1]
- const filePathRegex = //g
- const files: string[] = []
- let fileMatch
- while ((fileMatch = filePathRegex.exec(filesXml)) !== null) {
- files.push(fileMatch[1])
- }
-
- // Remove the attached-files block
- let cleanMessage = content.replace(attachedFilesRegex, '').trim()
-
- // Also remove @mentions for the attached files (they're shown as pills)
- for (const filePath of files) {
- // Get the display name (last part of path without extension)
- const fileName = filePath.split('/').pop()?.replace(/\.md$/i, '') || ''
- if (fileName) {
- // Remove @filename pattern (with optional trailing space)
- const mentionRegex = new RegExp(`@${fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
- cleanMessage = cleanMessage.replace(mentionRegex, '')
- }
- }
-
- return { message: cleanMessage.trim(), files }
-}
-
const untitledBaseName = 'untitled'
+const untitledIndexedNamePattern = /^untitled-\d+$/
+
+const isUntitledPlaceholderName = (name: string) =>
+ name === untitledBaseName || untitledIndexedNamePattern.test(name)
const getHeadingTitle = (markdown: string) => {
const lines = markdown.split('\n')
@@ -201,6 +141,98 @@ const getBaseName = (path: string) => {
return file.replace(/\.md$/i, '')
}
+const WIKI_LINK_TOKEN_REGEX = /\[\[([^[\]]+)\]\]/g
+const KNOWLEDGE_PREFIX = 'knowledge/'
+
+const normalizeRelPathForWiki = (relPath: string) =>
+ relPath.replace(/\\/g, '/').replace(/^\/+/, '')
+
+const stripKnowledgePrefixForWiki = (relPath: string) => {
+ const normalized = normalizeRelPathForWiki(relPath)
+ return normalized.toLowerCase().startsWith(KNOWLEDGE_PREFIX)
+ ? normalized.slice(KNOWLEDGE_PREFIX.length)
+ : normalized
+}
+
+const stripMarkdownExtensionForWiki = (wikiPath: string) =>
+ wikiPath.toLowerCase().endsWith('.md') ? wikiPath.slice(0, -3) : wikiPath
+
+const wikiPathCompareKey = (wikiPath: string) =>
+ stripMarkdownExtensionForWiki(wikiPath).toLowerCase()
+
+const splitWikiPathPrefix = (rawPath: string) => {
+ let normalized = rawPath.trim().replace(/^\/+/, '').replace(/^\.\//, '')
+ const hadKnowledgePrefix = /^knowledge\//i.test(normalized)
+ if (hadKnowledgePrefix) {
+ normalized = normalized.slice(KNOWLEDGE_PREFIX.length)
+ }
+ return { pathWithoutPrefix: normalized, hadKnowledgePrefix }
+}
+
+const rewriteWikiLinksForRenamedFileInMarkdown = (
+ markdown: string,
+ fromRelPath: string,
+ toRelPath: string
+) => {
+ const normalizedFrom = normalizeRelPathForWiki(fromRelPath)
+ const normalizedTo = normalizeRelPathForWiki(toRelPath)
+ const lowerFrom = normalizedFrom.toLowerCase()
+ const lowerTo = normalizedTo.toLowerCase()
+ if (!lowerFrom.startsWith(KNOWLEDGE_PREFIX) || !lowerFrom.endsWith('.md')) return markdown
+ if (!lowerTo.startsWith(KNOWLEDGE_PREFIX) || !lowerTo.endsWith('.md')) return markdown
+
+ const fromWikiPath = stripKnowledgePrefixForWiki(normalizedFrom)
+ const toWikiPath = stripKnowledgePrefixForWiki(normalizedTo)
+ const fromCompareKey = wikiPathCompareKey(fromWikiPath)
+ const fromBaseName = stripMarkdownExtensionForWiki(fromWikiPath).split('/').pop()?.toLowerCase() ?? null
+ const toWikiPathWithoutExtension = stripMarkdownExtensionForWiki(toWikiPath)
+ const toBaseName = toWikiPathWithoutExtension.split('/').pop() ?? toWikiPathWithoutExtension
+
+ return markdown.replace(WIKI_LINK_TOKEN_REGEX, (fullMatch, innerRaw: string) => {
+ const pipeIndex = innerRaw.indexOf('|')
+ const pathAndAnchor = pipeIndex >= 0 ? innerRaw.slice(0, pipeIndex) : innerRaw
+ const aliasSuffix = pipeIndex >= 0 ? innerRaw.slice(pipeIndex) : ''
+
+ const hashIndex = pathAndAnchor.indexOf('#')
+ const pathPart = hashIndex >= 0 ? pathAndAnchor.slice(0, hashIndex) : pathAndAnchor
+ const anchorSuffix = hashIndex >= 0 ? pathAndAnchor.slice(hashIndex) : ''
+
+ const leadingWhitespace = pathPart.match(/^\s*/)?.[0] ?? ''
+ const trailingWhitespace = pathPart.match(/\s*$/)?.[0] ?? ''
+ const rawPath = pathPart.trim()
+ if (!rawPath) return fullMatch
+
+ const { pathWithoutPrefix, hadKnowledgePrefix } = splitWikiPathPrefix(rawPath)
+ if (!pathWithoutPrefix) return fullMatch
+
+ const matchesFullPath = wikiPathCompareKey(pathWithoutPrefix) === fromCompareKey
+ const isBareTarget = !pathWithoutPrefix.includes('/')
+ const targetBaseName = stripMarkdownExtensionForWiki(pathWithoutPrefix).toLowerCase()
+ const matchesBareSelfName = Boolean(fromBaseName && isBareTarget && targetBaseName === fromBaseName)
+ if (!matchesFullPath && !matchesBareSelfName) return fullMatch
+
+ const preserveMarkdownExtension = rawPath.toLowerCase().endsWith('.md')
+ const rewrittenTarget = matchesBareSelfName
+ ? (preserveMarkdownExtension ? `${toBaseName}.md` : toBaseName)
+ : (preserveMarkdownExtension ? toWikiPath : toWikiPathWithoutExtension)
+ const finalPath = hadKnowledgePrefix ? `${KNOWLEDGE_PREFIX}${rewrittenTarget}` : rewrittenTarget
+
+ return `[[${leadingWhitespace}${finalPath}${trailingWhitespace}${anchorSuffix}${aliasSuffix}]]`
+ })
+}
+
+const getAncestorDirectoryPaths = (path: string): string[] => {
+ const parts = path.split('/').filter(Boolean)
+ if (parts.length <= 2) return []
+ const ancestors: string[] = []
+ for (let i = 1; i < parts.length - 1; i++) {
+ ancestors.push(parts.slice(0, i + 1).join('/'))
+ }
+ return ancestors
+}
+
+const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
+
const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => {
if (!usage) return null
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
@@ -218,29 +250,6 @@ const normalizeUsage = (usage?: Partial | null): LanguageMod
}
}
-const normalizeToolInput = (input: ToolCall['input'] | string | undefined): ToolCall['input'] => {
- if (input === undefined || input === null) return {}
- if (typeof input === 'string') {
- const trimmed = input.trim()
- if (!trimmed) return {}
- try {
- return JSON.parse(trimmed)
- } catch {
- return input
- }
- }
- return input
-}
-
-const normalizeToolOutput = (output: ToolCall['result'] | undefined, status: ToolCall['status']) => {
- if (output === undefined || output === null) {
- return status === 'completed' ? 'No output returned.' : null
- }
- if (output === '') return '(empty output)'
- if (typeof output === 'boolean' || typeof output === 'number') return String(output)
- return output
-}
-
// Sort nodes (dirs first, then alphabetically)
function sortNodes(nodes: TreeNode[]): TreeNode[] {
return nodes.sort((a, b) => {
@@ -292,170 +301,6 @@ const collectDirPaths = (nodes: TreeNode[]): string[] =>
const collectFilePaths = (nodes: TreeNode[]): string[] =>
nodes.flatMap(n => n.kind === 'file' ? [n.path] : (n.children ? collectFilePaths(n.children) : []))
-// Inner component that uses the controller to access mentions
-interface ChatInputInnerProps {
- onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
- onStop?: () => void
- isProcessing: boolean
- isStopping?: boolean
- presetMessage?: string
- onPresetMessageConsumed?: () => void
- runId?: string | null
-}
-
-function ChatInputInner({
- onSubmit,
- onStop,
- isProcessing,
- isStopping,
- presetMessage,
- onPresetMessageConsumed,
- runId,
-}: ChatInputInnerProps) {
- const controller = usePromptInputController()
- const message = controller.textInput.value
- const canSubmit = Boolean(message.trim()) && !isProcessing
-
- // Handle preset message from suggestions
- useEffect(() => {
- if (presetMessage) {
- controller.textInput.setInput(presetMessage)
- onPresetMessageConsumed?.()
- }
- }, [presetMessage, controller.textInput, onPresetMessageConsumed])
-
- const handleSubmit = useCallback(() => {
- if (!canSubmit) return
- onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions)
- controller.textInput.clear()
- controller.mentions.clearMentions()
- }, [canSubmit, message, onSubmit, controller])
-
- const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- handleSubmit()
- }
- }, [handleSubmit])
-
- useEffect(() => {
- const onDragOver = (e: DragEvent) => {
- if (e.dataTransfer?.types?.includes("Files")) {
- e.preventDefault()
- }
- }
- const onDrop = (e: DragEvent) => {
- if (e.dataTransfer?.types?.includes("Files")) {
- e.preventDefault()
- }
- if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
- const paths = Array.from(e.dataTransfer.files)
- .map((f) => window.electronUtils?.getPathForFile(f))
- .filter(Boolean)
- if (paths.length > 0) {
- const currentText = controller.textInput.value
- const pathText = paths.join(' ')
- controller.textInput.setInput(
- currentText ? `${currentText} ${pathText}` : pathText
- )
- }
- }
- }
- document.addEventListener("dragover", onDragOver)
- document.addEventListener("drop", onDrop)
- return () => {
- document.removeEventListener("dragover", onDragOver)
- document.removeEventListener("drop", onDrop)
- }
- }, [controller])
-
- return (
-
-
- {isProcessing ? (
-
- {isStopping ? (
-
- ) : (
-
- )}
-
- ) : (
-
-
-
- )}
-
- )
-}
-
-// Wrapper component with PromptInputProvider
-interface ChatInputWithMentionsProps {
- knowledgeFiles: string[]
- recentFiles: string[]
- visibleFiles: string[]
- onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
- onStop?: () => void
- isProcessing: boolean
- isStopping?: boolean
- presetMessage?: string
- onPresetMessageConsumed?: () => void
- runId?: string | null
-}
-
-function ChatInputWithMentions({
- knowledgeFiles,
- recentFiles,
- visibleFiles,
- onSubmit,
- onStop,
- isProcessing,
- isStopping,
- presetMessage,
- onPresetMessageConsumed,
- runId,
-}: ChatInputWithMentionsProps) {
- return (
-
-
-
- )
-}
-
/** A snapshot of which view the user is on */
type ViewState =
| { type: 'chat'; runId: string | null }
@@ -478,6 +323,7 @@ function FixedSidebarToggle({
canNavigateBack,
canNavigateForward,
onNewChat,
+ onOpenSearch,
leftInsetPx,
}: {
onNavigateBack: () => void
@@ -485,6 +331,7 @@ function FixedSidebarToggle({
canNavigateBack: boolean
canNavigateForward: boolean
onNewChat: () => void
+ onOpenSearch: () => void
leftInsetPx: number
}) {
const { toggleSidebar, state } = useSidebar()
@@ -511,6 +358,15 @@ function FixedSidebarToggle({
>
+
+
+
{/* Back / Forward navigation */}
{isCollapsed && (
<>
@@ -560,15 +416,15 @@ function ContentHeader({
return (
{!isCollapsed && onNavigateBack && onNavigateForward ? (
-
+
boolean; redo: () => boolean }
+
// File browser state (for Knowledge section)
const [selectedPath, setSelectedPath] = useState(null)
const [fileContent, setFileContent] = useState('')
const [editorContent, setEditorContent] = useState('')
const editorContentRef = useRef('')
+ const [editorContentByPath, setEditorContentByPath] = useState>({})
+ const editorContentByPathRef = useRef>(new Map())
const [tree, setTree] = useState([])
const [expandedPaths, setExpandedPaths] = useState>(new Set())
const [recentWikiFiles, setRecentWikiFiles] = useState([])
@@ -615,6 +476,8 @@ function App() {
const [graphStatus, setGraphStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
const [graphError, setGraphError] = useState(null)
const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true)
+ const [isRightPaneMaximized, setIsRightPaneMaximized] = useState(false)
+ const [activeShortcutPane, setActiveShortcutPane] = useState('left')
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac')
const collapsedLeftPaddingPx =
(isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0) +
@@ -632,6 +495,10 @@ function App() {
// Global navigation history (back/forward) across views (chat/file/graph/task)
const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] })
const [viewHistory, setViewHistory] = useState(historyRef.current)
+ const setHistory = useCallback((next: { back: ViewState[]; forward: ViewState[] }) => {
+ historyRef.current = next
+ setViewHistory(next)
+ }, [])
// Auto-save state
const [isSaving, setIsSaving] = useState(false)
@@ -640,8 +507,15 @@ function App() {
const initialContentRef = useRef('')
const renameInProgressRef = useRef(false)
+ // Version history state
+ const [versionHistoryPath, setVersionHistoryPath] = useState(null)
+ const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{
+ oid: string
+ content: string
+ } | null>(null)
+
// Chat state
- const [message, setMessage] = useState('')
+ const [, setMessage] = useState('')
const [conversation, setConversation] = useState([])
const [currentAssistantMessage, setCurrentAssistantMessage] = useState('')
const [, setModelUsage] = useState(null)
@@ -661,20 +535,151 @@ function App() {
type RunListItem = { id: string; title?: string; createdAt: string; agentId: string }
const [runs, setRuns] = useState([])
+ // Chat tab state
+ const [chatTabs, setChatTabs] = useState([{ id: 'default-chat-tab', runId: null }])
+ const [activeChatTabId, setActiveChatTabId] = useState('default-chat-tab')
+ const [chatViewStateByTab, setChatViewStateByTab] = useState>({
+ 'default-chat-tab': createEmptyChatTabViewState(),
+ })
+ const chatViewStateByTabRef = useRef(chatViewStateByTab)
+ const chatTabIdCounterRef = useRef(0)
+ const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}`
+ const chatDraftsRef = useRef(new Map())
+ const chatScrollTopByTabRef = useRef(new Map())
+ const [toolOpenByTab, setToolOpenByTab] = useState>>({})
+ const activeChatTabIdRef = useRef(activeChatTabId)
+ activeChatTabIdRef.current = activeChatTabId
+ const setChatDraftForTab = useCallback((tabId: string, text: string) => {
+ if (text) {
+ chatDraftsRef.current.set(tabId, text)
+ } else {
+ chatDraftsRef.current.delete(tabId)
+ }
+ }, [])
+ const isToolOpenForTab = useCallback((tabId: string, toolId: string): boolean => {
+ return toolOpenByTab[tabId]?.[toolId] ?? false
+ }, [toolOpenByTab])
+ const setToolOpenForTab = useCallback((tabId: string, toolId: string, open: boolean) => {
+ setToolOpenByTab((prev) => {
+ const prevForTab = prev[tabId] ?? {}
+ if (prevForTab[toolId] === open) return prev
+ return {
+ ...prev,
+ [tabId]: {
+ ...prevForTab,
+ [toolId]: open,
+ },
+ }
+ })
+ }, [])
+ const getChatScrollContainer = useCallback((tabId: string): HTMLElement | null => {
+ if (typeof document === 'undefined') return null
+ const panel = document.querySelector(
+ `[data-chat-tab-panel="${tabId}"][aria-hidden="false"]`
+ )
+ if (!panel) return null
+ const logRoot = panel.querySelector('[role="log"]')
+ if (!logRoot) return null
+ const children = Array.from(logRoot.children) as HTMLElement[]
+ for (const child of children) {
+ const style = window.getComputedStyle(child)
+ if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
+ return child
+ }
+ }
+ return null
+ }, [])
+ const saveChatScrollForTab = useCallback((tabId: string) => {
+ const container = getChatScrollContainer(tabId)
+ if (!container) return
+ chatScrollTopByTabRef.current.set(tabId, container.scrollTop)
+ }, [getChatScrollContainer])
+
+ const getChatTabTitle = useCallback((tab: ChatTab) => {
+ if (!tab.runId) return 'New chat'
+ return runs.find(r => r.id === tab.runId)?.title || '(Untitled chat)'
+ }, [runs])
+
+ const isChatTabProcessing = useCallback((tab: ChatTab) => {
+ return tab.runId ? processingRunIds.has(tab.runId) : false
+ }, [processingRunIds])
+
+ // File tab state
+ const [fileTabs, setFileTabs] = useState([])
+ const [activeFileTabId, setActiveFileTabId] = useState(null)
+ const [editorSessionByTabId, setEditorSessionByTabId] = useState>({})
+ const fileHistoryHandlersRef = useRef>(new Map())
+ const fileTabIdCounterRef = useRef(0)
+ const newFileTabId = () => `file-tab-${++fileTabIdCounterRef.current}`
+
+ const getFileTabTitle = useCallback((tab: FileTab) => {
+ if (isGraphTabPath(tab.path)) return 'Graph View'
+ return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
+ }, [])
+
// Pending requests state
- const [pendingPermissionRequests, setPendingPermissionRequests] = useState>>(new Map())
+ const [, setPendingPermissionRequests] = useState>>(new Map())
const [pendingAskHumanRequests, setPendingAskHumanRequests] = useState>>(new Map())
// Track ALL permission requests (for rendering with response status)
const [allPermissionRequests, setAllPermissionRequests] = useState>>(new Map())
// Track permission responses (toolCallId -> response)
const [permissionResponses, setPermissionResponses] = useState>(new Map())
+ useEffect(() => {
+ chatViewStateByTabRef.current = chatViewStateByTab
+ }, [chatViewStateByTab])
+
+ useEffect(() => {
+ const snapshot: ChatTabViewState = {
+ runId,
+ conversation,
+ currentAssistantMessage,
+ pendingAskHumanRequests: new Map(pendingAskHumanRequests),
+ allPermissionRequests: new Map(allPermissionRequests),
+ permissionResponses: new Map(permissionResponses),
+ }
+ setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot }))
+ }, [
+ activeChatTabId,
+ runId,
+ conversation,
+ currentAssistantMessage,
+ pendingAskHumanRequests,
+ allPermissionRequests,
+ permissionResponses,
+ ])
+
+ useEffect(() => {
+ const tabIds = new Set(chatTabs.map((tab) => tab.id))
+ setChatViewStateByTab((prev) => {
+ let changed = false
+ const next: Record = {}
+ for (const [tabId, state] of Object.entries(prev)) {
+ if (tabIds.has(tabId)) {
+ next[tabId] = state
+ } else {
+ changed = true
+ }
+ }
+ for (const tabId of tabIds) {
+ if (!next[tabId]) {
+ next[tabId] = createEmptyChatTabViewState()
+ changed = true
+ }
+ }
+ return changed ? next : prev
+ })
+ }, [chatTabs])
+
// Workspace root for full paths
const [workspaceRoot, setWorkspaceRoot] = useState('')
// Onboarding state
const [showOnboarding, setShowOnboarding] = useState(false)
+ // Search state
+ const [isSearchOpen, setIsSearchOpen] = useState(false)
+
// Background tasks state
type BackgroundTaskItem = {
name: string
@@ -699,42 +704,88 @@ function App() {
}
}, [selectedPath])
+ // Keep active file visible in the Knowledge tree by auto-expanding its ancestor folders.
+ useEffect(() => {
+ if (!selectedPath) return
+ const ancestorDirs = getAncestorDirectoryPaths(selectedPath)
+ if (ancestorDirs.length === 0) return
+
+ setExpandedPaths((prev) => {
+ let changed = false
+ const next = new Set(prev)
+ for (const dirPath of ancestorDirs) {
+ if (!next.has(dirPath)) {
+ next.add(dirPath)
+ changed = true
+ }
+ }
+ return changed ? next : prev
+ })
+ }, [selectedPath])
+
// Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures)
useEffect(() => {
runIdRef.current = runId
}, [runId])
- const handleEditorChange = useCallback((markdown: string) => {
+ const setEditorCacheForPath = useCallback((path: string, content: string) => {
+ editorContentByPathRef.current.set(path, content)
+ setEditorContentByPath((prev) => {
+ if (prev[path] === content) return prev
+ return { ...prev, [path]: content }
+ })
+ }, [])
+
+ const removeEditorCacheForPath = useCallback((path: string) => {
+ editorContentByPathRef.current.delete(path)
+ setEditorContentByPath((prev) => {
+ if (!(path in prev)) return prev
+ const next = { ...prev }
+ delete next[path]
+ return next
+ })
+ }, [])
+
+ const handleEditorChange = useCallback((path: string, markdown: string) => {
+ setEditorCacheForPath(path, markdown)
const nextSelectedPath = selectedPathRef.current
+ if (nextSelectedPath !== path) {
+ return
+ }
// Avoid clobbering editorPath during rapid transitions (e.g. autosave rename) where refs may lag a tick.
if (!editorPathRef.current || (nextSelectedPath && editorPathRef.current === nextSelectedPath)) {
editorPathRef.current = nextSelectedPath
}
editorContentRef.current = markdown
setEditorContent(markdown)
- }, [])
+ }, [setEditorCacheForPath])
// Keep processingRunIdsRef in sync for use in async callbacks
useEffect(() => {
processingRunIdsRef.current = processingRunIds
}, [processingRunIds])
- // Sync active run streaming UI with background tracking
+ // Sync active run streaming UI with background processing tracking.
+ // Depend on both runId and processingRunIds so we don't miss late/early event ordering.
useEffect(() => {
if (!runId) {
setIsProcessing(false)
+ setIsStopping(false)
+ setStopClickedAt(null)
setCurrentAssistantMessage('')
return
}
- const isRunProcessing = processingRunIdsRef.current.has(runId)
+ const isRunProcessing = processingRunIds.has(runId)
setIsProcessing(isRunProcessing)
if (isRunProcessing) {
const buffer = streamingBuffersRef.current.get(runId)
setCurrentAssistantMessage(buffer?.assistant ?? '')
} else {
+ setIsStopping(false)
+ setStopClickedAt(null)
setCurrentAssistantMessage('')
streamingBuffersRef.current.delete(runId)
}
- }, [runId])
+ }, [runId, processingRunIds])
// Load directory tree
const loadDirectory = useCallback(async () => {
@@ -762,15 +813,44 @@ function App() {
const changedPath = event.type === 'changed' ? event.path : null
const changedPaths = (event.type === 'bulkChanged' ? event.paths : []) ?? []
+ const eventPaths = (() => {
+ if (event.type === 'changed') return [event.path]
+ if (event.type === 'bulkChanged') return event.paths ?? []
+ if (event.type === 'moved') return [event.from, event.to]
+ if (event.type === 'created' || event.type === 'deleted') return [event.path]
+ return []
+ })()
+ const selectedPathAtEvent = selectedPathRef.current
// Reload background tasks if agent-schedule.json changed
- if (changedPath === 'config/agent-schedule.json' || changedPaths.includes('config/agent-schedule.json')) {
+ if (
+ changedPath === 'config/agent-schedule.json'
+ || changedPaths.includes('config/agent-schedule.json')
+ ) {
loadBackgroundTasks()
}
+ // Invalidate cached content for files changed outside the active editor.
+ // This prevents stale backlinks after rename-rewrite passes touch many files.
+ for (const path of eventPaths) {
+ if (!path.endsWith('.md')) continue
+ if (selectedPathAtEvent && path === selectedPathAtEvent) continue
+ removeEditorCacheForPath(path)
+ initialContentByPathRef.current.delete(path)
+ }
+
+ // Keep selection stable if a file is moved externally.
+ if (
+ event.type === 'moved'
+ && selectedPathAtEvent
+ && event.from === selectedPathAtEvent
+ ) {
+ setSelectedPath(event.to)
+ }
+
// Reload current file if it was changed externally
- if (!selectedPath) return
- const pathToReload = selectedPath
+ if (!selectedPathAtEvent) return
+ const pathToReload = selectedPathAtEvent
const isCurrentFileChanged =
changedPath === pathToReload || changedPaths.includes(pathToReload)
@@ -778,11 +858,12 @@ function App() {
if (isCurrentFileChanged) {
// Only reload if no unsaved edits
const baseline = initialContentByPathRef.current.get(pathToReload) ?? initialContentRef.current
- if (editorContent === baseline) {
+ if (editorContentRef.current === baseline) {
const result = await window.ipc.invoke('workspace:readFile', { path: pathToReload })
if (selectedPathRef.current !== pathToReload) return
setFileContent(result.data)
setEditorContent(result.data)
+ setEditorCacheForPath(pathToReload, result.data)
editorContentRef.current = result.data
editorPathRef.current = pathToReload
initialContentByPathRef.current.set(pathToReload, result.data)
@@ -792,7 +873,7 @@ function App() {
})
return cleanup
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [loadDirectory, selectedPath, editorContent])
+ }, [loadDirectory, removeEditorCacheForPath, setEditorCacheForPath])
// Load file content when selected
useEffect(() => {
@@ -804,6 +885,17 @@ function App() {
setLastSaved(null)
return
}
+ if (selectedPath.endsWith('.md')) {
+ const cachedContent = editorContentByPathRef.current.get(selectedPath)
+ if (cachedContent !== undefined) {
+ setFileContent(cachedContent)
+ setEditorContent(cachedContent)
+ editorContentRef.current = cachedContent
+ editorPathRef.current = selectedPath
+ initialContentRef.current = initialContentByPathRef.current.get(selectedPath) ?? cachedContent
+ return
+ }
+ }
const requestId = (fileLoadRequestIdRef.current += 1)
const pathToLoad = selectedPath
let cancelled = false
@@ -822,6 +914,9 @@ function App() {
&& normalizeForCompare(editorContentRef.current) !== normalizeForCompare(result.data)
if (!wouldClobberActiveEdits) {
setEditorContent(result.data)
+ if (pathToLoad.endsWith('.md')) {
+ setEditorCacheForPath(pathToLoad, result.data)
+ }
editorContentRef.current = result.data
editorPathRef.current = pathToLoad
initialContentByPathRef.current.set(pathToLoad, result.data)
@@ -850,7 +945,7 @@ function App() {
return () => {
cancelled = true
}
- }, [selectedPath])
+ }, [selectedPath, setEditorCacheForPath])
// Track recently opened markdown files for wiki links
useEffect(() => {
@@ -871,67 +966,106 @@ function App() {
if (debouncedContent === baseline) return
if (!debouncedContent) return
- const saveFile = async () => {
- const wasActiveAtStart = selectedPathRef.current === pathAtStart
- if (wasActiveAtStart) setIsSaving(true)
- let pathToSave = pathAtStart
- let renamedFrom: string | null = null
- let renamedTo: string | null = null
- try {
- // Only rename the currently active file (avoids renaming/jumping while user switches rapidly)
- if (
- wasActiveAtStart &&
- selectedPathRef.current === pathAtStart &&
+ const saveFile = async () => {
+ const wasActiveAtStart = selectedPathRef.current === pathAtStart
+ if (wasActiveAtStart) setIsSaving(true)
+ let pathToSave = pathAtStart
+ let contentToSave = debouncedContent
+ let renamedFrom: string | null = null
+ let renamedTo: string | null = null
+ try {
+ // Only rename the currently active file (avoids renaming/jumping while user switches rapidly)
+ if (
+ wasActiveAtStart &&
+ selectedPathRef.current === pathAtStart &&
!renameInProgressRef.current &&
pathAtStart.startsWith('knowledge/')
) {
- const headingTitle = getHeadingTitle(debouncedContent)
- const desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null
const currentBase = getBaseName(pathAtStart)
- if (desiredName && desiredName !== currentBase) {
- const parentDir = pathAtStart.split('/').slice(0, -1).join('/')
- const targetPath = `${parentDir}/${desiredName}.md`
- if (targetPath !== pathAtStart) {
- const exists = await window.ipc.invoke('workspace:exists', { path: targetPath })
- if (!exists.exists) {
- renameInProgressRef.current = true
- await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath })
- pathToSave = targetPath
- renamedFrom = pathAtStart
- renamedTo = targetPath
- editorPathRef.current = targetPath
- initialContentByPathRef.current.delete(pathAtStart)
- }
- }
- }
- }
- await window.ipc.invoke('workspace:writeFile', {
- path: pathToSave,
- data: debouncedContent,
- opts: { encoding: 'utf8' }
- })
- initialContentByPathRef.current.set(pathToSave, debouncedContent)
+ if (isUntitledPlaceholderName(currentBase)) {
+ const headingTitle = getHeadingTitle(debouncedContent)
+ const desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null
+ if (desiredName && desiredName !== currentBase) {
+ const parentDir = pathAtStart.split('/').slice(0, -1).join('/')
+ let targetPath = `${parentDir}/${desiredName}.md`
+ if (targetPath !== pathAtStart) {
+ let suffix = 1
+ while (true) {
+ const exists = await window.ipc.invoke('workspace:exists', { path: targetPath })
+ if (!exists.exists) break
+ targetPath = `${parentDir}/${desiredName}-${suffix}.md`
+ suffix += 1
+ }
+ renameInProgressRef.current = true
+ await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath })
+ pathToSave = targetPath
+ contentToSave = rewriteWikiLinksForRenamedFileInMarkdown(
+ debouncedContent,
+ pathAtStart,
+ targetPath
+ )
+ renamedFrom = pathAtStart
+ renamedTo = targetPath
+ editorPathRef.current = targetPath
+ setFileTabs(prev => prev.map(tab => (tab.path === pathAtStart ? { ...tab, path: targetPath } : tab)))
+ initialContentByPathRef.current.delete(pathAtStart)
+ const cachedContent = editorContentByPathRef.current.get(pathAtStart)
+ if (cachedContent !== undefined) {
+ const rewrittenCachedContent = rewriteWikiLinksForRenamedFileInMarkdown(
+ cachedContent,
+ pathAtStart,
+ targetPath
+ )
+ editorContentByPathRef.current.delete(pathAtStart)
+ editorContentByPathRef.current.set(targetPath, rewrittenCachedContent)
+ setEditorContentByPath((prev) => {
+ const oldContent = prev[pathAtStart]
+ if (oldContent === undefined) return prev
+ const next = { ...prev }
+ delete next[pathAtStart]
+ next[targetPath] = rewriteWikiLinksForRenamedFileInMarkdown(
+ oldContent,
+ pathAtStart,
+ targetPath
+ )
+ return next
+ })
+ }
+ if (selectedPathRef.current === pathAtStart) {
+ editorContentRef.current = contentToSave
+ setEditorContent(contentToSave)
+ }
+ }
+ }
+ }
+ }
+ await window.ipc.invoke('workspace:writeFile', {
+ path: pathToSave,
+ data: contentToSave,
+ opts: { encoding: 'utf8' }
+ })
+ initialContentByPathRef.current.set(pathToSave, contentToSave)
- // If we renamed the active file, update state/history AFTER the write completes so the editor
- // doesn't reload stale on-disk content mid-typing (which can drop the latest character).
- if (renamedFrom && renamedTo) {
- const fromPath = renamedFrom
- const toPath = renamedTo
- const replaceRenamedPath = (stack: ViewState[]) =>
- stack.map((v) => (v.type === 'file' && v.path === fromPath ? ({ type: 'file', path: toPath } satisfies ViewState) : v))
- setHistory({
- back: replaceRenamedPath(historyRef.current.back),
- forward: replaceRenamedPath(historyRef.current.forward),
- })
+ // If we renamed the active file, update state/history AFTER the write completes so the editor
+ // doesn't reload stale on-disk content mid-typing (which can drop the latest character).
+ if (renamedFrom && renamedTo) {
+ const fromPath = renamedFrom
+ const toPath = renamedTo
+ const replaceRenamedPath = (stack: ViewState[]) =>
+ stack.map((v) => (v.type === 'file' && v.path === fromPath ? ({ type: 'file', path: toPath } satisfies ViewState) : v))
+ setHistory({
+ back: replaceRenamedPath(historyRef.current.back),
+ forward: replaceRenamedPath(historyRef.current.forward),
+ })
- if (selectedPathRef.current === fromPath) {
- setSelectedPath(toPath)
- }
- }
+ if (selectedPathRef.current === fromPath) {
+ setSelectedPath(toPath)
+ }
+ }
- // Only update "current file" UI state if we're still on this file
- if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) {
- initialContentRef.current = debouncedContent
+ // Only update "current file" UI state if we're still on this file
+ if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) {
+ initialContentRef.current = contentToSave
setLastSaved(new Date())
}
} catch (err) {
@@ -944,7 +1078,15 @@ function App() {
}
}
saveFile()
- }, [debouncedContent])
+ }, [debouncedContent, setHistory])
+
+ // Close version history panel when switching files
+ useEffect(() => {
+ if (versionHistoryPath && selectedPath !== versionHistoryPath) {
+ setVersionHistoryPath(null)
+ setViewingHistoricalVersion(null)
+ }
+ }, [selectedPath, versionHistoryPath])
// Load runs list (all pages)
const loadRuns = useCallback(async () => {
@@ -1047,19 +1189,41 @@ function App() {
if (msg.role === 'user' || msg.role === 'assistant') {
// Extract text content from message
let textContent = ''
+ let msgAttachments: ChatMessage['attachments'] = undefined
if (typeof msg.content === 'string') {
textContent = msg.content
} else if (Array.isArray(msg.content)) {
- // Extract text parts
- textContent = msg.content
- .filter((part: { type: string }) => part.type === 'text')
- .map((part: { type: string; text?: string }) => part.text || '')
+ const contentParts = msg.content as Array<{
+ type: string
+ text?: string
+ path?: string
+ filename?: string
+ mimeType?: string
+ size?: number
+ toolCallId?: string
+ toolName?: string
+ arguments?: ToolUIPart['input']
+ }>
+
+ textContent = contentParts
+ .filter((part) => part.type === 'text')
+ .map((part) => part.text || '')
.join('')
-
+
+ const attachmentParts = contentParts.filter((part) => part.type === 'attachment' && part.path)
+ if (attachmentParts.length > 0) {
+ msgAttachments = attachmentParts.map((part) => ({
+ path: part.path!,
+ filename: part.filename || part.path!.split('/').pop() || part.path!,
+ mimeType: part.mimeType || 'application/octet-stream',
+ size: part.size,
+ }))
+ }
+
// Also extract tool-call parts from assistant messages
if (msg.role === 'assistant') {
- for (const part of msg.content) {
- if (part.type === 'tool-call') {
+ for (const part of contentParts) {
+ if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
const toolCall: ToolCall = {
id: part.toolCallId,
name: part.toolName,
@@ -1073,11 +1237,12 @@ function App() {
}
}
}
- if (textContent) {
+ if (textContent || msgAttachments) {
items.push({
id: event.messageId,
role: msg.role,
content: textContent,
+ attachments: msgAttachments,
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
})
}
@@ -1177,34 +1342,25 @@ function App() {
}
}, [])
- // Listen to run events
- // Listen to run events - use ref to avoid stale closure issues
- useEffect(() => {
- const cleanup = window.ipc.on('runs:events', ((event: unknown) => {
- handleRunEvent(event as RunEventType)
- }) as (event: null) => void)
- return cleanup
- }, [])
-
- const getStreamingBuffer = (id: string) => {
+ const getStreamingBuffer = useCallback((id: string) => {
const existing = streamingBuffersRef.current.get(id)
if (existing) return existing
const next = { assistant: '' }
streamingBuffersRef.current.set(id, next)
return next
- }
+ }, [])
- const appendStreamingBuffer = (id: string, delta: string) => {
+ const appendStreamingBuffer = useCallback((id: string, delta: string) => {
if (!delta) return
const buffer = getStreamingBuffer(id)
buffer.assistant += delta
- }
+ }, [getStreamingBuffer])
- const clearStreamingBuffer = (id: string) => {
+ const clearStreamingBuffer = useCallback((id: string) => {
streamingBuffersRef.current.delete(id)
- }
+ }, [])
- const handleRunEvent = (event: RunEventType) => {
+ const handleRunEvent = useCallback((event: RunEventType) => {
const activeRunId = runIdRef.current
const isActiveRun = event.runId === activeRunId
@@ -1228,6 +1384,7 @@ function App() {
next.delete(event.runId)
return next
})
+ void loadRuns()
clearStreamingBuffer(event.runId)
if (!isActiveRun) return
setIsProcessing(false)
@@ -1236,7 +1393,14 @@ function App() {
break
case 'start':
+ setProcessingRunIds(prev => {
+ if (prev.has(event.runId)) return prev
+ const next = new Set(prev)
+ next.add(event.runId)
+ return next
+ })
if (!isActiveRun) return
+ setIsProcessing(true)
setCurrentAssistantMessage('')
setModelUsage(null)
break
@@ -1244,12 +1408,20 @@ function App() {
case 'llm-stream-event':
{
const llmEvent = event.event
+ // Fallback: if processing-start is missed/out-of-order, stream activity still means run is active.
+ setProcessingRunIds(prev => {
+ if (prev.has(event.runId)) return prev
+ const next = new Set(prev)
+ next.add(event.runId)
+ return next
+ })
if (!isActiveRun) {
if (llmEvent.type === 'text-delta' && llmEvent.delta) {
appendStreamingBuffer(event.runId, llmEvent.delta)
}
return
}
+ setIsProcessing(true)
if (llmEvent.type === 'text-delta' && llmEvent.delta) {
appendStreamingBuffer(event.runId, llmEvent.delta)
setCurrentAssistantMessage(prev => prev + llmEvent.delta)
@@ -1273,6 +1445,16 @@ function App() {
case 'message':
{
const msg = event.message
+ if (msg.role === 'user' && typeof msg.content === 'string') {
+ const inferredTitle = inferRunTitleFromMessage(msg.content)
+ if (inferredTitle) {
+ setRuns(prev => prev.map(run => (
+ run.id === event.runId && run.title !== inferredTitle
+ ? { ...run, title: inferredTitle }
+ : run
+ )))
+ }
+ }
if (!isActiveRun) {
if (msg.role === 'assistant') {
clearStreamingBuffer(event.runId)
@@ -1467,68 +1649,159 @@ function App() {
console.error('Run error:', event.error)
break
}
- }
+ }, [appendStreamingBuffer, clearStreamingBuffer, loadRuns])
- const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => {
+ // Listen to run events - use refs/callbacks to avoid stale closure issues.
+ useEffect(() => {
+ const cleanup = window.ipc.on('runs:events', ((event: unknown) => {
+ handleRunEvent(event as RunEventType)
+ }) as (event: null) => void)
+ return cleanup
+ }, [handleRunEvent])
+
+ const handlePromptSubmit = async (
+ message: PromptInputMessage,
+ mentions?: FileMention[],
+ stagedAttachments: StagedAttachment[] = []
+ ) => {
if (isProcessing) return
- const { text } = message;
+ const { text } = message
const userMessage = text.trim()
- if (!userMessage) return
+ const hasAttachments = stagedAttachments.length > 0
+ if (!userMessage && !hasAttachments) return
setMessage('')
const userMessageId = `user-${Date.now()}`
- setConversation(prev => [...prev, {
+ const displayAttachments: ChatMessage['attachments'] = hasAttachments
+ ? stagedAttachments.map((attachment) => ({
+ path: attachment.path,
+ filename: attachment.filename,
+ mimeType: attachment.mimeType,
+ size: attachment.size,
+ thumbnailUrl: attachment.thumbnailUrl,
+ }))
+ : undefined
+ setConversation((prev) => [...prev, {
id: userMessageId,
role: 'user',
content: userMessage,
+ attachments: displayAttachments,
timestamp: Date.now(),
}])
try {
let currentRunId = runId
let isNewRun = false
+ let newRunCreatedAt: string | null = null
if (!currentRunId) {
const run = await window.ipc.invoke('runs:create', {
agentId,
})
currentRunId = run.id
+ newRunCreatedAt = run.createdAt
setRunId(currentRunId)
+ // Update active chat tab's runId to the new run
+ setChatTabs((prev) => prev.map((tab) => (
+ tab.id === activeChatTabId
+ ? { ...tab, runId: currentRunId }
+ : tab
+ )))
isNewRun = true
}
- // Read mentioned file contents and format message with XML context
- let formattedMessage = userMessage
- if (mentions && mentions.length > 0) {
- const attachedFiles = await Promise.all(
- mentions.map(async (m) => {
- try {
- const result = await window.ipc.invoke('workspace:readFile', { path: m.path })
- return { path: m.path, content: result.data as string }
- } catch (err) {
- console.error('Failed to read mentioned file:', m.path, err)
- return { path: m.path, content: `[Error reading file: ${m.path}]` }
- }
- })
- )
+ let titleSource = userMessage
- if (attachedFiles.length > 0) {
- const filesXml = attachedFiles
- .map(f => `\n${f.content}\n `)
- .join('\n')
- formattedMessage = `\n${filesXml}\n \n\n${userMessage}`
+ if (hasAttachments) {
+ type ContentPart =
+ | { type: 'text'; text: string }
+ | {
+ type: 'attachment'
+ path: string
+ filename: string
+ mimeType: string
+ size?: number
+ }
+
+ const contentParts: ContentPart[] = []
+
+ if (mentions && mentions.length > 0) {
+ for (const mention of mentions) {
+ contentParts.push({
+ type: 'attachment',
+ path: mention.path,
+ filename: mention.displayName || mention.path.split('/').pop() || mention.path,
+ mimeType: 'text/markdown',
+ })
+ }
}
+
+ for (const attachment of stagedAttachments) {
+ contentParts.push({
+ type: 'attachment',
+ path: attachment.path,
+ filename: attachment.filename,
+ mimeType: attachment.mimeType,
+ size: attachment.size,
+ })
+ }
+
+ if (userMessage) {
+ contentParts.push({ type: 'text', text: userMessage })
+ } else {
+ titleSource = stagedAttachments[0]?.filename ?? ''
+ }
+
+ // Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema.
+ const attachmentPayload = contentParts as unknown as string
+ await window.ipc.invoke('runs:createMessage', {
+ runId: currentRunId,
+ message: attachmentPayload,
+ })
+ } else {
+ // Legacy path: plain string with optional XML-formatted @mentions.
+ let formattedMessage = userMessage
+ if (mentions && mentions.length > 0) {
+ const attachedFiles = await Promise.all(
+ mentions.map(async (mention) => {
+ try {
+ const result = await window.ipc.invoke('workspace:readFile', { path: mention.path })
+ return { path: mention.path, content: result.data as string }
+ } catch (err) {
+ console.error('Failed to read mentioned file:', mention.path, err)
+ return { path: mention.path, content: `[Error reading file: ${mention.path}]` }
+ }
+ })
+ )
+
+ if (attachedFiles.length > 0) {
+ const filesXml = attachedFiles
+ .map((file) => `\n${file.content}\n `)
+ .join('\n')
+ formattedMessage = `\n${filesXml}\n \n\n${userMessage}`
+ }
+ }
+
+ await window.ipc.invoke('runs:createMessage', {
+ runId: currentRunId,
+ message: formattedMessage,
+ })
+
+ titleSource = formattedMessage
}
- await window.ipc.invoke('runs:createMessage', {
- runId: currentRunId,
- message: formattedMessage,
- })
-
- // Refresh runs list after message is sent (so title is available)
if (isNewRun) {
- loadRuns()
+ const inferredTitle = inferRunTitleFromMessage(titleSource)
+ setRuns((prev) => {
+ const withoutCurrent = prev.filter((run) => run.id !== currentRunId)
+ return [{
+ id: currentRunId!,
+ title: inferredTitle,
+ createdAt: newRunCreatedAt ?? new Date().toISOString(),
+ agentId,
+ }, ...withoutCurrent]
+ })
}
} catch (error) {
console.error('Failed to send message:', error)
@@ -1550,9 +1823,14 @@ function App() {
}
}, [runId, isStopping, stopClickedAt])
- const handlePermissionResponse = useCallback(async (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => {
+ const handlePermissionResponse = useCallback(async (
+ toolCallId: string,
+ subflow: string[],
+ response: 'approve' | 'deny',
+ scope?: 'once' | 'session' | 'always',
+ ) => {
if (!runId) return
-
+
// Optimistically update the UI immediately
setPermissionResponses(prev => {
const next = new Map(prev)
@@ -1564,11 +1842,11 @@ function App() {
next.delete(toolCallId)
return next
})
-
+
try {
await window.ipc.invoke('runs:authorizePermission', {
runId,
- authorization: { subflow, toolCallId, response }
+ authorization: { subflow, toolCallId, response, scope }
})
} catch (error) {
console.error('Failed to authorize permission:', error)
@@ -1607,26 +1885,340 @@ function App() {
setAllPermissionRequests(new Map())
setPermissionResponses(new Map())
setSelectedBackgroundTask(null)
+ setChatViewStateByTab(prev => ({
+ ...prev,
+ [activeChatTabIdRef.current]: createEmptyChatTabViewState(),
+ }))
}, [])
- const handleChatInputSubmit = (text: string) => {
+ // Chat tab operations
+ const applyChatTab = useCallback((tab: ChatTab) => {
+ if (tab.runId) {
+ loadRun(tab.runId)
+ } else {
+ loadRunRequestIdRef.current += 1
+ setConversation([])
+ setCurrentAssistantMessage('')
+ setRunId(null)
+ setMessage('')
+ setModelUsage(null)
+ setIsProcessing(false)
+ setPendingPermissionRequests(new Map())
+ setPendingAskHumanRequests(new Map())
+ setAllPermissionRequests(new Map())
+ setPermissionResponses(new Map())
+ }
+ }, [loadRun])
+
+ const restoreChatTabState = useCallback((tabId: string, fallbackRunId: string | null): boolean => {
+ const cached = chatViewStateByTabRef.current[tabId]
+ if (!cached) return false
+ // Ignore stale cache snapshots that don't match the tab's current run binding.
+ if (cached.runId !== fallbackRunId) return false
+
+ const resolvedRunId = fallbackRunId
+ setRunId(resolvedRunId)
+ setConversation(cached.conversation)
+ setCurrentAssistantMessage(cached.currentAssistantMessage)
+
+ const pendingPermissions = new Map>()
+ for (const [toolCallId, request] of cached.allPermissionRequests.entries()) {
+ if (!cached.permissionResponses.has(toolCallId)) {
+ pendingPermissions.set(toolCallId, request)
+ }
+ }
+ setPendingPermissionRequests(pendingPermissions)
+ setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests))
+ setAllPermissionRequests(new Map(cached.allPermissionRequests))
+ setPermissionResponses(new Map(cached.permissionResponses))
+ setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId)))
+ return true
+ }, [])
+
+ const openChatInNewTab = useCallback((targetRunId: string) => {
+ const existingTab = chatTabs.find(t => t.runId === targetRunId)
+ if (existingTab) {
+ // Cancel stale in-flight loads from previously focused tabs.
+ loadRunRequestIdRef.current += 1
+ setActiveChatTabId(existingTab.id)
+ const restored = restoreChatTabState(existingTab.id, existingTab.runId)
+ if (processingRunIdsRef.current.has(targetRunId) || !restored) {
+ loadRun(targetRunId)
+ }
+ return
+ }
+ const id = newChatTabId()
+ setChatTabs(prev => [...prev, { id, runId: targetRunId }])
+ setActiveChatTabId(id)
+ loadRun(targetRunId)
+ }, [chatTabs, loadRun, restoreChatTabState])
+
+ const switchChatTab = useCallback((tabId: string) => {
+ const tab = chatTabs.find(t => t.id === tabId)
+ if (!tab) return
+ if (tabId === activeChatTabId) return
+ saveChatScrollForTab(activeChatTabId)
+ // Cancel stale in-flight loads from previously focused tabs.
+ loadRunRequestIdRef.current += 1
+ setActiveChatTabId(tabId)
+ const restored = restoreChatTabState(tabId, tab.runId)
+ if (tab.runId && processingRunIdsRef.current.has(tab.runId)) {
+ loadRun(tab.runId)
+ return
+ }
+ if (!restored) {
+ applyChatTab(tab)
+ }
+ }, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab])
+
+ const closeChatTab = useCallback((tabId: string) => {
+ if (chatTabs.length <= 1) return
+ const idx = chatTabs.findIndex(t => t.id === tabId)
+ if (idx === -1) return
+ saveChatScrollForTab(tabId)
+ const nextTabs = chatTabs.filter(t => t.id !== tabId)
+ setChatTabs(nextTabs)
+ setChatViewStateByTab(prev => {
+ if (!(tabId in prev)) return prev
+ const next = { ...prev }
+ delete next[tabId]
+ return next
+ })
+ chatDraftsRef.current.delete(tabId)
+ chatScrollTopByTabRef.current.delete(tabId)
+ setToolOpenByTab((prev) => {
+ if (!(tabId in prev)) return prev
+ const next = { ...prev }
+ delete next[tabId]
+ return next
+ })
+
+ if (tabId === activeChatTabId && nextTabs.length > 0) {
+ const newIdx = Math.min(idx, nextTabs.length - 1)
+ const newActiveTab = nextTabs[newIdx]
+ // Cancel stale in-flight loads from the closing tab.
+ loadRunRequestIdRef.current += 1
+ setActiveChatTabId(newActiveTab.id)
+ const restored = restoreChatTabState(newActiveTab.id, newActiveTab.runId)
+ if (newActiveTab.runId && processingRunIdsRef.current.has(newActiveTab.runId)) {
+ loadRun(newActiveTab.runId)
+ } else if (!restored) {
+ applyChatTab(newActiveTab)
+ }
+ }
+ }, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab])
+
+ useEffect(() => {
+ let cleanupScrollListener: (() => void) | undefined
+ let pollRaf: number | undefined
+ let restoreRafA: number | undefined
+ let restoreRafB: number | undefined
+ let restoreTimeout: ReturnType | undefined
+ let cancelled = false
+
+ const restoreScrollTop = (container: HTMLElement, top: number) => {
+ const maxScroll = Math.max(0, container.scrollHeight - container.clientHeight)
+ const clampedTop = clampNumber(top, 0, maxScroll)
+ container.scrollTop = clampedTop
+ }
+
+ const attach = (): boolean => {
+ if (cancelled) return true
+ const container = getChatScrollContainer(activeChatTabId)
+ if (!container) return false
+
+ const savedTop = chatScrollTopByTabRef.current.get(activeChatTabId)
+ if (savedTop !== undefined) {
+ // Reinforce restoration across a couple frames because stick-to-bottom
+ // may schedule scroll adjustments during mount/resize.
+ restoreScrollTop(container, savedTop)
+ restoreRafA = requestAnimationFrame(() => {
+ restoreScrollTop(container, savedTop)
+ restoreRafB = requestAnimationFrame(() => {
+ restoreScrollTop(container, savedTop)
+ })
+ })
+ restoreTimeout = setTimeout(() => {
+ restoreScrollTop(container, savedTop)
+ }, 220)
+ }
+
+ const onScroll = () => {
+ chatScrollTopByTabRef.current.set(activeChatTabId, container.scrollTop)
+ }
+ container.addEventListener('scroll', onScroll, { passive: true })
+ cleanupScrollListener = () => {
+ chatScrollTopByTabRef.current.set(activeChatTabId, container.scrollTop)
+ container.removeEventListener('scroll', onScroll)
+ }
+ return true
+ }
+
+ let attempts = 0
+ const maxAttempts = 60
+ const pollAttach = () => {
+ if (cancelled) return
+ if (attach()) return
+ if (attempts >= maxAttempts) return
+ attempts += 1
+ pollRaf = requestAnimationFrame(pollAttach)
+ }
+ pollAttach()
+
+ return () => {
+ cancelled = true
+ cleanupScrollListener?.()
+ if (pollRaf !== undefined) cancelAnimationFrame(pollRaf)
+ if (restoreRafA !== undefined) cancelAnimationFrame(restoreRafA)
+ if (restoreRafB !== undefined) cancelAnimationFrame(restoreRafB)
+ if (restoreTimeout !== undefined) clearTimeout(restoreTimeout)
+ }
+ }, [
+ activeChatTabId,
+ selectedPath,
+ isGraphOpen,
+ isChatSidebarOpen,
+ isRightPaneMaximized,
+ getChatScrollContainer,
+ ])
+
+ // File tab operations
+ const openFileInNewTab = useCallback((path: string) => {
+ const existingTab = fileTabs.find(t => t.path === path)
+ if (existingTab) {
+ setActiveFileTabId(existingTab.id)
+ setIsGraphOpen(false)
+ setSelectedPath(path)
+ return
+ }
+ const id = newFileTabId()
+ setFileTabs(prev => [...prev, { id, path }])
+ setActiveFileTabId(id)
+ setIsGraphOpen(false)
+ setSelectedPath(path)
+ }, [fileTabs])
+
+ const switchFileTab = useCallback((tabId: string) => {
+ const tab = fileTabs.find(t => t.id === tabId)
+ if (!tab) return
+ setActiveFileTabId(tabId)
+ setSelectedBackgroundTask(null)
+ setExpandedFrom(null)
+ // If chat-only maximize is active, drop back to a visible knowledge layout.
+ if (isRightPaneMaximized) {
+ setIsRightPaneMaximized(false)
+ }
+ if (isGraphTabPath(tab.path)) {
+ setSelectedPath(null)
+ setIsGraphOpen(true)
+ return
+ }
+ setIsGraphOpen(false)
+ setSelectedPath(tab.path)
+ }, [fileTabs, isRightPaneMaximized])
+
+ const closeFileTab = useCallback((tabId: string) => {
+ const closingTab = fileTabs.find(t => t.id === tabId)
+ if (closingTab && !isGraphTabPath(closingTab.path)) {
+ removeEditorCacheForPath(closingTab.path)
+ initialContentByPathRef.current.delete(closingTab.path)
+ if (editorPathRef.current === closingTab.path) {
+ editorPathRef.current = null
+ }
+ }
+ setFileTabs(prev => {
+ if (prev.length <= 1) {
+ // Last file tab - close it and go back to chat
+ setActiveFileTabId(null)
+ setSelectedPath(null)
+ setIsGraphOpen(false)
+ return []
+ }
+ const idx = prev.findIndex(t => t.id === tabId)
+ if (idx === -1) return prev
+ const next = prev.filter(t => t.id !== tabId)
+ if (tabId === activeFileTabId && next.length > 0) {
+ const newIdx = Math.min(idx, next.length - 1)
+ const newActiveTab = next[newIdx]
+ setActiveFileTabId(newActiveTab.id)
+ if (isGraphTabPath(newActiveTab.path)) {
+ setSelectedPath(null)
+ setIsGraphOpen(true)
+ } else {
+ setIsGraphOpen(false)
+ setSelectedPath(newActiveTab.path)
+ }
+ }
+ return next
+ })
+ setEditorSessionByTabId((prev) => {
+ if (!(tabId in prev)) return prev
+ const next = { ...prev }
+ delete next[tabId]
+ return next
+ })
+ fileHistoryHandlersRef.current.delete(tabId)
+ }, [activeFileTabId, fileTabs, removeEditorCacheForPath])
+
+ const handleNewChatTab = useCallback(() => {
+ // If there's already an empty "New chat" tab, switch to it
+ const emptyTab = chatTabs.find(t => !t.runId)
+ if (emptyTab) {
+ if (emptyTab.id !== activeChatTabId) {
+ setActiveChatTabId(emptyTab.id)
+ }
+ } else {
+ // Create a new tab
+ const id = newChatTabId()
+ setChatTabs(prev => [...prev, { id, runId: null }])
+ setActiveChatTabId(id)
+ }
+ handleNewChat()
+ // Left-pane "new chat" should always open full chat view.
+ if (selectedPath || isGraphOpen) {
+ setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
+ } else {
+ setExpandedFrom(null)
+ }
+ setIsRightPaneMaximized(false)
+ setSelectedPath(null)
+ setIsGraphOpen(false)
+ }, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen])
+
+ // Sidebar variant: create/switch chat tab without leaving file/graph context.
+ const handleNewChatTabInSidebar = useCallback(() => {
+ const emptyTab = chatTabs.find(t => !t.runId)
+ if (emptyTab) {
+ if (emptyTab.id !== activeChatTabId) {
+ setActiveChatTabId(emptyTab.id)
+ }
+ } else {
+ const id = newChatTabId()
+ setChatTabs(prev => [...prev, { id, runId: null }])
+ setActiveChatTabId(id)
+ }
+ handleNewChat()
+ }, [chatTabs, activeChatTabId, handleNewChat])
+
+ const toggleKnowledgePane = useCallback(() => {
+ setIsRightPaneMaximized(false)
+ setIsChatSidebarOpen(prev => !prev)
+ }, [])
+
+ const toggleRightPaneMaximize = useCallback(() => {
setIsChatSidebarOpen(true)
- // Submit immediately - the sidebar will open and show the message
- handlePromptSubmit({ text, files: [] })
- }
+ setIsRightPaneMaximized(prev => !prev)
+ }, [])
const handleOpenFullScreenChat = useCallback(() => {
// Remember where we came from so the close button can return
if (selectedPath || isGraphOpen) {
setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
}
- // Copy sidebar input text to full-screen input (keep sidebar message intact for return)
- if (message.trim()) {
- setPresetMessage(message)
- }
+ setIsRightPaneMaximized(false)
setSelectedPath(null)
setIsGraphOpen(false)
- }, [selectedPath, isGraphOpen, message])
+ }, [selectedPath, isGraphOpen])
const handleCloseFullScreenChat = useCallback(() => {
if (expandedFrom) {
@@ -1636,14 +2228,10 @@ function App() {
setSelectedPath(expandedFrom.path)
}
setExpandedFrom(null)
+ setIsRightPaneMaximized(false)
}
}, [expandedFrom])
- const setHistory = useCallback((next: { back: ViewState[]; forward: ViewState[] }) => {
- historyRef.current = next
- setViewHistory(next)
- }, [])
-
const currentViewState = React.useMemo(() => {
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
if (selectedPath) return { type: 'file', path: selectedPath }
@@ -1657,30 +2245,80 @@ function App() {
return [...stack, entry]
}, [])
+ const ensureFileTabForPath = useCallback((path: string) => {
+ const existingTab = fileTabs.find((tab) => tab.path === path)
+ if (existingTab) {
+ setActiveFileTabId(existingTab.id)
+ return
+ }
+
+ if (activeFileTabId) {
+ const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId)
+ if (activeTab && !isGraphTabPath(activeTab.path)) {
+ setFileTabs((prev) => prev.map((tab) => (
+ tab.id === activeFileTabId ? { ...tab, path } : tab
+ )))
+ // Rebinds this tab to a different note path: reset editor session to clear undo history.
+ setEditorSessionByTabId((prev) => ({
+ ...prev,
+ [activeFileTabId]: (prev[activeFileTabId] ?? 0) + 1,
+ }))
+ return
+ }
+ }
+
+ const id = newFileTabId()
+ setFileTabs((prev) => [...prev, { id, path }])
+ setActiveFileTabId(id)
+ }, [fileTabs, activeFileTabId])
+
+ const ensureGraphFileTab = useCallback(() => {
+ const existingGraphTab = fileTabs.find((tab) => isGraphTabPath(tab.path))
+ if (existingGraphTab) {
+ setActiveFileTabId(existingGraphTab.id)
+ return
+ }
+ const id = newFileTabId()
+ setFileTabs((prev) => [...prev, { id, path: GRAPH_TAB_PATH }])
+ setActiveFileTabId(id)
+ }, [fileTabs])
+
const applyViewState = useCallback(async (view: ViewState) => {
switch (view.type) {
case 'file':
setSelectedBackgroundTask(null)
setIsGraphOpen(false)
setExpandedFrom(null)
+ // Preserve split vs knowledge-max mode when navigating knowledge files.
+ // Only exit chat-only maximize, because that would hide the selected file.
+ if (isRightPaneMaximized) {
+ setIsRightPaneMaximized(false)
+ }
setSelectedPath(view.path)
+ ensureFileTabForPath(view.path)
return
case 'graph':
setSelectedBackgroundTask(null)
setSelectedPath(null)
setExpandedFrom(null)
setIsGraphOpen(true)
+ ensureGraphFileTab()
+ if (isRightPaneMaximized) {
+ setIsRightPaneMaximized(false)
+ }
return
case 'task':
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
+ setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name)
return
case 'chat':
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
+ setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
if (view.runId) {
await loadRun(view.runId)
@@ -1689,7 +2327,7 @@ function App() {
}
return
}
- }, [handleNewChat, loadRun])
+ }, [ensureFileTabForPath, ensureGraphFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState
@@ -1829,6 +2467,124 @@ function App() {
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat])
+ // Keyboard shortcut: Cmd+K / Ctrl+K to open search
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
+ e.preventDefault()
+ setIsSearchOpen(true)
+ }
+ }
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [])
+
+ // Route undo/redo to the active markdown tab only (prevents cross-tab browser undo behavior).
+ useEffect(() => {
+ const handleHistoryKeyDown = (e: KeyboardEvent) => {
+ const mod = e.metaKey || e.ctrlKey
+ if (!mod || e.altKey) return
+
+ const key = e.key.toLowerCase()
+ const wantsUndo = key === 'z' && !e.shiftKey
+ const wantsRedo = (key === 'z' && e.shiftKey) || (!isMac && key === 'y')
+ if (!wantsUndo && !wantsRedo) return
+
+ if (!selectedPath || !selectedPath.endsWith('.md') || !activeFileTabId) return
+
+ const target = e.target as EventTarget | null
+ if (target instanceof HTMLElement) {
+ const inTipTapEditor = Boolean(target.closest('.tiptap-editor'))
+ const inOtherTextInput = (
+ target instanceof HTMLInputElement
+ || target instanceof HTMLTextAreaElement
+ || target.isContentEditable
+ ) && !inTipTapEditor
+ if (inOtherTextInput) return
+ }
+
+ const handlers = fileHistoryHandlersRef.current.get(activeFileTabId)
+ if (!handlers) return
+
+ e.preventDefault()
+ e.stopPropagation()
+ if (wantsUndo) {
+ handlers.undo()
+ } else {
+ handlers.redo()
+ }
+ }
+
+ document.addEventListener('keydown', handleHistoryKeyDown, true)
+ return () => document.removeEventListener('keydown', handleHistoryKeyDown, true)
+ }, [activeFileTabId, isMac, selectedPath])
+
+ // Keyboard shortcuts for tab management
+ useEffect(() => {
+ const handleTabKeyDown = (e: KeyboardEvent) => {
+ const mod = e.metaKey || e.ctrlKey
+ if (!mod) return
+ const rightPaneAvailable = Boolean((selectedPath || isGraphOpen) && isChatSidebarOpen)
+ const targetPane: ShortcutPane = rightPaneAvailable
+ ? (isRightPaneMaximized ? 'right' : activeShortcutPane)
+ : 'left'
+ const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen)
+ const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : selectedPath
+ const targetFileTabId = activeFileTabId ?? (
+ selectedKnowledgePath
+ ? (fileTabs.find((tab) => tab.path === selectedKnowledgePath)?.id ?? null)
+ : null
+ )
+
+ // Cmd+W — close active tab
+ if (e.key === 'w') {
+ e.preventDefault()
+ if (inFileView && targetFileTabId) {
+ closeFileTab(targetFileTabId)
+ } else {
+ closeChatTab(activeChatTabId)
+ }
+ return
+ }
+
+ // Cmd+1..9 — switch to tab N (Cmd+9 always goes to last tab)
+ if (/^[1-9]$/.test(e.key)) {
+ e.preventDefault()
+ const n = parseInt(e.key, 10)
+ if (inFileView) {
+ const idx = e.key === '9' ? fileTabs.length - 1 : n - 1
+ const tab = fileTabs[idx]
+ if (tab) switchFileTab(tab.id)
+ } else {
+ const idx = e.key === '9' ? chatTabs.length - 1 : n - 1
+ const tab = chatTabs[idx]
+ if (tab) switchChatTab(tab.id)
+ }
+ return
+ }
+
+ // Cmd+Shift+] — next tab, Cmd+Shift+[ — previous tab
+ if (e.shiftKey && (e.key === ']' || e.key === '[')) {
+ e.preventDefault()
+ const direction = e.key === ']' ? 1 : -1
+ if (inFileView) {
+ const currentIdx = fileTabs.findIndex(t => t.id === targetFileTabId)
+ if (currentIdx === -1) return
+ const nextIdx = (currentIdx + direction + fileTabs.length) % fileTabs.length
+ switchFileTab(fileTabs[nextIdx].id)
+ } else {
+ const currentIdx = chatTabs.findIndex(t => t.id === activeChatTabId)
+ if (currentIdx === -1) return
+ const nextIdx = (currentIdx + direction + chatTabs.length) % chatTabs.length
+ switchChatTab(chatTabs[nextIdx].id)
+ }
+ return
+ }
+ }
+ document.addEventListener('keydown', handleTabKeyDown)
+ return () => document.removeEventListener('keydown', handleTabKeyDown)
+ }, [selectedPath, isGraphOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
+
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') {
navigateToFile(path)
@@ -1844,16 +2600,6 @@ function App() {
setExpandedPaths(newExpanded)
}
- // Handle sidebar section changes - switch to chat view for tasks
- const handleSectionChange = useCallback((section: ActiveSection) => {
- if (section === 'tasks') {
- if (selectedBackgroundTask) return
- if (selectedPath || isGraphOpen) {
- void navigateToView({ type: 'chat', runId })
- }
- }
- }, [isGraphOpen, navigateToView, runId, selectedBackgroundTask, selectedPath])
-
// Knowledge quick actions
const knowledgeFiles = React.useMemo(() => {
const files = collectFilePaths(tree).filter((path) => path.endsWith('.md'))
@@ -1958,6 +2704,11 @@ function App() {
}
},
openGraph: () => {
+ // From chat-only landing state, open graph directly in full knowledge view.
+ if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
+ setIsChatSidebarOpen(false)
+ setIsRightPaneMaximized(false)
+ }
void navigateToView({ type: 'graph' })
},
expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))),
@@ -1970,6 +2721,36 @@ function App() {
parts[parts.length - 1] = finalName
const newPath = parts.join('/')
await window.ipc.invoke('workspace:rename', { from: oldPath, to: newPath })
+ const rewriteForRename = (content: string) =>
+ isDir ? content : rewriteWikiLinksForRenamedFileInMarkdown(content, oldPath, newPath)
+ setFileTabs(prev => prev.map(tab => (tab.path === oldPath ? { ...tab, path: newPath } : tab)))
+ if (editorPathRef.current === oldPath) {
+ editorPathRef.current = newPath
+ }
+ const baseline = initialContentByPathRef.current.get(oldPath)
+ if (baseline !== undefined) {
+ initialContentByPathRef.current.delete(oldPath)
+ initialContentByPathRef.current.set(newPath, rewriteForRename(baseline))
+ }
+ const cachedContent = editorContentByPathRef.current.get(oldPath)
+ if (cachedContent !== undefined) {
+ const rewrittenCachedContent = rewriteForRename(cachedContent)
+ editorContentByPathRef.current.delete(oldPath)
+ editorContentByPathRef.current.set(newPath, rewrittenCachedContent)
+ setEditorContentByPath(prev => {
+ if (!(oldPath in prev)) return prev
+ const next = { ...prev }
+ delete next[oldPath]
+ next[newPath] = rewriteForRename(cachedContent)
+ return next
+ })
+ }
+ if (selectedPath === oldPath) {
+ const rewrittenEditorContent = rewriteForRename(editorContentRef.current)
+ editorContentRef.current = rewrittenEditorContent
+ setEditorContent(rewrittenEditorContent)
+ initialContentRef.current = rewriteForRename(initialContentRef.current)
+ }
if (selectedPath === oldPath) setSelectedPath(newPath)
} catch (err) {
console.error('Failed to rename:', err)
@@ -1979,7 +2760,17 @@ function App() {
remove: async (path: string) => {
try {
await window.ipc.invoke('workspace:remove', { path, opts: { trash: true } })
- if (selectedPath === path) setSelectedPath(null)
+ if (path.endsWith('.md')) {
+ removeEditorCacheForPath(path)
+ initialContentByPathRef.current.delete(path)
+ }
+ // Close any file tab showing the deleted file
+ const tabForFile = fileTabs.find(t => t.path === path)
+ if (tabForFile) {
+ closeFileTab(tabForFile.id)
+ } else if (selectedPath === path) {
+ setSelectedPath(null)
+ }
} catch (err) {
console.error('Failed to remove:', err)
throw err
@@ -1989,7 +2780,10 @@ function App() {
const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path
navigator.clipboard.writeText(fullPath)
},
- }), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView])
+ onOpenInNewTab: (path: string) => {
+ openFileInNewTab(path)
+ },
+ }), [tree, selectedPath, isGraphOpen, selectedBackgroundTask, workspaceRoot, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab, removeEditorCacheForPath])
// Handler for when a voice note is created/updated
const handleVoiceNoteCreated = useCallback(async (notePath: string) => {
@@ -2164,9 +2958,21 @@ function App() {
}
}, [isGraphOpen, knowledgeFilePaths])
- const renderConversationItem = (item: ConversationItem) => {
+ const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) {
if (item.role === 'user') {
+ if (item.attachments && item.attachments.length > 0) {
+ return (
+
+
+
+
+ {item.content && (
+ {item.content}
+ )}
+
+ )
+ }
const { message, files } = parseAttachedFiles(item.content)
return (
@@ -2198,36 +3004,15 @@ function App() {
}
if (isToolCall(item)) {
- if (item.name === 'web-search') {
- const input = normalizeToolInput(item.input) as Record | undefined
- const result = item.result as Record | undefined
+ const webSearchData = getWebSearchCardData(item)
+ if (webSearchData) {
return (
) || []}
+ query={webSearchData.query}
+ results={webSearchData.results}
status={item.status}
- />
- )
- }
- if (item.name === 'research-search') {
- const input = normalizeToolInput(item.input) as Record | undefined
- const result = item.result as Record | undefined
- const rawResults = (result?.results as Array<{ title: string; url: string; highlights?: string[]; text?: string }>) || []
- const mapped = rawResults.map(r => ({
- title: r.title,
- url: r.url,
- description: r.highlights?.[0] || (r.text ? r.text.slice(0, 200) : ''),
- }))
- const category = input?.category as string | undefined
- const cardTitle = category ? `${category.charAt(0).toUpperCase() + category.slice(1)} search` : 'Researched the web'
- return (
-
)
}
@@ -2235,7 +3020,11 @@ function App() {
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
return (
-
+ setToolOpenForTab(tabId, item.id, open)}
+ >
0 || currentAssistantMessage
- const conversationContentClassName = hasConversation
- ? "mx-auto w-full max-w-4xl pb-28"
- : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
- const headerTitle = selectedPath
- ? selectedPath
- : isGraphOpen
- ? 'Graph View'
- : selectedBackgroundTask
- ? `Background Task: ${selectedBackgroundTask}`
- : 'Chat'
+ const activeChatTabState = React.useMemo(() => ({
+ runId,
+ conversation,
+ currentAssistantMessage,
+ pendingAskHumanRequests,
+ allPermissionRequests,
+ permissionResponses,
+ }), [
+ runId,
+ conversation,
+ currentAssistantMessage,
+ pendingAskHumanRequests,
+ allPermissionRequests,
+ permissionResponses,
+ ])
+ const emptyChatTabState = React.useMemo(() => createEmptyChatTabViewState(), [])
+ const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => {
+ if (tabId === activeChatTabId) return activeChatTabState
+ return chatViewStateByTab[tabId] ?? emptyChatTabState
+ }, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState])
+ const hasConversation = activeChatTabState.conversation.length > 0 || activeChatTabState.currentAssistantMessage
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
+ const isRightPaneContext = Boolean(selectedPath || isGraphOpen)
+ const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
+ const shouldCollapseLeftPane = isRightPaneOnlyMode
+ const openMarkdownTabs = React.useMemo(() => {
+ const markdownTabs = fileTabs.filter(tab => tab.path.endsWith('.md'))
+ if (selectedPath?.endsWith('.md')) {
+ const hasSelectedTab = markdownTabs.some(tab => tab.path === selectedPath)
+ if (!hasSelectedTab) {
+ return [...markdownTabs, { id: '__active-markdown-tab__', path: selectedPath }]
+ }
+ }
+ return markdownTabs
+ }, [fileTabs, selectedPath])
return (
-
-
+
+
{/* Content sidebar with SidebarProvider for collapse functionality */}
{
- void navigateToView({ type: 'chat', runId: null })
- },
+ onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => {
+ if (selectedPath || isGraphOpen) {
+ setIsChatSidebarOpen(true)
+ }
+
+ // If already open in a chat tab, switch to it
+ const existingTab = chatTabs.find(t => t.runId === runIdToLoad)
+ if (existingTab) {
+ switchChatTab(existingTab.id)
+ return
+ }
+ // In two-pane mode, keep current knowledge/graph context and just swap chat context.
+ if (selectedPath || isGraphOpen) {
+ setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
+ loadRun(runIdToLoad)
+ return
+ }
+
+ // Outside two-pane mode, navigate to chat.
+ setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
void navigateToView({ type: 'chat', runId: runIdToLoad })
},
+ onOpenInNewTab: (targetRunId) => {
+ openChatInNewTab(targetRunId)
+ },
onDeleteRun: async (runIdToDelete) => {
try {
await window.ipc.invoke('runs:delete', { runId: runIdToDelete })
- if (runId === runIdToDelete) {
- void navigateToView({ type: 'chat', runId: null })
+ // Close any chat tab showing the deleted run
+ const tabForRun = chatTabs.find(t => t.runId === runIdToDelete)
+ if (tabForRun) {
+ if (chatTabs.length > 1) {
+ closeChatTab(tabForRun.id)
+ } else {
+ // Only one tab, reset it to new chat
+ setChatTabs([{ id: tabForRun.id, runId: null }])
+ if (selectedPath || isGraphOpen) {
+ handleNewChat()
+ } else {
+ void navigateToView({ type: 'chat', runId: null })
+ }
+ }
+ } else if (runId === runIdToDelete) {
+ if (selectedPath || isGraphOpen) {
+ setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
+ handleNewChat()
+ } else {
+ void navigateToView({ type: 'chat', runId: null })
+ }
}
await loadRuns()
} catch (err) {
@@ -2324,7 +3175,16 @@ function App() {
backgroundTasks={backgroundTasks}
selectedBackgroundTask={selectedBackgroundTask}
/>
-
+ setActiveShortcutPane('left')}
+ onFocusCapture={() => setActiveShortcutPane('left')}
+ >
{/* Header - also serves as titlebar drag region, adjusts padding when sidebar collapsed */}
{ void navigateBack() }}
@@ -2333,11 +3193,29 @@ function App() {
canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
>
-
- {headerTitle}
-
+ {(selectedPath || isGraphOpen) && fileTabs.length >= 1 ? (
+ t.id}
+ onSwitchTab={switchFileTab}
+ onCloseTab={closeFileTab}
+ allowSingleTabClose={fileTabs.length === 1 && isGraphOpen}
+ />
+ ) : (
+ t.id}
+ isProcessing={isChatTabProcessing}
+ onSwitchTab={switchChatTab}
+ onCloseTab={closeChatTab}
+ />
+ )}
{selectedPath && (
-
+
{isSaving ? (
<>
@@ -2351,35 +3229,77 @@ function App() {
) : null}
)}
- {!selectedPath && isGraphOpen && (
-
{ void navigateToView({ type: 'chat', runId }) }}
- className="titlebar-no-drag text-foreground"
- >
- Close Graph
-
+ {selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && (
+
+
+ {
+ if (versionHistoryPath) {
+ setVersionHistoryPath(null)
+ setViewingHistoricalVersion(null)
+ } else {
+ setVersionHistoryPath(selectedPath)
+ }
+ }}
+ className={cn(
+ "titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0",
+ versionHistoryPath && "bg-accent text-foreground"
+ )}
+ aria-label="Version history"
+ >
+
+
+
+ Version history
+
+ )}
+ {!selectedPath && !isGraphOpen && !selectedTask && (
+
+
+
+
+
+
+ New chat tab
+
)}
{!selectedPath && !isGraphOpen && expandedFrom && (
-
-
-
+
+
+
+
+
+
+ Restore two-pane view
+
)}
{(selectedPath || isGraphOpen) && (
-
setIsChatSidebarOpen(!isChatSidebarOpen)}
- className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors -mr-1"
- aria-label="Toggle Chat Sidebar"
- >
-
-
+
+
+
+ {isChatSidebarOpen ? : }
+
+
+
+ {isChatSidebarOpen ? "Maximize knowledge view" : "Restore two-pane view"}
+
+
)}
@@ -2397,14 +3317,80 @@ function App() {
) : selectedPath ? (
selectedPath.endsWith('.md') ? (
-
-
+
+
+ {openMarkdownTabs.map((tab) => {
+ const isActive = activeFileTabId
+ ? tab.id === activeFileTabId || tab.path === selectedPath
+ : tab.path === selectedPath
+ const isViewingHistory = viewingHistoricalVersion && isActive && versionHistoryPath === tab.path
+ const tabContent = isViewingHistory
+ ? viewingHistoricalVersion.content
+ : editorContentByPath[tab.path]
+ ?? (isActive && editorPathRef.current === tab.path ? editorContent : '')
+ return (
+
+ { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
+ placeholder="Start writing..."
+ wikiLinks={wikiLinkConfig}
+ onImageUpload={handleImageUpload}
+ editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
+ onHistoryHandlersChange={(handlers) => {
+ if (handlers) {
+ fileHistoryHandlersRef.current.set(tab.id, handlers)
+ } else {
+ fileHistoryHandlersRef.current.delete(tab.id)
+ }
+ }}
+ editable={!isViewingHistory}
+ />
+
+ )
+ })}
+
+ {versionHistoryPath && (
+
{
+ setVersionHistoryPath(null)
+ setViewingHistoricalVersion(null)
+ }}
+ onSelectVersion={(oid, content) => {
+ if (oid === null) {
+ setViewingHistoricalVersion(null)
+ } else {
+ setViewingHistoricalVersion({ oid, content })
+ }
+ }}
+ onRestore={async (oid) => {
+ try {
+ await window.ipc.invoke('knowledge:restore', {
+ path: versionHistoryPath.startsWith('knowledge/')
+ ? versionHistoryPath.slice('knowledge/'.length)
+ : versionHistoryPath,
+ oid,
+ })
+ // Reload file content
+ const result = await window.ipc.invoke('workspace:readFile', { path: versionHistoryPath })
+ handleEditorChange(versionHistoryPath, result.data)
+ setViewingHistoricalVersion(null)
+ setVersionHistoryPath(null)
+ } catch (err) {
+ console.error('Failed to restore version:', err)
+ }
+ }}
+ />
+ )}
) : (
@@ -2431,70 +3417,94 @@ function App() {
) : (
{ navigateToFile(path) }}>
-
-
-
- {!hasConversation ? (
-
-
- What are we working on?
-
-
- ) : (
- <>
- {conversation.map(item => {
- const rendered = renderConversationItem(item)
- // If this is a tool call, check for permission request (pending or responded)
- if (isToolCall(item)) {
- const permRequest = allPermissionRequests.get(item.id)
- if (permRequest) {
- const response = permissionResponses.get(item.id) || null
- return (
-
- {rendered}
- handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
- onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
- isProcessing={isProcessing}
- response={response}
+
+ {chatTabs.map((tab) => {
+ const isActive = tab.id === activeChatTabId
+ const tabState = getChatTabStateForRender(tab.id)
+ const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage
+ const tabConversationContentClassName = tabHasConversation
+ ? "mx-auto w-full max-w-4xl pb-28"
+ : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
+ return (
+
+
+
+
+ {!tabHasConversation ? (
+
+
+ What are we working on?
+
+
+ ) : (
+ <>
+ {tabState.conversation.map(item => {
+ const rendered = renderConversationItem(item, tab.id)
+ if (isToolCall(item)) {
+ const permRequest = tabState.allPermissionRequests.get(item.id)
+ if (permRequest) {
+ const response = tabState.permissionResponses.get(item.id) || null
+ return (
+
+ {rendered}
+ handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
+ onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
+ onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
+ onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
+ isProcessing={isActive && isProcessing}
+ response={response}
+ />
+
+ )
+ }
+ }
+ return rendered
+ })}
+
+ {Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
+ handleAskHumanResponse(request.toolCallId, request.subflow, response)}
+ isProcessing={isActive && isProcessing}
/>
-
- )
- }
- }
- return rendered
- })}
+ ))}
- {/* Render pending ask-human requests */}
- {Array.from(pendingAskHumanRequests.values()).map((request) => (
- handleAskHumanResponse(request.toolCallId, request.subflow, response)}
- isProcessing={isProcessing}
- />
- ))}
+ {tabState.currentAssistantMessage && (
+
+
+ {tabState.currentAssistantMessage}
+
+
+ )}
- {currentAssistantMessage && (
-
-
- {currentAssistantMessage}
-
-
- )}
-
- {isProcessing && !currentAssistantMessage && (
-
-
- Thinking...
-
-
- )}
- >
- )}
-
-
+ {isActive && isProcessing && !tabState.currentAssistantMessage && (
+
+
+ Thinking...
+
+
+ )}
+ >
+ )}
+
+
+
+ )
+ })}
+
@@ -2502,18 +3512,34 @@ function App() {
{!hasConversation && (
)}
-
setPresetMessage(undefined)}
- runId={runId}
- />
+ {chatTabs.map((tab) => {
+ const isActive = tab.id === activeChatTabId
+ const tabState = getChatTabStateForRender(tab.id)
+ return (
+
+ setPresetMessage(undefined) : undefined}
+ runId={tabState.runId}
+ initialDraft={chatDraftsRef.current.get(tab.id)}
+ onDraftChange={(text) => setChatDraftForTab(tab.id, text)}
+ />
+
+ )
+ })}
@@ -2522,31 +3548,43 @@ function App() {
{/* Chat sidebar - shown when viewing files/graph */}
- {(selectedPath || isGraphOpen) && (
+ {isRightPaneContext && (
setPresetMessage(undefined)}
+ getInitialDraft={(tabId) => chatDraftsRef.current.get(tabId)}
+ onDraftChangeForTab={setChatDraftForTab}
pendingAskHumanRequests={pendingAskHumanRequests}
allPermissionRequests={allPermissionRequests}
permissionResponses={permissionResponses}
onPermissionResponse={handlePermissionResponse}
onAskHumanResponse={handleAskHumanResponse}
+ isToolOpenForTab={isToolOpenForTab}
+ onToolOpenChangeForTab={setToolOpenForTab}
onOpenKnowledgeFile={(path) => { navigateToFile(path) }}
+ onActivate={() => setActiveShortcutPane('right')}
/>
)}
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
@@ -2555,19 +3593,18 @@ function App() {
onNavigateForward={() => { void navigateForward() }}
canNavigateBack={canNavigateBack}
canNavigateForward={canNavigateForward}
- onNewChat={handleNewChat}
+ onNewChat={handleNewChatTab}
+ onOpenSearch={() => setIsSearchOpen(true)}
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
/>
-
- {/* Floating chat input - shown when viewing files/graph and chat sidebar is closed */}
- {(selectedPath || isGraphOpen) && !isChatSidebarOpen && (
- setIsChatSidebarOpen(true)}
- />
- )}
+ { void navigateToView({ type: 'chat', runId: id }) }}
+ />
{
- const { isAtBottom } = useStickToBottomContext();
+ const { isAtBottom, scrollRef } = useStickToBottomContext();
const preservationContext = useContext(ScrollPreservationContext);
const containerFoundRef = useRef(false);
@@ -110,29 +110,13 @@ export const ScrollPositionPreserver = () => {
useLayoutEffect(() => {
if (containerFoundRef.current || !preservationContext) return;
- // Find the scroll container (StickToBottom creates one)
- // It's the first parent with overflow-y scroll/auto
- const findScrollContainer = (): HTMLElement | null => {
- const candidates = document.querySelectorAll('[role="log"]');
- for (const candidate of candidates) {
- // The scroll container is a direct child of the role="log" element
- const children = candidate.children;
- for (const child of children) {
- const style = window.getComputedStyle(child);
- if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
- return child as HTMLElement;
- }
- }
- }
- return null;
- };
-
- const container = findScrollContainer();
+ // Use the local StickToBottom scroll container for this conversation instance.
+ const container = scrollRef.current;
if (container) {
preservationContext.registerScrollContainer(container);
containerFoundRef.current = true;
}
- }, [preservationContext]);
+ }, [preservationContext, scrollRef]);
// Track engagement based on scroll position
useEffect(() => {
diff --git a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx
index 91b16287..e9cef6dc 100644
--- a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx
+++ b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx
@@ -2,8 +2,14 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
-import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, XCircleIcon, XIcon } from "lucide-react";
+import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js";
import z from "zod";
@@ -11,6 +17,8 @@ import z from "zod";
export type PermissionRequestProps = ComponentProps<"div"> & {
toolCall: z.infer;
onApprove?: () => void;
+ onApproveSession?: () => void;
+ onApproveAlways?: () => void;
onDeny?: () => void;
isProcessing?: boolean;
response?: 'approve' | 'deny' | null;
@@ -20,6 +28,8 @@ export const PermissionRequest = ({
className,
toolCall,
onApprove,
+ onApproveSession,
+ onApproveAlways,
onDeny,
isProcessing = false,
response = null,
@@ -117,16 +127,40 @@ export const PermissionRequest = ({
{!isResponded && (
-
-
- Approve
-
+
+
+
+ Approve
+
+ {command && (
+
+
+
+
+
+
+
+
+ Allow for Session
+
+
+ Always Allow
+
+
+
+ )}
+
{
- textareaRef.current?.focus();
+ const textarea = textareaRef.current;
+ if (!textarea) return;
+ try {
+ textarea.focus({ preventScroll: true });
+ } catch {
+ textarea.focus();
+ }
}, 50);
return () => clearTimeout(timer);
}
diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
new file mode 100644
index 00000000..d3554c00
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
@@ -0,0 +1,360 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import {
+ ArrowUp,
+ AudioLines,
+ FileArchive,
+ FileCode2,
+ FileIcon,
+ FileSpreadsheet,
+ FileText,
+ FileVideo,
+ LoaderIcon,
+ Plus,
+ Square,
+ X,
+} from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+import {
+ type AttachmentIconKind,
+ getAttachmentDisplayName,
+ getAttachmentIconKind,
+ getAttachmentToneClass,
+ getAttachmentTypeLabel,
+} from '@/lib/attachment-presentation'
+import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils'
+import { cn } from '@/lib/utils'
+import {
+ type FileMention,
+ type PromptInputMessage,
+ PromptInputProvider,
+ PromptInputTextarea,
+ usePromptInputController,
+} from '@/components/ai-elements/prompt-input'
+import { toast } from 'sonner'
+
+export type StagedAttachment = {
+ id: string
+ path: string
+ filename: string
+ mimeType: string
+ isImage: boolean
+ size: number
+ thumbnailUrl?: string
+}
+
+const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
+
+function getAttachmentIcon(kind: AttachmentIconKind) {
+ switch (kind) {
+ case 'audio':
+ return AudioLines
+ case 'video':
+ return FileVideo
+ case 'spreadsheet':
+ return FileSpreadsheet
+ case 'archive':
+ return FileArchive
+ case 'code':
+ return FileCode2
+ case 'text':
+ return FileText
+ default:
+ return FileIcon
+ }
+}
+
+interface ChatInputInnerProps {
+ onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
+ onStop?: () => void
+ isProcessing: boolean
+ isStopping?: boolean
+ isActive: boolean
+ presetMessage?: string
+ onPresetMessageConsumed?: () => void
+ runId?: string | null
+ initialDraft?: string
+ onDraftChange?: (text: string) => void
+}
+
+function ChatInputInner({
+ onSubmit,
+ onStop,
+ isProcessing,
+ isStopping,
+ isActive,
+ presetMessage,
+ onPresetMessageConsumed,
+ runId,
+ initialDraft,
+ onDraftChange,
+}: ChatInputInnerProps) {
+ const controller = usePromptInputController()
+ const message = controller.textInput.value
+ const [attachments, setAttachments] = useState([])
+ const [focusNonce, setFocusNonce] = useState(0)
+ const fileInputRef = useRef(null)
+ const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
+
+ // Restore the tab draft when this input mounts.
+ useEffect(() => {
+ if (initialDraft) {
+ controller.textInput.setInput(initialDraft)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ useEffect(() => {
+ onDraftChange?.(message)
+ }, [message, onDraftChange])
+
+ useEffect(() => {
+ if (presetMessage) {
+ controller.textInput.setInput(presetMessage)
+ onPresetMessageConsumed?.()
+ }
+ }, [presetMessage, controller.textInput, onPresetMessageConsumed])
+
+ const addFiles = useCallback(async (paths: string[]) => {
+ const newAttachments: StagedAttachment[] = []
+ for (const filePath of paths) {
+ try {
+ const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath })
+ if (result.size > MAX_ATTACHMENT_SIZE) {
+ toast.error(`File too large: ${getFileDisplayName(filePath)} (max 10MB)`)
+ continue
+ }
+ const mime = result.mimeType || getMimeFromExtension(getExtension(filePath))
+ const image = isImageMime(mime)
+ newAttachments.push({
+ id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ path: filePath,
+ filename: getFileDisplayName(filePath),
+ mimeType: mime,
+ isImage: image,
+ size: result.size,
+ thumbnailUrl: image ? `data:${mime};base64,${result.data}` : undefined,
+ })
+ } catch (err) {
+ console.error('Failed to read file:', filePath, err)
+ toast.error(`Failed to read: ${getFileDisplayName(filePath)}`)
+ }
+ }
+ if (newAttachments.length > 0) {
+ setAttachments((prev) => [...prev, ...newAttachments])
+ setFocusNonce((value) => value + 1)
+ }
+ }, [])
+
+ const removeAttachment = useCallback((id: string) => {
+ setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
+ }, [])
+
+ const handleSubmit = useCallback(() => {
+ if (!canSubmit) return
+ onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments)
+ controller.textInput.clear()
+ controller.mentions.clearMentions()
+ setAttachments([])
+ }, [attachments, canSubmit, controller, message, onSubmit])
+
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSubmit()
+ }
+ }, [handleSubmit])
+
+ useEffect(() => {
+ if (!isActive) return
+ const onDragOver = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes('Files')) {
+ e.preventDefault()
+ }
+ }
+
+ const onDrop = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes('Files')) {
+ e.preventDefault()
+ }
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
+ const paths = Array.from(e.dataTransfer.files)
+ .map((file) => window.electronUtils?.getPathForFile(file))
+ .filter(Boolean) as string[]
+ if (paths.length > 0) {
+ void addFiles(paths)
+ }
+ }
+ }
+
+ document.addEventListener('dragover', onDragOver)
+ document.addEventListener('drop', onDrop)
+ return () => {
+ document.removeEventListener('dragover', onDragOver)
+ document.removeEventListener('drop', onDrop)
+ }
+ }, [addFiles, isActive])
+
+ return (
+
+ {attachments.length > 0 && (
+
+ {attachments.map((attachment) => {
+ const attachmentType = getAttachmentTypeLabel(attachment)
+ const attachmentName = getAttachmentDisplayName(attachment)
+ const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
+
+ return (
+
+
+ {attachment.isImage && attachment.thumbnailUrl ? (
+
+ ) : (
+
+ )}
+
+
+ {attachmentName}
+ {attachmentType}
+
+ removeAttachment(attachment.id)}
+ className="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full border border-border/70 bg-background/70 text-muted-foreground opacity-0 transition-[opacity,color] duration-150 hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
+ >
+
+
+
+ )
+ })}
+
+ )}
+
+
{
+ const files = e.target.files
+ if (!files || files.length === 0) return
+ const paths = Array.from(files)
+ .map((file) => window.electronUtils?.getPathForFile(file))
+ .filter(Boolean) as string[]
+ if (paths.length > 0) {
+ void addFiles(paths)
+ }
+ e.target.value = ''
+ }}
+ />
+
fileInputRef.current?.click()}
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+ aria-label="Attach files"
+ >
+
+
+
+ {isProcessing ? (
+
+ {isStopping ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+
+ )
+}
+
+export interface ChatInputWithMentionsProps {
+ knowledgeFiles: string[]
+ recentFiles: string[]
+ visibleFiles: string[]
+ onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
+ onStop?: () => void
+ isProcessing: boolean
+ isStopping?: boolean
+ isActive?: boolean
+ presetMessage?: string
+ onPresetMessageConsumed?: () => void
+ runId?: string | null
+ initialDraft?: string
+ onDraftChange?: (text: string) => void
+}
+
+export function ChatInputWithMentions({
+ knowledgeFiles,
+ recentFiles,
+ visibleFiles,
+ onSubmit,
+ onStop,
+ isProcessing,
+ isStopping,
+ isActive = true,
+ presetMessage,
+ onPresetMessageConsumed,
+ runId,
+ initialDraft,
+ onDraftChange,
+}: ChatInputWithMentionsProps) {
+ return (
+
+
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/chat-message-attachments.tsx b/apps/x/apps/renderer/src/components/chat-message-attachments.tsx
new file mode 100644
index 00000000..298e5f03
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/chat-message-attachments.tsx
@@ -0,0 +1,137 @@
+import {
+ AudioLines,
+ FileArchive,
+ FileCode2,
+ FileIcon,
+ FileSpreadsheet,
+ FileText,
+ FileVideo,
+} from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+
+import type { MessageAttachment } from '@/lib/chat-conversation'
+import {
+ type AttachmentIconKind,
+ getAttachmentDisplayName,
+ getAttachmentIconKind,
+ getAttachmentToneClass,
+ getAttachmentTypeLabel,
+} from '@/lib/attachment-presentation'
+import { isImageMime, toFileUrl } from '@/lib/file-utils'
+import { cn } from '@/lib/utils'
+
+function getAttachmentIcon(kind: AttachmentIconKind) {
+ switch (kind) {
+ case 'audio':
+ return AudioLines
+ case 'video':
+ return FileVideo
+ case 'spreadsheet':
+ return FileSpreadsheet
+ case 'archive':
+ return FileArchive
+ case 'code':
+ return FileCode2
+ case 'text':
+ return FileText
+ default:
+ return FileIcon
+ }
+}
+
+function ImageAttachmentPreview({ attachment }: { attachment: MessageAttachment }) {
+ const fallbackFileUrl = useMemo(() => toFileUrl(attachment.path), [attachment.path])
+ const [src, setSrc] = useState(attachment.thumbnailUrl || fallbackFileUrl)
+ const [triedBase64, setTriedBase64] = useState(Boolean(attachment.thumbnailUrl))
+
+ useEffect(() => {
+ const nextSrc = attachment.thumbnailUrl || fallbackFileUrl
+ setSrc(nextSrc)
+ setTriedBase64(Boolean(attachment.thumbnailUrl))
+ }, [attachment.thumbnailUrl, fallbackFileUrl])
+
+ const loadBase64 = useMemo(
+ () => async () => {
+ try {
+ const result = await window.ipc.invoke('shell:readFileBase64', { path: attachment.path })
+ const mimeType = result.mimeType || attachment.mimeType || 'image/*'
+ setSrc(`data:${mimeType};base64,${result.data}`)
+ } catch {
+ // Keep current src; fallback rendering (broken image icon) is better than crashing.
+ }
+ },
+ [attachment.mimeType, attachment.path]
+ )
+
+ useEffect(() => {
+ if (attachment.thumbnailUrl || triedBase64) return
+ setTriedBase64(true)
+ void loadBase64()
+ }, [attachment.thumbnailUrl, loadBase64, triedBase64])
+
+ return (
+ {
+ if (triedBase64) return
+ setTriedBase64(true)
+ void loadBase64()
+ }}
+ />
+ )
+}
+
+interface ChatMessageAttachmentsProps {
+ attachments: MessageAttachment[]
+ className?: string
+}
+
+export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) {
+ if (attachments.length === 0) return null
+
+ const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType))
+ const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType))
+
+ return (
+
+ {imageAttachments.length > 0 && (
+
+ {imageAttachments.map((attachment, index) => (
+
+ ))}
+
+ )}
+ {fileAttachments.length > 0 && (
+
+ {fileAttachments.map((attachment, index) => {
+ const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
+ const attachmentName = getAttachmentDisplayName(attachment)
+ const attachmentType = getAttachmentTypeLabel(attachment)
+ return (
+
+
+
+
+
+ {attachmentName}
+ {attachmentType}
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
index 8d2a005f..f020cdae 100644
--- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx
+++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
@@ -1,13 +1,9 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { ArrowUp, Expand, LoaderIcon, SquarePen, Square } from 'lucide-react'
-import type { ToolUIPart } from 'ai'
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
+
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from '@/components/ui/tooltip'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
Conversation,
ConversationContent,
@@ -19,233 +15,200 @@ import {
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message'
-
import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
+import { WebSearchResult } from '@/components/ai-elements/web-search-result'
import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { Suggestions } from '@/components/ai-elements/suggestions'
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
-import { useMentionDetection } from '@/hooks/use-mention-detection'
-import { MentionPopover } from '@/components/mention-popover'
-import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
-import { getMentionHighlightSegments } from '@/lib/mention-highlights'
-import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'
-import z from 'zod'
-import React from 'react'
import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
-
-interface ChatMessage {
- id: string
- role: 'user' | 'assistant'
- content: string
- timestamp: number
-}
-
-interface ToolCall {
- id: string
- name: string
- input: ToolUIPart['input']
- result?: ToolUIPart['output']
- status: 'pending' | 'running' | 'completed' | 'error'
- timestamp: number
-}
-
-interface ErrorMessage {
- id: string
- kind: 'error'
- message: string
- timestamp: number
-}
-
-type ConversationItem = ChatMessage | ToolCall | ErrorMessage
-
-type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
-
-const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
-const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
-const isErrorMessage = (item: ConversationItem): item is ErrorMessage => 'kind' in item && item.kind === 'error'
-
-const toToolState = (status: ToolCall['status']): ToolState => {
- switch (status) {
- case 'pending':
- return 'input-streaming'
- case 'running':
- return 'input-available'
- case 'completed':
- return 'output-available'
- case 'error':
- return 'output-error'
- default:
- return 'input-available'
- }
-}
-
-const normalizeToolInput = (input: ToolCall['input'] | string | undefined): ToolCall['input'] => {
- if (input === undefined || input === null) return {}
- if (typeof input === 'string') {
- const trimmed = input.trim()
- if (!trimmed) return {}
- try {
- return JSON.parse(trimmed)
- } catch {
- return input
- }
- }
- return input
-}
-
-const normalizeToolOutput = (output: ToolCall['result'] | undefined, status: ToolCall['status']) => {
- if (output === undefined || output === null) {
- return status === 'completed' ? 'No output returned.' : null
- }
- if (output === '') return '(empty output)'
- if (typeof output === 'boolean' || typeof output === 'number') return String(output)
- return output
-}
+import { TabBar, type ChatTab } from '@/components/tab-bar'
+import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions'
+import { ChatMessageAttachments } from '@/components/chat-message-attachments'
+import { wikiLabel } from '@/lib/wiki-links'
+import {
+ type ChatTabViewState,
+ type ConversationItem,
+ type PermissionResponse,
+ createEmptyChatTabViewState,
+ getWebSearchCardData,
+ isChatMessage,
+ isErrorMessage,
+ isToolCall,
+ normalizeToolInput,
+ normalizeToolOutput,
+ parseAttachedFiles,
+ toToolState,
+} from '@/lib/chat-conversation'
const streamdownComponents = { pre: MarkdownPreOverride }
-const MIN_WIDTH = 300
-const MAX_WIDTH = 700
-const DEFAULT_WIDTH = 400
+const MIN_WIDTH = 360
+const MAX_WIDTH = 1600
+const MIN_MAIN_PANE_WIDTH = 420
+const MIN_MAIN_PANE_RATIO = 0.3
+const DEFAULT_WIDTH = 460
+const RIGHT_PANE_WIDTH_STORAGE_KEY = 'x:right-pane-width'
+
+function clampPaneWidth(width: number, maxWidth: number = MAX_WIDTH): number {
+ const boundedMax = Math.max(0, Math.min(MAX_WIDTH, maxWidth))
+ const boundedMin = Math.min(MIN_WIDTH, boundedMax)
+ return Math.min(boundedMax, Math.max(boundedMin, width))
+}
+
+function getInitialPaneWidth(defaultWidth: number): number {
+ const fallback = clampPaneWidth(defaultWidth)
+ if (typeof window === 'undefined') return fallback
+ try {
+ const raw = window.localStorage.getItem(RIGHT_PANE_WIDTH_STORAGE_KEY)
+ if (!raw) return fallback
+ const parsed = Number(raw)
+ if (!Number.isFinite(parsed)) return fallback
+ return clampPaneWidth(parsed)
+ } catch {
+ return fallback
+ }
+}
interface ChatSidebarProps {
defaultWidth?: number
isOpen?: boolean
- onNewChat: () => void
+ isMaximized?: boolean
+ chatTabs: ChatTab[]
+ activeChatTabId: string
+ getChatTabTitle: (tab: ChatTab) => string
+ isChatTabProcessing: (tab: ChatTab) => boolean
+ onSwitchChatTab: (tabId: string) => void
+ onCloseChatTab: (tabId: string) => void
+ onNewChatTab: () => void
onOpenFullScreen?: () => void
conversation: ConversationItem[]
currentAssistantMessage: string
+ chatTabStates?: Record
isProcessing: boolean
isStopping?: boolean
onStop?: () => void
- message: string
- onMessageChange: (message: string) => void
- onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
+ onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
knowledgeFiles?: string[]
recentFiles?: string[]
visibleFiles?: string[]
- selectedPath?: string | null
- pendingPermissionRequests?: Map>
- pendingAskHumanRequests?: Map>
- allPermissionRequests?: Map>
- permissionResponses?: Map
- onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void
+ runId?: string | null
+ presetMessage?: string
+ onPresetMessageConsumed?: () => void
+ getInitialDraft?: (tabId: string) => string | undefined
+ onDraftChangeForTab?: (tabId: string, text: string) => void
+ pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
+ allPermissionRequests?: ChatTabViewState['allPermissionRequests']
+ permissionResponses?: ChatTabViewState['permissionResponses']
+ onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
+ isToolOpenForTab?: (tabId: string, toolId: string) => boolean
+ onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
onOpenKnowledgeFile?: (path: string) => void
+ onActivate?: () => void
}
export function ChatSidebar({
defaultWidth = DEFAULT_WIDTH,
isOpen = true,
- onNewChat,
+ isMaximized = false,
+ chatTabs,
+ activeChatTabId,
+ getChatTabTitle,
+ isChatTabProcessing,
+ onSwitchChatTab,
+ onCloseChatTab,
+ onNewChatTab,
onOpenFullScreen,
conversation,
currentAssistantMessage,
+ chatTabStates = {},
isProcessing,
isStopping,
onStop,
- message,
- onMessageChange,
onSubmit,
knowledgeFiles = [],
recentFiles = [],
visibleFiles = [],
- selectedPath,
+ runId,
+ presetMessage,
+ onPresetMessageConsumed,
+ getInitialDraft,
+ onDraftChangeForTab,
pendingAskHumanRequests = new Map(),
allPermissionRequests = new Map(),
permissionResponses = new Map(),
onPermissionResponse,
onAskHumanResponse,
+ isToolOpenForTab,
+ onToolOpenChangeForTab,
onOpenKnowledgeFile,
+ onActivate,
}: ChatSidebarProps) {
- const [width, setWidth] = useState(defaultWidth)
+ const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
const [isResizing, setIsResizing] = useState(false)
const [showContent, setShowContent] = useState(isOpen)
+ const [localPresetMessage, setLocalPresetMessage] = useState(undefined)
+
+ const paneRef = useRef(null)
+ const startXRef = useRef(0)
+ const startWidthRef = useRef(0)
+ const prevIsMaximizedRef = useRef(isMaximized)
+ const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
+
+ const getMaxAllowedWidth = useCallback(() => {
+ if (typeof window === 'undefined') return MAX_WIDTH
+ const paneElement = paneRef.current
+ const splitContainer = paneElement?.parentElement
+ const mainPane = splitContainer?.querySelector('[data-slot="sidebar-inset"]')
+ const paneWidth = paneElement?.getBoundingClientRect().width ?? 0
+ const mainPaneWidth = mainPane?.getBoundingClientRect().width ?? 0
+ const splitWidth = paneWidth + mainPaneWidth
+ const fallbackWidth = splitContainer?.clientWidth ?? window.innerWidth
+ const availableSplitWidth = splitWidth > 0 ? splitWidth : fallbackWidth
+ const minMainPaneWidth = Math.min(
+ availableSplitWidth,
+ Math.max(
+ MIN_MAIN_PANE_WIDTH,
+ Math.floor(availableSplitWidth * MIN_MAIN_PANE_RATIO)
+ )
+ )
+ return Math.max(0, availableSplitWidth - minMainPaneWidth)
+ }, [])
- // Delay showing content when opening, hide immediately when closing
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => setShowContent(true), 150)
return () => clearTimeout(timer)
- } else {
- setShowContent(false)
}
+ setShowContent(false)
}, [isOpen])
- const startXRef = useRef(0)
- const startWidthRef = useRef(0)
- const textareaRef = useRef(null)
- const containerRef = useRef(null)
- const highlightRef = useRef(null)
- const [mentions, setMentions] = useState([])
- const autoMentionRef = useRef<{ path: string; displayName: string } | null>(null)
- const lastSelectedPathRef = useRef(null)
-
- // Build mention labels for highlighting (handles multi-word names like "AI Agents")
- const mentionLabels = useMemo(() => {
- if (knowledgeFiles.length === 0) return []
- const labels = knowledgeFiles
- .map((path) => wikiLabel(path))
- .map((label) => label.trim())
- .filter(Boolean)
- return Array.from(new Set(labels))
- }, [knowledgeFiles])
-
- const { activeMention, cursorCoords } = useMentionDetection(
- textareaRef,
- message,
- knowledgeFiles.length > 0
- )
-
- // Use proper regex-based highlight segmentation that handles multi-word names
- const mentionHighlights = useMemo(
- () => getMentionHighlightSegments(message, activeMention, mentionLabels),
- [message, activeMention, mentionLabels]
- )
-
- // Sync highlight overlay scroll with textarea
- const syncHighlightScroll = useCallback(() => {
- const textarea = textareaRef.current
- const highlight = highlightRef.current
- if (!textarea || !highlight) return
- highlight.scrollTop = textarea.scrollTop
- highlight.scrollLeft = textarea.scrollLeft
- }, [])
useEffect(() => {
- syncHighlightScroll()
- }, [message, mentionHighlights.hasHighlights, syncHighlightScroll])
+ prevIsMaximizedRef.current = isMaximized
+ }, [isMaximized])
- const handleMentionSelect = useCallback(
- (path: string, displayName: string) => {
- if (!activeMention) return
+ useEffect(() => {
+ if (typeof window === 'undefined') return
+ try {
+ window.localStorage.setItem(RIGHT_PANE_WIDTH_STORAGE_KEY, String(width))
+ } catch {
+ // Ignore persistence failures and keep in-memory behavior.
+ }
+ }, [width])
- const beforeAt = message.substring(0, activeMention.triggerIndex)
- const afterQuery = message.substring(
- activeMention.triggerIndex + 1 + activeMention.query.length
- )
+ useEffect(() => {
+ const clampToAvailableWidth = () => {
+ const maxAllowedWidth = getMaxAllowedWidth()
+ setWidth((prev) => clampPaneWidth(prev, maxAllowedWidth))
+ }
- const newText = `${beforeAt}@${displayName} ${afterQuery}`
- onMessageChange(newText)
-
- const fullPath = toKnowledgePath(path)
- if (fullPath) {
- setMentions(prev => {
- if (prev.some(m => m.path === fullPath)) return prev
- return [...prev, { id: `mention-${Date.now()}`, path: fullPath, displayName }]
- })
- }
-
- textareaRef.current?.focus()
- },
- [activeMention, message, onMessageChange]
- )
-
- const handleMentionClose = useCallback(() => {
- // The popover handles its own closing
- }, [])
+ clampToAvailableWidth()
+ window.addEventListener('resize', clampToAvailableWidth)
+ return () => window.removeEventListener('resize', clampToAvailableWidth)
+ }, [getMaxAllowedWidth])
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
@@ -253,10 +216,10 @@ export function ChatSidebar({
startWidthRef.current = width
setIsResizing(true)
- const handleMouseMove = (e: MouseEvent) => {
- const delta = startXRef.current - e.clientX
- const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidthRef.current + delta))
- setWidth(newWidth)
+ const handleMouseMove = (event: MouseEvent) => {
+ const delta = startXRef.current - event.clientX
+ const maxAllowedWidth = getMaxAllowedWidth()
+ setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
}
const handleMouseUp = () => {
@@ -267,159 +230,101 @@ export function ChatSidebar({
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
- }, [width])
+ }, [width, getMaxAllowedWidth])
- // Auto-focus textarea when sidebar opens or when conversation is cleared (new chat)
- useEffect(() => {
- // Focus when conversation is empty (new chat started)
- if (conversation.length === 0) {
- const timer = setTimeout(() => {
- textareaRef.current?.focus()
- }, 50)
- return () => clearTimeout(timer)
- }
- }, [conversation.length])
+ const activeTabState = useMemo(() => ({
+ runId: runId ?? null,
+ conversation,
+ currentAssistantMessage,
+ pendingAskHumanRequests,
+ allPermissionRequests,
+ permissionResponses,
+ }), [
+ runId,
+ conversation,
+ currentAssistantMessage,
+ pendingAskHumanRequests,
+ allPermissionRequests,
+ permissionResponses,
+ ])
+ const emptyTabState = useMemo(() => createEmptyChatTabViewState(), [])
+ const getTabState = useCallback((tabId: string): ChatTabViewState => {
+ if (tabId === activeChatTabId) return activeTabState
+ return chatTabStates[tabId] ?? emptyTabState
+ }, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
+ const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
- // Auto-populate with @currentfile when switching knowledge files
- useEffect(() => {
- if (selectedPath === lastSelectedPathRef.current) return
- lastSelectedPathRef.current = selectedPath ?? null
-
- if (!selectedPath || !selectedPath.startsWith('knowledge/') || !selectedPath.endsWith('.md')) {
- return
- }
-
- const displayName = wikiLabel(selectedPath)
- const previousAuto = autoMentionRef.current
- const trimmed = message.trim()
- const previousToken = previousAuto ? `@${previousAuto.displayName}` : null
- const shouldReplace = !trimmed || (previousToken && trimmed === previousToken)
-
- if (!shouldReplace) {
- return
- }
-
- const nextText = `@${displayName} `
- if (message !== nextText) {
- onMessageChange(nextText)
- }
-
- setMentions((prev) => {
- const withoutPrevious = previousAuto
- ? prev.filter((mention) => mention.path !== previousAuto.path)
- : prev
- if (withoutPrevious.some((mention) => mention.path === selectedPath)) {
- return withoutPrevious
- }
- return [
- ...withoutPrevious,
- {
- id: `mention-auto-${Date.now()}`,
- path: selectedPath,
- displayName,
- },
- ]
- })
-
- autoMentionRef.current = { path: selectedPath, displayName }
- }, [selectedPath, message, onMessageChange])
-
- const hasConversation = conversation.length > 0 || currentAssistantMessage
- const canSubmit = Boolean(message.trim()) && !isProcessing
-
- const handleSubmit = () => {
- const trimmed = message.trim()
- if (trimmed && !isProcessing) {
- onSubmit({ text: trimmed, files: [] }, mentions)
- setMentions([])
- }
- }
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- // If mention popover is open, let it handle navigation keys
- if (activeMention && ['ArrowDown', 'ArrowUp', 'Tab', 'Escape'].includes(e.key)) {
- return
- }
-
- if (e.key === 'Enter') {
- // If mention popover is open, Enter should select the item
- if (activeMention) {
- return
- }
-
- if (!e.shiftKey) {
- e.preventDefault()
- handleSubmit()
- }
- }
-
- // Handle backspace to delete entire mention at once
- if (e.key === 'Backspace') {
- const textarea = e.currentTarget
- const cursorPos = textarea.selectionStart
- const selectionEnd = textarea.selectionEnd
-
- // Only handle if no text is selected (cursor is at a single position)
- if (cursorPos !== selectionEnd) return
-
- // Check if cursor is right after a mention
- for (const label of mentionLabels) {
- const mentionText = `@${label}`
- const startPos = cursorPos - mentionText.length
- if (startPos >= 0) {
- const textBefore = message.substring(startPos, cursorPos)
- if (textBefore === mentionText) {
- // Check if it's at word boundary (start of string or preceded by whitespace)
- if (startPos === 0 || /\s/.test(message[startPos - 1])) {
- e.preventDefault()
- const newText = message.substring(0, startPos) + message.substring(cursorPos)
- onMessageChange(newText)
- // Remove the mention from state
- setMentions(prev => prev.filter(m => m.displayName !== label))
- // Set cursor position after React updates
- setTimeout(() => {
- textarea.selectionStart = startPos
- textarea.selectionEnd = startPos
- }, 0)
- return
- }
- }
- }
- }
- }
- }
-
- const renderConversationItem = (item: ConversationItem) => {
+ const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) {
+ if (item.role === 'user') {
+ if (item.attachments && item.attachments.length > 0) {
+ return (
+
+
+
+
+ {item.content && (
+ {item.content}
+ )}
+
+ )
+ }
+ const { message, files } = parseAttachedFiles(item.content)
+ return (
+
+
+ {files.length > 0 && (
+
+ {files.map((filePath, index) => (
+
+ @{wikiLabel(filePath)}
+
+ ))}
+
+ )}
+ {message}
+
+
+ )
+ }
return (
- {item.role === 'assistant' ? (
- {item.content}
- ) : (
- item.content
- )}
+ {item.content}
)
}
if (isToolCall(item)) {
+ const webSearchData = getWebSearchCardData(item)
+ if (webSearchData) {
+ return (
+
+ )
+ }
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
return (
-
-
+ onToolOpenChangeForTab?.(tabId, item.id, open)}
+ >
+
- {output !== null ? (
-
- ) : null}
+ {output !== null ? : null}
)
@@ -438,218 +343,213 @@ export function ChatSidebar({
return null
}
- const displayWidth = isOpen ? width : 0
+ const paneStyle = useMemo(() => {
+ if (!isOpen) {
+ return { width: 0, flex: '0 0 auto' }
+ }
+ if (isMaximized) {
+ // In maximize mode the pane should grow into the freed left space,
+ // not add extra width to the right and overflow the app viewport.
+ return { width: 0, flex: '1 1 auto' }
+ }
+ return { width, flex: '0 0 auto' }
+ }, [isOpen, isMaximized, width])
return (
- {/* Resize handle */}
-
+ {!isMaximized && (
+
+ )}
- {/* Content - delayed on open, hidden immediately on close to avoid layout issues during animation */}
{showContent && (
<>
- {/* Header - minimal, expand and new chat buttons */}
-
+
+ tab.id}
+ isProcessing={isChatTabProcessing}
+ onSwitchTab={onSwitchChatTab}
+ onCloseTab={onCloseChatTab}
+ />
-
-
+
+
- New chat
+ New chat tab
{onOpenFullScreen && (
-
-
+
+ {isMaximized ? : }
- Full screen chat
+
+ {isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
+
)}
- {/* Conversation area */}
- {})}>
-
-
-
-
- {!hasConversation ? (
-
-
-
- ) : (
- <>
- {conversation.map(item => {
- const rendered = renderConversationItem(item)
- // If this is a tool call, check for permission request (pending or responded)
- if (isToolCall(item) && onPermissionResponse) {
- const permRequest = allPermissionRequests.get(item.id)
- if (permRequest) {
- const response = permissionResponses.get(item.id) || null
- return (
-
- {rendered}
- onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
- onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
- isProcessing={isProcessing}
- response={response}
- />
-
- )
- }
- }
- return rendered
+ {})}>
+
+
+ {chatTabs.map((tab) => {
+ const isActive = tab.id === activeChatTabId
+ const tabState = getTabState(tab.id)
+ const tabHasConversation = tabState.conversation.length > 0 || Boolean(tabState.currentAssistantMessage)
+ return (
+
+
+
+
+ {!tabHasConversation ? (
+
+ Ask anything...
+
+ ) : (
+ <>
+ {tabState.conversation.map((item) => {
+ const rendered = renderConversationItem(item, tab.id)
+ if (isToolCall(item) && onPermissionResponse) {
+ const permRequest = tabState.allPermissionRequests.get(item.id)
+ if (permRequest) {
+ const response = tabState.permissionResponses.get(item.id) || null
+ return (
+
+ {rendered}
+ onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
+ onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
+ onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
+ onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
+ isProcessing={isActive && isProcessing}
+ response={response}
+ />
+
+ )
+ }
+ }
+ return rendered
+ })}
+
+ {onAskHumanResponse && Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
+ onAskHumanResponse(request.toolCallId, request.subflow, response)}
+ isProcessing={isActive && isProcessing}
+ />
+ ))}
+
+ {tabState.currentAssistantMessage && (
+
+
+ {tabState.currentAssistantMessage}
+
+
+ )}
+
+ {isActive && isProcessing && !tabState.currentAssistantMessage && (
+
+
+ Thinking...
+
+
+ )}
+ >
+ )}
+
+
+
+ )
})}
+
- {/* Render pending ask-human requests */}
- {onAskHumanResponse && Array.from(pendingAskHumanRequests.values()).map((request) => (
-
onAskHumanResponse(request.toolCallId, request.subflow, response)}
- isProcessing={isProcessing}
- />
- ))}
-
- {currentAssistantMessage && (
-
-
- {currentAssistantMessage}
-
-
- )}
-
- {isProcessing && !currentAssistantMessage && (
-
-
- Thinking...
-
-
- )}
- >
- )}
-
-
-
- {/* Input area - responsive to sidebar width, matches floating bar position exactly */}
-
- {!hasConversation && (
-
{
- onMessageChange(prompt)
- setTimeout(() => textareaRef.current?.focus(), 0)
- }}
- vertical
- className="mb-3"
- />
- )}
-
-
- {mentionHighlights.hasHighlights && (
-
- {mentionHighlights.segments.map((segment, index) =>
- segment.highlighted ? (
-
- {segment.text}
-
- ) : (
-
{segment.text}
- )
+
+
+
+ {!hasConversation && (
+
)}
+ {chatTabs.map((tab) => {
+ const isActive = tab.id === activeChatTabId
+ const tabState = getTabState(tab.id)
+ return (
+
+ {
+ setLocalPresetMessage(undefined)
+ onPresetMessageConsumed?.()
+ } : undefined}
+ runId={tabState.runId}
+ initialDraft={getInitialDraft?.(tab.id)}
+ onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
+ />
+
+ )
+ })}
- )}
-
- {isProcessing ? (
-
- {isStopping ? (
-
- ) : (
-
- )}
-
- ) : (
-
-
-
- )}
-
- {knowledgeFiles.length > 0 && (
-
- )}
-
-
-
+
>
)}
diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx
index f05fcdb5..6bcaef29 100644
--- a/apps/x/apps/renderer/src/components/markdown-editor.tsx
+++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx
@@ -52,7 +52,16 @@ function getMarkdownWithBlankLines(editor: Editor): string {
const blocks: string[] = []
// Helper to convert a node to markdown text
- const nodeToText = (node: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record }> }>; attrs?: Record }): string => {
+ const nodeToText = (node: {
+ type?: string
+ content?: Array<{
+ type?: string
+ text?: string
+ marks?: Array<{ type: string; attrs?: Record }>
+ attrs?: Record
+ }>
+ attrs?: Record
+ }): string => {
if (!node.content) return ''
return node.content.map(child => {
if (child.type === 'text') {
@@ -67,6 +76,9 @@ function getMarkdownWithBlankLines(editor: Editor): string {
}
}
return text
+ } else if (child.type === 'wikiLink') {
+ const path = (child.attrs?.path as string) || ''
+ return path ? `[[${path}]]` : ''
} else if (child.type === 'hardBreak') {
return '\n'
}
@@ -183,6 +195,9 @@ interface MarkdownEditorProps {
placeholder?: string
wikiLinks?: WikiLinkConfig
onImageUpload?: (file: File) => Promise
+ editorSessionKey?: number
+ onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
+ editable?: boolean
}
type WikiLinkMatch = {
@@ -266,6 +281,9 @@ export function MarkdownEditor({
placeholder = 'Start writing...',
wikiLinks,
onImageUpload,
+ editorSessionKey = 0,
+ onHistoryHandlersChange,
+ editable = true,
}: MarkdownEditorProps) {
const isInternalUpdate = useRef(false)
const wrapperRef = useRef(null)
@@ -287,6 +305,7 @@ export function MarkdownEditor({
)
const editor = useEditor({
+ editable,
extensions: [
StarterKit.configure({
heading: {
@@ -388,7 +407,7 @@ export function MarkdownEditor({
return false
},
},
- })
+ }, [editorSessionKey])
const orderedFiles = useMemo(() => {
if (!wikiLinks) return []
@@ -477,12 +496,37 @@ export function MarkdownEditor({
isInternalUpdate.current = true
// Pre-process to preserve blank lines
const preprocessed = preprocessMarkdown(content)
- editor.commands.setContent(preprocessed)
+ // Treat tab-open content as baseline: do not add hydration to undo history.
+ editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
isInternalUpdate.current = false
}
}
}, [editor, content])
+ useEffect(() => {
+ if (!onHistoryHandlersChange) return
+ if (!editor) {
+ onHistoryHandlersChange(null)
+ return
+ }
+
+ onHistoryHandlersChange({
+ undo: () => editor.chain().focus().undo().run(),
+ redo: () => editor.chain().focus().redo().run(),
+ })
+
+ return () => {
+ onHistoryHandlersChange(null)
+ }
+ }, [editor, onHistoryHandlersChange])
+
+ // Update editable state when prop changes
+ useEffect(() => {
+ if (editor) {
+ editor.setEditable(editable)
+ }
+ }, [editor, editable])
+
// Force re-render decorations when selection highlight changes
useEffect(() => {
if (editor) {
diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx
index 4855cab7..9398f2fe 100644
--- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx
+++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx
@@ -57,14 +57,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [modelsCatalog, setModelsCatalog] = useState>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState(null)
- const [providerConfigs, setProviderConfigs] = useState>({
- openai: { apiKey: "", baseURL: "", model: "" },
- anthropic: { apiKey: "", baseURL: "", model: "" },
- google: { apiKey: "", baseURL: "", model: "" },
- openrouter: { apiKey: "", baseURL: "", model: "" },
- aigateway: { apiKey: "", baseURL: "", model: "" },
- ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" },
- "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" },
+ const [providerConfigs, setProviderConfigs] = useState>({
+ openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
+ "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
})
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle",
@@ -87,7 +87,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [slackConnecting, setSlackConnecting] = useState(false)
const updateProviderConfig = useCallback(
- (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => {
+ (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], ...updates },
@@ -287,6 +287,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const apiKey = activeConfig.apiKey.trim() || undefined
const baseURL = activeConfig.baseURL.trim() || undefined
const model = activeConfig.model.trim()
+ const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
const providerConfig = {
provider: {
flavor: llmProvider,
@@ -294,6 +295,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
baseURL,
},
model,
+ knowledgeGraphModel,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@@ -657,39 +659,74 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)}
-
-
Model
- {modelsLoading ? (
-
-
- Loading models...
-
- ) : showModelInput ? (
-
updateProviderConfig(llmProvider, { model: e.target.value })}
- placeholder="Enter model"
- />
- ) : (
-
updateProviderConfig(llmProvider, { model: value })}
- >
-
-
-
-
- {modelsForProvider.map((model) => (
-
- {model.name || model.id}
-
- ))}
-
-
- )}
- {modelsError && (
-
{modelsError}
- )}
+
+
+
Assistant model
+ {modelsLoading ? (
+
+
+ Loading...
+
+ ) : showModelInput ? (
+
updateProviderConfig(llmProvider, { model: e.target.value })}
+ placeholder="Enter model"
+ />
+ ) : (
+
updateProviderConfig(llmProvider, { model: value })}
+ >
+
+
+
+
+ {modelsForProvider.map((model) => (
+
+ {model.name || model.id}
+
+ ))}
+
+
+ )}
+ {modelsError && (
+
{modelsError}
+ )}
+
+
+
+
Knowledge graph model
+ {modelsLoading ? (
+
+
+ Loading...
+
+ ) : showModelInput ? (
+
updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
+ placeholder={activeConfig.model || "Enter model"}
+ />
+ ) : (
+
updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
+ >
+
+
+
+
+ Same as assistant
+ {modelsForProvider.map((model) => (
+
+ {model.name || model.id}
+
+ ))}
+
+
+ )}
+
{showApiKey && (
diff --git a/apps/x/apps/renderer/src/components/search-dialog.tsx b/apps/x/apps/renderer/src/components/search-dialog.tsx
new file mode 100644
index 00000000..22e398fe
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/search-dialog.tsx
@@ -0,0 +1,208 @@
+import { useState, useEffect, useCallback } from 'react'
+import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
+import {
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+} from '@/components/ui/command'
+import { useDebounce } from '@/hooks/use-debounce'
+import { useSidebarSection, type ActiveSection } from '@/contexts/sidebar-context'
+import { cn } from '@/lib/utils'
+
+interface SearchResult {
+ type: 'knowledge' | 'chat'
+ title: string
+ preview: string
+ path: string
+}
+
+type SearchType = 'knowledge' | 'chat'
+
+function activeTabToTypes(section: ActiveSection): SearchType[] {
+ if (section === 'knowledge') return ['knowledge']
+ return ['chat'] // "tasks" tab maps to chat
+}
+
+interface SearchDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSelectFile: (path: string) => void
+ onSelectRun: (runId: string) => void
+}
+
+export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) {
+ const { activeSection } = useSidebarSection()
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState
([])
+ const [isSearching, setIsSearching] = useState(false)
+ const [activeTypes, setActiveTypes] = useState>(
+ () => new Set(activeTabToTypes(activeSection))
+ )
+ const debouncedQuery = useDebounce(query, 250)
+
+ // Sync filter preselection when dialog opens
+ useEffect(() => {
+ if (open) {
+ setActiveTypes(new Set(activeTabToTypes(activeSection)))
+ }
+ }, [open, activeSection])
+
+ const toggleType = useCallback((type: SearchType) => {
+ setActiveTypes(new Set([type]))
+ }, [])
+
+ useEffect(() => {
+ if (!debouncedQuery.trim()) {
+ setResults([])
+ return
+ }
+
+ let cancelled = false
+ setIsSearching(true)
+
+ const types = Array.from(activeTypes) as ('knowledge' | 'chat')[]
+ window.ipc.invoke('search:query', { query: debouncedQuery, limit: 20, types })
+ .then((res) => {
+ if (!cancelled) {
+ setResults(res.results)
+ }
+ })
+ .catch((err) => {
+ console.error('Search failed:', err)
+ if (!cancelled) {
+ setResults([])
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setIsSearching(false)
+ }
+ })
+
+ return () => { cancelled = true }
+ }, [debouncedQuery, activeTypes])
+
+ // Reset state when dialog closes
+ useEffect(() => {
+ if (!open) {
+ setQuery('')
+ setResults([])
+ }
+ }, [open])
+
+ const handleSelect = useCallback((result: SearchResult) => {
+ onOpenChange(false)
+ if (result.type === 'knowledge') {
+ onSelectFile(result.path)
+ } else {
+ onSelectRun(result.path)
+ }
+ }, [onOpenChange, onSelectFile, onSelectRun])
+
+ const knowledgeResults = results.filter(r => r.type === 'knowledge')
+ const chatResults = results.filter(r => r.type === 'chat')
+
+ return (
+
+
+
+ toggleType('knowledge')}
+ icon={ }
+ label="Knowledge"
+ />
+ toggleType('chat')}
+ icon={ }
+ label="Chats"
+ />
+
+
+ {!query.trim() && (
+ Type to search...
+ )}
+ {query.trim() && !isSearching && results.length === 0 && (
+ No results found.
+ )}
+ {knowledgeResults.length > 0 && (
+
+ {knowledgeResults.map((result) => (
+ handleSelect(result)}
+ >
+
+
+ {result.title}
+ {result.preview}
+
+
+ ))}
+
+ )}
+ {chatResults.length > 0 && (
+
+ {chatResults.map((result) => (
+ handleSelect(result)}
+ >
+
+
+ {result.title}
+ {result.preview}
+
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+function FilterToggle({
+ active,
+ onClick,
+ icon,
+ label,
+}: {
+ active: boolean
+ onClick: () => void
+ icon: React.ReactNode
+ label: string
+}) {
+ return (
+
+ {icon}
+ {label}
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx
index 840b9cf4..2948ae02 100644
--- a/apps/x/apps/renderer/src/components/settings-dialog.tsx
+++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx
@@ -167,14 +167,14 @@ const defaultBaseURLs: Partial> = {
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [provider, setProvider] = useState("openai")
- const [providerConfigs, setProviderConfigs] = useState>({
- openai: { apiKey: "", baseURL: "", model: "" },
- anthropic: { apiKey: "", baseURL: "", model: "" },
- google: { apiKey: "", baseURL: "", model: "" },
- openrouter: { apiKey: "", baseURL: "", model: "" },
- aigateway: { apiKey: "", baseURL: "", model: "" },
- ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" },
- "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" },
+ const [providerConfigs, setProviderConfigs] = useState>({
+ openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
+ ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
+ "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
})
const [modelsCatalog, setModelsCatalog] = useState>({})
const [modelsLoading, setModelsLoading] = useState(false)
@@ -199,7 +199,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback(
- (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => {
+ (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], ...updates },
@@ -229,6 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
apiKey: parsed.provider.apiKey || "",
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
model: parsed.model,
+ knowledgeGraphModel: parsed.knowledgeGraphModel || "",
},
}))
}
@@ -296,6 +297,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
baseURL: activeConfig.baseURL.trim() || undefined,
},
model: activeConfig.model.trim(),
+ knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@@ -362,40 +364,75 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
)}
- {/* Model selection */}
-
-
Model
- {modelsLoading ? (
-
-
- Loading models...
-
- ) : showModelInput ? (
-
updateConfig(provider, { model: e.target.value })}
- placeholder="Enter model"
- />
- ) : (
-
updateConfig(provider, { model: value })}
- >
-
-
-
-
- {modelsForProvider.map((model) => (
-
- {model.name || model.id}
-
- ))}
-
-
- )}
- {modelsError && (
-
{modelsError}
- )}
+ {/* Model selection - side by side */}
+
+
+
Assistant model
+ {modelsLoading ? (
+
+
+ Loading...
+
+ ) : showModelInput ? (
+
updateConfig(provider, { model: e.target.value })}
+ placeholder="Enter model"
+ />
+ ) : (
+
updateConfig(provider, { model: value })}
+ >
+
+
+
+
+ {modelsForProvider.map((model) => (
+
+ {model.name || model.id}
+
+ ))}
+
+
+ )}
+ {modelsError && (
+
{modelsError}
+ )}
+
+
+
+
Knowledge graph model
+ {modelsLoading ? (
+
+
+ Loading...
+
+ ) : showModelInput ? (
+
updateConfig(provider, { knowledgeGraphModel: e.target.value })}
+ placeholder={activeConfig.model || "Enter model"}
+ />
+ ) : (
+
updateConfig(provider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
+ >
+
+
+
+
+ Same as assistant
+ {modelsForProvider.map((model) => (
+
+ {model.name || model.id}
+
+ ))}
+
+
+ )}
+
{/* API Key */}
diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx
index 5906f4d5..fb890ecb 100644
--- a/apps/x/apps/renderer/src/components/sidebar-content.tsx
+++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx
@@ -8,6 +8,7 @@ import {
ChevronsDownUp,
ChevronsUpDown,
Copy,
+ ExternalLink,
FilePlus,
FolderPlus,
AlertTriangle,
@@ -105,6 +106,7 @@ type KnowledgeActions = {
rename: (path: string, newName: string, isDir: boolean) => Promise
remove: (path: string) => Promise
copyPath: (path: string) => void
+ onOpenInNewTab?: (path: string) => void
}
type RunListItem = {
@@ -149,6 +151,7 @@ type TasksActions = {
onNewChat: () => void
onSelectRun: (runId: string) => void
onDeleteRun: (runId: string) => void
+ onOpenInNewTab?: (runId: string) => void
onSelectBackgroundTask?: (taskName: string) => void
}
@@ -817,6 +820,36 @@ function KnowledgeSection({
onVoiceNoteCreated?: (path: string) => void
}) {
const isExpanded = expandedPaths.size > 0
+ const treeContainerRef = React.useRef(null)
+
+ useEffect(() => {
+ if (!selectedPath) return
+
+ let cancelled = false
+ let rafId: number | null = null
+ let attempts = 0
+ const maxAttempts = 20
+
+ const revealActiveFile = () => {
+ if (cancelled) return
+ const container = treeContainerRef.current
+ if (!container) return
+ const activeRow = container.querySelector('[data-knowledge-active="true"]')
+ if (activeRow) {
+ activeRow.scrollIntoView({ block: "nearest", inline: "nearest" })
+ return
+ }
+ if (attempts >= maxAttempts) return
+ attempts += 1
+ rafId = requestAnimationFrame(revealActiveFile)
+ }
+
+ rafId = requestAnimationFrame(revealActiveFile)
+ return () => {
+ cancelled = true
+ if (rafId !== null) cancelAnimationFrame(rafId)
+ }
+ }, [selectedPath, expandedPaths, tree])
const quickActions = [
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
@@ -862,18 +895,20 @@ function KnowledgeSection({
-
- {tree.map((item, index) => (
-
- ))}
-
+
+
+ {tree.map((item, index) => (
+
+ ))}
+
+
@@ -932,7 +967,7 @@ function Tree({
try {
await actions.rename(item.path, trimmedName, isDir)
toast('Renamed successfully', 'success')
- } catch (err) {
+ } catch {
toast('Failed to rename', 'error')
}
}
@@ -947,7 +982,7 @@ function Tree({
try {
await actions.remove(item.path)
toast('Moved to trash', 'success')
- } catch (err) {
+ } catch {
toast('Failed to delete', 'error')
}
}
@@ -981,6 +1016,15 @@ function Tree({
>
)}
+ {!isDir && actions.onOpenInNewTab && (
+ <>
+ actions.onOpenInNewTab!(item.path)}>
+
+ Open in new tab
+
+
+ >
+ )}
Copy Path
@@ -1033,12 +1077,24 @@ function Tree({
return (
-
+
onSelect(item.path, item.kind)}
+ onClick={(e) => {
+ if (e.metaKey && actions.onOpenInNewTab) {
+ actions.onOpenInNewTab(item.path)
+ } else {
+ onSelect(item.path, item.kind)
+ }
+ }}
>
- {item.name}
+
+ {item.name}
+
@@ -1162,37 +1218,54 @@ function TasksSection({
{runs.map((run) => (
-
- actions?.onSelectRun(run.id)}
- >
-
- {processingRunIds?.has(run.id) ? (
-
- ) : null}
-
{run.title || '(Untitled chat)'}
- {run.createdAt ? (
-
- {formatRunTime(run.createdAt)}
-
- ) : null}
- {!processingRunIds?.has(run.id) && (
-
{
- e.stopPropagation()
- setPendingDeleteRunId(run.id)
- }}
- aria-label="Delete chat"
+
+
+
+ {
+ if (e.metaKey && actions?.onOpenInNewTab) {
+ actions.onOpenInNewTab(run.id)
+ } else {
+ actions?.onSelectRun(run.id)
+ }
+ }}
+ >
+
+ {processingRunIds?.has(run.id) ? (
+
+ ) : null}
+ {run.title || '(Untitled chat)'}
+ {run.createdAt ? (
+
+ {formatRunTime(run.createdAt)}
+
+ ) : null}
+
+
+
+
+
+ {actions?.onOpenInNewTab && (
+ actions.onOpenInNewTab!(run.id)}>
+
+ Open in new tab
+
+ )}
+ {!processingRunIds?.has(run.id) && (
+ <>
+ {actions?.onOpenInNewTab && }
+ setPendingDeleteRunId(run.id)}
>
-
-
- )}
-
-
-
+
+ Delete
+
+ >
+ )}
+
+
))}
>
diff --git a/apps/x/apps/renderer/src/components/tab-bar.tsx b/apps/x/apps/renderer/src/components/tab-bar.tsx
new file mode 100644
index 00000000..744f578d
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/tab-bar.tsx
@@ -0,0 +1,97 @@
+import * as React from "react"
+import { X } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+export type ChatTab = {
+ id: string
+ runId: string | null
+}
+
+export type FileTab = {
+ id: string
+ path: string
+}
+
+interface TabBarProps {
+ tabs: T[]
+ activeTabId: string
+ getTabTitle: (tab: T) => string
+ getTabId: (tab: T) => string
+ isProcessing?: (tab: T) => boolean
+ onSwitchTab: (tabId: string) => void
+ onCloseTab: (tabId: string) => void
+ layout?: 'fill' | 'scroll'
+ allowSingleTabClose?: boolean
+}
+
+export function TabBar({
+ tabs,
+ activeTabId,
+ getTabTitle,
+ getTabId,
+ isProcessing,
+ onSwitchTab,
+ onCloseTab,
+ layout = 'fill',
+ allowSingleTabClose = false,
+}: TabBarProps) {
+ return (
+
+ {tabs.map((tab, index) => {
+ const tabId = getTabId(tab)
+ const isActive = tabId === activeTabId
+ const processing = isProcessing?.(tab) ?? false
+ const title = getTabTitle(tab)
+
+ return (
+
+ {index > 0 && (
+
+ )}
+ onSwitchTab(tabId)}
+ className={cn(
+ 'titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
+ layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]',
+ isActive
+ ? 'bg-background text-foreground'
+ : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
+ )}
+ style={layout === 'scroll' ? { flex: '0 0 auto' } : { flex: '1 1 0px' }}
+ >
+ {processing && (
+
+ )}
+ {title}
+ {(allowSingleTabClose || tabs.length > 1) && (
+ {
+ e.stopPropagation()
+ onCloseTab(tabId)
+ }}
+ aria-label="Close tab"
+ >
+
+
+ )}
+
+ {/* Right edge divider after last tab to close off the section */}
+ {index === tabs.length - 1 && (
+
+ )}
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/version-history-panel.tsx b/apps/x/apps/renderer/src/components/version-history-panel.tsx
new file mode 100644
index 00000000..f8d03d38
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/version-history-panel.tsx
@@ -0,0 +1,177 @@
+import { useEffect, useState, useCallback } from 'react'
+import { X, Clock } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+
+interface CommitInfo {
+ oid: string
+ message: string
+ timestamp: number
+ author: string
+}
+
+interface VersionHistoryPanelProps {
+ path: string // knowledge-relative file path (e.g. "knowledge/People/John.md")
+ onClose: () => void
+ onSelectVersion: (oid: string | null, content: string) => void // null = current
+ onRestore: (oid: string) => void
+}
+
+function formatTimestamp(unixSeconds: number): { date: string; time: string } {
+ const d = new Date(unixSeconds * 1000)
+ const date = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
+ const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
+ return { date, time }
+}
+
+export function VersionHistoryPanel({
+ path,
+ onClose,
+ onSelectVersion,
+ onRestore,
+}: VersionHistoryPanelProps) {
+ const [commits, setCommits] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [selectedOid, setSelectedOid] = useState(null) // null = current/latest
+ const [error, setError] = useState(null)
+
+ // Strip "knowledge/" prefix for IPC calls
+ const relPath = path.startsWith('knowledge/') ? path.slice('knowledge/'.length) : path
+
+ const loadHistory = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const result = await window.ipc.invoke('knowledge:history', { path: relPath })
+ setCommits(result.commits)
+ } catch (err) {
+ console.error('Failed to load version history:', err)
+ setError('Failed to load history')
+ } finally {
+ setLoading(false)
+ }
+ }, [relPath])
+
+ useEffect(() => {
+ loadHistory()
+ }, [loadHistory])
+
+ // Refresh when new commits land
+ useEffect(() => {
+ return window.ipc.on('knowledge:didCommit', () => {
+ loadHistory()
+ })
+ }, [loadHistory])
+
+ const handleSelectCommit = useCallback(async (oid: string, isLatest: boolean) => {
+ if (isLatest) {
+ setSelectedOid(null)
+ // Read current file content
+ try {
+ const result = await window.ipc.invoke('workspace:readFile', { path })
+ onSelectVersion(null, result.data)
+ } catch (err) {
+ console.error('Failed to read current file:', err)
+ }
+ return
+ }
+
+ setSelectedOid(oid)
+ try {
+ const result = await window.ipc.invoke('knowledge:fileAtCommit', { path: relPath, oid })
+ onSelectVersion(oid, result.content)
+ } catch (err) {
+ console.error('Failed to load file at commit:', err)
+ }
+ }, [path, relPath, onSelectVersion])
+
+ const handleRestore = useCallback(() => {
+ if (selectedOid) {
+ onRestore(selectedOid)
+ }
+ }, [selectedOid, onRestore])
+
+ return (
+
+ {/* Header */}
+
+ Version history
+
+
+
+
+
+ {/* Commit list */}
+
+ {loading ? (
+
+ Loading...
+
+ ) : error ? (
+
+ {error}
+
+ ) : commits.length === 0 ? (
+
+ No history available
+
+ ) : (
+
+ {commits.map((commit, index) => {
+ const isLatest = index === 0
+ const isSelected = isLatest ? selectedOid === null : selectedOid === commit.oid
+ const { date, time } = formatTimestamp(commit.timestamp)
+
+ return (
+
handleSelectCommit(commit.oid, isLatest)}
+ className={cn(
+ 'w-full text-left px-3 py-2 transition-colors',
+ isSelected
+ ? 'bg-accent'
+ : 'hover:bg-accent/50'
+ )}
+ >
+
+ {!isLatest && (
+
+ )}
+
+ {date} · {time}
+
+
+ {isLatest && (
+
+ Current version
+
+ )}
+
+ )
+ })}
+
+ )}
+
+
+ {/* Footer */}
+ {selectedOid && (
+
+
+ Restore this version
+
+
+ )}
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/lib/attachment-presentation.ts b/apps/x/apps/renderer/src/lib/attachment-presentation.ts
new file mode 100644
index 00000000..7ddedd30
--- /dev/null
+++ b/apps/x/apps/renderer/src/lib/attachment-presentation.ts
@@ -0,0 +1,107 @@
+import { getExtension } from '@/lib/file-utils'
+
+export type AttachmentLike = {
+ filename?: string
+ path: string
+ mimeType: string
+}
+
+export type AttachmentIconKind =
+ | 'audio'
+ | 'video'
+ | 'spreadsheet'
+ | 'archive'
+ | 'code'
+ | 'text'
+ | 'file'
+
+const ARCHIVE_EXTENSIONS = new Set([
+ 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
+])
+
+const SPREADSHEET_EXTENSIONS = new Set([
+ 'csv', 'tsv', 'xls', 'xlsx',
+])
+
+const CODE_EXTENSIONS = new Set([
+ 'js', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'toml', 'xml',
+ 'py', 'rb', 'go', 'rs', 'java', 'kt', 'c', 'cpp', 'h', 'hpp',
+ 'cs', 'php', 'swift', 'sh', 'sql', 'html', 'css', 'scss', 'md',
+])
+
+export function getAttachmentDisplayName(attachment: AttachmentLike): string {
+ if (attachment.filename) return attachment.filename
+ const fromPath = attachment.path.split(/[\\/]/).pop()
+ return fromPath || attachment.path
+}
+
+export function getAttachmentTypeLabel(attachment: AttachmentLike): string {
+ const ext = getExtension(getAttachmentDisplayName(attachment))
+ if (ext) return ext.toUpperCase()
+
+ const mediaType = attachment.mimeType.toLowerCase()
+ if (mediaType.startsWith('audio/')) return 'AUDIO'
+ if (mediaType.startsWith('video/')) return 'VIDEO'
+ if (mediaType.startsWith('text/')) return 'TEXT'
+ if (mediaType.startsWith('image/')) return 'IMAGE'
+
+ const [, subtypeRaw = 'file'] = mediaType.split('/')
+ const subtype = subtypeRaw.split(';')[0].split('+').pop() || 'file'
+ const cleaned = subtype.replace(/[^a-z0-9]/gi, '')
+ return cleaned ? cleaned.toUpperCase() : 'FILE'
+}
+
+export function getAttachmentIconKind(attachment: AttachmentLike): AttachmentIconKind {
+ const mediaType = attachment.mimeType.toLowerCase()
+ const ext = getExtension(attachment.filename || attachment.path)
+
+ if (mediaType.startsWith('audio/')) return 'audio'
+ if (mediaType.startsWith('video/')) return 'video'
+ if (mediaType.includes('spreadsheet') || SPREADSHEET_EXTENSIONS.has(ext)) return 'spreadsheet'
+ if (mediaType.includes('zip') || mediaType.includes('compressed') || ARCHIVE_EXTENSIONS.has(ext)) return 'archive'
+ if (
+ mediaType.includes('json')
+ || mediaType.includes('javascript')
+ || mediaType.includes('typescript')
+ || mediaType.includes('xml')
+ || CODE_EXTENSIONS.has(ext)
+ ) {
+ return 'code'
+ }
+ if (mediaType.startsWith('text/') || mediaType.includes('pdf') || mediaType.includes('document')) {
+ return 'text'
+ }
+
+ return 'file'
+}
+
+export function getAttachmentToneClass(typeLabel: string): string {
+ switch (typeLabel) {
+ case 'PDF':
+ return 'bg-red-500 text-white'
+ case 'CSV':
+ case 'XLS':
+ case 'XLSX':
+ case 'TSV':
+ return 'bg-emerald-500 text-white'
+ case 'ZIP':
+ case 'RAR':
+ case '7Z':
+ case 'TAR':
+ case 'GZ':
+ return 'bg-amber-500 text-white'
+ case 'MP3':
+ case 'WAV':
+ case 'M4A':
+ case 'FLAC':
+ case 'AAC':
+ return 'bg-fuchsia-500 text-white'
+ case 'MP4':
+ case 'MOV':
+ case 'AVI':
+ case 'WEBM':
+ return 'bg-violet-500 text-white'
+ default:
+ return 'bg-primary/85 text-primary-foreground'
+ }
+}
diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts
new file mode 100644
index 00000000..830e250b
--- /dev/null
+++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts
@@ -0,0 +1,186 @@
+import type { ToolUIPart } from 'ai'
+import z from 'zod'
+import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
+
+export interface MessageAttachment {
+ path: string
+ filename: string
+ mimeType: string
+ size?: number
+ thumbnailUrl?: string
+}
+
+export interface ChatMessage {
+ id: string
+ role: 'user' | 'assistant'
+ content: string
+ attachments?: MessageAttachment[]
+ timestamp: number
+}
+
+export interface ToolCall {
+ id: string
+ name: string
+ input: ToolUIPart['input']
+ result?: ToolUIPart['output']
+ status: 'pending' | 'running' | 'completed' | 'error'
+ timestamp: number
+}
+
+export interface ErrorMessage {
+ id: string
+ kind: 'error'
+ message: string
+ timestamp: number
+}
+
+export type ConversationItem = ChatMessage | ToolCall | ErrorMessage
+export type PermissionResponse = 'approve' | 'deny'
+
+export type ChatTabViewState = {
+ runId: string | null
+ conversation: ConversationItem[]
+ currentAssistantMessage: string
+ pendingAskHumanRequests: Map>
+ allPermissionRequests: Map>
+ permissionResponses: Map
+}
+
+export const createEmptyChatTabViewState = (): ChatTabViewState => ({
+ runId: null,
+ conversation: [],
+ currentAssistantMessage: '',
+ pendingAskHumanRequests: new Map(),
+ allPermissionRequests: new Map(),
+ permissionResponses: new Map(),
+})
+
+export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
+
+export const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
+export const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
+export const isErrorMessage = (item: ConversationItem): item is ErrorMessage =>
+ 'kind' in item && item.kind === 'error'
+
+export const toToolState = (status: ToolCall['status']): ToolState => {
+ switch (status) {
+ case 'pending':
+ return 'input-streaming'
+ case 'running':
+ return 'input-available'
+ case 'completed':
+ return 'output-available'
+ case 'error':
+ return 'output-error'
+ default:
+ return 'input-available'
+ }
+}
+
+export const normalizeToolInput = (
+ input: ToolCall['input'] | string | undefined
+): ToolCall['input'] => {
+ if (input === undefined || input === null) return {}
+ if (typeof input === 'string') {
+ const trimmed = input.trim()
+ if (!trimmed) return {}
+ try {
+ return JSON.parse(trimmed)
+ } catch {
+ return input
+ }
+ }
+ return input
+}
+
+export const normalizeToolOutput = (
+ output: ToolCall['result'] | undefined,
+ status: ToolCall['status']
+) => {
+ if (output === undefined || output === null) {
+ return status === 'completed' ? 'No output returned.' : null
+ }
+ if (output === '') return '(empty output)'
+ if (typeof output === 'boolean' || typeof output === 'number') return String(output)
+ return output
+}
+
+export type WebSearchCardResult = { title: string; url: string; description: string }
+
+export type WebSearchCardData = {
+ query: string
+ results: WebSearchCardResult[]
+ title?: string
+}
+
+export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null => {
+ if (tool.name === 'web-search') {
+ const input = normalizeToolInput(tool.input) as Record | undefined
+ const result = tool.result as Record | undefined
+ return {
+ query: (input?.query as string) || '',
+ results: (result?.results as WebSearchCardResult[]) || [],
+ }
+ }
+
+ if (tool.name === 'research-search') {
+ const input = normalizeToolInput(tool.input) as Record | undefined
+ const result = tool.result as Record | undefined
+ const rawResults = (result?.results as Array<{
+ title: string
+ url: string
+ highlights?: string[]
+ text?: string
+ }>) || []
+ const mapped = rawResults.map((entry) => ({
+ title: entry.title,
+ url: entry.url,
+ description: entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
+ }))
+ const category = input?.category as string | undefined
+ return {
+ query: (input?.query as string) || '',
+ results: mapped,
+ title: category
+ ? `${category.charAt(0).toUpperCase() + category.slice(1)} search`
+ : 'Researched the web',
+ }
+ }
+
+ return null
+}
+
+// Parse attached files from message content and return clean message + file paths.
+export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
+ const attachedFilesRegex = /\s*([\s\S]*?)\s*<\/attached-files>/
+ const match = content.match(attachedFilesRegex)
+
+ if (!match) {
+ return { message: content, files: [] }
+ }
+
+ const filesXml = match[1]
+ const filePathRegex = //g
+ const files: string[] = []
+ let fileMatch
+ while ((fileMatch = filePathRegex.exec(filesXml)) !== null) {
+ files.push(fileMatch[1])
+ }
+
+ let cleanMessage = content.replace(attachedFilesRegex, '').trim()
+ for (const filePath of files) {
+ const fileName = filePath.split('/').pop()?.replace(/\.md$/i, '') || ''
+ if (!fileName) continue
+ const mentionRegex = new RegExp(`@${fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
+ cleanMessage = cleanMessage.replace(mentionRegex, '')
+ }
+
+ return { message: cleanMessage.trim(), files }
+}
+
+export const inferRunTitleFromMessage = (content: string): string | undefined => {
+ const { message } = parseAttachedFiles(content)
+ const normalized = message.replace(/\s+/g, ' ').trim()
+ if (!normalized) return undefined
+ return normalized.length > 100 ? normalized.substring(0, 100) : normalized
+}
diff --git a/apps/x/apps/renderer/src/lib/file-utils.ts b/apps/x/apps/renderer/src/lib/file-utils.ts
new file mode 100644
index 00000000..3ac3431a
--- /dev/null
+++ b/apps/x/apps/renderer/src/lib/file-utils.ts
@@ -0,0 +1,61 @@
+const IMAGE_MIMES = new Set([
+ 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
+ 'image/svg+xml', 'image/bmp', 'image/tiff', 'image/ico', 'image/avif',
+]);
+
+const EXTENSION_TO_MIME: Record = {
+ // Images
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
+ webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/ico',
+ avif: 'image/avif', tiff: 'image/tiff',
+ // Text / code
+ txt: 'text/plain', md: 'text/markdown', html: 'text/html', css: 'text/css',
+ csv: 'text/csv', xml: 'text/xml',
+ js: 'text/javascript', ts: 'text/typescript', jsx: 'text/javascript',
+ tsx: 'text/typescript', json: 'application/json', yaml: 'text/yaml',
+ yml: 'text/yaml', toml: 'text/toml',
+ py: 'text/x-python', rb: 'text/x-ruby', rs: 'text/x-rust',
+ go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++',
+ h: 'text/x-c', hpp: 'text/x-c++', sh: 'text/x-shellscript',
+ // Documents
+ pdf: 'application/pdf',
+ // Archives
+ zip: 'application/zip',
+};
+
+export function isImageMime(mimeType: string): boolean {
+ return IMAGE_MIMES.has(mimeType) || mimeType.startsWith('image/');
+}
+
+export function getMimeFromExtension(ext: string): string {
+ const normalized = ext.toLowerCase().replace(/^\./, '');
+ return EXTENSION_TO_MIME[normalized] || 'application/octet-stream';
+}
+
+export function getFileDisplayName(filePath: string): string {
+ return filePath.split('/').pop() || filePath;
+}
+
+export function getExtension(filePath: string): string {
+ const name = filePath.split('/').pop() || '';
+ const dotIndex = name.lastIndexOf('.');
+ return dotIndex > 0 ? name.slice(dotIndex + 1).toLowerCase() : '';
+}
+
+export function toFileUrl(filePath: string): string {
+ if (!filePath) return filePath;
+ if (
+ filePath.startsWith('data:') ||
+ filePath.startsWith('file://') ||
+ filePath.startsWith('http://') ||
+ filePath.startsWith('https://')
+ ) {
+ return filePath;
+ }
+ const normalized = filePath.replace(/\\/g, '/');
+ const encoded = encodeURI(normalized);
+ if (/^[A-Za-z]:\//.test(normalized)) {
+ return `file:///${encoded}`;
+ }
+ return `file://${encoded}`;
+}
diff --git a/apps/x/apps/renderer/src/lib/wiki-links.ts b/apps/x/apps/renderer/src/lib/wiki-links.ts
index 4b429a05..0e376cc9 100644
--- a/apps/x/apps/renderer/src/lib/wiki-links.ts
+++ b/apps/x/apps/renderer/src/lib/wiki-links.ts
@@ -9,8 +9,7 @@ export const normalizeWikiPath = (input: string) => {
}
export const ensureMarkdownExtension = (path: string) => {
- const lastSegment = path.split('/').pop() ?? path
- if (lastSegment.includes('.')) return path
+ if (path.toLowerCase().endsWith('.md')) return path
return `${path}.md`
}
diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css
index d94f0ffa..31ce2bf1 100644
--- a/apps/x/apps/renderer/src/styles/editor.css
+++ b/apps/x/apps/renderer/src/styles/editor.css
@@ -16,8 +16,17 @@
position: relative;
}
+/* Notion-like base typography */
.tiptap-editor .ProseMirror {
- padding: 1rem;
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif,
+ "Segoe UI Emoji", "Segoe UI Symbol";
+ font-size: 16px;
+ line-height: 1.5;
+ color: rgb(55, 53, 47);
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 2rem 4rem;
outline: none;
}
@@ -27,47 +36,45 @@
/* Placeholder */
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
- color: var(--muted-foreground);
+ color: rgba(55, 53, 47, 0.4);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
-/* Typography */
-.tiptap-editor .ProseMirror {
- font-size: 1rem;
- line-height: 1.75;
- color: var(--foreground);
-}
-
-.tiptap-editor .ProseMirror > * + * {
- margin-top: 0.75em;
+/* Paragraphs */
+.tiptap-editor .ProseMirror p {
+ margin: 1px 0;
+ padding: 3px 2px;
}
/* Headings */
.tiptap-editor .ProseMirror h1 {
- font-size: 2em;
- font-weight: 700;
- line-height: 1.2;
- margin-top: 1.5em;
- margin-bottom: 0.5em;
+ font-size: 1.875em;
+ font-weight: 600;
+ line-height: 1.3;
+ margin-top: 2em;
+ margin-bottom: 4px;
+ padding: 3px 2px;
}
.tiptap-editor .ProseMirror h2 {
font-size: 1.5em;
font-weight: 600;
line-height: 1.3;
- margin-top: 1.25em;
- margin-bottom: 0.5em;
+ margin-top: 1.1em;
+ margin-bottom: 1px;
+ padding: 3px 2px;
}
.tiptap-editor .ProseMirror h3 {
font-size: 1.25em;
font-weight: 600;
- line-height: 1.4;
+ line-height: 1.3;
margin-top: 1em;
- margin-bottom: 0.5em;
+ margin-bottom: 1px;
+ padding: 3px 2px;
}
.tiptap-editor .ProseMirror h1:first-child,
@@ -76,16 +83,11 @@
margin-top: 0;
}
-/* Paragraphs */
-.tiptap-editor .ProseMirror p {
- margin: 0;
-}
-
/* Lists */
.tiptap-editor .ProseMirror ul,
.tiptap-editor .ProseMirror ol {
- padding-left: 1.5em;
- margin: 0.5em 0;
+ padding-left: 1.625em;
+ margin: 1px 0;
}
.tiptap-editor .ProseMirror ul {
@@ -97,7 +99,7 @@
}
.tiptap-editor .ProseMirror li {
- margin: 0.25em 0;
+ padding: 3px 0;
}
.tiptap-editor .ProseMirror li p {
@@ -106,50 +108,56 @@
/* Blockquote */
.tiptap-editor .ProseMirror blockquote {
- border-left: 3px solid var(--border);
- padding-left: 1em;
+ border-left: 3px solid rgb(55, 53, 47);
+ padding-left: 14px;
+ margin: 4px 0;
margin-left: 0;
margin-right: 0;
- color: var(--muted-foreground);
- font-style: italic;
}
-/* Code */
-.tiptap-editor .ProseMirror code {
- background-color: var(--muted);
- border-radius: 0.25em;
- padding: 0.15em 0.3em;
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
- font-size: 0.9em;
-}
-
-/* Code Block */
+/* Code blocks */
.tiptap-editor .ProseMirror pre {
- background-color: var(--muted);
- border-radius: 0.5em;
- padding: 1em;
+ background: rgb(247, 246, 243);
+ border-radius: 4px;
+ padding: 2rem;
+ font-family: "SFMono-Regular", Menlo, Consolas, "PT Mono",
+ "Liberation Mono", Courier, monospace;
+ font-size: 0.85em;
+ line-height: 1.5;
+ margin: 8px 0;
overflow-x: auto;
- margin: 0.75em 0;
}
.tiptap-editor .ProseMirror pre code {
background: none;
padding: 0;
- font-size: 0.875em;
+ font-size: inherit;
color: inherit;
+ border-radius: 0;
}
-/* Horizontal Rule */
+/* Inline code */
+.tiptap-editor .ProseMirror code {
+ background: rgba(135, 131, 120, 0.15);
+ border-radius: 3px;
+ padding: 0.2em 0.4em;
+ font-family: "SFMono-Regular", Menlo, Consolas, monospace;
+ font-size: 0.85em;
+ color: #eb5757;
+}
+
+/* Divider */
.tiptap-editor .ProseMirror hr {
border: none;
- border-top: 1px solid var(--border);
- margin: 1.5em 0;
+ border-top: 1px solid rgba(55, 53, 47, 0.16);
+ margin: 8px 0;
}
/* Links */
.tiptap-editor .ProseMirror a {
- color: var(--primary);
+ color: inherit;
text-decoration: underline;
+ text-decoration-color: rgba(55, 53, 47, 0.4);
text-underline-offset: 2px;
cursor: pointer;
}
@@ -175,14 +183,13 @@
.tiptap-editor .ProseMirror ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
- margin: 0.5em 0;
+ margin: 1px 0;
}
.tiptap-editor .ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.5em;
- margin: 0.25em 0;
}
.tiptap-editor .ProseMirror ul[data-type="taskList"] li > label {
@@ -238,14 +245,16 @@
align-self: center;
}
-/* Content area centering */
+/* Keep knowledge text width readable while margins collapse on narrow panes. */
.tiptap-editor .ProseMirror {
- margin-left: 20%;
- margin-right: 20%;
- padding-left: 1rem;
- padding-right: 1rem;
+ width: 100%;
+ max-width: min(56rem, calc(100% - clamp(0.5rem, 2.5vw, 2rem)));
+ margin-left: auto;
+ margin-right: auto;
+ box-sizing: border-box;
+ padding-left: clamp(0.5rem, 1.5vw, 1rem);
+ padding-right: clamp(0.5rem, 1.5vw, 1rem);
}
-
.wiki-link-anchor {
position: absolute;
height: 0;
@@ -327,3 +336,33 @@
background-color: var(--primary);
transition: width 0.3s ease;
}
+
+/* Dark mode overrides */
+.dark .tiptap-editor .ProseMirror {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.dark .tiptap-editor .ProseMirror a {
+ text-decoration-color: rgba(255, 255, 255, 0.4);
+}
+
+.dark .tiptap-editor .ProseMirror blockquote {
+ border-left-color: rgba(255, 255, 255, 0.4);
+}
+
+.dark .tiptap-editor .ProseMirror hr {
+ border-top-color: rgba(255, 255, 255, 0.16);
+}
+
+.dark .tiptap-editor .ProseMirror pre {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.dark .tiptap-editor .ProseMirror code {
+ background: rgba(255, 255, 255, 0.1);
+ color: #ff7b72;
+}
+
+.dark .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
+ color: rgba(255, 255, 255, 0.3);
+}
diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json
index bcfac93d..53495637 100644
--- a/apps/x/packages/core/package.json
+++ b/apps/x/packages/core/package.json
@@ -27,6 +27,7 @@
"cron-parser": "^5.5.0",
"glob": "^13.0.0",
"google-auth-library": "^10.5.0",
+ "isomorphic-git": "^1.29.0",
"googleapis": "^169.0.0",
"mammoth": "^1.11.0",
"node-html-markdown": "^2.0.0",
diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts
index 09d1c721..0aeb167f 100644
--- a/apps/x/packages/core/src/agents/runtime.ts
+++ b/apps/x/packages/core/src/agents/runtime.ts
@@ -12,7 +12,7 @@ import { execTool } from "../application/lib/exec-tool.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { CopilotAgent } from "../application/assistant/agent.js";
-import { isBlocked } from "../application/lib/command-executor.js";
+import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
import container from "../di/container.js";
import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js";
@@ -226,13 +226,14 @@ export class StreamStepMessageBuilder {
private textBuffer: string = "";
private reasoningBuffer: string = "";
private providerOptions: z.infer | undefined = undefined;
+ private reasoningProviderOptions: z.infer | undefined = undefined;
flushBuffers() {
- // skip reasoning
- // if (this.reasoningBuffer) {
- // this.parts.push({ type: "reasoning", text: this.reasoningBuffer });
- // this.reasoningBuffer = "";
- // }
+ if (this.reasoningBuffer || this.reasoningProviderOptions) {
+ this.parts.push({ type: "reasoning", text: this.reasoningBuffer, providerOptions: this.reasoningProviderOptions });
+ this.reasoningBuffer = "";
+ this.reasoningProviderOptions = undefined;
+ }
if (this.textBuffer) {
this.parts.push({ type: "text", text: this.textBuffer });
this.textBuffer = "";
@@ -242,7 +243,11 @@ export class StreamStepMessageBuilder {
ingest(event: z.infer) {
switch (event.type) {
case "reasoning-start":
+ break;
case "reasoning-end":
+ this.reasoningProviderOptions = event.providerOptions;
+ this.flushBuffers();
+ break;
case "text-start":
case "text-end":
this.flushBuffers();
@@ -352,6 +357,12 @@ export async function loadAgent(id: string): Promise> {
return await repo.fetch(id);
}
+function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
export function convertFromMessages(messages: z.infer[]): ModelMessage[] {
const result: ModelMessage[] = [];
for (const msg of messages) {
@@ -395,11 +406,37 @@ export function convertFromMessages(messages: z.infer[]): ModelM
});
break;
case "user":
- result.push({
- role: "user",
- content: msg.content,
- providerOptions,
- });
+ if (typeof msg.content === 'string') {
+ // Legacy string — pass through unchanged
+ result.push({
+ role: "user",
+ content: msg.content,
+ providerOptions,
+ });
+ } else {
+ // New content parts array — collapse to text for LLM
+ const textSegments: string[] = [];
+ const attachmentLines: string[] = [];
+
+ for (const part of msg.content) {
+ if (part.type === "attachment") {
+ const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
+ attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`);
+ } else {
+ textSegments.push(part.text);
+ }
+ }
+
+ if (attachmentLines.length > 0) {
+ textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
+ }
+
+ result.push({
+ role: "user",
+ content: textSegments.join("\n"),
+ providerOptions,
+ });
+ }
break;
case "tool":
result.push({
@@ -457,6 +494,7 @@ export class AgentState {
pendingAskHumanRequests: Record> = {};
allowedToolCallIds: Record = {};
deniedToolCallIds: Record = {};
+ sessionAllowedCommands: Set = new Set();
getPendingPermissions(): z.infer[] {
const response: z.infer[] = [];
@@ -593,6 +631,16 @@ export class AgentState {
switch (event.response) {
case "approve":
this.allowedToolCallIds[event.toolCallId] = true;
+ // For session scope, extract command names and add to session allowlist
+ if (event.scope === "session") {
+ const toolCall = this.toolCallIdMap[event.toolCallId];
+ if (toolCall && typeof toolCall.arguments === 'object' && toolCall.arguments !== null && 'command' in toolCall.arguments) {
+ const names = extractCommandNames(String(toolCall.arguments.command));
+ for (const name of names) {
+ this.sessionAllowedCommands.add(name);
+ }
+ }
+ }
break;
case "deny":
this.deniedToolCallIds[event.toolCallId] = true;
@@ -658,7 +706,12 @@ export async function* streamAgent({
// set up provider + model
const provider = createProvider(modelConfig.provider);
- const model = provider.languageModel(modelConfig.model);
+ const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep"];
+ const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
+ ? modelConfig.knowledgeGraphModel
+ : modelConfig.model;
+ const model = provider.languageModel(modelId);
+ logger.log(`using model: ${modelId}`);
let loopCounter = 0;
while (true) {
@@ -827,10 +880,6 @@ export async function* streamAgent({
tools,
signal,
)) {
- // Only log significant events (not text-delta to reduce noise)
- if (event.type !== 'text-delta') {
- loopLogger.log('got llm-stream-event:', event.type);
- }
messageBuilder.ingest(event);
yield* processEvent({
runId,
@@ -881,7 +930,7 @@ export async function* streamAgent({
}
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
// if command is blocked, then seek permission
- if (isBlocked(part.arguments.command)) {
+ if (isBlocked(part.arguments.command, state.sessionAllowedCommands)) {
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
yield* processEvent({
runId,
@@ -924,9 +973,11 @@ async function* streamLlm(
tools: ToolSet,
signal?: AbortSignal,
): AsyncGenerator, void, unknown> {
+ const converted = convertFromMessages(messages);
+ console.log(`! SENDING payload to model: `, JSON.stringify(converted))
const { fullStream } = streamText({
model,
- messages: convertFromMessages(messages),
+ messages: converted,
system: instructions,
tools,
stopWhen: stepCountIs(1),
@@ -935,7 +986,7 @@ async function* streamLlm(
for await (const event of fullStream) {
// Check abort on every chunk for responsiveness
signal?.throwIfAborted();
- // console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event));
+ console.log("-> \t\tstream event", JSON.stringify(event));
switch (event.type) {
case "error":
yield {
@@ -968,6 +1019,12 @@ async function* streamLlm(
providerOptions: event.providerMetadata,
};
break;
+ case "text-end":
+ yield {
+ type: "text-end",
+ providerOptions: event.providerMetadata,
+ };
+ break;
case "text-delta":
yield {
type: "text-delta",
diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts
index 5e0ce472..96b50bb3 100644
--- a/apps/x/packages/core/src/application/assistant/instructions.ts
+++ b/apps/x/packages/core/src/application/assistant/instructions.ts
@@ -1,5 +1,8 @@
import { skillCatalog } from "./skills/index.js";
import { WorkDir as BASE_DIR } from "../../config/config.js";
+import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
+
+const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
@@ -125,6 +128,18 @@ Always consult this catalog first so you load the right skills before taking act
- Summarize completed work and suggest logical next steps at the end of a task.
- Always ask for confirmation before taking destructive actions.
+## Output Formatting
+- Use **H3** (###) for section headers in longer responses. Never use H1 or H2 — they're too large for chat.
+- Use **bold** for key terms, names, or concepts the user should notice.
+- Keep bullet points short (1-2 lines each). Use them for lists of 3+ items, not for general prose.
+- Use numbered lists only when order matters (steps, rankings).
+- For short answers (1-3 sentences), just use plain prose. No headers, no bullets.
+- Use code blocks with language tags (\`\`\`python, \`\`\`json, etc.) for any code or config.
+- Use inline \`code\` for file names, commands, variable names, or short technical references.
+- Add a blank line between sections for breathing room.
+- Never start a response with a heading. Lead with a sentence or two of context first.
+- Avoid deeply nested bullets. If nesting beyond 2 levels, restructure.
+
## MCP Tool Discovery (CRITICAL)
**ALWAYS check for MCP tools BEFORE saying you can't do something.**
@@ -138,18 +153,22 @@ When a user asks for ANY task that might require external capabilities (web sear
- Use relative paths (no \`\${BASE_DIR}\` prefixes) when running commands or referencing files.
- Keep user data safe—double-check before editing or deleting important resources.
+${runtimeContextPrompt}
+
## Workspace Access & Scope
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
-- The user is on **macOS**. Use macOS paths and commands (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` command).
+- Follow the detected runtime platform above for shell syntax and filesystem path style.
+- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS).
+- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\\\Desktop\`).
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`.
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
-- NEVER ask what OS the user is on - they are on macOS.
+- NEVER ask what OS the user is on if runtime platform is already available.
- Load the \`organize-files\` skill for guidance on file organization tasks.
## Builtin Tools vs Shell Commands
diff --git a/apps/x/packages/core/src/application/assistant/runtime-context.ts b/apps/x/packages/core/src/application/assistant/runtime-context.ts
new file mode 100644
index 00000000..f1011c2c
--- /dev/null
+++ b/apps/x/packages/core/src/application/assistant/runtime-context.ts
@@ -0,0 +1,69 @@
+export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh';
+export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
+
+export interface RuntimeContext {
+ platform: NodeJS.Platform;
+ osName: RuntimeOsName;
+ shellDialect: RuntimeShellDialect;
+ shellExecutable: string;
+}
+
+export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
+ return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
+}
+
+export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {
+ if (platform === 'win32') {
+ return {
+ platform,
+ osName: 'Windows',
+ shellDialect: 'windows-cmd',
+ shellExecutable: getExecutionShell(platform),
+ };
+ }
+
+ if (platform === 'darwin') {
+ return {
+ platform,
+ osName: 'macOS',
+ shellDialect: 'posix-sh',
+ shellExecutable: getExecutionShell(platform),
+ };
+ }
+
+ if (platform === 'linux') {
+ return {
+ platform,
+ osName: 'Linux',
+ shellDialect: 'posix-sh',
+ shellExecutable: getExecutionShell(platform),
+ };
+ }
+
+ return {
+ platform,
+ osName: 'Unknown',
+ shellDialect: 'posix-sh',
+ shellExecutable: getExecutionShell(platform),
+ };
+}
+
+export function getRuntimeContextPrompt(runtime: RuntimeContext): string {
+ if (runtime.shellDialect === 'windows-cmd') {
+ return `## Runtime Platform (CRITICAL)
+- Detected platform: **${runtime.platform}**
+- Detected OS: **${runtime.osName}**
+- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax)
+- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`).
+- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`).
+- Do not assume macOS/Linux command syntax when the runtime is Windows.`;
+ }
+
+ return `## Runtime Platform (CRITICAL)
+- Detected platform: **${runtime.platform}**
+- Detected OS: **${runtime.osName}**
+- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax)
+- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`).
+- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux).
+- Do not assume Windows command syntax when the runtime is POSIX.`;
+}
diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts
index e865e98d..947f49a0 100644
--- a/apps/x/packages/core/src/application/lib/command-executor.ts
+++ b/apps/x/packages/core/src/application/lib/command-executor.ts
@@ -1,17 +1,19 @@
import { exec, execSync, spawn, ChildProcess } from 'child_process';
import { promisify } from 'util';
import { getSecurityAllowList } from '../../config/security.js';
+import { getExecutionShell } from '../assistant/runtime-context.js';
const execPromise = promisify(exec);
-const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
+const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
+const EXECUTION_SHELL = getExecutionShell();
function sanitizeToken(token: string): string {
- return token.trim().replace(/^['"]+|['"]+$/g, '');
+ return token.trim().replace(/^['"()]+|['"()]+$/g, '');
}
-function extractCommandNames(command: string): string[] {
+export function extractCommandNames(command: string): string[] {
const discovered = new Set();
const segments = command.split(COMMAND_SPLIT_REGEX);
@@ -42,27 +44,21 @@ function extractCommandNames(command: string): string[] {
return Array.from(discovered);
}
-function findBlockedCommands(command: string): string[] {
+function findBlockedCommands(command: string, sessionAllowedCommands?: Set): string[] {
const invoked = extractCommandNames(command);
if (!invoked.length) return [];
const allowList = getSecurityAllowList();
- if (!allowList.length) return invoked;
+ if (!allowList.length && (!sessionAllowedCommands || sessionAllowedCommands.size === 0)) return invoked;
const allowSet = new Set(allowList);
if (allowSet.has('*')) return [];
- return invoked.filter((cmd) => !allowSet.has(cmd));
+ return invoked.filter((cmd) => !allowSet.has(cmd) && !sessionAllowedCommands?.has(cmd));
}
-// export const BlockedResult = {
-// stdout: '',
-// stderr: `Command blocked by security policy. Update ${SECURITY_CONFIG_PATH} to allow them before retrying.`,
-// exitCode: 126,
-// };
-
-export function isBlocked(command: string): boolean {
- const blocked = findBlockedCommands(command);
+export function isBlocked(command: string, sessionAllowedCommands?: Set): boolean {
+ const blocked = findBlockedCommands(command, sessionAllowedCommands);
return blocked.length > 0;
}
@@ -91,7 +87,7 @@ export async function executeCommand(
cwd: options?.cwd,
timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
- shell: '/bin/sh', // use sh for cross-platform compatibility
+ shell: EXECUTION_SHELL,
});
return {
@@ -151,7 +147,7 @@ export function executeCommandAbortable(
// Check if already aborted before spawning
if (options?.signal?.aborted) {
// Return a dummy process and a resolved result
- const dummyProc = spawn('true', { shell: true });
+ const dummyProc = spawn(process.execPath, ['-e', 'process.exit(0)']);
dummyProc.kill();
return {
process: dummyProc,
@@ -165,7 +161,7 @@ export function executeCommandAbortable(
}
const proc = spawn(command, [], {
- shell: '/bin/sh',
+ shell: EXECUTION_SHELL,
cwd: options?.cwd,
detached: process.platform !== 'win32', // Create process group on Unix
stdio: ['ignore', 'pipe', 'pipe'],
@@ -279,7 +275,7 @@ export function executeCommandSync(
cwd: options?.cwd,
timeout: options?.timeout,
encoding: 'utf-8',
- shell: '/bin/sh',
+ shell: EXECUTION_SHELL,
});
return {
diff --git a/apps/x/packages/core/src/application/lib/message-queue.ts b/apps/x/packages/core/src/application/lib/message-queue.ts
index c60ecd1f..2b864840 100644
--- a/apps/x/packages/core/src/application/lib/message-queue.ts
+++ b/apps/x/packages/core/src/application/lib/message-queue.ts
@@ -1,12 +1,16 @@
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
+import { UserMessageContent } from "@x/shared/dist/message.js";
+import z from "zod";
+
+export type UserMessageContentType = z.infer;
type EnqueuedMessage = {
messageId: string;
- message: string;
+ message: UserMessageContentType;
};
export interface IMessageQueue {
- enqueue(runId: string, message: string): Promise;
+ enqueue(runId: string, message: UserMessageContentType): Promise;
dequeue(runId: string): Promise;
}
@@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator;
}
- async enqueue(runId: string, message: string): Promise {
+ async enqueue(runId: string, message: UserMessageContentType): Promise {
if (!this.store[runId]) {
this.store[runId] = [];
}
diff --git a/apps/x/packages/core/src/auth/oauth-client.ts b/apps/x/packages/core/src/auth/oauth-client.ts
index 613cee2e..0442d7d6 100644
--- a/apps/x/packages/core/src/auth/oauth-client.ts
+++ b/apps/x/packages/core/src/auth/oauth-client.ts
@@ -152,19 +152,11 @@ export function generateState(): string {
*/
export function buildAuthorizationUrl(
config: client.Configuration,
- params: {
- redirectUri: string;
- scope: string;
- codeChallenge: string;
- state: string;
- }
+ params: Record
): URL {
return client.buildAuthorizationUrl(config, {
- redirect_uri: params.redirectUri,
- scope: params.scope,
- code_challenge: params.codeChallenge,
code_challenge_method: 'S256',
- state: params.state,
+ ...params,
});
}
@@ -208,6 +200,11 @@ export async function refreshTokens(
tokens.scopes = existingScopes;
}
+ // Preserve existing refresh token if server didn't return it
+ if (!tokens.refresh_token) {
+ tokens.refresh_token = refreshToken;
+ }
+
console.log(`[OAuth] Token refresh successful`);
return tokens;
}
diff --git a/apps/x/packages/core/src/auth/repo.ts b/apps/x/packages/core/src/auth/repo.ts
index 7a91e8b6..a53c2dcb 100644
--- a/apps/x/packages/core/src/auth/repo.ts
+++ b/apps/x/packages/core/src/auth/repo.ts
@@ -5,9 +5,9 @@ import { OAuthTokens } from './types.js';
import z from 'zod';
const ProviderConnectionSchema = z.object({
- tokens: OAuthTokens,
- clientId: z.string().optional(),
- error: z.string().optional(),
+ tokens: OAuthTokens.nullable().optional(),
+ clientId: z.string().nullable().optional(),
+ error: z.string().nullable().optional(),
});
const OAuthConfigSchema = z.object({
@@ -17,7 +17,7 @@ const OAuthConfigSchema = z.object({
const ClientFacingConfigSchema = z.record(z.string(), z.object({
connected: z.boolean(),
- error: z.string().optional(),
+ error: z.string().nullable().optional(),
}));
const LegacyOauthConfigSchema = z.record(z.string(), OAuthTokens);
@@ -28,13 +28,9 @@ const DEFAULT_CONFIG: z.infer = {
};
export interface IOAuthRepo {
- getTokens(provider: string): Promise;
- saveTokens(provider: string, tokens: OAuthTokens): Promise;
- clearTokens(provider: string): Promise;
- getClientId(provider: string): Promise;
- setClientId(provider: string, clientId: string): Promise;
- setError(provider: string, errorMessage: string): Promise;
- clearError(provider: string): Promise;
+ read(provider: string): Promise>;
+ upsert(provider: string, connection: Partial>): Promise;
+ delete(provider: string): Promise;
getClientFacingConfig(): Promise>;
}
@@ -92,71 +88,22 @@ export class FSOAuthRepo implements IOAuthRepo {
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
}
- async getTokens(provider: string): Promise {
+ async read(provider: string): Promise> {
const config = await this.readConfig();
- const tokens = config.providers[provider]?.tokens;
- return tokens ?? null;
+ return config.providers[provider] ?? {};
}
-
- async saveTokens(provider: string, tokens: OAuthTokens): Promise {
+ async upsert(provider: string, connection: Partial>): Promise {
const config = await this.readConfig();
- if (config.providers[provider]) {
- delete config.providers[provider];
- }
- config.providers[provider] = {
- tokens,
- };
+ config.providers[provider] = { ...config.providers[provider] ?? {}, ...connection };
await this.writeConfig(config);
}
- async clearTokens(provider: string): Promise {
+ async delete(provider: string): Promise {
const config = await this.readConfig();
delete config.providers[provider];
await this.writeConfig(config);
}
- async getClientId(provider: string): Promise {
- const config = await this.readConfig();
- const clientId = config.providers[provider]?.clientId;
- return clientId ?? null;
- }
-
- async setClientId(provider: string, clientId: string): Promise {
- const config = await this.readConfig();
- if (!config.providers[provider]) {
- throw new Error(`Provider ${provider} not found`);
- }
- config.providers[provider].clientId = clientId;
- await this.writeConfig(config);
- }
-
- async clearClientId(provider: string): Promise {
- const config = await this.readConfig();
- if (!config.providers[provider]) {
- throw new Error(`Provider ${provider} not found`);
- }
- delete config.providers[provider].clientId;
- await this.writeConfig(config);
- }
-
- async setError(provider: string, errorMessage: string): Promise {
- const config = await this.readConfig();
- if (!config.providers[provider]) {
- throw new Error(`Provider ${provider} not found`);
- }
- config.providers[provider].error = errorMessage;
- await this.writeConfig(config);
- }
-
- async clearError(provider: string): Promise {
- const config = await this.readConfig();
- if (!config.providers[provider]) {
- throw new Error(`Provider ${provider} not found`);
- }
- delete config.providers[provider].error;
- await this.writeConfig(config);
- }
-
async getClientFacingConfig(): Promise> {
const config = await this.readConfig();
const clientFacingConfig: z.infer = {};
diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts
index 97d1e8b2..070b4642 100644
--- a/apps/x/packages/core/src/composio/client.ts
+++ b/apps/x/packages/core/src/composio/client.ts
@@ -343,7 +343,7 @@ export async function executeAction(
try {
const client = getComposioClient();
const result = await client.tools.execute(actionSlug, {
- userId: connectedAccountId,
+ userId: 'rowboat-user',
arguments: input,
connectedAccountId,
dangerouslySkipVersionCheck: true,
@@ -352,8 +352,8 @@ export async function executeAction(
console.log(`[Composio] Action completed successfully`);
return { success: true, data: result.data };
} catch (error) {
- console.error(`[Composio] Action execution failed:`, error);
- const message = error instanceof Error ? error.message : 'Unknown error';
+ console.error(`[Composio] Action execution failed:`, JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}), 2));
+ const message = error instanceof Error ? error.message : (typeof error === 'object' ? JSON.stringify(error) : 'Unknown error');
return { success: false, data: null, error: message };
}
}
diff --git a/apps/x/packages/core/src/config/config.ts b/apps/x/packages/core/src/config/config.ts
index caefad82..4a91e101 100644
--- a/apps/x/packages/core/src/config/config.ts
+++ b/apps/x/packages/core/src/config/config.ts
@@ -91,4 +91,9 @@ function ensureWelcomeFile() {
ensureDirs();
ensureDefaultConfigs();
-ensureWelcomeFile();
\ No newline at end of file
+ensureWelcomeFile();
+
+// Initialize version history repo (async, fire-and-forget on startup)
+import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => {
+ console.error('[VersionHistory] Failed to init repo:', err);
+});
diff --git a/apps/x/packages/core/src/config/security.ts b/apps/x/packages/core/src/config/security.ts
index d69eb241..ac1afa58 100644
--- a/apps/x/packages/core/src/config/security.ts
+++ b/apps/x/packages/core/src/config/security.ts
@@ -20,6 +20,23 @@ const DEFAULT_ALLOW_LIST = [
let cachedAllowList: string[] | null = null;
let cachedMtimeMs: number | null = null;
+export async function addToSecurityConfig(commands: string[]): Promise {
+ ensureSecurityConfigSync();
+ const current = readAllowList();
+ const merged = new Set(current);
+ for (const cmd of commands) {
+ const normalized = cmd.trim().toLowerCase();
+ if (normalized) merged.add(normalized);
+ }
+ await fsPromises.writeFile(
+ SECURITY_CONFIG_PATH,
+ JSON.stringify(Array.from(merged).sort(), null, 2) + "\n",
+ "utf8",
+ );
+ // Reset cache so next read picks up the new file
+ resetSecurityAllowListCache();
+}
+
/**
* Async function to ensure security config file exists.
* Called explicitly at app startup via initConfigs().
@@ -102,11 +119,9 @@ export function getSecurityAllowList(): string[] {
if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) {
return cachedAllowList;
}
-
- const allowList = readAllowList();
- cachedAllowList = allowList;
+ cachedAllowList = readAllowList();
cachedMtimeMs = stats.mtimeMs;
- return allowList;
+ return cachedAllowList;
} catch {
cachedAllowList = null;
cachedMtimeMs = null;
diff --git a/apps/x/packages/core/src/index.ts b/apps/x/packages/core/src/index.ts
index b7718cba..0eab08e3 100644
--- a/apps/x/packages/core/src/index.ts
+++ b/apps/x/packages/core/src/index.ts
@@ -5,4 +5,7 @@ export * as workspace from './workspace/workspace.js';
export * as watcher from './workspace/watcher.js';
// Config initialization
-export { initConfigs } from './config/initConfigs.js';
\ No newline at end of file
+export { initConfigs } from './config/initConfigs.js';
+
+// Knowledge version history
+export * as versionHistory from './knowledge/version_history.js';
diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts
index a1b7e135..a119dfa6 100644
--- a/apps/x/packages/core/src/knowledge/build_graph.ts
+++ b/apps/x/packages/core/src/knowledge/build_graph.ts
@@ -15,6 +15,7 @@ import {
} from './graph_state.js';
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
import { limitEventItems } from './limit_event_items.js';
+import { commitAll } from './version_history.js';
/**
* Build obsidian-style knowledge graph by running topic extraction
@@ -320,6 +321,13 @@ async function buildGraphWithFiles(
// Save state after each successful batch
// This ensures partial progress is saved even if later batches fail
saveState(state);
+
+ // Commit knowledge changes to version history
+ try {
+ await commitAll('Knowledge update', 'Rowboat');
+ } catch (err) {
+ console.error(`[GraphBuilder] Failed to commit version history:`, err);
+ }
} catch (error) {
hadError = true;
console.error(`Error processing batch ${batchNumber}:`, error);
@@ -467,6 +475,13 @@ async function processVoiceMemosForKnowledge(): Promise {
// Save state after each batch
saveState(state);
+
+ // Commit knowledge changes to version history
+ try {
+ await commitAll('Knowledge update', 'Rowboat');
+ } catch (err) {
+ console.error(`[GraphBuilder] Failed to commit version history:`, err);
+ }
} catch (error) {
hadError = true;
console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error);
diff --git a/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts b/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts
index 67607bfe..1ee865e9 100644
--- a/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts
+++ b/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts
@@ -31,7 +31,7 @@ export class FirefliesClientFactory {
*/
static async getClient(): Promise {
const oauthRepo = container.resolve('oauthRepo');
- const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME);
+ const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
if (!tokens) {
this.clearCache();
@@ -49,7 +49,7 @@ export class FirefliesClientFactory {
// Token expired, try to refresh
if (!tokens.refresh_token) {
console.log("[Fireflies] Token expired and no refresh token available.");
- await oauthRepo.setError(this.PROVIDER_NAME, 'Missing refresh token. Please reconnect.');
+ await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Missing refresh token. Please reconnect.' });
this.clearCache();
return null;
}
@@ -62,7 +62,7 @@ export class FirefliesClientFactory {
tokens.refresh_token,
existingScopes
);
- await oauthRepo.saveTokens(this.PROVIDER_NAME, refreshedTokens);
+ await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens });
// Update cached tokens and recreate client
this.cache.tokens = refreshedTokens;
@@ -77,7 +77,7 @@ export class FirefliesClientFactory {
return this.cache.client;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to refresh token for Fireflies';
- await oauthRepo.setError(this.PROVIDER_NAME, message);
+ await oauthRepo.upsert(this.PROVIDER_NAME, { error: message });
console.error("[Fireflies] Failed to refresh token:", error);
this.clearCache();
return null;
@@ -107,7 +107,7 @@ export class FirefliesClientFactory {
*/
static async hasValidCredentials(): Promise {
const oauthRepo = container.resolve('oauthRepo');
- const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME);
+ const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
return tokens !== null;
}
diff --git a/apps/x/packages/core/src/knowledge/google-client-factory.ts b/apps/x/packages/core/src/knowledge/google-client-factory.ts
index e15d2513..3cb83cd1 100644
--- a/apps/x/packages/core/src/knowledge/google-client-factory.ts
+++ b/apps/x/packages/core/src/knowledge/google-client-factory.ts
@@ -27,9 +27,9 @@ export class GoogleClientFactory {
private static async resolveClientId(): Promise {
const oauthRepo = container.resolve('oauthRepo');
- const clientId = await oauthRepo.getClientId(this.PROVIDER_NAME);
+ const { clientId } = await oauthRepo.read(this.PROVIDER_NAME);
if (!clientId) {
- await oauthRepo.setError(this.PROVIDER_NAME, 'Google client ID missing. Please reconnect.');
+ await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Google client ID missing. Please reconnect.' });
throw new Error('Google client ID missing. Please reconnect.');
}
return clientId;
@@ -40,7 +40,7 @@ export class GoogleClientFactory {
*/
static async getClient(): Promise {
const oauthRepo = container.resolve('oauthRepo');
- const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME);
+ const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
if (!tokens) {
this.clearCache();
@@ -64,7 +64,7 @@ export class GoogleClientFactory {
// Token expired, try to refresh
if (!tokens.refresh_token) {
console.log("[OAuth] Token expired and no refresh token available for Google.");
- await oauthRepo.setError(this.PROVIDER_NAME, 'Missing refresh token. Please reconnect.');
+ await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Missing refresh token. Please reconnect.' });
this.clearCache();
return null;
}
@@ -77,7 +77,7 @@ export class GoogleClientFactory {
tokens.refresh_token,
existingScopes
);
- await oauthRepo.saveTokens(this.PROVIDER_NAME, refreshedTokens);
+ await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens });
// Update cached tokens and recreate client
this.cache.tokens = refreshedTokens;
@@ -89,7 +89,7 @@ export class GoogleClientFactory {
return this.cache.client;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to refresh token for Google';
- await oauthRepo.setError(this.PROVIDER_NAME, message);
+ await oauthRepo.upsert(this.PROVIDER_NAME, { error: message });
console.error("[OAuth] Failed to refresh token for Google:", error);
this.clearCache();
return null;
@@ -116,7 +116,7 @@ export class GoogleClientFactory {
*/
static async hasValidCredentials(requiredScopes: string | string[]): Promise {
const oauthRepo = container.resolve('oauthRepo');
- const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME);
+ const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
if (!tokens) {
return false;
}
diff --git a/apps/x/packages/core/src/knowledge/version_history.ts b/apps/x/packages/core/src/knowledge/version_history.ts
new file mode 100644
index 00000000..a6504f67
--- /dev/null
+++ b/apps/x/packages/core/src/knowledge/version_history.ts
@@ -0,0 +1,243 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import git from 'isomorphic-git';
+import { WorkDir } from '../config/config.js';
+
+const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
+
+// Simple promise-based mutex to serialize commits
+let commitLock: Promise = Promise.resolve();
+
+// Commit listeners for notifying other layers (e.g. renderer refresh)
+type CommitListener = () => void;
+const commitListeners: CommitListener[] = [];
+
+export function onCommit(listener: CommitListener): () => void {
+ commitListeners.push(listener);
+ return () => {
+ const idx = commitListeners.indexOf(listener);
+ if (idx >= 0) commitListeners.splice(idx, 1);
+ };
+}
+
+/**
+ * Initialize a git repo in the knowledge directory if one doesn't exist.
+ * Stages all existing .md files and makes an initial commit.
+ */
+export async function initRepo(): Promise {
+ const gitDir = path.join(KNOWLEDGE_DIR, '.git');
+ if (fs.existsSync(gitDir)) {
+ return;
+ }
+
+ // Ensure knowledge dir exists
+ if (!fs.existsSync(KNOWLEDGE_DIR)) {
+ fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
+ }
+
+ await git.init({ fs, dir: KNOWLEDGE_DIR });
+
+ // Stage all existing .md files
+ const files = getAllMdFiles(KNOWLEDGE_DIR, '');
+ for (const file of files) {
+ await git.add({ fs, dir: KNOWLEDGE_DIR, filepath: file });
+ }
+
+ if (files.length > 0) {
+ await git.commit({
+ fs,
+ dir: KNOWLEDGE_DIR,
+ message: 'Initial snapshot',
+ author: { name: 'Rowboat', email: 'local' },
+ });
+ }
+}
+
+/**
+ * Recursively find all .md files relative to the knowledge dir.
+ */
+function getAllMdFiles(baseDir: string, relDir: string): string[] {
+ const results: string[] = [];
+ const absDir = relDir ? path.join(baseDir, relDir) : baseDir;
+ let entries: string[];
+ try {
+ entries = fs.readdirSync(absDir);
+ } catch {
+ return results;
+ }
+ for (const entry of entries) {
+ if (entry === '.git' || entry.startsWith('.')) continue;
+ const fullPath = path.join(absDir, entry);
+ const relPath = relDir ? `${relDir}/${entry}` : entry;
+ const stat = fs.statSync(fullPath);
+ if (stat.isDirectory()) {
+ results.push(...getAllMdFiles(baseDir, relPath));
+ } else if (entry.endsWith('.md')) {
+ results.push(relPath);
+ }
+ }
+ return results;
+}
+
+/**
+ * Stage all changes to .md files and commit. No-op if nothing changed.
+ * Serialized via a promise lock to prevent concurrent git index corruption.
+ */
+export async function commitAll(message: string, authorName: string): Promise {
+ const prev = commitLock;
+ let resolve: () => void;
+ commitLock = new Promise(r => { resolve = r; });
+
+ await prev;
+ try {
+ await commitAllInner(message, authorName);
+ } finally {
+ resolve!();
+ }
+}
+
+async function commitAllInner(message: string, authorName: string): Promise {
+ const matrix = await git.statusMatrix({ fs, dir: KNOWLEDGE_DIR });
+
+ let hasChanges = false;
+ for (const [filepath, head, workdir, stage] of matrix) {
+ // Skip non-md files
+ if (!filepath.endsWith('.md')) continue;
+
+ // [filepath, HEAD, WORKDIR, STAGE]
+ // Unchanged: [f, 1, 1, 1]
+ if (head === 1 && workdir === 1 && stage === 1) continue;
+
+ hasChanges = true;
+
+ if (workdir === 0) {
+ // File deleted from workdir
+ await git.remove({ fs, dir: KNOWLEDGE_DIR, filepath });
+ } else {
+ // File added or modified
+ await git.add({ fs, dir: KNOWLEDGE_DIR, filepath });
+ }
+ }
+
+ if (!hasChanges) return;
+
+ await git.commit({
+ fs,
+ dir: KNOWLEDGE_DIR,
+ message,
+ author: { name: authorName, email: 'local' },
+ });
+
+ for (const listener of commitListeners) {
+ try { listener(); } catch { /* ignore */ }
+ }
+}
+
+export interface CommitInfo {
+ oid: string;
+ message: string;
+ timestamp: number;
+ author: string;
+}
+
+const MAX_FILE_HISTORY = 50;
+
+/**
+ * Get commit history for a specific file.
+ * Returns commits where the file content changed, most recent first.
+ * Capped at MAX_FILE_HISTORY entries.
+ */
+export async function getFileHistory(knowledgeRelPath: string): Promise {
+ // Normalize path separators for git (always forward slashes)
+ const filepath = knowledgeRelPath.replace(/\\/g, '/');
+
+ let commits: Awaited>;
+ try {
+ commits = await git.log({ fs, dir: KNOWLEDGE_DIR });
+ } catch {
+ return [];
+ }
+
+ if (commits.length === 0) return [];
+
+ const result: CommitInfo[] = [];
+
+ // Walk through commits and check if file changed between consecutive commits
+ for (let i = 0; i < commits.length; i++) {
+ if (result.length >= MAX_FILE_HISTORY) break;
+
+ const commit = commits[i]!;
+ const parentCommit = commits[i + 1]; // undefined for the first (oldest) commit
+
+ const currentOid = await getBlobOidAtCommit(commit.oid, filepath);
+ const parentOid = parentCommit
+ ? await getBlobOidAtCommit(parentCommit.oid, filepath)
+ : null;
+
+ // Include this commit if:
+ // - The file existed and changed from parent
+ // - The file was added (parentOid is null but currentOid exists)
+ // - The file was deleted (currentOid is null but parentOid exists)
+ if (currentOid !== parentOid) {
+ result.push({
+ oid: commit.oid,
+ message: commit.commit.message.trim(),
+ timestamp: commit.commit.author.timestamp,
+ author: commit.commit.author.name,
+ });
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Get the blob OID for a file at a specific commit, or null if not found.
+ */
+async function getBlobOidAtCommit(commitOid: string, filepath: string): Promise {
+ try {
+ const result = await git.readBlob({
+ fs,
+ dir: KNOWLEDGE_DIR,
+ oid: commitOid,
+ filepath,
+ });
+ // Compute a content hash from the blob to compare
+ return result.oid;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Read file content at a specific commit.
+ */
+export async function getFileAtCommit(knowledgeRelPath: string, oid: string): Promise {
+ const filepath = knowledgeRelPath.replace(/\\/g, '/');
+ const result = await git.readBlob({
+ fs,
+ dir: KNOWLEDGE_DIR,
+ oid,
+ filepath,
+ });
+ return Buffer.from(result.blob).toString('utf-8');
+}
+
+/**
+ * Restore a file to its content at a given commit, then commit the restoration.
+ */
+export async function restoreFile(knowledgeRelPath: string, oid: string): Promise {
+ const content = await getFileAtCommit(knowledgeRelPath, oid);
+ const absPath = path.join(KNOWLEDGE_DIR, knowledgeRelPath);
+
+ // Ensure parent directory exists
+ const dir = path.dirname(absPath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ fs.writeFileSync(absPath, content, 'utf-8');
+
+ const filename = path.basename(knowledgeRelPath);
+ await commitAll(`Restored ${filename}`, 'You');
+}
diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts
index 15873c49..5d563f1f 100644
--- a/apps/x/packages/core/src/runs/repo.ts
+++ b/apps/x/packages/core/src/runs/repo.ts
@@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo {
const messageEvent = event as z.infer;
if (messageEvent.message.role === 'user') {
const content = messageEvent.message.content;
- if (typeof content === 'string' && content.trim()) {
- // Clean attached-files XML and @mentions, then truncate to 100 chars
- const cleaned = cleanContentForTitle(content);
- if (!cleaned) continue; // Skip if only attached files/mentions
+ let textContent: string | undefined;
+ if (typeof content === 'string') {
+ textContent = content;
+ } else {
+ textContent = content
+ .filter(p => p.type === 'text')
+ .map(p => p.text)
+ .join('');
+ }
+ if (textContent && textContent.trim()) {
+ const cleaned = cleanContentForTitle(textContent);
+ if (!cleaned) continue;
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
}
}
@@ -90,9 +98,17 @@ export class FSRunsRepo implements IRunsRepo {
if (msg.role === 'user') {
// Found first user message - use as title
const content = msg.content;
- if (typeof content === 'string' && content.trim()) {
- // Clean attached-files XML and @mentions, then truncate
- const cleaned = cleanContentForTitle(content);
+ let textContent: string | undefined;
+ if (typeof content === 'string') {
+ textContent = content;
+ } else {
+ textContent = content
+ .filter(p => p.type === 'text')
+ .map(p => p.text)
+ .join('');
+ }
+ if (textContent && textContent.trim()) {
+ const cleaned = cleanContentForTitle(textContent);
if (cleaned) {
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
}
diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts
index 490e935f..0f123497 100644
--- a/apps/x/packages/core/src/runs/runs.ts
+++ b/apps/x/packages/core/src/runs/runs.ts
@@ -1,13 +1,15 @@
import z from "zod";
import container from "../di/container.js";
-import { IMessageQueue } from "../application/lib/message-queue.js";
-import { AskHumanResponseEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
+import { IMessageQueue, UserMessageContentType } from "../application/lib/message-queue.js";
+import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
import { IRunsRepo } from "./repo.js";
import { IAgentRuntime } from "../agents/runtime.js";
import { IBus } from "../application/lib/bus.js";
import { IAbortRegistry } from "./abort-registry.js";
import { IRunsLock } from "./lock.js";
import { forceCloseAllMcpClients } from "../mcp/mcp.js";
+import { extractCommandNames } from "../application/lib/command-executor.js";
+import { addToSecurityConfig } from "../config/security.js";
export async function createRun(opts: z.infer): Promise> {
const repo = container.resolve('runsRepo');
@@ -17,7 +19,7 @@ export async function createRun(opts: z.infer): Promise
return run;
}
-export async function createMessage(runId: string, message: string): Promise {
+export async function createMessage(runId: string, message: UserMessageContentType): Promise {
const queue = container.resolve('messageQueue');
const id = await queue.enqueue(runId, message);
const runtime = container.resolve('agentRuntime');
@@ -26,11 +28,32 @@ export async function createMessage(runId: string, message: string): Promise): Promise {
+ const { scope, ...rest } = ev;
+
+ // For "always" scope, derive command from the run log and persist to security config
+ if (rest.response === "approve" && scope === "always") {
+ const repo = container.resolve('runsRepo');
+ const run = await repo.fetch(runId);
+ const permReqEvent = run.log.find(
+ (e): e is z.infer =>
+ e.type === "tool-permission-request"
+ && e.toolCall.toolCallId === rest.toolCallId
+ && JSON.stringify(e.subflow) === JSON.stringify(rest.subflow)
+ );
+ if (permReqEvent && typeof permReqEvent.toolCall.arguments === 'object' && permReqEvent.toolCall.arguments !== null && 'command' in permReqEvent.toolCall.arguments) {
+ const commandNames = extractCommandNames(String(permReqEvent.toolCall.arguments.command));
+ if (commandNames.length > 0) {
+ await addToSecurityConfig(commandNames);
+ }
+ }
+ }
+
const repo = container.resolve('runsRepo');
const event: z.infer = {
- ...ev,
+ ...rest,
runId,
type: "tool-permission-response",
+ scope,
};
await repo.appendEvents(runId, [event]);
const runtime = container.resolve('agentRuntime');
diff --git a/apps/x/packages/core/src/search/search.ts b/apps/x/packages/core/src/search/search.ts
new file mode 100644
index 00000000..d68449f5
--- /dev/null
+++ b/apps/x/packages/core/src/search/search.ts
@@ -0,0 +1,375 @@
+import path from 'path';
+import fs from 'fs';
+import fsp from 'fs/promises';
+import readline from 'readline';
+import { execFile } from 'child_process';
+import { WorkDir } from '../config/config.js';
+
+interface SearchResult {
+ type: 'knowledge' | 'chat';
+ title: string;
+ preview: string;
+ path: string;
+}
+
+const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
+const RUNS_DIR = path.join(WorkDir, 'runs');
+
+type SearchType = 'knowledge' | 'chat';
+
+/**
+ * Search across knowledge files and chat history.
+ * @param types - optional filter to search only specific types (default: both)
+ */
+export async function search(query: string, limit = 20, types?: SearchType[]): Promise<{ results: SearchResult[] }> {
+ const trimmed = query.trim();
+ if (!trimmed) {
+ return { results: [] };
+ }
+
+ const searchKnowledgeEnabled = !types || types.includes('knowledge');
+ const searchChatsEnabled = !types || types.includes('chat');
+
+ const [knowledgeResults, chatResults] = await Promise.all([
+ searchKnowledgeEnabled ? searchKnowledge(trimmed, limit) : Promise.resolve([]),
+ searchChatsEnabled ? searchChats(trimmed, limit) : Promise.resolve([]),
+ ]);
+
+ const results = [...knowledgeResults, ...chatResults].slice(0, limit);
+ return { results };
+}
+
+/**
+ * Search knowledge markdown files by content and filename.
+ */
+async function searchKnowledge(query: string, limit: number): Promise {
+ if (!fs.existsSync(KNOWLEDGE_DIR)) {
+ return [];
+ }
+
+ const results: SearchResult[] = [];
+ const seenPaths = new Set();
+ const lowerQuery = query.toLowerCase();
+
+ // Content search via grep
+ try {
+ const grepMatches = await grepFiles(query, KNOWLEDGE_DIR, '*.md');
+ for (const match of grepMatches) {
+ if (results.length >= limit) break;
+ const relPath = path.relative(WorkDir, match.file);
+ if (seenPaths.has(relPath)) continue;
+ seenPaths.add(relPath);
+
+ const title = path.basename(match.file, '.md');
+ results.push({
+ type: 'knowledge',
+ title,
+ preview: match.line.trim().substring(0, 150),
+ path: relPath,
+ });
+ }
+ } catch {
+ // grep failed (no matches or dir issue) — continue
+ }
+
+ // Filename search — check files whose name matches the query
+ try {
+ const allFiles = await listMarkdownFiles(KNOWLEDGE_DIR);
+ for (const file of allFiles) {
+ if (results.length >= limit) break;
+ const relPath = path.relative(WorkDir, file);
+ if (seenPaths.has(relPath)) continue;
+
+ const basename = path.basename(file, '.md');
+ if (basename.toLowerCase().includes(lowerQuery)) {
+ seenPaths.add(relPath);
+ const preview = await readFirstLines(file, 2);
+ results.push({
+ type: 'knowledge',
+ title: basename,
+ preview,
+ path: relPath,
+ });
+ }
+ }
+ } catch {
+ // ignore errors
+ }
+
+ return results;
+}
+
+/**
+ * Search chat history by title and message content.
+ */
+async function searchChats(query: string, limit: number): Promise {
+ if (!fs.existsSync(RUNS_DIR)) {
+ return [];
+ }
+
+ const results: SearchResult[] = [];
+ const seenIds = new Set();
+ const lowerQuery = query.toLowerCase();
+
+ // Content search via grep on JSONL files
+ try {
+ const grepMatches = await grepFiles(query, RUNS_DIR, '*.jsonl');
+ for (const match of grepMatches) {
+ if (results.length >= limit) break;
+ const runId = path.basename(match.file, '.jsonl');
+ if (seenIds.has(runId)) continue;
+
+ const meta = await readRunMetadata(match.file);
+ if (meta.agentName !== 'copilot') {
+ seenIds.add(runId);
+ continue;
+ }
+ seenIds.add(runId);
+
+ // Extract a content preview from the matching line
+ let preview = '';
+ try {
+ const parsed = JSON.parse(match.line);
+ if (parsed.message?.content && typeof parsed.message.content === 'string') {
+ preview = parsed.message.content.replace(/[\s\S]*?<\/attached-files>/g, '').trim().substring(0, 150);
+ }
+ } catch {
+ preview = match.line.substring(0, 150);
+ }
+
+ results.push({
+ type: 'chat',
+ title: meta.title || runId,
+ preview,
+ path: runId,
+ });
+ }
+ } catch {
+ // grep failed — continue
+ }
+
+ // Title search — scan run files for matching titles
+ try {
+ const entries = await fsp.readdir(RUNS_DIR, { withFileTypes: true });
+ const jsonlFiles = entries
+ .filter(e => e.isFile() && e.name.endsWith('.jsonl'))
+ .map(e => e.name)
+ .sort()
+ .reverse(); // newest first
+
+ for (const name of jsonlFiles) {
+ if (results.length >= limit) break;
+ const runId = path.basename(name, '.jsonl');
+ if (seenIds.has(runId)) continue;
+
+ const filePath = path.join(RUNS_DIR, name);
+ const meta = await readRunMetadata(filePath);
+ if (meta.agentName !== 'copilot') {
+ seenIds.add(runId);
+ continue;
+ }
+ if (meta.title && meta.title.toLowerCase().includes(lowerQuery)) {
+ seenIds.add(runId);
+ results.push({
+ type: 'chat',
+ title: meta.title,
+ preview: meta.title,
+ path: runId,
+ });
+ }
+ }
+ } catch {
+ // ignore errors
+ }
+
+ return results;
+}
+
+/**
+ * Use grep to find files matching a query.
+ */
+function grepFiles(query: string, dir: string, includeGlob: string): Promise> {
+ return new Promise((resolve, reject) => {
+ execFile(
+ 'grep',
+ ['-ril', '--include=' + includeGlob, query, dir],
+ { maxBuffer: 1024 * 1024 },
+ (error, stdout) => {
+ if (error) {
+ // Exit code 1 = no matches
+ if (error.code === 1) {
+ resolve([]);
+ return;
+ }
+ reject(error);
+ return;
+ }
+
+ const files = stdout.trim().split('\n').filter(Boolean);
+ // For each matching file, get the first matching line
+ const promises = files.map(file =>
+ getFirstMatchingLine(file, query).then(line => ({ file, line }))
+ );
+ Promise.all(promises).then(resolve).catch(reject);
+ }
+ );
+ });
+}
+
+/**
+ * Get the first line in a file that matches the query (case-insensitive).
+ */
+function getFirstMatchingLine(filePath: string, query: string): Promise {
+ return new Promise((resolve) => {
+ let resolved = false;
+ const done = (value: string) => {
+ if (resolved) return;
+ resolved = true;
+ resolve(value);
+ };
+
+ const lowerQuery = query.toLowerCase();
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
+
+ rl.on('line', (line) => {
+ if (line.toLowerCase().includes(lowerQuery)) {
+ done(line);
+ rl.close();
+ stream.destroy();
+ }
+ });
+
+ rl.on('close', () => done(''));
+ stream.on('error', () => done(''));
+ });
+}
+
+interface RunMetadata {
+ title: string | undefined;
+ agentName: string | undefined;
+}
+
+/**
+ * Read metadata from a run JSONL file (agent name from start event, title from first user message).
+ */
+function readRunMetadata(filePath: string): Promise {
+ return new Promise((resolve) => {
+ let resolved = false;
+ const done = (value: RunMetadata) => {
+ if (resolved) return;
+ resolved = true;
+ resolve(value);
+ };
+
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
+ let lineIndex = 0;
+ let agentName: string | undefined;
+
+ rl.on('line', (line) => {
+ if (resolved) return;
+ const trimmed = line.trim();
+ if (!trimmed) return;
+
+ try {
+ if (lineIndex === 0) {
+ // Start event — extract agentName
+ const start = JSON.parse(trimmed);
+ agentName = start.agentName;
+ lineIndex++;
+ return;
+ }
+
+ const event = JSON.parse(trimmed);
+ if (event.type === 'message') {
+ const msg = event.message;
+ if (msg?.role === 'user') {
+ const content = msg.content;
+ if (typeof content === 'string' && content.trim()) {
+ let cleaned = content.replace(/[\s\S]*?<\/attached-files>/g, '');
+ cleaned = cleaned.replace(/\s+/g, ' ').trim();
+ if (cleaned) {
+ done({ title: cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned, agentName });
+ rl.close();
+ stream.destroy();
+ return;
+ }
+ }
+ done({ title: undefined, agentName });
+ rl.close();
+ stream.destroy();
+ return;
+ } else if (msg?.role === 'assistant') {
+ done({ title: undefined, agentName });
+ rl.close();
+ stream.destroy();
+ return;
+ }
+ }
+ lineIndex++;
+ } catch {
+ lineIndex++;
+ }
+ });
+
+ rl.on('close', () => done({ title: undefined, agentName }));
+ rl.on('error', () => done({ title: undefined, agentName: undefined }));
+ stream.on('error', () => {
+ rl.close();
+ done({ title: undefined, agentName: undefined });
+ });
+ });
+}
+
+/**
+ * Recursively list all .md files in a directory.
+ */
+async function listMarkdownFiles(dir: string): Promise {
+ const results: string[] = [];
+ try {
+ const entries = await fsp.readdir(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ const nested = await listMarkdownFiles(fullPath);
+ results.push(...nested);
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
+ results.push(fullPath);
+ }
+ }
+ } catch {
+ // ignore
+ }
+ return results;
+}
+
+/**
+ * Read the first N non-empty lines of a file for preview.
+ */
+async function readFirstLines(filePath: string, n: number): Promise {
+ return new Promise((resolve) => {
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
+ const lines: string[] = [];
+
+ rl.on('line', (line) => {
+ const trimmed = line.trim();
+ if (trimmed && !trimmed.startsWith('#')) {
+ lines.push(trimmed);
+ }
+ if (lines.length >= n) {
+ rl.close();
+ stream.destroy();
+ }
+ });
+
+ rl.on('close', () => {
+ resolve(lines.join(' ').substring(0, 150));
+ });
+
+ stream.on('error', () => {
+ resolve('');
+ });
+ });
+}
diff --git a/apps/x/packages/core/src/workspace/wiki-link-rewrite.ts b/apps/x/packages/core/src/workspace/wiki-link-rewrite.ts
new file mode 100644
index 00000000..fc451c7a
--- /dev/null
+++ b/apps/x/packages/core/src/workspace/wiki-link-rewrite.ts
@@ -0,0 +1,170 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+const WIKI_LINK_REGEX = /\[\[([^[\]]+)\]\]/g;
+const KNOWLEDGE_PREFIX = 'knowledge/';
+const MARKDOWN_EXTENSION = '.md';
+
+function normalizeRelPath(relPath: string): string {
+ return relPath.replace(/\\/g, '/');
+}
+
+function isKnowledgeMarkdownPath(relPath: string): boolean {
+ const normalized = normalizeRelPath(relPath).replace(/^\/+/, '');
+ const lower = normalized.toLowerCase();
+ return lower.startsWith(KNOWLEDGE_PREFIX) && lower.endsWith(MARKDOWN_EXTENSION);
+}
+
+function stripKnowledgePrefix(relPath: string): string {
+ const normalized = normalizeRelPath(relPath).replace(/^\/+/, '');
+ if (!normalized.toLowerCase().startsWith(KNOWLEDGE_PREFIX)) return normalized;
+ return normalized.slice(KNOWLEDGE_PREFIX.length);
+}
+
+function stripMarkdownExtension(wikiPath: string): string {
+ return wikiPath.toLowerCase().endsWith(MARKDOWN_EXTENSION)
+ ? wikiPath.slice(0, -MARKDOWN_EXTENSION.length)
+ : wikiPath;
+}
+
+function toWikiPathCompareKey(wikiPath: string): string {
+ return stripMarkdownExtension(wikiPath).toLowerCase();
+}
+
+function splitWikiPathPrefix(rawPath: string): { pathWithoutPrefix: string; hadKnowledgePrefix: boolean } {
+ let normalized = rawPath.trim().replace(/^\/+/, '').replace(/^\.\//, '');
+ const hadKnowledgePrefix = /^knowledge\//i.test(normalized);
+ if (hadKnowledgePrefix) {
+ normalized = normalized.slice(KNOWLEDGE_PREFIX.length);
+ }
+ return { pathWithoutPrefix: normalized, hadKnowledgePrefix };
+}
+
+function rewriteWikiLinksInMarkdown(
+ markdown: string,
+ fromWikiPath: string,
+ toWikiPath: string,
+ opts?: { allowBareSelfNameMatch?: boolean }
+): string {
+ const fromCompareKey = toWikiPathCompareKey(fromWikiPath);
+ const fromBaseName = stripMarkdownExtension(fromWikiPath).split('/').pop()?.toLowerCase() ?? null;
+ const toWikiPathWithoutExtension = stripMarkdownExtension(toWikiPath);
+ const toBaseName = toWikiPathWithoutExtension.split('/').pop() ?? toWikiPathWithoutExtension;
+
+ return markdown.replace(WIKI_LINK_REGEX, (fullMatch, innerRaw: string) => {
+ const pipeIndex = innerRaw.indexOf('|');
+ const pathAndAnchor = pipeIndex >= 0 ? innerRaw.slice(0, pipeIndex) : innerRaw;
+ const aliasSuffix = pipeIndex >= 0 ? innerRaw.slice(pipeIndex) : '';
+
+ const hashIndex = pathAndAnchor.indexOf('#');
+ const pathPart = hashIndex >= 0 ? pathAndAnchor.slice(0, hashIndex) : pathAndAnchor;
+ const anchorSuffix = hashIndex >= 0 ? pathAndAnchor.slice(hashIndex) : '';
+
+ const leadingWhitespace = pathPart.match(/^\s*/)?.[0] ?? '';
+ const trailingWhitespace = pathPart.match(/\s*$/)?.[0] ?? '';
+ const rawPath = pathPart.trim();
+ if (!rawPath) return fullMatch;
+
+ const { pathWithoutPrefix, hadKnowledgePrefix } = splitWikiPathPrefix(rawPath);
+ if (!pathWithoutPrefix) return fullMatch;
+
+ const matchesFullPath = toWikiPathCompareKey(pathWithoutPrefix) === fromCompareKey;
+ const isBareTarget = !pathWithoutPrefix.includes('/');
+ const targetBaseName = stripMarkdownExtension(pathWithoutPrefix).toLowerCase();
+ const matchesBareSelfName = Boolean(
+ opts?.allowBareSelfNameMatch
+ && fromBaseName
+ && isBareTarget
+ && targetBaseName === fromBaseName
+ );
+ if (!matchesFullPath && !matchesBareSelfName) {
+ return fullMatch;
+ }
+
+ const preserveMarkdownExtension = rawPath.toLowerCase().endsWith(MARKDOWN_EXTENSION);
+ const rewrittenPath = matchesBareSelfName
+ ? (preserveMarkdownExtension ? `${toBaseName}.md` : toBaseName)
+ : (preserveMarkdownExtension ? toWikiPath : toWikiPathWithoutExtension);
+ const finalPath = hadKnowledgePrefix ? `${KNOWLEDGE_PREFIX}${rewrittenPath}` : rewrittenPath;
+
+ return `[[${leadingWhitespace}${finalPath}${trailingWhitespace}${anchorSuffix}${aliasSuffix}]]`;
+ });
+}
+
+async function collectKnowledgeMarkdownFiles(workspaceRoot: string): Promise {
+ const knowledgeRoot = path.join(workspaceRoot, 'knowledge');
+ try {
+ const stat = await fs.lstat(knowledgeRoot);
+ if (!stat.isDirectory()) return [];
+ } catch {
+ return [];
+ }
+
+ const markdownFiles: string[] = [];
+ const pendingDirectories: string[] = [knowledgeRoot];
+
+ while (pendingDirectories.length > 0) {
+ const currentDirectory = pendingDirectories.pop();
+ if (!currentDirectory) continue;
+
+ const entries = await fs.readdir(currentDirectory, { withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.name.startsWith('.')) continue;
+
+ const absolutePath = path.join(currentDirectory, entry.name);
+ if (entry.isDirectory()) {
+ pendingDirectories.push(absolutePath);
+ continue;
+ }
+ if (!entry.isFile()) continue;
+ if (!entry.name.toLowerCase().endsWith(MARKDOWN_EXTENSION)) continue;
+
+ const relativePath = normalizeRelPath(path.relative(workspaceRoot, absolutePath));
+ markdownFiles.push(relativePath);
+ }
+ }
+
+ return markdownFiles;
+}
+
+export async function rewriteWikiLinksForRenamedKnowledgeFile(
+ workspaceRoot: string,
+ fromRelPath: string,
+ toRelPath: string
+): Promise {
+ const normalizedFrom = normalizeRelPath(fromRelPath);
+ const normalizedTo = normalizeRelPath(toRelPath);
+
+ if (!isKnowledgeMarkdownPath(normalizedFrom) || !isKnowledgeMarkdownPath(normalizedTo)) {
+ return 0;
+ }
+
+ const fromWikiPath = stripKnowledgePrefix(normalizedFrom);
+ const toWikiPath = stripKnowledgePrefix(normalizedTo);
+ if (toWikiPathCompareKey(fromWikiPath) === toWikiPathCompareKey(toWikiPath)) return 0;
+
+ const markdownFiles = await collectKnowledgeMarkdownFiles(workspaceRoot);
+ let rewrittenFiles = 0;
+
+ const normalizedToLower = normalizedTo.toLowerCase();
+ for (const relativePath of markdownFiles) {
+ const absolutePath = path.join(workspaceRoot, ...relativePath.split('/'));
+ try {
+ const markdown = await fs.readFile(absolutePath, 'utf8');
+ if (!markdown.includes('[[')) continue;
+
+ const isRenamedFile = normalizeRelPath(relativePath).toLowerCase() === normalizedToLower;
+ const rewritten = rewriteWikiLinksInMarkdown(markdown, fromWikiPath, toWikiPath, {
+ allowBareSelfNameMatch: isRenamedFile,
+ });
+ if (rewritten === markdown) continue;
+
+ await fs.writeFile(absolutePath, rewritten, 'utf8');
+ rewrittenFiles += 1;
+ } catch (error) {
+ console.error('Failed to rewrite wiki links in file:', relativePath, error);
+ }
+ }
+
+ return rewrittenFiles;
+}
diff --git a/apps/x/packages/core/src/workspace/workspace.ts b/apps/x/packages/core/src/workspace/workspace.ts
index 59910bdb..de1fe212 100644
--- a/apps/x/packages/core/src/workspace/workspace.ts
+++ b/apps/x/packages/core/src/workspace/workspace.ts
@@ -5,6 +5,8 @@ import { workspace } from '@x/shared';
import { z } from 'zod';
import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js';
import { WorkDir } from '../config/config.js';
+import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
+import { commitAll } from '../knowledge/version_history.js';
// ============================================================================
// Path Utilities
@@ -58,6 +60,11 @@ export function absToRelPosix(absPath: string): string | null {
return relPath.split(path.sep).join('/');
}
+function isKnowledgeMarkdownRelPath(relPath: string): boolean {
+ const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
+ return normalized.startsWith('knowledge/') && normalized.endsWith('.md');
+}
+
// ============================================================================
// File System Utilities
// ============================================================================
@@ -212,6 +219,21 @@ export async function readFile(
};
}
+// Debounced commit for knowledge file edits
+let knowledgeCommitTimer: ReturnType | null = null;
+
+function scheduleKnowledgeCommit(filename: string): void {
+ if (knowledgeCommitTimer) {
+ clearTimeout(knowledgeCommitTimer);
+ }
+ knowledgeCommitTimer = setTimeout(() => {
+ knowledgeCommitTimer = null;
+ commitAll(`Edit ${filename}`, 'You').catch(err => {
+ console.error('[VersionHistory] Failed to commit after edit:', err);
+ });
+ }, 3 * 60 * 1000);
+}
+
export async function writeFile(
relPath: string,
data: string,
@@ -260,6 +282,11 @@ export async function writeFile(
const stat = statToSchema(stats, 'file');
const etag = computeEtag(stats.size, stats.mtimeMs);
+ // Schedule a debounced version history commit for knowledge files
+ if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) {
+ scheduleKnowledgeCommit(path.basename(relPath));
+ }
+
return {
path: relPath,
stat,
@@ -286,6 +313,7 @@ export async function rename(
// Check if source exists
await fs.access(fromPath);
+ const fromStats = await fs.lstat(fromPath);
// Check if destination exists (only if overwrite is false)
if (!overwrite) {
@@ -309,6 +337,19 @@ export async function rename(
await fs.mkdir(path.dirname(toPath), { recursive: true });
await fs.rename(fromPath, toPath);
+
+ if (
+ fromStats.isFile()
+ && isKnowledgeMarkdownRelPath(from)
+ && isKnowledgeMarkdownRelPath(to)
+ ) {
+ try {
+ await rewriteWikiLinksForRenamedKnowledgeFile(WorkDir, from, to);
+ } catch (error) {
+ console.error('Failed to rewrite wiki backlinks after file rename:', error);
+ }
+ }
+
return { ok: true as const };
}
@@ -383,4 +424,4 @@ export async function remove(
}
return { ok: true as const };
-}
\ No newline at end of file
+}
diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts
index 7af39efb..b5803ffc 100644
--- a/apps/x/packages/shared/src/ipc.ts
+++ b/apps/x/packages/shared/src/ipc.ts
@@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js';
+import { UserMessageContent } from './message.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@@ -128,7 +129,7 @@ const ipcSchemas = {
'runs:createMessage': {
req: z.object({
runId: z.string(),
- message: z.string(),
+ message: UserMessageContent,
}),
res: z.object({
messageId: z.string(),
@@ -244,7 +245,7 @@ const ipcSchemas = {
res: z.object({
config: z.record(z.string(), z.object({
connected: z.boolean(),
- error: z.string().optional(),
+ error: z.string().nullable().optional(),
})),
}),
},
@@ -396,6 +397,46 @@ const ipcSchemas = {
req: z.object({ path: z.string() }),
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
},
+ // Knowledge version history channels
+ 'knowledge:history': {
+ req: z.object({ path: RelPath }),
+ res: z.object({
+ commits: z.array(z.object({
+ oid: z.string(),
+ message: z.string(),
+ timestamp: z.number(),
+ author: z.string(),
+ })),
+ }),
+ },
+ 'knowledge:fileAtCommit': {
+ req: z.object({ path: RelPath, oid: z.string() }),
+ res: z.object({ content: z.string() }),
+ },
+ 'knowledge:restore': {
+ req: z.object({ path: RelPath, oid: z.string() }),
+ res: z.object({ ok: z.literal(true) }),
+ },
+ 'knowledge:didCommit': {
+ req: z.object({}),
+ res: z.null(),
+ },
+ // Search channels
+ 'search:query': {
+ req: z.object({
+ query: z.string(),
+ limit: z.number().optional(),
+ types: z.array(z.enum(['knowledge', 'chat'])).optional(),
+ }),
+ res: z.object({
+ results: z.array(z.object({
+ type: z.enum(['knowledge', 'chat']),
+ title: z.string(),
+ preview: z.string(),
+ path: z.string(),
+ })),
+ }),
+ },
} as const;
// ============================================================================
diff --git a/apps/x/packages/shared/src/message.ts b/apps/x/packages/shared/src/message.ts
index 702b103a..be761853 100644
--- a/apps/x/packages/shared/src/message.ts
+++ b/apps/x/packages/shared/src/message.ts
@@ -28,9 +28,30 @@ export const AssistantContentPart = z.union([
ToolCallPart,
]);
+// A piece of user-typed text within a content array
+export const UserTextPart = z.object({
+ type: z.literal("text"),
+ text: z.string(),
+});
+
+// An attachment within a content array
+export const UserAttachmentPart = z.object({
+ type: z.literal("attachment"),
+ path: z.string(), // absolute file path
+ filename: z.string(), // display name ("photo.png")
+ mimeType: z.string(), // MIME type ("image/png", "text/plain")
+ size: z.number().optional(), // bytes
+});
+
+// Any single part of a user message (text or attachment)
+export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]);
+
+// Named type for user message content — used everywhere instead of repeating the union
+export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);
+
export const UserMessage = z.object({
role: z.literal("user"),
- content: z.string(),
+ content: UserMessageContent,
providerOptions: ProviderOptions.optional(),
});
diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts
index 14e91689..48085e9f 100644
--- a/apps/x/packages/shared/src/models.ts
+++ b/apps/x/packages/shared/src/models.ts
@@ -10,4 +10,5 @@ export const LlmProvider = z.object({
export const LlmModelConfig = z.object({
provider: LlmProvider,
model: z.string(),
+ knowledgeGraphModel: z.string().optional(),
});
diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts
index eccfb6a7..5f52f611 100644
--- a/apps/x/packages/shared/src/runs.ts
+++ b/apps/x/packages/shared/src/runs.ts
@@ -73,6 +73,7 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({
type: z.literal("tool-permission-response"),
toolCallId: z.string(),
response: z.enum(["approve", "deny"]),
+ scope: z.enum(["once", "session", "always"]).optional(),
});
export const RunErrorEvent = BaseRunEvent.extend({
@@ -106,6 +107,7 @@ export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
subflow: true,
toolCallId: true,
response: true,
+ scope: true,
});
export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml
index fa8765d6..d3525c4f 100644
--- a/apps/x/pnpm-lock.yaml
+++ b/apps/x/pnpm-lock.yaml
@@ -359,6 +359,9 @@ importers:
googleapis:
specifier: ^169.0.0
version: 169.0.0
+ isomorphic-git:
+ specifier: ^1.29.0
+ version: 1.37.2
mammoth:
specifier: ^1.11.0
version: 1.11.0
@@ -3501,6 +3504,10 @@ packages:
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
+ abort-controller@3.0.0:
+ resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
+ engines: {node: '>=6.5'}
+
abs-svg-path@0.1.1:
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
@@ -3627,6 +3634,9 @@ packages:
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
engines: {node: '>=8'}
+ async-lock@1.4.1:
+ resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
+
async@1.5.2:
resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==}
@@ -3641,6 +3651,10 @@ packages:
resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==}
engines: {node: '>=0.8'}
+ available-typed-arrays@1.0.7:
+ resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
+ engines: {node: '>= 0.4'}
+
awilix@12.0.5:
resolution: {integrity: sha512-Qf/V/hRo6DK0FoBKJ9QiObasRxHAhcNi0mV6kW2JMawxS3zq6Un+VsZmVAZDUfvB+MjTEiJ2tUJUl4cr0JiUAw==}
engines: {node: '>=16.3.0'}
@@ -3742,6 +3756,9 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
+ buffer@6.0.3:
+ resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
+
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -3762,6 +3779,10 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
+ call-bind@1.0.8:
+ resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
+ engines: {node: '>= 0.4'}
+
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
@@ -3825,6 +3846,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+ clean-git-ref@2.0.1:
+ resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==}
+
clean-stack@2.2.0:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'}
@@ -4256,6 +4280,9 @@ packages:
dfa@1.2.0:
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
+ diff3@0.0.3:
+ resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==}
+
dingbat-to-unicode@1.0.1:
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
@@ -4496,6 +4523,10 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
+ event-target-shim@5.0.1:
+ resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
+ engines: {node: '>=6'}
+
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@@ -4640,6 +4671,10 @@ packages:
fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
+ for-each@0.3.5:
+ resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
+ engines: {node: '>= 0.4'}
+
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@@ -5104,6 +5139,10 @@ packages:
is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
+ is-callable@1.2.7:
+ resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
+ engines: {node: '>= 0.4'}
+
is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
@@ -5170,6 +5209,10 @@ packages:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
+ is-typed-array@1.1.15:
+ resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
+ engines: {node: '>= 0.4'}
+
is-unicode-supported@0.1.0:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
@@ -5184,6 +5227,9 @@ packages:
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+ isarray@2.0.5:
+ resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
+
isbinaryfile@4.0.10:
resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
engines: {node: '>= 8.0.0'}
@@ -5191,6 +5237,11 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ isomorphic-git@1.37.2:
+ resolution: {integrity: sha512-HCQBBKmXIMPdHgYGstSBNp6MNmVcMQBbUqJF8xfywFmlpNseO4KKex59YlXqNxhRxmv3fUZwvNWvMyOdc1VvhA==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@@ -5762,6 +5813,9 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+ minimisted@2.0.1:
+ resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==}
+
minipass-collect@1.0.2:
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
engines: {node: '>= 8'}
@@ -6169,6 +6223,10 @@ packages:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
+ pify@4.0.1:
+ resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
+ engines: {node: '>=6'}
+
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
@@ -6186,6 +6244,10 @@ packages:
points-on-path@0.2.1:
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
+ possible-typed-array-names@1.1.0:
+ resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
+ engines: {node: '>= 0.4'}
+
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@@ -6220,6 +6282,10 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+ process@0.11.10:
+ resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
+ engines: {node: '>= 0.6.0'}
+
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
@@ -6434,6 +6500,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
+ readable-stream@4.7.0:
+ resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -6649,12 +6719,21 @@ packages:
server-destroy@1.0.1:
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
+ set-function-length@1.2.2:
+ resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
+ engines: {node: '>= 0.4'}
+
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+ sha.js@2.4.12:
+ resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==}
+ engines: {node: '>= 0.10'}
+ hasBin: true
+
shebang-command@1.2.0:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'}
@@ -6701,6 +6780,12 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
+ simple-concat@1.0.1:
+ resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
+
+ simple-get@4.0.1:
+ resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
+
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
@@ -6928,6 +7013,10 @@ packages:
resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==}
engines: {node: '>=0.12'}
+ to-buffer@1.2.2:
+ resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
+ engines: {node: '>= 0.4'}
+
to-data-view@1.1.0:
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
@@ -6998,6 +7087,10 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
+ typed-array-buffer@1.0.3:
+ resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
+ engines: {node: '>= 0.4'}
+
typescript-eslint@8.50.1:
resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -7272,6 +7365,10 @@ packages:
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
+ which-typed-array@1.1.20:
+ resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
+ engines: {node: '>= 0.4'}
+
which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true
@@ -11316,6 +11413,10 @@ snapshots:
abbrev@1.1.1: {}
+ abort-controller@3.0.0:
+ dependencies:
+ event-target-shim: 5.0.1
+
abs-svg-path@0.1.1: {}
accepts@2.0.0:
@@ -11440,6 +11541,8 @@ snapshots:
arrify@2.0.1: {}
+ async-lock@1.4.1: {}
+
async@1.5.2:
optional: true
@@ -11449,6 +11552,10 @@ snapshots:
author-regex@1.0.0: {}
+ available-typed-arrays@1.0.7:
+ dependencies:
+ possible-typed-array-names: 1.1.0
+
awilix@12.0.5:
dependencies:
camel-case: 4.1.2
@@ -11568,6 +11675,11 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
+ buffer@6.0.3:
+ dependencies:
+ base64-js: 1.5.1
+ ieee754: 1.2.1
+
bytes@3.1.2: {}
cacache@16.1.3:
@@ -11610,6 +11722,13 @@ snapshots:
es-errors: 1.3.0
function-bind: 1.1.2
+ call-bind@1.0.8:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ get-intrinsic: 1.3.0
+ set-function-length: 1.2.2
+
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -11672,6 +11791,8 @@ snapshots:
dependencies:
clsx: 2.1.1
+ clean-git-ref@2.0.1: {}
+
clean-stack@2.2.0: {}
cli-cursor@3.1.0:
@@ -12061,7 +12182,6 @@ snapshots:
es-define-property: 1.0.1
es-errors: 1.3.0
gopd: 1.2.0
- optional: true
define-properties@1.2.1:
dependencies:
@@ -12095,6 +12215,8 @@ snapshots:
dfa@1.2.0: {}
+ diff3@0.0.3: {}
+
dingbat-to-unicode@1.0.1: {}
dir-compare@4.2.0:
@@ -12451,6 +12573,8 @@ snapshots:
etag@1.8.1: {}
+ event-target-shim@5.0.1: {}
+
eventemitter3@5.0.1: {}
events@3.3.0: {}
@@ -12638,6 +12762,10 @@ snapshots:
unicode-properties: 1.4.1
unicode-trie: 2.0.0
+ for-each@0.3.5:
+ dependencies:
+ is-callable: 1.2.7
+
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
@@ -12983,7 +13111,6 @@ snapshots:
has-property-descriptors@1.0.2:
dependencies:
es-define-property: 1.0.1
- optional: true
has-symbols@1.1.0: {}
@@ -13251,6 +13378,8 @@ snapshots:
is-arrayish@0.3.4: {}
+ is-callable@1.2.7: {}
+
is-core-module@2.16.1:
dependencies:
hasown: 2.0.2
@@ -13300,6 +13429,10 @@ snapshots:
is-stream@2.0.1: {}
+ is-typed-array@1.1.15:
+ dependencies:
+ which-typed-array: 1.1.20
+
is-unicode-supported@0.1.0: {}
is-url@1.2.4: {}
@@ -13310,10 +13443,26 @@ snapshots:
isarray@1.0.0: {}
+ isarray@2.0.5: {}
+
isbinaryfile@4.0.10: {}
isexe@2.0.0: {}
+ isomorphic-git@1.37.2:
+ dependencies:
+ async-lock: 1.4.1
+ clean-git-ref: 2.0.1
+ crc-32: 1.2.2
+ diff3: 0.0.3
+ ignore: 5.3.2
+ minimisted: 2.0.1
+ pako: 1.0.11
+ pify: 4.0.1
+ readable-stream: 4.7.0
+ sha.js: 2.4.12
+ simple-get: 4.0.1
+
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
@@ -14139,6 +14288,10 @@ snapshots:
minimist@1.2.8: {}
+ minimisted@2.0.1:
+ dependencies:
+ minimist: 1.2.8
+
minipass-collect@1.0.2:
dependencies:
minipass: 3.3.6
@@ -14506,6 +14659,8 @@ snapshots:
pify@2.3.0: {}
+ pify@4.0.1: {}
+
pkce-challenge@5.0.1: {}
pkg-types@1.3.1:
@@ -14527,6 +14682,8 @@ snapshots:
path-data-parser: 0.1.0
points-on-curve: 0.2.0
+ possible-typed-array-names@1.1.0: {}
+
postcss-value-parser@4.2.0: {}
postcss@8.5.6:
@@ -14565,6 +14722,8 @@ snapshots:
process-nextick-args@2.0.1: {}
+ process@0.11.10: {}
+
progress@2.0.3: {}
promise-inflight@1.0.1: {}
@@ -14887,6 +15046,14 @@ snapshots:
string_decoder: 1.3.0
util-deprecate: 1.0.2
+ readable-stream@4.7.0:
+ dependencies:
+ abort-controller: 3.0.0
+ buffer: 6.0.3
+ events: 3.3.0
+ process: 0.11.10
+ string_decoder: 1.3.0
+
readdirp@4.1.2: {}
rechoir@0.8.0:
@@ -15175,10 +15342,25 @@ snapshots:
server-destroy@1.0.1: {}
+ set-function-length@1.2.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+
setimmediate@1.0.5: {}
setprototypeof@1.2.0: {}
+ sha.js@2.4.12:
+ dependencies:
+ inherits: 2.0.4
+ safe-buffer: 5.2.1
+ to-buffer: 1.2.2
+
shebang-command@1.2.0:
dependencies:
shebang-regex: 1.0.0
@@ -15236,6 +15418,14 @@ snapshots:
signal-exit@4.1.0: {}
+ simple-concat@1.0.1: {}
+
+ simple-get@4.0.1:
+ dependencies:
+ decompress-response: 6.0.0
+ once: 1.4.0
+ simple-concat: 1.0.1
+
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
@@ -15493,6 +15683,12 @@ snapshots:
unorm: 1.6.0
optional: true
+ to-buffer@1.2.2:
+ dependencies:
+ isarray: 2.0.5
+ safe-buffer: 5.2.1
+ typed-array-buffer: 1.0.3
+
to-data-view@1.1.0:
optional: true
@@ -15550,6 +15746,12 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.2
+ typed-array-buffer@1.0.3:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-typed-array: 1.1.15
+
typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@@ -15827,6 +16029,16 @@ snapshots:
tr46: 0.0.3
webidl-conversions: 3.0.1
+ which-typed-array@1.1.20:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.8
+ call-bound: 1.0.4
+ for-each: 0.3.5
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+
which@1.3.1:
dependencies:
isexe: 2.0.0