feat: add side AI workspace

This commit is contained in:
lucaronin
2026-05-29 00:46:59 +02:00
parent 6910347bf3
commit 78892c4637
94 changed files with 3763 additions and 505 deletions

View File

@@ -7,25 +7,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
<style>
:root {
color-scheme: light;
background: #FFFFFF;
color: #37352F;
}
:root[data-theme="dark"] {
color-scheme: dark;
background: #1F1E1B;
color: #E6E1D8;
}
body {
background: inherit;
color: inherit;
}
</style>
<title>Tolaria</title>
</head>
<body>
<script>
(function () {
function isResizeObserverLoopMessage(message) {
@@ -47,6 +28,63 @@
}
});
try {
if (new URLSearchParams(window.location.search).get('window') === 'ai-workspace') {
document.documentElement.classList.add('ai-workspace-native-window');
}
} catch {
// Leave the normal app chrome untouched if URL parsing is unavailable.
}
})();
</script>
<style>
:root {
color-scheme: light;
background: #FFFFFF;
color: #37352F;
}
:root[data-theme="dark"] {
color-scheme: dark;
background: #1F1E1B;
color: #E6E1D8;
}
body {
background: inherit;
color: inherit;
}
:root.ai-workspace-native-window,
:root.ai-workspace-native-window body {
background: transparent;
height: 100%;
margin: 0;
overflow: hidden;
width: 100%;
}
:root.ai-workspace-native-window body {
box-sizing: border-box;
padding: 20px;
}
:root.ai-workspace-native-window #root {
background: #FFFFFF;
border: 1px solid #E9E9E7;
border-radius: 12px;
box-sizing: border-box;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12), 0 1px 3px rgba(15, 23, 42, 0.10);
height: 100%;
overflow: hidden;
width: 100%;
}
:root.ai-workspace-native-window[data-theme="dark"] #root {
background: #1F1E1B;
border-color: #34322D;
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.30), 0 1px 4px rgba(0, 0, 0, 0.22);
}
</style>
<title>Tolaria</title>
</head>
<body>
<script>
(function () {
var key = 'tolaria-theme';
var legacyKey = 'laputa-theme';
var systemDarkQuery = '(prefers-color-scheme: dark)';

View File

@@ -313,11 +313,14 @@ files:
ai.panel.placeholder.missing: 547c492026d115608067988b7cfbb9a9
ai.panel.placeholder.ready: 31f5265c2e0432a7ca10d08e5c6f3114
ai.panel.send: 747c6a3564774149c989884e0c581d53
ai.workspace.title: d5b381672258b0bb54ede1e886895b3e
ai.workspace.title: 375099f0c804e924c1dc275370710d92
ai.workspace.newChat: a917baaf1484ec5dc85f19ab8fc4f4e9
ai.workspace.collapseSidebar: 9ecc763686b7c61547281f3e05ffd06f
ai.workspace.expandSidebar: 281b619f3da2ba27e604302c3c00db56
ai.workspace.close: 184649ab030e9a609488608221c28d8c
ai.workspace.expandPanel: edbe17b531457367c1412e4b6349c194
ai.workspace.restorePanel: 64eb6176c678c6b395cfc93bac02a16f
ai.workspace.closeChat: 6c5be35688a466297db40c3a65ac9805
ai.workspace.popOut: 85ec25ec056b3082651e6e3fcfa57416
ai.workspace.dock: 72e81ed23007ec6fb9820b9abec353c4
ai.workspace.settings: 2c178b1d7a60a3c2b37ca4221d322899

View File

@@ -0,0 +1,8 @@
# AI Agent Icons
- Claude Code: Claude mark from the official Claude wordmark on https://claude.com/product/claude-code
- Codex: OpenAI mark from https://openai.com/favicon.svg
- OpenCode: official OpenCode brand asset from https://opencode.ai/brand
- Pi: official Pi favicon from https://hey.pi.ai
- Gemini CLI: Google-hosted Gemini sparkle from https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg
- Kiro: official Kiro icon from https://kiro.dev/icon.svg?fe599162bb293ea0

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 125 125" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.375 118.75L56.125 111L58.125 101L59.75 93L61.25 83.125L62.125 79.875L62 79.625L61.375 79.75L53.875 90L42.5 105.375L33.5 114.875L31.375 115.75L27.625 113.875L28 110.375L30.125 107.375L42.5 91.5L50 81.625L54.875 76L54.75 75.25H54.5L21.5 96.75L15.625 97.5L13 95.125L13.375 91.25L14.625 90L24.5 83.125L49.125 69.375L49.5 68.125L49.125 67.5H47.875L43.75 67.25L29.75 66.875L17.625 66.375L5.75 65.75L2.75 65.125L0 61.375L0.25 59.5L2.75 57.875L6.375 58.125L14.25 58.75L26.125 59.5L34.75 60L47.5 61.375H49.5L49.75 60.5L49.125 60L48.625 59.5L36.25 51.25L23 42.5L16 37.375L12.25 34.75L10.375 32.375L9.625 27.125L13 23.375L17.625 23.75L18.75 24L23.375 27.625L33.25 35.25L46.25 44.875L48.125 46.375L49 45.875V45.5L48.125 44.125L41.125 31.375L33.625 18.375L30.25 13L29.375 9.75C29.0417 8.625 28.875 7.375 28.875 6L32.75 0.750006L34.875 0L40.125 0.750006L42.25 2.625L45.5 10L50.625 21.625L58.75 37.375L61.125 42.125L62.375 46.375L62.875 47.75H63.75V47L64.375 38L65.625 27.125L66.875 13.125L67.25 9.125L69.25 4.375L73.125 1.87501L76.125 3.25L78.625 6.875L78.25 9.125L76.875 18.75L73.875 33.875L72 44.125H73.125L74.375 42.75L79.5 36L88.125 25.25L91.875 21L96.375 16.25L99.25 14H104.625L108.5 19.875L106.75 26L101.25 33L96.625 38.875L90 47.75L86 54.875L86.375 55.375H87.25L102.125 52.125L110.25 50.75L119.75 49.125L124.125 51.125L124.625 53.125L122.875 57.375L112.625 59.875L100.625 62.25L82.75 66.5L82.5 66.625L82.75 67L90.75 67.75L94.25 68H102.75L118.5 69.125L122.625 71.875L125 75.125L124.625 77.75L118.25 80.875L109.75 78.875L89.75 74.125L83 72.5H82V73L87.75 78.625L98.125 88L111.25 100.125L111.875 103.125L110.25 105.625L108.5 105.375L97 96.625L92.5 92.75L82.5 84.375H81.875V85.25L84.125 88.625L96.375 107L97 112.625L96.125 114.375L92.875 115.5L89.5 114.875L82.25 104.875L74.875 93.5L68.875 83.375L68.25 83.875L64.625 121.625L63 123.5L59.25 125L56.125 122.625L54.375 118.75Z" fill="#D97757" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<svg width="41" height="41" viewBox="0 0 41 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37.5324 16.8707C37.9808 15.5241 38.1363 14.0974 37.9886 12.6859C37.8409 11.2744 37.3934 9.91076 36.676 8.68622C35.6126 6.83404 33.9882 5.3676 32.0373 4.4985C30.0864 3.62941 27.9098 3.40259 25.8215 3.85078C24.8796 2.7893 23.7219 1.94125 22.4257 1.36341C21.1295 0.785575 19.7249 0.491269 18.3058 0.500197C16.1708 0.495044 14.0893 1.16803 12.3614 2.42214C10.6335 3.67624 9.34853 5.44666 8.6917 7.47815C7.30085 7.76286 5.98686 8.3414 4.8377 9.17505C3.68854 10.0087 2.73073 11.0782 2.02839 12.312C0.956464 14.1591 0.498905 16.2988 0.721698 18.4228C0.944492 20.5467 1.83612 22.5449 3.268 24.1293C2.81966 25.4759 2.66413 26.9026 2.81182 28.3141C2.95951 29.7256 3.40701 31.0892 4.12437 32.3138C5.18791 34.1659 6.8123 35.6322 8.76321 36.5013C10.7141 37.3704 12.8907 37.5973 14.9789 37.1492C15.9208 38.2107 17.0786 39.0587 18.3747 39.6366C19.6709 40.2144 21.0755 40.5087 22.4946 40.4998C24.6307 40.5054 26.7133 39.8321 28.4418 38.5772C30.1704 37.3223 31.4556 35.5506 32.1119 33.5179C33.5027 33.2332 34.8167 32.6547 35.9659 31.821C37.115 30.9874 38.0728 29.9178 38.7752 28.684C39.8458 26.8371 40.3023 24.6979 40.0789 22.5748C39.8556 20.4517 38.9639 18.4544 37.5324 16.8707ZM22.4978 37.8849C20.7443 37.8874 19.0459 37.2733 17.6994 36.1501C17.7601 36.117 17.8666 36.0586 17.936 36.0161L25.9004 31.4156C26.1003 31.3019 26.2663 31.137 26.3813 30.9378C26.4964 30.7386 26.5563 30.5124 26.5549 30.2825V19.0542L29.9213 20.998C29.9389 21.0068 29.9541 21.0198 29.9656 21.0359C29.977 21.052 29.9842 21.0707 29.9867 21.0902V30.3889C29.9842 32.375 29.1946 34.2791 27.7909 35.6841C26.3872 37.0892 24.4838 37.8806 22.4978 37.8849ZM6.39227 31.0064C5.51397 29.4888 5.19742 27.7107 5.49804 25.9832C5.55718 26.0187 5.66048 26.0818 5.73461 26.1244L13.699 30.7248C13.8975 30.8408 14.1233 30.902 14.3532 30.902C14.583 30.902 14.8088 30.8408 15.0073 30.7248L24.731 25.1103V28.9979C24.7321 29.0177 24.7283 29.0376 24.7199 29.0556C24.7115 29.0736 24.6988 29.0893 24.6829 29.1012L16.6317 33.7497C14.9096 34.7416 12.8643 35.0097 10.9447 34.4954C9.02506 33.9811 7.38785 32.7263 6.39227 31.0064ZM4.29707 13.6194C5.17156 12.0998 6.55279 10.9364 8.19885 10.3327C8.19885 10.4013 8.19491 10.5228 8.19491 10.6071V19.808C8.19351 20.0378 8.25334 20.2638 8.36823 20.4629C8.48312 20.6619 8.64893 20.8267 8.84863 20.9404L18.5723 26.5542L15.206 28.4979C15.1894 28.5089 15.1703 28.5155 15.1505 28.5173C15.1307 28.5191 15.1107 28.516 15.0924 28.5082L7.04046 23.8557C5.32135 22.8601 4.06716 21.2235 3.55289 19.3046C3.03862 17.3858 3.30624 15.3413 4.29707 13.6194ZM31.955 20.0556L22.2312 14.4411L25.5976 12.4981C25.6142 12.4872 25.6333 12.4805 25.6531 12.4787C25.6729 12.4769 25.6928 12.4801 25.7111 12.4879L33.7631 17.1364C34.9967 17.849 36.0017 18.8982 36.6606 20.1613C37.3194 21.4244 37.6047 22.849 37.4832 24.2684C37.3617 25.6878 36.8382 27.0432 35.9743 28.1759C35.1103 29.3086 33.9415 30.1717 32.6047 30.6641C32.6047 30.5947 32.6047 30.4733 32.6047 30.3889V21.188C32.6066 20.9586 32.5474 20.7328 32.4332 20.5338C32.319 20.3348 32.154 20.1698 31.955 20.0556ZM35.3055 15.0128C35.2464 14.9765 35.1431 14.9142 35.069 14.8717L27.1045 10.2712C26.906 10.1554 26.6803 10.0943 26.4504 10.0943C26.2206 10.0943 25.9948 10.1554 25.7963 10.2712L16.0726 15.8858V11.9982C16.0715 11.9783 16.0753 11.9585 16.0837 11.9405C16.0921 11.9225 16.1048 11.9068 16.1207 11.8949L24.1719 7.25025C25.4053 6.53903 26.8158 6.19376 28.2383 6.25482C29.6608 6.31589 31.0364 6.78077 32.2044 7.59508C33.3723 8.40939 34.2842 9.53945 34.8334 10.8531C35.3826 12.1667 35.5464 13.6095 35.3055 15.0128ZM14.2424 21.9419L10.8752 19.9981C10.8576 19.9893 10.8423 19.9763 10.8309 19.9602C10.8195 19.9441 10.8122 19.9254 10.8098 19.9058V10.6071C10.8107 9.18295 11.2173 7.78848 11.9819 6.58696C12.7466 5.38544 13.8377 4.42659 15.1275 3.82264C16.4173 3.21869 17.8524 2.99464 19.2649 3.1767C20.6775 3.35876 22.0089 3.93941 23.1034 4.85067C23.0427 4.88379 22.937 4.94215 22.8668 4.98473L14.9024 9.58517C14.7025 9.69878 14.5366 9.86356 14.4215 10.0626C14.3065 10.2616 14.2466 10.4877 14.2479 10.7175L14.2424 21.9419ZM16.071 17.9991L20.4018 15.4978L24.7325 17.9975V22.9985L20.4018 25.4983L16.071 22.9985V17.9991Z" fill="#111111" />
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,10 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 28C14 26.0633 13.6267 24.2433 12.88 22.54C12.1567 20.8367 11.165 19.355 9.905 18.095C8.645 16.835 7.16333 15.8433 5.46 15.12C3.75667 14.3733 1.93667 14 0 14C1.93667 14 3.75667 13.6383 5.46 12.915C7.16333 12.1683 8.645 11.165 9.905 9.905C11.165 8.645 12.1567 7.16333 12.88 5.46C13.6267 3.75667 14 1.93667 14 0C14 1.93667 14.3617 3.75667 15.085 5.46C15.8317 7.16333 16.835 8.645 18.095 9.905C19.355 11.165 20.8367 12.1683 22.54 12.915C24.2433 13.6383 26.0633 14 28 14C26.0633 14 24.2433 14.3733 22.54 15.12C20.8367 15.8433 19.355 16.835 18.095 18.095C16.835 19.355 15.8317 20.8367 15.085 22.54C14.3617 24.2433 14 26.0633 14 28Z" fill="url(#paint0_radial_16771_53212)" />
<defs>
<radialGradient id="paint0_radial_16771_53212" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(2.77876 11.3795) rotate(18.6832) scale(29.8025 238.737)">
<stop offset="0.0671246" stop-color="#9168C0" />
<stop offset="0.342551" stop-color="#5684D1" />
<stop offset="0.672076" stop-color="#1BA1E3" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,11 @@
<svg width="1200" height="1200" viewBox="0 0 1200 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1200" height="1200" rx="260" fill="#9046FF" />
<mask id="mask0_1106_4856" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="272" y="202" width="655" height="796">
<path d="M926.578 202.793H272.637V997.857H926.578V202.793Z" fill="white" />
</mask>
<g mask="url(#mask0_1106_4856)">
<path d="M398.554 818.914C316.315 1001.03 491.477 1046.74 620.672 940.156C658.687 1059.66 801.052 970.473 852.234 877.795C964.787 673.567 919.318 465.357 907.64 422.374C827.637 129.443 427.623 128.946 358.8 423.865C342.651 475.544 342.402 534.18 333.458 595.051C328.986 625.86 325.507 645.488 313.83 677.785C306.873 696.424 297.68 712.819 282.773 740.645C259.915 783.881 269.604 867.113 387.87 823.883L399.051 818.914H398.554Z" fill="white" />
<path d="M636.123 549.353C603.328 549.353 598.359 510.097 598.359 486.742C598.359 465.623 602.086 448.977 609.293 438.293C615.504 428.852 624.697 424.131 636.123 424.131C647.555 424.131 657.492 428.852 664.447 438.541C672.398 449.474 676.623 466.12 676.623 486.742C676.623 525.998 661.471 549.353 636.375 549.353H636.123Z" fill="black" />
<path d="M771.24 549.353C738.445 549.353 733.477 510.097 733.477 486.742C733.477 465.623 737.203 448.977 744.41 438.293C750.621 428.852 759.814 424.131 771.24 424.131C782.672 424.131 792.609 428.852 799.564 438.541C807.516 449.474 811.74 466.12 811.74 486.742C811.74 525.998 796.588 549.353 771.492 549.353H771.24Z" fill="black" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,18 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(30, 0)">
<g clip-path="url(#clip0_1401_86274)">
<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
<path d="M240 0H0V300H240V0Z" fill="white" />
</mask>
<g mask="url(#mask0_1401_86274)">
<path d="M180 240H60V120H180V240Z" fill="#CFCECD" />
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E" />
</g>
</g>
</g>
<defs>
<clipPath id="clip0_1401_86274">
<rect width="240" height="300" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 713 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" fill="none">
<g clip-path="url(#a)">
<path fill="#FAF3EA" d="M-14-14h208v208H-14z" />
<path fill="#038247" d="M121.084 57.922c0 7.408 6.061 13.469 13.469 13.469 7.408 0 13.469-6.061 13.469-13.47 0-7.407-6.061-13.468-13.469-13.468-7.408 0-13.469 6.06-13.469 13.469Zm-5.657 81.622v-.135c0-2.828 1.617-4.579 5.388-5.792 3.771-1.347 4.714-2.289 4.714-7.273v-24.648c0-4.984-1.347-5.657-5.118-6.87-3.098-.942-4.984-2.424-4.984-4.713 0-1.886 1.213-3.368 2.963-4.041l19.531-7.004c4.31-1.616 7.677.539 7.677 4.445v42.831c0 4.984.943 5.926 4.714 7.273 3.771 1.213 5.388 2.964 5.388 5.792v.135c0 2.694-2.155 4.579-5.388 4.579h-29.497c-3.233 0-5.388-1.885-5.388-4.579ZM65.053 68.427c0-17.24 2.425-22.897 9.832-22.897 9.025 0 14.278 8.755 14.278 23.706 0 14.95-5.119 23.974-13.47 23.974-8.35 0-10.64-6.6-10.64-24.783ZM27.205 139.41v.135c0 2.694 2.155 4.579 5.388 4.579H76.77c3.233 0 5.388-1.885 5.388-4.579v-.135c0-2.559-1.482-4.445-5.253-5.253-9.428-2.155-10.64-3.098-10.64-8.351v-19.26c0-2.425 1.077-4.041 3.367-4.041s4.175 1.078 8.35 1.078c21.82 0 37.31-14.547 37.31-35.02 0-20.473-14.547-33.672-35.29-33.672-11.313 0-19.664 4.175-30.574 4.175H32.593c-3.233 0-5.388 1.886-5.388 4.58v.134c0 2.829 1.616 4.58 5.388 5.522 6.734 1.617 7.677 2.56 7.677 7.543v69.5c0 4.984-.943 5.926-7.677 7.543-3.772.943-5.388 2.694-5.388 5.522Z" />
</g>
<defs>
<clipPath id="a">
<rect width="180" height="180" fill="#fff" rx="45" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -21,7 +21,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
log = "0.4"
notify = "6.1"
tauri = { version = "2.10.0", features = ["protocol-asset", "devtools", "image-png"] }
tauri = { version = "2.10.0", features = ["macos-private-api", "protocol-asset", "devtools", "image-png"] }
tauri-plugin-log = "2"
gray_matter = "0.2"
walkdir = "2"

View File

@@ -73,6 +73,8 @@ pub struct AiAgentStreamRequest {
#[serde(default)]
pub vault_paths: Vec<String>,
pub permission_mode: Option<AiAgentPermissionMode>,
#[serde(default)]
pub event_name: Option<String>,
}
impl AiAgentStreamRequest {
@@ -260,6 +262,7 @@ mod tests {
vault_path: "/tmp/vault".into(),
vault_paths: Vec::new(),
permission_mode,
event_name: None,
}
}

View File

@@ -62,6 +62,8 @@ pub struct AiModelStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub api_key_override: Option<String>,
#[serde(default)]
pub event_name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -181,6 +183,7 @@ pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<Str
.into(),
),
api_key_override: normalize_optional_string(request.api_key_override),
event_name: None,
};
send_model_message(&request)
}
@@ -535,6 +538,7 @@ mod tests {
message: "Hello".into(),
system_prompt: Some(" Be concise. ".into()),
api_key_override: None,
event_name: None,
}
}

View File

