feat: add side AI workspace
This commit is contained in:
76
index.html
76
index.html
@@ -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)';
|
||||
|
||||
@@ -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
|
||||
|
||||
8
public/ai-agent-icons/SOURCES.md
Normal file
8
public/ai-agent-icons/SOURCES.md
Normal 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
|
||||
3
public/ai-agent-icons/claude-code.svg
Normal file
3
public/ai-agent-icons/claude-code.svg
Normal 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 |
3
public/ai-agent-icons/codex.svg
Normal file
3
public/ai-agent-icons/codex.svg
Normal 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 |
10
public/ai-agent-icons/gemini.svg
Normal file
10
public/ai-agent-icons/gemini.svg
Normal 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 |
11
public/ai-agent-icons/kiro.svg
Normal file
11
public/ai-agent-icons/kiro.svg
Normal 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 |
18
public/ai-agent-icons/opencode.svg
Normal file
18
public/ai-agent-icons/opencode.svg
Normal 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 |
11
public/ai-agent-icons/pi.svg
Normal file
11
public/ai-agent-icons/pi.svg
Normal 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 |
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"macOSPrivateApi": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Tolaria",
|
||||
|
||||
142
src/App.tsx
142
src/App.tsx
@@ -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)} />
|
||||
|
||||
55
src/components/AiAgentIcon.tsx
Normal file
55
src/components/AiAgentIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
100
src/components/AiWorkspaceFloatingButton.tsx
Normal file
100
src/components/AiWorkspaceFloatingButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
172
src/lib/aiWorkspaceSessionStore.ts
Normal file
172
src/lib/aiWorkspaceSessionStore.ts
Normal 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()
|
||||
}
|
||||
94
src/lib/aiWorkspaceWindowSharedContext.test.ts
Normal file
94
src/lib/aiWorkspaceWindowSharedContext.test.ts
Normal 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]]'] })
|
||||
})
|
||||
})
|
||||
176
src/lib/aiWorkspaceWindowSharedContext.ts
Normal file
176
src/lib/aiWorkspaceWindowSharedContext.ts
Normal 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()
|
||||
@@ -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": "Бяздзейны",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アイドル",
|
||||
|
||||
@@ -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": "유휴",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Бездействует",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "空闲",
|
||||
|
||||
@@ -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": "閒置",
|
||||
|
||||
@@ -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> = [
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
26
src/utils/ai-reference-content.ts
Normal file
26
src/utils/ai-reference-content.ts
Normal 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 }
|
||||
}))
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
7
src/utils/aiStreamEvents.ts
Normal file
7
src/utils/aiStreamEvents.ts
Normal 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()}`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
89
src/utils/streamAiModel.test.ts
Normal file
89
src/utils/streamAiModel.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user