@@ -16,7 +16,7 @@ const AGENT_DOCS_RESOURCE_DIR: &str = "agent-docs";
#[cfg(desktop)]
async fn run_desktop_stream<Event, Request, Runner>(
app_handle: tauri::AppHandle,
event_name: &'static str,
event_name: String,
request: Request,
runner: Runner,
) -> Result<String, String>
@@ -31,7 +31,7 @@ where
runner(
request,
Box::new(move |event| {
let _ = app_handle.emit(event_name, &event);
let _ = app_handle.emit(event_name.as_str(), &event);
}),
)
})
@@ -47,11 +47,32 @@ macro_rules! define_desktop_stream_command {
app_handle: tauri::AppHandle,
request: $request,
) -> Result<String, String> {
run_desktop_stream(app_handle, $event_name, request, $runner).await
run_desktop_stream(app_handle, $event_name.to_string(), request, $runner).await
}
};
}
#[cfg(desktop)]
fn is_scoped_stream_event_name(default_event_name: &str, event_name: &str) -> bool {
event_name
.strip_prefix(default_event_name)
.and_then(|suffix| suffix.strip_prefix('-'))
.is_some_and(|suffix| {
!suffix.is_empty()
&& suffix
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
})
}
#[cfg(desktop)]
fn stream_event_name(default_event_name: &'static str, requested: Option<&str>) -> String {
requested
.filter(|event_name| is_scoped_stream_event_name(default_event_name, event_name))
.unwrap_or(default_event_name)
.to_string()
}
// ── Claude CLI commands (desktop) ───────────────────────────────────────────
#[cfg(desktop)]
@@ -135,20 +156,36 @@ fn run_normalized_ai_agent_stream(
}
#[cfg(desktop)]
define_desktop_stream_command!(
stream_ai_agent,
AiAgentStreamRequest,
"ai-agent-stream",
run_normalized_ai_agent_stream
);
#[tauri::command]
pub async fn stream_ai_agent(
app_handle: tauri::AppHandle,
request: AiAgentStreamRequest,
) -> Result<String, String> {
let event_name = stream_event_name("ai-agent-stream", request.event_name.as_deref());
run_desktop_stream(
app_handle,
event_name,
request,
run_normalized_ai_agent_stream,
)
.await
}
#[cfg(desktop)]
define_desktop_stream_command!(
stream_ai_model,
AiModelStreamRequest,
"ai-model-stream",
crate::ai_models::run_ai_model_stream
);
#[tauri::command]
pub async fn stream_ai_model(
app_handle: tauri::AppHandle,
request: AiModelStreamRequest,
) -> Result<String, String> {
let event_name = stream_event_name("ai-model-stream", request.event_name.as_deref());
run_desktop_stream(
app_handle,
event_name,
request,
crate::ai_models::run_ai_model_stream,
)
.await
}
#[cfg(desktop)]
#[tauri::command]
@@ -280,6 +317,7 @@ mod tests {
vault_path: "~/Vaults/content".into(),
vault_paths: vec!["~/Vaults/secondary".into()],
permission_mode: None,
event_name: None,
};
let normalized = normalize_agent_request(request);
@@ -308,6 +346,7 @@ mod tests {
vault_path: "/Users/example/vault".into(),
vault_paths: Vec::new(),
permission_mode: None,
event_name: None,
};
let normalized = normalize_agent_request(request);
@@ -315,6 +354,23 @@ mod tests {
assert_eq!(normalized.vault_path, "/Users/example/vault");
}
#[cfg(desktop)]
#[test]
fn stream_event_name_accepts_only_scoped_names() {
assert_eq!(
stream_event_name("ai-agent-stream", Some("ai-agent-stream-chat-123")),
"ai-agent-stream-chat-123",
);
assert_eq!(
stream_event_name("ai-agent-stream", Some("ai-model-stream-chat-123")),
"ai-agent-stream",
);
assert_eq!(
stream_event_name("ai-agent-stream", Some("ai-agent-stream/../bad")),
"ai-agent-stream",
);
}
#[test]
fn guidance_commands_report_and_restore_vault_guidance_files() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -28,8 +28,8 @@ fn runtime_resource_roots_for_env_and_exe(
if let Some(resource_path) = resource_path {
push_resource_root(&mut roots, resource_path);
}
if let Some(resource_dir) = current_exe.and_then(macos_app_resources_dir) {
push_resource_root(&mut roots, resource_dir);
if let Some(current_exe) = current_exe {
push_current_exe_resource_roots(&mut roots, current_exe);
}
if let Some(appdir) = appdir {
push_resource_root(&mut roots, appdir.join("usr"));
@@ -44,6 +44,18 @@ fn runtime_resource_roots_for_env_and_exe(
roots
}
fn push_current_exe_resource_roots(roots: &mut Vec<PathBuf>, current_exe: &Path) {
let Some(exe_dir) = current_exe.parent() else {
return;
};
push_resource_root(roots, exe_dir.to_path_buf());
push_resource_root(roots, exe_dir.join("resources"));
if let Some(resource_dir) = macos_app_resources_dir(current_exe) {
push_resource_root(roots, resource_dir);
}
}
fn macos_app_resources_dir(executable: &Path) -> Option<PathBuf> {
let macos_dir = executable.parent()?;
if macos_dir.file_name() != Some(OsStr::new("MacOS")) {
@@ -102,5 +114,11 @@ mod tests {
assert!(roots.contains(&PathBuf::from(
"/Applications/Tolaria.app/Contents/Resources"
)));
let candidates =
super::super::mcp_server_dir_candidates(Path::new("/repo/mcp-server"), &roots);
assert!(candidates.contains(&PathBuf::from(
"/Applications/Tolaria.app/Contents/Resources/mcp-server"
)));
}
}

View File

@@ -11,6 +11,7 @@
},
"app": {
"withGlobalTauri": true,
"macOSPrivateApi": true,
"windows": [
{
"title": "Tolaria",

View File

@@ -13,6 +13,7 @@ import { CommitDialog } from './components/CommitDialog'
import { PulseView } from './components/PulseView'
import { StatusBar } from './components/StatusBar'
import { AppAiWorkspaceSurface } from './components/AppAiWorkspaceSurface'
import { AiWorkspaceFloatingButton } from './components/AiWorkspaceFloatingButton'
import { AiWorkspaceWindowApp } from './components/AiWorkspaceWindowApp'
import { SettingsPanel } from './components/SettingsPanel'
import { CloneVaultModal } from './components/CloneVaultModal'
@@ -134,7 +135,14 @@ import { useStartupScreenState } from './hooks/useStartupScreenState'
import { useGitFileWorkflows } from './hooks/useGitFileWorkflows'
import { useAutoGitWork } from './hooks/useAutoGitWork'
import { useAppAiWorkspaceBridge } from './hooks/useAppAiWorkspaceBridge'
import { publishAiWorkspaceWindowSharedContext } from './lib/aiWorkspaceWindowSharedContext'
import type { AiWorkspaceWindowContext } from './utils/openAiWorkspaceWindow'
import {
AI_WORKSPACE_FILE_CREATED_EVENT,
AI_WORKSPACE_FILE_MODIFIED_EVENT,
AI_WORKSPACE_OPEN_NOTE_REQUESTED_EVENT,
AI_WORKSPACE_VAULT_CHANGED_EVENT,
} from './utils/aiPromptBridge'
import './App.css'
const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror'
@@ -364,14 +372,12 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
? onboarding.state.vaultPath
: vaultSwitcher.vaultPath
)
const aiWorkspaceWindowContext = aiWorkspaceWindowContextForPath(resolvedPath)
const aiWorkspaceWindowContext = useMemo(() => aiWorkspaceWindowContextForPath(resolvedPath), [resolvedPath])
const [settingsInitialSectionId, setSettingsInitialSectionId] = useState<string | null>(null)
const {
effectiveShowAIChat,
handleDockCurrentAiWorkspaceWindow,
handleOpenAiSettings,
handleOpenDockedAiWorkspace,
handlePopOutAiWorkspace,
} = useAppAiWorkspaceBridge({
aiFeaturesEnabled,
aiWorkspaceWindow,
@@ -380,8 +386,18 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
openSettings: dialogs.openSettings,
setSettingsInitialSectionId,
showAIChat,
windowContext: aiWorkspaceWindowContext,
})
const handleToggleAiWorkspace = useCallback(() => {
if (effectiveShowAIChat) {
closeAIChat()
return
}
handleOpenDockedAiWorkspace()
}, [closeAIChat, effectiveShowAIChat, handleOpenDockedAiWorkspace])
const [lastAiWorkspaceConversationId, setLastAiWorkspaceConversationId] = useState<string | null>(null)
const handleActiveAiWorkspaceConversationChange = useCallback((id: string) => {
setLastAiWorkspaceConversationId(id)
}, [])
const {
folderVaults,
graphDefaultWorkspacePath,
@@ -788,6 +804,52 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
activeTabPath: notes.activeTabPath,
getActiveTabPath: () => notes.activeTabPathRef.current,
})
const handleAiWorkspaceWindowOpenNote = notes.handleNavigateWikilink
const {
handleAgentFileCreated: handleAiWorkspaceWindowFileCreated,
handleAgentFileModified: handleAiWorkspaceWindowFileModified,
handleAgentVaultChanged: handleAiWorkspaceWindowVaultChanged,
} = vaultBridge
useEffect(() => {
if (!isTauri()) return
let disposed = false
let unlisteners: Array<() => void> = []
void import('@tauri-apps/api/event')
.then(({ listen }) => Promise.all([
listen<string>(AI_WORKSPACE_OPEN_NOTE_REQUESTED_EVENT, (event) => {
if (typeof event.payload === 'string') handleAiWorkspaceWindowOpenNote(event.payload)
}),
listen<string>(AI_WORKSPACE_FILE_CREATED_EVENT, (event) => {
if (typeof event.payload === 'string') handleAiWorkspaceWindowFileCreated(event.payload)
}),
listen<string>(AI_WORKSPACE_FILE_MODIFIED_EVENT, (event) => {
if (typeof event.payload === 'string') handleAiWorkspaceWindowFileModified(event.payload)
}),
listen(AI_WORKSPACE_VAULT_CHANGED_EVENT, () => {
handleAiWorkspaceWindowVaultChanged()
}),
]))
.then((nextUnlisteners) => {
if (disposed) {
nextUnlisteners.forEach((unlisten) => unlisten())
return
}
unlisteners = nextUnlisteners
})
.catch(() => undefined)
return () => {
disposed = true
unlisteners.forEach((unlisten) => unlisten())
}
}, [
handleAiWorkspaceWindowFileCreated,
handleAiWorkspaceWindowFileModified,
handleAiWorkspaceWindowOpenNote,
handleAiWorkspaceWindowVaultChanged,
])
const conflictFlow = useConflictFlow({
resolvedPath: autoSync.conflictVaultPath ?? graphDefaultWorkspacePath,
@@ -1759,11 +1821,64 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
if (effectiveSelection.kind === 'entity') return { type: null, query: effectiveSelection.entry.title }
return { type: null, query: '' }
}, [effectiveSelection])
useEffect(() => {
publishAiWorkspaceWindowSharedContext({
...aiWorkspaceWindowContext,
activeEntry: activeTab?.entry ?? null,
activeNoteContent: activeTab?.content ?? null,
entries: visibleEntries,
openTabs: notes.tabs.map((tab) => tab.entry),
noteList: aiNoteList,
noteListFilter: aiNoteListFilter,
})
}, [
activeTab?.content,
activeTab?.entry,
aiNoteList,
aiNoteListFilter,
aiWorkspaceWindowContext,
notes.tabs,
visibleEntries,
])
const handleAiWorkspaceConversationsChange = useCallback((conversations: AiWorkspaceConversationSetting[]) => {
void saveSettings({ ...settings, ai_workspace_conversations: conversations })
}, [saveSettings, settings])
const aiWorkspaceSurface = <AppAiWorkspaceSurface mode={aiWorkspaceWindow ? 'window' : 'docked'} open={aiWorkspaceWindow || effectiveShowAIChat} aiAgentsStatus={aiAgentsStatus} aiModelProviders={settings.ai_model_providers ?? []} conversationSettings={settings.ai_workspace_conversations ?? null} conversationSettingsReady={settingsLoaded} defaultAiAgent={aiAgentPreferences.defaultAiAgent} defaultAiTarget={aiAgentPreferences.defaultAiTarget} defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness} defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady} activeEntry={activeTab?.entry ?? null} activeNoteContent={activeTab?.content ?? null} entries={visibleEntries} openTabs={notes.tabs.map((tab) => tab.entry)} noteList={aiNoteList} noteListFilter={aiNoteListFilter} onClose={aiWorkspaceWindow ? handleDockCurrentAiWorkspaceWindow : closeAIChat} onConversationSettingsChange={handleAiWorkspaceConversationsChange} onDock={aiWorkspaceWindow ? handleDockCurrentAiWorkspaceWindow : undefined} onPopOut={aiWorkspaceWindow ? undefined : handlePopOutAiWorkspace} onOpenAiSettings={aiWorkspaceWindow ? undefined : handleOpenAiSettings} onOpenNote={notes.handleNavigateWikilink} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} onUnsupportedAiPaste={setToastMessage} onFileCreated={vaultBridge.handleAgentFileCreated} onFileModified={vaultBridge.handleAgentFileModified} onVaultChanged={vaultBridge.handleAgentVaultChanged} vaultAiGuidanceStatus={vaultAiGuidanceStatus} vaultPath={activeEditorVaultPath} vaultPaths={writableVaultPaths} locale={appLocale} />
const aiWorkspaceSurface = (
<AppAiWorkspaceSurface
mode="side"
open={effectiveShowAIChat}
aiAgentsStatus={aiAgentsStatus}
aiModelProviders={settings.ai_model_providers ?? []}
conversationSettings={settings.ai_workspace_conversations ?? null}
conversationSettingsReady={settingsLoaded}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiTarget={aiAgentPreferences.defaultAiTarget}
defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
initialActiveConversationId={lastAiWorkspaceConversationId ?? undefined}
activeEntry={activeTab?.entry ?? null}
activeNoteContent={activeTab?.content ?? null}
entries={visibleEntries}
openTabs={notes.tabs.map((tab) => tab.entry)}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onActiveConversationChange={handleActiveAiWorkspaceConversationChange}
onClose={closeAIChat}
onConversationSettingsChange={handleAiWorkspaceConversationsChange}
onOpenAiSettings={handleOpenAiSettings}
onOpenNote={notes.handleNavigateWikilink}
onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined}
onUnsupportedAiPaste={setToastMessage}
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
vaultPath={activeEditorVaultPath}
vaultPaths={writableVaultPaths}
locale={appLocale}
/>
)
if (shouldShowStartupScreen) {
return (
<StartupScreen
@@ -1854,7 +1969,8 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
onChangeWorkspace={activeDeletedFile ? undefined : handleChangeWorkspace}
onInitializeProperties={handleInitializeProperties}
showAIChat={effectiveShowAIChat}
onToggleAIChat={aiFeaturesEnabled ? dialogs.toggleAIChat : undefined}
onToggleAIChat={aiFeaturesEnabled ? handleToggleAiWorkspace : undefined}
aiWorkspaceSurface={aiWorkspaceSurface}
vaultPath={activeEditorVaultPath}
vaultPaths={writableVaultPaths}
noteList={aiNoteList}
@@ -1895,11 +2011,21 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
locale={appLocale}
/>
</div>
{effectiveShowAIChat && aiWorkspaceSurface}
</div>
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onOpenAiWorkspace={aiFeaturesEnabled ? handleOpenDockedAiWorkspace : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onOpenAiWorkspace={aiFeaturesEnabled ? handleToggleAiWorkspace : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
{aiFeaturesEnabled && !effectiveShowAIChat ? (
<AiWorkspaceFloatingButton
statuses={aiAgentsStatus}
guidanceStatus={vaultAiGuidanceStatus}
defaultAgent={aiAgentPreferences.defaultAiAgent}
defaultTarget={settings.default_ai_target ?? undefined}
providers={settings.ai_model_providers ?? []}
locale={appLocale}
onOpen={handleToggleAiWorkspace}
/>
) : null}
<GitSetupDialog open={gitFeaturesEnabled && shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} onNeverForVault={neverForVaultGitSetupDialog} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />

View File

@@ -0,0 +1,55 @@
import type { CSSProperties } from 'react'
import { cn } from '@/lib/utils'
import { getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents'
interface AiAgentIconProps {
agent: AiAgentId
className?: string
size?: number
title?: string
}
const ICON_STYLE: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flex: '0 0 auto',
overflow: 'hidden',
}
const AI_AGENT_ICON_SOURCES: Record<AiAgentId, string> = {
claude_code: '/ai-agent-icons/claude-code.svg',
codex: '/ai-agent-icons/codex.svg',
opencode: '/ai-agent-icons/opencode.svg',
pi: '/ai-agent-icons/pi.svg',
gemini: '/ai-agent-icons/gemini.svg',
kiro: '/ai-agent-icons/kiro.svg',
}
export function AiAgentIcon({
agent,
className,
size = 16,
title,
}: AiAgentIconProps) {
const label = title ?? getAiAgentDefinition(agent).label
return (
<span
className={cn('rounded-[5px]', className)}
style={{ ...ICON_STYLE, width: size, height: size }}
role={title ? 'img' : undefined}
aria-label={title ? label : undefined}
aria-hidden={title ? undefined : true}
>
<img
src={AI_AGENT_ICON_SOURCES[agent]}
alt=""
draggable={false}
width={size}
height={size}
style={{ display: 'block', width: size, height: size, objectFit: 'contain' }}
/>
</span>
)
}

View File

@@ -11,12 +11,14 @@ const missingStatuses: AiAgentStatuses = {
opencode: { status: 'missing', version: null },
pi: { status: 'missing', version: null },
gemini: { status: 'missing', version: null },
kiro: { status: 'missing', version: null },
}
const missingAgentInstallTestIds = [
'ai-agents-onboarding-install-codex',
'ai-agents-onboarding-install-opencode',
'ai-agents-onboarding-install-pi',
'ai-agents-onboarding-install-gemini',
'ai-agents-onboarding-install-kiro',
] as const
const installLinkTargets = [
['ai-agents-onboarding-install-claude_code', 'https://docs.anthropic.com/en/docs/claude-code'],
@@ -24,6 +26,7 @@ const installLinkTargets = [
['ai-agents-onboarding-install-opencode', 'https://opencode.ai/docs/'],
['ai-agents-onboarding-install-pi', 'https://pi.dev'],
['ai-agents-onboarding-install-gemini', 'https://google-gemini.github.io/gemini-cli/'],
['ai-agents-onboarding-install-kiro', 'https://kiro.dev/docs/cli'],
] as const
vi.mock('../utils/url', () => ({

View File

@@ -8,6 +8,7 @@ import {
type AiAgentsStatus,
} from '../lib/aiAgents'
import { openExternalUrl } from '../utils/url'
import { AiAgentIcon } from './AiAgentIcon'
import { OnboardingShell } from './OnboardingShell'
import { Button } from './ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
@@ -89,12 +90,15 @@ function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) {
key={definition.id}
className="flex items-center justify-between rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm"
>
<div className="space-y-1 text-left">
<div className="font-medium text-foreground">{definition.label}</div>
<div className="text-xs text-muted-foreground">
{ready
? `${definition.label}${status.version ? ` ${status.version}` : ''} is ready.`
: `${definition.label} is not installed yet.`}
<div className="flex min-w-0 items-start gap-3 text-left">
<AiAgentIcon agent={definition.id} size={18} className="mt-0.5" />
<div className="min-w-0 space-y-1">
<div className="font-medium text-foreground">{definition.label}</div>
<div className="text-xs text-muted-foreground">
{ready
? `${definition.label}${status.version ? ` ${status.version}` : ''} is ready.`
: `${definition.label} is not installed yet.`}
</div>
</div>
</div>
<span
@@ -169,6 +173,7 @@ export function AiAgentsOnboardingPrompt({
onClick={() => void openExternalUrl(getAiAgentDefinition(definition.id).installUrl)}
data-testid={`ai-agents-onboarding-install-${definition.id}`}
>
<AiAgentIcon agent={definition.id} size={16} />
Install {definition.label}
<ArrowUpRight className="size-4" />
</Button>

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiMessage } from './AiMessage'
@@ -6,10 +6,22 @@ vi.mock('./MarkdownContent', () => ({
MarkdownContent: ({ content }: { content: string }) => <div data-testid="markdown-content">{content}</div>,
}))
const writeClipboardText = vi.fn()
vi.mock('../utils/clipboardText', () => ({
writeClipboardText: (text: string) => writeClipboardText(text),
}))
describe('AiMessage', () => {
beforeEach(() => {
writeClipboardText.mockReset()
writeClipboardText.mockResolvedValue(undefined)
})
it('renders user message', () => {
render(<AiMessage userMessage="Hello AI" actions={[]} />)
const { container } = render(<AiMessage userMessage="Hello AI" actions={[]} />)
expect(screen.getByText('Hello AI')).toBeTruthy()
expect(container.querySelector('[style*="background: var(--state-hover)"]')).toBeTruthy()
})
it('renders response as markdown', () => {
@@ -18,9 +30,12 @@ describe('AiMessage', () => {
expect(screen.getByText('Here is the **answer**')).toBeTruthy()
})
it('shows undo button with response', () => {
it('shows assistant message actions with response', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Done" />)
expect(screen.getByTestId('undo-button')).toBeTruthy()
expect(screen.getByTestId('ai-message-actions')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Regenerate response' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Copy response' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Fork chat from here' })).toBeTruthy()
})
it('shows reasoning expanded while streaming (reasoningDone=false)', () => {
@@ -52,7 +67,7 @@ describe('AiMessage', () => {
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('renders action cards', () => {
it('collapses tool use by default and shows the live call count', () => {
render(
<AiMessage
userMessage="Do something"
@@ -62,6 +77,14 @@ describe('AiMessage', () => {
]}
/>,
)
expect(screen.getByTestId('tool-use-toggle')).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByTestId('tool-use-count').textContent).toBe('2')
expect(screen.getByTestId('tool-use-count')).toHaveAttribute('data-pending', 'true')
expect(screen.queryByTestId('ai-action-card')).toBeNull()
fireEvent.click(screen.getByTestId('tool-use-toggle'))
expect(screen.getByTestId('tool-use-toggle')).toHaveAttribute('aria-expanded', 'true')
expect(screen.getAllByTestId('ai-action-card')).toHaveLength(2)
})
@@ -74,6 +97,7 @@ describe('AiMessage', () => {
onOpenNote={onOpenNote}
/>,
)
fireEvent.click(screen.getByTestId('tool-use-toggle'))
fireEvent.click(screen.getByTestId('action-card-header'))
expect(onOpenNote).toHaveBeenCalledWith('/vault/note.md')
})
@@ -92,6 +116,29 @@ describe('AiMessage', () => {
expect(container.querySelector('.typing-dot')).toBeNull()
})
it('runs assistant message actions', () => {
const onRegenerate = vi.fn()
const onFork = vi.fn()
render(
<AiMessage
userMessage="Ask"
actions={[]}
messageId="message-1"
response="Done"
onFork={onFork}
onRegenerate={onRegenerate}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Regenerate response' }))
fireEvent.click(screen.getByRole('button', { name: 'Copy response' }))
fireEvent.click(screen.getByRole('button', { name: 'Fork chat from here' }))
expect(onRegenerate).toHaveBeenCalledWith('message-1')
expect(writeClipboardText).toHaveBeenCalledWith('Done')
expect(onFork).toHaveBeenCalledWith('message-1')
})
it('does not render reasoning block when no reasoning', () => {
render(<AiMessage userMessage="Ask" actions={[]} />)
expect(screen.queryByTestId('reasoning-toggle')).toBeNull()
@@ -153,6 +200,7 @@ describe('AiMessage', () => {
]}
/>,
)
fireEvent.click(screen.getByTestId('tool-use-toggle'))
const headers = screen.getAllByTestId('action-card-header')
// Both collapsed initially
expect(screen.queryByTestId('action-card-details')).toBeNull()

View File

@@ -1,8 +1,11 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react'
import { CaretRight, CaretDown, Brain, ArrowsClockwise, Copy, GitBranch, Terminal } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { AiActionCard, type AiActionStatus } from './AiActionCard'
import { MarkdownContent } from './MarkdownContent'
import { translate, type AppLocale } from '../lib/i18n'
import type { NoteReference } from '../utils/ai-context'
import { writeClipboardText } from '../utils/clipboardText'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
export interface AiAction {
@@ -19,13 +22,17 @@ export interface AiMessageProps {
userMessage: string
references?: NoteReference[]
localMarker?: string
locale?: AppLocale
messageId?: string
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
response?: string
isStreaming?: boolean
onFork?: (messageId: string) => void
onOpenNote?: (path: string) => void
onNavigateWikilink?: (target: string) => void
onRegenerate?: (messageId: string) => void
}
function LocalMarker({ text }: { text: string }) {
@@ -44,8 +51,9 @@ function ReferencePill({ reference, onClick }: {
reference: NoteReference
onClick?: (path: string) => void
}) {
const color = getTypeColor(reference.type)
const lightColor = getTypeLightColor(reference.type)
const type = reference.type ?? null
const color = getTypeColor(type)
const lightColor = getTypeLightColor(type)
return (
<button type="button"
className="inline-flex items-center border-none cursor-pointer transition-opacity hover:opacity-80"
@@ -76,7 +84,7 @@ function UserBubble({ content, references, onOpenNote }: {
<div className="flex justify-end" style={{ marginBottom: 8 }}>
<div
style={{
background: 'var(--muted)',
background: 'var(--state-hover)',
color: 'var(--foreground)',
borderRadius: '12px 12px 2px 12px',
maxWidth: '85%',
@@ -162,25 +170,166 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
)
}
function ResponseBlock({ text, onNavigateWikilink }: { text: string; onNavigateWikilink?: (target: string) => void }) {
function ToolUseBlock({
actions,
expanded,
expandedActionIds,
onOpenNote,
onToggle,
onToggleAction,
}: {
actions: AiAction[]
expanded: boolean
expandedActionIds: Set<string>
onOpenNote?: (path: string) => void
onToggle: () => void
onToggleAction: (toolId: string) => void
}) {
const pending = actions.some((action) => action.status === 'pending')
return (
<div style={{ marginBottom: 4 }}>
<MarkdownContent content={text} onWikilinkClick={onNavigateWikilink} />
<button type="button"
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
style={{ fontSize: 11, marginTop: 4 }}
data-testid="undo-button"
<div style={{ marginBottom: 8 }}>
<button
type="button"
className="flex w-full cursor-pointer items-center gap-1.5 border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground"
style={{ fontSize: 12, padding: '4px 0' }}
aria-expanded={expanded}
onClick={onToggle}
data-testid="tool-use-toggle"
>
<ArrowCounterClockwise size={12} />
<span>Undo</span>
<Terminal size={14} />
<span>Tool use</span>
<span
className={`inline-flex h-4 min-w-4 items-center justify-center rounded-full ${pending ? 'animate-pulse' : ''}`}
style={{
background: 'var(--state-hover)',
color: 'var(--muted-foreground)',
fontSize: 10,
fontWeight: 600,
padding: '0 5px',
}}
data-pending={pending || undefined}
data-testid="tool-use-count"
>
{actions.length}
</span>
{expanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
</button>
{expanded && (
<div data-testid="tool-use-content" style={{ marginTop: 4 }}>
<ActionCardsList
actions={actions}
onOpenNote={onOpenNote}
expandedIds={expandedActionIds}
onToggleExpand={onToggleAction}
/>
</div>
)}
</div>
)
}
function ResponseActions({
locale,
messageId,
onCopy,
onFork,
onRegenerate,
}: {
locale: AppLocale
messageId?: string
onCopy: () => void
onFork?: (messageId: string) => void
onRegenerate?: (messageId: string) => void
}) {
const regenerateDisabled = !messageId || !onRegenerate
const forkDisabled = !messageId || !onFork
return (
<div
className="mt-1.5 flex items-center gap-1 opacity-0 transition-opacity group-hover/ai-response:opacity-100 group-focus-within/ai-response:opacity-100"
data-testid="ai-message-actions"
>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 rounded-md p-0 text-muted-foreground hover:text-foreground"
disabled={regenerateDisabled}
aria-label={translate(locale, 'ai.message.regenerate')}
title={translate(locale, 'ai.message.regenerate')}
onClick={() => messageId && onRegenerate?.(messageId)}
data-testid="ai-message-regenerate"
>
<ArrowsClockwise size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 rounded-md p-0 text-muted-foreground hover:text-foreground"
aria-label={translate(locale, 'ai.message.copy')}
title={translate(locale, 'ai.message.copy')}
onClick={onCopy}
data-testid="ai-message-copy"
>
<Copy size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 rounded-md p-0 text-muted-foreground hover:text-foreground"
disabled={forkDisabled}
aria-label={translate(locale, 'ai.message.fork')}
title={translate(locale, 'ai.message.fork')}
onClick={() => messageId && onFork?.(messageId)}
data-testid="ai-message-fork"
>
<GitBranch size={14} />
</Button>
</div>
)
}
function ResponseBlock({
locale,
messageId,
onFork,
onNavigateWikilink,
onRegenerate,
text,
}: {
locale: AppLocale
messageId?: string
onFork?: (messageId: string) => void
onNavigateWikilink?: (target: string) => void
onRegenerate?: (messageId: string) => void
text: string
}) {
const handleCopy = useCallback(() => {
void writeClipboardText(text).catch((error) => {
console.warn('[ai] Failed to copy assistant message:', error)
})
}, [text])
return (
<div className="group/ai-response" style={{ marginBottom: 4 }}>
<MarkdownContent content={text} onWikilinkClick={onNavigateWikilink} />
<ResponseActions
locale={locale}
messageId={messageId}
onCopy={handleCopy}
onFork={onFork}
onRegenerate={onRegenerate}
/>
</div>
)
}
function StreamingIndicator() {
return (
<div className="flex items-center gap-2 text-muted-foreground" style={{ fontSize: 12, padding: '4px 0' }}>
<div className="flex items-center gap-2 text-muted-foreground" style={{ fontSize: 12, marginTop: 8, padding: 0 }}>
<div className="flex gap-1">
<span className="typing-dot" />
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
@@ -198,10 +347,11 @@ export function AiMessage(props: AiMessageProps) {
return <ConversationMessage {...props} />
}
function ConversationMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
function ConversationMessage({ userMessage, references, locale = 'en', messageId, reasoning, reasoningDone, actions, response, isStreaming, onFork, onOpenNote, onNavigateWikilink, onRegenerate }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
const [toolUseExpanded, setToolUseExpanded] = useState(false)
// Auto: expanded while reasoning streams, collapsed once done
// User can manually toggle to override the auto state
@@ -228,14 +378,25 @@ function ConversationMessage({ userMessage, references, reasoning, reasoningDone
/>
)}
{actions.length > 0 && (
<ActionCardsList
<ToolUseBlock
actions={actions}
expanded={toolUseExpanded}
expandedActionIds={expandedActions}
onOpenNote={onOpenNote}
expandedIds={expandedActions}
onToggleExpand={toggleAction}
onToggle={() => setToolUseExpanded((current) => !current)}
onToggleAction={toggleAction}
/>
)}
{response && (
<ResponseBlock
locale={locale}
messageId={messageId}
text={response}
onFork={onFork}
onNavigateWikilink={onNavigateWikilink}
onRegenerate={onRegenerate}
/>
)}
{response && <ResponseBlock text={response} onNavigateWikilink={onNavigateWikilink} />}
{isStreaming && !response && <StreamingIndicator />}
</div>
)

View File

@@ -243,19 +243,19 @@ describe('AiPanel', () => {
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
expect(screen.getByText('Ask Claude Code about this note and its linked context')).toBeTruthy()
expect(screen.getByText('Ask anything to Claude Code')).toBeTruthy()
})
it('shows context bar with active entry title', () => {
it('does not render a context bar for the active entry', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
expect(screen.getByTestId('context-bar')).toBeTruthy()
expect(screen.getByText('My Note')).toBeTruthy()
expect(screen.queryByTestId('context-bar')).toBeNull()
expect(screen.queryByText('My Note')).toBeNull()
})
it('shows linked count in context bar when entry has outgoing links', () => {
it('does not show linked count in a sub-header', () => {
const linked = makeEntry({ path: '/vault/linked.md', title: 'Linked Note' })
const entry = makeEntry({ title: 'My Note', outgoingLinks: ['Linked Note'] })
render(
@@ -264,11 +264,7 @@ describe('AiPanel', () => {
activeEntry={entry} entries={[entry, linked]}
/>
)
expect(screen.getByText('+ 1 linked')).toBeTruthy()
})
it('does not show context bar when no active entry', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.queryByText('+ 1 linked')).toBeNull()
expect(screen.queryByTestId('context-bar')).toBeNull()
})

View File

@@ -1,7 +1,6 @@
import { useCallback, useRef, type CSSProperties, type ReactNode, type RefObject } from 'react'
import {
AiPanelComposer,
AiPanelContextBar,
AiPanelHeader,
AiPanelMessageHistory,
} from './AiPanelChrome'
@@ -58,8 +57,12 @@ interface AiPanelViewProps {
entries?: VaultEntry[]
interactive?: boolean
showHeader?: boolean
showLeftBorder?: boolean
surface?: 'default' | 'sidebar'
composerControls?: ReactNode
onForkMessage?: (messageId: string) => void
onSendPrompt?: (text: string) => void
onMessageHistoryScrollStateChange?: (scrolled: boolean) => void
}
function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness {
@@ -95,14 +98,16 @@ function resolveAiPanelViewModel({
}
}
function aiPanelFrameStyle(isActive: boolean): CSSProperties {
function aiPanelFrameStyle(isActive: boolean, showLeftBorder: boolean): CSSProperties {
return {
outline: 'none',
borderLeft: isActive
? '2px solid var(--accent-blue)'
: '1px solid var(--border)',
animation: isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined,
transition: 'border-color 0.3s ease',
borderLeft: showLeftBorder
? isActive
? '2px solid var(--accent-blue)'
: '1px solid var(--border)'
: undefined,
animation: showLeftBorder && isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined,
transition: showLeftBorder ? 'border-color 0.3s ease' : undefined,
}
}
@@ -110,17 +115,21 @@ function AiPanelFrame({
children,
isActive,
panelRef,
showLeftBorder,
surface,
}: {
children: ReactNode
isActive: boolean
panelRef: RefObject<HTMLElement | null>
showLeftBorder: boolean
surface: 'default' | 'sidebar'
}) {
return (
<aside
ref={panelRef}
tabIndex={-1}
className="flex flex-1 flex-col overflow-hidden bg-background text-foreground"
style={aiPanelFrameStyle(isActive)}
className={`flex flex-1 flex-col overflow-hidden ${surface === 'sidebar' ? 'bg-sidebar text-sidebar-foreground' : 'bg-background text-foreground'}`}
style={aiPanelFrameStyle(isActive, showLeftBorder)}
data-testid="ai-panel"
data-ai-active={isActive || undefined}
>
@@ -139,12 +148,15 @@ export function AiPanelView({
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
defaultAiAgentReady: providedDefaultAiAgentReady,
locale = 'en',
activeEntry,
entries,
interactive = true,
showHeader = true,
showLeftBorder = true,
surface = 'default',
composerControls,
onForkMessage,
onSendPrompt,
onMessageHistoryScrollStateChange,
}: AiPanelViewProps) {
const view = resolveAiPanelViewModel({
defaultAiAgent: providedDefaultAiAgent,
@@ -158,7 +170,6 @@ export function AiPanelView({
agent,
input,
setInput,
linkedEntries,
hasContext,
isActive,
permissionMode,
@@ -184,7 +195,7 @@ export function AiPanelView({
}, [handleSend, isActive, onSendPrompt])
return (
<AiPanelFrame panelRef={panelRef} isActive={isActive}>
<AiPanelFrame panelRef={panelRef} isActive={isActive} showLeftBorder={showLeftBorder} surface={surface}>
{showHeader && (
<AiPanelHeader
agentLabel={view.agentLabel}
@@ -198,17 +209,17 @@ export function AiPanelView({
onNewChat={handleNewChat}
/>
)}
{activeEntry && (
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} locale={locale} />
)}
<AiPanelMessageHistory
agentLabel={view.agentLabel}
agentReadiness={view.defaultAiAgentReadiness}
locale={locale}
messages={agent.messages}
isActive={isActive}
onForkMessage={onForkMessage}
onOpenNote={onOpenNote}
onNavigateWikilink={handleNavigateWikilink}
onRegenerateMessage={agent.regenerateMessage}
onScrollStateChange={onMessageHistoryScrollStateChange}
hasContext={hasContext}
/>
<AiPanelComposer

View File

@@ -1,5 +1,5 @@
import { memo, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { memo, useCallback, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
import { Sparkle, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
import { ActionTooltip } from '@/components/ui/action-tooltip'
@@ -41,8 +41,11 @@ interface AiPanelMessageHistoryProps {
locale?: AppLocale
messages: AiAgentMessage[]
isActive: boolean
onForkMessage?: (messageId: string) => void
onOpenNote?: (path: string) => void
onNavigateWikilink?: (target: string) => void
onRegenerateMessage?: (messageId: string) => void
onScrollStateChange?: (scrolled: boolean) => void
hasContext: boolean
}
@@ -117,6 +120,7 @@ function ComposerInput({
onUnsupportedPaste={onUnsupportedAiPaste}
disabled={disabled}
placeholder={placeholder}
placeholderClassName={hasControls ? 'px-2 py-1.5 text-[13px] leading-5' : undefined}
inputRef={inputRef}
editorClassName={cn(
'max-h-[120px] overflow-y-auto overscroll-contain',
@@ -220,7 +224,7 @@ function AiPanelEmptyState({
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<Sparkle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{t('ai.panel.empty.checkingTitle')}
</p>
@@ -237,7 +241,7 @@ function AiPanelEmptyState({
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<Sparkle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{t('ai.panel.empty.missingTitle', { agent: agentLabel })}
</p>
@@ -253,7 +257,7 @@ function AiPanelEmptyState({
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<Sparkle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{hasContext
? t('ai.panel.empty.withContextTitle', { agent: agentLabel })
@@ -292,7 +296,7 @@ export const AiPanelHeader = memo(function AiPanelHeader({
style={{ padding: '8px 12px', gap: 8 }}
>
<div className="flex items-center" style={{ gap: 8 }}>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<Sparkle size={16} className="shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
{t('ai.panel.title')}
@@ -422,20 +426,31 @@ export const AiPanelMessageHistory = memo(function AiPanelMessageHistory({
locale = 'en',
messages,
isActive,
onForkMessage,
onOpenNote,
onNavigateWikilink,
onRegenerateMessage,
onScrollStateChange,
hasContext,
}: AiPanelMessageHistoryProps) {
const containerRef = useRef<HTMLDivElement>(null)
const endRef = useRef<HTMLDivElement>(null)
const updateScrollState = useCallback(() => {
const element = containerRef.current
onScrollStateChange?.((element?.scrollTop ?? 0) > 1)
}, [onScrollStateChange])
useEffect(() => {
void isActive
void messages
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, isActive])
if (typeof window.requestAnimationFrame === 'function') window.requestAnimationFrame(updateScrollState)
else updateScrollState()
}, [messages, isActive, updateScrollState])
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
<div ref={containerRef} className="flex-1 overflow-y-auto" style={{ padding: 12 }} onScroll={updateScrollState}>
{messages.length === 0 && !isActive && (
<AiPanelEmptyState
agentLabel={agentLabel}
@@ -448,8 +463,12 @@ export const AiPanelMessageHistory = memo(function AiPanelMessageHistory({
<AiMessage
key={message.id ?? index}
{...message}
locale={locale}
messageId={message.id}
onFork={onForkMessage}
onOpenNote={onOpenNote}
onNavigateWikilink={onNavigateWikilink}
onRegenerate={onRegenerateMessage}
/>
))}
<div ref={endRef} />
@@ -487,7 +506,7 @@ export function AiPanelComposer({
return (
<div
className="flex shrink-0 flex-col border-t border-border"
className="flex shrink-0 flex-col"
style={{ padding: '6px 10px' }}
>
<div className={cn(

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { AiWorkspace } from './AiWorkspace'
import { buildAiWorkspaceTargetGroups } from './aiWorkspaceTargetGroups'
@@ -12,9 +12,25 @@ import type { AiModelProvider } from '../lib/aiTargets'
import type { AgentStatus } from '../hooks/useCliAiAgent'
import { resetVaultConfigStore } from '../utils/vaultConfigStore'
import type { VaultEntry } from '../types'
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
let mockedAgentStatus: AgentStatus = 'idle'
let controllerCalls: unknown[] = []
const { generateTitleMock } = vi.hoisted(() => ({
generateTitleMock: vi.fn(),
}))
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true, writable: true })
vi.mock('./useAiPanelController', () => ({
useAiPanelController: (args: unknown) => {
@@ -44,10 +60,12 @@ vi.mock('./useAiPanelController', () => ({
vi.mock('./AiPanel', () => ({
AiPanelView: ({
composerControls,
onMessageHistoryScrollStateChange,
onSendPrompt,
showHeader,
}: {
composerControls?: ReactNode
onMessageHistoryScrollStateChange?: (scrolled: boolean) => void
onSendPrompt?: (prompt: string) => void
showHeader?: boolean
}) => (
@@ -55,11 +73,18 @@ vi.mock('./AiPanel', () => ({
<button type="button" onClick={() => onSendPrompt?.('summarize quarterly sponsor outreach')}>
Send mocked prompt
</button>
<button type="button" onClick={() => onMessageHistoryScrollStateChange?.(true)}>
Mock history scroll
</button>
{composerControls}
</div>
),
}))
vi.mock('../utils/aiConversationTitle', () => ({
generateAiConversationTitleForTarget: generateTitleMock,
}))
function installedStatuses(): AiAgentsStatus {
return {
...createMissingAiAgentsStatus(),
@@ -142,6 +167,9 @@ describe('AiWorkspace', () => {
beforeEach(() => {
mockedAgentStatus = 'idle'
controllerCalls = []
generateTitleMock.mockReset()
generateTitleMock.mockResolvedValue('Summarize quarterly sponsor outreach')
localStorage.clear()
resetVaultConfigStore()
})
@@ -160,20 +188,137 @@ describe('AiWorkspace', () => {
const workspace = screen.getByTestId('ai-workspace')
expect(workspace).toHaveAttribute('data-ai-workspace-mode', 'docked')
expect(workspace).toHaveStyle({ width: '560px' })
expect(workspace.className).toContain('bottom-[30px]')
expect(workspace.className).not.toContain('shadow')
expect(screen.getByTestId('ai-panel-view')).toHaveAttribute('data-show-header', 'false')
expect(screen.queryByText('Agents')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
expect(screen.getByText('Agents')).toBeTruthy()
expect(screen.queryByText('AI Agent')).toBeNull()
expect(screen.queryByText('Idle')).toBeNull()
fireEvent.click(screen.getByTestId('ai-workspace-sidebar-new-chat'))
expect(screen.getAllByText('Chat 1').length).toBeGreaterThan(0)
expect(screen.getAllByText('Chat 2').length).toBeGreaterThan(0)
expect(screen.getAllByText('AI Chat 1').length).toBeGreaterThan(0)
expect(screen.getAllByText('AI Chat 2').length).toBeGreaterThan(0)
})
it('renders side mode as an in-editor tabbed panel and expands in place', () => {
const onClose = vi.fn()
render(<AiWorkspace open mode="side" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={onClose} />)
const workspace = screen.getByTestId('ai-workspace')
expect(workspace).toHaveAttribute('data-ai-workspace-mode', 'side')
expect(workspace).toHaveAttribute('data-ai-workspace-expanded', 'false')
expect(workspace).not.toHaveClass('fixed')
expect(workspace).toHaveClass('bg-sidebar')
expect(workspace).toHaveStyle({ width: '320px', minWidth: '320px' })
const header = screen.getByTestId('ai-workspace-side-header')
const tabStrip = screen.getByTestId('ai-workspace-side-tabs')
expect(header).not.toHaveClass('border-b')
fireEvent.click(screen.getByRole('button', { name: 'Mock history scroll' }))
expect(header).toHaveClass('border-b')
expect(tabStrip).toHaveClass('overflow-x-auto')
expect(screen.getByRole('button', { name: 'AI Chat 1' })).toBeTruthy()
expect(
within(header).getByRole('button', { name: 'Expand AI workspace' }).compareDocumentPosition(
within(header).getByRole('button', { name: 'Close AI workspace' }),
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New chat' }))
expect(screen.getByRole('button', { name: 'AI Chat 2' })).toBeTruthy()
expect(
within(tabStrip).getByRole('button', { name: 'AI Chat 2' }).compareDocumentPosition(
within(tabStrip).getByRole('button', { name: 'New chat' }),
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Close AI Chat 2' }))
expect(screen.queryByRole('button', { name: 'AI Chat 2' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Expand AI workspace' }))
expect(workspace).toHaveAttribute('data-ai-workspace-expanded', 'true')
expect(workspace).toHaveClass('absolute')
expect(screen.getByRole('button', { name: 'Restore AI workspace panel' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Close AI workspace' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('reports the active side chat so reopening can restore it', () => {
const onActiveConversationChange = vi.fn()
const { unmount } = render(
<AiWorkspace
open
mode="side"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
conversationSettings={[
{ id: 'chat-a', title: 'Planning notes', target_id: null, archived: false },
{ id: 'chat-b', title: 'Follow-up draft', target_id: null, archived: false },
]}
initialActiveConversationId="chat-a"
vaultPath="/tmp/vault"
onActiveConversationChange={onActiveConversationChange}
onClose={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Follow-up draft' }))
expect(onActiveConversationChange).toHaveBeenLastCalledWith('chat-b')
unmount()
render(
<AiWorkspace
open
mode="side"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
conversationSettings={[
{ id: 'chat-a', title: 'Planning notes', target_id: null, archived: false },
{ id: 'chat-b', title: 'Follow-up draft', target_id: null, archived: false },
]}
initialActiveConversationId="chat-b"
vaultPath="/tmp/vault"
onClose={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Follow-up draft' })).toHaveAttribute('aria-pressed', 'true')
})
it('renames a side chat from the tab on double-click', () => {
const onConversationSettingsChange = vi.fn()
render(
<AiWorkspace
open
mode="side"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
conversationSettings={[{ id: 'chat-a', title: 'AI Chat 1', target_id: null, archived: false }]}
vaultPath="/tmp/vault"
onClose={vi.fn()}
onConversationSettingsChange={onConversationSettingsChange}
/>,
)
fireEvent.doubleClick(screen.getByRole('button', { name: 'AI Chat 1' }))
const input = screen.getByLabelText('Rename chat')
fireEvent.change(input, { target: { value: 'Research thread' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(screen.getByRole('button', { name: 'Research thread' })).toBeTruthy()
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
expect.objectContaining({ id: 'chat-a', title: 'Research thread' }),
])
})
it('resizes the docked workspace from the left edge and the sidebar split', () => {
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
const workspace = screen.getByTestId('ai-workspace')
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
fireEvent.mouseDown(screen.getByTestId('ai-workspace-left-resize'), { clientX: 100, clientY: 20 })
fireEvent.mouseMove(window, { clientX: 60, clientY: 20 })
fireEvent.mouseUp(window)
@@ -188,6 +333,47 @@ describe('AiWorkspace', () => {
expect(sidebar).toHaveStyle({ width: '188px' })
})
it('restores the last resized side panel width when reopened', () => {
const { unmount } = render(<AiWorkspace open mode="side" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
const workspace = screen.getByTestId('ai-workspace')
fireEvent.mouseDown(screen.getByTestId('ai-workspace-left-resize'), { clientX: 100, clientY: 20 })
fireEvent.mouseMove(window, { clientX: 40, clientY: 20 })
fireEvent.mouseUp(window)
expect(workspace).toHaveStyle({ width: '380px' })
unmount()
render(<AiWorkspace open mode="side" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
expect(screen.getByTestId('ai-workspace')).toHaveStyle({ width: '380px' })
})
it('separates the guidance warning from the header and uses a short restore action', () => {
const status: VaultAiGuidanceStatus = {
agentsState: 'missing',
claudeState: 'managed',
geminiState: 'managed',
canRestore: true,
}
render(
<AiWorkspace
open
mode="side"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
vaultAiGuidanceStatus={status}
vaultPath="/tmp/vault"
onClose={vi.fn()}
onRestoreVaultAiGuidance={vi.fn()}
/>,
)
expect(screen.getByText('Vault guidance needs attention: Tolaria guidance missing or broken')).toHaveClass('min-w-0')
expect(screen.getByRole('button', { name: 'Restore' })).toBeTruthy()
expect(screen.getByText('Vault guidance needs attention: Tolaria guidance missing or broken').parentElement).toHaveClass('border-y')
})
it('does not archive an empty chat', () => {
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
@@ -195,8 +381,8 @@ describe('AiWorkspace', () => {
expect(archiveButtons.every((button) => button.hasAttribute('disabled'))).toBe(true)
fireEvent.click(archiveButtons[0])
expect(screen.getAllByText('Chat 1').length).toBeGreaterThan(0)
expect(screen.queryByText('Chat 2')).toBeNull()
expect(screen.getAllByText('AI Chat 1').length).toBeGreaterThan(0)
expect(screen.queryByText('AI Chat 2')).toBeNull()
})
it('activates a visible chat when persisted settings start with an archived chat', () => {
@@ -285,20 +471,29 @@ describe('AiWorkspace', () => {
expect(within(menu).getByText('OpenAI · GPT-4.1')).toBeTruthy()
})
it('renames the first chat from the first prompt and stores conversation settings', () => {
it('renames the first chat from the selected target title request and stores conversation settings', async () => {
const onConversationSettingsChange = vi.fn()
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
fireEvent.click(screen.getByText('Send mocked prompt'))
expect(screen.getAllByText('Summarize Quarterly Sponsor Outreach').length).toBeGreaterThan(0)
await waitFor(() => {
expect(screen.getAllByText('Summarize quarterly sponsor outreach').length).toBeGreaterThan(0)
})
expect(generateTitleMock).toHaveBeenCalledWith(expect.objectContaining({
permissionMode: 'safe',
prompt: 'summarize quarterly sponsor outreach',
target: expect.objectContaining({ kind: 'agent', agent: 'claude_code' }),
targetReady: true,
vaultPath: '/tmp/vault',
}))
expect(screen.getAllByRole('button', { name: 'Archive chat' }).some((button) => !button.hasAttribute('disabled'))).toBe(true)
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
expect.objectContaining({ title: 'Summarize Quarterly Sponsor Outreach' }),
expect.objectContaining({ title: 'Summarize quarterly sponsor outreach' }),
])
})
it('renames a persisted default chat title from the first prompt', () => {
it('renames a persisted default chat title from the first prompt', async () => {
const onConversationSettingsChange = vi.fn()
render(
<AiWorkspace
@@ -315,9 +510,11 @@ describe('AiWorkspace', () => {
fireEvent.click(screen.getByText('Send mocked prompt'))
expect(screen.getAllByText('Summarize Quarterly Sponsor Outreach').length).toBeGreaterThan(0)
await waitFor(() => {
expect(screen.getAllByText('Summarize quarterly sponsor outreach').length).toBeGreaterThan(0)
})
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
expect.objectContaining({ id: 'stored-chat', title: 'Summarize Quarterly Sponsor Outreach' }),
expect.objectContaining({ id: 'stored-chat', title: 'Summarize quarterly sponsor outreach' }),
])
})
@@ -325,6 +522,7 @@ describe('AiWorkspace', () => {
const onConversationSettingsChange = vi.fn()
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
fireEvent.doubleClick(screen.getByRole('button', { name: /chat 1/i }))
const input = screen.getByLabelText('Rename chat')
fireEvent.change(input, { target: { value: 'Sponsor Plan' } })
@@ -336,12 +534,15 @@ describe('AiWorkspace', () => {
])
})
it('collapses the workspace sidebar from the sidebar header', () => {
it('opens with the workspace sidebar collapsed and expands from the sidebar header', () => {
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Collapse AI chat list' }))
expect(screen.queryByText('AI workspace')).toBeNull()
expect(screen.queryByText('Agents')).toBeNull()
expect(screen.getByRole('button', { name: 'Expand AI chat list' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
expect(screen.getByText('Agents')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Collapse AI chat list' })).toBeTruthy()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
import { Sparkle, Warning } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import {
hasAnyInstalledAiAgent,
isAiAgentInstalled,
type AiAgentId,
type AiAgentsStatus,
} from '../lib/aiAgents'
import {
resolveAiTarget,
type AiModelProvider,
type AiTarget,
} from '../lib/aiTargets'
import { translate, type AppLocale } from '../lib/i18n'
import {
vaultAiGuidanceNeedsRestore,
type VaultAiGuidanceStatus,
} from '../lib/vaultAiGuidance'
import type { Settings } from '../types'
import { AiAgentIcon } from './AiAgentIcon'
interface AiWorkspaceFloatingButtonProps {
defaultAgent: AiAgentId
defaultTarget?: string
guidanceStatus?: VaultAiGuidanceStatus
locale?: AppLocale
providers?: AiModelProvider[]
statuses: AiAgentsStatus
onOpen: () => void
}
function selectedTargetForButton({
defaultAgent,
defaultTarget,
providers,
}: Pick<AiWorkspaceFloatingButtonProps, 'defaultAgent' | 'defaultTarget' | 'providers'>): AiTarget {
return resolveAiTarget({
default_ai_agent: defaultAgent,
default_ai_target: defaultTarget,
ai_model_providers: providers ?? [],
} as Settings)
}
function hasFloatingButtonWarning({
guidanceStatus,
selectedTarget,
statuses,
}: {
guidanceStatus?: VaultAiGuidanceStatus
selectedTarget: AiTarget
statuses: AiAgentsStatus
}): boolean {
if (guidanceStatus && vaultAiGuidanceNeedsRestore(guidanceStatus)) return true
if (selectedTarget.kind !== 'agent') return false
return !hasAnyInstalledAiAgent(statuses) || !isAiAgentInstalled(statuses, selectedTarget.agent)
}
function FloatingButtonIcon({
selectedTarget,
showWarning,
}: {
selectedTarget: AiTarget
showWarning: boolean
}) {
if (showWarning) return <Warning size={22} weight="regular" />
if (selectedTarget.kind === 'agent') return <AiAgentIcon agent={selectedTarget.agent} size={24} />
return <Sparkle size={22} weight="regular" />
}
export function AiWorkspaceFloatingButton({
defaultAgent,
defaultTarget,
guidanceStatus,
locale = 'en',
providers = [],
statuses,
onOpen,
}: AiWorkspaceFloatingButtonProps) {
const selectedTarget = selectedTargetForButton({ defaultAgent, defaultTarget, providers })
const showWarning = hasFloatingButtonWarning({ guidanceStatus, selectedTarget, statuses })
const label = translate(locale, 'editor.toolbar.openAi')
return (
<Button
type="button"
variant="ghost"
size="icon"
className="fixed right-5 bottom-11 z-30 size-12 rounded-full border border-border bg-background text-foreground shadow-[0_10px_28px_rgba(15,23,42,0.18),0_2px_8px_rgba(15,23,42,0.12)] hover:bg-background hover:text-foreground"
aria-label={label}
title={label}
data-tooltip-mode="native-title"
data-testid="ai-workspace-floating-button"
onClick={onOpen}
>
<span className={showWarning ? 'text-[var(--accent-orange)]' : undefined}>
<FloatingButtonIcon selectedTarget={selectedTarget} showWarning={showWarning} />
</span>
</Button>
)
}

View File

@@ -5,7 +5,6 @@ import {
Check,
CircleNotch,
Plus,
Robot,
SidebarSimple,
} from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
@@ -85,12 +84,9 @@ function SidebarHeader({
<SidebarSimple size={16} />
</Button>
{!collapsed && (
<>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<span className="truncate text-[13px] font-semibold text-foreground">
{translate(locale, 'ai.workspace.title')}
</span>
</>
<span className="truncate text-[13px] font-semibold text-foreground">
{translate(locale, 'ai.workspace.title')}
</span>
)}
</div>
{!collapsed && (
@@ -255,14 +251,14 @@ function ConversationRow({
variant="ghost"
size="sm"
className={cn(
'min-w-0 flex-1 justify-start gap-2 rounded-md px-2 pr-8 text-left text-[12px]',
'min-w-0 flex-1 justify-start gap-2 rounded-md px-2 pr-2 text-left text-[12px] transition-[padding] group-hover:pr-8 group-focus-within:pr-8',
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
aria-pressed={active}
onClick={() => onSelect(conversation.id)}
onDoubleClick={() => onStartEditing(conversation.id)}
>
<span className="truncate">{conversation.title}</span>
<span className="min-w-0 flex-1 truncate">{conversation.title}</span>
<span className="ml-auto flex shrink-0 items-center">
<SidebarStatusIndicator status={status} />
</span>

View File

@@ -1,29 +1,85 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useState, useSyncExternalStore } from 'react'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { AppPreferencesProvider, useAppPreferences } from '../hooks/useAppPreferences'
import { useAiAgentsStatus } from '../hooks/useAiAgentsStatus'
import { useSettings } from '../hooks/useSettings'
import { useVaultAiGuidanceStatus } from '../hooks/useVaultAiGuidanceStatus'
import { isTauri } from '../mock-tauri'
import { areAiFeaturesEnabled } from '../lib/aiFeatures'
import {
aiWorkspaceWindowSharedContextSnapshot,
subscribeAiWorkspaceWindowSharedContext,
} from '../lib/aiWorkspaceWindowSharedContext'
import type { AiWorkspaceConversationSetting, Settings } from '../types'
import {
AI_WORKSPACE_CONTEXT_UPDATED_EVENT,
closeCurrentAiWorkspaceWindow,
dockCurrentAiWorkspaceWindow,
readAiWorkspaceWindowContext,
type AiWorkspaceWindowContext,
} from '../utils/openAiWorkspaceWindow'
import {
AI_WORKSPACE_FILE_CREATED_EVENT,
AI_WORKSPACE_FILE_MODIFIED_EVENT,
AI_WORKSPACE_OPEN_NOTE_REQUESTED_EVENT,
AI_WORKSPACE_VAULT_CHANGED_EVENT,
} from '../utils/aiPromptBridge'
import { AppAiWorkspaceSurface } from './AppAiWorkspaceSurface'
import { Toast } from './Toast'
const RESIZE_EDGE = 18
type ResizeDirection =
| 'East'
| 'North'
| 'NorthEast'
| 'NorthWest'
| 'South'
| 'SouthEast'
| 'SouthWest'
| 'West'
function useAiWorkspaceWindowContext() {
return useMemo(() => readAiWorkspaceWindowContext(), [])
const [context, setContext] = useState(() => readAiWorkspaceWindowContext())
useEffect(() => {
let disposed = false
let unlisten: (() => void) | undefined
void import('@tauri-apps/api/event')
.then(({ listen }) => listen<AiWorkspaceWindowContext>(AI_WORKSPACE_CONTEXT_UPDATED_EVENT, (event) => {
setContext(event.payload)
}))
.then((nextUnlisten) => {
if (disposed) {
nextUnlisten()
return
}
unlisten = nextUnlisten
})
.catch(() => undefined)
return () => {
disposed = true
unlisten?.()
}
}, [])
return context
}
function useTransparentWindowBackground() {
useEffect(() => {
useLayoutEffect(() => {
const previousBodyBackground = document.body.style.background
const previousRootBackground = document.documentElement.style.background
document.documentElement.classList.add('ai-workspace-native-window')
document.body.classList.add('ai-workspace-native-window')
document.body.style.background = 'transparent'
document.documentElement.style.background = 'transparent'
return () => {
document.documentElement.classList.remove('ai-workspace-native-window')
document.body.classList.remove('ai-workspace-native-window')
document.body.style.background = previousBodyBackground
document.documentElement.style.background = previousRootBackground
}
@@ -41,10 +97,136 @@ function useAiWorkspaceSettingsSaver(
}, [enabled, saveSettings, settings])
}
function resizeDirectionForPoint(x: number, y: number, rect: DOMRect): ResizeDirection | null {
const nearLeft = Math.abs(x - rect.left) <= RESIZE_EDGE
const nearRight = Math.abs(x - rect.right) <= RESIZE_EDGE
const nearTop = Math.abs(y - rect.top) <= RESIZE_EDGE
const nearBottom = Math.abs(y - rect.bottom) <= RESIZE_EDGE
if (nearTop && nearLeft) return 'NorthWest'
if (nearTop && nearRight) return 'NorthEast'
if (nearBottom && nearLeft) return 'SouthWest'
if (nearBottom && nearRight) return 'SouthEast'
if (nearTop) return 'North'
if (nearBottom) return 'South'
if (nearLeft) return 'West'
if (nearRight) return 'East'
return null
}
function isInteractiveResizeTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false
return target.closest(
'button, [role="button"], a, input, textarea, select, [contenteditable="true"], [data-radix-popper-content-wrapper]',
) !== null
}
function useAiWorkspaceFrameResize() {
useEffect(() => {
if (!isTauri()) return
const appWindow = getCurrentWindow()
const startResize = (event: globalThis.MouseEvent) => {
if (event.button !== 0) return
if (isInteractiveResizeTarget(event.target)) return
const frame = document.getElementById('root')
if (!frame) return
const direction = resizeDirectionForPoint(event.clientX, event.clientY, frame.getBoundingClientRect())
if (!direction) return
event.preventDefault()
event.stopPropagation()
void appWindow.startResizeDragging(direction).catch(() => {})
}
document.addEventListener('mousedown', startResize, true)
return () => document.removeEventListener('mousedown', startResize, true)
}, [])
}
function useAiWorkspaceWindowChrome() {
useEffect(() => {
if (!isTauri()) return
const appWindow = getCurrentWindow()
void appWindow.setAlwaysOnTop(false).catch(() => {})
void appWindow.setShadow(false).catch(() => {})
}, [])
}
function useMainWindowEvent<T>(eventName: string) {
return useCallback((payload: T) => {
if (!isTauri()) return
void import('@tauri-apps/api/event')
.then(({ emitTo }) => emitTo('main', eventName, payload))
.catch(() => undefined)
}, [eventName])
}
function useAiWorkspaceFrameCursor() {
useEffect(() => {
if (!isTauri()) return
const syncCursor = (event: globalThis.MouseEvent) => {
if (isInteractiveResizeTarget(event.target)) {
resetCursor()
return
}
const frame = document.getElementById('root')
const direction = frame ? resizeDirectionForPoint(event.clientX, event.clientY, frame.getBoundingClientRect()) : null
document.body.style.cursor = resizeCursorForDirection(direction)
}
const resetCursor = () => {
document.body.style.cursor = ''
}
document.addEventListener('mousemove', syncCursor)
document.addEventListener('mouseleave', resetCursor)
return () => {
document.removeEventListener('mousemove', syncCursor)
document.removeEventListener('mouseleave', resetCursor)
resetCursor()
}
}, [])
}
function resizeCursorForDirection(direction: ResizeDirection | null): string {
switch (direction) {
case 'North':
case 'South':
return 'ns-resize'
case 'East':
case 'West':
return 'ew-resize'
case 'NorthEast':
case 'SouthWest':
return 'nesw-resize'
case 'NorthWest':
case 'SouthEast':
return 'nwse-resize'
default:
return ''
}
}
export function AiWorkspaceWindowApp() {
useTransparentWindowBackground()
useAiWorkspaceFrameResize()
useAiWorkspaceFrameCursor()
useAiWorkspaceWindowChrome()
const [toastMessage, setToastMessage] = useState<string | null>(null)
const context = useAiWorkspaceWindowContext()
const sharedContext = useSyncExternalStore(
subscribeAiWorkspaceWindowSharedContext,
aiWorkspaceWindowSharedContextSnapshot,
aiWorkspaceWindowSharedContextSnapshot,
)
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
const aiAgentsStatus = useAiAgentsStatus()
const aiFeaturesEnabled = areAiFeaturesEnabled(settings)
@@ -55,8 +237,9 @@ export function AiWorkspaceWindowApp() {
settings,
settingsLoaded,
})
const vaultPath = context.vaultPath ?? ''
const vaultPaths = context.vaultPaths ?? (vaultPath ? [vaultPath] : [])
const vaultPath = context.vaultPath ?? sharedContext.vaultPath ?? ''
const vaultPaths = context.vaultPaths ?? sharedContext.vaultPaths ?? (vaultPath ? [vaultPath] : [])
const activeConversationId = context.activeConversationId ?? sharedContext.activeConversationId
const { status: vaultAiGuidanceStatus } = useVaultAiGuidanceStatus(
aiFeaturesEnabled && vaultPath ? vaultPath : null,
vaultPath,
@@ -72,38 +255,55 @@ export function AiWorkspaceWindowApp() {
console.warn('[ai] Failed to close workspace window:', err)
})
}, [])
const handleOpenNote = useMainWindowEvent<string>(AI_WORKSPACE_OPEN_NOTE_REQUESTED_EVENT)
const handleFileCreated = useMainWindowEvent<string>(AI_WORKSPACE_FILE_CREATED_EVENT)
const handleFileModified = useMainWindowEvent<string>(AI_WORKSPACE_FILE_MODIFIED_EVENT)
const handleVaultChanged = useMainWindowEvent<null>(AI_WORKSPACE_VAULT_CHANGED_EVENT)
return (
<AppPreferencesProvider dateDisplayFormat={preferences.dateDisplayFormat}>
{settingsLoaded ? (
<AppAiWorkspaceSurface
mode="window"
open
aiAgentsStatus={aiAgentsStatus}
aiModelProviders={settings.ai_model_providers ?? []}
conversationSettings={settings.ai_workspace_conversations ?? null}
conversationSettingsReady={settingsLoaded}
defaultAiAgent={preferences.aiAgentPreferences.defaultAiAgent}
defaultAiTarget={preferences.aiAgentPreferences.defaultAiTarget}
defaultAiAgentReadiness={preferences.aiAgentPreferences.defaultAiAgentReadiness}
defaultAiAgentReady={preferences.aiAgentPreferences.defaultAiAgentReady}
entries={[]}
openTabs={[]}
noteList={[]}
noteListFilter={{ type: null, query: '' }}
onClose={handleClose}
onConversationSettingsChange={handleConversationSettingsChange}
onDock={handleDock}
onUnsupportedAiPaste={setToastMessage}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
vaultPath={vaultPath}
vaultPaths={vaultPaths}
locale={preferences.appLocale}
/>
) : (
<div className="fixed inset-0 bg-background" aria-hidden />
)}
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<div className="relative h-full w-full">
{settingsLoaded ? (
<AppAiWorkspaceSurface
key={activeConversationId ?? 'default'}
mode="window"
open
aiAgentsStatus={aiAgentsStatus}
aiModelProviders={settings.ai_model_providers ?? []}
conversationSettings={settings.ai_workspace_conversations ?? null}
conversationSettingsReady={settingsLoaded}
defaultAiAgent={preferences.aiAgentPreferences.defaultAiAgent}
defaultAiTarget={preferences.aiAgentPreferences.defaultAiTarget}
defaultAiAgentReadiness={preferences.aiAgentPreferences.defaultAiAgentReadiness}
defaultAiAgentReady={preferences.aiAgentPreferences.defaultAiAgentReady}
initialActiveConversationId={activeConversationId}
activeEntry={sharedContext.activeEntry ?? null}
activeNoteContent={sharedContext.activeNoteContent ?? null}
entries={sharedContext.entries ?? []}
openTabs={sharedContext.openTabs ?? []}
noteList={sharedContext.noteList ?? []}
noteListFilter={sharedContext.noteListFilter ?? { type: null, query: '' }}
onClose={handleClose}
onConversationSettingsChange={handleConversationSettingsChange}
onDock={handleDock}
onOpenNote={handleOpenNote}
onUnsupportedAiPaste={setToastMessage}
onFileCreated={handleFileCreated}
onFileModified={handleFileModified}
onVaultChanged={() => handleVaultChanged(null)}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
vaultPath={vaultPath}
vaultPaths={vaultPaths}
locale={preferences.appLocale}
/>
) : (
<div
className="h-full w-full bg-background"
aria-hidden
/>
)}
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
</div>
</AppPreferencesProvider>
)
}

View File

@@ -18,10 +18,12 @@ interface AppAiWorkspaceSurfaceProps {
defaultAiAgentReady: boolean
defaultAiTarget?: AiTarget
entries: VaultEntry[]
initialActiveConversationId?: string
locale: AppLocale
mode: 'docked' | 'window'
mode: 'docked' | 'side' | 'window'
noteList: NoteListItem[]
noteListFilter: { type: string | null; query: string }
onActiveConversationChange?: (id: string) => void
onClose: () => void
onConversationSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void
onDock?: () => void
@@ -29,7 +31,7 @@ interface AppAiWorkspaceSurfaceProps {
onFileModified?: (relativePath: string) => void
onOpenAiSettings?: () => void
onOpenNote?: (path: string) => void
onPopOut?: () => void
onPopOut?: (context?: { activeConversationId?: string }) => void
onRestoreVaultAiGuidance?: () => void
onUnsupportedAiPaste?: (message: string) => void
onVaultChanged?: () => void
@@ -52,10 +54,12 @@ export function AppAiWorkspaceSurface({
defaultAiAgentReady,
defaultAiTarget,
entries,
initialActiveConversationId,
locale,
mode,
noteList,
noteListFilter,
onActiveConversationChange,
onClose,
onConversationSettingsChange,
onDock,
@@ -85,12 +89,14 @@ export function AppAiWorkspaceSurface({
defaultAiTarget={defaultAiTarget}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
initialActiveConversationId={initialActiveConversationId}
activeEntry={activeEntry}
activeNoteContent={activeNoteContent}
entries={entries}
openTabs={openTabs}
noteList={noteList}
noteListFilter={noteListFilter}
onActiveConversationChange={onActiveConversationChange}
onClose={onClose}
onConversationSettingsChange={onConversationSettingsChange}
onDock={onDock}

View File

@@ -686,18 +686,17 @@ describe('BreadcrumbBar — note width toggle', () => {
})
describe('BreadcrumbBar — AI panel toggle', () => {
it('hides the AI panel action when no toggle callback is available', () => {
it('keeps the AI panel action out of the breadcrumb bar', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
expect(screen.queryByRole('button', { name: 'Open the AI panel' })).not.toBeInTheDocument()
})
it('shows and runs the AI panel action when a toggle callback is available', () => {
it('does not render the breadcrumb AI panel action when a toggle callback is available', () => {
const onToggleAIChat = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleAIChat={onToggleAIChat} />)
fireEvent.click(screen.getByRole('button', { name: 'Open the AI panel' }))
expect(onToggleAIChat).toHaveBeenCalledOnce()
expect(screen.queryByRole('button', { name: 'Open the AI panel' })).not.toBeInTheDocument()
expect(onToggleAIChat).not.toHaveBeenCalled()
})
})

View File

@@ -18,9 +18,8 @@ import {
import {
GitBranch,
Code,
Sparkle,
ListBullets,
SidebarSimple,
GearSix,
Trash,
Archive,
ArrowUUpLeft,
@@ -334,21 +333,6 @@ function NeighborhoodAction({
)
}
function AIChatAction({ showAIChat, locale = 'en', onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'locale' | 'onToggleAIChat'>) {
return (
<ToggleIconAction
active={!!showAIChat}
activeClassName="text-primary"
activeLabel={translate(locale, 'editor.toolbar.closeAi')}
inactiveLabel={translate(locale, 'editor.toolbar.openAi')}
onClick={onToggleAIChat}
shortcut={formatShortcutDisplay({ display: '⌘⇧L' })}
>
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</ToggleIconAction>
)
}
function TableOfContentsAction({
showTableOfContents,
locale = 'en',
@@ -416,9 +400,10 @@ function InspectorAction({
}}
onClick={onToggleInspector}
className="hover:text-foreground"
testId="breadcrumb-properties-button"
tooltipAlign="end"
>
<SidebarSimple size={16} weight="regular" className={BREADCRUMB_ICON_CLASS} style={{ transform: 'scaleX(-1)' }} />
<GearSix size={16} weight="regular" className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
@@ -823,8 +808,6 @@ function BreadcrumbActions({
forceRawMode,
noteWidth,
onToggleNoteWidth,
showAIChat,
onToggleAIChat,
showTableOfContents,
onToggleTableOfContents,
inspectorCollapsed,
@@ -861,9 +844,6 @@ function BreadcrumbActions({
<OverflowToolbarAction>
<NoteWidthAction noteWidth={noteWidth} locale={locale} onToggleNoteWidth={onToggleNoteWidth} />
</OverflowToolbarAction>
{onToggleAIChat ? (
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
) : null}
<OverflowToolbarAction>
<TableOfContentsAction
showTableOfContents={showTableOfContents}

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect, useCallback, memo, useState } from 'react'
import { useRef, useEffect, useCallback, memo, useState, type ReactNode } from 'react'
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
import { useCreateBlockNote } from '@blocknote/react'
import '@blocknote/mantine/style.css'
@@ -82,6 +82,7 @@ interface EditorProps {
onInitializeProperties?: (path: string) => void
showAIChat?: boolean
onToggleAIChat?: () => void
aiWorkspaceSurface?: ReactNode
vaultPath?: string
vaultPaths?: string[]
noteList?: NoteListItem[]
@@ -345,6 +346,7 @@ function EditorLayout({
showDiffToggle,
showAIChat,
onToggleAIChat,
aiWorkspaceSurface,
showTableOfContents,
onToggleTableOfContents,
inspectorCollapsed,
@@ -417,6 +419,7 @@ function EditorLayout({
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
aiWorkspaceSurface?: ReactNode
showTableOfContents?: boolean
onToggleTableOfContents?: () => void
inspectorCollapsed: boolean
@@ -475,7 +478,7 @@ function EditorLayout({
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
<div className="flex flex-1 min-h-0">
<div className="relative flex flex-1 min-h-0">
{showEmptyState
? <EditorEmptyState locale={locale} />
: activeBinaryTab
@@ -576,6 +579,7 @@ function EditorLayout({
workspaces={workspaces}
locale={locale}
/>
{showAIChat && aiWorkspaceSurface}
</div>
<EditorMemoryProbe entries={entries} vaultPath={vaultPath} locale={locale} />
</div>

View File

@@ -55,6 +55,7 @@ interface InlineWikilinkInputProps {
submitOnEmpty?: boolean
disabled?: boolean
placeholder?: string
placeholderClassName?: string
inputRef?: React.RefObject<HTMLDivElement | null>
dataTestId?: string
editorClassName?: string
@@ -189,6 +190,7 @@ export function InlineWikilinkInput({
submitOnEmpty = false,
disabled = false,
placeholder,
placeholderClassName,
inputRef,
dataTestId = 'agent-input',
editorClassName,
@@ -538,6 +540,7 @@ export function InlineWikilinkInput({
disabled={disabled}
inputRef={setCombinedRef}
dataTestId={dataTestId}
placeholderClassName={placeholderClassName}
editorClassName={editorClassName}
editorStyle={editorStyle}
onCompositionEnd={handleCompositionEnd}

View File

@@ -170,6 +170,7 @@ export function InlineWikilinkEditorField({
disabled,
inputRef,
dataTestId,
placeholderClassName,
editorClassName,
editorStyle,
onCompositionEnd,
@@ -188,6 +189,7 @@ export function InlineWikilinkEditorField({
disabled: boolean
inputRef: React.Ref<HTMLDivElement>
dataTestId: string
placeholderClassName?: string
editorClassName?: string
editorStyle?: CSSProperties
onCompositionEnd: (editor: HTMLDivElement) => void
@@ -220,8 +222,11 @@ export function InlineWikilinkEditorField({
<div className="relative">
{value.length === 0 && placeholder && (
<div
className="pointer-events-none absolute inset-0 flex items-center text-muted-foreground"
style={{ padding: '8px 10px', fontSize: 13 }}
className={cn(
'pointer-events-none absolute inset-0 text-muted-foreground',
placeholderClassName ?? 'flex items-center',
)}
style={placeholderClassName ? undefined : { padding: '8px 10px', fontSize: 13 }}
>
{placeholder}
</div>

View File

@@ -141,17 +141,15 @@ describe('Inspector', () => {
it('calls onToggle when the close button is clicked', () => {
const onToggle = vi.fn()
render(<Inspector {...defaultProps} onToggle={onToggle} />)
fireEvent.click(screen.getAllByRole('button', { name: 'Close Properties (⌘⇧I)' })[1])
fireEvent.click(screen.getByRole('button', { name: 'Close Properties (⌘⇧I)' }))
expect(onToggle).toHaveBeenCalledOnce()
})
it('closes when the properties sidebar icon is clicked', () => {
it('uses only the right-side close button while expanded', () => {
const onToggle = vi.fn()
render(<Inspector {...defaultProps} onToggle={onToggle} />)
fireEvent.click(screen.getAllByRole('button', { name: 'Close Properties (⌘⇧I)' })[0])
expect(onToggle).toHaveBeenCalledOnce()
expect(screen.getAllByRole('button', { name: 'Close Properties (⌘⇧I)' })).toHaveLength(1)
})
it('shows a colliding-properties warning that opens the raw editor', async () => {

View File

@@ -352,10 +352,13 @@ describe('NoteItem', () => {
)
const chip = screen.getByTestId('property-chip-belongs-to-0')
const row = chip.closest('[data-note-path]')
expect(chip).toHaveTextContent('Build App')
expect(chip.className).toContain('cursor-pointer')
expect(chip).toHaveStyle({ color: 'rgb(0, 255, 255)' })
expect(chip.getAttribute('style')).toContain('background-color: color-mix(in srgb, cyan 14%, transparent)')
expect(row?.tagName).toBe('DIV')
expect(row).toHaveAttribute('role', 'option')
fireEvent.click(chip)
expect(onClickNote).not.toHaveBeenCalled()

View File

@@ -90,8 +90,8 @@ type NoteItemRowState = 'binary' | 'multiSelected' | 'selected' | 'highlighted'
type NoteItemSurfaceProps = {
className: string
style: CSSProperties
onClick: MouseEventHandler<HTMLButtonElement>
onContextMenu?: MouseEventHandler<HTMLButtonElement>
onClick: MouseEventHandler<HTMLDivElement>
onContextMenu?: MouseEventHandler<HTMLDivElement>
onMouseEnter?: () => void
title?: string
testId?: string
@@ -458,19 +458,24 @@ function resolveNoteItemSurfaceProps({
function NoteItemRow({
surfaceProps,
entryPath,
isSelected,
isMultiSelected,
isHighlighted,
changeStatus,
children,
}: {
surfaceProps: NoteItemSurfaceProps
entryPath: string
isSelected: boolean
isMultiSelected: boolean
isHighlighted: boolean
changeStatus: NoteItemProps['changeStatus']
children: ReactNode
}) {
return (
<button
type="button"
<div
role="option"
aria-selected={isSelected || isMultiSelected}
className={surfaceProps.className}
style={surfaceProps.style}
onClick={surfaceProps.onClick}
@@ -483,7 +488,7 @@ function NoteItemRow({
title={surfaceProps.title}
>
{children}
</button>
</div>
)
}
@@ -566,6 +571,8 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
<NoteItemRow
surfaceProps={surfaceProps}
entryPath={entry.path}
isSelected={isSelected}
isMultiSelected={isMultiSelected}
isHighlighted={isHighlighted}
changeStatus={changeStatus}
>

View File

@@ -137,6 +137,7 @@ describe('SettingsPanel', () => {
opencode: { status: 'missing', version: null },
pi: { status: 'missing', version: null },
gemini: { status: 'missing', version: null },
kiro: { status: 'missing', version: null },
}
render(
<SettingsPanel

View File

@@ -46,6 +46,7 @@ import { areGitFeaturesEnabled } from '../lib/gitSettings'
import { areAiFeaturesEnabled } from '../lib/aiFeatures'
import { trackAllNotesVisibilityChanged } from '../lib/productAnalytics'
import { AiProviderSettings } from './AiProviderSettings'
import { AiAgentIcon } from './AiAgentIcon'
import { GitSettingsSection } from './GitSettingsSection'
import { PrivacySettingsSection } from './PrivacySettingsSection'
import { SettingsBodyNav } from './SettingsBodyNav'
@@ -438,7 +439,7 @@ function SettingsPanelInner({
return (
<div
ref={backdropRef}
className="fixed inset-0 z-50 flex items-center justify-center"
className="fixed inset-0 z-[1300] flex items-center justify-center"
style={{ background: 'var(--shadow-overlay)' }}
data-testid="settings-panel"
>
@@ -1136,7 +1137,10 @@ function AiAgentsInstalledSection({
return (
<div key={definition.id} className="rounded-md border border-border bg-background px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="truncate text-sm font-medium text-foreground">{definition.label}</div>
<div className="flex min-w-0 items-center gap-2">
<AiAgentIcon agent={definition.id} size={16} />
<div className="truncate text-sm font-medium text-foreground">{definition.label}</div>
</div>
<div className={installed ? 'text-xs text-emerald-700' : 'text-xs text-muted-foreground'}>
{installed ? t('settings.aiAgents.installed') : t('settings.aiAgents.missing')}
</div>

View File

@@ -22,6 +22,7 @@ const installedAiAgentsStatus = {
opencode: { status: 'installed' as const, version: '0.3.1' },
pi: { status: 'installed' as const, version: '0.70.2' },
gemini: { status: 'installed' as const, version: '0.5.1' },
kiro: { status: 'installed' as const, version: '0.12.0' },
}
const DEFAULT_WINDOW_WIDTH = 1280

View File

@@ -11,6 +11,7 @@ interface WikilinkChatInputProps {
onUnsupportedPaste?: (message: string) => void
disabled?: boolean
placeholder?: string
placeholderClassName?: string
inputRef?: React.RefObject<HTMLDivElement | null>
editorClassName?: string
editorStyle?: CSSProperties
@@ -24,6 +25,7 @@ export function WikilinkChatInput({
onUnsupportedPaste,
disabled,
placeholder,
placeholderClassName,
inputRef,
editorClassName,
editorStyle,
@@ -37,6 +39,7 @@ export function WikilinkChatInput({
onUnsupportedPaste={onUnsupportedPaste}
disabled={disabled}
placeholder={placeholder}
placeholderClassName={placeholderClassName}
inputRef={inputRef}
editorClassName={editorClassName}
editorStyle={editorStyle}

View File

@@ -1,4 +1,4 @@
import { SidebarSimple, X, Sparkle, WarningCircle, PencilSimple } from '@phosphor-icons/react'
import { GearSix, X, Sparkle, WarningCircle, PencilSimple } from '@phosphor-icons/react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import { useDragRegion } from '../../hooks/useDragRegion'
@@ -36,11 +36,10 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
const propertiesTitle = translate(locale, 'inspector.title.properties')
const showWarnings = Boolean(frontmatterWarnings && hasFrontmatterWarnings(frontmatterWarnings) && onOpenRawEditor)
const propertiesIcon = (testId?: string) => (
<SidebarSimple
<GearSix
size={16}
weight="regular"
className="shrink-0 text-muted-foreground"
style={{ transform: 'scaleX(-1)' }}
data-testid={testId}
/>
)
@@ -63,14 +62,7 @@ export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en',
</button>
) : (
<>
<button type="button"
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}
title={toggleLabel}
aria-label={toggleLabel}
>
{propertiesIcon('properties-panel-icon')}
</button>
{propertiesIcon('properties-panel-icon')}
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>{propertiesTitle}</span>
{showWarnings && onOpenRawEditor && (
<FrontmatterWarningsButton locale={locale} onOpenRawEditor={onOpenRawEditor} />

View File

@@ -16,6 +16,7 @@ const installedStatuses = {
opencode: { status: 'installed' as const, version: '0.3.1' },
pi: { status: 'installed' as const, version: '0.70.2' },
gemini: { status: 'installed' as const, version: '0.5.1' },
kiro: { status: 'installed' as const, version: '0.12.0' },
}
const openAiProvider: AiModelProvider = {
@@ -123,6 +124,7 @@ describe('AiAgentsBadge', () => {
opencode: { status: 'missing', version: null },
pi: { status: 'missing', version: null },
gemini: { status: 'missing', version: null },
kiro: { status: 'missing', version: null },
},
})
openAiAgentsMenu()
@@ -132,6 +134,7 @@ describe('AiAgentsBadge', () => {
expect(screen.queryByText('Install OpenCode')).not.toBeInTheDocument()
expect(screen.queryByText('Install Pi')).not.toBeInTheDocument()
expect(screen.queryByText('Install Gemini CLI')).not.toBeInTheDocument()
expect(screen.queryByText('Install Kiro')).not.toBeInTheDocument()
})
it('shows the vault guidance summary and restore action', async () => {

View File

@@ -1,6 +1,7 @@
import { CaretUpDown as ChevronsUpDown, Sparkle, Warning as AlertTriangle } from '@phosphor-icons/react'
import { forwardRef, type ComponentPropsWithoutRef } from 'react'
import { Button } from '@/components/ui/button'
import { AiAgentIcon } from '@/components/AiAgentIcon'
import {
AI_AGENT_DEFINITIONS,
getAiAgentAvailability,
@@ -147,6 +148,18 @@ function TriggerStateIcon({
return null
}
function TriggerLeadingIcon({
selectedTarget,
showWarning,
}: {
selectedTarget: AiTarget
showWarning: boolean
}) {
if (showWarning) return <AlertTriangle size={13} weight="regular" />
if (selectedTarget.kind === 'agent') return <AiAgentIcon agent={selectedTarget.agent} size={13} />
return <Sparkle size={13} weight="regular" />
}
function targetTriggerText(selectedTarget: AiTarget, defaultAgent: AiAgentId): string {
return selectedTarget.kind === 'api_model' ? selectedTarget.shortLabel : triggerLabel(defaultAgent)
}
@@ -189,7 +202,7 @@ const AiAgentsBadgeButton = forwardRef<HTMLButtonElement, AiAgentsBadgeButtonPro
{...buttonProps}
>
<span style={{ ...ICON_STYLE, color: triggerIconColor(showWarning) }}>
<Sparkle size={13} weight="regular" />
<TriggerLeadingIcon selectedTarget={selectedTarget} showWarning={showWarning} />
{!compact && targetTriggerText(selectedTarget, defaultAgent)}
<TriggerStateIcon showWarning={showWarning} showSwitcherCue={showSwitcherCue} />
</span>
@@ -257,7 +270,8 @@ function AgentMenuContent({
}}
>
{installedAgents.map((definition) => (
<DropdownMenuRadioItem key={definition.id} value={definition.id}>
<DropdownMenuRadioItem key={definition.id} value={definition.id} className="gap-2">
<AiAgentIcon agent={definition.id} size={16} />
<span>{definition.label}</span>
<span className="ml-auto text-xs text-muted-foreground">
{statusText(statuses, definition)}

View File

@@ -39,6 +39,7 @@ interface UseAiPanelControllerArgs {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
sessionId?: string
}
export interface AiPanelController {
@@ -116,6 +117,7 @@ function usePanelAgent({
onFileCreated,
onFileModified,
onVaultChanged,
sessionId,
}: Pick<
UseAiPanelControllerArgs,
| 'vaultPath'
@@ -127,6 +129,7 @@ function usePanelAgent({
| 'onFileCreated'
| 'onFileModified'
| 'onVaultChanged'
| 'sessionId'
> & { contextPrompt?: string }) {
const fileCallbacks = useAgentFileCallbacks({ onFileCreated, onFileModified, onVaultChanged })
const permissionMode = useVaultAiAgentPermissionMode()
@@ -135,6 +138,7 @@ function usePanelAgent({
target: defaultAiTarget,
agentReady: resolveAgentReady(defaultAiAgentReadiness, defaultAiAgentReady),
permissionMode,
sessionId,
})
return { agent, permissionMode }
}
@@ -157,6 +161,7 @@ export function useAiPanelController({
onFileCreated,
onFileModified,
onVaultChanged,
sessionId,
}: UseAiPanelControllerArgs): AiPanelController {
const [input, setInput] = useState('')
const { linkedEntries, contextPrompt } = useAiPanelContextSnapshot({
@@ -169,7 +174,7 @@ export function useAiPanelController({
noteListFilter,
})
const { agent, permissionMode } = usePanelAgent({ vaultPath, vaultPaths, contextPrompt, defaultAiAgent, defaultAiTarget, defaultAiAgentReady, defaultAiAgentReadiness, onFileCreated, onFileModified, onVaultChanged })
const { agent, permissionMode } = usePanelAgent({ vaultPath, vaultPaths, contextPrompt, defaultAiAgent, defaultAiTarget, defaultAiAgentReady, defaultAiAgentReadiness, onFileCreated, onFileModified, onVaultChanged, sessionId })
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
const handleSend = useCallback((text: string, references: NoteReference[]) => {

View File

@@ -19,8 +19,6 @@ function prepareRightPanelOpen(
{
closeTableOfContents,
inspectorCollapsed,
onToggleAIChat,
onToggleInspector,
showAIChat,
}: RightPanelToggleOptions,
) {
@@ -28,8 +26,6 @@ function prepareRightPanelOpen(
if (panel === 'ai' && showAIChat) return
closeTableOfContents()
if (panel === 'properties' && showAIChat) onToggleAIChat?.()
if (panel === 'ai' && !inspectorCollapsed) onToggleInspector()
}
function toggleTableOfContentsPanel({

View File

@@ -1,16 +1,10 @@
import { useCallback, useEffect } from 'react'
import { isTauri } from '../mock-tauri'
import { trackEvent } from '../lib/telemetry'
import { SETTINGS_SECTION_IDS } from '../components/settingsSectionIds'
import {
AI_WORKSPACE_DOCK_REQUESTED_EVENT,
OPEN_AI_CHAT_EVENT,
} from '../utils/aiPromptBridge'
import {
dockCurrentAiWorkspaceWindow,
openAiWorkspaceWindow,
type AiWorkspaceWindowContext,
} from '../utils/openAiWorkspaceWindow'
interface UseAppAiWorkspaceBridgeOptions {
aiFeaturesEnabled: boolean
@@ -20,38 +14,24 @@ interface UseAppAiWorkspaceBridgeOptions {
openSettings: () => void
setSettingsInitialSectionId: (sectionId: string | null) => void
showAIChat: boolean
windowContext?: AiWorkspaceWindowContext
}
interface AppAiWorkspaceBridge {
effectiveShowAIChat: boolean
handleDockCurrentAiWorkspaceWindow: () => void
handleOpenAiSettings: () => void
handleOpenDockedAiWorkspace: () => void
handlePopOutAiWorkspace: () => void
}
type Unlisten = () => void
function listenForNativeDockRequests(handleDockRequest: () => void): Promise<Unlisten | null> | null {
if (!isTauri()) return null
return import('@tauri-apps/api/event')
.then(({ listen }) => listen(AI_WORKSPACE_DOCK_REQUESTED_EVENT, handleDockRequest))
.catch(() => null)
}
function useOpenAiChatEvent(aiFeaturesEnabled: boolean, openAIChat: () => void) {
function useOpenAiChatEvent(aiFeaturesEnabled: boolean, openAiWorkspace: (source: 'event') => void) {
useEffect(() => {
const handleOpenAiChat = () => {
if (!aiFeaturesEnabled) return
openAIChat()
trackEvent('ai_workspace_open', { source: 'event' })
openAiWorkspace('event')
}
window.addEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
return () => window.removeEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
}, [aiFeaturesEnabled, openAIChat])
}, [aiFeaturesEnabled, openAiWorkspace])
}
function useDockRequestEvent(aiFeaturesEnabled: boolean, aiWorkspaceWindow: boolean, openAIChat: () => void) {
@@ -65,11 +45,9 @@ function useDockRequestEvent(aiFeaturesEnabled: boolean, aiWorkspaceWindow: bool
}
window.addEventListener(AI_WORKSPACE_DOCK_REQUESTED_EVENT, handleDockRequest)
const unlistenPromise = listenForNativeDockRequests(handleDockRequest)
return () => {
window.removeEventListener(AI_WORKSPACE_DOCK_REQUESTED_EVENT, handleDockRequest)
void unlistenPromise?.then((unlisten) => unlisten?.()).catch(() => undefined)
}
}, [aiFeaturesEnabled, aiWorkspaceWindow, openAIChat])
}
@@ -88,9 +66,7 @@ export function useAppAiWorkspaceBridge({
openSettings,
setSettingsInitialSectionId,
showAIChat,
windowContext,
}: UseAppAiWorkspaceBridgeOptions): AppAiWorkspaceBridge {
useOpenAiChatEvent(aiFeaturesEnabled, openAIChat)
useCloseDisabledAiWorkspace(aiFeaturesEnabled, closeAIChat, showAIChat)
useDockRequestEvent(aiFeaturesEnabled, aiWorkspaceWindow, openAIChat)
@@ -99,36 +75,23 @@ export function useAppAiWorkspaceBridge({
openSettings()
}, [openSettings, setSettingsInitialSectionId])
const openAiWorkspace = useCallback(
(source: 'event' | 'status_bar') => {
trackEvent('ai_workspace_open', { source })
openAIChat()
},
[openAIChat],
)
useOpenAiChatEvent(aiFeaturesEnabled, openAiWorkspace)
const handleOpenDockedAiWorkspace = useCallback(() => {
openAIChat()
trackEvent('ai_workspace_open', { source: 'status_bar' })
}, [openAIChat])
const handlePopOutAiWorkspace = useCallback(() => {
closeAIChat()
trackEvent('ai_workspace_pop_out')
void openAiWorkspaceWindow(windowContext)
.then((opened) => {
if (!opened) openAIChat()
})
.catch((err) => {
console.warn('[ai] Failed to open workspace window:', err)
openAIChat()
})
}, [closeAIChat, openAIChat, windowContext])
const handleDockCurrentAiWorkspaceWindow = useCallback(() => {
trackEvent('ai_workspace_docked', { source: 'button' })
void dockCurrentAiWorkspaceWindow().catch((err) => {
console.warn('[ai] Failed to dock workspace window:', err)
})
}, [])
openAiWorkspace('status_bar')
}, [openAiWorkspace])
return {
effectiveShowAIChat: aiFeaturesEnabled && showAIChat,
handleDockCurrentAiWorkspaceWindow,
handleOpenAiSettings,
handleOpenDockedAiWorkspace,
handlePopOutAiWorkspace,
}
}

View File

@@ -4,6 +4,10 @@ import { useCliAiAgent } from './useCliAiAgent'
import { streamAiAgent } from '../utils/streamAiAgent'
import { buildAgentSystemPrompt } from '../utils/ai-agent'
import { getAgentDocsPath } from '../lib/agentDocsPath'
import {
cloneAiWorkspaceSessionUntilMessage,
resetAiWorkspaceSessionStoreForTests,
} from '../lib/aiWorkspaceSessionStore'
vi.mock('../utils/streamAiAgent', () => ({
streamAiAgent: vi.fn(),
@@ -25,12 +29,14 @@ const VAULT = '/Users/luca/Laputa'
function renderAgent(
contextPrompt: string | undefined = undefined,
permissionMode: 'safe' | 'power_user' = 'safe',
sessionId?: string,
) {
return renderHook(
({ context }) => useCliAiAgent(VAULT, [VAULT, '/Users/luca/Brian'], context, undefined, {
agent: 'codex',
agentReady: true,
permissionMode,
sessionId,
}),
{ initialProps: { context: contextPrompt } },
)
@@ -40,6 +46,7 @@ describe('useCliAiAgent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetAgentDocsPath.mockResolvedValue('/app/agent-docs')
resetAiWorkspaceSessionStoreForTests()
mockStreamAiAgent.mockImplementation(async ({ callbacks }) => {
callbacks.onText('reply')
callbacks.onDone()
@@ -140,6 +147,62 @@ describe('useCliAiAgent', () => {
expect(freshMessage).not.toContain('<conversation_history>')
})
it('restores workspace session messages after remounting the same chat', async () => {
const firstSession = renderAgent(undefined, 'safe', 'chat-1')
await act(async () => { await firstSession.result.current.sendMessage('Remember this') })
expect(firstSession.result.current.messages).toEqual([expect.objectContaining({
userMessage: 'Remember this',
response: 'reply',
})])
firstSession.unmount()
const secondSession = renderAgent(undefined, 'safe', 'chat-1')
expect(secondSession.result.current.messages).toEqual([expect.objectContaining({
userMessage: 'Remember this',
response: 'reply',
})])
})
it('regenerates a selected message without duplicating the old response', async () => {
let responseNumber = 0
mockStreamAiAgent.mockImplementation(async ({ callbacks }) => {
responseNumber += 1
callbacks.onText(`Response ${responseNumber}`)
callbacks.onDone()
})
const { result } = renderAgent()
await act(async () => { await result.current.sendMessage('Q1') })
await act(async () => { await result.current.sendMessage('Q2') })
const messageId = result.current.messages[1]?.id ?? ''
await act(async () => { await result.current.regenerateMessage(messageId) })
expect(result.current.messages).toEqual([
expect.objectContaining({ userMessage: 'Q1', response: 'Response 1' }),
expect.objectContaining({ userMessage: 'Q2', response: 'Response 3' }),
])
expect(result.current.messages).toHaveLength(2)
})
it('clones a workspace session through a selected message', async () => {
const sourceSession = renderAgent(undefined, 'safe', 'chat-source')
await act(async () => { await sourceSession.result.current.sendMessage('First') })
await act(async () => { await sourceSession.result.current.sendMessage('Second') })
const firstMessageId = sourceSession.result.current.messages[0]?.id ?? ''
expect(firstMessageId).not.toBe('')
cloneAiWorkspaceSessionUntilMessage('chat-source', 'chat-fork', firstMessageId)
const forkSession = renderAgent(undefined, 'safe', 'chat-fork')
expect(forkSession.result.current.messages).toEqual([
expect.objectContaining({ userMessage: 'First', response: 'reply' }),
])
})
it('adds a local response instead of streaming when the selected agent is unavailable', async () => {
const { result } = renderHook(() => useCliAiAgent(VAULT, undefined, undefined, undefined, {
agent: 'codex',

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import type { AiAgentId } from '../lib/aiAgents'
import type { AiAgentPermissionMode } from '../lib/aiAgentPermissionMode'
import type { AiTarget } from '../lib/aiTargets'
@@ -12,10 +12,16 @@ import type { AgentFileCallbacks } from '../lib/aiAgentFileOperations'
import {
addAgentLocalMarker,
clearAgentConversation,
regenerateAgentMessage,
sendAgentMessage,
type AiAgentSessionRuntime,
} from '../lib/aiAgentSession'
import type { ToolInvocation } from '../lib/aiAgentMessageState'
import {
aiWorkspaceSessionDispatchers,
aiWorkspaceSessionSnapshot,
subscribeAiWorkspaceSession,
} from '../lib/aiWorkspaceSessionStore'
export type { AgentFileCallbacks } from '../lib/aiAgentFileOperations'
export type { AgentStatus } from '../lib/aiAgentConversation'
@@ -26,6 +32,7 @@ interface UseCliAiAgentOptions {
target?: AiTarget
agentReady: boolean
permissionMode: AiAgentPermissionMode
sessionId?: string
}
interface UseCliAiAgentRuntime extends AiAgentSessionRuntime {
@@ -65,6 +72,44 @@ function useCliAiAgentRuntime(fileCallbacks: AgentFileCallbacks | undefined): Us
}
}
function useSharedCliAiAgentRuntime(
sessionId: string | undefined,
fileCallbacks: AgentFileCallbacks | undefined,
): UseCliAiAgentRuntime {
const resolvedSessionId = sessionId ?? ''
const subscribe = useCallback((listener: () => void) => (
resolvedSessionId ? subscribeAiWorkspaceSession(resolvedSessionId, listener) : () => {}
), [resolvedSessionId])
const getSnapshot = useCallback(() => aiWorkspaceSessionSnapshot(resolvedSessionId), [resolvedSessionId])
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const dispatchers = resolvedSessionId
? aiWorkspaceSessionDispatchers(resolvedSessionId)
: { setMessages: () => {}, setStatus: () => {} }
const abortRef = useRef({ aborted: false })
const responseAccRef = useRef('')
const fileCallbacksRef = useRef(fileCallbacks)
const toolInputMapRef = useRef<Map<string, ToolInvocation>>(new Map())
const messagesRef = useRef<AiAgentMessage[]>(snapshot.messages)
const statusRef = useRef<AgentStatus>(snapshot.status)
useEffect(() => { messagesRef.current = snapshot.messages }, [snapshot.messages])
useEffect(() => { statusRef.current = snapshot.status }, [snapshot.status])
useEffect(() => { fileCallbacksRef.current = fileCallbacks }, [fileCallbacks])
return {
messages: snapshot.messages,
setMessages: dispatchers.setMessages,
status: snapshot.status,
setStatus: dispatchers.setStatus,
abortRef,
responseAccRef,
fileCallbacksRef,
toolInputMapRef,
messagesRef,
statusRef,
}
}
export function useCliAiAgent(
vaultPath: string,
vaultPaths: string[] | undefined,
@@ -72,30 +117,48 @@ export function useCliAiAgent(
fileCallbacks: AgentFileCallbacks | undefined,
options: UseCliAiAgentOptions,
) {
const { agent, agentReady, target } = options
const { agent, agentReady, sessionId, target } = options
const { permissionMode } = options
const runtime = useCliAiAgentRuntime(fileCallbacks)
const localRuntime = useCliAiAgentRuntime(fileCallbacks)
const sharedRuntime = useSharedCliAiAgentRuntime(sessionId, fileCallbacks)
const runtime = sessionId ? sharedRuntime : localRuntime
const { messages, status } = runtime
async function sendMessage(text: string, references?: NoteReference[]): Promise<void> {
async function buildAgentContext() {
const agentDocsPath = await getAgentDocsPath()
return {
agent,
agentDocsPath,
target,
ready: agentReady,
vaultPath,
vaultPaths,
permissionMode,
systemPromptOverride: contextPrompt,
}
}
async function sendPrompt(text: string, references?: NoteReference[]): Promise<void> {
await sendAgentMessage({
runtime,
context: {
agent,
agentDocsPath,
target,
ready: agentReady,
vaultPath,
vaultPaths,
permissionMode,
systemPromptOverride: contextPrompt,
},
context: await buildAgentContext(),
prompt: { text, references },
})
}
async function sendMessage(text: string, references?: NoteReference[]): Promise<void> {
await sendPrompt(text, references)
}
async function regenerateMessage(messageId: string): Promise<void> {
await regenerateAgentMessage({
runtime,
context: await buildAgentContext(),
messageId,
})
}
function clearConversation(): void {
clearAgentConversation(runtime)
}
@@ -104,5 +167,5 @@ export function useCliAiAgent(
addAgentLocalMarker(runtime, text)
}
return { messages, status, sendMessage, clearConversation, addLocalMarker }
return { messages, status, sendMessage, regenerateMessage, clearConversation, addLocalMarker }
}

View File

@@ -150,4 +150,16 @@ describe('useLayoutPanels', () => {
})
expect(localStorage.getItem(LEGACY_APP_STORAGE_KEYS.layoutPanels)).toBeNull()
})
it('keeps the resized inspector width across close and reopen toggles', () => {
const { result } = renderHook(() => useLayoutPanels({ initialInspectorCollapsed: false }))
act(() => result.current.handleInspectorResize(-70))
expect(result.current.inspectorWidth).toBe(350)
act(() => result.current.setInspectorCollapsed(true))
act(() => result.current.setInspectorCollapsed(false))
expect(result.current.inspectorWidth).toBe(350)
})
})

View File

@@ -51,7 +51,7 @@ function applyToModules(c: VaultConfig): void {
}
export function useVaultConfig(vaultPath: string) {
const config = useSyncExternalStore(subscribeVaultConfig, getVaultConfig)
const config = useSyncExternalStore(subscribeVaultConfig, getVaultConfig, getVaultConfig)
useEffect(() => {
resetVaultConfigStore()

View File

@@ -392,6 +392,25 @@
width: 100%;
height: 100%;
}
html.ai-workspace-native-window,
html.ai-workspace-native-window body,
html.ai-workspace-native-window #root {
background: transparent;
overflow: hidden;
}
html.ai-workspace-native-window body {
padding: 20px;
}
html.ai-workspace-native-window #root {
background: var(--background);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12), 0 1px 3px rgba(15, 23, 42, 0.10);
}
html.ai-workspace-native-window.dark #root,
html.ai-workspace-native-window[data-theme="dark"] #root {
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.30), 0 1px 4px rgba(0, 0, 0, 0.22);
}
/* Interactive controls should advertise clickability unless disabled. */
:where(
button:not(:disabled):not([aria-disabled="true"]),
@@ -573,8 +592,8 @@
.typing-dot {
display: inline-block;
width: 6px;
height: 6px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--muted-foreground);
animation: typing-bounce 1.2s ease-in-out infinite;
@@ -582,5 +601,5 @@
@keyframes typing-bounce {
0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-3px); }
30% { opacity: 1; transform: translateY(-2px); }
}

View File

@@ -164,6 +164,25 @@ describe('aiAgentConversation', () => {
})
expect(result.systemPrompt).toBe('SYSTEM')
})
it('formats explicit note references into the current prompt', () => {
buildFormattedMessage(
{ agent: 'codex', ready: true, vaultPath: '/vault', permissionMode: 'safe' },
[],
{
text: 'Use this note',
references: [{
path: '/vault/ref.md',
title: 'Ref',
type: 'Note',
content: 'Referenced body',
}],
},
)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([], expect.stringContaining('Referenced body'))
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([], expect.stringContaining('/vault/ref.md'))
})
})
describe('aiAgentMessageState', () => {

View File

@@ -8,7 +8,7 @@ import {
trimHistory,
type ChatMessage,
} from '../utils/ai-chat'
import type { NoteReference } from '../utils/ai-context'
import { formatPromptWithReferences, type NoteReference } from '../utils/ai-context'
import type { AiAgentId } from './aiAgents'
import { getAiAgentDefinition } from './aiAgents'
import type { AiAgentPermissionMode } from './aiAgentPermissionMode'
@@ -123,9 +123,10 @@ export function buildFormattedMessage(
})
const chatHistory = toChatHistory(messages.filter((message) => !message.isStreaming))
const trimmedHistory = trimHistory(chatHistory, MAX_HISTORY_TOKENS)
const promptText = formatPromptWithReferences(prompt.text, prompt.references)
return {
formattedMessage: formatMessageWithHistory(trimmedHistory, prompt.text),
formattedMessage: formatMessageWithHistory(trimmedHistory, promptText),
systemPrompt,
}
}

View File

@@ -5,6 +5,7 @@ const {
buildAgentSystemPromptMock,
createStreamCallbacksMock,
formatMessageWithHistoryMock,
hydrateNoteReferencesMock,
nextMessageIdMock,
streamAiAgentMock,
trackEventMock,
@@ -13,6 +14,7 @@ const {
buildAgentSystemPromptMock: vi.fn(() => 'SYSTEM'),
createStreamCallbacksMock: vi.fn(() => ({ stream: 'callbacks' })),
formatMessageWithHistoryMock: vi.fn((_history: unknown, prompt: string) => `formatted:${prompt}`),
hydrateNoteReferencesMock: vi.fn(async (references: unknown) => references),
nextMessageIdMock: vi.fn(),
streamAiAgentMock: vi.fn(async () => {}),
trackEventMock: vi.fn(),
@@ -38,6 +40,10 @@ vi.mock('../utils/streamAiAgent', () => ({
streamAiAgent: streamAiAgentMock,
}))
vi.mock('../utils/ai-reference-content', () => ({
hydrateNoteReferences: hydrateNoteReferencesMock,
}))
vi.mock('./telemetry', () => ({
trackEvent: trackEventMock,
}))
@@ -120,7 +126,14 @@ function expectStreamingRuntimeState(session: RuntimeFixture): void {
function expectFormattedHistoryUsed(): void {
expect(trimHistoryMock).toHaveBeenCalledWith(expectedChatHistory, 100_000)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith(expectedChatHistory, 'Latest question')
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith(
expectedChatHistory,
expect.stringContaining('Latest question'),
)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith(
expectedChatHistory,
expect.stringContaining('/vault/ref.md'),
)
}
function expectStreamingRequest(runtime: RuntimeFixture['runtime']): void {
@@ -132,7 +145,7 @@ function expectStreamingRequest(runtime: RuntimeFixture['runtime']): void {
}))
expect(streamAiAgentMock).toHaveBeenCalledWith({
agent: 'codex',
message: 'formatted:Latest question',
message: expect.stringContaining('formatted:Latest question'),
systemPrompt: 'SYSTEM',
vaultPath: '/vault',
permissionMode: 'power_user',
@@ -148,6 +161,7 @@ describe('aiAgentSession', () => {
formatMessageWithHistoryMock.mockImplementation((_history: unknown, prompt: string) => `formatted:${prompt}`)
trimHistoryMock.mockImplementation((history: unknown) => history)
streamAiAgentMock.mockResolvedValue(undefined)
hydrateNoteReferencesMock.mockImplementation(async (references: unknown) => references)
trackEventMock.mockClear()
})
@@ -255,6 +269,7 @@ describe('aiAgentSession', () => {
})
expectStreamingRuntimeState(session)
expect(hydrateNoteReferencesMock).toHaveBeenCalledWith([{ path: '/vault/ref.md', title: 'Ref' }])
expectFormattedHistoryUsed()
expect(buildAgentSystemPromptMock).toHaveBeenCalledWith({
agent: 'codex',

View File

@@ -16,6 +16,7 @@ import type { ToolInvocation } from './aiAgentMessageState'
import { trackAiAgentMessageBlocked, trackAiAgentMessageSent } from './productAnalytics'
import { streamAiAgent } from '../utils/streamAiAgent'
import { streamAiModel } from '../utils/streamAiModel'
import { hydrateNoteReferences } from '../utils/ai-reference-content'
export interface AiAgentSessionRuntime {
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>
@@ -34,6 +35,12 @@ interface SendAgentMessageOptions {
prompt: PendingUserPrompt
}
interface RegenerateAgentMessageOptions {
runtime: AiAgentSessionRuntime
context: AgentExecutionContext
messageId: string
}
function normalizePrompt(prompt: PendingUserPrompt): PendingUserPrompt {
return {
text: prompt.text.trim(),
@@ -125,11 +132,15 @@ export async function sendAgentMessage({
const messageId = appendStreamingMessage(runtime.setMessages, normalizedPrompt)
runtime.setStatus('thinking')
const promptForAgent = {
...normalizedPrompt,
references: await hydrateNoteReferences(normalizedPrompt.references),
}
const { formattedMessage, systemPrompt } = buildFormattedMessage(
context,
runtime.messagesRef.current,
normalizedPrompt,
promptForAgent,
)
const callbacks = createStreamCallbacks({
@@ -147,6 +158,35 @@ export async function sendAgentMessage({
await streamWithSelectedTarget(context, formattedMessage, systemPrompt, callbacks)
}
export async function regenerateAgentMessage({
runtime,
context,
messageId,
}: RegenerateAgentMessageOptions): Promise<void> {
const currentMessages = runtime.messagesRef.current
const messageIndex = currentMessages.findIndex((message) => message.id === messageId)
const message = currentMessages[messageIndex]
if (!message || message.localMarker || runtime.statusRef.current === 'thinking' || runtime.statusRef.current === 'tool-executing') return
const preservedMessages = currentMessages.slice(0, messageIndex)
runtime.abortRef.current = { aborted: false }
runtime.responseAccRef.current = ''
runtime.toolInputMapRef.current = new Map()
runtime.messagesRef.current = preservedMessages
runtime.statusRef.current = 'idle'
runtime.setMessages(preservedMessages)
runtime.setStatus('idle')
await sendAgentMessage({
runtime,
context,
prompt: {
text: message.userMessage,
references: message.references,
},
})
}
export function addAgentLocalMarker(
runtime: Pick<AiAgentSessionRuntime, 'setMessages'>,
text: string,

View File

@@ -29,6 +29,7 @@ describe('aiAgents helpers', () => {
opencode: { installed: true, version: '0.3.1' },
pi: { installed: true, version: '0.70.2' },
gemini: { installed: true, version: '0.5.1' },
kiro: { installed: true, version: '0.12.0' },
})
expect(statuses.claude_code).toEqual({ status: 'installed', version: '1.0.20' })
@@ -36,6 +37,7 @@ describe('aiAgents helpers', () => {
expect(statuses.opencode).toEqual({ status: 'installed', version: '0.3.1' })
expect(statuses.pi).toEqual({ status: 'installed', version: '0.70.2' })
expect(statuses.gemini).toEqual({ status: 'installed', version: '0.5.1' })
expect(statuses.kiro).toEqual({ status: 'installed', version: '0.12.0' })
})
it('cycles through the supported agents', () => {

View File

@@ -0,0 +1,172 @@
import type { Dispatch, SetStateAction } from 'react'
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
const STORAGE_KEY = 'tolaria:ai-workspace-sessions:v1'
const BROADCAST_CHANNEL = 'tolaria-ai-workspace-sessions'
export interface AiWorkspaceSessionSnapshot {
messages: AiAgentMessage[]
status: AgentStatus
}
type SessionMap = Record<string, AiWorkspaceSessionSnapshot>
type Listener = () => void
const EMPTY_SESSION: AiWorkspaceSessionSnapshot = {
messages: [],
status: 'idle',
}
let sessions: SessionMap = readStoredSessions()
let broadcastChannel: BroadcastChannel | null = null
const listeners = new Set<Listener>()
function isSessionSnapshot(value: unknown): value is AiWorkspaceSessionSnapshot {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<AiWorkspaceSessionSnapshot>
return Array.isArray(candidate.messages) && typeof candidate.status === 'string'
}
function readStoredSessions(): SessionMap {
if (typeof localStorage === 'undefined') return {}
try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<string, unknown>
return Object.fromEntries(
Object.entries(parsed).filter((entry): entry is [string, AiWorkspaceSessionSnapshot] => (
typeof entry[0] === 'string' && isSessionSnapshot(entry[1])
)),
)
} catch {
return {}
}
}
function writeStoredSessions(): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions))
} catch {
// Session history is a convenience cache; streaming state still lives in memory.
}
}
function notifyListeners(): void {
for (const listener of listeners) listener()
}
function broadcastSessions(): void {
if (typeof BroadcastChannel === 'undefined') return
broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL)
broadcastChannel.postMessage({ type: 'ai-workspace-sessions-updated' })
}
function publishSessions(): void {
writeStoredSessions()
broadcastSessions()
notifyListeners()
}
function replaceSessions(nextSessions: SessionMap): void {
sessions = nextSessions
notifyListeners()
}
function syncFromStorage(): void {
replaceSessions(readStoredSessions())
}
function ensureCrossWindowSync(): void {
if (typeof window === 'undefined') return
window.addEventListener('storage', (event) => {
if (event.key === STORAGE_KEY) syncFromStorage()
})
if (typeof BroadcastChannel === 'undefined') return
broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL)
broadcastChannel.onmessage = syncFromStorage
}
ensureCrossWindowSync()
export function aiWorkspaceSessionSnapshot(sessionId: string): AiWorkspaceSessionSnapshot {
return sessions[sessionId] ?? EMPTY_SESSION
}
export function subscribeAiWorkspaceSession(_sessionId: string, listener: Listener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function setAiWorkspaceSessionMessages(
sessionId: string,
next: SetStateAction<AiAgentMessage[]>,
): void {
const current = aiWorkspaceSessionSnapshot(sessionId)
const messages = typeof next === 'function' ? next(current.messages) : next
sessions = {
...sessions,
[sessionId]: {
...current,
messages,
},
}
publishSessions()
}
export function setAiWorkspaceSessionStatus(
sessionId: string,
next: SetStateAction<AgentStatus>,
): void {
const current = aiWorkspaceSessionSnapshot(sessionId)
const status = typeof next === 'function' ? next(current.status) : next
sessions = {
...sessions,
[sessionId]: {
...current,
status,
},
}
publishSessions()
}
export function resetAiWorkspaceSession(sessionId: string): void {
sessions = {
...sessions,
[sessionId]: EMPTY_SESSION,
}
publishSessions()
}
export function cloneAiWorkspaceSessionUntilMessage(sourceSessionId: string, targetSessionId: string, messageId: string): void {
const source = aiWorkspaceSessionSnapshot(sourceSessionId)
const messageIndex = source.messages.findIndex((message) => message.id === messageId)
const messages = messageIndex >= 0 ? source.messages.slice(0, messageIndex + 1) : source.messages
sessions = {
...sessions,
[targetSessionId]: {
messages: messages.map((message) => ({ ...message, isStreaming: false })),
status: 'idle',
},
}
publishSessions()
}
export function aiWorkspaceSessionDispatchers(sessionId: string): {
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>
setStatus: Dispatch<SetStateAction<AgentStatus>>
} {
return {
setMessages: (next) => setAiWorkspaceSessionMessages(sessionId, next),
setStatus: (next) => setAiWorkspaceSessionStatus(sessionId, next),
}
}
export function resetAiWorkspaceSessionStoreForTests(): void {
sessions = {}
writeStoredSessions()
notifyListeners()
}

View File

@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VaultEntry } from '../types'
const STORAGE_KEY = 'tolaria:ai-workspace-window-context:v1'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note.md',
filename: 'note.md',
title: 'Note',
isA: 'Project',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: 'Snippet',
wordCount: 42,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: true,
organized: true,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
...overrides,
})
describe('aiWorkspaceWindowSharedContext', () => {
let store: Record<string, string>
beforeEach(() => {
vi.resetModules()
store = {}
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
})
vi.stubGlobal('BroadcastChannel', class {
onmessage: (() => void) | null = null
postMessage = vi.fn()
})
})
it('preserves note metadata needed by the AI context builder', async () => {
const {
aiWorkspaceWindowSharedContextSnapshot,
publishAiWorkspaceWindowSharedContext,
} = await import('./aiWorkspaceWindowSharedContext')
const activeEntry = makeEntry({
path: '/vault/active.md',
title: 'Active',
aliases: ['A'],
belongsTo: ['[[Parent]]'],
relatedTo: ['[[Sibling]]'],
outgoingLinks: ['Linked'],
properties: { Owner: 'Alice' },
relationships: { People: ['[[Alice]]'] },
wordCount: 128,
})
publishAiWorkspaceWindowSharedContext({
activeEntry,
activeNoteContent: '# Active',
entries: [activeEntry],
openTabs: [activeEntry],
vaultPath: '/vault',
vaultPaths: ['/vault'],
})
const snapshot = aiWorkspaceWindowSharedContextSnapshot()
expect(snapshot.activeEntry?.outgoingLinks).toEqual(['Linked'])
expect(snapshot.activeEntry?.belongsTo).toEqual(['[[Parent]]'])
expect(snapshot.activeEntry?.relatedTo).toEqual(['[[Sibling]]'])
expect(snapshot.activeEntry?.relationships).toEqual({ People: ['[[Alice]]'] })
expect(snapshot.activeEntry?.properties).toEqual({ Owner: 'Alice' })
expect(snapshot.activeEntry?.wordCount).toBe(128)
const stored = JSON.parse(store[STORAGE_KEY])
expect(stored.activeEntry.outgoingLinks).toEqual(['Linked'])
expect(stored.activeEntry.relationships).toEqual({ People: ['[[Alice]]'] })
})
})

View File

@@ -0,0 +1,176 @@
import type { AiWorkspaceWindowContext } from '../utils/openAiWorkspaceWindow'
import type { NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
const STORAGE_KEY = 'tolaria:ai-workspace-window-context:v1'
const BROADCAST_CHANNEL = 'tolaria-ai-workspace-window-context'
type Listener = () => void
export interface AiWorkspaceWindowSharedContext extends AiWorkspaceWindowContext {
activeEntry?: VaultEntry | null
activeNoteContent?: string | null
entries?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
openTabs?: VaultEntry[]
}
const EMPTY_CONTEXT: AiWorkspaceWindowSharedContext = {}
let context = readStoredContext()
let broadcastChannel: BroadcastChannel | null = null
const listeners = new Set<Listener>()
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isVaultEntry(value: unknown): value is VaultEntry {
if (!isRecord(value)) return false
return typeof value.path === 'string'
&& typeof value.filename === 'string'
&& typeof value.title === 'string'
&& Array.isArray(value.aliases)
}
function isNoteListItem(value: unknown): value is NoteListItem {
if (!isRecord(value)) return false
return typeof value.path === 'string'
&& typeof value.title === 'string'
&& typeof value.type === 'string'
}
function sanitizeEntries(value: unknown): VaultEntry[] | undefined {
if (!Array.isArray(value)) return undefined
const entries = value.filter(isVaultEntry)
return entries.length > 0 ? entries : undefined
}
function sanitizeNoteList(value: unknown): NoteListItem[] | undefined {
if (!Array.isArray(value)) return undefined
const noteList = value.filter(isNoteListItem)
return noteList.length > 0 ? noteList : undefined
}
function sanitizeContext(value: unknown): AiWorkspaceWindowSharedContext {
if (!isRecord(value)) return EMPTY_CONTEXT
const activeEntry = value.activeEntry === null || isVaultEntry(value.activeEntry)
? value.activeEntry
: undefined
const noteListFilter = isRecord(value.noteListFilter)
? {
type: typeof value.noteListFilter.type === 'string' ? value.noteListFilter.type : null,
query: typeof value.noteListFilter.query === 'string' ? value.noteListFilter.query : '',
}
: undefined
return {
activeConversationId: typeof value.activeConversationId === 'string' ? value.activeConversationId : undefined,
activeEntry,
activeNoteContent: typeof value.activeNoteContent === 'string' ? value.activeNoteContent : null,
entries: sanitizeEntries(value.entries),
noteList: sanitizeNoteList(value.noteList),
noteListFilter,
openTabs: sanitizeEntries(value.openTabs),
vaultPath: typeof value.vaultPath === 'string' ? value.vaultPath : undefined,
vaultPaths: Array.isArray(value.vaultPaths)
? value.vaultPaths.filter((item): item is string => typeof item === 'string')
: undefined,
}
}
function readStoredContext(): AiWorkspaceWindowSharedContext {
if (typeof localStorage === 'undefined') return EMPTY_CONTEXT
try {
return sanitizeContext(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'))
} catch {
return EMPTY_CONTEXT
}
}
function writeStoredContext(): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(context))
} catch {
// The AI window can still open without shared editor context.
}
}
function notify(): void {
for (const listener of listeners) listener()
}
function broadcast(): void {
if (typeof BroadcastChannel === 'undefined') return
broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL)
broadcastChannel.postMessage({ type: 'ai-workspace-window-context-updated' })
}
function syncFromStorage(): void {
context = readStoredContext()
notify()
}
function ensureCrossWindowSync(): void {
if (typeof window === 'undefined') return
window.addEventListener('storage', (event) => {
if (event.key === STORAGE_KEY) syncFromStorage()
})
if (typeof BroadcastChannel === 'undefined') return
broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL)
broadcastChannel.onmessage = syncFromStorage
}
function cloneEntryForWindowContext(entry: VaultEntry): VaultEntry {
return {
...entry,
createdAt: entry.createdAt ?? null,
modifiedAt: entry.modifiedAt ?? null,
aliases: [...entry.aliases],
belongsTo: [...entry.belongsTo],
relatedTo: [...entry.relatedTo],
outgoingLinks: [...entry.outgoingLinks],
listPropertiesDisplay: [...entry.listPropertiesDisplay],
properties: { ...entry.properties },
relationships: Object.fromEntries(
Object.entries(entry.relationships).map(([key, values]) => [key, [...values]]),
),
}
}
function cloneContextForWindow(nextContext: AiWorkspaceWindowSharedContext): AiWorkspaceWindowSharedContext {
return {
...nextContext,
activeEntry: nextContext.activeEntry ? cloneEntryForWindowContext(nextContext.activeEntry) : nextContext.activeEntry,
entries: nextContext.entries?.map(cloneEntryForWindowContext),
openTabs: nextContext.openTabs?.map(cloneEntryForWindowContext),
noteList: nextContext.noteList?.map((item) => ({ ...item })),
noteListFilter: nextContext.noteListFilter ? { ...nextContext.noteListFilter } : nextContext.noteListFilter,
vaultPaths: nextContext.vaultPaths ? [...nextContext.vaultPaths] : nextContext.vaultPaths,
}
}
export function aiWorkspaceWindowSharedContextSnapshot(): AiWorkspaceWindowSharedContext {
return context
}
export function subscribeAiWorkspaceWindowSharedContext(listener: Listener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function publishAiWorkspaceWindowSharedContext(nextContext: AiWorkspaceWindowSharedContext): void {
context = cloneContextForWindow(nextContext)
writeStoredContext()
broadcast()
notify()
}
ensureCrossWindowSync()

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "Паведамленні стануць даступныя пасля загрузкі",
"ai.panel.empty.missingTitle": "{agent} недаступны на гэтай прыладзе",
"ai.panel.empty.missingDescription": "Усталюйце або змяніце агента ў Наладах",
"ai.panel.empty.withContextTitle": "Запытайце {agent} аб гэтай нататцы",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Адкрыйце нататку, каб пачаць размову з {agent}",
"ai.panel.empty.withContextDescription": "Рэзюмаваць, шукаць сувязі, генераваць ідэі",
"ai.panel.empty.noContextDescription": "ШІ будзе выкарыстоўваць актыўную нататку як кантэкст",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} не ўсталяваны. Адкрыйце Налады.",
"ai.panel.placeholder.ready": "Запытаць {agent}",
"ai.panel.send": "Адправіць паведамленне",
"ai.workspace.title": "Працоўная прастора ШІ",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "Агенты",
"ai.workspace.newChat": "Новы чат",
"ai.workspace.collapseSidebar": "Згарнуць спіс чатаў са ШІ",
"ai.workspace.expandSidebar": "Разгарнуць спіс чатаў са ШІ",
"ai.workspace.close": "Зачыніць працоўную прастору ШІ",
"ai.workspace.expandPanel": "Разгарнуць працоўную вобласць AI",
"ai.workspace.restorePanel": "Аднавіць панэль працоўнай прасторы ШІ",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "Зачыніць {title}",
"ai.workspace.popOut": "Адкрыць у асобным акне",
"ai.workspace.dock": "Закрыць працоўную прастору ШІ",
"ai.workspace.settings": "Налады ШІ",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Схаваць архівыя чаты",
"ai.workspace.noActiveChats": "Няма актыўных чатаў",
"ai.workspace.noArchivedChats": "Няма архіваваных чатаў",
"ai.workspace.chatTitle": "Чат {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Перайменаваць чат",
"ai.workspace.status.archived": "Архівавана",
"ai.workspace.status.idle": "Бяздзейны",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "Paviedamlienni stanuć dastupnyja paslia zahruzki",
"ai.panel.empty.missingTitle": "{agent} niedastupny na hetaj pryladzie",
"ai.panel.empty.missingDescription": "Ustaliujcie abo zmianicie ahienta ŭ Naladach",
"ai.panel.empty.withContextTitle": "Zapytajcie {agent} ab hetaj natatcy",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Adkryjcie natatku, kab pačać razmovu z {agent}",
"ai.panel.empty.withContextDescription": "Reziumavać, šukać suviazi, hienieravać idei",
"ai.panel.empty.noContextDescription": "ŠI budzie vykarystoŭvać aktyŭnuju natatku jak kantekst",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} nie ŭstaliavany. Adkryjcie Nalady.",
"ai.panel.placeholder.ready": "Zapytać {agent}",
"ai.panel.send": "Adpravić paviedamliennie",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "ŠI prastora",
"ai.workspace.newChat": "Novy čat",
"ai.workspace.collapseSidebar": "Zharnuć spis čataŭ sa ŠI",
"ai.workspace.expandSidebar": "Razhornuć spis čataŭ sa ŠI",
"ai.workspace.close": "Zakryć ŠI prastoru",
"ai.workspace.expandPanel": "Razhornuć ŠI prastoru",
"ai.workspace.restorePanel": "Adnavić panel ŠI prastory",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "Zakryć {title}",
"ai.workspace.popOut": "Adkryć u asobnym aknie",
"ai.workspace.dock": "Prymacavać ŠI prastoru",
"ai.workspace.settings": "Nalady ŠI",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Schavać archivavanyja čaty",
"ai.workspace.noActiveChats": "Niama aktyŭnych čataŭ",
"ai.workspace.noArchivedChats": "Niama archivavanych čataŭ",
"ai.workspace.chatTitle": "Čat {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Pierajmienavać čat",
"ai.workspace.status.archived": "Archivavany",
"ai.workspace.status.idle": "Čakannie",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "Nachrichten können gesendet werden, sobald der ausgewählte Agent bereit ist",
"ai.panel.empty.missingTitle": "{agent} ist auf diesem Computer nicht verfügbar",
"ai.panel.empty.missingDescription": "Installieren Sie ihn oder ändern Sie den standardmäßigen KI-Agenten in den Einstellungen.",
"ai.panel.empty.withContextTitle": "{agent} nach dieser Notiz und ihrem verknüpften Kontext fragen",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Öffnen Sie eine Notiz und fragen Sie dann {agent} danach",
"ai.panel.empty.withContextDescription": "Zusammenfassen, Zusammenhänge finden, Ideen ausbauen",
"ai.panel.empty.noContextDescription": "Die KI verwendet die aktive Notiz als Kontext",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} ist nicht installiert. Öffnen Sie „AI Agents“ in den Einstellungen.",
"ai.panel.placeholder.ready": "{agent} fragen",
"ai.panel.send": "Nachricht senden",
"ai.workspace.title": "KI-Workspace",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "Agenten",
"ai.workspace.newChat": "Neuer Chat",
"ai.workspace.collapseSidebar": "AI-Chat-Liste einklappen",
"ai.workspace.expandSidebar": "KI-Chat-Liste erweitern",
"ai.workspace.close": "AI-Workspace schließen",
"ai.workspace.expandPanel": "AI-Workspace erweitern",
"ai.workspace.restorePanel": "AI-Workspace-Panel wiederherstellen",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "{title} schließen",
"ai.workspace.popOut": "In einem separaten Fenster öffnen",
"ai.workspace.dock": "AI-Workspace andocken",
"ai.workspace.settings": "KI-Einstellungen",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Archivierte Chats ausblenden",
"ai.workspace.noActiveChats": "Keine aktiven Chats",
"ai.workspace.noArchivedChats": "Keine archivierten Chats",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Chat umbenennen",
"ai.workspace.status.archived": "Archiviert",
"ai.workspace.status.idle": "Inaktiv",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "Messages can be sent when the selected agent is ready",
"ai.panel.empty.missingTitle": "{agent} is not available on this machine",
"ai.panel.empty.missingDescription": "Install it or switch the default AI agent in Settings",
"ai.panel.empty.withContextTitle": "Ask {agent} about this note and its linked context",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Open a note, then ask {agent} about it",
"ai.panel.empty.withContextDescription": "Summarize, find connections, expand ideas",
"ai.panel.empty.noContextDescription": "The AI will use the active note as context",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} is not installed. Open AI Agents in Settings.",
"ai.panel.placeholder.ready": "Ask {agent}",
"ai.panel.send": "Send message",
"ai.workspace.title": "AI workspace",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "Agents",
"ai.workspace.newChat": "New chat",
"ai.workspace.collapseSidebar": "Collapse AI chat list",
"ai.workspace.expandSidebar": "Expand AI chat list",
"ai.workspace.close": "Close AI workspace",
"ai.workspace.expandPanel": "Expand AI workspace",
"ai.workspace.restorePanel": "Restore AI workspace panel",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "Close {title}",
"ai.workspace.popOut": "Open in separate window",
"ai.workspace.dock": "Dock AI workspace",
"ai.workspace.settings": "AI settings",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Hide archived chats",
"ai.workspace.noActiveChats": "No active chats",
"ai.workspace.noArchivedChats": "No archived chats",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Rename chat",
"ai.workspace.status.archived": "Archived",
"ai.workspace.status.idle": "Idle",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "Se podrán enviar mensajes cuando el agente seleccionado esté disponible",
"ai.panel.empty.missingTitle": "{agent} no está disponible en esta máquina",
"ai.panel.empty.missingDescription": "Instálelo o cambie el agente de IA predeterminado en Configuración",
"ai.panel.empty.withContextTitle": "Pregúntele a {agent} sobre esta nota y su contexto vinculado",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Abra una nota y luego pregúntele a {agent} al respecto",
"ai.panel.empty.withContextDescription": "Resumir, encontrar conexiones, desarrollar ideas",
"ai.panel.empty.noContextDescription": "La IA utilizará la nota activa como contexto",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} no está instalado. Abra Agentes de IA en Configuración.",
"ai.panel.placeholder.ready": "Preguntar a {agent}",
"ai.panel.send": "Enviar mensaje",
"ai.workspace.title": "Espacio de trabajo de IA",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "Agentes",
"ai.workspace.newChat": "Nuevo chat",
"ai.workspace.collapseSidebar": "Contraer la lista de chat de IA",
"ai.workspace.expandSidebar": "Expandir la lista de chat de IA",
"ai.workspace.close": "Cerrar el espacio de trabajo de IA",
"ai.workspace.expandPanel": "Expandir el espacio de trabajo de IA",
"ai.workspace.restorePanel": "Restaurar el panel del espacio de trabajo de IA",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "Cerrar {title}",
"ai.workspace.popOut": "Abrir en una ventana separada",
"ai.workspace.dock": "Acoplar el espacio de trabajo de IA",
"ai.workspace.settings": "Configuración de IA",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Ocultar chats archivados",
"ai.workspace.noActiveChats": "No hay chats activos",
"ai.workspace.noArchivedChats": "No hay chats archivados",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Cambiar el nombre del chat",
"ai.workspace.status.archived": "Archivado",
"ai.workspace.status.idle": "Inactivo",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "Se podrán enviar mensajes cuando el agente seleccionado esté disponible",
"ai.panel.empty.missingTitle": "{agent} no está disponible en este equipo",
"ai.panel.empty.missingDescription": "Instálelo o cambie el agente de IA predeterminado en Ajustes",
"ai.panel.empty.withContextTitle": "Preguntar a {agent} sobre esta nota y su contexto vinculado",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Abre una nota y, a continuación, pregúntale a {agent} sobre ella",
"ai.panel.empty.withContextDescription": "Resumir, encontrar conexiones, desarrollar ideas",
"ai.panel.empty.noContextDescription": "La IA utilizará la nota activa como contexto",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} no está instalado. Abre Agentes de IA en Ajustes.",
"ai.panel.placeholder.ready": "Preguntar a {agent}",
"ai.panel.send": "Enviar mensaje",
"ai.workspace.title": "Espacio de trabajo de IA",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "Agentes",
"ai.workspace.newChat": "Nuevo chat",
"ai.workspace.collapseSidebar": "Contraer la lista de chat de IA",
"ai.workspace.expandSidebar": "Expandir la lista de chat de IA",
"ai.workspace.close": "Cerrar el espacio de trabajo de IA",
"ai.workspace.expandPanel": "Expandir el espacio de trabajo de IA",
"ai.workspace.restorePanel": "Restaurar el panel del espacio de trabajo de IA",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "Cerrar {title}",
"ai.workspace.popOut": "Abrir en una ventana separada",
"ai.workspace.dock": "Acoplar el espacio de trabajo de IA",
"ai.workspace.settings": "Ajustes de IA",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Ocultar chats archivados",
"ai.workspace.noActiveChats": "No hay chats activos",
"ai.workspace.noArchivedChats": "No hay chats archivados",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Cambiar el nombre del chat",
"ai.workspace.status.archived": "Archivado",
"ai.workspace.status.idle": "Inactivo",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "Les messages pourront être envoyés lorsque l'agent sélectionné sera disponible",
"ai.panel.empty.missingTitle": "{agent} n'est pas disponible sur cet ordinateur",
"ai.panel.empty.missingDescription": "Installez-le ou modifiez l'agent d'IA par défaut dans les Paramètres",
"ai.panel.empty.withContextTitle": "Interroger {agent} sur cette note et son contexte associé",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Ouvrez une note, puis posez des questions à {agent} à son sujet",
"ai.panel.empty.withContextDescription": "Résumer, trouver des liens, développer des idées",
"ai.panel.empty.noContextDescription": "L'IA utilisera la note active comme contexte",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} n'est pas installé. Ouvrez Agents IA dans les Paramètres.",
"ai.panel.placeholder.ready": "Demander à {agent}",
"ai.panel.send": "Envoyer un message",
"ai.workspace.title": "Espace de travail IA",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "Agents",
"ai.workspace.newChat": "Nouveau chat",
"ai.workspace.collapseSidebar": "Réduire la liste de chat IA",
"ai.workspace.expandSidebar": "Développer la liste de chat IA",
"ai.workspace.close": "Fermer l'espace de travail IA",
"ai.workspace.expandPanel": "Développer l'espace de travail IA",
"ai.workspace.restorePanel": "Restaurer le panneau de l'espace de travail IA",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "Fermer {title}",
"ai.workspace.popOut": "Ouvrir dans une fenêtre séparée",
"ai.workspace.dock": "Ancrer l'espace de travail IA",
"ai.workspace.settings": "Paramètres de l'IA",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Masquer les chats archivés",
"ai.workspace.noActiveChats": "Aucun chat actif",
"ai.workspace.noArchivedChats": "Aucun chat archivé",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Renommer le chat",
"ai.workspace.status.archived": "Archivé",
"ai.workspace.status.idle": "Inactif",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "Pesan dapat dikirim ketika agen yang dipilih sudah siap",
"ai.panel.empty.missingTitle": "{agent} tidak tersedia di mesin ini",
"ai.panel.empty.missingDescription": "Pasang atau ganti agen AI default di Pengaturan",
"ai.panel.empty.withContextTitle": "Tanyai {agent} tentang catatan ini dan konteks tertautnya",
"ai.panel.empty.withContextTitle": "Tanyakan apa saja kepada {agent}",
"ai.panel.empty.noContextTitle": "Buka catatan, lalu tanyai {agent} tentangnya",
"ai.panel.empty.withContextDescription": "Rangkum, temukan koneksi, kembangkan ide",
"ai.panel.empty.noContextDescription": "AI akan menggunakan catatan aktif sebagai konteks",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} tidak terpasang. Buka Agen AI di Pengaturan.",
"ai.panel.placeholder.ready": "Tanyai {agent}",
"ai.panel.send": "Kirim pesan",
"ai.workspace.title": "Ruang kerja AI",
"ai.message.regenerate": "Buat ulang respons",
"ai.message.copy": "Salin respons",
"ai.message.fork": "Fork obrolan dari sini",
"ai.workspace.title": "Agen",
"ai.workspace.newChat": "Obrolan baru",
"ai.workspace.collapseSidebar": "Ciutkan daftar obrolan AI",
"ai.workspace.expandSidebar": "Perluas daftar obrolan AI",
"ai.workspace.close": "Tutup ruang kerja AI",
"ai.workspace.expandPanel": "Perluas ruang kerja AI",
"ai.workspace.restorePanel": "Pulihkan panel ruang kerja AI",
"ai.workspace.restoreGuidance": "Pulihkan",
"ai.workspace.closeChat": "Tutup {title}",
"ai.workspace.popOut": "Buka di jendela terpisah",
"ai.workspace.dock": "Dok ruang kerja AI",
"ai.workspace.settings": "Pengaturan AI",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Sembunyikan obrolan yang diarsipkan",
"ai.workspace.noActiveChats": "Tidak ada obrolan aktif",
"ai.workspace.noArchivedChats": "Tidak ada obrolan yang diarsipkan",
"ai.workspace.chatTitle": "Obrolan {index}",
"ai.workspace.chatTitle": "Obrolan AI {index}",
"ai.workspace.renameChat": "Ubah nama obrolan",
"ai.workspace.status.archived": "Diarsipkan",
"ai.workspace.status.idle": "Siaga",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "È possibile inviare messaggi quando l'agente selezionato è disponibile",
"ai.panel.empty.missingTitle": "{agent} non è disponibile su questo computer",
"ai.panel.empty.missingDescription": "Installalo o cambia l'agente IA predefinito nelle Impostazioni",
"ai.panel.empty.withContextTitle": "Chiedi a {agent} informazioni su questa nota e sul contesto a cui è collegata",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Apri una nota, quindi chiedi a {agent} informazioni al riguardo",
"ai.panel.empty.withContextDescription": "Riassumi, trova connessioni, sviluppa idee",
"ai.panel.empty.noContextDescription": "L'IA utilizzerà la nota attiva come contesto",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} non è installato. Apri Agenti IA in Impostazioni.",
"ai.panel.placeholder.ready": "Chiedi a {agent}",
"ai.panel.send": "Invia messaggio",
"ai.workspace.title": "Area di lavoro IA",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "Agenti",
"ai.workspace.newChat": "Nuova chat",
"ai.workspace.collapseSidebar": "Comprimi l'elenco delle chat con l'IA",
"ai.workspace.expandSidebar": "Espandi l'elenco delle chat con l'IA",
"ai.workspace.close": "Chiudi l'area di lavoro IA",
"ai.workspace.expandPanel": "Espandi l'area di lavoro AI",
"ai.workspace.restorePanel": "Ripristina il pannello AI workspace",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "Chiudi {title}",
"ai.workspace.popOut": "Apri in una finestra separata",
"ai.workspace.dock": "Ancora l'area di lavoro IA",
"ai.workspace.settings": "Impostazioni IA",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Nascondi chat archiviate",
"ai.workspace.noActiveChats": "Nessuna chat attiva",
"ai.workspace.noArchivedChats": "Nessuna chat archiviata",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Rinomina chat",
"ai.workspace.status.archived": "Archiviate",
"ai.workspace.status.idle": "Inattivo",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "選択したエージェントの準備が整ったら、メッセージを送信できます",
"ai.panel.empty.missingTitle": "{agent}はこのマシンでは利用できません",
"ai.panel.empty.missingDescription": "インストールするか、設定でデフォルトのAIエージェントを切り替えてください",
"ai.panel.empty.withContextTitle": "{agent}にこのノートとその関連コンテキストについて尋ねる",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "ノートを開いてから、{agent}にそのノートについて尋ねます",
"ai.panel.empty.withContextDescription": "要約し、つながりを見つけ、アイデアを発展させる",
"ai.panel.empty.noContextDescription": "AIはアクティブなートをコンテキストとして使用します",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent}がインストールされていません。[設定] で [AI エージェント] を開きます。",
"ai.panel.placeholder.ready": "{agent}に尋ねる",
"ai.panel.send": "メッセージを送信",
"ai.workspace.title": "AIワークスペース",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "エージェント",
"ai.workspace.newChat": "新しいチャット",
"ai.workspace.collapseSidebar": "AIチャットリストを折りたたむ",
"ai.workspace.expandSidebar": "AIチャットリストを展開",
"ai.workspace.close": "AIワークスペースを閉じる",
"ai.workspace.expandPanel": "AIワークスペースを展開",
"ai.workspace.restorePanel": "AIワークスペースパネルを復元",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "{title}を閉じる",
"ai.workspace.popOut": "別のウィンドウで開く",
"ai.workspace.dock": "AIワークスペースをドック",
"ai.workspace.settings": "AI設定",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "アーカイブされたチャットを非表示にする",
"ai.workspace.noActiveChats": "アクティブなチャットはありません",
"ai.workspace.noArchivedChats": "アーカイブされたチャットはありません",
"ai.workspace.chatTitle": "チャット {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "チャットの名前を変更",
"ai.workspace.status.archived": "アーカイブ済み",
"ai.workspace.status.idle": "アイドル",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "선택한 에이전트가 준비되면 메시지를 보낼 수 있습니다.",
"ai.panel.empty.missingTitle": "이 컴퓨터에서는 {agent}을(를) 사용할 수 없습니다.",
"ai.panel.empty.missingDescription": "설치하거나 설정에서 기본 AI 에이전트를 변경하세요.",
"ai.panel.empty.withContextTitle": "{agent}에게 이 노트와 연결된 컨텍스트에 대해 물어보세요",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "노트를 열고 {agent}에게 해당 노트에 대해 질문하세요.",
"ai.panel.empty.withContextDescription": "요약하고, 연결 고리를 찾고, 아이디어를 확장하세요",
"ai.panel.empty.noContextDescription": "AI는 활성 노트를 배경 정보로 사용합니다.",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent}이(가) 설치되어 있지 않습니다. 설정에서 AI Agents를 여세요.",
"ai.panel.placeholder.ready": "{agent}에게 물어보기",
"ai.panel.send": "메시지 보내기",
"ai.workspace.title": "AI 작업 공간",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "에이전트",
"ai.workspace.newChat": "새 채팅",
"ai.workspace.collapseSidebar": "AI 채팅 목록 접기",
"ai.workspace.expandSidebar": "AI 채팅 목록 펼치기",
"ai.workspace.close": "AI 작업 공간 닫기",
"ai.workspace.expandPanel": "AI 작업 공간 펼치기",
"ai.workspace.restorePanel": "AI 작업 영역 패널 복원",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "{title} 닫기",
"ai.workspace.popOut": "별도의 창에서 열기",
"ai.workspace.dock": "AI 작업 공간 고정",
"ai.workspace.settings": "AI 설정",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "보관된 채팅 숨기기",
"ai.workspace.noActiveChats": "진행 중인 채팅 없음",
"ai.workspace.noArchivedChats": "보관된 채팅 없음",
"ai.workspace.chatTitle": "채팅 {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "채팅 이름 변경",
"ai.workspace.status.archived": "보관됨",
"ai.workspace.status.idle": "유휴",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "Wiadomości można wysyłać, gdy wybrany agent jest gotowy",
"ai.panel.empty.missingTitle": "{agent} nie jest dostępny na tym komputerze",
"ai.panel.empty.missingDescription": "Zainstaluj go lub zmień domyślnego agenta AI w Ustawieniach",
"ai.panel.empty.withContextTitle": "Zapytaj {agent} o tę notatkę i jej powiązany kontekst",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Otwórz notatkę, a następnie zapytaj {agent} o nią",
"ai.panel.empty.withContextDescription": "Podsumuj, znajdź powiązania, rozwiń pomysły",
"ai.panel.empty.noContextDescription": "AI użyje aktywnej notatki jako kontekstu",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} nie jest zainstalowany. Otwórz Agentów AI w Ustawieniach.",
"ai.panel.placeholder.ready": "Zapytaj {agent}",
"ai.panel.send": "Wyślij wiadomość",
"ai.workspace.title": "Obszar roboczy AI",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "Agenci",
"ai.workspace.newChat": "Nowy czat",
"ai.workspace.collapseSidebar": "Zwiń listę czatów AI",
"ai.workspace.expandSidebar": "Rozwiń listę czatów AI",
"ai.workspace.close": "Zamknij obszar roboczy AI",
"ai.workspace.expandPanel": "Rozwiń obszar roboczy AI",
"ai.workspace.restorePanel": "Przywróć panel obszaru roboczego AI",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "Zamknij {title}",
"ai.workspace.popOut": "Otwórz w oddzielnym oknie",
"ai.workspace.dock": "Zadokuj obszar roboczy AI",
"ai.workspace.settings": "Ustawienia AI",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Ukryj zarchiwizowane czaty",
"ai.workspace.noActiveChats": "Brak aktywnych czatów",
"ai.workspace.noArchivedChats": "Brak zarchiwizowanych czatów",
"ai.workspace.chatTitle": "Czat {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Zmień nazwę czatu",
"ai.workspace.status.archived": "Zarchiwizowane",
"ai.workspace.status.idle": "Bezczynny",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "As mensagens podem ser enviadas quando o agente selecionado estiver disponível",
"ai.panel.empty.missingTitle": "{agent} não está disponível nesta máquina",
"ai.panel.empty.missingDescription": "Instale-o ou altere o agente de IA padrão em Configurações",
"ai.panel.empty.withContextTitle": "Pergunte ao {agent} sobre esta nota e o contexto vinculado a ela",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Abra uma nota e pergunte ao {agent} sobre ela",
"ai.panel.empty.withContextDescription": "Resuma, encontre conexões, desenvolva ideias",
"ai.panel.empty.noContextDescription": "A IA usará a nota ativa como contexto",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} não está instalado. Abra Agentes de IA em Configurações.",
"ai.panel.placeholder.ready": "Perguntar ao {agent}",
"ai.panel.send": "Enviar mensagem",
"ai.workspace.title": "Espaço de trabalho de IA",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "Agentes",
"ai.workspace.newChat": "Novo chat",
"ai.workspace.collapseSidebar": "Recolher lista de chat de IA",
"ai.workspace.expandSidebar": "Expandir lista de chat de IA",
"ai.workspace.close": "Fechar o espaço de trabalho de IA",
"ai.workspace.expandPanel": "Expandir o espaço de trabalho de IA",
"ai.workspace.restorePanel": "Restaurar painel do AI Workspace",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "Fechar {title}",
"ai.workspace.popOut": "Abrir em uma janela separada",
"ai.workspace.dock": "Encaixar o espaço de trabalho de IA",
"ai.workspace.settings": "Configurações de IA",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Ocultar chats arquivados",
"ai.workspace.noActiveChats": "Nenhum chat ativo",
"ai.workspace.noArchivedChats": "Nenhum chat arquivado",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Renomear chat",
"ai.workspace.status.archived": "Arquivado",
"ai.workspace.status.idle": "Ocioso",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "As mensagens podem ser enviadas quando o agente selecionado estiver disponível",
"ai.panel.empty.missingTitle": "{agent} não está disponível neste computador",
"ai.panel.empty.missingDescription": "Instale-o ou altere o agente de IA predefinido nas Definições",
"ai.panel.empty.withContextTitle": "Perguntar ao {agent} sobre esta nota e o seu contexto associado",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Abra uma nota e, em seguida, pergunte ao {agent} sobre ela",
"ai.panel.empty.withContextDescription": "Resumir, encontrar ligações, desenvolver ideias",
"ai.panel.empty.noContextDescription": "A IA utilizará a nota ativa como contexto",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "O {agent} não está instalado. Abra Agentes de IA em Definições.",
"ai.panel.placeholder.ready": "Perguntar ao {agent}",
"ai.panel.send": "Enviar mensagem",
"ai.workspace.title": "Espaço de trabalho de IA",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "Agentes",
"ai.workspace.newChat": "Novo chat",
"ai.workspace.collapseSidebar": "Recolher a lista de chat de IA",
"ai.workspace.expandSidebar": "Expandir a lista de chat de IA",
"ai.workspace.close": "Fechar espaço de trabalho de IA",
"ai.workspace.expandPanel": "Expandir o espaço de trabalho de IA",
"ai.workspace.restorePanel": "Restaurar o painel do espaço de trabalho de IA",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "Fechar {title}",
"ai.workspace.popOut": "Abrir numa janela separada",
"ai.workspace.dock": "Afixar espaço de trabalho de IA",
"ai.workspace.settings": "Definições de IA",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Ocultar chats arquivados",
"ai.workspace.noActiveChats": "Sem chats ativos",
"ai.workspace.noArchivedChats": "Sem chats arquivados",
"ai.workspace.chatTitle": "Chat {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Mudar o nome do chat",
"ai.workspace.status.archived": "Arquivado",
"ai.workspace.status.idle": "Inativo",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "Сообщения можно будет отправлять, когда выбранный агент будет готов",
"ai.panel.empty.missingTitle": "{agent} недоступен на этом компьютере",
"ai.panel.empty.missingDescription": "Установите его или измените агент ИИ по умолчанию в настройках.",
"ai.panel.empty.withContextTitle": "Спросите {agent} об этой заметке и связанном с ней контексте",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Откройте заметку, а затем спросите {agent} о ней",
"ai.panel.empty.withContextDescription": "Делайте выводы, находите связи, развивайте идеи",
"ai.panel.empty.noContextDescription": "ИИ будет использовать активную заметку в качестве контекста",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} не установлен. Откройте «AI Agents» в настройках.",
"ai.panel.placeholder.ready": "Спросить {agent}",
"ai.panel.send": "Отправить сообщение",
"ai.workspace.title": "Рабочее пространство ИИ",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "Агенты",
"ai.workspace.newChat": "Новый чат",
"ai.workspace.collapseSidebar": "Свернуть список чатов с ИИ",
"ai.workspace.expandSidebar": "Развернуть список чатов с ИИ",
"ai.workspace.close": "Закрыть рабочее пространство ИИ",
"ai.workspace.expandPanel": "Развернуть рабочее пространство ИИ",
"ai.workspace.restorePanel": "Восстановить панель рабочего пространства ИИ",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "Закрыть {title}",
"ai.workspace.popOut": "Открыть в отдельном окне",
"ai.workspace.dock": "Закрепить рабочее пространство ИИ",
"ai.workspace.settings": "Настройки ИИ",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Скрыть архив чатов",
"ai.workspace.noActiveChats": "Нет активных чатов",
"ai.workspace.noArchivedChats": "Нет заархивированных чатов",
"ai.workspace.chatTitle": "Чат {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Переименовать чат",
"ai.workspace.status.archived": "Архивировано",
"ai.workspace.status.idle": "Бездействует",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "Messages can be sent when the selected agent is ready",
"ai.panel.empty.missingTitle": "{agent} is not available on this machine",
"ai.panel.empty.missingDescription": "Install it or switch the default AI agent in Settings",
"ai.panel.empty.withContextTitle": "Ask {agent} about this note and its linked context",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "Open a note, then ask {agent} about it",
"ai.panel.empty.withContextDescription": "Summarize, find connections, expand ideas",
"ai.panel.empty.noContextDescription": "The AI will use the active note as context",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "{agent} is not installed. Open AI Agents in Settings.",
"ai.panel.placeholder.ready": "Ask {agent}",
"ai.panel.send": "Send message",
"ai.workspace.title": "Không gian làm việc AI",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "Đại lý",
"ai.workspace.newChat": "Trò chuyện mới",
"ai.workspace.collapseSidebar": "Thu gọn danh sách trò chuyện AI",
"ai.workspace.expandSidebar": "Mở rộng danh sách trò chuyện AI",
"ai.workspace.close": "Đóng không gian làm việc AI",
"ai.workspace.expandPanel": "Mở rộng không gian làm việc AI",
"ai.workspace.restorePanel": "Khôi phục bảng điều khiển không gian làm việc AI",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "Đóng {title}",
"ai.workspace.popOut": "Mở trong cửa sổ riêng",
"ai.workspace.dock": "Gắn không gian làm việc AI vào màn hình",
"ai.workspace.settings": "Cài đặt AI",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "Ẩn các cuộc trò chuyện đã lưu trữ",
"ai.workspace.noActiveChats": "Không có cuộc trò chuyện đang hoạt động",
"ai.workspace.noArchivedChats": "Không có cuộc trò chuyện nào được lưu trữ",
"ai.workspace.chatTitle": "Trò chuyện {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "Đổi tên cuộc trò chuyện",
"ai.workspace.status.archived": "Đã lưu trữ",
"ai.workspace.status.idle": "Không hoạt động",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "所选代理就绪后,即可发送消息",
"ai.panel.empty.missingTitle": "{agent} 在此计算机上不可用",
"ai.panel.empty.missingDescription": "请安装该应用,或在“设置”中切换默认 AI 代理",
"ai.panel.empty.withContextTitle": "向{agent}询问有关此笔记及其关联上下文的问题",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "打开一个笔记,然后向{agent}询问有关该笔记的问题",
"ai.panel.empty.withContextDescription": "总结、发现关联、拓展想法",
"ai.panel.empty.noContextDescription": "人工智能将使用当前笔记作为上下文",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "未安装 {agent}。在“设置”中打开 AI 代理。",
"ai.panel.placeholder.ready": "询问 {agent}",
"ai.panel.send": "发送消息",
"ai.workspace.title": "AI 工作区",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "代理",
"ai.workspace.newChat": "新聊天",
"ai.workspace.collapseSidebar": "收起 AI 聊天列表",
"ai.workspace.expandSidebar": "展开 AI 聊天列表",
"ai.workspace.close": "关闭 AI 工作区",
"ai.workspace.expandPanel": "展开 AI 工作区",
"ai.workspace.restorePanel": "恢复 AI 工作区面板",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "关闭{title}",
"ai.workspace.popOut": "在单独窗口中打开",
"ai.workspace.dock": "停靠 AI 工作区",
"ai.workspace.settings": "AI 设置",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "隐藏已归档的聊天",
"ai.workspace.noActiveChats": "没有活跃的聊天",
"ai.workspace.noArchivedChats": "没有已归档的聊天",
"ai.workspace.chatTitle": "聊天 {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "重命名聊天",
"ai.workspace.status.archived": "已归档",
"ai.workspace.status.idle": "空闲",

View File

@@ -303,7 +303,7 @@
"ai.panel.empty.checkingDescription": "當所選客服人員準備就緒時,即可傳送訊息",
"ai.panel.empty.missingTitle": "{agent} 在此電腦上無法使用",
"ai.panel.empty.missingDescription": "請安裝它,或在「設定」中切換預設的 AI 代理程式",
"ai.panel.empty.withContextTitle": "向 {agent} 詢問有關此筆記及其連結內容的問題",
"ai.panel.empty.withContextTitle": "Ask anything to {agent}",
"ai.panel.empty.noContextTitle": "開啟一則筆記,然後向 {agent} 詢問有關該筆記的問題",
"ai.panel.empty.withContextDescription": "進行總結、找出關聯、拓展想法",
"ai.panel.empty.noContextDescription": "人工智慧將使用目前啟用的筆記作為背景資訊",
@@ -311,11 +311,18 @@
"ai.panel.placeholder.missing": "未安裝 {agent}。在「設定」中開啟 AI 代理程式。",
"ai.panel.placeholder.ready": "詢問 {agent}",
"ai.panel.send": "傳送訊息",
"ai.workspace.title": "AI 工作區",
"ai.message.regenerate": "Regenerate response",
"ai.message.copy": "Copy response",
"ai.message.fork": "Fork chat from here",
"ai.workspace.title": "代理人",
"ai.workspace.newChat": "新對話",
"ai.workspace.collapseSidebar": "收合 AI 聊天清單",
"ai.workspace.expandSidebar": "展開 AI 聊天清單",
"ai.workspace.close": "關閉 AI 工作區",
"ai.workspace.expandPanel": "展開 AI 工作區",
"ai.workspace.restorePanel": "還原 AI 工作區面板",
"ai.workspace.restoreGuidance": "Restore",
"ai.workspace.closeChat": "關閉{title}",
"ai.workspace.popOut": "在單獨視窗中開啟",
"ai.workspace.dock": "固定 AI 工作區",
"ai.workspace.settings": "AI 設定",
@@ -325,7 +332,7 @@
"ai.workspace.hideArchived": "隱藏已封存的聊天",
"ai.workspace.noActiveChats": "沒有進行中的聊天",
"ai.workspace.noArchivedChats": "沒有已封存的聊天",
"ai.workspace.chatTitle": "聊天 {index}",
"ai.workspace.chatTitle": "AI Chat {index}",
"ai.workspace.renameChat": "重新命名聊天",
"ai.workspace.status.archived": "已封存",
"ai.workspace.status.idle": "閒置",

View File

@@ -10,7 +10,7 @@ import type { ThemeMode } from './themeMode'
type TrackedPreviewKind = FilePreviewKind | 'unsupported'
type FilePreviewAction = 'copy_deep_link' | 'copy_path' | 'open_external' | 'reveal'
type AgentBlockedReason = 'agent_unavailable' | 'missing_vault'
type AiWorkspaceMode = 'docked' | 'window'
type AiWorkspaceMode = 'docked' | 'side' | 'window'
type AiWorkspaceTitleSource = 'generated' | 'manual'
const ALL_NOTES_VISIBILITY_CATEGORIES: ReadonlyArray<keyof AllNotesFileVisibility> = [

View File

@@ -1,5 +1,11 @@
import { describe, it, expect } from 'vitest'
import { resolveTarget, collectLinkedEntries, buildContextualPrompt, buildContextSnapshot } from './ai-context'
import {
resolveTarget,
collectLinkedEntries,
buildContextualPrompt,
buildContextSnapshot,
formatPromptWithReferences,
} from './ai-context'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
@@ -237,6 +243,23 @@ describe('buildContextSnapshot', () => {
expect(json.referencedNotes[1].type).toBe('Note') // null fallback
})
it('embeds explicit referenced note bodies when available', () => {
const result = buildContextSnapshot({
activeEntry: active, entries,
references: [
{
title: 'Beta',
path: '/vault/b.md',
type: 'Person',
content: '---\ntitle: Beta\n---\n\n# Beta\nReferenced body.',
},
],
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.referencedNotes[0].body).toBe('# Beta\nReferenced body.')
expect(json.referencedNotes[0].body).not.toContain('title: Beta')
})
it('omits referencedNotes when no references provided', () => {
const result = buildContextSnapshot({ activeEntry: active, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
@@ -397,3 +420,25 @@ describe('buildContextSnapshot', () => {
expect(json.activeNote.frontmatter.relationships).toEqual({ people: ['[[Alice]]'] })
})
})
describe('formatPromptWithReferences', () => {
it('adds referenced note context to the current prompt', () => {
const result = formatPromptWithReferences('Summarize this', [
{
title: 'Beta',
path: '/vault/b.md',
type: 'Person',
content: '---\ntitle: Beta\n---\n\nImportant referenced content.',
},
])
expect(result).toContain('Summarize this')
expect(result).toContain('Referenced Notes')
expect(result).toContain('Important referenced content.')
expect(result).toContain('/vault/b.md')
})
it('leaves prompts without references unchanged', () => {
expect(formatPromptWithReferences('Plain prompt')).toBe('Plain prompt')
})
})

View File

@@ -59,7 +59,8 @@ export function collectLinkedEntries(
export interface NoteReference {
title: string
path: string
type: string | null
type?: string | null
content?: string
}
/** Lightweight note summary for the context snapshot. */
@@ -84,6 +85,9 @@ export interface ContextSnapshotParams {
const MAX_ACTIVE_NOTE_BODY_CHARS = 24_000
const ACTIVE_NOTE_BODY_HEAD_CHARS = 16_000
const ACTIVE_NOTE_BODY_TAIL_CHARS = 4_000
const MAX_REFERENCED_NOTE_BODY_CHARS = 12_000
const REFERENCED_NOTE_BODY_HEAD_CHARS = 8_000
const REFERENCED_NOTE_BODY_TAIL_CHARS = 2_000
const MAX_NOTE_LIST_ITEMS = 100
interface ActiveNoteBody {
@@ -142,6 +146,14 @@ function truncatedBodyInstruction(path: string, omittedChars: number): string {
].join(' ')
}
function truncatedReferencedBodyInstruction(path: string, omittedChars: number): string {
return [
'[Referenced note body truncated by Tolaria to keep CLI agent context within provider limits.',
`Omitted approximately ${omittedChars} characters from the middle.`,
`Use get_note("${path}") to read the full note before making content-sensitive edits or summaries.]`,
].join(' ')
}
function compactActiveNoteBody(body: string, path: string): ActiveNoteBody {
if (body.length <= MAX_ACTIVE_NOTE_BODY_CHARS) {
return { body }
@@ -161,6 +173,25 @@ function compactActiveNoteBody(body: string, path: string): ActiveNoteBody {
}
}
function compactReferencedNoteBody(body: string, path: string): ActiveNoteBody {
if (body.length <= MAX_REFERENCED_NOTE_BODY_CHARS) {
return { body }
}
const head = body.slice(0, REFERENCED_NOTE_BODY_HEAD_CHARS).trimEnd()
const tail = body.slice(-REFERENCED_NOTE_BODY_TAIL_CHARS).trimStart()
const omittedChars = Math.max(0, body.length - REFERENCED_NOTE_BODY_HEAD_CHARS - REFERENCED_NOTE_BODY_TAIL_CHARS)
return {
body: `${head}\n\n${truncatedReferencedBodyInstruction(path, omittedChars)}\n\n${tail}`,
bodyTruncated: {
shownChars: REFERENCED_NOTE_BODY_HEAD_CHARS + REFERENCED_NOTE_BODY_TAIL_CHARS,
totalChars: body.length,
strategy: 'head-tail',
},
}
}
function activeNoteBody(activeEntry: VaultEntry, activeNoteContent?: string): ActiveNoteBody {
const body = extractBody(activeNoteContent || '')
if (!body && activeEntry.wordCount > 0) {
@@ -208,14 +239,33 @@ function hasNoteListFilter(noteListFilter?: { type: string | null; query: string
return Boolean(noteListFilter?.type || noteListFilter?.query)
}
function appendReferencedNotes(snapshot: Record<string, unknown>, references?: NoteReference[]): void {
if (!references?.length) return
snapshot.referencedNotes = references.map(ref => ({
function referencedNoteSnapshot(ref: NoteReference): Record<string, unknown> {
const note: Record<string, unknown> = {
path: ref.path,
title: ref.title,
type: ref.type ?? 'Note',
}))
}
if (ref.content === undefined) {
note.body = `[Referenced note content not embedded — use get_note("${ref.path}") to read the full note before answering about it.]`
return note
}
const bodySnapshot = compactReferencedNoteBody(extractBody(ref.content), ref.path)
note.body = bodySnapshot.body
assignIfPresent(note, 'bodyTruncated', bodySnapshot.bodyTruncated)
return note
}
function referencedNotesSnapshot(references?: NoteReference[]): Record<string, unknown>[] {
return references?.map(referencedNoteSnapshot) ?? []
}
function appendReferencedNotes(snapshot: Record<string, unknown>, references?: NoteReference[]): void {
const referencedNotes = referencedNotesSnapshot(references)
if (!referencedNotes.length) return
snapshot.referencedNotes = referencedNotes
}
function vaultSummary(entries: VaultEntry[]): Record<string, unknown> {
@@ -258,6 +308,21 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
return `${preamble}\n\n## Context Snapshot\n\`\`\`json\n${JSON.stringify(snapshot, null, 2)}\n\`\`\``
}
export function formatPromptWithReferences(text: string, references?: NoteReference[]): string {
const referencedNotes = referencedNotesSnapshot(references)
if (!referencedNotes.length) return text
return [
text,
'',
'## Referenced Notes',
'The user explicitly referenced these notes in the prompt. Use their bodies as first-class context.',
'```json',
JSON.stringify(referencedNotes, null, 2),
'```',
].join('\n')
}
/** Legacy: Build a contextual system prompt (text-based). */
export function buildContextualPrompt(
active: VaultEntry,

View File

@@ -0,0 +1,26 @@
import { isTauri, mockInvoke } from '../mock-tauri'
import type { NoteReference } from './ai-context'
async function readNoteContent(path: string): Promise<string | null> {
try {
if (!isTauri()) {
return await mockInvoke<string>('get_note_content', { path })
}
const { invoke } = await import('@tauri-apps/api/core')
return await invoke<string>('get_note_content', { path })
} catch {
return null
}
}
export async function hydrateNoteReferences(references?: NoteReference[]): Promise<NoteReference[] | undefined> {
if (!references?.length) return references
return Promise.all(references.map(async (reference) => {
if (reference.content !== undefined) return reference
const content = await readNoteContent(reference.path)
return content === null ? reference : { ...reference, content }
}))
}

View File

@@ -1,16 +1,107 @@
import { describe, expect, it } from 'vitest'
import { generateAiConversationTitle } from './aiConversationTitle'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { streamAiAgentMock, streamAiModelMock } = vi.hoisted(() => ({
streamAiAgentMock: vi.fn(),
streamAiModelMock: vi.fn(),
}))
vi.mock('./streamAiAgent', () => ({
streamAiAgent: streamAiAgentMock,
}))
vi.mock('./streamAiModel', () => ({
streamAiModel: streamAiModelMock,
}))
import {
generateAiConversationTitle,
generateAiConversationTitleForTarget,
normalizeAiConversationTitle,
} from './aiConversationTitle'
describe('generateAiConversationTitle', () => {
it('creates a compact title from the first prompt', () => {
beforeEach(() => {
vi.clearAllMocks()
streamAiAgentMock.mockResolvedValue(undefined)
streamAiModelMock.mockResolvedValue(undefined)
})
it('creates a sentence-case fallback title from the first prompt', () => {
expect(generateAiConversationTitle('please summarize quarterly sponsor outreach next steps')).toBe(
'Summarize Quarterly Sponsor Outreach',
'Summarize quarterly sponsor outreach next steps',
)
})
it('strips wikilinks and urls before choosing title words', () => {
expect(generateAiConversationTitle('help with [[Sponsor Onboarding]] https://example.com plan')).toBe(
'Sponsor Onboarding Plan',
'Sponsor onboarding plan',
)
})
it('normalizes model title text to sentence case while preserving acronyms', () => {
expect(normalizeAiConversationTitle('Title: Fix MCP Server Lookup.')).toBe('Fix MCP server lookup')
})
it('asks the selected agent for a short chat title', async () => {
streamAiAgentMock.mockImplementation(async ({ callbacks }) => {
callbacks.onText('Fix MCP Server Lookup')
callbacks.onDone()
})
await expect(generateAiConversationTitleForTarget({
permissionMode: 'power_user',
prompt: 'The MCP server cannot be found anymore',
target: { kind: 'agent', agent: 'codex', id: 'agent:codex', label: 'Codex', shortLabel: 'Codex' },
targetReady: true,
vaultPath: '/vault',
vaultPaths: ['/vault'],
})).resolves.toBe('Fix MCP server lookup')
expect(streamAiAgentMock).toHaveBeenCalledWith(expect.objectContaining({
agent: 'codex',
permissionMode: 'power_user',
vaultPath: '/vault',
vaultPaths: ['/vault'],
}))
})
it('asks the selected API model for a short chat title', async () => {
const provider = {
id: 'openai',
name: 'OpenAI',
kind: 'open_ai' as const,
api_key_storage: 'env' as const,
api_key_env_var: 'OPENAI_API_KEY',
models: [],
}
const model = {
id: 'gpt-4.1',
display_name: 'GPT-4.1',
capabilities: { streaming: true, tools: false, vision: false, json_mode: false, reasoning: false },
}
streamAiModelMock.mockImplementation(async ({ callbacks }) => {
callbacks.onText('Review Sidebar Spacing')
callbacks.onDone()
})
await expect(generateAiConversationTitleForTarget({
permissionMode: 'safe',
prompt: 'Please review the spacing in the sidebar',
target: {
kind: 'api_model',
provider,
model,
id: 'model:openai/gpt-4.1',
label: 'OpenAI · GPT-4.1',
shortLabel: 'GPT-4.1',
},
targetReady: true,
vaultPath: '/vault',
})).resolves.toBe('Review sidebar spacing')
expect(streamAiModelMock).toHaveBeenCalledWith(expect.objectContaining({
provider,
model,
}))
})
})

View File

@@ -1,4 +1,20 @@
const TITLE_WORD_LIMIT = 4
import type { AiAgentPermissionMode } from '../lib/aiAgentPermissionMode'
import type { AiTarget } from '../lib/aiTargets'
import { streamAiAgent, type AgentStreamCallbacks } from './streamAiAgent'
import { streamAiModel } from './streamAiModel'
const TITLE_WORD_LIMIT = 7
const TITLE_CHAR_LIMIT = 56
const TITLE_SYSTEM_PROMPT = [
'Create a concise title for this chat.',
'Use 2 to 6 words when possible, and never exceed 56 characters.',
'Describe the user request specifically.',
'Use sentence case: capitalize only the first word and preserve acronyms.',
'Do not use quotation marks, markdown, emojis, or trailing punctuation.',
'Return only the title.',
'Do not inspect files or use tools.',
].join(' ')
const STOP_WORDS = new Set([
'a',
@@ -21,7 +37,7 @@ const STOP_WORDS = new Set([
'you',
])
function cleanPrompt(prompt: string): string {
function cleanPrompt({ prompt }: { prompt: string }): string {
return prompt
.replace(/```[\s\S]*?```/g, ' ')
.replace(/\[\[([^\]]+)\]\]/g, '$1')
@@ -31,13 +47,65 @@ function cleanPrompt(prompt: string): string {
.trim()
}
function toTitleWord(word: string): string {
if (word.length <= 1) return word.toUpperCase()
return `${word[0].toUpperCase()}${word.slice(1)}`
function stripTitleDecorations({ title }: { title: string }): string {
return title
.split('\n')[0]
.replace(/^#+\s*/, '')
.replace(/^(chat\s+title|title|summary)\s*[:-]\s*/i, '')
.replace(/^["'`“”‘’\s]+|["'`“”‘’\s]+$/g, '')
.replace(/[.!?;:]+$/g, '')
.replace(/\s+/g, ' ')
.trim()
}
function shouldPreserveWordCase({ word }: { word: string }): boolean {
return /^[A-Z0-9][A-Z0-9.+#-]{1,}$/.test(word)
|| /^[a-z]+[A-Z]/.test(word)
|| /^[A-Z]+[a-z]+[A-Z]/.test(word)
}
function sentenceCaseWord({ firstWord, word }: { firstWord: boolean; word: string }): string {
if (shouldPreserveWordCase({ word })) return word
const lower = word.toLocaleLowerCase()
if (!firstWord) return lower
const [first = '', ...rest] = Array.from(lower)
return `${first.toLocaleUpperCase()}${rest.join('')}`
}
function toSentenceCase({ title }: { title: string }): string {
let firstWord = true
return title.replace(/\p{L}[\p{L}\p{N}'-]*/gu, (word) => {
const next = sentenceCaseWord({ firstWord, word })
firstWord = false
return next
})
}
function trimTitleLength({ title }: { title: string }): string {
const words = title.split(/\s+/).filter(Boolean).slice(0, TITLE_WORD_LIMIT)
let nextTitle = ''
for (const word of words) {
const candidate = nextTitle ? `${nextTitle} ${word}` : word
if (candidate.length > TITLE_CHAR_LIMIT) break
nextTitle = candidate
}
return nextTitle || words[0]?.slice(0, TITLE_CHAR_LIMIT) || ''
}
export function normalizeAiConversationTitle(title: string): string | null {
const cleanTitle = trimTitleLength({ title: stripTitleDecorations({ title }) })
if (!cleanTitle) return null
return toSentenceCase({ title: cleanTitle })
}
export function generateAiConversationTitle(prompt: string): string | null {
const words = cleanPrompt(prompt)
const words = cleanPrompt({ prompt })
.split(' ')
.map((word) => word.trim())
.filter(Boolean)
@@ -45,5 +113,71 @@ export function generateAiConversationTitle(prompt: string): string | null {
const titleWords = (meaningfulWords.length > 0 ? meaningfulWords : words).slice(0, TITLE_WORD_LIMIT)
if (titleWords.length === 0) return null
return titleWords.map(toTitleWord).join(' ')
return normalizeAiConversationTitle(titleWords.join(' '))
}
export interface GenerateAiConversationTitleRequest {
permissionMode: AiAgentPermissionMode
prompt: string
target: AiTarget
targetReady: boolean
vaultPath: string
vaultPaths?: string[]
}
function titlePrompt({ prompt }: { prompt: string }): string {
return `User request:\n${prompt.trim()}\n\nReturn only the title.`
}
function createTitleStreamCallbacks(onText: (text: string) => void): AgentStreamCallbacks {
return {
onText,
onThinking: () => {},
onToolStart: () => {},
onToolDone: () => {},
onError: () => {},
onDone: () => {},
}
}
async function generateAiTitleText(request: GenerateAiConversationTitleRequest): Promise<string | null> {
let title = ''
const callbacks = createTitleStreamCallbacks((text) => {
title += text
})
if (request.target.kind === 'api_model') {
await streamAiModel({
provider: request.target.provider,
model: request.target.model,
message: titlePrompt({ prompt: request.prompt }),
systemPrompt: TITLE_SYSTEM_PROMPT,
callbacks,
})
return normalizeAiConversationTitle(title)
}
await streamAiAgent({
agent: request.target.agent,
message: titlePrompt({ prompt: request.prompt }),
systemPrompt: TITLE_SYSTEM_PROMPT,
vaultPath: request.vaultPath,
vaultPaths: request.vaultPaths,
permissionMode: request.permissionMode,
callbacks,
})
return normalizeAiConversationTitle(title)
}
export async function generateAiConversationTitleForTarget(
request: GenerateAiConversationTitleRequest,
): Promise<string | null> {
const fallbackTitle = generateAiConversationTitle(request.prompt)
if (!request.targetReady || !request.vaultPath.trim()) return fallbackTitle
try {
return await generateAiTitleText(request) ?? fallbackTitle
} catch {
return fallbackTitle
}
}

View File

@@ -4,6 +4,10 @@ export const OPEN_AI_CHAT_EVENT = 'tolaria:open-ai-chat'
export const AI_PROMPT_QUEUED_EVENT = 'tolaria:ai-prompt-queued'
export const NEW_AI_CHAT_EVENT = 'tolaria:new-ai-chat'
export const AI_WORKSPACE_DOCK_REQUESTED_EVENT = 'tolaria:ai-workspace-dock-requested'
export const AI_WORKSPACE_OPEN_NOTE_REQUESTED_EVENT = 'tolaria:ai-workspace-open-note-requested'
export const AI_WORKSPACE_FILE_CREATED_EVENT = 'tolaria:ai-workspace-file-created'
export const AI_WORKSPACE_FILE_MODIFIED_EVENT = 'tolaria:ai-workspace-file-modified'
export const AI_WORKSPACE_VAULT_CHANGED_EVENT = 'tolaria:ai-workspace-vault-changed'
export interface QueuedAiPrompt {
id: number

View File

@@ -0,0 +1,7 @@
function fallbackStreamEventId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
}
export function createScopedStreamEventName(baseName: string): string {
return `${baseName}-${globalThis.crypto?.randomUUID?.() ?? fallbackStreamEventId()}`
}

View File

@@ -3,9 +3,11 @@ import {
AI_WORKSPACE_WINDOW_LABEL,
buildAiWorkspaceWindowUrl,
buildRuntimeAiWorkspaceWindowUrl,
closePreloadedAiWorkspaceWindow,
closeCurrentAiWorkspaceWindow,
dockCurrentAiWorkspaceWindow,
openAiWorkspaceWindow,
preloadAiWorkspaceWindow,
readAiWorkspaceWindowContext,
} from './openAiWorkspaceWindow'
import { isTauri } from '../mock-tauri'
@@ -13,8 +15,11 @@ import { AI_WORKSPACE_DOCK_REQUESTED_EVENT } from './aiPromptBridge'
const webviewWindowCalls = vi.fn()
const webviewGetByLabel = vi.fn()
const existingShow = vi.fn().mockResolvedValue(undefined)
const existingUnminimize = vi.fn().mockResolvedValue(undefined)
const existingSetFocus = vi.fn().mockResolvedValue(undefined)
const existingClose = vi.fn().mockResolvedValue(undefined)
const existingIsVisible = vi.fn().mockResolvedValue(true)
const emitTo = vi.fn().mockResolvedValue(undefined)
const close = vi.fn().mockResolvedValue(undefined)
const localStorageMock = (() => {
@@ -63,11 +68,13 @@ describe('openAiWorkspaceWindow', () => {
vi.clearAllMocks()
vi.mocked(isTauri).mockReturnValue(false)
webviewGetByLabel.mockResolvedValue(null)
existingIsVisible.mockResolvedValue(true)
localStorage.clear()
})
it('builds the AI workspace route', () => {
const url = buildAiWorkspaceWindowUrl('ai-workspace', {
activeConversationId: 'chat-1',
vaultPath: '/tmp/vault',
vaultPaths: ['/tmp/vault', '/tmp/other'],
})
@@ -76,14 +83,16 @@ describe('openAiWorkspaceWindow', () => {
expect(parsed.pathname).toBe('/')
expect(parsed.searchParams.get('window')).toBe('ai-workspace')
expect(parsed.searchParams.get('windowLabel')).toBe('ai-workspace')
expect(parsed.searchParams.get('activeConversationId')).toBe('chat-1')
expect(parsed.searchParams.get('vault')).toBe('/tmp/vault')
expect(JSON.parse(parsed.searchParams.get('vaultPaths') ?? '[]')).toEqual(['/tmp/vault', '/tmp/other'])
})
it('reads the AI workspace route context', () => {
const search = '?window=ai-workspace&vault=%2Ftmp%2Fvault&vaultPaths=%5B%22%2Ftmp%2Fvault%22%5D'
const search = '?window=ai-workspace&activeConversationId=chat-1&vault=%2Ftmp%2Fvault&vaultPaths=%5B%22%2Ftmp%2Fvault%22%5D'
expect(readAiWorkspaceWindowContext(search)).toEqual({
activeConversationId: 'chat-1',
vaultPath: '/tmp/vault',
vaultPaths: ['/tmp/vault'],
})
@@ -113,33 +122,98 @@ describe('openAiWorkspaceWindow', () => {
expect.objectContaining({
url: expect.stringContaining('vault=%2Ftmp%2Fvault'),
title: 'Tolaria AI',
width: 940,
width: 560,
height: 680,
minWidth: 520,
minWidth: 420,
minHeight: 420,
minimizable: false,
alwaysOnTop: false,
decorations: false,
shadow: true,
shadow: false,
transparent: true,
backgroundColor: '#00000000',
visible: true,
}),
)
expect(localStorage.getItem('tolaria:ai-workspace-window:ai-workspace')).toBe('true')
})
it('preloads a hidden native AI workspace window', async () => {
vi.mocked(isTauri).mockReturnValue(true)
await preloadAiWorkspaceWindow({ vaultPath: '/tmp/vault' })
expect(webviewWindowCalls).toHaveBeenCalledWith(
AI_WORKSPACE_WINDOW_LABEL,
expect.objectContaining({
url: expect.stringContaining('vault=%2Ftmp%2Fvault'),
visible: false,
}),
)
expect(localStorage.getItem('tolaria:ai-workspace-window:ai-workspace')).toBe('true')
})
it('closes a hidden preloaded window', async () => {
vi.mocked(isTauri).mockReturnValue(true)
await preloadAiWorkspaceWindow()
webviewGetByLabel.mockResolvedValue({ close: existingClose, isVisible: vi.fn().mockResolvedValue(false) })
await closePreloadedAiWorkspaceWindow()
expect(existingClose).toHaveBeenCalledOnce()
})
it('keeps a visible preloaded window open during cleanup', async () => {
vi.mocked(isTauri).mockReturnValue(true)
await preloadAiWorkspaceWindow()
webviewGetByLabel.mockResolvedValue({ close: existingClose, isVisible: vi.fn().mockResolvedValue(true) })
await closePreloadedAiWorkspaceWindow()
expect(existingClose).not.toHaveBeenCalled()
})
it('focuses an existing AI workspace window instead of creating another', async () => {
vi.mocked(isTauri).mockReturnValue(true)
webviewGetByLabel.mockResolvedValue({
show: existingShow,
unminimize: existingUnminimize,
setFocus: existingSetFocus,
isVisible: existingIsVisible,
})
await openAiWorkspaceWindow()
expect(existingShow).toHaveBeenCalledOnce()
expect(existingUnminimize).toHaveBeenCalledOnce()
expect(existingSetFocus).toHaveBeenCalledOnce()
expect(existingIsVisible).toHaveBeenCalledTimes(2)
expect(webviewWindowCalls).not.toHaveBeenCalled()
})
it('replaces a stale existing AI workspace window when it cannot be shown', async () => {
vi.mocked(isTauri).mockReturnValue(true)
existingIsVisible.mockResolvedValue(false)
webviewGetByLabel.mockResolvedValue({
show: existingShow,
unminimize: existingUnminimize,
setFocus: existingSetFocus,
isVisible: existingIsVisible,
close: existingClose,
})
await openAiWorkspaceWindow({ vaultPath: '/tmp/vault' })
expect(existingClose).toHaveBeenCalledOnce()
expect(webviewWindowCalls).toHaveBeenCalledWith(
AI_WORKSPACE_WINDOW_LABEL,
expect.objectContaining({
url: expect.stringContaining('vault=%2Ftmp%2Fvault'),
visible: true,
}),
)
})
it('closes the current AI workspace window without docking it', async () => {
const dispatchSpy = await runCurrentWindowAction(closeCurrentAiWorkspaceWindow)

View File

@@ -3,15 +3,36 @@ import { rememberAiWorkspaceWindow } from './windowMode'
import { AI_WORKSPACE_DOCK_REQUESTED_EVENT, requestDockAiWorkspace } from './aiPromptBridge'
export const AI_WORKSPACE_WINDOW_LABEL = 'ai-workspace'
export const AI_WORKSPACE_CONTEXT_UPDATED_EVENT = 'ai-workspace-context-updated'
const AI_WORKSPACE_WINDOW_TITLE = 'Tolaria AI'
const APP_ORIGIN_PROTOCOLS = new Set(['http:', 'https:'])
const CREATE_TIMEOUT_MS = 4_000
const TRANSPARENT_WINDOW_BACKGROUND: [number, number, number, number] = [0, 0, 0, 0]
let preloadedContextKey: string | null = null
export interface AiWorkspaceWindowContext {
activeConversationId?: string
vaultPath?: string
vaultPaths?: string[]
}
interface ExistingAiWorkspaceWindow {
close?: () => Promise<void>
isVisible?: () => Promise<boolean>
once?: (event: string, handler: (event?: unknown) => void) => Promise<() => void>
setAlwaysOnTop?: (alwaysOnTop: boolean) => Promise<void>
setBackgroundColor?: (color: typeof TRANSPARENT_WINDOW_BACKGROUND) => Promise<void>
setFocus: () => Promise<void>
setShadow?: (enabled: boolean) => Promise<void>
show?: () => Promise<void>
unminimize: () => Promise<void>
}
interface AiWorkspaceWindowOptions {
visible?: boolean
}
export function buildAiWorkspaceWindowUrl(
windowLabel = AI_WORKSPACE_WINDOW_LABEL,
context: AiWorkspaceWindowContext = {},
@@ -20,6 +41,7 @@ export function buildAiWorkspaceWindowUrl(
window: 'ai-workspace',
windowLabel,
})
if (context.activeConversationId) params.set('activeConversationId', context.activeConversationId)
if (context.vaultPath) params.set('vault', context.vaultPath)
if (context.vaultPaths?.length) params.set('vaultPaths', JSON.stringify(context.vaultPaths))
@@ -41,9 +63,10 @@ export function buildRuntimeAiWorkspaceWindowUrl(
export function readAiWorkspaceWindowContext(search = window.location.search): AiWorkspaceWindowContext {
const params = new URLSearchParams(search)
const activeConversationId = params.get('activeConversationId') ?? undefined
const vaultPath = params.get('vault') ?? undefined
const vaultPaths = parseVaultPathsParam(params.get('vaultPaths'))
return { vaultPath, vaultPaths }
return { activeConversationId, vaultPath, vaultPaths }
}
function parseVaultPathsParam(raw: string | null): string[] | undefined {
@@ -62,33 +85,183 @@ function parseVaultPathsParam(raw: string | null): string[] | undefined {
return undefined
}
function aiWorkspaceWindowOptions(
context: AiWorkspaceWindowContext,
options: AiWorkspaceWindowOptions = {},
) {
return {
url: buildRuntimeAiWorkspaceWindowUrl(AI_WORKSPACE_WINDOW_LABEL, context),
title: AI_WORKSPACE_WINDOW_TITLE,
width: 560,
height: 680,
minWidth: 420,
minHeight: 420,
center: true,
resizable: true,
minimizable: false,
alwaysOnTop: false,
decorations: false,
shadow: false,
transparent: true,
backgroundColor: '#00000000',
visible: options.visible ?? true,
}
}
function aiWorkspaceContextKey(context: AiWorkspaceWindowContext): string {
return JSON.stringify({
vaultPath: context.vaultPath ?? null,
vaultPaths: context.vaultPaths ?? null,
})
}
async function emitAiWorkspaceContext(context: AiWorkspaceWindowContext): Promise<void> {
const { emitTo } = await import('@tauri-apps/api/event')
await emitTo(AI_WORKSPACE_WINDOW_LABEL, AI_WORKSPACE_CONTEXT_UPDATED_EVENT, context).catch(() => {})
}
function waitForCreated(existingWindow: ExistingAiWorkspaceWindow): Promise<void> {
const once = existingWindow.once?.bind(existingWindow)
if (!once) return Promise.resolve()
return new Promise((resolve, reject) => {
let settled = false
let cleanup: Array<() => void> = []
const finish = (error?: unknown) => {
if (settled) return
settled = true
for (const unlisten of cleanup) unlisten()
if (error) {
reject(error)
return
}
resolve()
}
const timeout = window.setTimeout(() => finish(), CREATE_TIMEOUT_MS)
const complete = (error?: unknown) => {
window.clearTimeout(timeout)
finish(error)
}
void Promise.all([
once('tauri://created', () => complete()),
once('tauri://error', (event) => complete(event)),
]).then((unlisteners) => {
cleanup = unlisteners
}).catch(complete)
})
}
async function refreshAiWorkspaceWindowChrome(existingWindow: ExistingAiWorkspaceWindow): Promise<void> {
await existingWindow.setAlwaysOnTop?.(false).catch(() => {})
await existingWindow.setBackgroundColor?.(TRANSPARENT_WINDOW_BACKGROUND).catch(() => {})
await existingWindow.setShadow?.(false).catch(() => {})
}
export async function raiseAiWorkspaceWindowAboveMain(): Promise<void> {
if (!isTauri()) return
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const existingWindow = await WebviewWindow.getByLabel(AI_WORKSPACE_WINDOW_LABEL)
if (!existingWindow) return
await existingWindow.setAlwaysOnTop?.(true).catch(() => {})
await existingWindow.show?.().catch(() => {})
window.setTimeout(() => {
void existingWindow.setAlwaysOnTop?.(false).catch(() => {})
}, 150)
}
async function revealAiWorkspaceWindow(existingWindow: ExistingAiWorkspaceWindow): Promise<boolean> {
await existingWindow.setBackgroundColor?.(TRANSPARENT_WINDOW_BACKGROUND).catch(() => {})
await existingWindow.show?.()
await existingWindow.unminimize().catch(() => {})
await existingWindow.setFocus().catch(() => {})
await refreshAiWorkspaceWindowChrome(existingWindow)
return existingWindow.isVisible ? existingWindow.isVisible().catch(() => true) : true
}
async function visibleWindowState(existingWindow: ExistingAiWorkspaceWindow): Promise<boolean> {
return existingWindow.isVisible ? existingWindow.isVisible().catch(() => true) : true
}
export async function preloadAiWorkspaceWindow(context: AiWorkspaceWindowContext = {}): Promise<boolean> {
if (!isTauri()) return false
const key = aiWorkspaceContextKey(context)
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const existingWindow = await WebviewWindow.getByLabel(AI_WORKSPACE_WINDOW_LABEL)
if (existingWindow) {
if (preloadedContextKey === key) return true
const visible = await visibleWindowState(existingWindow)
if (visible) return true
await existingWindow.close?.().catch(() => {})
}
rememberAiWorkspaceWindow()
const window = new WebviewWindow(
AI_WORKSPACE_WINDOW_LABEL,
aiWorkspaceWindowOptions(context, { visible: false }),
) as ExistingAiWorkspaceWindow
await waitForCreated(window)
preloadedContextKey = key
return true
}
export async function closePreloadedAiWorkspaceWindow(): Promise<void> {
if (!isTauri() || !preloadedContextKey) return
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const existingWindow = await WebviewWindow.getByLabel(AI_WORKSPACE_WINDOW_LABEL)
preloadedContextKey = null
if (!existingWindow) return
const visible = await existingWindow.isVisible?.().catch(() => true)
if (!visible) await existingWindow.close?.().catch(() => {})
}
export async function openAiWorkspaceWindow(context: AiWorkspaceWindowContext = {}): Promise<boolean> {
if (!isTauri()) return false
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const existingWindow = await WebviewWindow.getByLabel(AI_WORKSPACE_WINDOW_LABEL)
let staleExistingWindowClosed = false
if (existingWindow) {
await existingWindow.unminimize().catch(() => {})
await existingWindow.setFocus().catch(() => {})
return true
const visible = await visibleWindowState(existingWindow)
if (!preloadedContextKey && !visible) {
await existingWindow.close?.().catch(() => {})
staleExistingWindowClosed = true
} else if (preloadedContextKey && preloadedContextKey !== aiWorkspaceContextKey(context)) {
preloadedContextKey = null
await existingWindow.close?.().catch(() => {})
staleExistingWindowClosed = true
} else {
preloadedContextKey = null
const existingWindowIsVisible = await revealAiWorkspaceWindow(existingWindow).catch(() => false)
if (existingWindowIsVisible) {
await emitAiWorkspaceContext(context)
return true
}
}
}
const currentWindow = staleExistingWindowClosed ? null : await WebviewWindow.getByLabel(AI_WORKSPACE_WINDOW_LABEL)
if (currentWindow) {
const existingWindowIsVisible = await revealAiWorkspaceWindow(currentWindow).catch(() => false)
if (existingWindowIsVisible) {
await emitAiWorkspaceContext(context)
return true
}
await currentWindow.close?.().catch(() => {})
}
rememberAiWorkspaceWindow()
new WebviewWindow(AI_WORKSPACE_WINDOW_LABEL, {
url: buildRuntimeAiWorkspaceWindowUrl(AI_WORKSPACE_WINDOW_LABEL, context),
title: AI_WORKSPACE_WINDOW_TITLE,
width: 940,
height: 680,
minWidth: 520,
minHeight: 420,
center: true,
resizable: true,
minimizable: false,
decorations: false,
shadow: true,
transparent: true,
})
const window = new WebviewWindow(
AI_WORKSPACE_WINDOW_LABEL,
aiWorkspaceWindowOptions(context),
) as ExistingAiWorkspaceWindow
await waitForCreated(window)
await refreshAiWorkspaceWindowChrome(window)
await emitAiWorkspaceContext(context)
return true
}

View File

@@ -32,6 +32,19 @@ vi.mock('@tauri-apps/api/event', () => ({
import { streamAiAgent } from './streamAiAgent'
const STREAM_EVENT_NAME_PATTERN = /^ai-agent-stream-/
function createCallbacks() {
return {
onText: vi.fn(),
onThinking: vi.fn(),
onToolStart: vi.fn(),
onToolDone: vi.fn(),
onError: vi.fn(),
onDone: vi.fn(),
}
}
describe('streamAiAgent', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -44,14 +57,7 @@ describe('streamAiAgent', () => {
it('uses the mock response when Tauri is unavailable', async () => {
vi.useFakeTimers()
const callbacks = {
onText: vi.fn(),
onThinking: vi.fn(),
onToolStart: vi.fn(),
onToolDone: vi.fn(),
onError: vi.fn(),
onDone: vi.fn(),
}
const callbacks = createCallbacks()
const promise = streamAiAgent({
agent: 'codex',
@@ -74,9 +80,11 @@ describe('streamAiAgent', () => {
it('forwards streamed Tauri events and invokes the backend request', async () => {
isTauriState.value = true
const unlistenMock = vi.fn()
let listenedEventName = ''
let eventHandler: ((event: { payload: unknown }) => void) | undefined
listenMock.mockImplementation(async (_eventName: string, handler: typeof eventHandler) => {
listenMock.mockImplementation(async (eventName: string, handler: typeof eventHandler) => {
listenedEventName = eventName
eventHandler = handler
return unlistenMock
})
@@ -90,14 +98,7 @@ describe('streamAiAgent', () => {
return 'session-1'
})
const callbacks = {
onText: vi.fn(),
onThinking: vi.fn(),
onToolStart: vi.fn(),
onToolDone: vi.fn(),
onError: vi.fn(),
onDone: vi.fn(),
}
const callbacks = createCallbacks()
const promise = streamAiAgent({
agent: 'claude_code',
@@ -110,7 +111,7 @@ describe('streamAiAgent', () => {
await promise
expect(listenMock).toHaveBeenCalledWith('ai-agent-stream', expect.any(Function))
expect(listenMock).toHaveBeenCalledWith(expect.stringMatching(STREAM_EVENT_NAME_PATTERN), expect.any(Function))
expect(invokeMock).toHaveBeenCalledWith('stream_ai_agent', {
request: {
agent: 'claude_code',
@@ -119,6 +120,7 @@ describe('streamAiAgent', () => {
vault_path: '/vault',
vault_paths: null,
permission_mode: 'power_user',
event_name: listenedEventName,
},
})
expect(callbacks.onThinking).toHaveBeenCalledWith('thinking...')
@@ -136,14 +138,7 @@ describe('streamAiAgent', () => {
listenMock.mockResolvedValue(unlistenMock)
invokeMock.mockRejectedValue(new Error('backend boom'))
const callbacks = {
onText: vi.fn(),
onThinking: vi.fn(),
onToolStart: vi.fn(),
onToolDone: vi.fn(),
onError: vi.fn(),
onDone: vi.fn(),
}
const callbacks = createCallbacks()
await streamAiAgent({
agent: 'codex',
@@ -171,14 +166,7 @@ describe('streamAiAgent', () => {
return 'session-2'
})
const callbacks = {
onText: vi.fn(),
onThinking: vi.fn(),
onToolStart: vi.fn(),
onToolDone: vi.fn(),
onError: vi.fn(),
onDone: vi.fn(),
}
const callbacks = createCallbacks()
await streamAiAgent({
agent: 'claude_code',
@@ -191,4 +179,35 @@ describe('streamAiAgent', () => {
expect(callbacks.onDone).toHaveBeenCalledTimes(1)
expect(unlistenMock).toHaveBeenCalledTimes(1)
})
it('uses a fresh request-scoped event channel for each stream', async () => {
isTauriState.value = true
const handlers = new Map<string, (event: { payload: unknown }) => void>()
const eventNames: string[] = []
listenMock.mockImplementation(async (eventName: string, handler: (event: { payload: unknown }) => void) => {
handlers.set(eventName, handler)
return vi.fn()
})
invokeMock.mockImplementation(async (_command: string, args: { request: { event_name: string; message: string } }) => {
eventNames.push(args.request.event_name)
const handler = handlers.get(args.request.event_name)
handler?.({ payload: { kind: 'TextDelta', text: args.request.message } })
handler?.({ payload: { kind: 'Done' } })
return args.request.event_name
})
const first = createCallbacks()
const second = createCallbacks()
await streamAiAgent({ agent: 'codex', message: 'first response', vaultPath: '/vault', callbacks: first })
await streamAiAgent({ agent: 'codex', message: 'second response', vaultPath: '/vault', callbacks: second })
expect([...handlers.keys()]).toHaveLength(2)
expect(new Set(eventNames).size).toBe(2)
expect(first.onText).toHaveBeenCalledWith('first response')
expect(first.onText).not.toHaveBeenCalledWith('second response')
expect(second.onText).toHaveBeenCalledWith('second response')
expect(second.onText).not.toHaveBeenCalledWith('first response')
})
})

View File

@@ -4,6 +4,7 @@ import {
normalizeAiAgentPermissionMode,
type AiAgentPermissionMode,
} from '../lib/aiAgentPermissionMode'
import { createScopedStreamEventName } from './aiStreamEvents'
type AiAgentStreamEvent =
| { kind: 'Init'; session_id: string }
@@ -93,6 +94,7 @@ export async function streamAiAgent(
const { invoke } = await import('@tauri-apps/api/core')
const { listen } = await import('@tauri-apps/api/event')
const eventName = createScopedStreamEventName('ai-agent-stream')
let closed = false
const closeStream = (): void => {
@@ -101,7 +103,7 @@ export async function streamAiAgent(
callbacks.onDone()
}
const unlisten = await listen<AiAgentStreamEvent>('ai-agent-stream', (event) => {
const unlisten = await listen<AiAgentStreamEvent>(eventName, (event) => {
if (event.payload.kind === 'Done') {
closeStream()
return
@@ -119,6 +121,7 @@ export async function streamAiAgent(
vault_path: vaultPath,
vault_paths: vaultPaths && vaultPaths.length > 0 ? vaultPaths : null,
permission_mode: normalizeAiAgentPermissionMode(permissionMode),
event_name: eventName,
},
})
closeStream()

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiModelDefinition, AiModelProvider } from '../lib/aiTargets'
const {
invokeMock,
isTauriState,
listenMock,
} = vi.hoisted(() => ({
invokeMock: vi.fn(),
isTauriState: { value: false },
listenMock: vi.fn(),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => isTauriState.value,
}))
vi.mock('@tauri-apps/api/core', () => ({
invoke: invokeMock,
}))
vi.mock('@tauri-apps/api/event', () => ({
listen: listenMock,
}))
import { streamAiModel } from './streamAiModel'
function createCallbacks() {
return {
onText: vi.fn(),
onThinking: vi.fn(),
onToolStart: vi.fn(),
onToolDone: vi.fn(),
onError: vi.fn(),
onDone: vi.fn(),
}
}
const provider: AiModelProvider = {
id: 'provider',
name: 'Provider',
kind: 'openai_compatible',
base_url: 'https://example.com/v1',
api_key_storage: 'local_file',
models: [],
}
const model: AiModelDefinition = {
id: 'model',
display_name: 'Model',
capabilities: { streaming: true, tools: false, vision: false, json_mode: false, reasoning: false },
}
describe('streamAiModel', () => {
beforeEach(() => {
vi.clearAllMocks()
isTauriState.value = false
})
it('uses a request-scoped event channel for native model streams', async () => {
isTauriState.value = true
const unlisten = vi.fn()
let listenedEventName = ''
let handler: ((event: { payload: unknown }) => void) | undefined
listenMock.mockImplementation(async (eventName: string, nextHandler: typeof handler) => {
listenedEventName = eventName
handler = nextHandler
return unlisten
})
invokeMock.mockImplementation(async (_command: string, args: { request: { event_name: string } }) => {
handler?.({ payload: { kind: 'TextDelta', text: args.request.event_name } })
handler?.({ payload: { kind: 'Done' } })
return 'session'
})
const callbacks = createCallbacks()
await streamAiModel({ provider, model, message: 'hello', callbacks })
expect(listenMock).toHaveBeenCalledWith(expect.stringMatching(/^ai-model-stream-/), expect.any(Function))
expect(invokeMock).toHaveBeenCalledWith('stream_ai_model', {
request: expect.objectContaining({ event_name: listenedEventName }),
})
expect(callbacks.onText).toHaveBeenCalledWith(listenedEventName)
expect(callbacks.onDone).toHaveBeenCalledTimes(1)
expect(unlisten).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,6 +1,7 @@
import { isTauri } from '../mock-tauri'
import type { AiModelDefinition, AiModelProvider } from '../lib/aiTargets'
import type { AgentStreamCallbacks } from './streamAiAgent'
import { createScopedStreamEventName } from './aiStreamEvents'
type AiModelStreamEvent =
| { kind: 'Init'; session_id: string }
@@ -64,6 +65,7 @@ export async function streamAiModel({
const { invoke } = await import('@tauri-apps/api/core')
const { listen } = await import('@tauri-apps/api/event')
const eventName = createScopedStreamEventName('ai-model-stream')
let closed = false
const closeStream = (): void => {
if (closed) return
@@ -71,7 +73,7 @@ export async function streamAiModel({
callbacks.onDone()
}
const unlisten = await listen<AiModelStreamEvent>('ai-model-stream', (event) => {
const unlisten = await listen<AiModelStreamEvent>(eventName, (event) => {
if (event.payload.kind === 'Done') {
closeStream()
return
@@ -87,6 +89,7 @@ export async function streamAiModel({
message,
system_prompt: systemPrompt || null,
api_key_override: null,
event_name: eventName,
},
})
closeStream()

View File

@@ -32,6 +32,7 @@ export function bindVaultConfigStore(initial: VaultConfig, save: SaveFn): void {
export function resetVaultConfigStore(): void {
config = DEFAULT_CONFIG
saveFn = null
notify()
}
export function updateVaultConfigField<K extends keyof VaultConfig>(key: K, value: VaultConfig[K]): void {